Skip to content

Commit 39ea257

Browse files
committed
fix(imessage): harden outbound send transport
1 parent ec0f311 commit 39ea257

14 files changed

Lines changed: 253 additions & 48 deletions

docs/channels/imessage.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,31 @@ imessage: suppressed stale inbound backlog account=<id> sent=<iso> recovery=<boo
763763

764764
</Accordion>
765765

766+
<Accordion title="Messages send but inbound iMessages do not arrive">
767+
First prove whether the message reached the local Mac. If `chat.db` does not change, OpenClaw cannot receive the message even when `imsg status --json` reports a healthy bridge.
768+
769+
```bash
770+
imsg chats --limit 10 --json
771+
imsg watch --chat-id <chat-id> --json
772+
sqlite3 ~/Library/Messages/chat.db \
773+
"select datetime(max(date)/1000000000 + 978307200, 'unixepoch', 'localtime'), max(ROWID) from message;"
774+
```
775+
776+
If phone-sent messages create no new rows, repair the macOS Messages and Apple Push layer before changing OpenClaw config. A one-shot service refresh is often enough:
777+
778+
```bash
779+
launchctl kickstart -k system/com.apple.apsd
780+
launchctl kickstart -k gui/$(id -u)/com.apple.CommCenter
781+
launchctl kickstart -k gui/$(id -u)/com.apple.identityservicesd
782+
launchctl kickstart -k gui/$(id -u)/com.apple.imagent
783+
imsg launch
784+
openclaw gateway restart
785+
```
786+
787+
Send a fresh iMessage from the phone and confirm a new `chat.db` row or `imsg watch` event before debugging OpenClaw sessions. Do not run this as a periodic bridge-relaunch loop; repeated `imsg launch` plus gateway restarts during active work can interrupt deliveries and strand in-flight channel runs.
788+
789+
</Accordion>
790+
766791
<Accordion title="Gateway is not running on macOS">
767792
The default `cliPath: "imsg"` must run on the Mac signed into Messages. On Linux or Windows, set `channels.imessage.cliPath` to a wrapper script that SSHes to that Mac and runs `imsg "$@"`.
768793

docs/gateway/config-channels.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,7 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
615615
remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"],
616616
mediaMaxMb: 16,
617617
service: "auto",
618+
sendTransport: "auto",
618619
region: "US",
619620
actions: {
620621
reactions: true,
@@ -637,6 +638,7 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
637638
- `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default: `/Users/*/Library/Messages/Attachments`).
638639
- SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.
639640
- `channels.imessage.configWrites`: allow or deny iMessage-initiated config writes.
641+
- `channels.imessage.sendTransport`: preferred `imsg` RPC send transport for normal outbound replies. `auto` (default) uses the IMCore bridge for existing chats when it is running, then falls back to AppleScript; `bridge` requires private-API delivery; `applescript` forces the public Messages automation path.
640642
- `channels.imessage.actions.*`: enable private API actions that are also gated by `imsg status` / `openclaw channels status --probe`.
641643
- `channels.imessage.includeAttachments` is off by default; set it to `true` before expecting inbound media in agent turns.
642644
- Inbound recovery after a bridge/gateway restart is automatic (GUID dedupe plus a stale-backlog age fence). Existing `channels.imessage.catchup.enabled: true` configs are still honored as a deprecated compatibility profile.

extensions/imessage/src/accounts.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,26 @@ describe("resolveIMessageAccount", () => {
5151
expect(resolved.config.dmPolicy).toBe("open");
5252
expect(resolved.configured).toBe(true);
5353
});
54+
55+
it("treats sendTransport as an intentional account config", () => {
56+
const resolved = resolveIMessageAccount({
57+
cfg: {
58+
channels: {
59+
imessage: {
60+
accounts: {
61+
work: {
62+
sendTransport: "bridge",
63+
},
64+
},
65+
},
66+
},
67+
} as never,
68+
accountId: "work",
69+
});
70+
71+
expect(resolved.config.sendTransport).toBe("bridge");
72+
expect(resolved.configured).toBe(true);
73+
});
5474
});
5575

5676
describe("iMessage duplicate-source watcher ownership", () => {

extensions/imessage/src/accounts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export function resolveIMessageAccount(params: {
131131
merged.cliPath?.trim() ||
132132
merged.dbPath?.trim() ||
133133
merged.service ||
134+
merged.sendTransport ||
134135
merged.region?.trim() ||
135136
(merged.allowFrom && merged.allowFrom.length > 0) ||
136137
(merged.groupAllowFrom && merged.groupAllowFrom.length > 0) ||

extensions/imessage/src/config-schema.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,38 @@ describe("imessage config schema", () => {
110110
expect(res.success).toBe(true);
111111
});
112112

113+
it("accepts send transport overrides", () => {
114+
const res = IMessageConfigSchema.safeParse({
115+
sendTransport: "auto",
116+
accounts: {
117+
bridge: {
118+
sendTransport: "bridge",
119+
},
120+
applescript: {
121+
sendTransport: "applescript",
122+
},
123+
},
124+
});
125+
126+
expect(res.success).toBe(true);
127+
if (res.success) {
128+
expect(res.data.sendTransport).toBe("auto");
129+
expect(res.data.accounts?.bridge?.sendTransport).toBe("bridge");
130+
expect(res.data.accounts?.applescript?.sendTransport).toBe("applescript");
131+
}
132+
});
133+
134+
it("rejects invalid send transport overrides", () => {
135+
const res = IMessageConfigSchema.safeParse({
136+
sendTransport: "private-api",
137+
});
138+
139+
expect(res.success).toBe(false);
140+
if (!res.success) {
141+
expect(res.error.issues[0]?.path.join(".")).toBe("sendTransport");
142+
}
143+
});
144+
113145
it("rejects invalid reaction notification modes", () => {
114146
const res = IMessageConfigSchema.safeParse({
115147
reactionNotifications: "allowlist",

extensions/imessage/src/config-ui-hints.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,8 @@ export const iMessageChannelConfigUiHints = {
1818
label: "iMessage CLI Path",
1919
help: "Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments.",
2020
},
21+
sendTransport: {
22+
label: "iMessage Send Transport",
23+
help: 'Preferred imsg RPC send transport for normal outbound replies. "auto" uses the IMCore bridge when available, "bridge" requires it, and "applescript" forces Messages automation.',
24+
},
2125
} satisfies Record<string, ChannelConfigUiHint>;

extensions/imessage/src/monitor/deliver.test.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ let createIMessageEchoCachingSend: typeof import("./deliver.js").createIMessageE
3131
describe("deliverReplies", () => {
3232
const IMESSAGE_TEST_CFG = { channels: { imessage: { accounts: { default: {} } } } };
3333
const runtime = { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv;
34-
const client = {} as Awaited<ReturnType<typeof import("../client.js").createIMessageRpcClient>>;
3534

3635
beforeAll(async () => {
3736
({ createIMessageEchoCachingSend, deliverReplies } = await import("./deliver.js"));
@@ -48,14 +47,13 @@ describe("deliverReplies", () => {
4847
vi.resetModules();
4948
});
5049

51-
it("propagates payload replyToId through all text chunks", async () => {
50+
it("sends monitor text chunks without reusing the watch rpc client", async () => {
5251
chunkTextWithModeMock.mockImplementation((text: string) => text.split("|"));
5352

5453
await deliverReplies({
5554
cfg: IMESSAGE_TEST_CFG,
5655
replies: [{ text: "first|second", replyToId: "reply-1" }],
5756
target: "chat_id:10",
58-
client,
5957
accountId: "default",
6058
runtime,
6159
maxBytes: 4096,
@@ -70,7 +68,6 @@ describe("deliverReplies", () => {
7068
expect.objectContaining({
7169
config: IMESSAGE_TEST_CFG,
7270
maxBytes: 4096,
73-
client,
7471
accountId: "default",
7572
replyToId: "reply-1",
7673
}),
@@ -81,7 +78,6 @@ describe("deliverReplies", () => {
8178
expect.objectContaining({
8279
config: IMESSAGE_TEST_CFG,
8380
maxBytes: 4096,
84-
client,
8581
accountId: "default",
8682
replyToId: "reply-1",
8783
}),
@@ -100,7 +96,6 @@ describe("deliverReplies", () => {
10096
},
10197
],
10298
target: "chat_id:20",
103-
client,
10499
accountId: "acct-2",
105100
runtime,
106101
maxBytes: 8192,
@@ -116,7 +111,6 @@ describe("deliverReplies", () => {
116111
config: IMESSAGE_TEST_CFG,
117112
mediaUrl: "https://example.com/a.jpg",
118113
maxBytes: 8192,
119-
client,
120114
accountId: "acct-2",
121115
replyToId: "reply-2",
122116
}),
@@ -128,7 +122,6 @@ describe("deliverReplies", () => {
128122
config: IMESSAGE_TEST_CFG,
129123
mediaUrl: "https://example.com/b.jpg",
130124
maxBytes: 8192,
131-
client,
132125
accountId: "acct-2",
133126
replyToId: "reply-2",
134127
}),
@@ -139,7 +132,6 @@ describe("deliverReplies", () => {
139132
it("records durable outbound sends in the sent-message cache", async () => {
140133
const remember = vi.fn();
141134
const send = createIMessageEchoCachingSend({
142-
client,
143135
accountId: "acct-5",
144136
sentMessageCache: { remember },
145137
});
@@ -160,7 +152,6 @@ describe("deliverReplies", () => {
160152
expect.objectContaining({
161153
config: IMESSAGE_TEST_CFG,
162154
accountId: "acct-ignored",
163-
client,
164155
}),
165156
],
166157
]);
@@ -173,7 +164,6 @@ describe("deliverReplies", () => {
173164
it("sanitizes durable outbound text before sending", async () => {
174165
const remember = vi.fn();
175166
const send = createIMessageEchoCachingSend({
176-
client,
177167
accountId: "acct-6",
178168
sentMessageCache: { remember },
179169
});
@@ -194,7 +184,6 @@ describe("deliverReplies", () => {
194184
expect.objectContaining({
195185
config: IMESSAGE_TEST_CFG,
196186
accountId: "acct-ignored",
197-
client,
198187
}),
199188
],
200189
]);
@@ -217,7 +206,6 @@ describe("deliverReplies", () => {
217206
cfg: IMESSAGE_TEST_CFG,
218207
replies: [{ text: "first|second" }],
219208
target: "chat_id:30",
220-
client,
221209
accountId: "acct-3",
222210
runtime,
223211
maxBytes: 2048,
@@ -247,7 +235,6 @@ describe("deliverReplies", () => {
247235
cfg: IMESSAGE_TEST_CFG,
248236
replies: [{ mediaUrls: ["https://example.com/a.jpg"] }],
249237
target: "chat_id:40",
250-
client,
251238
accountId: "acct-4",
252239
runtime,
253240
maxBytes: 2048,

extensions/imessage/src/monitor/deliver.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
} from "openclaw/plugin-sdk/reply-payload";
77
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
88
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
9-
import type { IMessageRpcClient } from "../client.js";
109
import { sendMessageIMessage } from "../send.js";
1110
import {
1211
chunkTextWithMode,
@@ -21,15 +20,13 @@ export async function deliverReplies(params: {
2120
cfg: OpenClawConfig;
2221
replies: ReplyPayload[];
2322
target: string;
24-
client: IMessageRpcClient;
2523
accountId?: string;
2624
runtime: RuntimeEnv;
2725
maxBytes: number;
2826
textLimit: number;
2927
sentMessageCache?: Pick<SentMessageCache, "remember">;
3028
}) {
31-
const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } =
32-
params;
29+
const { replies, target, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params;
3330
const scope = `${accountId ?? ""}:${target}`;
3431
const { cfg } = params;
3532
const tableMode = resolveMarkdownTableMode({
@@ -51,7 +48,6 @@ export async function deliverReplies(params: {
5148
const sent = await sendMessageIMessage(target, chunk, {
5249
config: params.cfg,
5350
maxBytes,
54-
client,
5551
accountId,
5652
replyToId: payload.replyToId,
5753
});
@@ -65,7 +61,6 @@ export async function deliverReplies(params: {
6561
config: params.cfg,
6662
mediaUrl,
6763
maxBytes,
68-
client,
6964
accountId,
7065
replyToId: payload.replyToId,
7166
});
@@ -82,16 +77,12 @@ export async function deliverReplies(params: {
8277
}
8378

8479
export function createIMessageEchoCachingSend(params: {
85-
client: IMessageRpcClient;
8680
accountId?: string;
8781
sentMessageCache?: Pick<SentMessageCache, "remember">;
8882
}): typeof sendMessageIMessage {
8983
return async (target, text, opts) => {
9084
const sanitizedText = sanitizeOutboundText(text);
91-
const sent = await sendMessageIMessage(target, sanitizedText, {
92-
...opts,
93-
client: params.client,
94-
});
85+
const sent = await sendMessageIMessage(target, sanitizedText, opts);
9586
const scope = `${params.accountId ?? opts.accountId ?? ""}:${target}`;
9687
params.sentMessageCache?.remember(scope, {
9788
text: sent.echoText ?? (sent.sentText || undefined),

extensions/imessage/src/monitor/monitor-provider.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,7 +1089,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
10891089
to: target,
10901090
deps: {
10911091
imessage: createIMessageEchoCachingSend({
1092-
client: getActiveClient(),
10931092
accountId: accountInfo.accountId,
10941093
sentMessageCache,
10951094
}),
@@ -1105,7 +1104,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
11051104
cfg,
11061105
replies: [payload],
11071106
target,
1108-
client: getActiveClient(),
11091107
accountId: accountInfo.accountId,
11101108
runtime,
11111109
maxBytes: mediaMaxBytes,

0 commit comments

Comments
 (0)