mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 00:00:46 +00:00
feat(imessage): private-API support via imsg JSON-RPC [AI-assisted] (#78317)
Merged via squash.
Prepared head SHA: b7d336b296
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com>
Reviewed-by: @omarshahine
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -4,6 +4,10 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Highlights
|
||||
|
||||
- Channels/iMessage: bundled `imessage` plugin upgraded with full BlueBubbles parity over `imsg` JSON-RPC, offering a complete replacement for BlueBubbles-backed setups. See [docs/channels/imessage-from-bluebubbles.md](docs/channels/imessage-from-bluebubbles.md) for the migration guide. (#78317) Thanks @omarshahine.
|
||||
|
||||
### Changes
|
||||
|
||||
- Agents/failover: harden state-aware lane suspension by persisting quota resume transitions, restoring configured lane concurrency, preserving non-quota failure reasons, and exporting model failover events through diagnostics OTLP. Thanks @BunsDev.
|
||||
@@ -155,6 +159,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/hooks: add a `before_agent_run` pass/block gate that can stop a user prompt before model submission while preserving a redacted transcript entry for the user, and clarify that raw conversation hooks require `hooks.allowConversationAccess=true`. (#75035) Thanks @jesse-merhi.
|
||||
- Config/Nix: keep startup-derived plugin enablement, gateway auth tokens, control UI origins, and owner-display secrets runtime-only instead of rewriting `openclaw.json`; in Nix mode, config writers, mutating `openclaw update`, plugin lifecycle mutators, and doctor repair/token-generation now refuse with agent-first nix-openclaw guidance. (#78047) Thanks @joshp123.
|
||||
- Agents/context engine: invalidate cached assembled context views when source history shrinks or assembly fails, preventing stale pre-reset history from being reused. Fixes #77968. (#78163) Thanks @brokemac79 and @ChrisBot2026.
|
||||
- Channels/iMessage: drive the bundled `imessage` plugin over `imsg` JSON-RPC so private API actions (`react`, `edit`, `unsend`, `reply`, `sendWithEffect`, `renameGroup`, `setGroupIcon`, `addParticipant`, `removeParticipant`, `leaveGroup`, `sendAttachment`) are reachable when `imsg launch` is running, capability-gated per-method via `imsg status --json`, and inbound chats are marked read with a typing bubble before dispatch unless `channels.imessage.sendReadReceipts: false` [AI-assisted]. (#78317) Thanks @omarshahine.
|
||||
|
||||
### Breaking
|
||||
|
||||
@@ -258,6 +263,11 @@ Docs: https://docs.openclaw.ai
|
||||
- LINE: reject `dmPolicy: "open"` configs without wildcard `allowFrom` so webhook DMs fail validation instead of being acknowledged and silently blocked before inbound processing. Fixes #78316.
|
||||
- Telegram/Codex: keep message-tool-only progress drafts visible and render native Codex tool progress once per tool instead of duplicating item/tool draft lines. Fixes #75641. (#77949) Thanks @keshavbotagent.
|
||||
- Telegram/sessions: gap-fill delivered embedded final replies into the session JSONL even when the runner trace is missing, so Telegram answers after tool calls do not vanish from the durable transcript. Fixes #77814. (#78426) Thanks @obviyus, @ChushulSuri, and @DougButdorf.
|
||||
- Channels/iMessage: probe all persistable echo-cache scope shapes (`chat_id:N`, `chat_guid:<guid>`, `chat_identifier:<id>`, `imessage:<handle>`) on inbound match, so an outbound message addressed by `chat_guid` no longer bypasses the chat_id-only inbound lookup and re-feeds the agent its own reply [AI-assisted]. Thanks @omarshahine.
|
||||
- Security/iMessage: clamp `reply-cache.jsonl` to `0600` (parent dir `0700`) on every write/append and chmod existing entries from older gateway versions, blocking same-UID enumeration of conversation guids and shortId injection on multi-user hosts [AI-assisted]. Thanks @omarshahine.
|
||||
- Security/iMessage: apply the same `0600`/`0700` clamp to `sent-echoes.jsonl` so outbound message text and scope keys are not world-readable on multi-user hosts [AI-assisted]. Thanks @omarshahine.
|
||||
- Config/iMessage: add `probeTimeoutMs` to `IMessageAccountSchemaBase` so the `channels.imessage.probeTimeoutMs` option declared on `IMessageAccountConfig` actually round-trips through validation instead of being silently stripped by zod parse [AI-assisted]. Thanks @omarshahine.
|
||||
- Security/iMessage: gate `edit` and `unsend` private API actions on `isFromMe`, so an agent in a group chat can only modify messages the gateway itself sent, not messages received from other participants. Records `isFromMe: true` for outbound sends and `false` for inbound, then refuses to resolve message ids that fail the check before dispatch [AI-assisted]. Thanks @omarshahine.
|
||||
- Providers/xAI: stop sending OpenAI-style reasoning effort controls to native Grok Responses models, so `xai/grok-4.3` no longer fails live Docker/Gateway runs with `Invalid reasoning effort`.
|
||||
- Providers/xAI: clamp the bundled xAI thinking profile to `off` so live Gateway runs cannot send unsupported reasoning levels to native Grok Responses models.
|
||||
- Matrix/approvals: retry approval delivery up to 3 times with a short backoff so transient Matrix send failures do not strand pending approval prompts. (#78179) Thanks @Patrick-Erichsen.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
7238265b921affbb481198f603293c9b1c988025713c55ee19fdbf132a8339ab config-baseline.json
|
||||
97579293de31bc607194bce3e22c16d140c08ab9e6f1e38298f3ce47fbc9d68b config-baseline.core.json
|
||||
463c45a79d02598184caccbc6f316692df962fe6b0e84d1a3e3cc1809f862b15 config-baseline.channel.json
|
||||
b6d36d17e554a2ec5a1a6c6d32107a9a1113c274a700100962d97b6afbdafb25 config-baseline.plugin.json
|
||||
0a77e8265b3bf5d75e06c2e5aad7f0b7c60667de2ec57c9676e2b18305b0cc08 config-baseline.json
|
||||
b2ed92dd6a269d54f263728a2a761d8f6e60f849ec0562dfad17c959bfe90dfa config-baseline.core.json
|
||||
ed15b24c1ccf0234e6b3435149a6f1c1e709579d1259f1d09402688799b149bd config-baseline.channel.json
|
||||
dfc16c21bdd6d727c920de871bf7fe86b771c80df86335c6376b436c0c4898ee config-baseline.plugin.json
|
||||
|
||||
@@ -3,6 +3,26 @@
|
||||
"source": "OpenClaw",
|
||||
"target": "OpenClaw"
|
||||
},
|
||||
{
|
||||
"source": "iMessage",
|
||||
"target": "iMessage"
|
||||
},
|
||||
{
|
||||
"source": "Coming from BlueBubbles",
|
||||
"target": "Coming from BlueBubbles"
|
||||
},
|
||||
{
|
||||
"source": "BlueBubbles",
|
||||
"target": "BlueBubbles"
|
||||
},
|
||||
{
|
||||
"source": "Pairing",
|
||||
"target": "配对"
|
||||
},
|
||||
{
|
||||
"source": "Channel Routing",
|
||||
"target": "频道路由"
|
||||
},
|
||||
{
|
||||
"source": "ClawHub",
|
||||
"target": "ClawHub"
|
||||
|
||||
229
docs/channels/imessage-from-bluebubbles.md
Normal file
229
docs/channels/imessage-from-bluebubbles.md
Normal file
@@ -0,0 +1,229 @@
|
||||
---
|
||||
summary: "Switch from the BlueBubbles plugin to the bundled iMessage plugin without losing pairing, allowlists, or group bindings."
|
||||
read_when:
|
||||
- Planning a move from BlueBubbles to the bundled iMessage plugin
|
||||
- Translating BlueBubbles config keys to iMessage equivalents
|
||||
- Rolling back a partial iMessage cutover
|
||||
title: "Coming from BlueBubbles"
|
||||
---
|
||||
|
||||
The bundled `imessage` plugin now reaches the same private API surface as BlueBubbles (`react`, `edit`, `unsend`, `reply`, `sendWithEffect`, group management, attachments) by driving [`steipete/imsg`](https://github.com/steipete/imsg) over JSON-RPC. If you already run a Mac with `imsg` installed, you can drop the BlueBubbles server and let the plugin talk to Messages.app directly.
|
||||
|
||||
This guide is opt-in. BlueBubbles still works and remains the right choice if you cannot run `imsg` on the host where the Mac signs into iMessage (for example, if the Mac is unreachable from the gateway).
|
||||
|
||||
## When this migration makes sense
|
||||
|
||||
- You already run `imsg` on the same Mac (or one reachable over SSH) where Messages.app is signed in.
|
||||
- You want one fewer moving part — no separate BlueBubbles server, no REST endpoint to authenticate, no webhook plumbing. Single CLI binary instead of a server + client app + helper.
|
||||
- You are on a [supported macOS / `imsg` build](/channels/imessage#requirements-and-permissions-macos) where the private API probe reports `available: true`.
|
||||
|
||||
## When to stay on BlueBubbles
|
||||
|
||||
- The Mac with Messages.app is on a network the gateway cannot reach via SSH.
|
||||
- You depend on BlueBubbles features the bundled plugin does not yet cover (rich text formatting attributes beyond bold/italic/underline/strikethrough, BlueBubbles-specific webhook integrations).
|
||||
- Your current setup hard-codes BlueBubbles webhook URLs into other systems that you cannot rewire.
|
||||
|
||||
## Before you start
|
||||
|
||||
1. Install `imsg` on the Mac that runs Messages.app:
|
||||
|
||||
```bash
|
||||
brew install steipete/tap/imsg
|
||||
imsg launch
|
||||
imsg rpc --help
|
||||
```
|
||||
|
||||
2. Verify the private API bridge:
|
||||
|
||||
```bash
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
You want `imessage.privateApi.available: true`. If it reports `false`, fix that first — see [Capability detection](/channels/imessage#private-api-actions).
|
||||
|
||||
3. Snapshot your config so you can roll back:
|
||||
|
||||
```bash
|
||||
cp ~/.openclaw/openclaw.json5 ~/.openclaw/openclaw.json5.bak
|
||||
```
|
||||
|
||||
## Config translation
|
||||
|
||||
iMessage and BlueBubbles share a lot of channel-level config. The keys that change are mostly transport (REST server vs local CLI). Behavior keys (`dmPolicy`, `groupPolicy`, `allowFrom`, etc.) keep the same meaning.
|
||||
|
||||
| BlueBubbles | bundled iMessage | Notes |
|
||||
| ---------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `channels.bluebubbles.enabled` | `channels.imessage.enabled` | Same semantics. |
|
||||
| `channels.bluebubbles.serverUrl` | _(removed)_ | No REST server — the plugin spawns `imsg rpc` over stdio. |
|
||||
| `channels.bluebubbles.password` | _(removed)_ | No webhook authentication needed. |
|
||||
| _(implicit)_ | `channels.imessage.cliPath` | Path to `imsg` (default `imsg`); use a wrapper script for SSH. |
|
||||
| _(implicit)_ | `channels.imessage.dbPath` | Optional Messages.app `chat.db` override; auto-detected when omitted. |
|
||||
| _(implicit)_ | `channels.imessage.remoteHost` | `host` or `user@host` — only needed when `cliPath` is an SSH wrapper and you want SCP attachment fetches. |
|
||||
| `channels.bluebubbles.dmPolicy` | `channels.imessage.dmPolicy` | Same values (`pairing` / `allowlist` / `open` / `disabled`). |
|
||||
| `channels.bluebubbles.allowFrom` | `channels.imessage.allowFrom` | Pairing approvals carry over by handle, not by token. |
|
||||
| `channels.bluebubbles.groupPolicy` | `channels.imessage.groupPolicy` | Same values (`allowlist` / `open` / `disabled`). |
|
||||
| `channels.bluebubbles.groupAllowFrom` | `channels.imessage.groupAllowFrom` | Same. |
|
||||
| `channels.bluebubbles.groups` | `channels.imessage.groups` | **Copy this verbatim, including any `groups: { "*": { ... } }` wildcard entry.** Per-group `requireMention`, `tools`, `toolsBySender` carry over. With `groupPolicy: "allowlist"`, an empty or missing `groups` block silently drops every group message — see "Group registry footgun" below. |
|
||||
| `channels.bluebubbles.sendReadReceipts` | `channels.imessage.sendReadReceipts` | Default `true`. With the bundled plugin this only fires when the private API probe is up. |
|
||||
| `channels.bluebubbles.includeAttachments` | `channels.imessage.includeAttachments` | Same. |
|
||||
| `channels.bluebubbles.attachmentRoots` | `channels.imessage.attachmentRoots` | Local roots; same wildcard rules. |
|
||||
| _(N/A)_ | `channels.imessage.remoteAttachmentRoots` | Only used when `remoteHost` is set for SCP fetches. |
|
||||
| `channels.bluebubbles.mediaMaxMb` | `channels.imessage.mediaMaxMb` | Default 16 MB on iMessage (BlueBubbles default was 8 MB). Set explicitly if you want to keep the lower cap. |
|
||||
| `channels.bluebubbles.textChunkLimit` | `channels.imessage.textChunkLimit` | Default 4000 on both. |
|
||||
| `channels.bluebubbles.coalesceSameSenderDms` | `channels.imessage.coalesceSameSenderDms` | Same opt-in. DM-only — group chats keep instant per-message dispatch on both channels. Widens the default inbound debounce to 2500 ms when enabled without an explicit `messages.inbound.byChannel.imessage`. See [iMessage docs § Coalescing split-send DMs](/channels/imessage#coalescing-split-send-dms-command--url-in-one-composition). |
|
||||
| `channels.bluebubbles.enrichGroupParticipantsFromContacts` | _(N/A)_ | iMessage already reads sender display names from `chat.db`. |
|
||||
| `channels.bluebubbles.actions.*` | `channels.imessage.actions.*` | Per-action toggles: `reactions`, `edit`, `unsend`, `reply`, `sendWithEffect`, `renameGroup`, `setGroupIcon`, `addParticipant`, `removeParticipant`, `leaveGroup`, `sendAttachment`. |
|
||||
|
||||
Multi-account configs (`channels.bluebubbles.accounts.*`) translate one-to-one to `channels.imessage.accounts.*`.
|
||||
|
||||
## Group registry footgun
|
||||
|
||||
The bundled iMessage plugin runs **two** separate group allowlist gates back-to-back. Both must pass for a group message to reach the agent:
|
||||
|
||||
1. **Sender / chat-target allowlist** (`channels.imessage.groupAllowFrom`) — checked by `isAllowedIMessageSender`. Matches inbound messages by sender handle, `chat_guid`, `chat_identifier`, or `chat_id`. Same shape as BlueBubbles.
|
||||
2. **Group registry** (`channels.imessage.groups`) — checked by `resolveChannelGroupPolicy` from `inbound-processing.ts:199`. With `groupPolicy: "allowlist"`, this gate requires either:
|
||||
- a `groups: { "*": { ... } }` wildcard entry (sets `allowAll = true`), or
|
||||
- an explicit per-`chat_id` entry under `groups`.
|
||||
|
||||
If gate 1 passes but gate 2 fails, the message is dropped and the rejection logs **only at `verbose`/`debug` level** (`inbound-processing.ts:337`). At the default `info` log level every group message vanishes silently — DMs continue to work because they take a different code path.
|
||||
|
||||
This is the most common BlueBubbles → bundled-iMessage migration failure mode: operators copy `groupAllowFrom` and `groupPolicy` but skip the `groups` block, because BlueBubbles' `groups: { "*": { "requireMention": true } }` looks like an unrelated mention setting. It's actually load-bearing for the registry gate.
|
||||
|
||||
The minimum config to keep group messages flowing after `groupPolicy: "allowlist"`:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
imessage: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["+15555550123", "chat_guid:any;-;..."],
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`requireMention: true` under `*` is harmless when no mention patterns are configured: the runtime sets `canDetectMention = false` and short-circuits the mention drop at `inbound-processing.ts:512`. With mention patterns configured (`agents.list[].groupChat.mentionPatterns`), it works as expected.
|
||||
|
||||
To debug a suspected silent drop, raise log level temporarily:
|
||||
|
||||
```bash
|
||||
OPENCLAW_LOG_LEVEL=debug openclaw gateway
|
||||
```
|
||||
|
||||
Then look for `imessage: skipping group message (<chat_id>) not in allowlist`. If you see that line, gate 2 is dropping — add the `groups` block.
|
||||
|
||||
Tracker for raising the drop's log severity so this is no longer silent at `info`: [openclaw#78749](https://github.com/openclaw/openclaw/issues/78749).
|
||||
|
||||
## Step-by-step
|
||||
|
||||
1. Add an iMessage block alongside the existing BlueBubbles block. Do not delete BlueBubbles yet:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
// ... existing config ...
|
||||
},
|
||||
imessage: {
|
||||
enabled: false, // turn on after the dry run below
|
||||
cliPath: "/opt/homebrew/bin/imsg",
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["+15555550123"], // copy from bluebubbles.allowFrom
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [], // copy from bluebubbles.groupAllowFrom
|
||||
groups: { "*": { requireMention: true } }, // copy from bluebubbles.groups — silently drops groups if missing, see "Group registry footgun" above
|
||||
actions: {
|
||||
reactions: true,
|
||||
edit: true,
|
||||
unsend: true,
|
||||
reply: true,
|
||||
sendWithEffect: true,
|
||||
sendAttachment: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
2. **Dry-run probe** — start the gateway and confirm both channels report healthy:
|
||||
|
||||
```bash
|
||||
openclaw gateway
|
||||
openclaw channels status
|
||||
openclaw channels status --probe # expect imessage.privateApi.available: true
|
||||
```
|
||||
|
||||
Because `imessage.enabled` is still `false`, no inbound iMessage traffic is routed yet — but `--probe` exercises the bridge so you catch permission/install issues before the cutover.
|
||||
|
||||
3. **Cut over.** Disable BlueBubbles and enable iMessage in one config edit:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
bluebubbles: { enabled: false }, // keep the rest of the block for rollback
|
||||
imessage: { enabled: true /* ... */ },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Restart the gateway. Inbound iMessage traffic now flows through the bundled plugin.
|
||||
|
||||
4. **Verify DMs.** Send the agent a direct message; confirm the reply lands.
|
||||
|
||||
5. **Verify groups separately.** DMs and groups take different code paths — DM success does not prove groups are routing. Send the agent a message in a paired group chat and confirm the reply lands. If the group goes silent (no agent reply, no error), restart the gateway with `OPENCLAW_LOG_LEVEL=debug openclaw gateway` and look for `imessage: skipping group message (...) not in allowlist`. If that line appears, your `groups` block is missing or empty — see "Group registry footgun" above.
|
||||
|
||||
6. **Verify the action surface** — from a paired DM, ask the agent to react, edit, unsend, reply, send a photo, and (in a group) rename the group / add or remove a participant. Each action should land natively in Messages.app. If any throws "iMessage `<action>` requires the imsg private API bridge", run `imsg launch` again and refresh `channels status --probe`.
|
||||
|
||||
7. **Stop the BlueBubbles server** once you have run on iMessage for at least a few hours of normal traffic. Remove the BlueBubbles block from config and restart the gateway.
|
||||
|
||||
## Action parity at a glance
|
||||
|
||||
| Action | BlueBubbles | bundled iMessage |
|
||||
| ---------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| Send text / SMS fallback | ✅ | ✅ |
|
||||
| Send media (photo, video, file, voice) | ✅ | ✅ |
|
||||
| Threaded reply (`reply_to_guid`) | ✅ | ✅ (closes [#51892](https://github.com/openclaw/openclaw/issues/51892)) |
|
||||
| Tapback (`react`) | ✅ | ✅ |
|
||||
| Edit / unsend (macOS 13+ recipients) | ✅ | ✅ |
|
||||
| Send with screen effect | ✅ | ✅ (closes part of [#9394](https://github.com/openclaw/openclaw/issues/9394)) |
|
||||
| Rich text bold / italic / underline / strikethrough | ✅ | ✅ (typed-run formatting via attributedBody) |
|
||||
| Rename group / set group icon | ✅ | ✅ |
|
||||
| Add / remove participant, leave group | ✅ | ✅ |
|
||||
| Read receipts and typing indicator | ✅ | ✅ (gated on private API probe) |
|
||||
| Same-sender DM coalescing | ✅ | ✅ (DM-only; opt-in via `channels.imessage.coalesceSameSenderDms`) |
|
||||
| Catchup of inbound messages received while gateway is down | ✅ (webhook replay + history fetch) | _(not yet — tracked at [#78649](https://github.com/openclaw/openclaw/issues/78649))_ |
|
||||
|
||||
The catchup gap is the most operationally significant one for production deployments: planned restarts, mac sleep, or an unexpected gateway crash that takes more than a few seconds will silently drop any inbound iMessage traffic that arrives during the gap when running on bundled iMessage. BlueBubbles' webhook + history-fetch flow recovers those messages on reconnect. If your deployment is sensitive to that, stay on BlueBubbles until [#78649](https://github.com/openclaw/openclaw/issues/78649) lands.
|
||||
|
||||
## Pairing, sessions, and ACP bindings
|
||||
|
||||
- **Pairing approvals** carry over by handle. You do not need to re-approve known senders — `channels.imessage.allowFrom` recognizes the same `+15555550123` / `user@example.com` strings BlueBubbles used.
|
||||
- **Sessions** stay scoped per agent + chat. DMs collapse into the agent main session under default `session.dmScope=main`; group sessions stay isolated per `chat_id`. The session keys differ (`agent:<id>:imessage:group:<chat_id>` vs the BlueBubbles equivalent) — old conversation history under BlueBubbles session keys does not carry into iMessage sessions.
|
||||
- **ACP bindings** referencing `match.channel: "bluebubbles"` need to be updated to `"imessage"`. The `match.peer.id` shapes (`chat_id:`, `chat_guid:`, `chat_identifier:`, bare handle) are identical.
|
||||
|
||||
## Running both at once
|
||||
|
||||
You can keep both `bluebubbles` and `imessage` enabled during cutover testing. BlueBubbles' manifest still declares `preferOver: ["imessage"]`, so the auto-enable resolver continues to prefer BlueBubbles when both channels are configured — the bundled iMessage plugin will not pick up traffic until BlueBubbles is disabled (`channels.bluebubbles.enabled: false`) or removed from config.
|
||||
|
||||
If you want both channels to run simultaneously instead of in cutover mode, that is not currently supported through plugin auto-enable; use one channel at a time.
|
||||
|
||||
## Rollback
|
||||
|
||||
Because you kept the BlueBubbles config block:
|
||||
|
||||
1. Set `channels.bluebubbles.enabled: true` and `channels.imessage.enabled: false`.
|
||||
2. Restart the gateway.
|
||||
3. Inbound traffic returns to BlueBubbles. Reply caches and ACP bindings on the iMessage side stay on disk under `~/.openclaw/state/imessage/` and resume cleanly if you re-enable later.
|
||||
|
||||
The reply cache lives at `~/.openclaw/state/imessage/reply-cache.jsonl` (mode `0600`, parent dir `0700`). It is safe to delete if you want a clean slate.
|
||||
|
||||
## Related
|
||||
|
||||
- [iMessage](/channels/imessage) — full iMessage channel reference, including `imsg launch` setup and capability detection.
|
||||
- [BlueBubbles](/channels/bluebubbles) — full BlueBubbles channel reference for the legacy path.
|
||||
- [Pairing](/channels/pairing) — DM authentication and pairing flow.
|
||||
- [Channel Routing](/channels/channel-routing) — how the gateway picks a channel for outbound replies.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Native iMessage support via imsg (JSON-RPC over stdio). Preferred for new OpenClaw iMessage setups when host requirements fit."
|
||||
summary: "Native iMessage support via imsg (JSON-RPC over stdio), with private API actions for replies, tapbacks, effects, attachments, and group management. Preferred for new OpenClaw iMessage setups when host requirements fit."
|
||||
read_when:
|
||||
- Setting up iMessage support
|
||||
- Debugging iMessage send/receive
|
||||
@@ -8,15 +8,20 @@ title: "iMessage"
|
||||
|
||||
<Note>
|
||||
For OpenClaw iMessage deployments, use `imsg` on a signed-in macOS Messages host. If your Gateway runs on Linux or Windows, point `channels.imessage.cliPath` at an SSH wrapper that runs `imsg` on the Mac.
|
||||
|
||||
**Known gap: no gateway-downtime catchup.** Messages that arrive while the gateway is down (crash, restart, Mac sleep, machine off) are not delivered to the agent once the gateway comes back up — `imsg watch` resumes from the current state and ignores anything that landed in `chat.db` during the gap. Tracked at [openclaw#78649](https://github.com/openclaw/openclaw/issues/78649).
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
BlueBubbles is deprecated and no longer ships as a bundled OpenClaw channel. Migrate `channels.bluebubbles` configs to `channels.imessage`; OpenClaw now supports iMessage through `imsg` only. If you still need a BlueBubbles-backed bridge, publish or install it as a third-party plugin outside core.
|
||||
</Warning>
|
||||
|
||||
Status: native external CLI integration. Gateway spawns `imsg rpc` and communicates over JSON-RPC on stdio (no separate daemon/port).
|
||||
Status: native external CLI integration. Gateway spawns `imsg rpc` and communicates over JSON-RPC on stdio (no separate daemon/port). Advanced actions require `imsg launch` and a successful private API probe.
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="Private API actions" icon="wand-sparkles" href="#private-api-actions">
|
||||
Replies, tapbacks, effects, attachments, and group management.
|
||||
</Card>
|
||||
<Card title="Pairing" icon="link" href="/channels/pairing">
|
||||
iMessage DMs default to pairing mode.
|
||||
</Card>
|
||||
@@ -38,6 +43,8 @@ Status: native external CLI integration. Gateway spawns `imsg rpc` and communica
|
||||
```bash
|
||||
brew install steipete/tap/imsg
|
||||
imsg rpc --help
|
||||
imsg launch
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
</Step>
|
||||
@@ -119,6 +126,7 @@ exec ssh -T gateway-host imsg "$@"
|
||||
- Messages must be signed in on the Mac running `imsg`.
|
||||
- Full Disk Access is required for the process context running OpenClaw/`imsg` (Messages DB access).
|
||||
- Automation permission is required to send messages through Messages.app.
|
||||
- For advanced actions (react / edit / unsend / threaded reply / effects / group ops), System Integrity Protection must be disabled — see [Enabling the imsg private API](#enabling-the-imsg-private-api) below. Basic text and media send/receive work without it.
|
||||
|
||||
<Tip>
|
||||
Permissions are granted per process context. If gateway runs headless (LaunchAgent/SSH), run a one-time interactive command in that same context to trigger prompts:
|
||||
@@ -131,6 +139,71 @@ imsg send <handle> "test"
|
||||
|
||||
</Tip>
|
||||
|
||||
## Enabling the imsg private API
|
||||
|
||||
`imsg` ships in two operational modes:
|
||||
|
||||
- **Basic mode** (default, no SIP changes needed): outbound text and media via `send`, inbound watch/history, chat list. This is what you get out of the box from a fresh `brew install steipete/tap/imsg` plus the standard macOS permissions above.
|
||||
- **Private API mode**: `imsg` injects a helper dylib into `Messages.app` to call internal `IMCore` functions. This is what unlocks `react`, `edit`, `unsend`, `reply` (threaded), `sendWithEffect`, `renameGroup`, `setGroupIcon`, `addParticipant`, `removeParticipant`, `leaveGroup`, plus typing indicators and read receipts.
|
||||
|
||||
To reach the advanced action surface that this channel page documents, you need Private API mode. The `imsg` README is explicit about the requirement:
|
||||
|
||||
> Advanced features such as `read`, `typing`, `launch`, bridge-backed rich send, message mutation, and chat management are opt-in. They require SIP to be disabled and a helper dylib to be injected into `Messages.app`. `imsg launch` refuses to inject when SIP is enabled.
|
||||
|
||||
The helper-injection technique is a manual port of the BlueBubbles private-API surface (Apache-2.0 inspired) into `imsg`'s own dylib — no third-party binary, but the same SIP-disabled requirement that BlueBubbles' Private API mode has. There is no SIP-asymmetry between the two channels.
|
||||
|
||||
<Warning>
|
||||
**Disabling SIP is a real security tradeoff.** SIP is one of macOS's core protections against running modified system code; turning it off system-wide opens up additional attack surface and side effects. Notably, **disabling SIP on Apple Silicon Macs also disables the ability to install and run iOS apps on your Mac**.
|
||||
|
||||
Treat this as a deliberate operational choice, not a default. If your threat model can't tolerate SIP being off, both bundled iMessage and BlueBubbles will be limited to their basic modes — text and media send/receive only, no reactions / edit / unsend / effects / group ops on either channel.
|
||||
</Warning>
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Install (or upgrade) `imsg`** on the Mac that runs Messages.app:
|
||||
|
||||
```bash
|
||||
brew install steipete/tap/imsg
|
||||
imsg --version
|
||||
imsg status --json
|
||||
```
|
||||
|
||||
The `imsg status --json` output reports `bridge_version`, `rpc_methods`, and per-method `selectors` so you can see what the current build supports before you start.
|
||||
|
||||
2. **Disable System Integrity Protection.** This is macOS-version-specific, identical to the BlueBubbles flow because the underlying Apple requirement is the same:
|
||||
- **macOS 10.13–10.15 (Sierra–Catalina):** disable Library Validation via Terminal, reboot to Recovery Mode, run `csrutil disable`, restart.
|
||||
- **macOS 11+ (Big Sur and later), Intel:** Recovery Mode (or Internet Recovery), `csrutil disable`, restart.
|
||||
- **macOS 11+, Apple Silicon:** power-button startup sequence to enter Recovery; on recent macOS versions hold the **Left Shift** key when you click Continue, then `csrutil disable`. Virtual-machine setups follow a separate flow — take a VM snapshot first.
|
||||
- **macOS 26 / Tahoe:** library-validation policies and `imagent` private-entitlement checks have tightened further; `imsg` may need an updated build to keep up. If `imsg launch` injection or specific `selectors` start returning false after a macOS major upgrade, check `imsg`'s release notes before assuming the SIP step succeeded.
|
||||
|
||||
The [BlueBubbles Private API installation guide](https://docs.bluebubbles.app/private-api/installation) is the canonical step-by-step for the SIP-disable flow itself; the macOS-side steps are not specific to BB, only the helper that gets injected differs.
|
||||
|
||||
3. **Inject the helper.** With SIP disabled and Messages.app signed in:
|
||||
|
||||
```bash
|
||||
imsg launch
|
||||
```
|
||||
|
||||
`imsg launch` refuses to inject when SIP is still enabled, so this also doubles as a confirmation that step 2 took.
|
||||
|
||||
4. **Verify the bridge from OpenClaw:**
|
||||
|
||||
```bash
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
The iMessage entry should report `works`, and `imsg status --json | jq '.selectors'` should show `retractMessagePart: true` plus whichever edit / typing / read selectors your macOS build exposes. The OpenClaw plugin per-method gating in `actions.ts` only advertises actions whose underlying selector is `true`, so the action surface you see in the agent's tool list reflects what the bridge can actually do on this host.
|
||||
|
||||
If `openclaw channels status --probe` reports the channel as `works` but specific actions throw "iMessage `<action>` requires the imsg private API bridge" at dispatch time, run `imsg launch` again — the helper can fall out (Messages.app restart, OS update, etc.) and the cached `available: true` status will keep advertising actions until the next probe refreshes.
|
||||
|
||||
### When you can't disable SIP
|
||||
|
||||
If SIP-disabled isn't acceptable for your threat model:
|
||||
|
||||
- Both `imsg` and BlueBubbles fall back to basic mode — text + media + receive only.
|
||||
- The OpenClaw plugin still advertises text/media send and inbound monitoring; it just hides `react`, `edit`, `unsend`, `reply`, `sendWithEffect`, and group ops from the action surface (per the per-method capability gate).
|
||||
- You can run a separate non-Apple-Silicon Mac (or a dedicated bot Mac) with SIP off for the iMessage workload, while keeping SIP enabled on your primary devices. See [Dedicated bot macOS user (separate iMessage identity)](#deployment-patterns) below.
|
||||
|
||||
## Access control and routing
|
||||
|
||||
<Tabs>
|
||||
@@ -160,6 +233,31 @@ imsg send <handle> "test"
|
||||
Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available.
|
||||
Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set).
|
||||
|
||||
<Warning>
|
||||
Group routing has **two** allowlist gates running back-to-back, and both must pass:
|
||||
|
||||
1. **Sender / chat-target allowlist** (`channels.imessage.groupAllowFrom`) — handle, `chat_guid`, `chat_identifier`, or `chat_id`.
|
||||
2. **Group registry** (`channels.imessage.groups`) — with `groupPolicy: "allowlist"`, this gate requires either a `groups: { "*": { ... } }` wildcard entry (sets `allowAll = true`), or an explicit per-`chat_id` entry under `groups`.
|
||||
|
||||
If gate 2 has nothing in it, every group message is dropped — and the rejection logs only at `verbose`/`debug` level, so the drops are silent at the default `info` log level. DMs continue to work because they take a different code path.
|
||||
|
||||
Minimum config to keep groups flowing under `groupPolicy: "allowlist"`:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
imessage: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["+15555550123"],
|
||||
groups: { "*": { "requireMention": true } },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
To debug a suspected silent drop, run `OPENCLAW_LOG_LEVEL=debug openclaw gateway` and look for `imessage: skipping group message (<chat_id>) not in allowlist`. If that line appears, gate 2 is dropping — add the `groups` block.
|
||||
</Warning>
|
||||
|
||||
Mention gating for groups:
|
||||
|
||||
- iMessage has no native mention metadata
|
||||
@@ -264,24 +362,24 @@ See [ACP Agents](/tools/acp-agents) for shared ACP binding behavior.
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
imessage: {
|
||||
enabled: true,
|
||||
cliPath: "~/.openclaw/scripts/imsg-ssh",
|
||||
remoteHost: "bot@mac-mini.tailnet-1234.ts.net",
|
||||
includeAttachments: true,
|
||||
dbPath: "/Users/bot/Library/Messages/chat.db",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
imessage: {
|
||||
enabled: true,
|
||||
cliPath: "~/.openclaw/scripts/imsg-ssh",
|
||||
remoteHost: "bot@mac-mini.tailnet-1234.ts.net",
|
||||
includeAttachments: true,
|
||||
dbPath: "/Users/bot/Library/Messages/chat.db",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
|
||||
```
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
|
||||
```
|
||||
|
||||
Use SSH keys so both SSH and SCP are non-interactive.
|
||||
Ensure the host key is trusted first (for example `ssh bot@mac-mini.tailnet-1234.ts.net`) so `known_hosts` is populated.
|
||||
@@ -332,10 +430,76 @@ exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
|
||||
- `sms:+1555...`
|
||||
- `user@example.com`
|
||||
|
||||
```bash
|
||||
imsg chats --limit 20
|
||||
```bash
|
||||
imsg chats --limit 20
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Private API actions
|
||||
|
||||
When `imsg launch` is running and `openclaw channels status --probe` reports `privateApi.available: true`, the message tool can use iMessage-native actions in addition to normal text sends.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
imessage: {
|
||||
actions: {
|
||||
reactions: true,
|
||||
edit: true,
|
||||
unsend: true,
|
||||
reply: true,
|
||||
sendWithEffect: true,
|
||||
sendAttachment: true,
|
||||
renameGroup: true,
|
||||
setGroupIcon: true,
|
||||
addParticipant: true,
|
||||
removeParticipant: true,
|
||||
leaveGroup: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Available actions">
|
||||
- **react**: Add/remove iMessage tapbacks (`messageId`, `emoji`, `remove`). Supported tapbacks map to love, like, dislike, laugh, emphasize, and question.
|
||||
- **reply**: Send a threaded reply to an existing message (`messageId`, `text` or `message`, plus `chatGuid`, `chatId`, `chatIdentifier`, or `to`).
|
||||
- **sendWithEffect**: Send text with an iMessage effect (`text` or `message`, `effect` or `effectId`).
|
||||
- **edit**: Edit a sent message on supported macOS/private API versions (`messageId`, `text` or `newText`).
|
||||
- **unsend**: Retract a sent message on supported macOS/private API versions (`messageId`).
|
||||
- **upload-file**: Send media/files (`buffer` as base64 or a hydrated `media`/`path`/`filePath`, `filename`, optional `asVoice`). Legacy alias: `sendAttachment`.
|
||||
- **renameGroup**, **setGroupIcon**, **addParticipant**, **removeParticipant**, **leaveGroup**: Manage group chats when the current target is a group conversation.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Message IDs">
|
||||
Inbound iMessage context includes both short `MessageSid` values and full message GUIDs when available. Short IDs are scoped to the recent in-memory reply cache and are checked against the current chat before use. If a short ID has expired or belongs to another chat, retry with the full `MessageSidFull`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Capability detection">
|
||||
OpenClaw hides private API actions only when the cached probe status says the bridge is unavailable. If the status is unknown, actions remain visible and dispatch probes lazily so the first action can succeed after `imsg launch` without a separate manual status refresh.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Read receipts and typing">
|
||||
When the private API bridge is up, accepted inbound chats are marked read before dispatch and a typing bubble is shown to the sender while the agent generates. Disable read-marking with:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
imessage: {
|
||||
sendReadReceipts: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Older `imsg` builds that pre-date the per-method capability list will gate off typing/read silently; OpenClaw logs a one-time warning per restart so the missing receipt is attributable.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -355,18 +519,98 @@ Disable:
|
||||
}
|
||||
```
|
||||
|
||||
<a id="coalescing-split-send-dms-command--url-in-one-composition"></a>
|
||||
|
||||
## Coalescing split-send DMs (command + URL in one composition)
|
||||
|
||||
When a user types a command and a URL together — e.g. `Dump https://example.com/article` — Apple's Messages app splits the send into **two separate `chat.db` rows**:
|
||||
|
||||
1. A text message (`"Dump"`).
|
||||
2. A URL-preview balloon (`"https://..."`) with OG-preview images as attachments.
|
||||
|
||||
The two rows arrive at OpenClaw ~0.8-2.0 s apart on most setups. Without coalescing, the agent receives the command alone on turn 1, replies (often "send me the URL"), and only sees the URL on turn 2 — at which point the command context is already lost. This is Apple's send pipeline, not anything OpenClaw or `imsg` introduces, so the same fix applies as it does on the BlueBubbles channel.
|
||||
|
||||
`channels.imessage.coalesceSameSenderDms` opts a DM into merging consecutive same-sender rows into a single agent turn. Group chats continue to dispatch per-message so multi-user turn structure is preserved.
|
||||
|
||||
<Tabs>
|
||||
<Tab title="When to enable">
|
||||
Enable when:
|
||||
|
||||
- You ship skills that expect `command + payload` in one message (dump, paste, save, queue, etc.).
|
||||
- Your users paste URLs, images, or long content alongside commands.
|
||||
- You can accept the added DM turn latency (see below).
|
||||
|
||||
Leave disabled when:
|
||||
|
||||
- You need minimum command latency for single-word DM triggers.
|
||||
- All your flows are one-shot commands without payload follow-ups.
|
||||
|
||||
</Tab>
|
||||
<Tab title="Enabling">
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
imessage: {
|
||||
coalesceSameSenderDms: true, // opt in (default: false)
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
With the flag on and no explicit `messages.inbound.byChannel.imessage`, the debounce window widens to **2500 ms** (the legacy default is 0 ms — no debouncing). The wider window is required because Apple's split-send cadence of 0.8-2.0 s does not fit in a tighter default.
|
||||
|
||||
To tune the window yourself:
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
inbound: {
|
||||
byChannel: {
|
||||
// 2500 ms works for most setups; raise to 4000 ms if your Mac is
|
||||
// slow or under memory pressure (observed gap can stretch past 2 s
|
||||
// then).
|
||||
imessage: 2500,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="Trade-offs">
|
||||
- **Added latency for DM messages.** With the flag on, every DM (including standalone control commands and single-text follow-ups) waits up to the debounce window before dispatching, in case a payload row is coming. Group-chat messages keep instant dispatch.
|
||||
- **Merged output is bounded.** Merged text caps at 4000 chars with an explicit `…[truncated]` marker; attachments cap at 20; source entries cap at 10 (first-plus-latest retained beyond that). Every source GUID is tracked in `coalescedMessageGuids` for downstream telemetry.
|
||||
- **DM-only.** Group chats fall through to per-message dispatch so the bot stays responsive when multiple people are typing.
|
||||
- **Opt-in, per-channel.** Other channels (Telegram, WhatsApp, Slack, …) are unaffected. The BlueBubbles channel has the same opt-in under `channels.bluebubbles.coalesceSameSenderDms`.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Scenarios and what the agent sees
|
||||
|
||||
| User composes | `chat.db` produces | Flag off (default) | Flag on + 2500 ms window |
|
||||
| ------------------------------------------------------------------ | --------------------- | --------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `Dump https://example.com` (one send) | 2 rows ~1 s apart | Two agent turns: "Dump" alone, then URL | One turn: merged text `Dump https://example.com` |
|
||||
| `Save this 📎image.jpg caption` (attachment + text) | 2 rows | Two turns (attachment dropped on merge) | One turn: text + image preserved |
|
||||
| `/status` (standalone command) | 1 row | Instant dispatch | **Wait up to window, then dispatch** |
|
||||
| URL pasted alone | 1 row | Instant dispatch | Instant dispatch (only one entry in bucket) |
|
||||
| Text + URL sent as two deliberate separate messages, minutes apart | 2 rows outside window | Two turns | Two turns (window expires between them) |
|
||||
| Rapid flood (>10 small DMs inside window) | N rows | N turns | One turn, bounded output (first + latest, text/attachment caps applied) |
|
||||
| Two people typing in a group chat | N rows from M senders | M+ turns (one per sender bucket) | M+ turns — group chats are not coalesced |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="imsg not found or RPC unsupported">
|
||||
Validate the binary and RPC support:
|
||||
|
||||
```bash
|
||||
imsg rpc --help
|
||||
openclaw channels status --probe
|
||||
```
|
||||
```bash
|
||||
imsg rpc --help
|
||||
imsg status --json
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
If probe reports RPC unsupported, update `imsg`. If the Gateway is not running on macOS, use the Remote Mac over SSH setup above instead of the default local `imsg` path.
|
||||
If probe reports RPC unsupported, update `imsg`. If private API actions are unavailable, run `imsg launch` in the logged-in macOS user session and probe again. If the Gateway is not running on macOS, use the Remote Mac over SSH setup above instead of the default local `imsg` path.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -419,10 +663,10 @@ openclaw channels status --probe --channel imessage
|
||||
<Accordion title="macOS permission prompts were missed">
|
||||
Re-run in an interactive GUI terminal in the same user/session context and approve prompts:
|
||||
|
||||
```bash
|
||||
imsg chats --limit 1
|
||||
imsg send <handle> "test"
|
||||
```
|
||||
```bash
|
||||
imsg chats --limit 1
|
||||
imsg send <handle> "test"
|
||||
```
|
||||
|
||||
Confirm Full Disk Access + Automation are granted for the process context that runs OpenClaw/`imsg`.
|
||||
|
||||
@@ -438,6 +682,7 @@ imsg send <handle> "test"
|
||||
## Related
|
||||
|
||||
- [Channels Overview](/channels) — all supported channels
|
||||
- [Coming from BlueBubbles](/channels/imessage-from-bluebubbles) — config translation table and step-by-step cutover
|
||||
- [Pairing](/channels/pairing) — DM authentication and pairing flow
|
||||
- [Groups](/channels/groups) — group chat behavior and mention gating
|
||||
- [Channel Routing](/channels/channel-routing) — session routing for messages
|
||||
|
||||
@@ -24,7 +24,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Discord](/channels/discord) - Discord Bot API + Gateway; supports servers, channels, and DMs.
|
||||
- [Feishu](/channels/feishu) - Feishu/Lark bot via WebSocket (bundled plugin).
|
||||
- [Google Chat](/channels/googlechat) - Google Chat API app via HTTP webhook (downloadable plugin).
|
||||
- [iMessage](/channels/imessage) - Native macOS integration via the `imsg` CLI on a signed-in Mac; use an SSH wrapper when the Gateway runs elsewhere.
|
||||
- [iMessage](/channels/imessage) - Native macOS integration via the `imsg` bridge on a signed-in Mac (or SSH wrapper when the Gateway runs elsewhere), including private API actions for replies, tapbacks, effects, attachments, and group management. Preferred for new OpenClaw iMessage setups when host permissions and Messages access fit.
|
||||
- [IRC](/channels/irc) - Classic IRC servers; channels + DMs with pairing/allowlist controls.
|
||||
- [LINE](/channels/line) - LINE Messaging API bot (downloadable plugin).
|
||||
- [Matrix](/channels/matrix) - Matrix protocol (downloadable plugin).
|
||||
|
||||
@@ -1063,6 +1063,7 @@
|
||||
"channels/msteams",
|
||||
"channels/googlechat",
|
||||
"channels/imessage",
|
||||
"channels/imessage-from-bluebubbles",
|
||||
"channels/matrix",
|
||||
"channels/matrix-migration",
|
||||
"channels/matrix-push-rules"
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"detailLabel": "iMessage",
|
||||
"docsPath": "/channels/imessage",
|
||||
"docsLabel": "imessage",
|
||||
"blurb": "iMessage via the imsg CLI on a signed-in Mac or SSH wrapper.",
|
||||
"blurb": "Local iMessage/SMS through the imsg bridge, including private API message actions when enabled.",
|
||||
"aliases": [
|
||||
"imsg"
|
||||
],
|
||||
@@ -38,6 +38,37 @@
|
||||
"description": "iMessage region (for SMS)"
|
||||
}
|
||||
]
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.3"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.3"
|
||||
}
|
||||
},
|
||||
"pluginInspector": {
|
||||
"version": 1,
|
||||
"plugin": {
|
||||
"id": "imessage",
|
||||
"priority": "high",
|
||||
"seams": [
|
||||
"channel-plugin",
|
||||
"message-actions",
|
||||
"conversation-bindings",
|
||||
"outbound-media"
|
||||
],
|
||||
"sourceRoot": "src",
|
||||
"expect": {
|
||||
"registrations": [
|
||||
"createChatChannelPlugin"
|
||||
],
|
||||
"manifestContracts": [
|
||||
"channels"
|
||||
]
|
||||
}
|
||||
},
|
||||
"capture": {
|
||||
"mockSdk": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
extensions/imessage/src/actions-contract.ts
Normal file
19
extensions/imessage/src/actions-contract.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const IMESSAGE_ACTIONS = {
|
||||
react: { gate: "reactions" },
|
||||
edit: { gate: "edit" },
|
||||
unsend: { gate: "unsend" },
|
||||
reply: { gate: "reply" },
|
||||
sendWithEffect: { gate: "sendWithEffect" },
|
||||
renameGroup: { gate: "renameGroup", groupOnly: true },
|
||||
setGroupIcon: { gate: "setGroupIcon", groupOnly: true },
|
||||
addParticipant: { gate: "addParticipant", groupOnly: true },
|
||||
removeParticipant: { gate: "removeParticipant", groupOnly: true },
|
||||
leaveGroup: { gate: "leaveGroup", groupOnly: true },
|
||||
sendAttachment: { gate: "sendAttachment" },
|
||||
} as const;
|
||||
|
||||
type IMessageActionSpecs = typeof IMESSAGE_ACTIONS;
|
||||
|
||||
export const IMESSAGE_ACTION_NAMES = Object.keys(IMESSAGE_ACTIONS) as Array<
|
||||
keyof IMessageActionSpecs
|
||||
>;
|
||||
185
extensions/imessage/src/actions.runtime.test.ts
Normal file
185
extensions/imessage/src/actions.runtime.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:child_process")>()),
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
const { imessageActionsRuntime, _findChatGuidForTest, _normalizeDirectChatIdentifierForTest } =
|
||||
await import("./actions.runtime.js");
|
||||
|
||||
function mockSpawnJsonResponse(payload: Record<string, unknown> = { success: true }) {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter & { setEncoding: (encoding: string) => void };
|
||||
stderr: EventEmitter & { setEncoding: (encoding: string) => void };
|
||||
kill: (signal: string) => void;
|
||||
};
|
||||
child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() });
|
||||
child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() });
|
||||
child.kill = vi.fn();
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit("data", `${JSON.stringify(payload)}\n`);
|
||||
child.emit("close", 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
describe("imessage actions runtime", () => {
|
||||
it("passes the configured Messages db path to private API bridge commands", async () => {
|
||||
mockSpawnJsonResponse();
|
||||
|
||||
await imessageActionsRuntime.sendReaction({
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
messageId: "message-guid",
|
||||
reaction: "like",
|
||||
options: {
|
||||
cliPath: "imsg",
|
||||
dbPath: "/tmp/messages.db",
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
},
|
||||
});
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
"imsg",
|
||||
[
|
||||
"tapback",
|
||||
"--chat",
|
||||
"iMessage;+;chat0000",
|
||||
"--message",
|
||||
"message-guid",
|
||||
"--kind",
|
||||
"like",
|
||||
"--part",
|
||||
"0",
|
||||
"--db",
|
||||
"/tmp/messages.db",
|
||||
"--json",
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findChatGuid cross-format identifier resolution", () => {
|
||||
// imsg's chats.list returns DM chats as `identifier: <phone>` and
|
||||
// `guid: any;-;<phone>`. The agent's action surface synthesizes
|
||||
// `iMessage;-;<phone>` from a phone-number target. A naive string-equality
|
||||
// lookup would miss this match — this is the bug that surfaced in
|
||||
// production today: agent passes phone target → chat-guid resolver returns
|
||||
// null → react/edit/unsend throw "no registered chat" even though chats.list
|
||||
// does have the chat.
|
||||
const chatsList = [
|
||||
{
|
||||
id: 3,
|
||||
identifier: "+12069106512",
|
||||
guid: "any;-;+12069106512",
|
||||
service: "iMessage",
|
||||
is_group: false,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
identifier: "chat0000",
|
||||
guid: "iMessage;+;chat0000",
|
||||
service: "iMessage",
|
||||
is_group: true,
|
||||
},
|
||||
];
|
||||
|
||||
it("matches a synthesized iMessage;-;<phone> target against the chats.list <phone> identifier", () => {
|
||||
const result = _findChatGuidForTest(chatsList, {
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "iMessage;-;+12069106512",
|
||||
});
|
||||
expect(result).toBe("any;-;+12069106512");
|
||||
});
|
||||
|
||||
it("matches a synthesized SMS;-;<phone> target the same way", () => {
|
||||
const result = _findChatGuidForTest(chatsList, {
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "SMS;-;+12069106512",
|
||||
});
|
||||
expect(result).toBe("any;-;+12069106512");
|
||||
});
|
||||
|
||||
it("matches a bare <phone> identifier exactly", () => {
|
||||
const result = _findChatGuidForTest(chatsList, {
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "+12069106512",
|
||||
});
|
||||
expect(result).toBe("any;-;+12069106512");
|
||||
});
|
||||
|
||||
it("matches an any;-;<phone> guid form against the chats.list guid column", () => {
|
||||
const result = _findChatGuidForTest(chatsList, {
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "any;-;+12069106512",
|
||||
});
|
||||
expect(result).toBe("any;-;+12069106512");
|
||||
});
|
||||
|
||||
it("matches a group chat by exact guid", () => {
|
||||
const result = _findChatGuidForTest(chatsList, {
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "iMessage;+;chat0000",
|
||||
});
|
||||
expect(result).toBe("iMessage;+;chat0000");
|
||||
});
|
||||
|
||||
it("matches a group chat by chat_id", () => {
|
||||
const result = _findChatGuidForTest(chatsList, { kind: "chat_id", chatId: 7 });
|
||||
expect(result).toBe("iMessage;+;chat0000");
|
||||
});
|
||||
|
||||
it("returns null for a phone number that does not exist in chats.list", () => {
|
||||
const result = _findChatGuidForTest(chatsList, {
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "iMessage;-;+19999999999",
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("does not cross-match different phone numbers via the prefix-stripping path", () => {
|
||||
const result = _findChatGuidForTest(chatsList, {
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "iMessage;-;+18001234567",
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("does not match a DM target against a group's chat_identifier", () => {
|
||||
const result = _findChatGuidForTest(chatsList, {
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "iMessage;+;chat-not-here",
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeDirectChatIdentifier", () => {
|
||||
it("strips the iMessage;-; prefix", () => {
|
||||
expect(_normalizeDirectChatIdentifierForTest("iMessage;-;+12069106512")).toBe("+12069106512");
|
||||
});
|
||||
it("strips the SMS;-; prefix", () => {
|
||||
expect(_normalizeDirectChatIdentifierForTest("SMS;-;+12069106512")).toBe("+12069106512");
|
||||
});
|
||||
it("strips the any;-; prefix", () => {
|
||||
expect(_normalizeDirectChatIdentifierForTest("any;-;+12069106512")).toBe("+12069106512");
|
||||
});
|
||||
it("matches case-insensitively", () => {
|
||||
expect(_normalizeDirectChatIdentifierForTest("IMESSAGE;-;+12069106512")).toBe("+12069106512");
|
||||
});
|
||||
it("leaves group identifiers (iMessage;+;chat...) unchanged", () => {
|
||||
expect(_normalizeDirectChatIdentifierForTest("iMessage;+;chat0000")).toBe(
|
||||
"iMessage;+;chat0000",
|
||||
);
|
||||
});
|
||||
it("leaves bare values unchanged", () => {
|
||||
expect(_normalizeDirectChatIdentifierForTest("+12069106512")).toBe("+12069106512");
|
||||
expect(_normalizeDirectChatIdentifierForTest("foo@bar.com")).toBe("foo@bar.com");
|
||||
});
|
||||
});
|
||||
502
extensions/imessage/src/actions.runtime.ts
Normal file
502
extensions/imessage/src/actions.runtime.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { createIMessageRpcClient } from "./client.js";
|
||||
import { extractMarkdownFormatRuns } from "./markdown-format.js";
|
||||
import { resolveIMessageMessageId as resolveIMessageMessageIdImpl } from "./monitor-reply-cache.js";
|
||||
import type { IMessageTarget } from "./targets.js";
|
||||
|
||||
type CliRunOptions = {
|
||||
cliPath: string;
|
||||
dbPath?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type IMessageBridgeActionOptions = CliRunOptions & {
|
||||
chatGuid: string;
|
||||
};
|
||||
|
||||
type IMessageBridgeSendResult = {
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
type TempFileInput = {
|
||||
buffer: Uint8Array;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
type IMessageChatListResponse = {
|
||||
chats?: unknown;
|
||||
};
|
||||
|
||||
function asChatList(value: unknown): Array<Record<string, unknown>> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const chats = (value as IMessageChatListResponse).chats;
|
||||
if (!Array.isArray(chats)) {
|
||||
return [];
|
||||
}
|
||||
return chats.filter(
|
||||
(chat): chat is Record<string, unknown> =>
|
||||
chat != null && typeof chat === "object" && !Array.isArray(chat),
|
||||
);
|
||||
}
|
||||
|
||||
function numberFromUnknown(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value.trim());
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringFromUnknown(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
// 30s TTL on the chats.list cache, keyed by cliPath+dbPath. Long enough to
|
||||
// absorb a burst of agent actions; short enough that a freshly-created
|
||||
// chat shows up without restarting the gateway.
|
||||
const CHAT_LIST_CACHE_TTL_MS = 30 * 1000;
|
||||
type ChatListCacheEntry = {
|
||||
list: ReadonlyArray<Record<string, unknown>>;
|
||||
expiresAt: number;
|
||||
};
|
||||
const chatListCache = new Map<string, ChatListCacheEntry>();
|
||||
|
||||
function chatListCacheKey(cliPath: string, dbPath?: string): string {
|
||||
return `${cliPath}\0${dbPath ?? ""}`;
|
||||
}
|
||||
|
||||
function chatListCacheGet(
|
||||
cliPath: string,
|
||||
dbPath?: string,
|
||||
): ReadonlyArray<Record<string, unknown>> | null {
|
||||
const entry = chatListCache.get(chatListCacheKey(cliPath, dbPath));
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
chatListCache.delete(chatListCacheKey(cliPath, dbPath));
|
||||
return null;
|
||||
}
|
||||
return entry.list;
|
||||
}
|
||||
|
||||
function chatListCacheSet(
|
||||
cliPath: string,
|
||||
dbPath: string | undefined,
|
||||
list: ReadonlyArray<Record<string, unknown>>,
|
||||
): void {
|
||||
chatListCache.set(chatListCacheKey(cliPath, dbPath), {
|
||||
list,
|
||||
expiresAt: Date.now() + CHAT_LIST_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the iMessage;-;/SMS;-;/any;-; service prefix that Messages uses
|
||||
* for direct DM chats. Different layers report direct DMs in different
|
||||
* forms — the action surface synthesizes `iMessage;-;<phone>` from a
|
||||
* handle target, while imsg's chats.list returns `identifier: <phone>`
|
||||
* and `guid: any;-;<phone>`. Comparing the raw strings would falsely
|
||||
* miss the match. Mirror of the same helper in monitor-reply-cache.ts.
|
||||
*/
|
||||
export function _normalizeDirectChatIdentifierForTest(raw: string): string {
|
||||
return normalizeDirectChatIdentifier(raw);
|
||||
}
|
||||
|
||||
export function _findChatGuidForTest(
|
||||
chats: readonly Record<string, unknown>[],
|
||||
target: Extract<IMessageTarget, { kind: "chat_id" | "chat_identifier" }>,
|
||||
): string | null {
|
||||
return findChatGuid(chats, target);
|
||||
}
|
||||
|
||||
function normalizeDirectChatIdentifier(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
const lowered = trimmed.toLowerCase();
|
||||
for (const prefix of ["imessage;-;", "sms;-;", "any;-;"]) {
|
||||
if (lowered.startsWith(prefix)) {
|
||||
return trimmed.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function findChatGuid(
|
||||
chats: readonly Record<string, unknown>[],
|
||||
target: Extract<IMessageTarget, { kind: "chat_id" | "chat_identifier" }>,
|
||||
): string | null {
|
||||
if (target.kind === "chat_id") {
|
||||
for (const chat of chats) {
|
||||
const id = numberFromUnknown(chat.id);
|
||||
const guid = stringFromUnknown(chat.guid);
|
||||
if (id === target.chatId && guid) {
|
||||
return guid;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// target.kind === "chat_identifier"
|
||||
const wanted = normalizeDirectChatIdentifier(target.chatIdentifier);
|
||||
for (const chat of chats) {
|
||||
const identifier = stringFromUnknown(chat.identifier);
|
||||
const guid = stringFromUnknown(chat.guid);
|
||||
if (!guid) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
identifier === target.chatIdentifier ||
|
||||
guid === target.chatIdentifier ||
|
||||
(identifier && normalizeDirectChatIdentifier(identifier) === wanted) ||
|
||||
normalizeDirectChatIdentifier(guid) === wanted
|
||||
) {
|
||||
return guid;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildIMessageCliJsonArgs(args: readonly string[], options: CliRunOptions): string[] {
|
||||
const dbPath = options.dbPath?.trim();
|
||||
return [...args, ...(dbPath ? ["--db", dbPath] : []), "--json"];
|
||||
}
|
||||
|
||||
async function runIMessageCliJson(
|
||||
args: readonly string[],
|
||||
options: CliRunOptions,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(options.cliPath, buildIMessageCliJsonArgs(args, options), {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let killEscalation: ReturnType<typeof setTimeout> | null = null;
|
||||
const timer =
|
||||
options.timeoutMs && options.timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
// If SIGTERM doesn't take within 2s (wedged child, ignored
|
||||
// signal handler), escalate to SIGKILL so the process doesn't
|
||||
// linger as a zombie.
|
||||
killEscalation = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}, 2000);
|
||||
reject(new Error(`iMessage action timed out after ${options.timeoutMs}ms`));
|
||||
}, options.timeoutMs)
|
||||
: null;
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (killEscalation) {
|
||||
clearTimeout(killEscalation);
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (killEscalation) {
|
||||
clearTimeout(killEscalation);
|
||||
}
|
||||
const lines = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const last = lines.at(-1);
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
if (last) {
|
||||
try {
|
||||
const value = JSON.parse(last);
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
parsed = value as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
if (code !== 0) {
|
||||
const detail =
|
||||
(typeof parsed?.error === "string" && parsed.error.trim()) ||
|
||||
stderr.trim() ||
|
||||
stdout.trim() ||
|
||||
`imsg exited with code ${code}`;
|
||||
reject(new Error(detail));
|
||||
return;
|
||||
}
|
||||
if (!parsed) {
|
||||
reject(new Error(`imsg returned non-JSON output: ${stdout.trim() || stderr.trim()}`));
|
||||
return;
|
||||
}
|
||||
if (parsed.success === false) {
|
||||
const error =
|
||||
typeof parsed.error === "string" && parsed.error.trim()
|
||||
? parsed.error.trim()
|
||||
: "iMessage action failed";
|
||||
reject(new Error(error));
|
||||
return;
|
||||
}
|
||||
resolve(parsed);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMessageId(result: Record<string, unknown>): string {
|
||||
const raw =
|
||||
(typeof result.messageGuid === "string" && result.messageGuid.trim()) ||
|
||||
(typeof result.messageId === "string" && result.messageId.trim()) ||
|
||||
(typeof result.guid === "string" && result.guid.trim()) ||
|
||||
(typeof result.id === "string" && result.id.trim());
|
||||
return raw || "ok";
|
||||
}
|
||||
|
||||
async function withTempFile<T>(input: TempFileInput, fn: (path: string) => Promise<T>): Promise<T> {
|
||||
const dir = await mkdtemp(join(resolvePreferredOpenClawTmpDir(), "openclaw-imessage-"));
|
||||
const safeExt = extname(input.filename).slice(0, 16) || ".bin";
|
||||
const filePath = join(dir, `upload${safeExt}`);
|
||||
try {
|
||||
await writeFile(filePath, input.buffer);
|
||||
return await fn(filePath);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export const imessageActionsRuntime = {
|
||||
resolveIMessageMessageId: resolveIMessageMessageIdImpl,
|
||||
|
||||
async resolveChatGuidForTarget(params: {
|
||||
target: Extract<IMessageTarget, { kind: "chat_id" | "chat_identifier" }>;
|
||||
options: CliRunOptions;
|
||||
}): Promise<string | null> {
|
||||
// Each `chats.list` call spawns a fresh imsg rpc subprocess and pulls
|
||||
// every chat the account knows about. Bursts of agent actions (react
|
||||
// then reply, reply then add-participant, etc.) all paid that cost
|
||||
// until we cached the chats list per cliPath+dbPath for ~30 seconds.
|
||||
const cached = chatListCacheGet(params.options.cliPath, params.options.dbPath);
|
||||
if (cached) {
|
||||
return findChatGuid(cached, params.target);
|
||||
}
|
||||
const client = await createIMessageRpcClient({
|
||||
cliPath: params.options.cliPath,
|
||||
dbPath: params.options.dbPath,
|
||||
});
|
||||
try {
|
||||
const result = await client.request<IMessageChatListResponse>(
|
||||
"chats.list",
|
||||
{ limit: 1000 },
|
||||
{ timeoutMs: params.options.timeoutMs },
|
||||
);
|
||||
const list = asChatList(result);
|
||||
chatListCacheSet(params.options.cliPath, params.options.dbPath, list);
|
||||
return findChatGuid(list, params.target);
|
||||
} finally {
|
||||
await client.stop();
|
||||
}
|
||||
},
|
||||
|
||||
async sendReaction(params: {
|
||||
chatGuid: string;
|
||||
messageId: string;
|
||||
reaction: string;
|
||||
remove?: boolean;
|
||||
partIndex?: number;
|
||||
options: IMessageBridgeActionOptions;
|
||||
}) {
|
||||
await runIMessageCliJson(
|
||||
[
|
||||
"tapback",
|
||||
"--chat",
|
||||
params.chatGuid,
|
||||
"--message",
|
||||
params.messageId,
|
||||
"--kind",
|
||||
params.reaction,
|
||||
"--part",
|
||||
String(params.partIndex ?? 0),
|
||||
...(params.remove ? ["--remove"] : []),
|
||||
],
|
||||
params.options,
|
||||
);
|
||||
},
|
||||
|
||||
async editMessage(params: {
|
||||
chatGuid: string;
|
||||
messageId: string;
|
||||
text: string;
|
||||
backwardsCompatMessage?: string;
|
||||
partIndex?: number;
|
||||
options: IMessageBridgeActionOptions;
|
||||
}) {
|
||||
await runIMessageCliJson(
|
||||
[
|
||||
"edit",
|
||||
"--chat",
|
||||
params.chatGuid,
|
||||
"--message",
|
||||
params.messageId,
|
||||
"--new-text",
|
||||
params.text,
|
||||
"--bc-text",
|
||||
params.backwardsCompatMessage ?? params.text,
|
||||
"--part",
|
||||
String(params.partIndex ?? 0),
|
||||
],
|
||||
params.options,
|
||||
);
|
||||
},
|
||||
|
||||
async unsendMessage(params: {
|
||||
chatGuid: string;
|
||||
messageId: string;
|
||||
partIndex?: number;
|
||||
options: IMessageBridgeActionOptions;
|
||||
}) {
|
||||
await runIMessageCliJson(
|
||||
[
|
||||
"unsend",
|
||||
"--chat",
|
||||
params.chatGuid,
|
||||
"--message",
|
||||
params.messageId,
|
||||
"--part",
|
||||
String(params.partIndex ?? 0),
|
||||
],
|
||||
params.options,
|
||||
);
|
||||
},
|
||||
|
||||
async sendRichMessage(params: {
|
||||
chatGuid: string;
|
||||
text: string;
|
||||
effectId?: string;
|
||||
replyToMessageId?: string;
|
||||
partIndex?: number;
|
||||
options: IMessageBridgeActionOptions;
|
||||
}): Promise<IMessageBridgeSendResult> {
|
||||
// Extract markdown bold/italic/underline/strikethrough into typed-run
|
||||
// ranges so the recipient sees actual styling rather than literal
|
||||
// asterisks. This mirrors the same extraction the rpc-send path does;
|
||||
// any caller that hits the bridge via `imsg send-rich` benefits without
|
||||
// needing to pre-format the text themselves.
|
||||
const formatted = extractMarkdownFormatRuns(params.text);
|
||||
const result = await runIMessageCliJson(
|
||||
[
|
||||
"send-rich",
|
||||
"--chat",
|
||||
params.chatGuid,
|
||||
"--text",
|
||||
formatted.text,
|
||||
"--part",
|
||||
String(params.partIndex ?? 0),
|
||||
...(params.effectId ? ["--effect", params.effectId] : []),
|
||||
...(params.replyToMessageId ? ["--reply-to", params.replyToMessageId] : []),
|
||||
...(formatted.ranges.length > 0 ? ["--format", JSON.stringify(formatted.ranges)] : []),
|
||||
],
|
||||
params.options,
|
||||
);
|
||||
return { messageId: resolveMessageId(result) };
|
||||
},
|
||||
|
||||
async renameGroup(params: {
|
||||
chatGuid: string;
|
||||
displayName: string;
|
||||
options: IMessageBridgeActionOptions;
|
||||
}) {
|
||||
await runIMessageCliJson(
|
||||
["chat-name", "--chat", params.chatGuid, "--name", params.displayName],
|
||||
params.options,
|
||||
);
|
||||
},
|
||||
|
||||
async setGroupIcon(params: {
|
||||
chatGuid: string;
|
||||
buffer: Uint8Array;
|
||||
filename: string;
|
||||
options: IMessageBridgeActionOptions;
|
||||
}) {
|
||||
await withTempFile({ buffer: params.buffer, filename: params.filename }, async (filePath) => {
|
||||
await runIMessageCliJson(
|
||||
["chat-photo", "--chat", params.chatGuid, "--file", filePath],
|
||||
params.options,
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async addParticipant(params: {
|
||||
chatGuid: string;
|
||||
address: string;
|
||||
options: IMessageBridgeActionOptions;
|
||||
}) {
|
||||
await runIMessageCliJson(
|
||||
["chat-add-member", "--chat", params.chatGuid, "--address", params.address],
|
||||
params.options,
|
||||
);
|
||||
},
|
||||
|
||||
async removeParticipant(params: {
|
||||
chatGuid: string;
|
||||
address: string;
|
||||
options: IMessageBridgeActionOptions;
|
||||
}) {
|
||||
await runIMessageCliJson(
|
||||
["chat-remove-member", "--chat", params.chatGuid, "--address", params.address],
|
||||
params.options,
|
||||
);
|
||||
},
|
||||
|
||||
async leaveGroup(params: { chatGuid: string; options: IMessageBridgeActionOptions }) {
|
||||
await runIMessageCliJson(["chat-leave", "--chat", params.chatGuid], params.options);
|
||||
},
|
||||
|
||||
async sendAttachment(params: {
|
||||
chatGuid: string;
|
||||
buffer: Uint8Array;
|
||||
filename: string;
|
||||
asVoice?: boolean;
|
||||
options: IMessageBridgeActionOptions;
|
||||
}): Promise<IMessageBridgeSendResult> {
|
||||
return await withTempFile(
|
||||
{ buffer: params.buffer, filename: params.filename },
|
||||
async (filePath) => {
|
||||
const result = await runIMessageCliJson(
|
||||
[
|
||||
"send-attachment",
|
||||
"--chat",
|
||||
params.chatGuid,
|
||||
"--file",
|
||||
filePath,
|
||||
...(params.asVoice ? ["--audio"] : []),
|
||||
],
|
||||
params.options,
|
||||
);
|
||||
return { messageId: resolveMessageId(result) };
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export type IMessageActionsRuntime = typeof imessageActionsRuntime;
|
||||
535
extensions/imessage/src/actions.test.ts
Normal file
535
extensions/imessage/src/actions.test.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const probeMock = vi.hoisted(() => ({
|
||||
getCachedIMessagePrivateApiStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
const runtimeMock = vi.hoisted(() => ({
|
||||
resolveIMessageMessageId: vi.fn((id: string) => id),
|
||||
resolveChatGuidForTarget: vi.fn(),
|
||||
sendReaction: vi.fn(),
|
||||
sendRichMessage: vi.fn(),
|
||||
sendAttachment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
getCachedIMessagePrivateApiStatus: probeMock.getCachedIMessagePrivateApiStatus,
|
||||
}));
|
||||
|
||||
vi.mock("./actions.runtime.js", () => ({
|
||||
imessageActionsRuntime: runtimeMock,
|
||||
}));
|
||||
|
||||
const { imessageMessageActions } = await import("./actions.js");
|
||||
|
||||
function cfg(actions?: Record<string, boolean | undefined>): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
imessage: {
|
||||
cliPath: "imsg",
|
||||
dbPath: "/tmp/messages.db",
|
||||
actions,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("imessage message actions", () => {
|
||||
beforeEach(() => {
|
||||
runtimeMock.resolveIMessageMessageId.mockClear();
|
||||
runtimeMock.resolveIMessageMessageId.mockImplementation((id: string) => id);
|
||||
runtimeMock.resolveChatGuidForTarget.mockReset();
|
||||
runtimeMock.sendReaction.mockReset();
|
||||
runtimeMock.sendRichMessage.mockReset();
|
||||
runtimeMock.sendAttachment.mockReset();
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReset();
|
||||
});
|
||||
|
||||
it("does not advertise private API actions when the bridge is known unavailable", () => {
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: false,
|
||||
v2Ready: false,
|
||||
selectors: {},
|
||||
});
|
||||
|
||||
const described = imessageMessageActions.describeMessageTool({
|
||||
cfg: cfg(),
|
||||
currentChannelId: "chat_guid:iMessage;+;chat0000",
|
||||
} as never);
|
||||
|
||||
expect(described?.actions).toEqual([]);
|
||||
});
|
||||
|
||||
it("advertises private API actions while private API status is unknown", () => {
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue(undefined);
|
||||
|
||||
const described = imessageMessageActions.describeMessageTool({
|
||||
cfg: cfg(),
|
||||
currentChannelId: "chat_guid:iMessage;+;chat0000",
|
||||
} as never);
|
||||
|
||||
expect(described?.actions).toEqual(
|
||||
expect.arrayContaining(["react", "reply", "sendWithEffect", "upload-file"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("advertises BB-parity actions when private API and selectors are available", () => {
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {
|
||||
editMessage: true,
|
||||
retractMessagePart: true,
|
||||
},
|
||||
});
|
||||
|
||||
const described = imessageMessageActions.describeMessageTool({
|
||||
cfg: cfg(),
|
||||
currentChannelId: "chat_guid:iMessage;+;chat0000",
|
||||
} as never);
|
||||
|
||||
expect(described?.actions).toEqual(
|
||||
expect.arrayContaining([
|
||||
"react",
|
||||
"edit",
|
||||
"unsend",
|
||||
"reply",
|
||||
"sendWithEffect",
|
||||
"renameGroup",
|
||||
"setGroupIcon",
|
||||
"addParticipant",
|
||||
"removeParticipant",
|
||||
"leaveGroup",
|
||||
"upload-file",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("respects configured action gates", () => {
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {
|
||||
editMessage: true,
|
||||
retractMessagePart: true,
|
||||
},
|
||||
});
|
||||
|
||||
const described = imessageMessageActions.describeMessageTool({
|
||||
cfg: cfg({ reactions: false, reply: false }),
|
||||
currentChannelId: "chat_guid:iMessage;+;chat0000",
|
||||
} as never);
|
||||
|
||||
expect(described?.actions).not.toContain("react");
|
||||
expect(described?.actions).not.toContain("reply");
|
||||
expect(described?.actions).toContain("edit");
|
||||
});
|
||||
|
||||
it("maps message tool reactions to imsg tapback kinds", async () => {
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
runtimeMock.sendReaction.mockResolvedValue(undefined);
|
||||
|
||||
await imessageMessageActions.handleAction?.({
|
||||
action: "react",
|
||||
cfg: cfg(),
|
||||
params: {
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
messageId: "message-guid",
|
||||
emoji: "👍",
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(runtimeMock.sendReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
messageId: "message-guid",
|
||||
reaction: "like",
|
||||
options: expect.objectContaining({
|
||||
dbPath: "/tmp/messages.db",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves chat_id targets before invoking bridge actions", async () => {
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
runtimeMock.resolveChatGuidForTarget.mockResolvedValue("iMessage;+;resolved");
|
||||
runtimeMock.sendReaction.mockResolvedValue(undefined);
|
||||
|
||||
await imessageMessageActions.handleAction?.({
|
||||
action: "react",
|
||||
cfg: cfg(),
|
||||
params: {
|
||||
target: "chat_id:42",
|
||||
messageId: "message-guid",
|
||||
emoji: "👍",
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(runtimeMock.resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: { kind: "chat_id", chatId: 42 },
|
||||
}),
|
||||
);
|
||||
expect(runtimeMock.sendReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatGuid: "iMessage;+;resolved",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves short message ids before invoking bridge actions", async () => {
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
runtimeMock.resolveIMessageMessageId.mockReturnValueOnce("full-guid");
|
||||
runtimeMock.sendReaction.mockResolvedValue(undefined);
|
||||
|
||||
await imessageMessageActions.handleAction?.({
|
||||
action: "react",
|
||||
cfg: cfg(),
|
||||
params: {
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
messageId: "1",
|
||||
emoji: "👍",
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(runtimeMock.resolveIMessageMessageId).toHaveBeenCalledWith("1", {
|
||||
requireKnownShortId: true,
|
||||
chatContext: {
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
chatIdentifier: undefined,
|
||||
chatId: undefined,
|
||||
},
|
||||
});
|
||||
expect(runtimeMock.sendReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageId: "full-guid",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves chat_identifier targets before invoking bridge actions", async () => {
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
runtimeMock.resolveChatGuidForTarget.mockResolvedValue("iMessage;+;resolved-ident");
|
||||
runtimeMock.sendRichMessage.mockResolvedValue({ messageId: "reply-guid" });
|
||||
|
||||
await imessageMessageActions.handleAction?.({
|
||||
action: "reply",
|
||||
cfg: cfg(),
|
||||
params: {
|
||||
chatIdentifier: "team-thread",
|
||||
messageId: "message-guid",
|
||||
text: "reply",
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(runtimeMock.resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: { kind: "chat_identifier", chatIdentifier: "team-thread" },
|
||||
}),
|
||||
);
|
||||
expect(runtimeMock.sendRichMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatGuid: "iMessage;+;resolved-ident",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("phone-number target end-to-end (regressions caught the hard way)", () => {
|
||||
it("synthesizes iMessage;-;<phone> chat_identifier from a handle target and sends through to sendReaction", async () => {
|
||||
// Scenario from prod: agent calls react with `target:"+12069106512"` and a
|
||||
// known-cached short messageId. resolveChatGuid synthesizes
|
||||
// `iMessage;-;+12069106512` and asks the runtime to look it up. The
|
||||
// runtime returns the real chat guid. sendReaction must receive the
|
||||
// resolved guid, not the synthesized stand-in.
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
runtimeMock.resolveChatGuidForTarget.mockResolvedValue("any;-;+12069106512");
|
||||
runtimeMock.resolveIMessageMessageId.mockReturnValueOnce("full-guid");
|
||||
runtimeMock.sendReaction.mockResolvedValue(undefined);
|
||||
|
||||
await imessageMessageActions.handleAction?.({
|
||||
action: "react",
|
||||
cfg: cfg(),
|
||||
params: {
|
||||
target: "+12069106512",
|
||||
messageId: "5",
|
||||
emoji: "👍",
|
||||
},
|
||||
} as never);
|
||||
|
||||
// resolveChatGuid synthesizes the chat_identifier; the runtime then
|
||||
// does the chats.list lookup against it.
|
||||
expect(runtimeMock.resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: {
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "iMessage;-;+12069106512",
|
||||
},
|
||||
}),
|
||||
);
|
||||
// The cache lookup uses the synthesized chat_identifier as scope so
|
||||
// cross-chat checks have something to match against.
|
||||
expect(runtimeMock.resolveIMessageMessageId).toHaveBeenCalledWith("5", {
|
||||
requireKnownShortId: true,
|
||||
chatContext: expect.objectContaining({
|
||||
chatIdentifier: "iMessage;-;+12069106512",
|
||||
}),
|
||||
});
|
||||
// sendReaction lands on the real registered chat guid, not the
|
||||
// synthesized stand-in.
|
||||
expect(runtimeMock.sendReaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatGuid: "any;-;+12069106512",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects react/edit/unsend when the synthesized chat is not registered", async () => {
|
||||
// Scenario from prod: agent invokes react against a phone target whose
|
||||
// chat has never been touched yet. We refuse rather than fabricate the
|
||||
// identifier and let it fail downstream — there's no message to react
|
||||
// to in a chat that doesn't exist yet.
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
runtimeMock.resolveChatGuidForTarget.mockResolvedValue(null);
|
||||
runtimeMock.sendReaction.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
imessageMessageActions.handleAction?.({
|
||||
action: "react",
|
||||
cfg: cfg(),
|
||||
params: {
|
||||
target: "+19999999999",
|
||||
messageId: "irrelevant",
|
||||
emoji: "👍",
|
||||
},
|
||||
} as never),
|
||||
).rejects.toThrow(/requires a known chat/i);
|
||||
expect(runtimeMock.sendReaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the synthesized identifier for send/reply/sendWithEffect when the chat is not yet registered", async () => {
|
||||
// Counterpart to the above: send/reply/sendWithEffect targeting a brand-
|
||||
// new phone-number chat is fine — Messages will register the chat as a
|
||||
// side effect of the send. Only the mutate-existing-message actions
|
||||
// need a registered chat.
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
runtimeMock.resolveChatGuidForTarget.mockResolvedValue(null);
|
||||
runtimeMock.sendRichMessage.mockResolvedValue({ messageId: "ok" });
|
||||
runtimeMock.resolveIMessageMessageId.mockReturnValueOnce("parent-guid");
|
||||
|
||||
await imessageMessageActions.handleAction?.({
|
||||
action: "reply",
|
||||
cfg: cfg(),
|
||||
params: {
|
||||
target: "+18001234567",
|
||||
messageId: "parent-guid",
|
||||
text: "first contact",
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(runtimeMock.sendRichMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatGuid: "iMessage;-;+18001234567",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a tapback by fanning out across all known kinds when emoji is empty/unknown and remove:true", async () => {
|
||||
// Scenario from the audit: agent calls react with `remove: true` but
|
||||
// forgot which emoji was originally added (or used a non-mapped emoji
|
||||
// like 🦞). We fan a remove out to every known kind; the bridge no-ops
|
||||
// kinds that weren't there.
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
runtimeMock.sendReaction.mockResolvedValue(undefined);
|
||||
|
||||
await imessageMessageActions.handleAction?.({
|
||||
action: "react",
|
||||
cfg: cfg(),
|
||||
params: {
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
messageId: "message-guid",
|
||||
emoji: "🦞",
|
||||
remove: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const kinds = runtimeMock.sendReaction.mock.calls.map(
|
||||
(call: unknown[]) => (call[0] as { reaction: string }).reaction,
|
||||
);
|
||||
expect(kinds.toSorted()).toEqual(
|
||||
["dislike", "emphasize", "laugh", "like", "love", "question"].toSorted(),
|
||||
);
|
||||
expect(
|
||||
runtimeMock.sendReaction.mock.calls.every(
|
||||
(call: unknown[]) => (call[0] as { remove: boolean }).remove,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown effect with an actionable error message", async () => {
|
||||
// Scenario from the audit: agent passes a typo like `invisible_ink`
|
||||
// (note underscore vs `invisibleink` alias). We refuse rather than
|
||||
// forwarding gibberish to the bridge for an opaque CLI failure.
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
runtimeMock.sendRichMessage.mockResolvedValue({ messageId: "ok" });
|
||||
|
||||
await expect(
|
||||
imessageMessageActions.handleAction?.({
|
||||
action: "sendWithEffect",
|
||||
cfg: cfg(),
|
||||
params: {
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
text: "boom",
|
||||
effect: "invisible_ink",
|
||||
},
|
||||
} as never),
|
||||
).rejects.toThrow(/unknown effect|invisible_ink/i);
|
||||
expect(runtimeMock.sendRichMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts known effect aliases like 'slam' and 'invisibleink'", async () => {
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
runtimeMock.sendRichMessage.mockResolvedValue({ messageId: "ok" });
|
||||
|
||||
await imessageMessageActions.handleAction?.({
|
||||
action: "sendWithEffect",
|
||||
cfg: cfg(),
|
||||
params: {
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
text: "boom",
|
||||
effect: "slam",
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(runtimeMock.sendRichMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
effectId: "com.apple.MobileSMS.expressivesend.impact",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["echo", "com.apple.messages.effect.CKEchoEffect"],
|
||||
["happybirthday", "com.apple.messages.effect.CKHappyBirthdayEffect"],
|
||||
["shootingstar", "com.apple.messages.effect.CKShootingStarEffect"],
|
||||
["sparkles", "com.apple.messages.effect.CKSparklesEffect"],
|
||||
["spotlight", "com.apple.messages.effect.CKSpotlightEffect"],
|
||||
])(
|
||||
"resolves the screen-effect alias %s that the error message advertises",
|
||||
async (alias, canonical) => {
|
||||
// Codex review caught these: the error message at effectIdFromParam
|
||||
// listed echo / happybirthday / shootingstar / sparkles / spotlight
|
||||
// as valid aliases, but they were missing from the alias map. Agents
|
||||
// following our own guidance got "unknown effect" thrown back.
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
runtimeMock.sendRichMessage.mockResolvedValue({ messageId: "ok" });
|
||||
|
||||
await imessageMessageActions.handleAction?.({
|
||||
action: "sendWithEffect",
|
||||
cfg: cfg(),
|
||||
params: {
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
text: "boom",
|
||||
effect: alias,
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(runtimeMock.sendRichMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ effectId: canonical }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("trims whitespace-only currentChannelId so parseIMessageTarget never sees it", async () => {
|
||||
// Scenario from the audit: a whitespace-only currentChannelId would
|
||||
// hit parseIMessageTarget which throws on empty input, aborting the
|
||||
// whole action with a confusing "target is required" message.
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
imessageMessageActions.handleAction?.({
|
||||
action: "react",
|
||||
cfg: cfg(),
|
||||
params: { messageId: "x", emoji: "👍" },
|
||||
toolContext: { currentChannelId: " \t " },
|
||||
} as never),
|
||||
).rejects.toThrow(/requires chatGuid, chatId, chatIdentifier, or a chat target/);
|
||||
});
|
||||
});
|
||||
|
||||
it("routes upload-file through the private API attachment bridge", async () => {
|
||||
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
});
|
||||
runtimeMock.sendAttachment.mockResolvedValue({ messageId: "sent-guid" });
|
||||
|
||||
const result = await imessageMessageActions.handleAction?.({
|
||||
action: "upload-file",
|
||||
cfg: cfg(),
|
||||
params: {
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
filename: "photo.jpg",
|
||||
buffer: Buffer.from("image").toString("base64"),
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(runtimeMock.sendAttachment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
filename: "photo.jpg",
|
||||
}),
|
||||
);
|
||||
expect(result?.details).toEqual({ ok: true, messageId: "sent-guid" });
|
||||
});
|
||||
});
|
||||
624
extensions/imessage/src/actions.ts
Normal file
624
extensions/imessage/src/actions.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||
import {
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
import { resolveIMessageAccount } from "./accounts.js";
|
||||
import { IMESSAGE_ACTION_NAMES, IMESSAGE_ACTIONS } from "./actions-contract.js";
|
||||
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
|
||||
import { findLatestIMessageEntryForChat, type IMessageChatContext } from "./monitor-reply-cache.js";
|
||||
import { getCachedIMessagePrivateApiStatus } from "./probe.js";
|
||||
import {
|
||||
inferIMessageTargetChatType,
|
||||
parseIMessageTarget,
|
||||
type IMessageTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
const loadIMessageActionsRuntime = createLazyRuntimeNamedExport(
|
||||
() => import("./actions.runtime.js"),
|
||||
"imessageActionsRuntime",
|
||||
);
|
||||
|
||||
const providerId = "imessage";
|
||||
|
||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
|
||||
...IMESSAGE_ACTION_NAMES,
|
||||
"upload-file",
|
||||
]);
|
||||
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
|
||||
"react",
|
||||
"edit",
|
||||
"unsend",
|
||||
"reply",
|
||||
"sendWithEffect",
|
||||
"renameGroup",
|
||||
"setGroupIcon",
|
||||
"addParticipant",
|
||||
"removeParticipant",
|
||||
"leaveGroup",
|
||||
"sendAttachment",
|
||||
]);
|
||||
|
||||
function readMessageText(params: Record<string, unknown>): string | undefined {
|
||||
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read messageId from the action params, falling back to the most recent
|
||||
* inbound in the same chat when the caller omitted it. The natural intent
|
||||
* for "react with 👍" or "tapback the last message" is the message that
|
||||
* just arrived in the current conversation; making the agent re-quote a
|
||||
* message id every time is friction the cache already has the answer for.
|
||||
*/
|
||||
function readMessageIdWithChatFallback(
|
||||
params: Record<string, unknown>,
|
||||
chatContext: IMessageChatContext & { accountId: string },
|
||||
): string {
|
||||
const explicit = readStringParam(params, "messageId");
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
const latest = findLatestIMessageEntryForChat(chatContext);
|
||||
if (latest?.messageId) {
|
||||
return latest.messageId;
|
||||
}
|
||||
// Surface the same error the strict readMessageId would have, so the
|
||||
// agent gets a clear "you must supply messageId" signal when there is
|
||||
// also no cached message to fall back to.
|
||||
return readStringParam(params, "messageId", { required: true });
|
||||
}
|
||||
|
||||
function isGroupTarget(raw?: string | null): boolean {
|
||||
// Defer to the canonical target classifier so action gating and the
|
||||
// routing layer can't drift apart on edge cases (URI-encoded targets,
|
||||
// service prefixes, etc.).
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
return inferIMessageTargetChatType(raw) === "group";
|
||||
}
|
||||
|
||||
type IMessageActionsRuntime = Awaited<ReturnType<typeof loadIMessageActionsRuntime>>;
|
||||
|
||||
async function resolveChatGuid(params: {
|
||||
action: ChannelMessageActionName;
|
||||
actionParams: Record<string, unknown>;
|
||||
currentChannelId?: string;
|
||||
runtime: IMessageActionsRuntime;
|
||||
options: {
|
||||
cliPath: string;
|
||||
dbPath?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
}): Promise<string> {
|
||||
const explicitChatGuid = readStringParam(params.actionParams, "chatGuid");
|
||||
if (explicitChatGuid) {
|
||||
return explicitChatGuid;
|
||||
}
|
||||
const explicitChatId = readNumberParam(params.actionParams, "chatId", { integer: true });
|
||||
if (typeof explicitChatId === "number") {
|
||||
const resolved = await params.runtime.resolveChatGuidForTarget({
|
||||
target: { kind: "chat_id", chatId: explicitChatId },
|
||||
options: params.options,
|
||||
});
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
throw new Error(`iMessage ${params.action} failed: chatGuid not found for chat_id:<redacted>.`);
|
||||
}
|
||||
const explicitChatIdentifier = readStringParam(params.actionParams, "chatIdentifier");
|
||||
if (explicitChatIdentifier) {
|
||||
const resolved = await params.runtime.resolveChatGuidForTarget({
|
||||
target: { kind: "chat_identifier", chatIdentifier: explicitChatIdentifier },
|
||||
options: params.options,
|
||||
});
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
throw new Error(
|
||||
`iMessage ${params.action} failed: chatGuid not found for chat_identifier:<redacted>.`,
|
||||
);
|
||||
}
|
||||
const rawTarget =
|
||||
readStringParam(params.actionParams, "to") ??
|
||||
readStringParam(params.actionParams, "target") ??
|
||||
(params.currentChannelId?.trim() || undefined);
|
||||
if (rawTarget) {
|
||||
const target = parseIMessageTarget(rawTarget);
|
||||
if (target.kind === "chat_guid") {
|
||||
return target.chatGuid;
|
||||
}
|
||||
if (target.kind === "chat_id" || target.kind === "chat_identifier") {
|
||||
const resolved = await params.runtime.resolveChatGuidForTarget({
|
||||
target,
|
||||
options: params.options,
|
||||
});
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
throw new Error(
|
||||
`iMessage ${params.action} failed: chatGuid not found for ${formatUnresolvedTarget(target)}.`,
|
||||
);
|
||||
}
|
||||
if (target.kind === "handle") {
|
||||
// A bare phone/email is a valid chat scope for direct messages —
|
||||
// Messages addresses DMs as `iMessage;-;<handle>` / `SMS;-;<handle>`.
|
||||
// Promote it to chat_identifier so resolveChatGuidForTarget (which
|
||||
// only accepts chat_id / chat_identifier kinds) can look it up.
|
||||
const synthesizedIdentifier = `${target.service === "sms" ? "SMS" : "iMessage"};-;${target.to}`;
|
||||
const resolved = await params.runtime.resolveChatGuidForTarget({
|
||||
target: { kind: "chat_identifier", chatIdentifier: synthesizedIdentifier },
|
||||
options: params.options,
|
||||
});
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
// Per-action fallback policy:
|
||||
// - send / reply / sendWithEffect / sendAttachment: fine to send to
|
||||
// a synthesized DM identifier; Messages will register the chat.
|
||||
// - react / edit / unsend: these mutate an existing message that
|
||||
// must already exist in the chat. If we have no registered chat
|
||||
// we have no message to act on, and synthesizing the identifier
|
||||
// just produces a confusing CLI failure.
|
||||
if (params.action === "react" || params.action === "edit" || params.action === "unsend") {
|
||||
throw new Error(
|
||||
`iMessage ${params.action} requires a known chat. ` +
|
||||
`No registered chat for the supplied target; send a message first or pass an explicit chatGuid.`,
|
||||
);
|
||||
}
|
||||
return synthesizedIdentifier;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`iMessage ${params.action} requires chatGuid, chatId, chatIdentifier, or a chat target.`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatUnresolvedTarget(
|
||||
target: Extract<IMessageTarget, { kind: "chat_id" | "chat_identifier" }>,
|
||||
): string {
|
||||
// Redact the actual identifier — error strings end up in agent tool
|
||||
// results and log streams, and exposing a chat_id or chat_identifier
|
||||
// there would leak the conversation handle to anything that observes
|
||||
// them.
|
||||
return target.kind === "chat_id" ? "chat_id:<redacted>" : "chat_identifier:<redacted>";
|
||||
}
|
||||
|
||||
function buildChatContextFromActionParams(params: {
|
||||
actionParams: Record<string, unknown>;
|
||||
currentChannelId?: string;
|
||||
}): IMessageChatContext {
|
||||
const explicitChatGuid = readStringParam(params.actionParams, "chatGuid")?.trim();
|
||||
const explicitChatIdentifier = readStringParam(params.actionParams, "chatIdentifier")?.trim();
|
||||
const explicitChatId = readNumberParam(params.actionParams, "chatId", { integer: true });
|
||||
// Trim before the truthy check so a whitespace-only currentChannelId can't
|
||||
// reach parseIMessageTarget (which throws on empty/whitespace input and
|
||||
// would abort the whole action with a confusing "target is required").
|
||||
const rawTarget =
|
||||
readStringParam(params.actionParams, "to") ??
|
||||
readStringParam(params.actionParams, "target") ??
|
||||
(params.currentChannelId?.trim() || undefined);
|
||||
const target = rawTarget ? parseIMessageTarget(rawTarget) : null;
|
||||
// A "handle" target (raw phone or email — what the agent uses most of the
|
||||
// time) is still a usable chat scope: Messages addresses DMs as
|
||||
// `iMessage;-;+15551234567` / `SMS;-;+15551234567`. Synthesizing the
|
||||
// chat-identifier here lets resolveIMessageMessageId succeed without
|
||||
// forcing every action plumbing site to also surface chatGuid/chatId.
|
||||
const handleChatIdentifier =
|
||||
target?.kind === "handle"
|
||||
? `${target.service === "sms" ? "SMS" : "iMessage"};-;${target.to}`
|
||||
: undefined;
|
||||
return {
|
||||
chatGuid: explicitChatGuid || (target?.kind === "chat_guid" ? target.chatGuid : undefined),
|
||||
chatIdentifier:
|
||||
explicitChatIdentifier ||
|
||||
(target?.kind === "chat_identifier" ? target.chatIdentifier : undefined) ||
|
||||
handleChatIdentifier,
|
||||
chatId:
|
||||
typeof explicitChatId === "number"
|
||||
? explicitChatId
|
||||
: target?.kind === "chat_id"
|
||||
? target.chatId
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapTapbackReaction(emoji?: string): string | undefined {
|
||||
const value = normalizeOptionalLowercaseString(emoji)?.replace(/\ufe0f/g, "");
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
if (["love", "heart", "❤", "❤️"].includes(value)) {
|
||||
return "love";
|
||||
}
|
||||
if (["like", "+1", "thumbsup", "👍"].includes(value)) {
|
||||
return "like";
|
||||
}
|
||||
if (["dislike", "-1", "thumbsdown", "👎"].includes(value)) {
|
||||
return "dislike";
|
||||
}
|
||||
if (["laugh", "haha", "😂", "🤣"].includes(value)) {
|
||||
return "laugh";
|
||||
}
|
||||
if (["emphasize", "!!", "‼", "‼️"].includes(value)) {
|
||||
return "emphasize";
|
||||
}
|
||||
if (["question", "?", "?", "❓"].includes(value)) {
|
||||
return "question";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function decodeBase64Buffer(params: Record<string, unknown>, action: string): Uint8Array {
|
||||
const base64Buffer = readStringParam(params, "buffer");
|
||||
if (!base64Buffer) {
|
||||
throw new Error(`iMessage ${action} requires buffer (base64) parameter.`);
|
||||
}
|
||||
return Uint8Array.from(Buffer.from(base64Buffer, "base64"));
|
||||
}
|
||||
|
||||
// Whitelist of expressive-send effect IDs the bridge accepts. Restricting
|
||||
// to a fixed set lets us return a clear error for typos ("invisible_ink"
|
||||
// vs "invisibleink") instead of silently forwarding gibberish to the
|
||||
// bridge and surfacing an opaque CLI failure.
|
||||
const KNOWN_EFFECT_IDS: ReadonlySet<string> = new Set([
|
||||
"com.apple.MobileSMS.expressivesend.impact",
|
||||
"com.apple.MobileSMS.expressivesend.loud",
|
||||
"com.apple.MobileSMS.expressivesend.gentle",
|
||||
"com.apple.MobileSMS.expressivesend.invisibleink",
|
||||
"com.apple.MobileSMS.expressivesend.confetti",
|
||||
"com.apple.MobileSMS.expressivesend.lasers",
|
||||
"com.apple.MobileSMS.expressivesend.fireworks",
|
||||
"com.apple.MobileSMS.expressivesend.balloon",
|
||||
"com.apple.MobileSMS.expressivesend.heart",
|
||||
"com.apple.messages.effect.CKEchoEffect",
|
||||
"com.apple.messages.effect.CKHappyBirthdayEffect",
|
||||
"com.apple.messages.effect.CKShootingStarEffect",
|
||||
"com.apple.messages.effect.CKSparklesEffect",
|
||||
"com.apple.messages.effect.CKSpotlightEffect",
|
||||
]);
|
||||
|
||||
function effectIdFromParam(raw?: string): string | undefined {
|
||||
const value = normalizeOptionalLowercaseString(raw);
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const aliases: Record<string, string> = {
|
||||
slam: "com.apple.MobileSMS.expressivesend.impact",
|
||||
impact: "com.apple.MobileSMS.expressivesend.impact",
|
||||
loud: "com.apple.MobileSMS.expressivesend.loud",
|
||||
gentle: "com.apple.MobileSMS.expressivesend.gentle",
|
||||
"invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink",
|
||||
invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink",
|
||||
confetti: "com.apple.MobileSMS.expressivesend.confetti",
|
||||
lasers: "com.apple.MobileSMS.expressivesend.lasers",
|
||||
fireworks: "com.apple.MobileSMS.expressivesend.fireworks",
|
||||
balloons: "com.apple.MobileSMS.expressivesend.balloon",
|
||||
balloon: "com.apple.MobileSMS.expressivesend.balloon",
|
||||
heart: "com.apple.MobileSMS.expressivesend.heart",
|
||||
// Background screen effects (com.apple.messages.effect.CK*Effect).
|
||||
// The error message below advertises these short names, so they must
|
||||
// map to the canonical CKEffect identifier — without this, agents
|
||||
// that follow our own guidance get "unknown effect" thrown back.
|
||||
echo: "com.apple.messages.effect.CKEchoEffect",
|
||||
happybirthday: "com.apple.messages.effect.CKHappyBirthdayEffect",
|
||||
"happy-birthday": "com.apple.messages.effect.CKHappyBirthdayEffect",
|
||||
shootingstar: "com.apple.messages.effect.CKShootingStarEffect",
|
||||
"shooting-star": "com.apple.messages.effect.CKShootingStarEffect",
|
||||
sparkles: "com.apple.messages.effect.CKSparklesEffect",
|
||||
spotlight: "com.apple.messages.effect.CKSpotlightEffect",
|
||||
};
|
||||
const resolved = aliases[value] ?? raw;
|
||||
if (typeof resolved === "string" && KNOWN_EFFECT_IDS.has(resolved)) {
|
||||
return resolved;
|
||||
}
|
||||
throw new Error(
|
||||
`iMessage sendWithEffect rejected unknown effect "${raw}". ` +
|
||||
"Use one of: slam, loud, gentle, invisibleink, confetti, lasers, fireworks, balloon, heart, " +
|
||||
"echo, happybirthday, shootingstar, sparkles, spotlight (or the canonical com.apple.MobileSMS.expressivesend.* / com.apple.messages.effect.* identifier).",
|
||||
);
|
||||
}
|
||||
|
||||
export const imessageMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: ({ cfg, accountId, currentChannelId }) => {
|
||||
const account = resolveIMessageAccount({ cfg, accountId });
|
||||
if (!account.enabled || !account.configured) {
|
||||
return null;
|
||||
}
|
||||
const privateApiStatus = getCachedIMessagePrivateApiStatus(
|
||||
account.config.cliPath?.trim() || "imsg",
|
||||
);
|
||||
const gate = createActionGate(account.config.actions);
|
||||
const actions = new Set<ChannelMessageActionName>();
|
||||
for (const action of IMESSAGE_ACTION_NAMES) {
|
||||
const spec = IMESSAGE_ACTIONS[action];
|
||||
if (!spec?.gate || !gate(spec.gate)) {
|
||||
continue;
|
||||
}
|
||||
if (privateApiStatus?.available === false && PRIVATE_API_ACTIONS.has(action)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
action === "edit" &&
|
||||
privateApiStatus?.selectors &&
|
||||
!privateApiStatus.selectors.editMessage &&
|
||||
!privateApiStatus.selectors.editMessageItem
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (action === "unsend" && privateApiStatus?.selectors?.retractMessagePart !== true) {
|
||||
continue;
|
||||
}
|
||||
actions.add(action);
|
||||
}
|
||||
if (!isGroupTarget(currentChannelId)) {
|
||||
for (const action of IMESSAGE_ACTION_NAMES) {
|
||||
if ("groupOnly" in IMESSAGE_ACTIONS[action] && IMESSAGE_ACTIONS[action].groupOnly) {
|
||||
actions.delete(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (actions.delete("sendAttachment")) {
|
||||
actions.add("upload-file");
|
||||
}
|
||||
return { actions: Array.from(actions) };
|
||||
},
|
||||
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
||||
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||
const runtime = await loadIMessageActionsRuntime();
|
||||
const account = resolveIMessageAccount({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
const cliPathForProbe = account.config.cliPath?.trim() || "imsg";
|
||||
let privateApiStatus = getCachedIMessagePrivateApiStatus(cliPathForProbe);
|
||||
const assertPrivateApiEnabled = async () => {
|
||||
if (privateApiStatus?.available !== true) {
|
||||
// Probe lazily: the running gateway only populates the cache via the
|
||||
// status adapter, which doesn't fire eagerly on first dispatch. Run
|
||||
// an inline probe so the first react/send-rich attempt after `imsg
|
||||
// launch` succeeds without requiring a manual `channels status`.
|
||||
const { probeIMessagePrivateApi } = await import("./probe.js");
|
||||
privateApiStatus = await probeIMessagePrivateApi(
|
||||
cliPathForProbe,
|
||||
account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
if (!privateApiStatus?.available) {
|
||||
throw new Error(
|
||||
`iMessage ${action} requires the imsg private API bridge. Run imsg launch, then openclaw channels status to refresh capability detection.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const opts = {
|
||||
cliPath: account.config.cliPath?.trim() || "imsg",
|
||||
dbPath: account.config.dbPath?.trim() || undefined,
|
||||
timeoutMs: account.config.probeTimeoutMs,
|
||||
chatGuid: "",
|
||||
};
|
||||
const chatGuid = async () =>
|
||||
await resolveChatGuid({
|
||||
action,
|
||||
actionParams: params,
|
||||
currentChannelId: toolContext?.currentChannelId,
|
||||
runtime,
|
||||
options: opts,
|
||||
});
|
||||
const messageId = (resolveOpts?: { requireFromMe?: boolean }) => {
|
||||
const chatContext = buildChatContextFromActionParams({
|
||||
actionParams: params,
|
||||
currentChannelId: toolContext?.currentChannelId,
|
||||
});
|
||||
const fallbackContext = { ...chatContext, accountId: account.accountId };
|
||||
return runtime.resolveIMessageMessageId(
|
||||
readMessageIdWithChatFallback(params, fallbackContext),
|
||||
{
|
||||
requireKnownShortId: true,
|
||||
chatContext,
|
||||
...(resolveOpts?.requireFromMe ? { requireFromMe: true } : {}),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (action === "react") {
|
||||
await assertPrivateApiEnabled();
|
||||
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||
removeErrorMessage: "Emoji is required to remove an iMessage reaction.",
|
||||
});
|
||||
const reaction = mapTapbackReaction(emoji);
|
||||
const TAPBACK_KINDS = ["love", "like", "dislike", "laugh", "emphasize", "question"] as const;
|
||||
// For add operations we need a recognized tapback kind. For remove
|
||||
// operations, the agent may not remember which kind it added — when
|
||||
// the emoji is empty or unrecognized but `remove: true`, fan out a
|
||||
// remove against every known kind. The bridge no-ops kinds that
|
||||
// weren't there, so this is safe and matches user intent ("undo my
|
||||
// reaction, whatever it was").
|
||||
if (!remove && (isEmpty || !reaction)) {
|
||||
throw new Error(
|
||||
"iMessage react supports love, like, dislike, laugh, emphasize, and question tapbacks.",
|
||||
);
|
||||
}
|
||||
const resolvedMessageId = messageId();
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
const resolvedChatGuid = await chatGuid();
|
||||
const reactionsToSend = remove && !reaction ? [...TAPBACK_KINDS] : reaction ? [reaction] : [];
|
||||
for (const kind of reactionsToSend) {
|
||||
await runtime.sendReaction({
|
||||
chatGuid: resolvedChatGuid,
|
||||
messageId: resolvedMessageId,
|
||||
reaction: kind,
|
||||
remove: remove || undefined,
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
options: { ...opts, chatGuid: resolvedChatGuid },
|
||||
});
|
||||
}
|
||||
return jsonResult({ ok: true, ...(remove ? { removed: true } : { added: reaction }) });
|
||||
}
|
||||
|
||||
if (action === "edit") {
|
||||
await assertPrivateApiEnabled();
|
||||
const resolvedMessageId = messageId({ requireFromMe: true });
|
||||
const text =
|
||||
readStringParam(params, "text") ??
|
||||
readStringParam(params, "newText") ??
|
||||
readStringParam(params, "message");
|
||||
if (!text) {
|
||||
throw new Error("iMessage edit requires text, newText, or message.");
|
||||
}
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
||||
const resolvedChatGuid = await chatGuid();
|
||||
await runtime.editMessage({
|
||||
chatGuid: resolvedChatGuid,
|
||||
messageId: resolvedMessageId,
|
||||
text,
|
||||
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
options: { ...opts, chatGuid: resolvedChatGuid },
|
||||
});
|
||||
return jsonResult({ ok: true, edited: resolvedMessageId });
|
||||
}
|
||||
|
||||
if (action === "unsend") {
|
||||
await assertPrivateApiEnabled();
|
||||
const resolvedMessageId = messageId({ requireFromMe: true });
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
const resolvedChatGuid = await chatGuid();
|
||||
await runtime.unsendMessage({
|
||||
chatGuid: resolvedChatGuid,
|
||||
messageId: resolvedMessageId,
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
options: { ...opts, chatGuid: resolvedChatGuid },
|
||||
});
|
||||
return jsonResult({ ok: true, unsent: resolvedMessageId });
|
||||
}
|
||||
|
||||
if (action === "reply") {
|
||||
await assertPrivateApiEnabled();
|
||||
const resolvedMessageId = messageId();
|
||||
const text = readMessageText(params);
|
||||
if (!text) {
|
||||
throw new Error("iMessage reply requires text or message.");
|
||||
}
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
const resolvedChatGuid = await chatGuid();
|
||||
const result = await runtime.sendRichMessage({
|
||||
chatGuid: resolvedChatGuid,
|
||||
text,
|
||||
replyToMessageId: resolvedMessageId,
|
||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||
options: { ...opts, chatGuid: resolvedChatGuid },
|
||||
});
|
||||
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: resolvedMessageId });
|
||||
}
|
||||
|
||||
if (action === "sendWithEffect") {
|
||||
await assertPrivateApiEnabled();
|
||||
const text = readMessageText(params);
|
||||
const effectId = effectIdFromParam(
|
||||
readStringParam(params, "effectId") ?? readStringParam(params, "effect"),
|
||||
);
|
||||
if (!text || !effectId) {
|
||||
throw new Error("iMessage sendWithEffect requires text/message and effect/effectId.");
|
||||
}
|
||||
const resolvedChatGuid = await chatGuid();
|
||||
const result = await runtime.sendRichMessage({
|
||||
chatGuid: resolvedChatGuid,
|
||||
text,
|
||||
effectId,
|
||||
options: { ...opts, chatGuid: resolvedChatGuid },
|
||||
});
|
||||
return jsonResult({ ok: true, messageId: result.messageId, effect: effectId });
|
||||
}
|
||||
|
||||
if (action === "renameGroup") {
|
||||
await assertPrivateApiEnabled();
|
||||
const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
|
||||
if (!displayName) {
|
||||
throw new Error("iMessage renameGroup requires displayName or name.");
|
||||
}
|
||||
const resolvedChatGuid = await chatGuid();
|
||||
await runtime.renameGroup({
|
||||
chatGuid: resolvedChatGuid,
|
||||
displayName,
|
||||
options: { ...opts, chatGuid: resolvedChatGuid },
|
||||
});
|
||||
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
|
||||
}
|
||||
|
||||
if (action === "setGroupIcon") {
|
||||
await assertPrivateApiEnabled();
|
||||
const filename =
|
||||
readStringParam(params, "filename") ?? readStringParam(params, "name") ?? "icon.png";
|
||||
const resolvedChatGuid = await chatGuid();
|
||||
await runtime.setGroupIcon({
|
||||
chatGuid: resolvedChatGuid,
|
||||
buffer: decodeBase64Buffer(params, action),
|
||||
filename,
|
||||
options: { ...opts, chatGuid: resolvedChatGuid },
|
||||
});
|
||||
return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true });
|
||||
}
|
||||
|
||||
if (action === "addParticipant" || action === "removeParticipant") {
|
||||
await assertPrivateApiEnabled();
|
||||
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
|
||||
if (!address) {
|
||||
throw new Error(`iMessage ${action} requires address or participant.`);
|
||||
}
|
||||
const resolvedChatGuid = await chatGuid();
|
||||
if (action === "addParticipant") {
|
||||
await runtime.addParticipant({
|
||||
chatGuid: resolvedChatGuid,
|
||||
address,
|
||||
options: { ...opts, chatGuid: resolvedChatGuid },
|
||||
});
|
||||
return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid });
|
||||
}
|
||||
await runtime.removeParticipant({
|
||||
chatGuid: resolvedChatGuid,
|
||||
address,
|
||||
options: { ...opts, chatGuid: resolvedChatGuid },
|
||||
});
|
||||
return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
|
||||
}
|
||||
|
||||
if (action === "leaveGroup") {
|
||||
await assertPrivateApiEnabled();
|
||||
const resolvedChatGuid = await chatGuid();
|
||||
await runtime.leaveGroup({
|
||||
chatGuid: resolvedChatGuid,
|
||||
options: { ...opts, chatGuid: resolvedChatGuid },
|
||||
});
|
||||
return jsonResult({ ok: true, left: resolvedChatGuid });
|
||||
}
|
||||
|
||||
if (action === "sendAttachment" || action === "upload-file") {
|
||||
await assertPrivateApiEnabled();
|
||||
const filename = readStringParam(params, "filename", { required: true });
|
||||
const asVoice = readBooleanParam(params, "asVoice");
|
||||
const resolvedChatGuid = await chatGuid();
|
||||
const result = await runtime.sendAttachment({
|
||||
chatGuid: resolvedChatGuid,
|
||||
buffer: decodeBase64Buffer(params, action),
|
||||
filename,
|
||||
asVoice: asVoice ?? undefined,
|
||||
options: { ...opts, chatGuid: resolvedChatGuid },
|
||||
});
|
||||
return jsonResult({ ok: true, messageId: result.messageId });
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||
},
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
|
||||
import { imessageMessageActions } from "./actions.js";
|
||||
import {
|
||||
chunkTextForOutbound,
|
||||
collectStatusIssuesFromLastError,
|
||||
@@ -300,6 +301,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount, IMessageProb
|
||||
},
|
||||
},
|
||||
message: imessageMessageAdapter,
|
||||
actions: imessageMessageActions,
|
||||
},
|
||||
pairing: {
|
||||
text: {
|
||||
|
||||
183
extensions/imessage/src/chat.ts
Normal file
183
extensions/imessage/src/chat.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
|
||||
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
|
||||
import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js";
|
||||
|
||||
type ChatActionOpts = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
account?: ResolvedIMessageAccount;
|
||||
client?: IMessageRpcClient;
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
service?: IMessageService;
|
||||
region?: string;
|
||||
timeoutMs?: number;
|
||||
chatId?: number;
|
||||
};
|
||||
|
||||
function buildChatTargetParams(
|
||||
to: string,
|
||||
opts: ChatActionOpts,
|
||||
): {
|
||||
params: Record<string, unknown>;
|
||||
service?: IMessageService;
|
||||
region?: string;
|
||||
account: ResolvedIMessageAccount;
|
||||
} {
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "iMessage chat action");
|
||||
const account = opts.account ?? resolveIMessageAccount({ cfg, accountId: opts.accountId });
|
||||
const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to);
|
||||
const params: Record<string, unknown> = {};
|
||||
if (target.kind === "chat_id") {
|
||||
params.chat_id = target.chatId;
|
||||
} else if (target.kind === "chat_guid") {
|
||||
params.chat_guid = target.chatGuid;
|
||||
} else if (target.kind === "chat_identifier") {
|
||||
params.chat_identifier = target.chatIdentifier;
|
||||
} else {
|
||||
params.to = target.to;
|
||||
}
|
||||
const service =
|
||||
opts.service ??
|
||||
(target.kind === "handle" ? target.service : undefined) ??
|
||||
(account.config.service as IMessageService | undefined);
|
||||
const region = opts.region?.trim() || account.config.region?.trim() || "US";
|
||||
return { params, service, region, account };
|
||||
}
|
||||
|
||||
async function runChatAction<T>(
|
||||
method:
|
||||
| "typing"
|
||||
| "read"
|
||||
| "chats.create"
|
||||
| "chats.delete"
|
||||
| "chats.markUnread"
|
||||
| "group.rename"
|
||||
| "group.setIcon"
|
||||
| "group.addParticipant"
|
||||
| "group.removeParticipant"
|
||||
| "group.leave",
|
||||
params: Record<string, unknown>,
|
||||
opts: ChatActionOpts,
|
||||
): Promise<T> {
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "iMessage chat action");
|
||||
const account = opts.account ?? resolveIMessageAccount({ cfg, accountId: opts.accountId });
|
||||
const cliPath = opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg";
|
||||
const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim();
|
||||
const client = opts.client ?? (await createIMessageRpcClient({ cliPath, dbPath }));
|
||||
const shouldClose = !opts.client;
|
||||
try {
|
||||
return await client.request<T>(method, params, { timeoutMs: opts.timeoutMs });
|
||||
} finally {
|
||||
if (shouldClose) {
|
||||
await client.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendIMessageTyping(
|
||||
to: string,
|
||||
isTyping: boolean,
|
||||
opts: ChatActionOpts,
|
||||
): Promise<void> {
|
||||
const { params, service } = buildChatTargetParams(to, opts);
|
||||
params.typing = isTyping;
|
||||
if (service) {
|
||||
params.service = service;
|
||||
}
|
||||
await runChatAction<{ ok?: boolean }>("typing", params, opts);
|
||||
}
|
||||
|
||||
export async function markIMessageChatRead(to: string, opts: ChatActionOpts): Promise<void> {
|
||||
const { params } = buildChatTargetParams(to, opts);
|
||||
await runChatAction<{ ok?: boolean }>("read", params, opts);
|
||||
}
|
||||
|
||||
export async function markIMessageChatUnread(to: string, opts: ChatActionOpts): Promise<void> {
|
||||
const { params } = buildChatTargetParams(to, opts);
|
||||
await runChatAction<{ ok?: boolean }>("chats.markUnread", params, opts);
|
||||
}
|
||||
|
||||
export async function createIMessageChat(
|
||||
params: {
|
||||
addresses: string[];
|
||||
name?: string;
|
||||
text?: string;
|
||||
service?: "iMessage" | "SMS";
|
||||
},
|
||||
opts: Omit<ChatActionOpts, "chatId">,
|
||||
): Promise<{ chatGuid?: string }> {
|
||||
if (!params.addresses.length) {
|
||||
throw new Error("createIMessageChat requires at least one address");
|
||||
}
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
addresses: params.addresses,
|
||||
service: params.service ?? "iMessage",
|
||||
};
|
||||
if (params.name) {
|
||||
rpcParams.name = params.name;
|
||||
}
|
||||
if (params.text) {
|
||||
rpcParams.text = params.text;
|
||||
}
|
||||
const result = await runChatAction<{ ok?: boolean; chat_guid?: string }>(
|
||||
"chats.create",
|
||||
rpcParams,
|
||||
opts,
|
||||
);
|
||||
return { chatGuid: result.chat_guid };
|
||||
}
|
||||
|
||||
export async function deleteIMessageChat(to: string, opts: ChatActionOpts): Promise<void> {
|
||||
const { params } = buildChatTargetParams(to, opts);
|
||||
await runChatAction<{ ok?: boolean }>("chats.delete", params, opts);
|
||||
}
|
||||
|
||||
export async function renameIMessageGroup(
|
||||
to: string,
|
||||
name: string,
|
||||
opts: ChatActionOpts,
|
||||
): Promise<void> {
|
||||
const { params } = buildChatTargetParams(to, opts);
|
||||
params.name = name;
|
||||
await runChatAction<{ ok?: boolean }>("group.rename", params, opts);
|
||||
}
|
||||
|
||||
export async function setIMessageGroupIcon(
|
||||
to: string,
|
||||
filePath: string | undefined,
|
||||
opts: ChatActionOpts,
|
||||
): Promise<void> {
|
||||
const { params } = buildChatTargetParams(to, opts);
|
||||
if (filePath) {
|
||||
params.file = filePath;
|
||||
}
|
||||
await runChatAction<{ ok?: boolean }>("group.setIcon", params, opts);
|
||||
}
|
||||
|
||||
export async function addIMessageGroupParticipant(
|
||||
to: string,
|
||||
address: string,
|
||||
opts: ChatActionOpts,
|
||||
): Promise<void> {
|
||||
const { params } = buildChatTargetParams(to, opts);
|
||||
params.address = address;
|
||||
await runChatAction<{ ok?: boolean }>("group.addParticipant", params, opts);
|
||||
}
|
||||
|
||||
export async function removeIMessageGroupParticipant(
|
||||
to: string,
|
||||
address: string,
|
||||
opts: ChatActionOpts,
|
||||
): Promise<void> {
|
||||
const { params } = buildChatTargetParams(to, opts);
|
||||
params.address = address;
|
||||
await runChatAction<{ ok?: boolean }>("group.removeParticipant", params, opts);
|
||||
}
|
||||
|
||||
export async function leaveIMessageGroup(to: string, opts: ChatActionOpts): Promise<void> {
|
||||
const { params } = buildChatTargetParams(to, opts);
|
||||
await runChatAction<{ ok?: boolean }>("group.leave", params, opts);
|
||||
}
|
||||
@@ -71,6 +71,27 @@ describe("imessage config schema", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts private API action gates", () => {
|
||||
const res = IMessageConfigSchema.safeParse({
|
||||
cliPath: "imsg",
|
||||
actions: {
|
||||
reactions: false,
|
||||
edit: true,
|
||||
sendAttachment: true,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
actions: {
|
||||
reply: false,
|
||||
sendWithEffect: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts safe remoteHost", () => {
|
||||
const res = IMessageConfigSchema.safeParse({
|
||||
remoteHost: "bot@gateway-host",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
ChannelOutboundAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-send-deps";
|
||||
import { collectStatusIssuesFromLastError } from "openclaw/plugin-sdk/status-helpers";
|
||||
@@ -90,8 +94,42 @@ const defaultIMessageOutbound: ChannelOutboundAdapter = {
|
||||
},
|
||||
};
|
||||
|
||||
const defaultIMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: () => ({
|
||||
actions: [
|
||||
"react",
|
||||
"edit",
|
||||
"unsend",
|
||||
"reply",
|
||||
"sendWithEffect",
|
||||
"upload-file",
|
||||
"renameGroup",
|
||||
"setGroupIcon",
|
||||
"addParticipant",
|
||||
"removeParticipant",
|
||||
"leaveGroup",
|
||||
],
|
||||
}),
|
||||
supportsAction: ({ action }) =>
|
||||
new Set<ChannelMessageActionName>([
|
||||
"react",
|
||||
"edit",
|
||||
"unsend",
|
||||
"reply",
|
||||
"sendWithEffect",
|
||||
"upload-file",
|
||||
"sendAttachment",
|
||||
"renameGroup",
|
||||
"setGroupIcon",
|
||||
"addParticipant",
|
||||
"removeParticipant",
|
||||
"leaveGroup",
|
||||
]).has(action),
|
||||
};
|
||||
|
||||
export const createIMessageTestPlugin = (params?: {
|
||||
outbound?: ChannelOutboundAdapter;
|
||||
actions?: ChannelMessageActionAdapter;
|
||||
}): ChannelPlugin => ({
|
||||
id: "imessage",
|
||||
meta: {
|
||||
@@ -110,6 +148,7 @@ export const createIMessageTestPlugin = (params?: {
|
||||
status: {
|
||||
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts),
|
||||
},
|
||||
actions: params?.actions ?? defaultIMessageActions,
|
||||
outbound: params?.outbound ?? defaultIMessageOutbound,
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
|
||||
99
extensions/imessage/src/markdown-format.test.ts
Normal file
99
extensions/imessage/src/markdown-format.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractMarkdownFormatRuns } from "./markdown-format.js";
|
||||
|
||||
describe("extractMarkdownFormatRuns", () => {
|
||||
it("returns the text unchanged when there is no markdown", () => {
|
||||
const { text, ranges } = extractMarkdownFormatRuns("plain text reply");
|
||||
expect(text).toBe("plain text reply");
|
||||
expect(ranges).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts a bold span", () => {
|
||||
const { text, ranges } = extractMarkdownFormatRuns("**bold** text");
|
||||
expect(text).toBe("bold text");
|
||||
expect(ranges).toEqual([{ start: 0, length: 4, styles: ["bold"] }]);
|
||||
});
|
||||
|
||||
it("extracts mixed bold and italic", () => {
|
||||
const { text, ranges } = extractMarkdownFormatRuns("**hi** and *there*");
|
||||
expect(text).toBe("hi and there");
|
||||
expect(ranges).toEqual([
|
||||
{ start: 0, length: 2, styles: ["bold"] },
|
||||
{ start: 7, length: 5, styles: ["italic"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts underline and strikethrough", () => {
|
||||
const { text, ranges } = extractMarkdownFormatRuns("__under__ and ~~strike~~");
|
||||
expect(text).toBe("under and strike");
|
||||
expect(ranges).toEqual([
|
||||
{ start: 0, length: 5, styles: ["underline"] },
|
||||
{ start: 10, length: 6, styles: ["strikethrough"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("respects word boundaries on single-underscore italics", () => {
|
||||
const { text, ranges } = extractMarkdownFormatRuns("snake_case_var ok");
|
||||
expect(text).toBe("snake_case_var ok");
|
||||
expect(ranges).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats single-underscore as italic when surrounded by whitespace", () => {
|
||||
const { text, ranges } = extractMarkdownFormatRuns("a _word_ b");
|
||||
expect(text).toBe("a word b");
|
||||
expect(ranges).toEqual([{ start: 2, length: 4, styles: ["italic"] }]);
|
||||
});
|
||||
|
||||
it("does not treat empty marker pairs as formatting", () => {
|
||||
const { text, ranges } = extractMarkdownFormatRuns("** ** literal");
|
||||
expect(text).toBe("** ** literal");
|
||||
expect(ranges).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves a lone asterisk alone", () => {
|
||||
const { text, ranges } = extractMarkdownFormatRuns("price * quantity");
|
||||
expect(text).toBe("price * quantity");
|
||||
expect(ranges).toEqual([]);
|
||||
});
|
||||
|
||||
it("computes ranges in output coordinates, not input", () => {
|
||||
const { text, ranges } = extractMarkdownFormatRuns("a **b** c **d** e");
|
||||
expect(text).toBe("a b c d e");
|
||||
expect(ranges).toEqual([
|
||||
{ start: 2, length: 1, styles: ["bold"] },
|
||||
{ start: 6, length: 1, styles: ["bold"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses ***triple-marker*** as bold + italic over the same span", () => {
|
||||
const { text, ranges } = extractMarkdownFormatRuns("***hi***");
|
||||
expect(text).toBe("hi");
|
||||
// Compound marker emits both styles over the same span.
|
||||
expect(ranges).toEqual([
|
||||
{ start: 0, length: 2, styles: ["bold"] },
|
||||
{ start: 0, length: 2, styles: ["italic"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses **bold _and underline_ together** as nested ranges", () => {
|
||||
const { text, ranges } = extractMarkdownFormatRuns("**bold _and underline_ together**");
|
||||
expect(text).toBe("bold and underline together");
|
||||
// Inner italic-via-_ at offset 5, length 13; outer bold over the full span.
|
||||
expect(ranges).toEqual([
|
||||
{ start: 5, length: 13, styles: ["italic"] },
|
||||
{ start: 0, length: 27, styles: ["bold"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("respects word boundaries on double-underscore underline", () => {
|
||||
const { text, ranges } = extractMarkdownFormatRuns("def __init__(self):");
|
||||
expect(text).toBe("def __init__(self):");
|
||||
expect(ranges).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not leak literal asterisks from triple markers when intent is unclear", () => {
|
||||
// `***bold***` should never produce a bare `*` in the output text.
|
||||
const { text } = extractMarkdownFormatRuns("hello ***world***");
|
||||
expect(text).not.toMatch(/\*/);
|
||||
});
|
||||
});
|
||||
154
extensions/imessage/src/markdown-format.ts
Normal file
154
extensions/imessage/src/markdown-format.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Convert markdown bold/italic/underline/strikethrough markers in agent text
|
||||
* into typed-run formatting ranges that the imsg bridge's `sendMessage`
|
||||
* action understands. Returns the marker-stripped text plus an array of
|
||||
* ranges keyed by their start in the OUTPUT string.
|
||||
*
|
||||
* macOS 15+ recipients render typed runs natively; macOS 14 falls back to
|
||||
* client-side markdown rendering, so passing both raw markdown and ranges
|
||||
* would double up — callers should send the stripped `text` only.
|
||||
*
|
||||
* Supported markers:
|
||||
* - `**bold**`
|
||||
* - `*italic*` / `_italic_` (single-underscore enforces word boundaries)
|
||||
* - `__underline__` (double-underscore also enforces word boundaries so
|
||||
* Python identifiers like `__init__` are not mangled)
|
||||
* - `~~strikethrough~~`
|
||||
*
|
||||
* Nesting:
|
||||
* - `***bold-italic***` is parsed as `**` containing `*italic*`, yielding
|
||||
* two ranges over the same span (one bold, one italic).
|
||||
* - Other nested combinations (`**bold _underline_**`, etc.) are
|
||||
* similarly parsed by recursing into the inner text of every marker
|
||||
* pair we consume.
|
||||
*
|
||||
* Out of scope: escaped markers (`\*literal\*`), code spans (` `code` `),
|
||||
* and combining-character edge cases. The receiver's iMessage style
|
||||
* vocabulary covers only bold/italic/underline/strikethrough — there is
|
||||
* nowhere to render anything fancier, and over-eager parsing would mangle
|
||||
* plain-text emoji/punctuation that happens to look like markdown.
|
||||
*/
|
||||
|
||||
export type IMessageFormatStyle = "bold" | "italic" | "underline" | "strikethrough";
|
||||
|
||||
export type IMessageFormatRange = {
|
||||
start: number;
|
||||
length: number;
|
||||
styles: IMessageFormatStyle[];
|
||||
};
|
||||
|
||||
type Marker = {
|
||||
marker: string;
|
||||
styles: IMessageFormatStyle[];
|
||||
/**
|
||||
* When true, the marker only counts when both ends sit on a word
|
||||
* boundary. Single-underscore italics need this so `snake_case_var` is
|
||||
* left literal, and double-underscore underline needs it so Python
|
||||
* dunder names like `__init__` are not turned into underline.
|
||||
*/
|
||||
requireWordBoundary: boolean;
|
||||
};
|
||||
|
||||
// Order matters: longer/compound markers are tried first.
|
||||
// - `***...***` is bold+italic over the inner span.
|
||||
// - `___...___` is underline+italic.
|
||||
// - `~~`, `**`, `__` cover their own styles.
|
||||
// - `*` / `_` italic match last (with `_` enforcing word boundaries).
|
||||
const MARKERS: readonly Marker[] = [
|
||||
{ marker: "***", styles: ["bold", "italic"], requireWordBoundary: false },
|
||||
{ marker: "___", styles: ["underline", "italic"], requireWordBoundary: true },
|
||||
{ marker: "~~", styles: ["strikethrough"], requireWordBoundary: false },
|
||||
{ marker: "**", styles: ["bold"], requireWordBoundary: false },
|
||||
{ marker: "__", styles: ["underline"], requireWordBoundary: true },
|
||||
{ marker: "*", styles: ["italic"], requireWordBoundary: false },
|
||||
{ marker: "_", styles: ["italic"], requireWordBoundary: true },
|
||||
];
|
||||
|
||||
function tryConsumeMarker(
|
||||
input: string,
|
||||
i: number,
|
||||
m: Marker,
|
||||
): { close: number; inner: string } | null {
|
||||
if (!input.startsWith(m.marker, i)) {
|
||||
return null;
|
||||
}
|
||||
// For single-char markers, reject when the next char is the same so we
|
||||
// don't consume the leading half of a longer marker (e.g. `*` matching
|
||||
// the first asterisk of `**bold**`).
|
||||
if (m.marker.length === 1 && input[i + 1] === m.marker) {
|
||||
return null;
|
||||
}
|
||||
// For 2-char markers, reject when there's a third repeat — that's the
|
||||
// longer compound marker (`***`, `___`) which should match first.
|
||||
if (m.marker.length === 2 && input[i + 2] === m.marker[0]) {
|
||||
return null;
|
||||
}
|
||||
// For underscore markers we use a stricter rule than CommonMark: the
|
||||
// OUTSIDE of each marker must be whitespace, start-of-string, or
|
||||
// end-of-string. That keeps `def __init__(self)` literal (`(` after the
|
||||
// close is neither whitespace nor end-of-string) while `__under__ and`
|
||||
// still parses cleanly. Asterisk markers don't need this because they
|
||||
// don't appear inside identifiers.
|
||||
const isAtBoundary = (ch: string | undefined): boolean => ch === undefined || /\s/.test(ch);
|
||||
if (m.requireWordBoundary && i > 0 && !isAtBoundary(input[i - 1])) {
|
||||
return null;
|
||||
}
|
||||
const startInner = i + m.marker.length;
|
||||
const close = input.indexOf(m.marker, startInner);
|
||||
if (close === -1 || close === startInner) {
|
||||
return null;
|
||||
}
|
||||
if (m.requireWordBoundary && !isAtBoundary(input[close + m.marker.length])) {
|
||||
return null;
|
||||
}
|
||||
const inner = input.slice(startInner, close);
|
||||
if (!inner.trim()) {
|
||||
return null;
|
||||
}
|
||||
return { close, inner };
|
||||
}
|
||||
|
||||
function parseInternal(input: string, baseOffset: number, sink: IMessageFormatRange[]): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
while (i < input.length) {
|
||||
let consumed = false;
|
||||
for (const m of MARKERS) {
|
||||
const hit = tryConsumeMarker(input, i, m);
|
||||
if (!hit) {
|
||||
continue;
|
||||
}
|
||||
// Recurse on the inner span so nested markers compose. The inner
|
||||
// ranges are emitted with offsets relative to the new base.
|
||||
const innerOffset = baseOffset + out.length;
|
||||
const innerStripped = parseInternal(hit.inner, innerOffset, sink);
|
||||
// Compound markers (`***`, `___`) emit multiple styles over the same
|
||||
// span — push them in order so callers see e.g. italic before bold.
|
||||
for (const style of m.styles) {
|
||||
sink.push({
|
||||
start: innerOffset,
|
||||
length: innerStripped.length,
|
||||
styles: [style],
|
||||
});
|
||||
}
|
||||
out += innerStripped;
|
||||
i = hit.close + m.marker.length;
|
||||
consumed = true;
|
||||
break;
|
||||
}
|
||||
if (!consumed) {
|
||||
out += input[i];
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function extractMarkdownFormatRuns(input: string): {
|
||||
text: string;
|
||||
ranges: IMessageFormatRange[];
|
||||
} {
|
||||
const ranges: IMessageFormatRange[] = [];
|
||||
const text = parseInternal(input, 0, ranges);
|
||||
return { text, ranges };
|
||||
}
|
||||
406
extensions/imessage/src/monitor-reply-cache.test.ts
Normal file
406
extensions/imessage/src/monitor-reply-cache.test.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
_resetIMessageShortIdState,
|
||||
findLatestIMessageEntryForChat,
|
||||
rememberIMessageReplyCache,
|
||||
resolveIMessageMessageId,
|
||||
} from "./monitor-reply-cache.js";
|
||||
|
||||
// Isolate from any live ~/.openclaw/imessage/reply-cache.jsonl that the
|
||||
// developer might have from a running gateway. Without this, the on-disk
|
||||
// hydrate path picks up production data and tests get cross-pollinated.
|
||||
//
|
||||
// vi.stubEnv defaults to per-test scoping in this codebase, which means a
|
||||
// beforeAll-only stub gets unstubbed between tests. Mutate process.env
|
||||
// directly so the override holds across the whole file.
|
||||
let tempStateDir: string;
|
||||
let priorStateDir: string | undefined;
|
||||
beforeAll(() => {
|
||||
tempStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-reply-cache-"));
|
||||
priorStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = tempStateDir;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (priorStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = priorStateDir;
|
||||
}
|
||||
fs.rmSync(tempStateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
_resetIMessageShortIdState();
|
||||
// Belt-and-suspenders: also nuke the persisted file directly. The
|
||||
// _reset helper does this when OPENCLAW_STATE_DIR is set, but explicitly
|
||||
// clearing here protects the test from any future refactor of _reset's
|
||||
// gating logic.
|
||||
try {
|
||||
fs.rmSync(path.join(tempStateDir, "imessage", "reply-cache.jsonl"), { force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
});
|
||||
|
||||
describe("imessage short message id resolution", () => {
|
||||
it("resolves a short id to a cached message guid", () => {
|
||||
const entry = rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "full-guid",
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(entry.shortId).toBe("1");
|
||||
expect(
|
||||
resolveIMessageMessageId("1", {
|
||||
requireKnownShortId: true,
|
||||
chatContext: { chatGuid: "iMessage;+;chat0000" },
|
||||
}),
|
||||
).toBe("full-guid");
|
||||
});
|
||||
|
||||
it("resolves a known short id even without caller-supplied chat scope", () => {
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "full-guid",
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// The cached entry already carries chat info; cross-chat checks only
|
||||
// matter when the caller separately provides a (potentially conflicting)
|
||||
// chat scope. A plain known short id from the cache must resolve.
|
||||
expect(resolveIMessageMessageId("1", { requireKnownShortId: true })).toBe("full-guid");
|
||||
});
|
||||
|
||||
it("requires chat scope when a privileged short id is unknown", () => {
|
||||
expect(() => resolveIMessageMessageId("9999", { requireKnownShortId: true })).toThrow(
|
||||
"requires a chat scope",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects short ids from another chat", () => {
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "full-guid",
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
resolveIMessageMessageId("1", {
|
||||
requireKnownShortId: true,
|
||||
chatContext: { chatGuid: "iMessage;+;other" },
|
||||
}),
|
||||
).toThrow("belongs to a different chat");
|
||||
});
|
||||
|
||||
it("guards full guid reuse across chats when cached", () => {
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "full-guid",
|
||||
chatId: 42,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(() => resolveIMessageMessageId("full-guid", { chatContext: { chatId: 99 } })).toThrow(
|
||||
"belongs to a different chat",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireFromMe (edit / unsend authorization)", () => {
|
||||
it("rejects a short id resolution when the cached entry came from inbound", () => {
|
||||
// The default inbound recorder sets isFromMe:false (or omits it), so
|
||||
// resolving with requireFromMe must reject — agents cannot edit/unsend
|
||||
// messages that other participants sent.
|
||||
const entry = rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "inbound-guid",
|
||||
chatGuid: "iMessage;+;chatA",
|
||||
timestamp: Date.now(),
|
||||
isFromMe: false,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
resolveIMessageMessageId(entry.shortId, {
|
||||
requireKnownShortId: true,
|
||||
chatContext: { chatGuid: "iMessage;+;chatA" },
|
||||
requireFromMe: true,
|
||||
}),
|
||||
).toThrow("not one this agent sent");
|
||||
});
|
||||
|
||||
it("allows a short id resolution when the cached entry was sent by the gateway", () => {
|
||||
const entry = rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "outbound-guid",
|
||||
chatGuid: "iMessage;+;chatA",
|
||||
timestamp: Date.now(),
|
||||
isFromMe: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveIMessageMessageId(entry.shortId, {
|
||||
requireKnownShortId: true,
|
||||
chatContext: { chatGuid: "iMessage;+;chatA" },
|
||||
requireFromMe: true,
|
||||
}),
|
||||
).toBe("outbound-guid");
|
||||
});
|
||||
|
||||
it("rejects an uncached full guid under requireFromMe (agent cannot edit/unsend unknown messages)", () => {
|
||||
expect(() =>
|
||||
resolveIMessageMessageId("never-seen-guid", {
|
||||
chatContext: { chatGuid: "iMessage;+;chatA" },
|
||||
requireFromMe: true,
|
||||
}),
|
||||
).toThrow("not one this agent sent");
|
||||
});
|
||||
|
||||
it("rejects when the cached entry has no isFromMe field (older persisted entry, treated as not-from-me)", () => {
|
||||
// Persisted entries written before this option existed do not carry
|
||||
// isFromMe. Treat undefined as the safe default (false) — that pre-
|
||||
// existing-on-disk caller is the inbound recorder, the only writer that
|
||||
// existed before.
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "legacy-guid",
|
||||
chatGuid: "iMessage;+;chatA",
|
||||
timestamp: Date.now(),
|
||||
// isFromMe deliberately omitted
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
resolveIMessageMessageId("legacy-guid", {
|
||||
chatContext: { chatGuid: "iMessage;+;chatA" },
|
||||
requireFromMe: true,
|
||||
}),
|
||||
).toThrow("not one this agent sent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findLatestIMessageEntryForChat", () => {
|
||||
it("returns the latest entry for the matching chat scope", () => {
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "older",
|
||||
chatGuid: "any;-;+12069106512",
|
||||
chatIdentifier: "+12069106512",
|
||||
timestamp: Date.now() - 1000,
|
||||
});
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "newest",
|
||||
chatGuid: "any;-;+12069106512",
|
||||
chatIdentifier: "+12069106512",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const result = findLatestIMessageEntryForChat({
|
||||
accountId: "default",
|
||||
chatIdentifier: "iMessage;-;+12069106512",
|
||||
});
|
||||
expect(result?.messageId).toBe("newest");
|
||||
});
|
||||
|
||||
it("requires a positive identifier match — no overlap means no fallback", () => {
|
||||
// Cache entry has only chatGuid; caller has only chatId. With the old
|
||||
// isCrossChatMismatch-as-filter, this entry would have been returned
|
||||
// (no overlap → no mismatch → pass). The strict positive-match
|
||||
// semantics require both sides to share at least one identifier kind.
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "different-chat",
|
||||
chatGuid: "iMessage;+;chat0000",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(findLatestIMessageEntryForChat({ accountId: "default", chatId: 99 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never crosses account boundaries", () => {
|
||||
// Diagnostic: verify the temp-dir env stub is actually visible.
|
||||
expect(process.env.OPENCLAW_STATE_DIR).toBe(tempStateDir);
|
||||
const cachePath = path.join(tempStateDir, "imessage", "reply-cache.jsonl");
|
||||
expect(fs.existsSync(cachePath)).toBe(false);
|
||||
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "other-account",
|
||||
messageId: "foreign-account",
|
||||
chatIdentifier: "+12069106512",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(
|
||||
findLatestIMessageEntryForChat({
|
||||
accountId: "default",
|
||||
chatIdentifier: "+12069106512",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores entries older than the recency window", () => {
|
||||
const TWELVE_MINUTES_AGO = Date.now() - 12 * 60 * 1000;
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "stale",
|
||||
chatIdentifier: "+12069106512",
|
||||
timestamp: TWELVE_MINUTES_AGO,
|
||||
});
|
||||
|
||||
expect(
|
||||
findLatestIMessageEntryForChat({
|
||||
accountId: "default",
|
||||
chatIdentifier: "+12069106512",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches across chat-id-format flavors (iMessage;-;<phone>, any;-;<phone>, bare phone)", () => {
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "phone-msg",
|
||||
chatGuid: "any;-;+12069106512",
|
||||
chatIdentifier: "+12069106512",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
for (const ctx of [
|
||||
{ accountId: "default", chatIdentifier: "iMessage;-;+12069106512" },
|
||||
{ accountId: "default", chatIdentifier: "SMS;-;+12069106512" },
|
||||
{ accountId: "default", chatGuid: "any;-;+12069106512" },
|
||||
{ accountId: "default", chatIdentifier: "+12069106512" },
|
||||
]) {
|
||||
const found = findLatestIMessageEntryForChat(ctx);
|
||||
expect(found?.messageId).toBe("phone-msg");
|
||||
}
|
||||
});
|
||||
|
||||
it("requires accountId — refuses to guess across all known chats", () => {
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "anywhere",
|
||||
chatIdentifier: "+12069106512",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// accountId is optional in the signature; calling without it exercises the
|
||||
// runtime guard that returns undefined rather than a cross-account match.
|
||||
expect(findLatestIMessageEntryForChat({ chatIdentifier: "+12069106512" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reply cache disk permissions", () => {
|
||||
it("clamps pre-existing reply-cache.jsonl from older 0644/0755 to 0600/0700", () => {
|
||||
// Older gateway versions wrote with default modes. Every append must
|
||||
// clamp existing files back to owner-only — appendFileSync's `mode`
|
||||
// only applies on creation, so a chmod-on-create-only path would leave
|
||||
// the upgrade case world-readable forever.
|
||||
const imsgDir = path.join(tempStateDir, "imessage");
|
||||
fs.mkdirSync(imsgDir, { recursive: true, mode: 0o755 });
|
||||
const cacheFile = path.join(imsgDir, "reply-cache.jsonl");
|
||||
fs.writeFileSync(cacheFile, "", { mode: 0o644 });
|
||||
fs.chmodSync(imsgDir, 0o755);
|
||||
fs.chmodSync(cacheFile, 0o644);
|
||||
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "clamp-test-guid",
|
||||
chatIdentifier: "+12069106512",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const fileMode = fs.statSync(cacheFile).mode & 0o777;
|
||||
const dirMode = fs.statSync(imsgDir).mode & 0o777;
|
||||
expect(fileMode).toBe(0o600);
|
||||
expect(dirMode).toBe(0o700);
|
||||
});
|
||||
|
||||
it("writes the cache file 0600 and parent dir 0700", () => {
|
||||
// Map gateway-allocated short-ids to message guids; a hostile same-UID
|
||||
// process reading or writing this file could (a) enumerate active
|
||||
// conversation guids or (b) inject lines so a future shortId resolves
|
||||
// to an attacker-chosen guid. Owner-only mode is the mitigation.
|
||||
rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "perm-test-guid",
|
||||
chatIdentifier: "+12069106512",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const cacheFile = path.join(tempStateDir, "imessage", "reply-cache.jsonl");
|
||||
const cacheDir = path.dirname(cacheFile);
|
||||
expect(fs.existsSync(cacheFile)).toBe(true);
|
||||
|
||||
const fileMode = fs.statSync(cacheFile).mode & 0o777;
|
||||
const dirMode = fs.statSync(cacheDir).mode & 0o777;
|
||||
expect(fileMode).toBe(0o600);
|
||||
expect(dirMode).toBe(0o700);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrate-on-resolve (post-restart short-id persistence)", () => {
|
||||
it("hydrates the on-disk JSONL before resolving a short id whose mapping predates this run", () => {
|
||||
// Issue-then-restart contract: a shortId we issued before a gateway
|
||||
// restart must still resolve afterwards. The first resolve call after
|
||||
// process boot would otherwise miss the persisted mapping because the
|
||||
// in-memory maps haven't been hydrated yet — that's the bug codex
|
||||
// review flagged. resolveIMessageMessageId now hydrates on entry.
|
||||
const issued = rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "outbound-guid-pre-restart",
|
||||
chatGuid: "iMessage;+;chatA",
|
||||
timestamp: Date.now(),
|
||||
isFromMe: true,
|
||||
});
|
||||
expect(issued.shortId).not.toBe("");
|
||||
|
||||
// Simulate a restart: clear the in-memory state but leave the JSONL on
|
||||
// disk. _resetIMessageShortIdState only deletes the persisted file when
|
||||
// OPENCLAW_STATE_DIR is set, so we have to keep the file ourselves
|
||||
// since this test runs under the suite's temp state dir.
|
||||
const cachePath = path.join(tempStateDir, "imessage", "reply-cache.jsonl");
|
||||
const persisted = fs.readFileSync(cachePath, "utf8");
|
||||
_resetIMessageShortIdState();
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
fs.writeFileSync(cachePath, persisted, "utf8");
|
||||
|
||||
// Now resolve the short id we issued before the "restart". Without the
|
||||
// hydrate-on-resolve fix this throws "no longer available" because the
|
||||
// in-memory maps are empty and rememberIMessageReplyCache hasn't been
|
||||
// called yet to trigger hydration.
|
||||
expect(
|
||||
resolveIMessageMessageId(issued.shortId, {
|
||||
requireKnownShortId: true,
|
||||
chatContext: { chatGuid: "iMessage;+;chatA" },
|
||||
}),
|
||||
).toBe("outbound-guid-pre-restart");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrate counter advancement (rowid-collision protection)", () => {
|
||||
it("advances the short-id counter past a corrupt persisted line so new allocations don't collide", () => {
|
||||
// Direct hydrate isn't easy to invoke without disk fixtures; instead
|
||||
// verify the public contract: after rememberIMessageReplyCache fires,
|
||||
// the next allocation never re-uses an existing live shortId.
|
||||
const a = rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "msg-a",
|
||||
chatIdentifier: "+12069106512",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const b = rememberIMessageReplyCache({
|
||||
accountId: "default",
|
||||
messageId: "msg-b",
|
||||
chatIdentifier: "+12069106512",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(a.shortId).not.toBe(b.shortId);
|
||||
expect(Number.parseInt(b.shortId, 10)).toBeGreaterThan(Number.parseInt(a.shortId, 10));
|
||||
});
|
||||
});
|
||||
587
extensions/imessage/src/monitor-reply-cache.ts
Normal file
587
extensions/imessage/src/monitor-reply-cache.ts
Normal file
@@ -0,0 +1,587 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const REPLY_CACHE_MAX = 2000;
|
||||
const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
/** Recency window for the "react to the latest message" fallback. */
|
||||
const LATEST_FALLBACK_MS = 10 * 60 * 1000;
|
||||
let persistenceFailureLogged = false;
|
||||
let parseFailureLogged = false;
|
||||
function reportPersistenceFailure(scope: string, err: unknown): void {
|
||||
if (persistenceFailureLogged) {
|
||||
return;
|
||||
}
|
||||
persistenceFailureLogged = true;
|
||||
logVerbose(`imessage reply-cache: ${scope} disabled after first failure: ${String(err)}`);
|
||||
}
|
||||
|
||||
export type IMessageChatContext = {
|
||||
chatGuid?: string;
|
||||
chatIdentifier?: string;
|
||||
chatId?: number;
|
||||
};
|
||||
|
||||
type IMessageReplyCacheEntry = IMessageChatContext & {
|
||||
accountId: string;
|
||||
messageId: string;
|
||||
shortId: string;
|
||||
timestamp: number;
|
||||
/**
|
||||
* True when the gateway sent this message itself (recorded from the
|
||||
* outbound path in send.ts after a successful imsg send), false when the
|
||||
* cache entry came from inbound watch (most common path).
|
||||
*
|
||||
* Edit / unsend actions require this to be true: Messages.app only lets
|
||||
* the original sender edit or retract a message, and even if the bridge
|
||||
* accepted a non-sender attempt, letting an agent unsend a human user's
|
||||
* message in a group chat would be a permission boundary violation.
|
||||
*
|
||||
* Optional for backwards compatibility with persisted entries from older
|
||||
* gateway versions that did not record this field; missing values are
|
||||
* treated as `false` (the safe default — pre-existing entries on disk
|
||||
* came from the inbound-only writer that existed before this change).
|
||||
*/
|
||||
isFromMe?: boolean;
|
||||
};
|
||||
|
||||
const imessageReplyCacheByMessageId = new Map<string, IMessageReplyCacheEntry>();
|
||||
const imessageShortIdToUuid = new Map<string, string>();
|
||||
const imessageUuidToShortId = new Map<string, string>();
|
||||
let imessageShortIdCounter = 0;
|
||||
|
||||
// On-disk persistence: short-id ↔ UUID mappings need to survive gateway
|
||||
// restarts so an agent that received "[message_id:5]" before a restart can
|
||||
// still react to that message after the restart. The on-disk store is
|
||||
// best-effort — corruption or write failure falls back to the in-memory
|
||||
// cache, so the worst case is the same as before persistence existed.
|
||||
|
||||
function resolveReplyCachePath(): string {
|
||||
return path.join(resolveStateDir(), "imessage", "reply-cache.jsonl");
|
||||
}
|
||||
|
||||
function readPersistedEntries(): {
|
||||
entries: IMessageReplyCacheEntry[];
|
||||
maxObservedShortId: number;
|
||||
} {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(resolveReplyCachePath(), "utf8");
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
||||
reportPersistenceFailure("read", err);
|
||||
}
|
||||
return { entries: [], maxObservedShortId: 0 };
|
||||
}
|
||||
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
|
||||
const out: IMessageReplyCacheEntry[] = [];
|
||||
// The counter must advance past every shortId we have ever observed in
|
||||
// the file — including lines we skip because they are stale or malformed.
|
||||
// Otherwise a future allocation can collide with a still-live mapping
|
||||
// that came earlier in the file.
|
||||
let maxObservedShortId = 0;
|
||||
for (const line of raw.split(/\n+/)) {
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
let parsed: Partial<IMessageReplyCacheEntry> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Partial<IMessageReplyCacheEntry>;
|
||||
} catch {
|
||||
if (!parseFailureLogged) {
|
||||
parseFailureLogged = true;
|
||||
logVerbose(
|
||||
`imessage reply-cache: dropping unparseable line (further parse errors suppressed)`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (parsed && typeof parsed.shortId === "string") {
|
||||
const numeric = Number.parseInt(parsed.shortId, 10);
|
||||
if (Number.isFinite(numeric) && numeric > maxObservedShortId) {
|
||||
maxObservedShortId = numeric;
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof parsed?.accountId !== "string" ||
|
||||
typeof parsed.messageId !== "string" ||
|
||||
typeof parsed.shortId !== "string" ||
|
||||
typeof parsed.timestamp !== "number"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (parsed.timestamp < cutoff) {
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
accountId: parsed.accountId,
|
||||
messageId: parsed.messageId,
|
||||
shortId: parsed.shortId,
|
||||
timestamp: parsed.timestamp,
|
||||
chatGuid: typeof parsed.chatGuid === "string" ? parsed.chatGuid : undefined,
|
||||
chatIdentifier: typeof parsed.chatIdentifier === "string" ? parsed.chatIdentifier : undefined,
|
||||
chatId: typeof parsed.chatId === "number" ? parsed.chatId : undefined,
|
||||
isFromMe: typeof parsed.isFromMe === "boolean" ? parsed.isFromMe : undefined,
|
||||
});
|
||||
}
|
||||
return { entries: out.slice(-REPLY_CACHE_MAX), maxObservedShortId };
|
||||
}
|
||||
|
||||
// reply-cache.jsonl maps gateway-allocated short-ids to message guids. A
|
||||
// hostile same-UID process could otherwise (a) read the file to learn
|
||||
// active conversation guids, or (b) inject lines so a future shortId
|
||||
// resolution returns an attacker-chosen guid (allowing the agent to
|
||||
// react/edit/unsend a message it never saw). Owner-only mode on both the
|
||||
// directory and file closes that vector — defaults are 0755/0644 which
|
||||
// are world-readable on a multi-user Mac.
|
||||
const REPLY_CACHE_DIR_MODE = 0o700;
|
||||
const REPLY_CACHE_FILE_MODE = 0o600;
|
||||
|
||||
function writePersistedEntries(entries: IMessageReplyCacheEntry[]): void {
|
||||
const filePath = resolveReplyCachePath();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: REPLY_CACHE_DIR_MODE });
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
entries.map((entry) => JSON.stringify(entry)).join("\n") + (entries.length ? "\n" : ""),
|
||||
{ encoding: "utf8", mode: REPLY_CACHE_FILE_MODE },
|
||||
);
|
||||
// mkdirSync's mode is masked by umask and only applies on creation. If
|
||||
// the dir already existed from an older gateway version, clamp it now.
|
||||
try {
|
||||
fs.chmodSync(path.dirname(filePath), REPLY_CACHE_DIR_MODE);
|
||||
fs.chmodSync(filePath, REPLY_CACHE_FILE_MODE);
|
||||
} catch {
|
||||
// best-effort — fs may not support chmod on every platform
|
||||
}
|
||||
} catch (err) {
|
||||
reportPersistenceFailure("write", err);
|
||||
}
|
||||
}
|
||||
|
||||
function appendPersistedEntry(entry: IMessageReplyCacheEntry): void {
|
||||
const filePath = resolveReplyCachePath();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: REPLY_CACHE_DIR_MODE });
|
||||
fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: REPLY_CACHE_FILE_MODE,
|
||||
});
|
||||
// Always clamp — appendFileSync's `mode` only applies on creation, so
|
||||
// an existing 0644 file from an older gateway version would otherwise
|
||||
// never get tightened. chmod is microseconds; doing it every append
|
||||
// keeps the security guarantee monotonic instead of conditional on
|
||||
// creation order.
|
||||
try {
|
||||
fs.chmodSync(path.dirname(filePath), REPLY_CACHE_DIR_MODE);
|
||||
fs.chmodSync(filePath, REPLY_CACHE_FILE_MODE);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
} catch (err) {
|
||||
reportPersistenceFailure("append", err);
|
||||
}
|
||||
}
|
||||
|
||||
let hydrated = false;
|
||||
function hydrateFromDiskOnce(): void {
|
||||
if (hydrated) {
|
||||
return;
|
||||
}
|
||||
hydrated = true;
|
||||
const { entries, maxObservedShortId } = readPersistedEntries();
|
||||
// Bump the counter past every observed shortId, even from dropped lines —
|
||||
// see comment in readPersistedEntries.
|
||||
if (maxObservedShortId > imessageShortIdCounter) {
|
||||
imessageShortIdCounter = maxObservedShortId;
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Entries are appended chronologically, so iterate forward to keep the
|
||||
// newest entry as the "live" mapping when the same messageId appears
|
||||
// multiple times (e.g. after a write-rewrite cycle).
|
||||
for (const entry of entries) {
|
||||
imessageReplyCacheByMessageId.set(entry.messageId, entry);
|
||||
imessageShortIdToUuid.set(entry.shortId, entry.messageId);
|
||||
imessageUuidToShortId.set(entry.messageId, entry.shortId);
|
||||
}
|
||||
}
|
||||
|
||||
function generateShortId(): string {
|
||||
imessageShortIdCounter += 1;
|
||||
return String(imessageShortIdCounter);
|
||||
}
|
||||
|
||||
export function rememberIMessageReplyCache(
|
||||
entry: Omit<IMessageReplyCacheEntry, "shortId">,
|
||||
): IMessageReplyCacheEntry {
|
||||
hydrateFromDiskOnce();
|
||||
const messageId = entry.messageId.trim();
|
||||
if (!messageId) {
|
||||
return { ...entry, shortId: "" };
|
||||
}
|
||||
|
||||
let shortId = imessageUuidToShortId.get(messageId);
|
||||
let allocatedNew = false;
|
||||
if (!shortId) {
|
||||
shortId = generateShortId();
|
||||
imessageShortIdToUuid.set(shortId, messageId);
|
||||
imessageUuidToShortId.set(messageId, shortId);
|
||||
allocatedNew = true;
|
||||
}
|
||||
|
||||
const fullEntry: IMessageReplyCacheEntry = { ...entry, messageId, shortId };
|
||||
imessageReplyCacheByMessageId.delete(messageId);
|
||||
imessageReplyCacheByMessageId.set(messageId, fullEntry);
|
||||
|
||||
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
|
||||
let evicted = false;
|
||||
for (const [key, value] of imessageReplyCacheByMessageId) {
|
||||
if (value.timestamp >= cutoff) {
|
||||
break;
|
||||
}
|
||||
imessageReplyCacheByMessageId.delete(key);
|
||||
if (value.shortId) {
|
||||
imessageShortIdToUuid.delete(value.shortId);
|
||||
imessageUuidToShortId.delete(key);
|
||||
}
|
||||
evicted = true;
|
||||
}
|
||||
while (imessageReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
|
||||
const oldest = imessageReplyCacheByMessageId.keys().next().value;
|
||||
if (!oldest) {
|
||||
break;
|
||||
}
|
||||
const oldEntry = imessageReplyCacheByMessageId.get(oldest);
|
||||
imessageReplyCacheByMessageId.delete(oldest);
|
||||
if (oldEntry?.shortId) {
|
||||
imessageShortIdToUuid.delete(oldEntry.shortId);
|
||||
imessageUuidToShortId.delete(oldest);
|
||||
}
|
||||
evicted = true;
|
||||
}
|
||||
|
||||
// Append-only is hot-path cheap; periodic rewrite happens when we evict
|
||||
// stale entries so the file does not grow unbounded across restarts.
|
||||
if (allocatedNew) {
|
||||
appendPersistedEntry(fullEntry);
|
||||
}
|
||||
if (evicted) {
|
||||
writePersistedEntries([...imessageReplyCacheByMessageId.values()]);
|
||||
}
|
||||
|
||||
return fullEntry;
|
||||
}
|
||||
|
||||
function hasChatScope(ctx?: IMessageChatContext): boolean {
|
||||
if (!ctx) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
normalizeOptionalString(ctx.chatGuid) ||
|
||||
normalizeOptionalString(ctx.chatIdentifier) ||
|
||||
typeof ctx.chatId === "number",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the `iMessage;-;` / `SMS;-;` / `any;-;` service prefix that Messages
|
||||
* uses for direct chats. Different layers report direct DMs in different
|
||||
* forms — imsg's watch emits the bare handle plus an `any;-;…` chat_guid,
|
||||
* the action surface synthesizes `iMessage;-;…` from a phone-number target —
|
||||
* so comparing the raw strings would falsely flag the same chat as a
|
||||
* cross-chat target. Normalize both sides to the bare suffix.
|
||||
*/
|
||||
function normalizeDirectChatIdentifier(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
const lowered = trimmed.toLowerCase();
|
||||
for (const prefix of ["imessage;-;", "sms;-;", "any;-;"]) {
|
||||
if (lowered.startsWith(prefix)) {
|
||||
return trimmed.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isCrossChatMismatch(cached: IMessageReplyCacheEntry, ctx: IMessageChatContext): boolean {
|
||||
const cachedChatGuid = normalizeOptionalString(cached.chatGuid);
|
||||
const ctxChatGuid = normalizeOptionalString(ctx.chatGuid);
|
||||
if (cachedChatGuid && ctxChatGuid) {
|
||||
if (
|
||||
normalizeDirectChatIdentifier(cachedChatGuid) === normalizeDirectChatIdentifier(ctxChatGuid)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return cachedChatGuid !== ctxChatGuid;
|
||||
}
|
||||
const cachedChatIdentifier = normalizeOptionalString(cached.chatIdentifier);
|
||||
const ctxChatIdentifier = normalizeOptionalString(ctx.chatIdentifier);
|
||||
if (cachedChatIdentifier && ctxChatIdentifier) {
|
||||
if (
|
||||
normalizeDirectChatIdentifier(cachedChatIdentifier) ===
|
||||
normalizeDirectChatIdentifier(ctxChatIdentifier)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return cachedChatIdentifier !== ctxChatIdentifier;
|
||||
}
|
||||
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
|
||||
const ctxChatId = typeof ctx.chatId === "number" ? ctx.chatId : undefined;
|
||||
if (cachedChatId !== undefined && ctxChatId !== undefined) {
|
||||
return cachedChatId !== ctxChatId;
|
||||
}
|
||||
// Cross-format pairing: caller supplied chatIdentifier=iMessage;-;<phone>
|
||||
// and the cache stored chatGuid=any;-;<phone> (or vice versa). Compare via
|
||||
// the direct-DM normalization so we recognize them as the same chat.
|
||||
const cachedFingerprint = cachedChatGuid
|
||||
? normalizeDirectChatIdentifier(cachedChatGuid)
|
||||
: cachedChatIdentifier
|
||||
? normalizeDirectChatIdentifier(cachedChatIdentifier)
|
||||
: undefined;
|
||||
const ctxFingerprint = ctxChatGuid
|
||||
? normalizeDirectChatIdentifier(ctxChatGuid)
|
||||
: ctxChatIdentifier
|
||||
? normalizeDirectChatIdentifier(ctxChatIdentifier)
|
||||
: undefined;
|
||||
if (cachedFingerprint && ctxFingerprint) {
|
||||
return cachedFingerprint !== ctxFingerprint;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function describeChatForError(values: IMessageChatContext): string {
|
||||
const parts: string[] = [];
|
||||
if (normalizeOptionalString(values.chatGuid)) {
|
||||
parts.push("chatGuid=<redacted>");
|
||||
}
|
||||
if (normalizeOptionalString(values.chatIdentifier)) {
|
||||
parts.push("chatIdentifier=<redacted>");
|
||||
}
|
||||
if (typeof values.chatId === "number") {
|
||||
parts.push("chatId=<redacted>");
|
||||
}
|
||||
return parts.length === 0 ? "<unknown chat>" : parts.join(", ");
|
||||
}
|
||||
|
||||
function describeMessageIdForError(inputId: string, inputKind: "short" | "uuid"): string {
|
||||
if (inputKind === "short") {
|
||||
return `<short:${inputId.length}-digit>`;
|
||||
}
|
||||
return `<uuid:${inputId.slice(0, 8)}...>`;
|
||||
}
|
||||
|
||||
function buildCrossChatError(
|
||||
inputId: string,
|
||||
inputKind: "short" | "uuid",
|
||||
cached: IMessageReplyCacheEntry,
|
||||
ctx: IMessageChatContext,
|
||||
): Error {
|
||||
const remediation =
|
||||
inputKind === "short"
|
||||
? "Retry with MessageSidFull to avoid cross-chat reactions/replies landing in the wrong conversation."
|
||||
: "Retry with the correct chat target.";
|
||||
return new Error(
|
||||
`iMessage message id ${describeMessageIdForError(inputId, inputKind)} belongs to a different chat ` +
|
||||
`(${describeChatForError(cached)}) than the current call target (${describeChatForError(ctx)}). ${remediation}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveIMessageMessageId(
|
||||
shortOrUuid: string,
|
||||
opts?: {
|
||||
requireKnownShortId?: boolean;
|
||||
chatContext?: IMessageChatContext;
|
||||
/**
|
||||
* When true, only resolve message ids that the gateway recorded as sent
|
||||
* by itself (`isFromMe: true`). Used by `edit` / `unsend` so an agent
|
||||
* cannot retract or edit messages other participants sent — Messages.app
|
||||
* enforces this at the OS level too, but failing earlier in the plugin
|
||||
* gives a clean error and avoids dispatching a guaranteed-to-fail bridge
|
||||
* call.
|
||||
*
|
||||
* Cache entries with no `isFromMe` field (older persisted entries from
|
||||
* before this option existed, or any uncached UUID the agent passes
|
||||
* through) are treated as not-from-me and rejected.
|
||||
*/
|
||||
requireFromMe?: boolean;
|
||||
},
|
||||
): string {
|
||||
const trimmed = shortOrUuid.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
// Hydrate the on-disk JSONL into the in-memory maps before reading them.
|
||||
// Without this, the first post-restart action that arrives with a short
|
||||
// MessageSid would miss `imessageShortIdToUuid` and fall through to the
|
||||
// "no longer available" path, breaking the persistence contract — the
|
||||
// mapping was on disk, we just hadn't read it yet on this read path.
|
||||
// `rememberIMessageReplyCache` already hydrates on its own, so this only
|
||||
// matters for the resolve-first-after-restart sequence.
|
||||
hydrateFromDiskOnce();
|
||||
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
// Cache hit: the cached entry carries the chat info this short id was
|
||||
// issued for, so we can resolve the UUID even without a caller-supplied
|
||||
// chat scope. Cross-chat detection still fires when the caller did
|
||||
// provide a scope and it disagrees with the cache.
|
||||
const uuid = imessageShortIdToUuid.get(trimmed);
|
||||
if (uuid) {
|
||||
const cached = imessageReplyCacheByMessageId.get(uuid);
|
||||
if (opts?.chatContext && hasChatScope(opts.chatContext)) {
|
||||
if (cached && isCrossChatMismatch(cached, opts.chatContext)) {
|
||||
throw buildCrossChatError(trimmed, "short", cached, opts.chatContext);
|
||||
}
|
||||
}
|
||||
if (opts?.requireFromMe && cached?.isFromMe !== true) {
|
||||
throw buildFromMeError(trimmed, "short");
|
||||
}
|
||||
return uuid;
|
||||
}
|
||||
// Cache miss: now the chat-scope requirement matters — without scope
|
||||
// we have no way to verify the caller is reacting in the right chat,
|
||||
// and without a cached UUID the bridge cannot resolve the short id.
|
||||
if (opts?.requireKnownShortId && !hasChatScope(opts.chatContext)) {
|
||||
throw new Error(
|
||||
`iMessage short message id ${describeMessageIdForError(trimmed, "short")} requires a chat scope (chatGuid / chatIdentifier / chatId or a target).`,
|
||||
);
|
||||
}
|
||||
if (opts?.requireKnownShortId) {
|
||||
throw new Error(
|
||||
`iMessage short message id ${describeMessageIdForError(trimmed, "short")} is no longer available. Use MessageSidFull.`,
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const cached = imessageReplyCacheByMessageId.get(trimmed);
|
||||
if (opts?.chatContext) {
|
||||
if (cached && isCrossChatMismatch(cached, opts.chatContext)) {
|
||||
throw buildCrossChatError(trimmed, "uuid", cached, opts.chatContext);
|
||||
}
|
||||
}
|
||||
if (opts?.requireFromMe && cached?.isFromMe !== true) {
|
||||
throw buildFromMeError(trimmed, "uuid");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function buildFromMeError(inputId: string, inputKind: "short" | "uuid"): Error {
|
||||
return new Error(
|
||||
`iMessage message id ${describeMessageIdForError(inputId, inputKind)} is not one this agent sent. ` +
|
||||
`edit and unsend can only target messages the gateway delivered itself; ` +
|
||||
`messages received from other participants cannot be modified.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the most recent cached entry whose chat scope matches the supplied
|
||||
* context. Used as a fallback when an agent calls a per-message action (e.g.
|
||||
* `react`) without specifying a `messageId` — the natural intent is "react
|
||||
* to the message I just received in this chat."
|
||||
*
|
||||
* Strict semantics for safety:
|
||||
* - Caller must supply a chat scope. We refuse to "guess" the active chat.
|
||||
* - Cached entry must positively match on at least one identifier kind
|
||||
* (chatGuid, chatIdentifier, chatId, or normalized direct-DM fingerprint).
|
||||
* We do NOT fall through on "no overlapping identifier" — that's how a
|
||||
* cached entry from a foreign chat could be returned when the caller's
|
||||
* context didn't share any identifier kind with the cache.
|
||||
* - Caller must supply an accountId; we never cross account boundaries.
|
||||
* - We only consider entries newer than `LATEST_FALLBACK_MS`. The intent
|
||||
* of "react to the latest" is "the message I just received," not
|
||||
* "anything in this chat from any time."
|
||||
*/
|
||||
export function findLatestIMessageEntryForChat(
|
||||
ctx: IMessageChatContext & { accountId?: string },
|
||||
): IMessageReplyCacheEntry | undefined {
|
||||
if (!hasChatScope(ctx)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!ctx.accountId) {
|
||||
return undefined;
|
||||
}
|
||||
const cutoff = Date.now() - LATEST_FALLBACK_MS;
|
||||
let best: IMessageReplyCacheEntry | undefined;
|
||||
for (const entry of imessageReplyCacheByMessageId.values()) {
|
||||
if (entry.accountId !== ctx.accountId) {
|
||||
continue;
|
||||
}
|
||||
if (entry.timestamp < cutoff) {
|
||||
continue;
|
||||
}
|
||||
if (!isPositiveChatMatch(entry, ctx)) {
|
||||
continue;
|
||||
}
|
||||
if (!best || entry.timestamp > best.timestamp) {
|
||||
best = entry;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when the cached entry positively matches the caller's chat
|
||||
* context on at least one identifier kind. Unlike `isCrossChatMismatch`,
|
||||
* which returns false for "no overlap," this requires concrete agreement.
|
||||
*/
|
||||
function isPositiveChatMatch(entry: IMessageReplyCacheEntry, ctx: IMessageChatContext): boolean {
|
||||
const cachedChatGuid = normalizeOptionalString(entry.chatGuid);
|
||||
const ctxChatGuid = normalizeOptionalString(ctx.chatGuid);
|
||||
if (cachedChatGuid && ctxChatGuid && cachedChatGuid === ctxChatGuid) {
|
||||
return true;
|
||||
}
|
||||
const cachedChatIdentifier = normalizeOptionalString(entry.chatIdentifier);
|
||||
const ctxChatIdentifier = normalizeOptionalString(ctx.chatIdentifier);
|
||||
if (cachedChatIdentifier && ctxChatIdentifier && cachedChatIdentifier === ctxChatIdentifier) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
typeof entry.chatId === "number" &&
|
||||
typeof ctx.chatId === "number" &&
|
||||
entry.chatId === ctx.chatId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Cross-format: cached chatGuid vs ctx chatIdentifier, etc. Compare via
|
||||
// the direct-DM normalization that strips iMessage;-;/SMS;-;/any;-; .
|
||||
const cachedFingerprint = cachedChatGuid
|
||||
? normalizeDirectChatIdentifier(cachedChatGuid)
|
||||
: cachedChatIdentifier
|
||||
? normalizeDirectChatIdentifier(cachedChatIdentifier)
|
||||
: undefined;
|
||||
const ctxFingerprint = ctxChatGuid
|
||||
? normalizeDirectChatIdentifier(ctxChatGuid)
|
||||
: ctxChatIdentifier
|
||||
? normalizeDirectChatIdentifier(ctxChatIdentifier)
|
||||
: undefined;
|
||||
if (cachedFingerprint && ctxFingerprint && cachedFingerprint === ctxFingerprint) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function _resetIMessageShortIdState(): void {
|
||||
imessageReplyCacheByMessageId.clear();
|
||||
imessageShortIdToUuid.clear();
|
||||
imessageUuidToShortId.clear();
|
||||
imessageShortIdCounter = 0;
|
||||
hydrated = false;
|
||||
persistenceFailureLogged = false;
|
||||
parseFailureLogged = false;
|
||||
// Only delete the persisted file when the test harness has explicitly
|
||||
// pointed us at an isolated state directory. Otherwise we would nuke
|
||||
// whatever live gateway happens to share `~/.openclaw` — and in vitest
|
||||
// file-level parallelism, two test files calling this at once could
|
||||
// race a peer's appendFileSync mid-write.
|
||||
if (!process.env.OPENCLAW_STATE_DIR) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.rmSync(resolveReplyCachePath(), { force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { _resetIMessageShortIdState } from "./monitor-reply-cache.js";
|
||||
import {
|
||||
buildIMessageInboundContext,
|
||||
resolveIMessageInboundDecision,
|
||||
@@ -7,6 +8,10 @@ import {
|
||||
import { parseIMessageNotification } from "./monitor/parse-notification.js";
|
||||
import type { IMessagePayload } from "./monitor/types.js";
|
||||
|
||||
beforeEach(() => {
|
||||
_resetIMessageShortIdState();
|
||||
});
|
||||
|
||||
function baseCfg(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
@@ -159,6 +164,27 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
expect(ctxPayload.To).toBe("chat_id:42");
|
||||
});
|
||||
|
||||
it("uses short message ids in context and keeps the full guid for actions", () => {
|
||||
const cfg = baseCfg();
|
||||
const message: IMessagePayload = {
|
||||
id: 3,
|
||||
guid: "full-message-guid",
|
||||
chat_id: 42,
|
||||
chat_guid: "iMessage;+;chat0000",
|
||||
chat_identifier: "thread-42",
|
||||
sender: "+15550002222",
|
||||
is_from_me: false,
|
||||
text: "@openclaw ping",
|
||||
is_group: true,
|
||||
chat_name: "Lobster Squad",
|
||||
participants: ["+1555", "+1556"],
|
||||
};
|
||||
const ctxPayload = buildDispatchContextPayload({ cfg, message });
|
||||
|
||||
expect(ctxPayload.MessageSid).toBe("1");
|
||||
expect(ctxPayload.MessageSidFull).toBe("full-message-guid");
|
||||
});
|
||||
|
||||
it("includes reply-to context fields + suffix", () => {
|
||||
const cfg = baseCfg();
|
||||
const message: IMessagePayload = {
|
||||
|
||||
143
extensions/imessage/src/monitor/coalesce.test.ts
Normal file
143
extensions/imessage/src/monitor/coalesce.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
combineIMessagePayloads,
|
||||
MAX_COALESCED_ATTACHMENTS,
|
||||
MAX_COALESCED_ENTRIES,
|
||||
MAX_COALESCED_TEXT_CHARS,
|
||||
} from "./coalesce.js";
|
||||
import type { IMessagePayload } from "./types.js";
|
||||
|
||||
const makePayload = (overrides: Partial<IMessagePayload> = {}): IMessagePayload => ({
|
||||
guid: `msg-${Math.random().toString(36).slice(2, 10)}`,
|
||||
chat_id: 1,
|
||||
sender: "+15555550100",
|
||||
is_from_me: false,
|
||||
is_group: false,
|
||||
text: null,
|
||||
attachments: null,
|
||||
created_at: new Date(2025, 0, 1).toISOString(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("combineIMessagePayloads", () => {
|
||||
it("throws on empty input", () => {
|
||||
expect(() => combineIMessagePayloads([])).toThrowError();
|
||||
});
|
||||
|
||||
it("returns the lone payload unchanged when only one entry", () => {
|
||||
const payload = makePayload({ text: "alone", guid: "solo" });
|
||||
const result = combineIMessagePayloads([payload]);
|
||||
expect(result).toBe(payload);
|
||||
expect(result.guid).toBe("solo");
|
||||
});
|
||||
|
||||
it("merges Dump + URL split-send into one payload anchored on the first GUID", () => {
|
||||
const text = makePayload({ text: "Dump", guid: "row-1", created_at: "2025-01-01T00:00:00Z" });
|
||||
const balloon = makePayload({
|
||||
text: "https://example.com/article",
|
||||
guid: "row-2",
|
||||
created_at: "2025-01-01T00:00:01.500Z",
|
||||
});
|
||||
const merged = combineIMessagePayloads([text, balloon]);
|
||||
|
||||
expect(merged.text).toBe("Dump https://example.com/article");
|
||||
expect(merged.guid).toBe("row-1");
|
||||
expect(merged.created_at).toBe("2025-01-01T00:00:01.500Z");
|
||||
expect(merged.coalescedMessageGuids).toEqual(["row-1", "row-2"]);
|
||||
});
|
||||
|
||||
it("preserves attachments instead of dropping them on merge", () => {
|
||||
const text = makePayload({ text: "Save", guid: "row-1" });
|
||||
const image = makePayload({
|
||||
text: "caption",
|
||||
guid: "row-2",
|
||||
attachments: [{ original_path: "/tmp/a.jpg", mime_type: "image/jpeg" }],
|
||||
});
|
||||
const merged = combineIMessagePayloads([text, image]);
|
||||
|
||||
expect(merged.attachments).toEqual([{ original_path: "/tmp/a.jpg", mime_type: "image/jpeg" }]);
|
||||
});
|
||||
|
||||
it("dedupes identical text appearing in both rows (URL in text and balloon)", () => {
|
||||
const a = makePayload({ text: "https://example.com", guid: "row-1" });
|
||||
const b = makePayload({ text: "https://example.com", guid: "row-2" });
|
||||
const merged = combineIMessagePayloads([a, b]);
|
||||
|
||||
expect(merged.text).toBe("https://example.com");
|
||||
expect(merged.coalescedMessageGuids).toEqual(["row-1", "row-2"]);
|
||||
});
|
||||
|
||||
it("caps merged text length and appends the truncated marker", () => {
|
||||
const longA = makePayload({ text: "A".repeat(3000), guid: "row-1" });
|
||||
const longB = makePayload({ text: "B".repeat(3000), guid: "row-2" });
|
||||
const merged = combineIMessagePayloads([longA, longB]);
|
||||
|
||||
expect(merged.text?.endsWith("…[truncated]")).toBe(true);
|
||||
expect(merged.text?.length).toBeLessThanOrEqual(
|
||||
MAX_COALESCED_TEXT_CHARS + "…[truncated]".length,
|
||||
);
|
||||
});
|
||||
|
||||
it("caps the attachment count", () => {
|
||||
// 5 attachments per row × 6 rows = 30 attachments offered, capped at 20.
|
||||
// Stays under the entry cap so the merge isn't pruned for that reason.
|
||||
const payloads = Array.from({ length: 6 }, (_, i) =>
|
||||
makePayload({
|
||||
guid: `row-${i}`,
|
||||
attachments: Array.from({ length: 5 }, (_, j) => ({
|
||||
original_path: `/tmp/${i}-${j}.jpg`,
|
||||
mime_type: "image/jpeg",
|
||||
})),
|
||||
}),
|
||||
);
|
||||
const merged = combineIMessagePayloads(payloads);
|
||||
|
||||
expect(merged.attachments?.length).toBe(MAX_COALESCED_ATTACHMENTS);
|
||||
});
|
||||
|
||||
it("keeps first + most recent when entry count exceeds the cap, but tracks every GUID", () => {
|
||||
const payloads = Array.from({ length: 25 }, (_, i) =>
|
||||
makePayload({ text: `msg ${i}`, guid: `row-${i}` }),
|
||||
);
|
||||
const merged = combineIMessagePayloads(payloads);
|
||||
|
||||
// First payload's GUID anchors the merged shape.
|
||||
expect(merged.guid).toBe("row-0");
|
||||
// Every source GUID is tracked, even those whose text was dropped by the cap.
|
||||
expect(merged.coalescedMessageGuids?.length).toBe(25);
|
||||
expect(merged.coalescedMessageGuids?.[0]).toBe("row-0");
|
||||
expect(merged.coalescedMessageGuids?.[24]).toBe("row-24");
|
||||
// Merged text contains only first MAX_COALESCED_ENTRIES-1 entries plus the latest.
|
||||
expect(merged.text).toContain("msg 0");
|
||||
expect(merged.text).toContain("msg 24");
|
||||
expect(merged.text).not.toContain("msg 10"); // dropped by cap
|
||||
});
|
||||
|
||||
it("preserves reply context from any entry that carries one", () => {
|
||||
const noReply = makePayload({ text: "hello", guid: "row-1" });
|
||||
const reply = makePayload({
|
||||
text: "follow-up",
|
||||
guid: "row-2",
|
||||
reply_to_id: "parent-msg",
|
||||
reply_to_text: "earlier",
|
||||
reply_to_sender: "+15555550199",
|
||||
});
|
||||
const merged = combineIMessagePayloads([noReply, reply]);
|
||||
|
||||
expect(merged.reply_to_id).toBe("parent-msg");
|
||||
expect(merged.reply_to_text).toBe("earlier");
|
||||
expect(merged.reply_to_sender).toBe("+15555550199");
|
||||
});
|
||||
|
||||
it("does not set coalescedMessageGuids when no entry carries a GUID", () => {
|
||||
const a = makePayload({ text: "a", guid: null });
|
||||
const b = makePayload({ text: "b", guid: null });
|
||||
const merged = combineIMessagePayloads([a, b]);
|
||||
|
||||
expect(merged.coalescedMessageGuids).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects the documented entry cap value", () => {
|
||||
expect(MAX_COALESCED_ENTRIES).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
123
extensions/imessage/src/monitor/coalesce.ts
Normal file
123
extensions/imessage/src/monitor/coalesce.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { IMessagePayload } from "./types.js";
|
||||
|
||||
// Mirrors BlueBubbles' `combineDebounceEntries` semantics (caps, ID tracking,
|
||||
// reply-context preference) deliberately, so a future SDK lift into
|
||||
// `openclaw/plugin-sdk/channel-inbound` is a mechanical extraction instead of
|
||||
// a behavioral redesign. Both bundled Apple-store readers (BlueBubbles and
|
||||
// imsg) face the same Apple split-send pipeline.
|
||||
|
||||
/**
|
||||
* Bounds on the merged output when multiple inbound iMessage payloads are
|
||||
* folded into one agent turn. Mirrors the BlueBubbles caps so a sender who
|
||||
* rapid-fires DMs inside the debounce window cannot amplify the downstream
|
||||
* prompt past a safe ceiling. Every source GUID still surfaces via
|
||||
* `coalescedMessageGuids` so a future replay path can recognize duplicates.
|
||||
*/
|
||||
export const MAX_COALESCED_TEXT_CHARS = 4000;
|
||||
export const MAX_COALESCED_ATTACHMENTS = 20;
|
||||
export const MAX_COALESCED_ENTRIES = 10;
|
||||
|
||||
export type CoalescedIMessagePayload = IMessagePayload & {
|
||||
/**
|
||||
* Source GUIDs folded into this merged payload, in arrival order. Includes
|
||||
* GUIDs from entries that were dropped by the entry cap so downstream
|
||||
* dedupe paths can still recognize them.
|
||||
*/
|
||||
coalescedMessageGuids?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Combine consecutive same-sender iMessage payloads into a single payload for
|
||||
* downstream dispatch. Used when the debouncer flushes a bucket containing
|
||||
* more than one event — e.g. Apple's split-send for `Dump https://example.com`
|
||||
* arriving as two separate `chat.db` rows ~0.8-2.0 s apart.
|
||||
*
|
||||
* The first payload anchors the merged shape (preserving its GUID for reply
|
||||
* threading). Text is concatenated with deduplication, attachments are merged
|
||||
* (capped), and the latest `created_at` wins so downstream sees the most
|
||||
* recent activity timestamp.
|
||||
*/
|
||||
export function combineIMessagePayloads(payloads: IMessagePayload[]): CoalescedIMessagePayload {
|
||||
if (payloads.length === 0) {
|
||||
throw new Error("combineIMessagePayloads: cannot combine empty payloads");
|
||||
}
|
||||
if (payloads.length === 1) {
|
||||
return payloads[0];
|
||||
}
|
||||
|
||||
const first = payloads[0];
|
||||
const last = payloads[payloads.length - 1];
|
||||
|
||||
// Cap entries: keep first (preserves command/context) + most recent
|
||||
// (preserves latest payload) when a flood exceeds the cap.
|
||||
const boundedPayloads =
|
||||
payloads.length > MAX_COALESCED_ENTRIES
|
||||
? [...payloads.slice(0, MAX_COALESCED_ENTRIES - 1), last]
|
||||
: payloads;
|
||||
|
||||
// Combine text across bounded entries. Skip duplicates so a URL appearing
|
||||
// both as plain text and as a separately-rendered link-preview row does not
|
||||
// get repeated in the merged prompt.
|
||||
const seenTexts = new Set<string>();
|
||||
const textParts: string[] = [];
|
||||
for (const payload of boundedPayloads) {
|
||||
const text = (payload.text ?? "").trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const normalized = text.toLowerCase();
|
||||
if (seenTexts.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seenTexts.add(normalized);
|
||||
textParts.push(text);
|
||||
}
|
||||
let combinedText = textParts.join(" ");
|
||||
if (combinedText.length > MAX_COALESCED_TEXT_CHARS) {
|
||||
combinedText = `${combinedText.slice(0, MAX_COALESCED_TEXT_CHARS)}…[truncated]`;
|
||||
}
|
||||
|
||||
// Merge attachments across bounded entries, capped to keep downstream media
|
||||
// fan-out proportional to a single message.
|
||||
const allAttachments = boundedPayloads
|
||||
.flatMap((p) => p.attachments ?? [])
|
||||
.slice(0, MAX_COALESCED_ATTACHMENTS);
|
||||
|
||||
// Latest `created_at` (lexically max ISO-8601 string) so downstream sees
|
||||
// the freshest activity timestamp. Falls back to `first.created_at` if no
|
||||
// entries carry a usable timestamp.
|
||||
const createdAts = payloads
|
||||
.map((p) => p.created_at)
|
||||
.filter((c): c is string => typeof c === "string" && c.length > 0);
|
||||
const latestCreatedAt =
|
||||
createdAts.length > 0 ? createdAts.reduce((a, b) => (a > b ? a : b)) : first.created_at;
|
||||
|
||||
// Walk the unbounded `payloads` so even GUIDs whose text/attachments were
|
||||
// dropped by the cap are still remembered for downstream dedupe.
|
||||
const seenGuids = new Set<string>();
|
||||
const coalescedMessageGuids: string[] = [];
|
||||
for (const payload of payloads) {
|
||||
const guid = payload.guid?.trim();
|
||||
if (!guid || seenGuids.has(guid)) {
|
||||
continue;
|
||||
}
|
||||
seenGuids.add(guid);
|
||||
coalescedMessageGuids.push(guid);
|
||||
}
|
||||
|
||||
// Reply context: prefer any entry that carries one; the last balloon in a
|
||||
// split-send rarely does, but a manual quote-reply earlier in the bucket
|
||||
// might.
|
||||
const entryWithReply = payloads.find((p) => p.reply_to_id != null);
|
||||
|
||||
return {
|
||||
...first,
|
||||
text: combinedText,
|
||||
attachments: allAttachments.length > 0 ? allAttachments : null,
|
||||
created_at: latestCreatedAt,
|
||||
reply_to_id: entryWithReply?.reply_to_id ?? first.reply_to_id ?? null,
|
||||
reply_to_text: entryWithReply?.reply_to_text ?? first.reply_to_text ?? null,
|
||||
reply_to_sender: entryWithReply?.reply_to_sender ?? first.reply_to_sender ?? null,
|
||||
coalescedMessageGuids: coalescedMessageGuids.length > 0 ? coalescedMessageGuids : undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { hasPersistedIMessageEcho } from "./persisted-echo-cache.js";
|
||||
|
||||
type SentMessageLookup = {
|
||||
text?: string;
|
||||
messageId?: string;
|
||||
@@ -69,6 +71,9 @@ class DefaultSentMessageCache implements SentMessageCache {
|
||||
|
||||
has(scope: string, lookup: SentMessageLookup, skipIdShortCircuit = false): boolean {
|
||||
this.cleanup();
|
||||
if (hasPersistedIMessageEcho({ scope, ...lookup })) {
|
||||
return true;
|
||||
}
|
||||
const textKey = normalizeEchoTextKey(lookup.text);
|
||||
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
|
||||
if (messageIdKey) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { sanitizeTerminalText } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { _resetIMessageShortIdState } from "../monitor-reply-cache.js";
|
||||
import {
|
||||
buildIMessageInboundContext,
|
||||
describeIMessageEchoDropLog,
|
||||
resolveIMessageInboundDecision,
|
||||
} from "./inbound-processing.js";
|
||||
@@ -248,6 +253,112 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("drops group echoes persisted under chat_guid scope", () => {
|
||||
// Outbound `send` to a group keyed by chat_guid persists the echo scope
|
||||
// as `${accountId}:chat_guid:${chatGuid}` (see send.ts:resolveOutboundEchoScope).
|
||||
// The inbound side has chat_id, chat_guid, and chat_identifier all
|
||||
// populated by chat.db. Without the multi-scope check, the chat_guid-keyed
|
||||
// echo would never be matched against the chat_id-only inbound scope and
|
||||
// the agent would react to its own message.
|
||||
const echoHas = vi.fn((scope: string, lookup: { text?: string; messageId?: string }) => {
|
||||
return scope === "default:chat_guid:iMessage;+;chat0000" && lookup.messageId === "9001";
|
||||
});
|
||||
|
||||
const decision = resolveDecision({
|
||||
message: {
|
||||
id: 9001,
|
||||
chat_id: 42,
|
||||
chat_guid: "iMessage;+;chat0000",
|
||||
chat_identifier: "chat0000",
|
||||
sender: "+15555550123",
|
||||
text: "echo",
|
||||
is_group: true,
|
||||
},
|
||||
messageText: "echo",
|
||||
bodyText: "echo",
|
||||
echoCache: { has: echoHas },
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ kind: "drop", reason: "echo" });
|
||||
// The match should land on the chat_guid scope variant.
|
||||
const calls = echoHas.mock.calls.map(([scope]) => scope);
|
||||
expect(calls).toContain("default:chat_guid:iMessage;+;chat0000");
|
||||
});
|
||||
|
||||
it("drops group echoes persisted under chat_identifier scope", () => {
|
||||
const echoHas = vi.fn((scope: string, lookup: { text?: string; messageId?: string }) => {
|
||||
return scope === "default:chat_identifier:chat0000" && lookup.messageId === "9001";
|
||||
});
|
||||
|
||||
const decision = resolveDecision({
|
||||
message: {
|
||||
id: 9001,
|
||||
chat_id: 42,
|
||||
chat_guid: "iMessage;+;chat0000",
|
||||
chat_identifier: "chat0000",
|
||||
sender: "+15555550123",
|
||||
text: "echo",
|
||||
is_group: true,
|
||||
},
|
||||
messageText: "echo",
|
||||
bodyText: "echo",
|
||||
echoCache: { has: echoHas },
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ kind: "drop", reason: "echo" });
|
||||
const calls = echoHas.mock.calls.map(([scope]) => scope);
|
||||
expect(calls).toContain("default:chat_identifier:chat0000");
|
||||
});
|
||||
|
||||
it("drops group echoes persisted under chat_id scope (baseline)", () => {
|
||||
const echoHas = vi.fn((scope: string, lookup: { text?: string; messageId?: string }) => {
|
||||
return scope === "default:chat_id:42" && lookup.messageId === "9001";
|
||||
});
|
||||
|
||||
const decision = resolveDecision({
|
||||
message: {
|
||||
id: 9001,
|
||||
chat_id: 42,
|
||||
chat_guid: "iMessage;+;chat0000",
|
||||
chat_identifier: "chat0000",
|
||||
sender: "+15555550123",
|
||||
text: "echo",
|
||||
is_group: true,
|
||||
},
|
||||
messageText: "echo",
|
||||
bodyText: "echo",
|
||||
echoCache: { has: echoHas },
|
||||
});
|
||||
|
||||
expect(decision).toEqual({ kind: "drop", reason: "echo" });
|
||||
const calls = echoHas.mock.calls.map(([scope]) => scope);
|
||||
expect(calls).toContain("default:chat_id:42");
|
||||
});
|
||||
|
||||
it("does not drop a group inbound when echo cache holds an unrelated chat_guid", () => {
|
||||
const echoHas = vi.fn(
|
||||
(scope: string, lookup: { text?: string; messageId?: string }) =>
|
||||
scope === "default:chat_guid:iMessage;+;OTHER" && lookup.messageId === "9001",
|
||||
);
|
||||
|
||||
const decision = resolveDecision({
|
||||
message: {
|
||||
id: 9001,
|
||||
chat_id: 42,
|
||||
chat_guid: "iMessage;+;chat0000",
|
||||
chat_identifier: "chat0000",
|
||||
sender: "+15555550123",
|
||||
text: "fresh inbound",
|
||||
is_group: true,
|
||||
},
|
||||
messageText: "fresh inbound",
|
||||
bodyText: "fresh inbound",
|
||||
echoCache: { has: echoHas },
|
||||
});
|
||||
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("sanitizes reflected duplicate previews before logging", () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const logVerbose = vi.fn();
|
||||
@@ -356,3 +467,80 @@ describe("resolveIMessageInboundDecision command auth", () => {
|
||||
expect(decision.commandAuthorized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildIMessageInboundContext MessageSid handling (rowid-leak regression)", () => {
|
||||
let tempStateDir: string;
|
||||
let priorStateDir: string | undefined;
|
||||
beforeAll(() => {
|
||||
tempStateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-inbound-"));
|
||||
priorStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = tempStateDir;
|
||||
});
|
||||
afterAll(() => {
|
||||
if (priorStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = priorStateDir;
|
||||
}
|
||||
fs.rmSync(tempStateDir, { recursive: true, force: true });
|
||||
});
|
||||
beforeEach(() => {
|
||||
_resetIMessageShortIdState();
|
||||
try {
|
||||
fs.rmSync(path.join(tempStateDir, "imessage", "reply-cache.jsonl"), { force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
});
|
||||
|
||||
function buildParams(messageOverrides: Partial<{ id: number; guid: string }>) {
|
||||
const decision = {
|
||||
kind: "dispatch" as const,
|
||||
route: { accountId: "default", agentId: "lobster", sessionKey: "k", mainSessionKey: "mk" },
|
||||
isGroup: false,
|
||||
sender: "+15555550123",
|
||||
senderId: "+15555550123",
|
||||
senderNormalized: "+15555550123",
|
||||
historyKey: "h",
|
||||
chatId: 3,
|
||||
chatGuid: "any;-;+15555550123",
|
||||
chatIdentifier: "+15555550123",
|
||||
replyContext: undefined,
|
||||
isCommand: false,
|
||||
commandAuthorized: false,
|
||||
};
|
||||
return {
|
||||
cfg: {} as OpenClawConfig,
|
||||
decision: decision as unknown as Parameters<
|
||||
typeof buildIMessageInboundContext
|
||||
>[0]["decision"],
|
||||
message: { sender: "+15555550123", text: "hi", ...messageOverrides },
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
} as unknown as Parameters<typeof buildIMessageInboundContext>[0];
|
||||
}
|
||||
|
||||
it("uses the gateway-allocated shortId when the inbound has a guid", () => {
|
||||
const { ctxPayload } = buildIMessageInboundContext(
|
||||
buildParams({ id: 999, guid: "FAB-INBOUND-1" }),
|
||||
);
|
||||
// First inbound → shortId "1". The chat.db rowid 999 must NOT leak.
|
||||
expect(ctxPayload.MessageSid).toBe("1");
|
||||
});
|
||||
|
||||
it("does not leak chat.db ROWIDs as MessageSid when the guid is missing", () => {
|
||||
// Pre-fix bug: when rememberedMessage was nil/empty, MessageSid fell
|
||||
// back to `String(message.id)` — leaking chat.db ROWID into the agent's
|
||||
// short-id namespace. Agent then tried to react to a phantom shortId
|
||||
// that the resolver couldn't find ("13 is no longer available").
|
||||
const { ctxPayload } = buildIMessageInboundContext(buildParams({ id: 13, guid: undefined }));
|
||||
expect(ctxPayload.MessageSid).toBeUndefined();
|
||||
// Critically: never the rowid as a string.
|
||||
expect(ctxPayload.MessageSid).not.toBe("13");
|
||||
});
|
||||
|
||||
it("does not leak chat.db ROWIDs even when the guid is whitespace", () => {
|
||||
const { ctxPayload } = buildIMessageInboundContext(buildParams({ id: 13, guid: " " }));
|
||||
expect(ctxPayload.MessageSid).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { sanitizeTerminalText } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveIMessageConversationRoute } from "../conversation-route.js";
|
||||
import { rememberIMessageReplyCache } from "../monitor-reply-cache.js";
|
||||
import {
|
||||
formatIMessageChatTarget,
|
||||
isAllowedIMessageSender,
|
||||
@@ -90,25 +91,44 @@ function hasIMessageEchoMatch(params: {
|
||||
skipIdShortCircuit?: boolean,
|
||||
) => boolean;
|
||||
};
|
||||
scope: string;
|
||||
scope: string | readonly string[];
|
||||
text?: string;
|
||||
messageIds: string[];
|
||||
skipIdShortCircuit?: boolean;
|
||||
}): boolean {
|
||||
for (const messageId of params.messageIds) {
|
||||
if (params.echoCache.has(params.scope, { messageId })) {
|
||||
// Outbound sends persist echo scopes keyed by whichever target shape was
|
||||
// used (chat_id, chat_guid, chat_identifier, or imessage:<handle>). Inbound
|
||||
// messages from chat.db typically carry chat_id + chat_guid + chat_identifier
|
||||
// for groups and just sender for DMs, so the same conversation can be
|
||||
// echo-cached under one shape and re-encountered under another. Probe every
|
||||
// candidate scope so a chat_guid-keyed send isn't surfaced back to the agent
|
||||
// as a fresh inbound when chat.db only annotates it with chat_id (or
|
||||
// vice-versa).
|
||||
const scopes = typeof params.scope === "string" ? [params.scope] : params.scope;
|
||||
for (const scope of scopes) {
|
||||
if (!scope) {
|
||||
continue;
|
||||
}
|
||||
for (const messageId of params.messageIds) {
|
||||
if (params.echoCache.has(scope, { messageId })) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const fallbackMessageId = params.messageIds[0];
|
||||
if (!params.text && !fallbackMessageId) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
params.echoCache.has(
|
||||
scope,
|
||||
{ text: params.text, messageId: fallbackMessageId },
|
||||
params.skipIdShortCircuit,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const fallbackMessageId = params.messageIds[0];
|
||||
if (!params.text && !fallbackMessageId) {
|
||||
return false;
|
||||
}
|
||||
return params.echoCache.has(
|
||||
params.scope,
|
||||
{ text: params.text, messageId: fallbackMessageId },
|
||||
params.skipIdShortCircuit,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
type IMessageInboundDispatchDecision = {
|
||||
@@ -237,6 +257,8 @@ export function resolveIMessageInboundDecision(params: {
|
||||
accountId: params.accountId,
|
||||
isGroup,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
sender,
|
||||
});
|
||||
if (
|
||||
@@ -350,6 +372,8 @@ export function resolveIMessageInboundDecision(params: {
|
||||
accountId: params.accountId,
|
||||
isGroup,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
sender,
|
||||
});
|
||||
if (
|
||||
@@ -550,6 +574,25 @@ export function buildIMessageInboundContext(params: {
|
||||
const chatId = decision.chatId;
|
||||
const chatTarget =
|
||||
decision.isGroup && chatId != null ? formatIMessageChatTarget(chatId) : undefined;
|
||||
const messageGuid = normalizeReplyField(params.message.guid);
|
||||
const rememberedMessage = messageGuid
|
||||
? rememberIMessageReplyCache({
|
||||
accountId: decision.route.accountId,
|
||||
messageId: messageGuid,
|
||||
chatGuid: decision.chatGuid,
|
||||
chatIdentifier: decision.chatIdentifier,
|
||||
chatId: decision.chatId,
|
||||
timestamp: Date.now(),
|
||||
isFromMe: false,
|
||||
})
|
||||
: null;
|
||||
// Only surface the gateway-allocated shortId — never the raw chat.db
|
||||
// ROWID. Mixing the two namespaces means the agent can call back with a
|
||||
// numeric id that the gateway will treat as a shortId but never issued
|
||||
// (e.g. chat.db rowid 13 with shortIds only allocated 1..10), and the
|
||||
// resolver throws "no longer available". When we have no guid we have
|
||||
// no stable handle to expose, so drop the field rather than leak rowids.
|
||||
const messageSid = rememberedMessage?.shortId || undefined;
|
||||
|
||||
const replySuffix = decision.replyContext
|
||||
? `\n\n[Replying to ${decision.replyContext.sender ?? "unknown sender"}${
|
||||
@@ -629,7 +672,8 @@ export function buildIMessageInboundContext(params: {
|
||||
SenderId: decision.sender,
|
||||
Provider: "imessage",
|
||||
Surface: "imessage",
|
||||
MessageSid: params.message.id ? String(params.message.id) : undefined,
|
||||
MessageSid: messageSid,
|
||||
MessageSidFull: messageGuid,
|
||||
ReplyToId: decision.replyContext?.id,
|
||||
ReplyToBody: decision.replyContext?.body,
|
||||
ReplyToSender: decision.replyContext?.sender,
|
||||
@@ -657,9 +701,33 @@ function buildIMessageEchoScope(params: {
|
||||
accountId: string;
|
||||
isGroup: boolean;
|
||||
chatId?: number;
|
||||
chatGuid?: string;
|
||||
chatIdentifier?: string;
|
||||
sender: string;
|
||||
}): string {
|
||||
return `${params.accountId}:${params.isGroup ? formatIMessageChatTarget(params.chatId) : `imessage:${params.sender}`}`;
|
||||
}): string[] {
|
||||
// Mirror every shape resolveOutboundEchoScope can persist (see send.ts).
|
||||
// Inbound messages carry chat_id, chat_guid, and chat_identifier when
|
||||
// available, but the outbound side only writes one of them — whichever
|
||||
// shape the caller used. Returning all candidates lets hasIMessageEchoMatch
|
||||
// cross-check, so a chat_guid-keyed send is suppressed even when chat.db
|
||||
// annotates the inbound row with chat_id+chat_identifier (or any other
|
||||
// permutation).
|
||||
const scopes: string[] = [];
|
||||
if (params.isGroup) {
|
||||
const chatIdScope = formatIMessageChatTarget(params.chatId);
|
||||
if (chatIdScope) {
|
||||
scopes.push(`${params.accountId}:${chatIdScope}`);
|
||||
}
|
||||
} else {
|
||||
scopes.push(`${params.accountId}:imessage:${params.sender}`);
|
||||
}
|
||||
if (params.chatGuid) {
|
||||
scopes.push(`${params.accountId}:chat_guid:${params.chatGuid}`);
|
||||
}
|
||||
if (params.chatIdentifier) {
|
||||
scopes.push(`${params.accountId}:chat_identifier:${params.chatIdentifier}`);
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
|
||||
export function describeIMessageEchoDropLog(params: {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSentMessageCache } from "./echo-cache.js";
|
||||
import { rememberPersistedIMessageEcho } from "./persisted-echo-cache.js";
|
||||
|
||||
describe("iMessage sent-message echo cache", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("matches recent text within the same scope", () => {
|
||||
@@ -71,4 +81,73 @@ describe("iMessage sent-message echo cache", () => {
|
||||
expect(cache.has("acct:imessage:+1555", { text: "hello" })).toBe(false);
|
||||
expect(cache.has("acct:imessage:+1555", { messageId: "m-1" })).toBe(true);
|
||||
});
|
||||
|
||||
it("matches persisted echoes written by another process", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-echo-"));
|
||||
tempDirs.push(stateDir);
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
const cache = createSentMessageCache();
|
||||
|
||||
rememberPersistedIMessageEcho({
|
||||
scope: "acct:imessage:+1555",
|
||||
text: "OpenClaw imsg live test",
|
||||
messageId: "guid-1",
|
||||
});
|
||||
|
||||
expect(cache.has("acct:imessage:+1555", { text: "OpenClaw imsg live test" })).toBe(true);
|
||||
expect(cache.has("acct:imessage:+1666", { text: "OpenClaw imsg live test" })).toBe(false);
|
||||
expect(cache.has("acct:imessage:+1555", { messageId: "guid-1" })).toBe(true);
|
||||
});
|
||||
|
||||
it("writes sent-echoes.jsonl 0600 and parent dir 0700", () => {
|
||||
// sent-echoes.jsonl carries scope keys + outbound message text + messageIds.
|
||||
// Same threat model as reply-cache.jsonl: a same-UID hostile process could
|
||||
// enumerate active conversations or inject lines so a future inbound dedupe
|
||||
// call wrongly suppresses a legitimate inbound. Owner-only mode is the
|
||||
// mitigation.
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-echo-perm-"));
|
||||
tempDirs.push(stateDir);
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
|
||||
rememberPersistedIMessageEcho({
|
||||
scope: "acct:imessage:+1555",
|
||||
text: "perm-test",
|
||||
messageId: "guid-perm",
|
||||
});
|
||||
|
||||
const echoFile = path.join(stateDir, "imessage", "sent-echoes.jsonl");
|
||||
const echoDir = path.dirname(echoFile);
|
||||
expect(fs.existsSync(echoFile)).toBe(true);
|
||||
|
||||
const fileMode = fs.statSync(echoFile).mode & 0o777;
|
||||
const dirMode = fs.statSync(echoDir).mode & 0o777;
|
||||
expect(fileMode).toBe(0o600);
|
||||
expect(dirMode).toBe(0o700);
|
||||
});
|
||||
|
||||
it("clamps pre-existing sent-echoes.jsonl from older 0644/0755 to 0600/0700", () => {
|
||||
// Older gateway versions wrote with default modes. After upgrade, the next
|
||||
// remember must clamp the existing file/dir back to owner-only.
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-echo-clamp-"));
|
||||
tempDirs.push(stateDir);
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
|
||||
const imsgDir = path.join(stateDir, "imessage");
|
||||
fs.mkdirSync(imsgDir, { recursive: true, mode: 0o755 });
|
||||
const echoFile = path.join(imsgDir, "sent-echoes.jsonl");
|
||||
fs.writeFileSync(echoFile, "", { mode: 0o644 });
|
||||
fs.chmodSync(imsgDir, 0o755);
|
||||
fs.chmodSync(echoFile, 0o644);
|
||||
|
||||
rememberPersistedIMessageEcho({
|
||||
scope: "acct:imessage:+1555",
|
||||
text: "clamp-test",
|
||||
messageId: "guid-clamp",
|
||||
});
|
||||
|
||||
const fileMode = fs.statSync(echoFile).mode & 0o777;
|
||||
const dirMode = fs.statSync(imsgDir).mode & 0o777;
|
||||
expect(fileMode).toBe(0o600);
|
||||
expect(dirMode).toBe(0o700);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
import {
|
||||
createChannelInboundDebouncer,
|
||||
shouldDebounceTextInbound,
|
||||
@@ -20,7 +21,7 @@ import { isInboundPathAllowed, kindFromMime } from "openclaw/plugin-sdk/media-ru
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
||||
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { createReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { settleReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { danger, logVerbose, shouldLogVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
|
||||
@@ -34,16 +35,22 @@ import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/sess
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime";
|
||||
import { resolveIMessageAccount } from "../accounts.js";
|
||||
import { markIMessageChatRead, sendIMessageTyping } from "../chat.js";
|
||||
import { createIMessageRpcClient, type IMessageRpcClient } from "../client.js";
|
||||
import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "../constants.js";
|
||||
import {
|
||||
resolveIMessageAttachmentRoots,
|
||||
resolveIMessageRemoteAttachmentRoots,
|
||||
} from "../media-contract.js";
|
||||
import { probeIMessage } from "../probe.js";
|
||||
import {
|
||||
getCachedIMessagePrivateApiStatus,
|
||||
imessageRpcSupportsMethod,
|
||||
probeIMessage,
|
||||
} from "../probe.js";
|
||||
import { sendMessageIMessage } from "../send.js";
|
||||
import { normalizeIMessageHandle } from "../targets.js";
|
||||
import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
|
||||
import { combineIMessagePayloads } from "./coalesce.js";
|
||||
import { createIMessageEchoCachingSend, deliverReplies } from "./deliver.js";
|
||||
import { createSentMessageCache } from "./echo-cache.js";
|
||||
import {
|
||||
@@ -83,11 +90,47 @@ async function detectRemoteHostFromCliPath(cliPath: string): Promise<string | un
|
||||
// Fallback: match host-only before imsg command (e.g., ssh -T mac-mini imsg)
|
||||
const hostOnlyMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z][a-zA-Z0-9._-]*)\s+\S*\bimsg\b/);
|
||||
return hostOnlyMatch?.[1];
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// ENOENT / ENOTDIR are expected for non-script cliPaths (just an
|
||||
// executable on disk). Anything else (EACCES, broken symlink) is
|
||||
// worth flagging — silent failure means attachments will fail to find
|
||||
// remote media because remoteHost stays undefined.
|
||||
const code = (err as NodeJS.ErrnoException)?.code;
|
||||
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
||||
logVerbose(
|
||||
`imessage: failed to inspect cliPath ${cliPath} for remoteHost detection: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** One-shot warning when typing/read are gated off due to old imsg build. */
|
||||
const warnIfImsgUpgradeNeeded = (() => {
|
||||
let fired = false;
|
||||
return {
|
||||
fireOnce: (
|
||||
rpcMethods: readonly string[],
|
||||
runtime: { log?: (msg: string) => void; error?: (msg: string) => void },
|
||||
) => {
|
||||
if (fired) {
|
||||
return;
|
||||
}
|
||||
fired = true;
|
||||
const detail =
|
||||
rpcMethods.length === 0
|
||||
? "imsg build pre-dates the rpc_methods capability list"
|
||||
: `imsg rpc_methods=[${rpcMethods.join(", ")}] does not include typing/read`;
|
||||
runtime.log?.(
|
||||
warn(
|
||||
`imessage: typing indicators / read receipts gated off (${detail}). ` +
|
||||
`Upgrade imsg (current bridge needs typing+read in rpc_methods).`,
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
function isRetriableWatchSubscribeStartupError(error: unknown): boolean {
|
||||
return /imsg rpc timeout \(watch\.subscribe\)|imsg rpc (closed|exited|not running)/i.test(
|
||||
String(error),
|
||||
@@ -187,48 +230,88 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
}
|
||||
}
|
||||
|
||||
// When `coalesceSameSenderDms` is enabled and the user has not set an
|
||||
// explicit inbound debounce for this channel, widen the window to 2500 ms.
|
||||
// Apple's split-send for `<command> <URL>` arrives ~0.8-2.0 s apart on most
|
||||
// setups, so the legacy 0 ms default would flush the command alone before
|
||||
// the URL row reaches the debouncer. Mirrors the BlueBubbles policy.
|
||||
const coalesceSameSenderDms = imessageCfg.coalesceSameSenderDms === true;
|
||||
const inboundCfg = cfg.messages?.inbound;
|
||||
const hasExplicitInboundDebounce =
|
||||
typeof inboundCfg?.debounceMs === "number" ||
|
||||
typeof inboundCfg?.byChannel?.imessage === "number";
|
||||
const debounceMsOverride =
|
||||
coalesceSameSenderDms && !hasExplicitInboundDebounce ? 2500 : undefined;
|
||||
|
||||
const { debouncer: inboundDebouncer } = createChannelInboundDebouncer<{
|
||||
message: IMessagePayload;
|
||||
}>({
|
||||
cfg,
|
||||
channel: "imessage",
|
||||
debounceMsOverride,
|
||||
buildKey: (entry) => {
|
||||
const sender = entry.message.sender?.trim();
|
||||
const msg = entry.message;
|
||||
const sender = msg.sender?.trim();
|
||||
if (!sender) {
|
||||
return null;
|
||||
}
|
||||
const conversationId =
|
||||
entry.message.chat_id != null
|
||||
? `chat:${entry.message.chat_id}`
|
||||
: (entry.message.chat_guid ?? entry.message.chat_identifier ?? "unknown");
|
||||
msg.chat_id != null
|
||||
? `chat:${msg.chat_id}`
|
||||
: (msg.chat_guid ?? msg.chat_identifier ?? "unknown");
|
||||
|
||||
// With coalesceSameSenderDms enabled, DMs key on chat:sender so two
|
||||
// distinct user sends — `Dump` followed by a pasted URL that Apple
|
||||
// delivers as a separate row — fall into the same bucket and merge
|
||||
// into one agent turn. Group chats fall through to the legacy key so
|
||||
// shouldDebounce can route them to the instant-dispatch path and
|
||||
// preserve multi-user turn structure.
|
||||
if (coalesceSameSenderDms && msg.is_group !== true) {
|
||||
return `imessage:${accountInfo.accountId}:dm:${conversationId}:${sender}`;
|
||||
}
|
||||
|
||||
return `imessage:${accountInfo.accountId}:${conversationId}:${sender}`;
|
||||
},
|
||||
shouldDebounce: (entry) => {
|
||||
const msg = entry.message;
|
||||
// From-me messages are cached, not processed — never debounce.
|
||||
if (msg.is_from_me === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// With coalesceSameSenderDms enabled, debounce DM messages aggressively
|
||||
// (text, media, control commands) so split-sends — `Dump <URL>`,
|
||||
// `Save 📎image caption`, and rapid floods — merge into one agent
|
||||
// turn. Group chats keep instant dispatch so the bot stays responsive
|
||||
// when multiple people are typing.
|
||||
if (coalesceSameSenderDms) {
|
||||
return msg.is_group !== true;
|
||||
}
|
||||
|
||||
// Legacy gate: text-only, no control commands, no media.
|
||||
return shouldDebounceTextInbound({
|
||||
text: entry.message.text,
|
||||
text: msg.text,
|
||||
cfg,
|
||||
hasMedia: Boolean(entry.message.attachments && entry.message.attachments.length > 0),
|
||||
hasMedia: Boolean(msg.attachments && msg.attachments.length > 0),
|
||||
});
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) {
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
await handleMessageNow(last.message);
|
||||
await handleMessageNow(entries[0].message);
|
||||
return;
|
||||
}
|
||||
const combinedText = entries
|
||||
.map((entry) => entry.message.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const syntheticMessage: IMessagePayload = {
|
||||
...last.message,
|
||||
text: combinedText,
|
||||
attachments: null,
|
||||
};
|
||||
await handleMessageNow(syntheticMessage);
|
||||
|
||||
const combined = combineIMessagePayloads(entries.map((e) => e.message));
|
||||
if (shouldLogVerbose()) {
|
||||
const text = combined.text ?? "";
|
||||
const preview = text.slice(0, 50);
|
||||
const ellipsis = text.length > 50 ? "..." : "";
|
||||
logVerbose(`[imessage] coalesced ${entries.length} messages: "${preview}${ellipsis}"`);
|
||||
}
|
||||
await handleMessageNow(combined);
|
||||
},
|
||||
onError: (err) => {
|
||||
runtime.error?.(`imessage debounce flush failed: ${String(err)}`);
|
||||
@@ -361,7 +444,9 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
});
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
logVerbose(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`);
|
||||
// Pairing relies on the user receiving the challenge — silent
|
||||
// failure here is the user's only "pairing seems broken" signal.
|
||||
runtime.error?.(`imessage pairing reply failed for ${decision.senderId}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -405,14 +490,82 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
);
|
||||
}
|
||||
|
||||
const privateApiStatus = getCachedIMessagePrivateApiStatus(cliPath);
|
||||
const supportsTyping = imessageRpcSupportsMethod(privateApiStatus, "typing");
|
||||
const supportsRead = imessageRpcSupportsMethod(privateApiStatus, "read");
|
||||
if (privateApiStatus?.available === true) {
|
||||
// Surface a single warning per restart when the bridge is up but we
|
||||
// had to gate off typing/read because the imsg build pre-dates the
|
||||
// capability list. Otherwise the user sees no typing bubble / no
|
||||
// "Read" receipt with no visible reason.
|
||||
if (!supportsTyping || !supportsRead) {
|
||||
warnIfImsgUpgradeNeeded.fireOnce(privateApiStatus.rpcMethods, runtime);
|
||||
}
|
||||
}
|
||||
const sendReadReceipts = imessageCfg.sendReadReceipts !== false;
|
||||
const typingTarget = ctxPayload.To;
|
||||
|
||||
if (supportsRead && sendReadReceipts && typingTarget) {
|
||||
try {
|
||||
await markIMessageChatRead(typingTarget, {
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
client: getActiveClient(),
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`imessage: mark read failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
|
||||
cfg,
|
||||
agentId: decision.route.agentId,
|
||||
channel: "imessage",
|
||||
accountId: decision.route.accountId,
|
||||
typing:
|
||||
supportsTyping && typingTarget
|
||||
? {
|
||||
start: async () => {
|
||||
await sendIMessageTyping(typingTarget, true, {
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
client: getActiveClient(),
|
||||
});
|
||||
},
|
||||
stop: async () => {
|
||||
await sendIMessageTyping(typingTarget, false, {
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
client: getActiveClient(),
|
||||
});
|
||||
},
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (msg) => logVerbose(msg),
|
||||
channel: "imessage",
|
||||
action: "start",
|
||||
target: typingTarget,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
onStopError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (msg) => logVerbose(msg),
|
||||
channel: "imessage",
|
||||
action: "stop",
|
||||
target: typingTarget,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const dispatcher = createReplyDispatcher({
|
||||
const {
|
||||
dispatcher,
|
||||
replyOptions: typingReplyOptions,
|
||||
markDispatchIdle,
|
||||
} = createReplyDispatcherWithTyping({
|
||||
...replyPipeline,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, decision.route.agentId),
|
||||
deliver: async (payload, info) => {
|
||||
@@ -513,20 +666,30 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
historyMap: groupHistories,
|
||||
limit: historyLimit,
|
||||
},
|
||||
onPreDispatchFailure: () => settleReplyDispatcher({ dispatcher }),
|
||||
runDispatch: () =>
|
||||
dispatchInboundMessage({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
onPreDispatchFailure: () =>
|
||||
settleReplyDispatcher({
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
disableBlockStreaming:
|
||||
typeof accountInfo.config.blockStreaming === "boolean"
|
||||
? !accountInfo.config.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
onSettled: () => markDispatchIdle(),
|
||||
}),
|
||||
runDispatch: async () => {
|
||||
try {
|
||||
return await dispatchInboundMessage({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...typingReplyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof accountInfo.config.blockStreaming === "boolean"
|
||||
? !accountInfo.config.blockStreaming
|
||||
: undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
markDispatchIdle();
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -535,7 +698,16 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
const handleMessage = async (raw: unknown) => {
|
||||
const message = parseIMessageNotification(raw);
|
||||
if (!message) {
|
||||
logVerbose("imessage: dropping malformed RPC message payload");
|
||||
// A malformed RPC notification means imsg shipped a payload shape
|
||||
// we do not understand — almost always a real bridge bug. Surface
|
||||
// the keys so an operator can correlate without leaking content.
|
||||
const shape =
|
||||
raw && typeof raw === "object" && !Array.isArray(raw)
|
||||
? Object.keys(raw as Record<string, unknown>)
|
||||
.toSorted()
|
||||
.join(",")
|
||||
: typeof raw;
|
||||
runtime.error?.(`imessage: dropping malformed RPC message payload (keys=${shape})`);
|
||||
return;
|
||||
}
|
||||
await inboundDebouncer.enqueue({ message });
|
||||
|
||||
236
extensions/imessage/src/monitor/persisted-echo-cache.ts
Normal file
236
extensions/imessage/src/monitor/persisted-echo-cache.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
|
||||
type PersistedEchoEntry = {
|
||||
scope: string;
|
||||
text?: string;
|
||||
messageId?: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const PERSISTED_ECHO_TTL_MS = 2 * 60 * 1000;
|
||||
const MAX_PERSISTED_ECHO_ENTRIES = 256;
|
||||
|
||||
// sent-echoes.jsonl carries scope keys + outbound message text + messageIds.
|
||||
// A hostile same-UID process could otherwise (a) read the file to enumerate
|
||||
// active conversations and outbound content, or (b) inject lines so a future
|
||||
// inbound dedupe call wrongly suppresses a legitimate inbound message. Owner-
|
||||
// only mode on both the directory and file closes that vector — defaults are
|
||||
// 0755/0644 which are world-readable on a multi-user Mac.
|
||||
const PERSISTED_ECHO_DIR_MODE = 0o700;
|
||||
const PERSISTED_ECHO_FILE_MODE = 0o600;
|
||||
|
||||
function resolvePersistedEchoPath(): string {
|
||||
return path.join(resolveStateDir(), "imessage", "sent-echoes.jsonl");
|
||||
}
|
||||
|
||||
function clampPersistedEchoModes(filePath: string): void {
|
||||
// mkdirSync's mode is masked by umask and only applies on creation. If the
|
||||
// dir or file already exists from an older gateway version, clamp now.
|
||||
try {
|
||||
fs.chmodSync(path.dirname(filePath), PERSISTED_ECHO_DIR_MODE);
|
||||
fs.chmodSync(filePath, PERSISTED_ECHO_FILE_MODE);
|
||||
} catch {
|
||||
// best-effort — fs may not support chmod on every platform
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(text: string | undefined): string | undefined {
|
||||
const normalized = text?.replace(/\r\n?/g, "\n").trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function normalizeMessageId(messageId: string | undefined): string | undefined {
|
||||
const normalized = messageId?.trim();
|
||||
if (!normalized || normalized === "ok" || normalized === "unknown") {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseEntry(line: string): PersistedEchoEntry | null {
|
||||
try {
|
||||
const parsed = JSON.parse(line) as Partial<PersistedEchoEntry>;
|
||||
if (typeof parsed.scope !== "string" || typeof parsed.timestamp !== "number") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
scope: parsed.scope,
|
||||
text: typeof parsed.text === "string" ? parsed.text : undefined,
|
||||
messageId: typeof parsed.messageId === "string" ? parsed.messageId : undefined,
|
||||
timestamp: parsed.timestamp,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory mirror of the persisted file. The echo cache is consulted on
|
||||
// every inbound message; without a cache, group-chat bursts trigger a
|
||||
// readFileSync + JSON.parse for every member's reply. The mirror is
|
||||
// invalidated by file mtime so concurrent gateway processes (rare) and
|
||||
// post-restart hydrate still see fresh data.
|
||||
let mirror: { entries: PersistedEchoEntry[]; mtimeMs: number } | null = null;
|
||||
let persistenceFailureLogged = false;
|
||||
function reportFailure(scope: string, err: unknown): void {
|
||||
if (persistenceFailureLogged) {
|
||||
return;
|
||||
}
|
||||
persistenceFailureLogged = true;
|
||||
logVerbose(`imessage echo-cache: ${scope} disabled after first failure: ${String(err)}`);
|
||||
}
|
||||
|
||||
function loadMirrorIfStale(): void {
|
||||
const filePath = resolvePersistedEchoPath();
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
mtimeMs = fs.statSync(filePath).mtimeMs;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
|
||||
reportFailure("stat", err);
|
||||
}
|
||||
mirror = { entries: [], mtimeMs: 0 };
|
||||
return;
|
||||
}
|
||||
if (mirror && mirror.mtimeMs === mtimeMs) {
|
||||
return;
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, "utf8");
|
||||
} catch (err) {
|
||||
reportFailure("read", err);
|
||||
mirror = { entries: [], mtimeMs };
|
||||
return;
|
||||
}
|
||||
const cutoff = Date.now() - PERSISTED_ECHO_TTL_MS;
|
||||
const entries = raw
|
||||
.split(/\n+/)
|
||||
.map(parseEntry)
|
||||
.filter((entry): entry is PersistedEchoEntry => Boolean(entry && entry.timestamp >= cutoff))
|
||||
.slice(-MAX_PERSISTED_ECHO_ENTRIES);
|
||||
mirror = { entries, mtimeMs };
|
||||
}
|
||||
|
||||
function readRecentEntries(): PersistedEchoEntry[] {
|
||||
loadMirrorIfStale();
|
||||
return mirror?.entries ?? [];
|
||||
}
|
||||
|
||||
// Trigger compaction once the on-disk file grows past 2x the cap or holds
|
||||
// stale entries beyond the TTL window. Until then, every remember is an
|
||||
// O(1) append rather than a full rewrite — group-chat bursts that send 5+
|
||||
// outbound messages back-to-back used to write the entire file 5+ times.
|
||||
const COMPACT_AT_ENTRY_COUNT = MAX_PERSISTED_ECHO_ENTRIES * 2;
|
||||
|
||||
function compactRecentEntries(entries: PersistedEchoEntry[]): void {
|
||||
const filePath = resolvePersistedEchoPath();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: PERSISTED_ECHO_DIR_MODE });
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
entries.map((entry) => JSON.stringify(entry)).join("\n") + (entries.length ? "\n" : ""),
|
||||
{ encoding: "utf8", mode: PERSISTED_ECHO_FILE_MODE },
|
||||
);
|
||||
clampPersistedEchoModes(filePath);
|
||||
} catch (err) {
|
||||
reportFailure("compact", err);
|
||||
// Persistence failed; don't update the in-memory mirror so the next
|
||||
// read still reflects what's actually on disk.
|
||||
return;
|
||||
}
|
||||
// Update mirror to reflect what we just wrote, so the next has() call
|
||||
// doesn't re-read the file we just authored.
|
||||
let mtimeMs = 0;
|
||||
try {
|
||||
mtimeMs = fs.statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
// ignore — stale mirror will refresh on next access
|
||||
}
|
||||
mirror = { entries: [...entries], mtimeMs };
|
||||
}
|
||||
|
||||
function appendEntry(entry: PersistedEchoEntry): void {
|
||||
const filePath = resolvePersistedEchoPath();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: PERSISTED_ECHO_DIR_MODE });
|
||||
fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: PERSISTED_ECHO_FILE_MODE,
|
||||
});
|
||||
// Always clamp — appendFileSync's `mode` only applies on creation, and
|
||||
// an older gateway version may have left an existing 0644 file behind.
|
||||
// chmod is microseconds; doing it every append keeps the security
|
||||
// guarantee monotonic instead of conditional on creation order.
|
||||
clampPersistedEchoModes(filePath);
|
||||
} catch (err) {
|
||||
reportFailure("append", err);
|
||||
return;
|
||||
}
|
||||
// Mirror stays in sync without re-reading the file: append our entry to
|
||||
// the in-memory copy and bump the mtime to whatever the FS reports now.
|
||||
let mtimeMs = 0;
|
||||
try {
|
||||
mtimeMs = fs.statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (mirror) {
|
||||
mirror = { entries: [...mirror.entries, entry], mtimeMs };
|
||||
} else {
|
||||
mirror = { entries: [entry], mtimeMs };
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberPersistedIMessageEcho(params: {
|
||||
scope: string;
|
||||
text?: string;
|
||||
messageId?: string;
|
||||
}): void {
|
||||
const entry: PersistedEchoEntry = {
|
||||
scope: params.scope,
|
||||
text: normalizeText(params.text),
|
||||
messageId: normalizeMessageId(params.messageId),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
if (!entry.text && !entry.messageId) {
|
||||
return;
|
||||
}
|
||||
// Make sure the mirror reflects whatever's on disk before we decide
|
||||
// whether a compaction is due.
|
||||
loadMirrorIfStale();
|
||||
appendEntry(entry);
|
||||
const total = mirror?.entries.length ?? 0;
|
||||
const cutoff = Date.now() - PERSISTED_ECHO_TTL_MS;
|
||||
const oldestStale = mirror?.entries[0] && mirror.entries[0].timestamp < cutoff;
|
||||
if (total > COMPACT_AT_ENTRY_COUNT || oldestStale) {
|
||||
const fresh = (mirror?.entries ?? []).filter((e) => e.timestamp >= cutoff);
|
||||
compactRecentEntries(fresh.slice(-MAX_PERSISTED_ECHO_ENTRIES));
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPersistedIMessageEcho(params: {
|
||||
scope: string;
|
||||
text?: string;
|
||||
messageId?: string;
|
||||
}): boolean {
|
||||
const text = normalizeText(params.text);
|
||||
const messageId = normalizeMessageId(params.messageId);
|
||||
if (!text && !messageId) {
|
||||
return false;
|
||||
}
|
||||
for (const entry of readRecentEntries()) {
|
||||
if (entry.scope !== params.scope) {
|
||||
continue;
|
||||
}
|
||||
if (messageId && entry.messageId === messageId) {
|
||||
return true;
|
||||
}
|
||||
if (text && entry.text === text) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -104,4 +104,20 @@ describe("detectReflectedContent", () => {
|
||||
const result = detectReflectedContent("This is a <thought experiment I ran");
|
||||
expect(result.isReflection).toBe(false);
|
||||
});
|
||||
|
||||
it("detects reflected ACP channel error replies", () => {
|
||||
const result = detectReflectedContent(
|
||||
"ACP error (ACP_SESSION_INIT_FAILED): ACP metadata is missing for agent:codex",
|
||||
);
|
||||
expect(result.isReflection).toBe(true);
|
||||
expect(result.matchedLabels).toContain("acp-error");
|
||||
});
|
||||
|
||||
it("detects reflected gateway auth failure replies", () => {
|
||||
const result = detectReflectedContent(
|
||||
"⚠️ Missing API key for OpenAI on the gateway. Use `openai-codex/gpt-5.5`, or set `OPENAI_API_KEY`, then try again.",
|
||||
);
|
||||
expect(result.isReflection).toBe(true);
|
||||
expect(result.matchedLabels).toContain("gateway-missing-api-key");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*
|
||||
const RELEVANT_MEMORIES_TAG_RE = /<\s*\/?\s*relevant[-_]memories\b[^<>]*>/i;
|
||||
// Require closing `>` to avoid false-positives on phrases like "<final answer>".
|
||||
const FINAL_TAG_RE = /<\s*\/?\s*final\b[^<>]*>/i;
|
||||
const ACP_ERROR_RE = /\bACP error\s*\(\s*ACP_[A-Z0-9_]+\s*\):/i;
|
||||
const GATEWAY_MISSING_API_KEY_RE = /\bMissing API key for\b.+\bon the gateway\b/i;
|
||||
|
||||
const REFLECTION_PATTERNS: Array<{ re: RegExp; label: string }> = [
|
||||
{ re: INTERNAL_SEPARATOR_RE, label: "internal-separator" },
|
||||
@@ -20,6 +22,8 @@ const REFLECTION_PATTERNS: Array<{ re: RegExp; label: string }> = [
|
||||
{ re: THINKING_TAG_RE, label: "thinking-tag" },
|
||||
{ re: RELEVANT_MEMORIES_TAG_RE, label: "relevant-memories-tag" },
|
||||
{ re: FINAL_TAG_RE, label: "final-tag" },
|
||||
{ re: ACP_ERROR_RE, label: "acp-error" },
|
||||
{ re: GATEWAY_MISSING_API_KEY_RE, label: "gateway-missing-api-key" },
|
||||
];
|
||||
|
||||
type ReflectionDetection = {
|
||||
|
||||
94
extensions/imessage/src/probe.test.ts
Normal file
94
extensions/imessage/src/probe.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { imessageRpcSupportsMethod } from "./probe.js";
|
||||
|
||||
describe("imessageRpcSupportsMethod", () => {
|
||||
it("returns false when the bridge is not available", () => {
|
||||
expect(
|
||||
imessageRpcSupportsMethod(
|
||||
{
|
||||
available: false,
|
||||
v2Ready: false,
|
||||
selectors: {},
|
||||
rpcMethods: ["typing", "read"],
|
||||
},
|
||||
"typing",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when status is undefined", () => {
|
||||
expect(imessageRpcSupportsMethod(undefined, "typing")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when the requested method is in the explicit rpcMethods list", () => {
|
||||
expect(
|
||||
imessageRpcSupportsMethod(
|
||||
{
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
rpcMethods: ["chats.list", "send", "typing", "read"],
|
||||
},
|
||||
"typing",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a method not in the explicit rpcMethods list", () => {
|
||||
expect(
|
||||
imessageRpcSupportsMethod(
|
||||
{
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
rpcMethods: ["chats.list", "send"],
|
||||
},
|
||||
"typing",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the foundational set when rpcMethods is empty (older imsg builds)", () => {
|
||||
// Older imsg builds shipped chats.list/send/watch.*/messages.history
|
||||
// before the rpc_methods capability list existed. Without this fallback
|
||||
// we'd silently break send() on every gateway running an older imsg.
|
||||
const oldBuild = {
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
rpcMethods: [],
|
||||
};
|
||||
for (const method of [
|
||||
"chats.list",
|
||||
"messages.history",
|
||||
"watch.subscribe",
|
||||
"watch.unsubscribe",
|
||||
"send",
|
||||
]) {
|
||||
expect(imessageRpcSupportsMethod(oldBuild, method)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("gates newer methods off when rpcMethods is empty (forces upgrade for typing/read/group)", () => {
|
||||
const oldBuild = {
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
rpcMethods: [],
|
||||
};
|
||||
for (const method of [
|
||||
"typing",
|
||||
"read",
|
||||
"chats.create",
|
||||
"chats.delete",
|
||||
"chats.markUnread",
|
||||
"group.rename",
|
||||
"group.setIcon",
|
||||
"group.addParticipant",
|
||||
"group.removeParticipant",
|
||||
"group.leave",
|
||||
]) {
|
||||
expect(imessageRpcSupportsMethod(oldBuild, method)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,13 @@ export { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
|
||||
|
||||
export type IMessageProbe = BaseProbeResult & {
|
||||
fatal?: boolean;
|
||||
privateApi?: {
|
||||
available: boolean;
|
||||
v2Ready: boolean;
|
||||
selectors: Record<string, boolean>;
|
||||
rpcMethods: string[];
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type IMessageProbeOptions = {
|
||||
@@ -28,7 +35,21 @@ type RpcSupportResult = {
|
||||
fatal?: boolean;
|
||||
};
|
||||
|
||||
const rpcSupportCache = new Map<string, RpcSupportResult>();
|
||||
// 5-minute TTL on the rpc-support cache lets us cope with `brew upgrade imsg`
|
||||
// happening mid-process without forcing a gateway restart.
|
||||
const RPC_SUPPORT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
// 10-second negative TTL on the private-api status cache lets a flurry of
|
||||
// agent actions during a bridge outage avoid serializing on probe RPC.
|
||||
const PRIVATE_API_NEGATIVE_TTL_MS = 10 * 1000;
|
||||
|
||||
type RpcSupportCacheEntry = { result: RpcSupportResult; expiresAt: number };
|
||||
type PrivateApiCacheEntry = {
|
||||
status: NonNullable<IMessageProbe["privateApi"]>;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const rpcSupportCache = new Map<string, RpcSupportCacheEntry>();
|
||||
const bridgeStatusCache = new Map<string, PrivateApiCacheEntry>();
|
||||
|
||||
function isDefaultLocalIMessageCliPath(cliPath: string): boolean {
|
||||
const trimmed = cliPath.trim();
|
||||
@@ -47,8 +68,8 @@ export function resolveIMessageNonMacHostError(
|
||||
|
||||
async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise<RpcSupportResult> {
|
||||
const cached = rpcSupportCache.get(cliPath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.result;
|
||||
}
|
||||
try {
|
||||
const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs });
|
||||
@@ -60,12 +81,18 @@ async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise<RpcS
|
||||
fatal: true,
|
||||
error: 'imsg CLI does not support the "rpc" subcommand (update imsg)',
|
||||
};
|
||||
rpcSupportCache.set(cliPath, fatal);
|
||||
rpcSupportCache.set(cliPath, {
|
||||
result: fatal,
|
||||
expiresAt: Date.now() + RPC_SUPPORT_CACHE_TTL_MS,
|
||||
});
|
||||
return fatal;
|
||||
}
|
||||
if (result.code === 0) {
|
||||
const supported = { supported: true };
|
||||
rpcSupportCache.set(cliPath, supported);
|
||||
rpcSupportCache.set(cliPath, {
|
||||
result: supported,
|
||||
expiresAt: Date.now() + RPC_SUPPORT_CACHE_TTL_MS,
|
||||
});
|
||||
return supported;
|
||||
}
|
||||
return {
|
||||
@@ -77,6 +104,172 @@ async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise<RpcS
|
||||
}
|
||||
}
|
||||
|
||||
function parseStatusPayload(stdout: string): {
|
||||
payload: Record<string, unknown> | null;
|
||||
firstLineSnippet?: string;
|
||||
} {
|
||||
const lines = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
for (const line of lines.toReversed()) {
|
||||
try {
|
||||
const value = JSON.parse(line);
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return { payload: value as Record<string, unknown> };
|
||||
}
|
||||
} catch {
|
||||
// Continue scanning earlier JSONL records.
|
||||
}
|
||||
}
|
||||
// No JSONL line parsed. Surface a small snippet of the first non-empty
|
||||
// line so the operator can grep imsg release notes if the status output
|
||||
// schema has shifted.
|
||||
const snippet = lines[0]?.slice(0, 120);
|
||||
return { payload: null, firstLineSnippet: snippet };
|
||||
}
|
||||
|
||||
function selectorsFromPayload(payload: Record<string, unknown>): Record<string, boolean> {
|
||||
const raw = payload.selectors;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
const selectors: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (typeof value === "boolean") {
|
||||
selectors[key] = value;
|
||||
}
|
||||
}
|
||||
return selectors;
|
||||
}
|
||||
|
||||
function rpcMethodsFromPayload(payload: Record<string, unknown>): string[] {
|
||||
const raw = payload.rpc_methods;
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
return raw.filter((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
|
||||
// Methods that have always existed on imsg's rpc surface, before the
|
||||
// `rpc_methods` capability list was added. An older imsg build that
|
||||
// reports `available: true` but ships no rpc_methods array is assumed to
|
||||
// support these — gating them off would silently break the integration
|
||||
// for everyone who hasn't upgraded yet.
|
||||
const FOUNDATIONAL_RPC_METHODS = new Set<string>([
|
||||
"chats.list",
|
||||
"messages.history",
|
||||
"watch.subscribe",
|
||||
"watch.unsubscribe",
|
||||
"send",
|
||||
]);
|
||||
|
||||
export function imessageRpcSupportsMethod(
|
||||
status: IMessageProbe["privateApi"] | undefined,
|
||||
method: string,
|
||||
): boolean {
|
||||
if (!status?.available) {
|
||||
return false;
|
||||
}
|
||||
if (status.rpcMethods.length === 0) {
|
||||
// Older imsg builds (pre-rpc_methods): assume the foundational set,
|
||||
// gate every newer method off until the user upgrades. This keeps
|
||||
// chats.list/send/watch working while making typing/read/group.* etc.
|
||||
// explicit-upgrade-required.
|
||||
return FOUNDATIONAL_RPC_METHODS.has(method);
|
||||
}
|
||||
return status.rpcMethods.includes(method);
|
||||
}
|
||||
|
||||
export function getCachedIMessagePrivateApiStatus(
|
||||
cliPath?: string | null,
|
||||
): IMessageProbe["privateApi"] | undefined {
|
||||
const key = cliPath?.trim() || "imsg";
|
||||
const entry = bridgeStatusCache.get(key);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
// Negative cache entries expire so a flurry of agent actions during a
|
||||
// bridge outage don't all serialize on a re-probe.
|
||||
if (entry.expiresAt > 0 && entry.expiresAt < Date.now()) {
|
||||
bridgeStatusCache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.status;
|
||||
}
|
||||
|
||||
export function clearIMessagePrivateApiCache(cliPath?: string): void {
|
||||
if (cliPath) {
|
||||
const key = cliPath.trim() || "imsg";
|
||||
bridgeStatusCache.delete(key);
|
||||
rpcSupportCache.delete(key);
|
||||
} else {
|
||||
bridgeStatusCache.clear();
|
||||
rpcSupportCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeIMessagePrivateApi(
|
||||
cliPath: string,
|
||||
timeoutMs: number,
|
||||
options: { forceRefresh?: boolean } = {},
|
||||
): Promise<NonNullable<IMessageProbe["privateApi"]>> {
|
||||
const key = cliPath.trim() || "imsg";
|
||||
if (!options.forceRefresh) {
|
||||
const entry = bridgeStatusCache.get(key);
|
||||
if (entry) {
|
||||
if (entry.status.available) {
|
||||
return entry.status;
|
||||
}
|
||||
if (entry.expiresAt > Date.now()) {
|
||||
return entry.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await runCommandWithTimeout([key, "status", "--json"], { timeoutMs });
|
||||
const combined = `${result.stdout}\n${result.stderr}`.trim();
|
||||
const { payload, firstLineSnippet } = parseStatusPayload(result.stdout);
|
||||
const selectors = payload ? selectorsFromPayload(payload) : {};
|
||||
const rpcMethods = payload ? rpcMethodsFromPayload(payload) : [];
|
||||
const advancedFeatures = payload?.advanced_features === true;
|
||||
const v2Ready = payload?.v2_ready === true;
|
||||
const status: NonNullable<IMessageProbe["privateApi"]> = {
|
||||
available: result.code === 0 && advancedFeatures && v2Ready,
|
||||
v2Ready,
|
||||
selectors,
|
||||
rpcMethods,
|
||||
...(result.code === 0
|
||||
? !payload && firstLineSnippet
|
||||
? {
|
||||
error:
|
||||
`imsg status --json returned no parseable JSONL ` +
|
||||
`(first line: "${firstLineSnippet}") — output schema may have changed`,
|
||||
}
|
||||
: {}
|
||||
: { error: combined || `imsg status --json failed (code ${String(result.code)})` }),
|
||||
};
|
||||
bridgeStatusCache.set(key, {
|
||||
status,
|
||||
expiresAt: status.available ? 0 : Date.now() + PRIVATE_API_NEGATIVE_TTL_MS,
|
||||
});
|
||||
return status;
|
||||
} catch (err) {
|
||||
const status: NonNullable<IMessageProbe["privateApi"]> = {
|
||||
available: false,
|
||||
v2Ready: false,
|
||||
selectors: {},
|
||||
rpcMethods: [],
|
||||
error: String(err),
|
||||
};
|
||||
bridgeStatusCache.set(key, {
|
||||
status,
|
||||
expiresAt: Date.now() + PRIVATE_API_NEGATIVE_TTL_MS,
|
||||
});
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe iMessage RPC availability.
|
||||
* @param timeoutMs - Explicit timeout in ms. If undefined, uses config or default.
|
||||
@@ -112,6 +305,8 @@ export async function probeIMessage(
|
||||
};
|
||||
}
|
||||
|
||||
const privateApi = await probeIMessagePrivateApi(cliPath, effectiveTimeout);
|
||||
|
||||
const client = await createIMessageRpcClient({
|
||||
cliPath,
|
||||
dbPath,
|
||||
@@ -119,9 +314,9 @@ export async function probeIMessage(
|
||||
});
|
||||
try {
|
||||
await client.request("chats.list", { limit: 1 }, { timeoutMs: effectiveTimeout });
|
||||
return { ok: true };
|
||||
return { ok: true, privateApi };
|
||||
} catch (err) {
|
||||
return { ok: false, error: String(err) };
|
||||
return { ok: false, error: String(err), privateApi };
|
||||
} finally {
|
||||
await client.stop();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
|
||||
import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js";
|
||||
import { extractMarkdownFormatRuns } from "./markdown-format.js";
|
||||
import { rememberIMessageReplyCache } from "./monitor-reply-cache.js";
|
||||
import { rememberPersistedIMessageEcho } from "./monitor/persisted-echo-cache.js";
|
||||
import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js";
|
||||
|
||||
type IMessageSendOpts = {
|
||||
@@ -140,6 +143,22 @@ function createIMessageSendReceipt(params: {
|
||||
return createMessageReceiptFromOutboundResults(receiptParams);
|
||||
}
|
||||
|
||||
function resolveOutboundEchoScope(params: {
|
||||
accountId: string;
|
||||
target: ReturnType<typeof parseIMessageTarget>;
|
||||
}): string | null {
|
||||
if (params.target.kind === "chat_id") {
|
||||
return `${params.accountId}:${formatIMessageChatTarget(params.target.chatId)}`;
|
||||
}
|
||||
if (params.target.kind === "chat_guid") {
|
||||
return `${params.accountId}:chat_guid:${params.target.chatGuid}`;
|
||||
}
|
||||
if (params.target.kind === "chat_identifier") {
|
||||
return `${params.accountId}:chat_identifier:${params.target.chatIdentifier}`;
|
||||
}
|
||||
return `${params.accountId}:imessage:${params.target.to}`;
|
||||
}
|
||||
|
||||
export async function sendMessageIMessage(
|
||||
to: string,
|
||||
text: string,
|
||||
@@ -194,6 +213,17 @@ export async function sendMessageIMessage(
|
||||
if (!message.trim() && !filePath) {
|
||||
throw new Error("iMessage send requires text or media");
|
||||
}
|
||||
// Extract markdown bold/italic/underline/strikethrough into typed-run
|
||||
// ranges that the imsg bridge applies via attributedBody. macOS 15+
|
||||
// recipients render the runs natively; earlier macOS recipients still
|
||||
// see the marker-stripped text without literal asterisks.
|
||||
const formatted = message.trim()
|
||||
? extractMarkdownFormatRuns(message)
|
||||
: { text: message, ranges: [] };
|
||||
message = formatted.text;
|
||||
if (!message.trim() && !filePath) {
|
||||
throw new Error("iMessage send requires text or media");
|
||||
}
|
||||
const resolvedReplyToId = sanitizeReplyToId(opts.replyToId);
|
||||
const params: Record<string, unknown> = {
|
||||
text: message,
|
||||
@@ -203,6 +233,9 @@ export async function sendMessageIMessage(
|
||||
if (resolvedReplyToId) {
|
||||
params.reply_to = resolvedReplyToId;
|
||||
}
|
||||
if (formatted.ranges.length > 0) {
|
||||
params.formatting = formatted.ranges;
|
||||
}
|
||||
if (filePath) {
|
||||
params.file = filePath;
|
||||
}
|
||||
@@ -229,6 +262,34 @@ export async function sendMessageIMessage(
|
||||
});
|
||||
const resolvedId = resolveMessageId(result);
|
||||
const messageId = resolvedId ?? (result?.ok ? "ok" : "unknown");
|
||||
const echoScope = resolveOutboundEchoScope({ accountId: account.accountId, target });
|
||||
if (echoScope) {
|
||||
rememberPersistedIMessageEcho({
|
||||
scope: echoScope,
|
||||
text: message,
|
||||
messageId: resolvedId ?? undefined,
|
||||
});
|
||||
}
|
||||
// Record the outbound message in the reply cache with isFromMe=true so
|
||||
// edit/unsend actions can verify the agent actually sent the message
|
||||
// before dispatching. Inbound recording (in monitor/inbound-processing)
|
||||
// sets isFromMe=false, so the cache distinguishes own-sent from received.
|
||||
if (resolvedId) {
|
||||
rememberIMessageReplyCache({
|
||||
accountId: account.accountId,
|
||||
messageId: resolvedId,
|
||||
chatGuid: target.kind === "chat_guid" ? target.chatGuid : undefined,
|
||||
chatIdentifier:
|
||||
target.kind === "chat_identifier"
|
||||
? target.chatIdentifier
|
||||
: target.kind === "handle"
|
||||
? `${target.service === "sms" ? "SMS" : "iMessage"};-;${target.to}`
|
||||
: undefined,
|
||||
chatId: target.kind === "chat_id" ? target.chatId : undefined,
|
||||
timestamp: Date.now(),
|
||||
isFromMe: true,
|
||||
});
|
||||
}
|
||||
return {
|
||||
messageId,
|
||||
sentText: message,
|
||||
|
||||
@@ -171,6 +171,7 @@ export const imessageCompletionNote = {
|
||||
lines: [
|
||||
"Run OpenClaw on the Mac signed into Messages, or set cliPath to an SSH wrapper that runs imsg on that Mac.",
|
||||
"Linux/Windows hosts cannot run the default local imsg path directly.",
|
||||
"Run `imsg launch`, then `openclaw channels status --probe` to verify private API actions.",
|
||||
"Ensure OpenClaw has Full Disk Access to Messages DB.",
|
||||
"Grant Automation permission for Messages when prompted.",
|
||||
"List chats with: imsg chats --limit 20",
|
||||
|
||||
@@ -166,4 +166,12 @@ describe("createIMessageTestPlugin", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes seeded private API actions for binding contract tests", () => {
|
||||
const plugin = createIMessageTestPlugin();
|
||||
|
||||
expect(plugin.actions?.describeMessageTool({} as never)?.actions).toEqual(
|
||||
expect.arrayContaining(["react", "edit", "unsend", "reply", "sendWithEffect", "upload-file"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -29,6 +29,20 @@ export type IMessageAccountConfig = {
|
||||
dbPath?: string;
|
||||
/** Remote SSH host token for SCP attachment fetches (`host` or `user@host`). */
|
||||
remoteHost?: string;
|
||||
/** Enable or disable private API message actions. */
|
||||
actions?: {
|
||||
reactions?: boolean;
|
||||
edit?: boolean;
|
||||
unsend?: boolean;
|
||||
reply?: boolean;
|
||||
sendWithEffect?: boolean;
|
||||
renameGroup?: boolean;
|
||||
setGroupIcon?: boolean;
|
||||
addParticipant?: boolean;
|
||||
removeParticipant?: boolean;
|
||||
leaveGroup?: boolean;
|
||||
sendAttachment?: boolean;
|
||||
};
|
||||
/** Optional default send service (imessage|sms|auto). */
|
||||
service?: "imessage" | "sms" | "auto";
|
||||
/** Optional default region (used when sending SMS). */
|
||||
@@ -73,6 +87,18 @@ export type IMessageAccountConfig = {
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** When private API is available, mark inbound chats read before dispatch (default: true). */
|
||||
sendReadReceipts?: boolean;
|
||||
/**
|
||||
* Merge consecutive same-sender DM rows from `chat.db` into a single agent
|
||||
* turn. Mirrors `channels.bluebubbles.coalesceSameSenderDms` so Apple's
|
||||
* split-send (`<command> <URL>` arriving as two separate rows ~0.8-2.0 s
|
||||
* apart) lands as one merged message. DM-only — group chats keep instant
|
||||
* per-message dispatch. Widens the default inbound debounce window to
|
||||
* 2500 ms when enabled without an explicit
|
||||
* `messages.inbound.byChannel.imessage`. Default: `false`.
|
||||
*/
|
||||
coalesceSameSenderDms?: boolean;
|
||||
groups?: Record<
|
||||
string,
|
||||
{
|
||||
|
||||
@@ -1350,6 +1350,23 @@ export const IrcConfigSchema = IrcAccountSchemaBase.extend({
|
||||
}
|
||||
});
|
||||
|
||||
const IMessageActionSchema = z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
edit: z.boolean().optional(),
|
||||
unsend: z.boolean().optional(),
|
||||
reply: z.boolean().optional(),
|
||||
sendWithEffect: z.boolean().optional(),
|
||||
renameGroup: z.boolean().optional(),
|
||||
setGroupIcon: z.boolean().optional(),
|
||||
addParticipant: z.boolean().optional(),
|
||||
removeParticipant: z.boolean().optional(),
|
||||
leaveGroup: z.boolean().optional(),
|
||||
sendAttachment: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const IMessageAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
@@ -1363,6 +1380,7 @@ export const IMessageAccountSchemaBase = z
|
||||
.string()
|
||||
.refine(isSafeScpRemoteHost, "expected SSH host or user@host (no spaces/options)")
|
||||
.optional(),
|
||||
actions: IMessageActionSchema,
|
||||
service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
|
||||
region: z.string().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
@@ -1382,10 +1400,13 @@ export const IMessageAccountSchemaBase = z
|
||||
.array(z.string().refine(isValidInboundPathRootPattern, "expected absolute path root"))
|
||||
.optional(),
|
||||
mediaMaxMb: z.number().int().positive().optional(),
|
||||
probeTimeoutMs: z.number().int().positive().optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
sendReadReceipts: z.boolean().optional(),
|
||||
coalesceSameSenderDms: z.boolean().optional(),
|
||||
groups: z
|
||||
.record(
|
||||
z.string(),
|
||||
|
||||
Reference in New Issue
Block a user