mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 18:42:25 +00:00
fix(imessage): harden outbound send transport (#91783)
Merged via squash.
Prepared head SHA: 39ea25767b
Proof:
- Focused tests, docs/config generation, lint/type/doc checks passed before merge.
- ClawSweeper re-review marked proof and patch quality platinum after lobster live send proof.
- Maintainer accepted the `channels.imessage.sendTransport` config surface and compatibility-risk tradeoff.
Lobster proof id: openclaw-lobster-live-proof-c74895c2-b629-4bb0-abcb-e6521069b3d8
Reviewed-by: @omarshahine
This commit is contained in:
@@ -763,6 +763,31 @@ imessage: suppressed stale inbound backlog account=<id> sent=<iso> recovery=<boo
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Messages send but inbound iMessages do not arrive">
|
||||
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.
|
||||
|
||||
```bash
|
||||
imsg chats --limit 10 --json
|
||||
imsg watch --chat-id <chat-id> --json
|
||||
sqlite3 ~/Library/Messages/chat.db \
|
||||
"select datetime(max(date)/1000000000 + 978307200, 'unixepoch', 'localtime'), max(ROWID) from message;"
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k system/com.apple.apsd
|
||||
launchctl kickstart -k gui/$(id -u)/com.apple.CommCenter
|
||||
launchctl kickstart -k gui/$(id -u)/com.apple.identityservicesd
|
||||
launchctl kickstart -k gui/$(id -u)/com.apple.imagent
|
||||
imsg launch
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Gateway is not running on macOS">
|
||||
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 "$@"`.
|
||||
|
||||
|
||||
@@ -615,6 +615,7 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
|
||||
remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"],
|
||||
mediaMaxMb: 16,
|
||||
service: "auto",
|
||||
sendTransport: "auto",
|
||||
region: "US",
|
||||
actions: {
|
||||
reactions: true,
|
||||
@@ -637,6 +638,7 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
|
||||
- `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default: `/Users/*/Library/Messages/Attachments`).
|
||||
- SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.
|
||||
- `channels.imessage.configWrites`: allow or deny iMessage-initiated config writes.
|
||||
- `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.
|
||||
- `channels.imessage.actions.*`: enable private API actions that are also gated by `imsg status` / `openclaw channels status --probe`.
|
||||
- `channels.imessage.includeAttachments` is off by default; set it to `true` before expecting inbound media in agent turns.
|
||||
- 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.
|
||||
|
||||
@@ -51,6 +51,26 @@ describe("resolveIMessageAccount", () => {
|
||||
expect(resolved.config.dmPolicy).toBe("open");
|
||||
expect(resolved.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("treats sendTransport as an intentional account config", () => {
|
||||
const resolved = resolveIMessageAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
imessage: {
|
||||
accounts: {
|
||||
work: {
|
||||
sendTransport: "bridge",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.config.sendTransport).toBe("bridge");
|
||||
expect(resolved.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("iMessage duplicate-source watcher ownership", () => {
|
||||
|
||||
@@ -131,6 +131,7 @@ export function resolveIMessageAccount(params: {
|
||||
merged.cliPath?.trim() ||
|
||||
merged.dbPath?.trim() ||
|
||||
merged.service ||
|
||||
merged.sendTransport ||
|
||||
merged.region?.trim() ||
|
||||
(merged.allowFrom && merged.allowFrom.length > 0) ||
|
||||
(merged.groupAllowFrom && merged.groupAllowFrom.length > 0) ||
|
||||
|
||||
@@ -110,6 +110,38 @@ describe("imessage config schema", () => {
|
||||
expect(res.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts send transport overrides", () => {
|
||||
const res = IMessageConfigSchema.safeParse({
|
||||
sendTransport: "auto",
|
||||
accounts: {
|
||||
bridge: {
|
||||
sendTransport: "bridge",
|
||||
},
|
||||
applescript: {
|
||||
sendTransport: "applescript",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
if (res.success) {
|
||||
expect(res.data.sendTransport).toBe("auto");
|
||||
expect(res.data.accounts?.bridge?.sendTransport).toBe("bridge");
|
||||
expect(res.data.accounts?.applescript?.sendTransport).toBe("applescript");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid send transport overrides", () => {
|
||||
const res = IMessageConfigSchema.safeParse({
|
||||
sendTransport: "private-api",
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
if (!res.success) {
|
||||
expect(res.error.issues[0]?.path.join(".")).toBe("sendTransport");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid reaction notification modes", () => {
|
||||
const res = IMessageConfigSchema.safeParse({
|
||||
reactionNotifications: "allowlist",
|
||||
|
||||
@@ -18,4 +18,8 @@ export const iMessageChannelConfigUiHints = {
|
||||
label: "iMessage CLI Path",
|
||||
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.",
|
||||
},
|
||||
sendTransport: {
|
||||
label: "iMessage Send Transport",
|
||||
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.',
|
||||
},
|
||||
} satisfies Record<string, ChannelConfigUiHint>;
|
||||
|
||||
@@ -31,7 +31,6 @@ let createIMessageEchoCachingSend: typeof import("./deliver.js").createIMessageE
|
||||
describe("deliverReplies", () => {
|
||||
const IMESSAGE_TEST_CFG = { channels: { imessage: { accounts: { default: {} } } } };
|
||||
const runtime = { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv;
|
||||
const client = {} as Awaited<ReturnType<typeof import("../client.js").createIMessageRpcClient>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createIMessageEchoCachingSend, deliverReplies } = await import("./deliver.js"));
|
||||
@@ -48,14 +47,13 @@ describe("deliverReplies", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("propagates payload replyToId through all text chunks", async () => {
|
||||
it("sends monitor text chunks without reusing the watch rpc client", async () => {
|
||||
chunkTextWithModeMock.mockImplementation((text: string) => text.split("|"));
|
||||
|
||||
await deliverReplies({
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
replies: [{ text: "first|second", replyToId: "reply-1" }],
|
||||
target: "chat_id:10",
|
||||
client,
|
||||
accountId: "default",
|
||||
runtime,
|
||||
maxBytes: 4096,
|
||||
@@ -70,7 +68,6 @@ describe("deliverReplies", () => {
|
||||
expect.objectContaining({
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
maxBytes: 4096,
|
||||
client,
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
}),
|
||||
@@ -81,7 +78,6 @@ describe("deliverReplies", () => {
|
||||
expect.objectContaining({
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
maxBytes: 4096,
|
||||
client,
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
}),
|
||||
@@ -100,7 +96,6 @@ describe("deliverReplies", () => {
|
||||
},
|
||||
],
|
||||
target: "chat_id:20",
|
||||
client,
|
||||
accountId: "acct-2",
|
||||
runtime,
|
||||
maxBytes: 8192,
|
||||
@@ -116,7 +111,6 @@ describe("deliverReplies", () => {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
mediaUrl: "https://example.com/a.jpg",
|
||||
maxBytes: 8192,
|
||||
client,
|
||||
accountId: "acct-2",
|
||||
replyToId: "reply-2",
|
||||
}),
|
||||
@@ -128,7 +122,6 @@ describe("deliverReplies", () => {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
mediaUrl: "https://example.com/b.jpg",
|
||||
maxBytes: 8192,
|
||||
client,
|
||||
accountId: "acct-2",
|
||||
replyToId: "reply-2",
|
||||
}),
|
||||
@@ -139,7 +132,6 @@ describe("deliverReplies", () => {
|
||||
it("records durable outbound sends in the sent-message cache", async () => {
|
||||
const remember = vi.fn();
|
||||
const send = createIMessageEchoCachingSend({
|
||||
client,
|
||||
accountId: "acct-5",
|
||||
sentMessageCache: { remember },
|
||||
});
|
||||
@@ -160,7 +152,6 @@ describe("deliverReplies", () => {
|
||||
expect.objectContaining({
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
accountId: "acct-ignored",
|
||||
client,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
@@ -173,7 +164,6 @@ describe("deliverReplies", () => {
|
||||
it("sanitizes durable outbound text before sending", async () => {
|
||||
const remember = vi.fn();
|
||||
const send = createIMessageEchoCachingSend({
|
||||
client,
|
||||
accountId: "acct-6",
|
||||
sentMessageCache: { remember },
|
||||
});
|
||||
@@ -194,7 +184,6 @@ describe("deliverReplies", () => {
|
||||
expect.objectContaining({
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
accountId: "acct-ignored",
|
||||
client,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
@@ -217,7 +206,6 @@ describe("deliverReplies", () => {
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
replies: [{ text: "first|second" }],
|
||||
target: "chat_id:30",
|
||||
client,
|
||||
accountId: "acct-3",
|
||||
runtime,
|
||||
maxBytes: 2048,
|
||||
@@ -247,7 +235,6 @@ describe("deliverReplies", () => {
|
||||
cfg: IMESSAGE_TEST_CFG,
|
||||
replies: [{ mediaUrls: ["https://example.com/a.jpg"] }],
|
||||
target: "chat_id:40",
|
||||
client,
|
||||
accountId: "acct-4",
|
||||
runtime,
|
||||
maxBytes: 2048,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { IMessageRpcClient } from "../client.js";
|
||||
import { sendMessageIMessage } from "../send.js";
|
||||
import {
|
||||
chunkTextWithMode,
|
||||
@@ -21,15 +20,13 @@ export async function deliverReplies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
replies: ReplyPayload[];
|
||||
target: string;
|
||||
client: IMessageRpcClient;
|
||||
accountId?: string;
|
||||
runtime: RuntimeEnv;
|
||||
maxBytes: number;
|
||||
textLimit: number;
|
||||
sentMessageCache?: Pick<SentMessageCache, "remember">;
|
||||
}) {
|
||||
const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } =
|
||||
params;
|
||||
const { replies, target, runtime, maxBytes, textLimit, accountId, sentMessageCache } = params;
|
||||
const scope = `${accountId ?? ""}:${target}`;
|
||||
const { cfg } = params;
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
@@ -51,7 +48,6 @@ export async function deliverReplies(params: {
|
||||
const sent = await sendMessageIMessage(target, chunk, {
|
||||
config: params.cfg,
|
||||
maxBytes,
|
||||
client,
|
||||
accountId,
|
||||
replyToId: payload.replyToId,
|
||||
});
|
||||
@@ -65,7 +61,6 @@ export async function deliverReplies(params: {
|
||||
config: params.cfg,
|
||||
mediaUrl,
|
||||
maxBytes,
|
||||
client,
|
||||
accountId,
|
||||
replyToId: payload.replyToId,
|
||||
});
|
||||
@@ -82,16 +77,12 @@ export async function deliverReplies(params: {
|
||||
}
|
||||
|
||||
export function createIMessageEchoCachingSend(params: {
|
||||
client: IMessageRpcClient;
|
||||
accountId?: string;
|
||||
sentMessageCache?: Pick<SentMessageCache, "remember">;
|
||||
}): typeof sendMessageIMessage {
|
||||
return async (target, text, opts) => {
|
||||
const sanitizedText = sanitizeOutboundText(text);
|
||||
const sent = await sendMessageIMessage(target, sanitizedText, {
|
||||
...opts,
|
||||
client: params.client,
|
||||
});
|
||||
const sent = await sendMessageIMessage(target, sanitizedText, opts);
|
||||
const scope = `${params.accountId ?? opts.accountId ?? ""}:${target}`;
|
||||
params.sentMessageCache?.remember(scope, {
|
||||
text: sent.echoText ?? (sent.sentText || undefined),
|
||||
|
||||
@@ -1089,7 +1089,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
to: target,
|
||||
deps: {
|
||||
imessage: createIMessageEchoCachingSend({
|
||||
client: getActiveClient(),
|
||||
accountId: accountInfo.accountId,
|
||||
sentMessageCache,
|
||||
}),
|
||||
@@ -1105,7 +1104,6 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
cfg,
|
||||
replies: [payload],
|
||||
target,
|
||||
client: getActiveClient(),
|
||||
accountId: accountInfo.accountId,
|
||||
runtime,
|
||||
maxBytes: mediaMaxBytes,
|
||||
|
||||
@@ -121,6 +121,56 @@ describe("sendMessageIMessage receipts", () => {
|
||||
expect(result.receipt.sentAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("passes the default RPC send transport", async () => {
|
||||
const client = createClient({ guid: "p:0/imsg-transport-default" });
|
||||
|
||||
await sendMessageIMessage("chat_id:42", "hello", {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
client,
|
||||
});
|
||||
|
||||
expect(getClientMocks(client).request).toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({
|
||||
chat_id: 42,
|
||||
text: "hello",
|
||||
transport: "auto",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the configured RPC send transport", async () => {
|
||||
const client = createClient({ guid: "p:0/imsg-transport-bridge" });
|
||||
|
||||
await sendMessageIMessage("chat_id:42", "hello", {
|
||||
config: {
|
||||
channels: {
|
||||
imessage: {
|
||||
sendTransport: "applescript",
|
||||
accounts: {
|
||||
work: {
|
||||
sendTransport: "bridge",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
client,
|
||||
});
|
||||
|
||||
expect(getClientMocks(client).request).toHaveBeenCalledWith(
|
||||
"send",
|
||||
expect.objectContaining({
|
||||
chat_id: 42,
|
||||
text: "hello",
|
||||
transport: "bridge",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the dedicated send timeout (covers macOS 26 stalls), not the 10s probe default", async () => {
|
||||
const client = createClient({ guid: "p:0/imsg-1" });
|
||||
|
||||
@@ -940,10 +990,10 @@ describe("sendMessageIMessage receipts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("throws the rpc timeout without resending for generic text", async () => {
|
||||
it("throws the rpc timeout without matching generic text to older sent rows", async () => {
|
||||
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
|
||||
const runCliJson = vi.fn();
|
||||
const resolveSentMessageGuidImpl = vi.fn(async () => "p:0/stale-guid");
|
||||
const resolveSentMessageGuidImpl = vi.fn(async () => "p:0/older-identical-text-guid");
|
||||
|
||||
await expect(
|
||||
sendMessageIMessage("chat_id:42", "hello", {
|
||||
@@ -959,6 +1009,73 @@ describe("sendMessageIMessage receipts", () => {
|
||||
expect(resolveSentMessageGuidImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws the rpc timeout without resending when sent-row recovery misses", async () => {
|
||||
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
|
||||
const runCliJson = vi.fn();
|
||||
const resolveSentMessageGuidImpl = vi.fn(async () => null);
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
nowSpy
|
||||
.mockReturnValueOnce(1_000)
|
||||
.mockReturnValueOnce(1_000)
|
||||
.mockReturnValueOnce(1_000)
|
||||
.mockReturnValueOnce(6_001);
|
||||
|
||||
await expect(
|
||||
sendMessageIMessage("chat_id:42", "hello", {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
createClient: async () => client,
|
||||
runCliJson,
|
||||
dbPath: "/Users/me/Library/Messages/chat.db",
|
||||
resolveSentMessageGuidImpl,
|
||||
}),
|
||||
).rejects.toThrow("imsg rpc timeout (send)");
|
||||
|
||||
expect(getClientMocks(client).stop).toHaveBeenCalledTimes(1);
|
||||
expect(runCliJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not stop caller-owned rpc clients after sent-row recovery misses", async () => {
|
||||
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
|
||||
const runCliJson = vi.fn();
|
||||
const resolveSentMessageGuidImpl = vi.fn(async () => null);
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
nowSpy
|
||||
.mockReturnValueOnce(1_000)
|
||||
.mockReturnValueOnce(1_000)
|
||||
.mockReturnValueOnce(1_000)
|
||||
.mockReturnValueOnce(6_001);
|
||||
|
||||
await expect(
|
||||
sendMessageIMessage("chat_id:42", "hello", {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
client,
|
||||
runCliJson,
|
||||
dbPath: "/Users/me/Library/Messages/chat.db",
|
||||
resolveSentMessageGuidImpl,
|
||||
}),
|
||||
).rejects.toThrow("imsg rpc timeout (send)");
|
||||
|
||||
expect(runCliJson).not.toHaveBeenCalled();
|
||||
expect(getClientMocks(client).stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws the rpc timeout without resending when sent-row checks are unavailable", async () => {
|
||||
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
|
||||
const runCliJson = vi.fn();
|
||||
|
||||
await expect(
|
||||
sendMessageIMessage("chat_id:42", "hello", {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
client,
|
||||
runCliJson,
|
||||
dbPath: "/Users/me/Library/Messages/chat.db",
|
||||
}),
|
||||
).rejects.toThrow("imsg rpc timeout (send)");
|
||||
|
||||
expect(runCliJson).not.toHaveBeenCalled();
|
||||
expect(getClientMocks(client).stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws the rpc timeout without resending when approval GUID recovery misses", async () => {
|
||||
const client = createRejectingClient(new Error("imsg rpc timeout (send)"));
|
||||
const runCliJson = vi.fn();
|
||||
|
||||
@@ -43,6 +43,7 @@ const require = createRequire(import.meta.url);
|
||||
type ParsedIMessageTarget = ReturnType<typeof parseIMessageTarget>;
|
||||
const MIN_PENDING_PERSISTED_ECHO_TTL_MS = 60_000;
|
||||
const PENDING_PERSISTED_ECHO_GRACE_MS = 5_000;
|
||||
type IMessageSendTransport = "auto" | "bridge" | "applescript";
|
||||
|
||||
type IMessageSendOpts = {
|
||||
cliPath?: string;
|
||||
@@ -471,6 +472,16 @@ function shouldRecoverApprovalPromptGuid(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function canCheckSentMessageAfterRpcTimeout(params: {
|
||||
dbPath?: string;
|
||||
resolveSentMessageGuidImpl?: IMessageSendOpts["resolveSentMessageGuidImpl"];
|
||||
}): boolean {
|
||||
return (
|
||||
Boolean(params.resolveSentMessageGuidImpl) ||
|
||||
canResolveLatestSentMessageGuidFromChatDb(params.dbPath)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOutboundEchoText(text: string, mediaContentType?: string): string | undefined {
|
||||
if (text.trim()) {
|
||||
return text;
|
||||
@@ -882,6 +893,7 @@ export async function sendMessageIMessage(
|
||||
opts.service ??
|
||||
resolveTargetService(target) ??
|
||||
(account.config.service as IMessageService | undefined);
|
||||
const sendTransport = (account.config.sendTransport ?? "auto") as IMessageSendTransport;
|
||||
// Sends use a dedicated longer default (not the 10s probe timeout) so macOS 26
|
||||
// bridge stalls aren't aborted mid-send. Explicit opts/probeTimeoutMs still win
|
||||
// for callers that tuned them. See DEFAULT_IMESSAGE_SEND_TIMEOUT_MS.
|
||||
@@ -990,6 +1002,7 @@ export async function sendMessageIMessage(
|
||||
text: message,
|
||||
service: service || "auto",
|
||||
region,
|
||||
transport: sendTransport,
|
||||
};
|
||||
if (resolvedReplyToId) {
|
||||
params.reply_to = resolvedReplyToId;
|
||||
@@ -1019,6 +1032,14 @@ export async function sendMessageIMessage(
|
||||
? await opts.createClient({ cliPath, dbPath })
|
||||
: await createIMessageRpcClient({ cliPath, dbPath }));
|
||||
const shouldClose = !opts.client;
|
||||
let closedClient = false;
|
||||
const stopOwnedClient = async () => {
|
||||
if (!shouldClose || closedClient) {
|
||||
return;
|
||||
}
|
||||
closedClient = true;
|
||||
await client.stop();
|
||||
};
|
||||
let result: Record<string, unknown>;
|
||||
const sendStartedAtMs = Date.now();
|
||||
let pendingEchoKey: string | undefined;
|
||||
@@ -1036,7 +1057,7 @@ export async function sendMessageIMessage(
|
||||
timeoutMs,
|
||||
});
|
||||
} catch (error) {
|
||||
if (filePath || resolvedReplyToId || !isIMessageRpcSendTimeout(error)) {
|
||||
if (filePath || !isIMessageRpcSendTimeout(error)) {
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
@@ -1044,6 +1065,10 @@ export async function sendMessageIMessage(
|
||||
message,
|
||||
filePath,
|
||||
replyToId: resolvedReplyToId,
|
||||
}) ||
|
||||
!canCheckSentMessageAfterRpcTimeout({
|
||||
dbPath: chatDbLookupPath,
|
||||
resolveSentMessageGuidImpl: opts.resolveSentMessageGuidImpl,
|
||||
})
|
||||
) {
|
||||
throw error;
|
||||
@@ -1055,10 +1080,11 @@ export async function sendMessageIMessage(
|
||||
sentAfterMs: sendStartedAtMs,
|
||||
resolveSentMessageGuidImpl: opts.resolveSentMessageGuidImpl,
|
||||
});
|
||||
if (!recoveredGuid) {
|
||||
if (recoveredGuid) {
|
||||
result = { guid: recoveredGuid, status: "sent" };
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
result = { guid: recoveredGuid, status: "sent" };
|
||||
}
|
||||
const resolvedId = resolveMessageId(result);
|
||||
const messageId =
|
||||
@@ -1149,8 +1175,6 @@ export async function sendMessageIMessage(
|
||||
forgetPersistedIMessageEchoKey(pendingEchoKey);
|
||||
throw error;
|
||||
} finally {
|
||||
if (shouldClose) {
|
||||
await client.stop();
|
||||
}
|
||||
await stopOwnedClient();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -34,6 +34,7 @@ export type IMessageActionConfig = {
|
||||
|
||||
/** Inbound tapback notification policy. */
|
||||
export type IMessageReactionNotificationMode = "off" | "own" | "all";
|
||||
export type IMessageSendTransport = "auto" | "bridge" | "applescript";
|
||||
|
||||
/** Per-account iMessage runtime/config shape. */
|
||||
export type IMessageAccountConfig = {
|
||||
@@ -57,6 +58,8 @@ export type IMessageAccountConfig = {
|
||||
actions?: IMessageActionConfig;
|
||||
/** Optional default send service (imessage|sms|auto). */
|
||||
service?: "imessage" | "sms" | "auto";
|
||||
/** Preferred imsg RPC send transport. Default: auto. */
|
||||
sendTransport?: IMessageSendTransport;
|
||||
/** Optional default region (used when sending SMS). */
|
||||
region?: string;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
|
||||
@@ -1396,6 +1396,7 @@ export const IMessageAccountSchemaBase = z
|
||||
.optional(),
|
||||
actions: IMessageActionSchema,
|
||||
service: z.union([z.literal("imessage"), z.literal("sms"), z.literal("auto")]).optional(),
|
||||
sendTransport: z.enum(["auto", "bridge", "applescript"]).optional(),
|
||||
region: z.string().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
|
||||
Reference in New Issue
Block a user