From 40bca6d8bbfc089ddf45b2838cbce6f1367e87dc Mon Sep 17 00:00:00 2001 From: Kevin Lin Date: Wed, 27 May 2026 15:55:28 -0700 Subject: [PATCH] fix(imessage): suppress duplicate native exec approvals Fix iMessage native exec approval routing so approval prompts bind to the sent GUID without duplicate sends after RPC timeout. Also keeps chat.db GUID recovery on the local imsg path while avoiding local DB recovery for configured or detected SSH wrappers. Thanks @kevinslin. --- .../src/approval-handler.runtime.test.ts | 5 +- .../imessage/src/approval-handler.runtime.ts | 54 +-- .../imessage/src/approval-native.test.ts | 256 ++++++++++- extensions/imessage/src/approval-native.ts | 106 ++++- .../src/approval-reaction-poller.test.ts | 230 ++++++++++ .../imessage/src/approval-reaction-poller.ts | 279 ++++++++++++ .../imessage/src/approval-reactions.test.ts | 78 +++- extensions/imessage/src/approval-reactions.ts | 323 +++++-------- extensions/imessage/src/channel.ts | 7 +- extensions/imessage/src/client.ts | 2 +- .../imessage/src/monitor/monitor-provider.ts | 32 ++ extensions/imessage/src/send.test.ts | 331 +++++++++++++- extensions/imessage/src/send.ts | 426 ++++++++++++++++-- extensions/imessage/src/test-plugin.test.ts | 43 ++ 14 files changed, 1872 insertions(+), 300 deletions(-) create mode 100644 extensions/imessage/src/approval-reaction-poller.test.ts create mode 100644 extensions/imessage/src/approval-reaction-poller.ts diff --git a/extensions/imessage/src/approval-handler.runtime.test.ts b/extensions/imessage/src/approval-handler.runtime.test.ts index 00485d7f7c6..52092c53f7a 100644 --- a/extensions/imessage/src/approval-handler.runtime.test.ts +++ b/extensions/imessage/src/approval-handler.runtime.test.ts @@ -10,7 +10,7 @@ vi.mock("./send.js", () => ({ })); describe("imessageApprovalNativeRuntime", () => { - it("renders allowed thumbs-only reactions in pending exec approvals", async () => { + it("renders shared reactions in pending exec approvals", async () => { const payload = await imessageApprovalNativeRuntime.presentation.buildPendingPayload({ cfg: {} as never, accountId: "default", @@ -54,7 +54,7 @@ describe("imessageApprovalNativeRuntime", () => { expect(payload.allowedDecisions).toEqual(["allow-once", "deny"]); }); - it("renders allowed thumbs-only reactions in pending plugin approvals", async () => { + it("renders shared reactions in pending plugin approvals", async () => { const payload = await imessageApprovalNativeRuntime.presentation.buildPendingPayload({ cfg: {} as never, accountId: "default", @@ -105,6 +105,7 @@ describe("imessageApprovalNativeRuntime", () => { expect(payload.text).toContain("Plugin approval required"); expect(payload.text).toContain("Reply with: /approve plugin:abc allow-once|allow-always|deny"); expect(payload.text).toContain("πŸ‘ Allow Once"); + expect(payload.text).toContain("♾️ Allow Always"); expect(payload.text).toContain("πŸ‘Ž Deny"); expect(payload.text).not.toContain("/approve "); expect(payload.allowedDecisions).toEqual(["allow-once", "allow-always", "deny"]); diff --git a/extensions/imessage/src/approval-handler.runtime.ts b/extensions/imessage/src/approval-handler.runtime.ts index 7cb32181afa..050793d1561 100644 --- a/extensions/imessage/src/approval-handler.runtime.ts +++ b/extensions/imessage/src/approval-handler.runtime.ts @@ -5,15 +5,11 @@ import { type ResolvedApprovalView, } from "openclaw/plugin-sdk/approval-handler-runtime"; import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime"; -import { - buildExecApprovalPendingReplyPayload, - type ExecApprovalReplyDecision, - type ExecApprovalPendingReplyParams, -} from "openclaw/plugin-sdk/approval-reply-runtime"; +import { buildApprovalReactionPendingContent } from "openclaw/plugin-sdk/approval-reaction-runtime"; +import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime"; import { buildApprovalResolvedReplyPayload, buildPluginApprovalExpiredMessage, - buildPluginApprovalPendingReplyPayload, buildPluginApprovalResolvedMessage, type ExecApprovalRequest, type ExecApprovalResolved, @@ -23,12 +19,10 @@ import { import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { - addIMessageApprovalReactionHintToText, registerIMessageApprovalReactionTarget, unregisterIMessageApprovalReactionTarget, type IMessageApprovalConversationKey, } from "./approval-reactions.js"; -import { replaceApprovalIdPlaceholder } from "./approval-text.js"; import { normalizeIMessageMessagingTarget } from "./normalize.js"; import { sendMessageIMessage } from "./send.js"; import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; @@ -61,44 +55,14 @@ function buildPendingPayload(params: { nowMs: number; view: PendingApprovalView; }): IMessagePendingDelivery { - const allowedDecisions = params.view.actions.map((action) => action.decision); - const payload = - params.approvalKind === "plugin" - ? buildPluginApprovalPendingReplyPayload({ - request: params.request as PluginApprovalRequest, - nowMs: params.nowMs, - allowedDecisions, - }) - : buildExecApprovalPendingReplyPayload({ - approvalId: params.request.id, - approvalSlug: params.request.id.slice(0, 8), - approvalCommandId: params.request.id, - warningText: - params.view.approvalKind === "exec" - ? (params.view.warningText ?? undefined) - : undefined, - ask: params.view.approvalKind === "exec" ? (params.view.ask ?? null) : null, - agentId: params.view.approvalKind === "exec" ? (params.view.agentId ?? null) : null, - sessionKey: params.view.approvalKind === "exec" ? (params.view.sessionKey ?? null) : null, - command: params.view.approvalKind === "exec" ? params.view.commandText : "", - cwd: params.view.approvalKind === "exec" ? (params.view.cwd ?? undefined) : undefined, - host: - params.view.approvalKind === "exec" && params.view.host === "node" ? "node" : "gateway", - nodeId: - params.view.approvalKind === "exec" ? (params.view.nodeId ?? undefined) : undefined, - allowedDecisions, - expiresAtMs: params.request.expiresAtMs, - nowMs: params.nowMs, - } satisfies ExecApprovalPendingReplyParams); + const pendingContent = buildApprovalReactionPendingContent({ + request: params.request, + view: params.view as never, + nowMs: params.nowMs, + }); return { - // Use the same hint-insertion helper as the render path so the two routes - // produce identical prompt text and the helper's idempotency guard - // prevents a double-hint when the upstream payload already includes one. - text: addIMessageApprovalReactionHintToText({ - text: replaceApprovalIdPlaceholder(payload.text, params.request.id), - allowedDecisions, - }), - allowedDecisions, + text: pendingContent.reactionPayload.text ?? "", + allowedDecisions: pendingContent.reactionPayload.allowedDecisions, }; } diff --git a/extensions/imessage/src/approval-native.test.ts b/extensions/imessage/src/approval-native.test.ts index b5130738bc9..3372e675ad4 100644 --- a/extensions/imessage/src/approval-native.test.ts +++ b/extensions/imessage/src/approval-native.test.ts @@ -4,7 +4,11 @@ import type { } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { describe, expect, it } from "vitest"; -import { imessageApprovalCapability, imessageNativeApprovalAdapter } from "./approval-native.js"; +import { + imessageApprovalCapability, + imessageNativeApprovalAdapter, + shouldSuppressLocalIMessageExecApprovalPrompt, +} from "./approval-native.js"; type IMessageConfig = NonNullable["imessage"]>; @@ -79,6 +83,27 @@ function nativeShouldHandle(params: { }); } +function buildLocalApprovalPayload( + params: { + approvalKind?: "exec" | "plugin"; + agentId?: string | null; + sessionKey?: string | null; + } = {}, +) { + return { + text: "Approval required.", + channelData: { + execApproval: { + approvalId: params.approvalKind === "plugin" ? "plugin:approval-1" : "exec-1", + approvalSlug: params.approvalKind === "plugin" ? "plugin:approval-1" : "exec-1", + approvalKind: params.approvalKind ?? "exec", + agentId: params.agentId, + sessionKey: params.sessionKey, + }, + }, + }; +} + describe("imessage approval capability", () => { it("disables native approvals when no top-level approvals config is set", () => { const cfg = buildConfig(); @@ -570,3 +595,232 @@ describe("imessage approval capability", () => { }); }); }); + +describe("shouldSuppressLocalIMessageExecApprovalPrompt", () => { + const activeExecHint = { + kind: "approval-pending", + approvalKind: "exec", + nativeRouteActive: true, + } as const; + + it("suppresses eligible session-mode exec approval prompts", () => { + const cfg = buildConfig({ + imessage: { allowFrom: ["+15551230000"] }, + approvals: { + exec: { + enabled: true, + agentFilter: ["main"], + }, + }, + }); + + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg, + accountId: "default", + payload: buildLocalApprovalPayload({ + agentId: null, + sessionKey: "agent:main:imessage:+15551230000", + }), + hint: activeExecHint, + }), + ).toBe(true); + }); + + it("keeps local prompts for disabled, target-only, inactive, or non-exec cases", () => { + const enabledConfig = buildConfig({ + imessage: { allowFrom: ["+15551230000"] }, + approvals: { exec: { enabled: true } }, + }); + const payload = buildLocalApprovalPayload({ + agentId: "main", + sessionKey: "agent:main:imessage:+15551230000", + }); + + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg: buildConfig(), + payload, + hint: activeExecHint, + }), + ).toBe(false); + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg: buildConfig({ + imessage: { allowFrom: ["+15551230000"] }, + approvals: { exec: { enabled: false } }, + }), + payload, + hint: activeExecHint, + }), + ).toBe(false); + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg: buildConfig({ + imessage: { allowFrom: ["+15551230000"] }, + approvals: { + exec: { + enabled: true, + mode: "targets", + targets: [{ channel: "imessage", to: "+15551230000" }], + }, + }, + }), + payload, + hint: activeExecHint, + }), + ).toBe(false); + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg: enabledConfig, + payload, + hint: { ...activeExecHint, nativeRouteActive: false }, + }), + ).toBe(false); + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg: enabledConfig, + payload: buildLocalApprovalPayload({ approvalKind: "plugin" }), + hint: activeExecHint, + }), + ).toBe(false); + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg: enabledConfig, + payload: { text: "Approval required." }, + hint: activeExecHint, + }), + ).toBe(false); + }); + + it("suppresses direct same-chat iMessage prompts without explicit approvers", () => { + const cfg = buildConfig({ + approvals: { exec: { enabled: true } }, + }); + + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg, + payload: buildLocalApprovalPayload({ + agentId: "main", + sessionKey: "agent:main:imessage:+15551230000", + }), + hint: activeExecHint, + }), + ).toBe(true); + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg, + accountId: "default", + payload: buildLocalApprovalPayload({ + agentId: "main", + sessionKey: "agent:main:imessage:direct:+15551230000", + }), + hint: activeExecHint, + }), + ).toBe(true); + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg, + accountId: "default", + payload: buildLocalApprovalPayload({ + agentId: "main", + sessionKey: "agent:main:imessage:default:direct:+15551230000", + }), + hint: activeExecHint, + }), + ).toBe(true); + }); + + it("keeps no-approver local prompts for ambiguous or group iMessage sessions", () => { + const cfg = buildConfig({ + approvals: { exec: { enabled: true } }, + }); + + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg, + payload: buildLocalApprovalPayload({ + agentId: "main", + sessionKey: "agent:main:imessage:group:test-group", + }), + hint: activeExecHint, + }), + ).toBe(false); + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg, + payload: buildLocalApprovalPayload({ + agentId: "main", + sessionKey: "agent:main:imessage:chat_guid:iMessage;+;chat42", + }), + hint: activeExecHint, + }), + ).toBe(false); + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg, + payload: buildLocalApprovalPayload({ + agentId: "main", + sessionKey: "agent:main:slack:C123", + }), + hint: activeExecHint, + }), + ).toBe(false); + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg, + accountId: "work", + payload: buildLocalApprovalPayload({ + agentId: "main", + sessionKey: "agent:main:imessage:default:direct:+15551230000", + }), + hint: activeExecHint, + }), + ).toBe(false); + }); + + it("applies top-level approval filters with agent fallback from session key", () => { + const cfg = buildConfig({ + imessage: { allowFrom: ["+15551230000"] }, + approvals: { + exec: { + enabled: true, + agentFilter: ["ops"], + sessionFilter: ["imessage"], + }, + }, + }); + + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg, + payload: buildLocalApprovalPayload({ + agentId: null, + sessionKey: "agent:ops:imessage:+15551230000", + }), + hint: activeExecHint, + }), + ).toBe(true); + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg, + payload: buildLocalApprovalPayload({ + agentId: null, + sessionKey: "agent:main:imessage:+15551230000", + }), + hint: activeExecHint, + }), + ).toBe(false); + expect( + shouldSuppressLocalIMessageExecApprovalPrompt({ + cfg, + payload: buildLocalApprovalPayload({ + agentId: null, + sessionKey: "agent:ops:slack:C123", + }), + hint: activeExecHint, + }), + ).toBe(false); + }); +}); diff --git a/extensions/imessage/src/approval-native.ts b/extensions/imessage/src/approval-native.ts index 8f705fce97e..06994631c5b 100644 --- a/extensions/imessage/src/approval-native.ts +++ b/extensions/imessage/src/approval-native.ts @@ -10,10 +10,12 @@ import { createChannelNativeOriginTargetResolver, doesApprovalRequestMatchChannelAccount, resolveApprovalRequestSessionTarget, + shouldSuppressLocalNativeExecApprovalPrompt, } from "openclaw/plugin-sdk/approval-native-runtime"; import { buildExecApprovalPendingReplyPayload, buildPluginApprovalPendingReplyPayload, + getExecApprovalReplyMetadata, resolveExecApprovalCommandDisplay, resolveExecApprovalRequestAllowedDecisions, } from "openclaw/plugin-sdk/approval-runtime"; @@ -22,10 +24,14 @@ import type { ExecApprovalReplyDecision, PluginApprovalRequest, } from "openclaw/plugin-sdk/approval-runtime"; -import type { ChannelApprovalCapability } from "openclaw/plugin-sdk/channel-contract"; +import type { + ChannelApprovalCapability, + ChannelOutboundPayloadHint, +} from "openclaw/plugin-sdk/channel-contract"; import { channelRouteTargetsMatchExact } from "openclaw/plugin-sdk/channel-route"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { normalizeAccountId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -367,6 +373,102 @@ function resolveSessionIMessageOriginTarget(sessionTarget: { return to ? { to, accountId: normalizeOptionalString(sessionTarget.accountId) } : null; } +function resolveIMessageSessionTargetFromSessionKey( + sessionKey?: string | null, +): IMessageApprovalTarget | null { + const parsed = parseAgentSessionKey(sessionKey); + const rest = parsed?.rest ?? normalizeOptionalString(sessionKey); + if (!rest || !normalizeLowercaseStringOrEmpty(rest).startsWith("imessage:")) { + return null; + } + const route = rest.slice("imessage:".length).trim(); + const routeLower = normalizeLowercaseStringOrEmpty(route); + if ( + !route || + routeLower.startsWith("group:") || + routeLower.startsWith("channel:") || + routeLower.startsWith("chat:") + ) { + return null; + } + + const directPrefix = "direct:"; + if (routeLower.startsWith(directPrefix)) { + const to = normalizeIMessageMessagingTarget(route.slice(directPrefix.length)); + return to ? { to } : null; + } + + const accountScopedDirect = /^([^:]+):direct:(.+)$/i.exec(route); + if (accountScopedDirect) { + const to = normalizeIMessageMessagingTarget(accountScopedDirect[2] ?? ""); + return to ? { to, accountId: normalizeAccountId(accountScopedDirect[1] ?? "") } : null; + } + + const to = normalizeIMessageMessagingTarget(route); + if (!to || inferIMessageTargetChatType(to) !== "direct") { + return null; + } + return { to }; +} + +export function shouldSuppressLocalIMessageExecApprovalPrompt(params: { + cfg: OpenClawConfig; + accountId?: string | null; + payload: ReplyPayload; + hint?: ChannelOutboundPayloadHint; +}): boolean { + if ( + shouldSuppressLocalNativeExecApprovalPrompt({ + ...params, + isTransportEnabled: isIMessageApprovalTransportEnabled, + isSessionRouteEligible: ({ cfg, accountId, metadata }) => { + if (getIMessageApprovalApprovers({ cfg, accountId }).length > 0) { + return true; + } + const sessionTarget = resolveIMessageSessionTargetFromSessionKey(metadata.sessionKey); + if (!sessionTarget || inferIMessageTargetChatType(sessionTarget.to) !== "direct") { + return false; + } + const targetAccountId = normalizeOptionalString(sessionTarget.accountId); + return ( + !targetAccountId || + !accountId || + normalizeAccountId(targetAccountId) === normalizeAccountId(accountId) + ); + }, + }) + ) { + return true; + } + + const metadata = getExecApprovalReplyMetadata(params.payload); + if ( + params.hint?.kind !== "approval-pending" || + params.hint.approvalKind !== "exec" || + params.hint.nativeRouteActive !== true || + metadata?.approvalKind !== "exec" + ) { + return false; + } + + // The Pi tool-result path currently rebuilds the local approval prompt from + // exec result details that omit agentId/sessionKey. The native iMessage + // approval runtime has already received the full request and will deliver the + // reaction prompt. When explicit iMessage approvers exist, keep the local + // fallback from sending a second manual prompt for the same approval. + if (metadata.agentId || metadata.sessionKey) { + return false; + } + if (getIMessageApprovalApprovers({ cfg: params.cfg, accountId: params.accountId }).length === 0) { + return false; + } + return canApprovalPotentiallyRouteToIMessage({ + ...params, + approvalKind: "exec", + nativeSessionOnly: true, + }); +} + function shouldHandleIMessageApprovalRequest(params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/extensions/imessage/src/approval-reaction-poller.test.ts b/extensions/imessage/src/approval-reaction-poller.test.ts new file mode 100644 index 00000000000..a92f179de8c --- /dev/null +++ b/extensions/imessage/src/approval-reaction-poller.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { pollPendingIMessageApprovalReactions } from "./approval-reaction-poller.js"; +import { + clearIMessageApprovalReactionTargetsForTest, + registerIMessageApprovalReactionTarget, +} from "./approval-reactions.js"; +import type { IMessageRpcClient } from "./client.js"; + +const resolverMocks = vi.hoisted(() => ({ + resolveIMessageApproval: vi.fn(), + isApprovalNotFoundError: vi.fn(() => false), +})); + +vi.mock("./approval-resolver.js", () => ({ + resolveIMessageApproval: resolverMocks.resolveIMessageApproval, + isApprovalNotFoundError: resolverMocks.isApprovalNotFoundError, +})); + +function createClient(request: ReturnType): IMessageRpcClient { + return { request } as unknown as IMessageRpcClient; +} + +describe("iMessage approval reaction poller", () => { + beforeEach(() => { + clearIMessageApprovalReactionTargetsForTest(); + resolverMocks.resolveIMessageApproval.mockReset(); + resolverMocks.resolveIMessageApproval.mockResolvedValue(undefined); + resolverMocks.isApprovalNotFoundError.mockReset(); + resolverMocks.isApprovalNotFoundError.mockReturnValue(false); + }); + + it("does not scan recent chats during fast polling with no pending targets", async () => { + const request = vi.fn(); + + await pollPendingIMessageApprovalReactions({ + client: createClient(request), + cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } }, + accountId: "default", + }); + + expect(request).not.toHaveBeenCalled(); + }); + + it("does not scan recent chats during fast polling for handle-only targets", async () => { + registerIMessageApprovalReactionTarget({ + accountId: "default", + conversation: { handle: "+15551230000" }, + messageId: "msg-1", + approvalId: "exec-1", + allowedDecisions: ["allow-once", "deny"], + }); + const request = vi.fn(); + + await pollPendingIMessageApprovalReactions({ + client: createClient(request), + cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } }, + accountId: "default", + }); + + expect(request).not.toHaveBeenCalled(); + }); + + it("discovers observed approval prompts on the bounded recent-chat path", async () => { + const request = vi.fn(async (method: string) => { + if (method === "chats.list") { + return { chats: [{ id: 42 }] }; + } + if (method === "messages.history") { + return { + messages: [ + { + guid: "msg-1", + chat_id: 42, + chat_guid: "SMS;-;+15551230000", + chat_identifier: "+15551230000", + is_from_me: true, + sender: "+15551230000", + text: [ + "Exec approval required", + "ID: exec-1", + "", + "Reply with: /approve exec-1 allow-once|deny", + ].join("\n"), + reactions: [ + { + id: 7, + sender: "+15551230000", + is_from_me: true, + type: "like", + emoji: "πŸ‘", + created_at: "2026-05-27T21:00:00.000Z", + }, + ], + }, + ], + }; + } + throw new Error(`unexpected method ${method}`); + }); + + await pollPendingIMessageApprovalReactions({ + client: createClient(request), + cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } }, + accountId: "default", + allowRecentChatDiscovery: true, + }); + + expect(request).toHaveBeenCalledWith("chats.list", { limit: 50 }, { timeoutMs: 10_000 }); + expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith({ + cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } }, + approvalId: "exec-1", + decision: "allow-once", + senderId: "+15551230000", + gatewayUrl: undefined, + }); + }); + + it("uses learned chat ids for fast scoped polling after discovery", async () => { + registerIMessageApprovalReactionTarget({ + accountId: "default", + conversation: { handle: "+15551230000" }, + messageId: "msg-1", + approvalId: "exec-1", + allowedDecisions: ["allow-once", "deny"], + }); + registerIMessageApprovalReactionTarget({ + accountId: "default", + conversation: { chatId: 42, chatGuid: "SMS;-;+15551230000" }, + messageId: "msg-1", + approvalId: "exec-1", + allowedDecisions: ["allow-once", "deny"], + }); + const request = vi.fn(async (method: string) => { + if (method === "messages.history") { + return { messages: [] }; + } + throw new Error(`unexpected method ${method}`); + }); + + await pollPendingIMessageApprovalReactions({ + client: createClient(request), + cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } }, + accountId: "default", + }); + + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith( + "messages.history", + { chat_id: 42, limit: 30 }, + { timeoutMs: 10_000 }, + ); + }); + + it("includes recent chats during discovery when scoped and unscoped targets are pending", async () => { + registerIMessageApprovalReactionTarget({ + accountId: "default", + conversation: { chatId: 42, chatGuid: "SMS;-;+15551230000" }, + messageId: "msg-scoped", + approvalId: "exec-scoped", + allowedDecisions: ["allow-once", "deny"], + }); + registerIMessageApprovalReactionTarget({ + accountId: "default", + conversation: { handle: "+15551239999" }, + messageId: "msg-handle", + approvalId: "exec-handle", + allowedDecisions: ["allow-once", "deny"], + }); + const request = vi.fn(async (method: string, payload?: { chat_id?: number }) => { + if (method === "chats.list") { + return { chats: [{ id: 42 }, { id: 99 }] }; + } + if (method === "messages.history" && payload?.chat_id === 42) { + return { messages: [] }; + } + if (method === "messages.history" && payload?.chat_id === 99) { + return { + messages: [ + { + guid: "msg-handle", + chat_id: 99, + chat_guid: "SMS;-;+15551239999", + chat_identifier: "+15551239999", + is_from_me: true, + sender: "+15551239999", + text: "Exec approval required\nID: exec-handle", + reactions: [ + { + id: 8, + sender: "+15551239999", + is_from_me: true, + type: "like", + emoji: "πŸ‘", + created_at: "2026-05-27T21:01:00.000Z", + }, + ], + }, + ], + }; + } + throw new Error(`unexpected request ${method} ${JSON.stringify(payload)}`); + }); + + await pollPendingIMessageApprovalReactions({ + client: createClient(request), + cfg: { channels: { imessage: { allowFrom: ["+15551239999"] } } }, + accountId: "default", + allowRecentChatDiscovery: true, + }); + + expect(request).toHaveBeenCalledWith("chats.list", { limit: 50 }, { timeoutMs: 10_000 }); + expect(request).toHaveBeenCalledWith( + "messages.history", + { chat_id: 42, limit: 30 }, + { timeoutMs: 10_000 }, + ); + expect(request).toHaveBeenCalledWith( + "messages.history", + { chat_id: 99, limit: 30 }, + { timeoutMs: 10_000 }, + ); + expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith({ + cfg: { channels: { imessage: { allowFrom: ["+15551239999"] } } }, + approvalId: "exec-handle", + decision: "allow-once", + senderId: "+15551239999", + gatewayUrl: undefined, + }); + }); +}); diff --git a/extensions/imessage/src/approval-reaction-poller.ts b/extensions/imessage/src/approval-reaction-poller.ts new file mode 100644 index 00000000000..f2234176ee7 --- /dev/null +++ b/extensions/imessage/src/approval-reaction-poller.ts @@ -0,0 +1,279 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + extractIMessageApprovalPromptBinding, + listPendingIMessageApprovalReactionPollTargets, + maybeResolveIMessageApprovalReaction, + registerIMessageApprovalReactionTarget, + type PendingIMessageApprovalReactionPollTarget, + type IMessageApprovalConversationKey, +} from "./approval-reactions.js"; +import type { IMessageRpcClient } from "./client.js"; +import type { IMessagePayload } from "./monitor/types.js"; + +const RECENT_CHAT_LIMIT = 50; +const PER_CHAT_HISTORY_LIMIT = 30; +const OBSERVED_APPROVAL_PROMPT_TARGET_TTL_MS = 5 * 60 * 1000; + +type ChatListEntry = { + id?: number | null; +}; + +type HistoryMessage = IMessagePayload & { + reactions?: Array<{ + id?: number | string | null; + sender?: string | null; + is_from_me?: boolean | null; + type?: string | null; + emoji?: string | null; + created_at?: string | null; + }> | null; +}; + +function normalizeChatId(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null; +} + +function listTargetChatIds( + targets: readonly PendingIMessageApprovalReactionPollTarget[], +): number[] { + const chatIds = new Set(); + for (const target of targets) { + const chatId = normalizeChatId(target.conversation.chatId); + if (chatId !== null) { + chatIds.add(chatId); + } + } + return [...chatIds]; +} + +function hasUnscopedTarget(targets: readonly PendingIMessageApprovalReactionPollTarget[]): boolean { + return targets.some((target) => normalizeChatId(target.conversation.chatId) === null); +} + +function uniqueChatIds(chatIds: readonly number[]): number[] { + return [...new Set(chatIds)]; +} + +function normalizeMessageGuid(value: string): string { + return value.trim().replace(/^p:\d+\//iu, ""); +} + +function enumerateMessageGuidCandidates(value: string): string[] { + const trimmed = value.trim(); + if (!trimmed) { + return []; + } + const normalized = normalizeMessageGuid(trimmed); + return [trimmed, normalized].filter( + (candidate, index, candidates) => + candidate.length > 0 && candidates.indexOf(candidate) === index, + ); +} + +function buildPendingTargetsByMessageId( + targets: readonly PendingIMessageApprovalReactionPollTarget[], +): Map { + const pendingByMessageId = new Map(); + for (const target of targets) { + for (const candidate of enumerateMessageGuidCandidates(target.messageId)) { + pendingByMessageId.set(candidate, target); + } + } + return pendingByMessageId; +} + +async function listRecentChatIds(client: IMessageRpcClient): Promise { + const result = await client.request<{ chats?: ChatListEntry[] }>( + "chats.list", + { limit: RECENT_CHAT_LIMIT }, + { timeoutMs: 10_000 }, + ); + return (result.chats ?? []) + .map((chat) => normalizeChatId(chat.id)) + .filter((chatId): chatId is number => chatId !== null); +} + +async function fetchRecentHistory(params: { + client: IMessageRpcClient; + chatId: number; +}): Promise { + const result = await params.client.request<{ messages?: unknown[] }>( + "messages.history", + { + chat_id: params.chatId, + limit: PER_CHAT_HISTORY_LIMIT, + }, + { timeoutMs: 10_000 }, + ); + return (result.messages ?? []).filter((message): message is HistoryMessage => + Boolean(message && typeof message === "object"), + ); +} + +function buildReactionPayload(params: { + targetMessage: HistoryMessage; + reaction: NonNullable[number]; +}): IMessagePayload | null { + const emoji = params.reaction.emoji?.trim(); + const sender = params.reaction.sender?.trim(); + const targetGuid = params.targetMessage.guid?.trim(); + if (!emoji || !sender || !targetGuid) { + return null; + } + const reactionId = normalizeChatId(params.reaction.id); + return { + ...(reactionId !== null ? { id: reactionId } : {}), + guid: `reaction:${targetGuid}:${sender}:${emoji}:${params.reaction.created_at ?? ""}`, + chat_id: params.targetMessage.chat_id, + chat_guid: params.targetMessage.chat_guid, + chat_identifier: params.targetMessage.chat_identifier, + chat_name: params.targetMessage.chat_name, + participants: params.targetMessage.participants, + is_group: params.targetMessage.is_group, + sender, + destination_caller_id: params.targetMessage.destination_caller_id, + is_from_me: params.reaction.is_from_me, + text: `${params.reaction.type ?? "reaction"} "${params.targetMessage.text ?? ""}"`, + created_at: params.reaction.created_at, + is_reaction: true, + is_tapback: true, + associated_message_guid: targetGuid, + associated_message_type: 2000, + reaction_type: params.reaction.type ?? undefined, + reaction_emoji: emoji, + is_reaction_add: true, + reacted_to_guid: targetGuid, + }; +} + +function buildConversationKeyFromMessage(message: HistoryMessage): IMessageApprovalConversationKey { + return { + ...(message.chat_guid?.trim() ? { chatGuid: message.chat_guid.trim() } : {}), + ...(message.chat_identifier?.trim() ? { chatIdentifier: message.chat_identifier.trim() } : {}), + ...(normalizeChatId(message.chat_id) !== null ? { chatId: message.chat_id as number } : {}), + }; +} + +function bindObservedConversation(params: { + target: PendingIMessageApprovalReactionPollTarget; + message: HistoryMessage; +}): void { + const ttlMs = params.target.expiresAtMs - Date.now(); + if (ttlMs <= 0) { + return; + } + const conversation = buildConversationKeyFromMessage(params.message); + const messageIds = new Set([ + ...enumerateMessageGuidCandidates(params.target.messageId), + ...enumerateMessageGuidCandidates(params.message.guid ?? ""), + ]); + for (const messageId of messageIds) { + registerIMessageApprovalReactionTarget({ + accountId: params.target.accountId, + conversation, + messageId, + approvalId: params.target.approvalId, + allowedDecisions: params.target.allowedDecisions, + ttlMs, + }); + } +} + +function bindObservedApprovalPrompt(params: { + accountId: string; + message: HistoryMessage; +}): PendingIMessageApprovalReactionPollTarget | null { + if (params.message.is_from_me !== true) { + return null; + } + const messageId = params.message.guid?.trim(); + if (!messageId) { + return null; + } + const binding = extractIMessageApprovalPromptBinding(params.message.text ?? ""); + if (!binding) { + return null; + } + const conversation = buildConversationKeyFromMessage(params.message); + const target: PendingIMessageApprovalReactionPollTarget = { + accountId: params.accountId, + conversation, + messageId, + approvalId: binding.approvalId, + allowedDecisions: binding.allowedDecisions, + expiresAtMs: Date.now() + OBSERVED_APPROVAL_PROMPT_TARGET_TTL_MS, + }; + bindObservedConversation({ target, message: params.message }); + return target; +} + +export async function pollPendingIMessageApprovalReactions(params: { + client: IMessageRpcClient; + cfg: OpenClawConfig; + accountId: string; + allowRecentChatDiscovery?: boolean; + logVerboseMessage?: (message: string) => void; +}): Promise { + const targets = listPendingIMessageApprovalReactionPollTargets({ + accountId: params.accountId, + }); + if (targets.length === 0 && params.allowRecentChatDiscovery !== true) { + return; + } + + const pendingByMessageId = buildPendingTargetsByMessageId(targets); + const explicitChatIds = listTargetChatIds(targets); + const shouldDiscoverRecentChats = + params.allowRecentChatDiscovery === true && + (targets.length === 0 || hasUnscopedTarget(targets)); + const chatIds = shouldDiscoverRecentChats + ? uniqueChatIds([...explicitChatIds, ...(await listRecentChatIds(params.client))]) + : explicitChatIds; + if (chatIds.length === 0) { + return; + } + for (const chatId of chatIds) { + let messages: HistoryMessage[]; + try { + messages = await fetchRecentHistory({ client: params.client, chatId }); + } catch (err) { + params.logVerboseMessage?.( + `imessage: approval reaction poll skipped chat_id=${chatId}: ${String(err)}`, + ); + continue; + } + for (const message of messages) { + const targetGuid = message.guid?.trim(); + if (!targetGuid) { + continue; + } + const target = + pendingByMessageId.get(targetGuid) ?? + pendingByMessageId.get(normalizeMessageGuid(targetGuid)) ?? + bindObservedApprovalPrompt({ + accountId: params.accountId, + message, + }); + if (!target) { + continue; + } + bindObservedConversation({ target, message }); + for (const reaction of message.reactions ?? []) { + const reactionPayload = buildReactionPayload({ targetMessage: message, reaction }); + if (!reactionPayload) { + continue; + } + const handled = await maybeResolveIMessageApprovalReaction({ + cfg: params.cfg, + accountId: params.accountId, + message: reactionPayload, + bodyText: reactionPayload.text ?? "", + logVerboseMessage: params.logVerboseMessage, + }); + if (handled) { + return; + } + } + } + } +} diff --git a/extensions/imessage/src/approval-reactions.test.ts b/extensions/imessage/src/approval-reactions.test.ts index 4db22dfe720..e4065106ecc 100644 --- a/extensions/imessage/src/approval-reactions.test.ts +++ b/extensions/imessage/src/approval-reactions.test.ts @@ -4,6 +4,7 @@ import { buildIMessageApprovalReactionHint, clearIMessageApprovalReactionTargetsForTest, extractIMessageApprovalPromptBinding, + listPendingIMessageApprovalReactionPollTargets, maybeResolveIMessageApprovalReaction, registerIMessageApprovalReactionTargetForOutboundMessage, registerIMessageApprovalReactionTarget, @@ -40,9 +41,9 @@ describe("iMessage approval reactions", () => { resolverMocks.isApprovalNotFoundError.mockReturnValue(false); }); - it("renders thumbs-only reaction choices for allowed decisions", () => { - expect(buildIMessageApprovalReactionHint(["allow-once", "deny"])).toBe( - "React with:\n\nπŸ‘ Allow Once\nπŸ‘Ž Deny", + it("renders shared reaction choices for allowed decisions", () => { + expect(buildIMessageApprovalReactionHint(["allow-once", "allow-always", "deny"])).toBe( + "React with:\n\nπŸ‘ Allow Once\n♾️ Allow Always\nπŸ‘Ž Deny", ); }); @@ -70,13 +71,13 @@ describe("iMessage approval reactions", () => { expect(appendIMessageApprovalReactionHintForOutboundMessage(prompt)).toBe(prompt); }); - it("does not expose allow-always as a reaction choice", () => { + it("exposes allow-always as the shared infinity reaction choice", () => { expect(buildIMessageApprovalReactionHint(["allow-once", "allow-always", "deny"])).toBe( - "React with:\n\nπŸ‘ Allow Once\nπŸ‘Ž Deny", + "React with:\n\nπŸ‘ Allow Once\n♾️ Allow Always\nπŸ‘Ž Deny", ); }); - it("does not register reaction state when only allow-always is available", () => { + it("registers and resolves allow-always through the shared infinity reaction", async () => { expect( registerIMessageApprovalReactionTarget({ accountId: "default", @@ -85,7 +86,22 @@ describe("iMessage approval reactions", () => { approvalId: "exec-allow-always", allowedDecisions: ["allow-always"], }), - ).toBeNull(); + ).toEqual({ + approvalId: "exec-allow-always", + allowedDecisions: ["allow-always"], + }); + + await expect( + resolveIMessageApprovalReactionTargetWithPersistence({ + accountId: "default", + conversation: { handle: "+15551230000" }, + messageId: "msg-allow-always", + reactionKey: "β™Ύ", + }), + ).resolves.toEqual({ + approvalId: "exec-allow-always", + decision: "allow-always", + }); }); it("resolves a registered reaction target keyed by handle", async () => { @@ -110,6 +126,40 @@ describe("iMessage approval reactions", () => { }); }); + it("merges learned chat ids into pending poll targets", () => { + registerIMessageApprovalReactionTarget({ + accountId: "default", + conversation: { handle: "+15551230000" }, + messageId: "p:0/msg-1", + approvalId: "exec-1", + allowedDecisions: ["allow-once", "deny"], + }); + registerIMessageApprovalReactionTarget({ + accountId: "default", + conversation: { + chatGuid: "SMS;-;+15551230000", + chatIdentifier: "+15551230000", + chatId: 42, + }, + messageId: "msg-1", + approvalId: "exec-1", + allowedDecisions: ["allow-once", "deny"], + }); + + expect(listPendingIMessageApprovalReactionPollTargets({ accountId: "default" })).toEqual([ + expect.objectContaining({ + approvalId: "exec-1", + conversation: { + chatGuid: "SMS;-;+15551230000", + chatIdentifier: "+15551230000", + chatId: 42, + handle: "+15551230000", + }, + messageId: "p:0/msg-1", + }), + ]); + }); + it("resolves a registered group reaction target keyed by chat_guid", async () => { registerIMessageApprovalReactionTarget({ accountId: "default", @@ -172,7 +222,7 @@ describe("iMessage approval reactions", () => { decision: "deny", }); - for (const reactionKey of ["1️⃣", "2️⃣", "3️⃣", "1", "2", "3", "❀️"]) { + for (const reactionKey of ["1️⃣", "2️⃣", "3️⃣", "1", "2", "3", "❀️", "♾️"]) { await expect( resolveIMessageApprovalReactionTargetWithPersistence({ accountId: "default", @@ -217,7 +267,7 @@ describe("iMessage approval reactions", () => { }); }); - it("ignores cross-device is_from_me tapbacks even when the actor is an approver", async () => { + it("resolves is_from_me tapbacks when the actor is an explicit approver", async () => { registerIMessageApprovalReactionTarget({ accountId: "default", conversation: { handle: "+15551230000" }, @@ -238,8 +288,14 @@ describe("iMessage approval reactions", () => { bodyText: "", }); - expect(handled).toBe(false); - expect(resolverMocks.resolveIMessageApproval).not.toHaveBeenCalled(); + expect(handled).toBe(true); + expect(resolverMocks.resolveIMessageApproval).toHaveBeenCalledWith( + expect.objectContaining({ + approvalId: "exec-self", + decision: "allow-once", + senderId: "+15551230000", + }), + ); }); it("clears the in-memory binding on successful approval resolve so toggle πŸ‘β†’πŸ‘Ž does not refire", async () => { diff --git a/extensions/imessage/src/approval-reactions.ts b/extensions/imessage/src/approval-reactions.ts index 75fd46f066a..98d9dc3bbfb 100644 --- a/extensions/imessage/src/approval-reactions.ts +++ b/extensions/imessage/src/approval-reactions.ts @@ -1,3 +1,11 @@ +import { + buildApprovalReactionHint, + createApprovalReactionTargetStore, + listApprovalReactionBindings, + resolveApprovalReactionTarget, + type ApprovalReactionDecisionBinding, + type ApprovalReactionTargetRecord, +} from "openclaw/plugin-sdk/approval-reaction-runtime"; import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { getIMessageApprovalApprovers, imessageApprovalAuth } from "./approval-auth.js"; @@ -6,56 +14,18 @@ import type { IMessagePayload } from "./monitor/types.js"; import { getOptionalIMessageRuntime } from "./runtime.js"; import { normalizeIMessageHandle } from "./targets.js"; -const IMESSAGE_APPROVAL_REACTION_META = { - "allow-once": { - emoji: "πŸ‘", - label: "Allow Once", - }, - deny: { - emoji: "πŸ‘Ž", - label: "Deny", - }, -} satisfies Partial>; - -const IMESSAGE_APPROVAL_REACTION_ORDER = [ - "allow-once", - "deny", -] as const satisfies readonly ExecApprovalReplyDecision[]; - const PERSISTENT_NAMESPACE = "imessage.approval-reactions"; const PERSISTENT_MAX_ENTRIES = 1000; const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000; -export type IMessageApprovalReactionBinding = { - decision: ExecApprovalReplyDecision; - emoji: string; - label: string; -}; +export type IMessageApprovalReactionBinding = ApprovalReactionDecisionBinding; type IMessageApprovalReactionResolution = { approvalId: string; decision: ExecApprovalReplyDecision; }; -type IMessageApprovalReactionTarget = { - approvalId: string; - allowedDecisions: readonly ExecApprovalReplyDecision[]; -}; - -type PersistedIMessageApprovalReactionTarget = { - version: 1; - target: IMessageApprovalReactionTarget; -}; - -type IMessageApprovalReactionStore = { - register( - key: string, - value: PersistedIMessageApprovalReactionTarget, - opts?: { ttlMs?: number }, - ): Promise; - lookup(key: string): Promise; - delete(key: string): Promise; -}; +type IMessageApprovalReactionTarget = ApprovalReactionTargetRecord; export type IMessageApprovalConversationKey = { chatGuid?: string; @@ -65,15 +35,17 @@ export type IMessageApprovalConversationKey = { handle?: string; }; -type InMemoryReactionEntry = { - target: IMessageApprovalReactionTarget; +export type PendingIMessageApprovalReactionPollTarget = { + accountId: string; + conversation: IMessageApprovalConversationKey; + messageId: string; + approvalId: string; + allowedDecisions: readonly ExecApprovalReplyDecision[]; expiresAtMs: number; }; -const imessageApprovalReactionTargets = new Map(); -let persistentStore: IMessageApprovalReactionStore | undefined; -let persistentStoreDisabled = false; let resolverRuntimePromise: Promise | undefined; +const pendingReactionPollTargets = new Map(); function loadApprovalResolver(): Promise { resolverRuntimePromise ??= import("./approval-resolver.js"); @@ -134,6 +106,58 @@ function enumerateReactionTargetKeys(params: { ); } +function prunePendingReactionPollTargets(nowMs = Date.now()): void { + for (const [key, target] of pendingReactionPollTargets.entries()) { + if (target.expiresAtMs <= nowMs) { + pendingReactionPollTargets.delete(key); + } + } +} + +function normalizePollTargetMessageId(messageId: string): string { + return messageId.trim().replace(/^p:\d+\//iu, ""); +} + +function mergePollTargetConversation( + left: IMessageApprovalConversationKey, + right: IMessageApprovalConversationKey, +): IMessageApprovalConversationKey { + return { + chatGuid: left.chatGuid ?? right.chatGuid, + chatIdentifier: left.chatIdentifier ?? right.chatIdentifier, + chatId: left.chatId ?? right.chatId, + handle: left.handle ?? right.handle, + }; +} + +export function listPendingIMessageApprovalReactionPollTargets(params: { + accountId: string; +}): PendingIMessageApprovalReactionPollTarget[] { + const accountId = params.accountId.trim(); + if (!accountId) { + return []; + } + prunePendingReactionPollTargets(); + const targetByApprovalAndMessage = new Map(); + for (const target of pendingReactionPollTargets.values()) { + if (target.accountId !== accountId) { + continue; + } + const key = `${target.approvalId}:${normalizePollTargetMessageId(target.messageId)}`; + const existing = targetByApprovalAndMessage.get(key); + if (!existing) { + targetByApprovalAndMessage.set(key, target); + continue; + } + targetByApprovalAndMessage.set(key, { + ...existing, + conversation: mergePollTargetConversation(existing.conversation, target.conversation), + expiresAtMs: Math.max(existing.expiresAtMs, target.expiresAtMs), + }); + } + return [...targetByApprovalAndMessage.values()]; +} + function reportPersistentApprovalReactionError(error: unknown): void { try { getOptionalIMessageRuntime() @@ -144,108 +168,46 @@ function reportPersistentApprovalReactionError(error: unknown): void { } } -function disablePersistentApprovalReactionStore(error: unknown): void { - persistentStoreDisabled = true; - persistentStore = undefined; - reportPersistentApprovalReactionError(error); -} - -function getPersistentApprovalReactionStore(): IMessageApprovalReactionStore | undefined { - if (persistentStoreDisabled) { - return undefined; - } - if (persistentStore) { - return persistentStore; - } - const runtime = getOptionalIMessageRuntime(); - if (!runtime) { - return undefined; - } - try { - persistentStore = runtime.state.openKeyedStore({ - namespace: PERSISTENT_NAMESPACE, - maxEntries: PERSISTENT_MAX_ENTRIES, - defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS, - }); - return persistentStore; - } catch (error) { - disablePersistentApprovalReactionStore(error); - return undefined; - } -} - function readPersistedTarget(value: unknown): IMessageApprovalReactionTarget | null { - const persisted = value as PersistedIMessageApprovalReactionTarget | undefined; - if ( - persisted?.version !== 1 || - !persisted.target || - typeof persisted.target.approvalId !== "string" || - !Array.isArray(persisted.target.allowedDecisions) - ) { + const target = value as Partial | undefined; + if (!target || typeof target.approvalId !== "string" || !Array.isArray(target.allowedDecisions)) { return null; } - return persisted.target; -} - -function rememberPersistentApprovalReactionTarget(params: { - key: string; - target: IMessageApprovalReactionTarget; - ttlMs?: number; -}): void { - const ttlMs = params.ttlMs == null ? DEFAULT_REACTION_TARGET_TTL_MS : Math.max(1, params.ttlMs); - const store = getPersistentApprovalReactionStore(); - if (!store) { - return; - } - void store - .register(params.key, { version: 1, target: params.target }, { ttlMs }) - .catch(disablePersistentApprovalReactionStore); -} - -function forgetPersistentApprovalReactionTarget(key: string): void { - const store = getPersistentApprovalReactionStore(); - if (!store) { - return; - } - void store.delete(key).catch(disablePersistentApprovalReactionStore); -} - -async function lookupPersistentApprovalReactionTarget( - key: string, -): Promise { - const store = getPersistentApprovalReactionStore(); - if (!store) { - return null; - } - try { - return readPersistedTarget(await store.lookup(key)); - } catch (error) { - disablePersistentApprovalReactionStore(error); + const allowedDecisions = target.allowedDecisions + .map((value) => (typeof value === "string" ? normalizeApprovalDecision(value) : null)) + .filter((value): value is ExecApprovalReplyDecision => Boolean(value)); + if (allowedDecisions.length === 0) { return null; } + return { + approvalId: target.approvalId, + allowedDecisions, + ...(target.approvalKind === "exec" || target.approvalKind === "plugin" + ? { approvalKind: target.approvalKind } + : {}), + }; } +const imessageApprovalReactionTargets = + createApprovalReactionTargetStore({ + namespace: PERSISTENT_NAMESPACE, + maxEntries: PERSISTENT_MAX_ENTRIES, + defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS, + openStore: (params) => getOptionalIMessageRuntime()?.state.openKeyedStore(params), + logPersistentError: reportPersistentApprovalReactionError, + readPersistedTarget, + }); + export function listIMessageApprovalReactionBindings( allowedDecisions: readonly ExecApprovalReplyDecision[], ): IMessageApprovalReactionBinding[] { - const allowed = new Set(allowedDecisions); - return IMESSAGE_APPROVAL_REACTION_ORDER.filter((decision) => allowed.has(decision)).map( - (decision) => ({ - decision, - emoji: IMESSAGE_APPROVAL_REACTION_META[decision].emoji, - label: IMESSAGE_APPROVAL_REACTION_META[decision].label, - }), - ); + return listApprovalReactionBindings({ allowedDecisions }); } export function buildIMessageApprovalReactionHint( allowedDecisions: readonly ExecApprovalReplyDecision[], ): string | null { - const bindings = listIMessageApprovalReactionBindings(allowedDecisions); - if (bindings.length === 0) { - return null; - } - return `React with:\n\n${bindings.map((binding) => `${binding.emoji} ${binding.label}`).join("\n")}`; + return buildApprovalReactionHint({ allowedDecisions }); } function insertIMessageApprovalReactionHintNearHeader(params: { @@ -292,26 +254,6 @@ export function appendIMessageApprovalReactionHintForOutboundMessage(text: strin }); } -function resolveIMessageApprovalReactionDecision( - reactionKey: string, - allowedDecisions: readonly ExecApprovalReplyDecision[], -): ExecApprovalReplyDecision | null { - const normalizedReaction = reactionKey.trim(); - if (!normalizedReaction) { - return null; - } - const allowed = new Set(allowedDecisions); - for (const decision of IMESSAGE_APPROVAL_REACTION_ORDER) { - if (!allowed.has(decision)) { - continue; - } - if (IMESSAGE_APPROVAL_REACTION_META[decision].emoji === normalizedReaction) { - return decision; - } - } - return null; -} - function normalizeApprovalDecision(value: string): ExecApprovalReplyDecision | null { const normalized = value.trim().toLowerCase(); if (normalized === "always") { @@ -376,7 +318,6 @@ export function registerIMessageApprovalReactionTarget(params: { } const target = { approvalId, allowedDecisions }; const ttlMs = params.ttlMs == null ? DEFAULT_REACTION_TARGET_TTL_MS : Math.max(1, params.ttlMs); - const expiresAtMs = Date.now() + ttlMs; // Register the binding under every key we can derive from the conversation // (chat_guid / chat_identifier / chat_id / handle). Inbound lookup precedence // can differ from outbound β€” e.g. send only sees `{handle: "+1..."}` for a @@ -392,10 +333,17 @@ export function registerIMessageApprovalReactionTarget(params: { return null; } for (const key of keys) { - imessageApprovalReactionTargets.set(key, { target, expiresAtMs }); - rememberPersistentApprovalReactionTarget({ key, target, ttlMs }); + imessageApprovalReactionTargets.register(key, target, { ttlMs }); + pendingReactionPollTargets.set(key, { + accountId: params.accountId, + conversation: params.conversation, + messageId: params.messageId, + approvalId, + allowedDecisions, + expiresAtMs: Date.now() + ttlMs, + }); } - pruneExpiredInMemoryReactionTargets(); + prunePendingReactionPollTargets(); return target; } @@ -430,7 +378,7 @@ export function unregisterIMessageApprovalReactionTarget(params: { const keys = enumerateReactionTargetKeys(params); for (const key of keys) { imessageApprovalReactionTargets.delete(key); - forgetPersistentApprovalReactionTarget(key); + pendingReactionPollTargets.delete(key); } } @@ -438,36 +386,8 @@ function resolveTarget(params: { target: IMessageApprovalReactionTarget | null | undefined; reactionKey: string; }): IMessageApprovalReactionResolution | null { - const target = params.target; - if (!target) { - return null; - } - const decision = resolveIMessageApprovalReactionDecision( - params.reactionKey, - target.allowedDecisions, - ); - return decision ? { approvalId: target.approvalId, decision } : null; -} - -function lookupInMemoryReactionTarget(key: string): IMessageApprovalReactionTarget | null { - const entry = imessageApprovalReactionTargets.get(key); - if (!entry) { - return null; - } - if (entry.expiresAtMs <= Date.now()) { - imessageApprovalReactionTargets.delete(key); - return null; - } - return entry.target; -} - -function pruneExpiredInMemoryReactionTargets(): void { - const now = Date.now(); - for (const [key, entry] of imessageApprovalReactionTargets) { - if (entry.expiresAtMs <= now) { - imessageApprovalReactionTargets.delete(key); - } - } + const target = resolveApprovalReactionTarget(params); + return target ? { approvalId: target.approvalId, decision: target.decision } : null; } export async function resolveIMessageApprovalReactionTargetWithPersistence(params: { @@ -482,21 +402,12 @@ export async function resolveIMessageApprovalReactionTargetWithPersistence(param // (chat_guid β†’ chat_identifier β†’ chat_id β†’ handle) and accept the first hit. const keys = enumerateReactionTargetKeys(params); for (const key of keys) { - const inMemory = resolveTarget({ - target: lookupInMemoryReactionTarget(key), + const target = resolveTarget({ + target: await imessageApprovalReactionTargets.lookup(key), reactionKey: params.reactionKey, }); - if (inMemory) { - return inMemory; - } - } - for (const key of keys) { - const persisted = resolveTarget({ - target: await lookupPersistentApprovalReactionTarget(key), - reactionKey: params.reactionKey, - }); - if (persisted) { - return persisted; + if (target) { + return target; } } return null; @@ -523,13 +434,6 @@ function readApprovalReactionEvent( message: IMessagePayload, bodyText: string, ): IMessageApprovalReactionEvent | null { - // Cross-device echo: Apple delivers is_from_me=true rows for the operator's - // own tapbacks across paired devices. Ignoring them prevents a bot whose - // own handle is in `allowFrom` (a common dogfooding setup) from - // self-approving via the operator's reaction on a different Apple device. - if (message.is_from_me === true) { - return null; - } const reaction = resolveIMessageReactionContext(message, bodyText); if (!reaction) { return null; @@ -684,8 +588,7 @@ export async function maybeResolveIMessageApprovalReaction(params: { } export function clearIMessageApprovalReactionTargetsForTest(): void { - imessageApprovalReactionTargets.clear(); - persistentStore = undefined; - persistentStoreDisabled = false; + imessageApprovalReactionTargets.clearForTest(); + pendingReactionPollTargets.clear(); resolverRuntimePromise = undefined; } diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 89c4a8ffe5c..29f46f04d15 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -16,7 +16,10 @@ import { } from "openclaw/plugin-sdk/status-helpers"; import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { imessageMessageActions } from "./actions.js"; -import { imessageApprovalCapability } from "./approval-native.js"; +import { + imessageApprovalCapability, + shouldSuppressLocalIMessageExecApprovalPrompt, +} from "./approval-native.js"; import { chunkTextForOutbound, collectStatusIssuesFromLastError, @@ -320,6 +323,8 @@ export const imessagePlugin: ChannelPlugin sanitizeForPlainText(sanitizeOutboundText(text)), + shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload, hint }) => + shouldSuppressLocalIMessageExecApprovalPrompt({ cfg, accountId, payload, hint }), deliveryCapabilities: { durableFinal: { text: true, diff --git a/extensions/imessage/src/client.ts b/extensions/imessage/src/client.ts index 323bba23e79..463a043151e 100644 --- a/extensions/imessage/src/client.ts +++ b/extensions/imessage/src/client.ts @@ -88,7 +88,7 @@ export class IMessageRpcClient { if (isTestEnv()) { throw new Error("Refusing to start imsg rpc in test environment; mock iMessage RPC client"); } - const args = ["rpc"]; + const args = ["rpc", "--json"]; if (this.dbPath) { args.push("--db", this.dbPath); } diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts index b20d5075b3d..0465e13cfe7 100644 --- a/extensions/imessage/src/monitor/monitor-provider.ts +++ b/extensions/imessage/src/monitor/monitor-provider.ts @@ -39,6 +39,7 @@ import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/sess import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime"; import { resolveIMessageAccount } from "../accounts.js"; +import { pollPendingIMessageApprovalReactions } from "../approval-reaction-poller.js"; import { maybeResolveIMessageApprovalReaction } from "../approval-reactions.js"; import { markIMessageChatRead, sendIMessageTyping } from "../chat.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "../client.js"; @@ -82,6 +83,8 @@ import { sanitizeIMessageWatchErrorPayload } from "./watch-error-log.js"; const WATCH_SUBSCRIBE_MAX_ATTEMPTS = 3; const WATCH_SUBSCRIBE_RETRY_DELAY_MS = 1_000; +const APPROVAL_REACTION_POLL_INTERVAL_MS = 2_000; +const APPROVAL_REACTION_DISCOVERY_INTERVAL_MS = 60_000; function isIMessagePluginPayloadAttachment(attachment: { original_path?: string | null; @@ -1052,6 +1055,33 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P abortSignal: abort, }) : undefined; + let approvalReactionPollInFlight = false; + const pollApprovalReactions = async (allowRecentChatDiscovery = false) => { + if (approvalReactionPollInFlight) { + return; + } + approvalReactionPollInFlight = true; + try { + await pollPendingIMessageApprovalReactions({ + client: activeClient, + cfg, + accountId: accountInfo.accountId, + allowRecentChatDiscovery, + logVerboseMessage: logVerbose, + }); + } catch (err) { + logVerbose(`imessage: approval reaction poll failed: ${String(err)}`); + } finally { + approvalReactionPollInFlight = false; + } + }; + const approvalReactionPollTimer = setInterval(() => { + void pollApprovalReactions(); + }, APPROVAL_REACTION_POLL_INTERVAL_MS); + const approvalReactionDiscoveryTimer = setInterval(() => { + void pollApprovalReactions(true); + }, APPROVAL_REACTION_DISCOVERY_INTERVAL_MS); + void pollApprovalReactions(true); // Catchup runs once between watch.subscribe and the live dispatch loop. // Anything that arrives during the catchup pass itself flows through @@ -1100,6 +1130,8 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P runtime.error?.(danger(`imessage: monitor failed: ${String(err)}`)); throw err; } finally { + clearInterval(approvalReactionPollTimer); + clearInterval(approvalReactionDiscoveryTimer); approvalContextLease?.dispose(); detachAbortHandler(); await activeClient.stop(); diff --git a/extensions/imessage/src/send.test.ts b/extensions/imessage/src/send.test.ts index 99c12cbe097..92df0150c7a 100644 --- a/extensions/imessage/src/send.test.ts +++ b/extensions/imessage/src/send.test.ts @@ -1,4 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + clearIMessageApprovalReactionTargetsForTest, + resolveIMessageApprovalReactionTargetWithPersistence, +} from "./approval-reactions.js"; import type { IMessageRpcClient } from "./client.js"; import { sendMessageIMessage } from "./send.js"; @@ -19,7 +26,30 @@ function createClient(result: Record): IMessageRpcClient { } as unknown as IMessageRpcClient; } +function createRejectingClient(error: Error): IMessageRpcClient { + return { + request: vi.fn(async () => { + throw error; + }), + stop: vi.fn(async () => {}), + } as unknown as IMessageRpcClient; +} + +function createApprovalText(id = "approval-123"): string { + return [ + "Exec approval required", + `ID: ${id}`, + "", + `Reply with: /approve ${id} allow-once|deny`, + ].join("\n"); +} + describe("sendMessageIMessage receipts", () => { + afterEach(() => { + clearIMessageApprovalReactionTargetsForTest(); + vi.unstubAllEnvs(); + }); + it("attaches a text receipt for native send ids", async () => { const client = createClient({ guid: "p:0/imsg-1" }); @@ -265,4 +295,303 @@ describe("sendMessageIMessage receipts", () => { expect(result.messageId).toBe("ok"); expect(result.receipt.platformMessageIds).toStrictEqual([]); }); + + it("resolves numeric chat.db ROWIDs to GUIDs for approval reaction binding", async () => { + const client = createClient({ message_id: 12345 }); + const resolveMessageGuidImpl = vi.fn(async () => "p:0/resolved-guid"); + + const result = await sendMessageIMessage("chat_id:42", "hello", { + config: IMESSAGE_TEST_CFG, + client, + dbPath: "/Users/me/Library/Messages/chat.db", + resolveMessageGuidImpl, + }); + + expect(result.messageId).toBe("12345"); + expect(result.guid).toBe("p:0/resolved-guid"); + expect(resolveMessageGuidImpl).toHaveBeenCalledWith({ + dbPath: "/Users/me/Library/Messages/chat.db", + messageId: "12345", + }); + }); + + it("does not resolve chat.db GUIDs when the bridge already returned a GUID", async () => { + const client = createClient({ guid: "p:0/native-guid" }); + const resolveMessageGuidImpl = vi.fn(async () => "p:0/resolved-guid"); + + const result = await sendMessageIMessage("chat_id:42", "hello", { + config: IMESSAGE_TEST_CFG, + client, + dbPath: "/Users/me/Library/Messages/chat.db", + resolveMessageGuidImpl, + }); + + expect(result.messageId).toBe("p:0/native-guid"); + expect(result.guid).toBe("p:0/native-guid"); + expect(resolveMessageGuidImpl).not.toHaveBeenCalled(); + }); + + it("leaves reaction binding unset when numeric ROWID cannot be resolved", async () => { + const client = createClient({ message_id: 12345 }); + const resolveMessageGuidImpl = vi.fn(async () => null); + + const result = await sendMessageIMessage("chat_id:42", "hello", { + config: IMESSAGE_TEST_CFG, + client, + dbPath: "/Users/me/Library/Messages/chat.db", + resolveMessageGuidImpl, + }); + + expect(result.messageId).toBe("12345"); + expect(result.guid).toBeUndefined(); + }); + + it("recovers approval prompt GUID without resending when rpc send times out", async () => { + const client = createRejectingClient(new Error("imsg rpc timeout (send)")); + const createClient = vi.fn(async () => client); + const runCliJson = vi.fn(); + const resolveSentMessageGuidImpl = vi.fn(async () => "p:0/fallback-guid"); + const approvalText = createApprovalText(); + + const result = await sendMessageIMessage("chat_id:42", approvalText, { + config: IMESSAGE_TEST_CFG, + createClient, + runCliJson, + service: "sms", + dbPath: "/Users/me/Library/Messages/chat.db", + resolveSentMessageGuidImpl, + }); + + expect(result.messageId).toBe("p:0/fallback-guid"); + expect(result.guid).toBe("p:0/fallback-guid"); + expect(client.stop).toHaveBeenCalledOnce(); + expect(runCliJson).not.toHaveBeenCalled(); + expect(resolveSentMessageGuidImpl).toHaveBeenCalledWith({ + dbPath: "/Users/me/Library/Messages/chat.db", + target: expect.objectContaining({ kind: "chat_id", chatId: 42 }), + text: expect.stringContaining("ID: approval-123"), + sentAfterMs: expect.any(Number), + }); + }); + + it("uses the default local chat.db path for timeout GUID recovery", async () => { + vi.stubEnv("HOME", "/Users/me"); + const client = createRejectingClient(new Error("imsg rpc timeout (send)")); + const runCliJson = vi.fn(); + const resolveSentMessageGuidImpl = vi.fn(async () => "p:0/default-db-guid"); + const approvalText = createApprovalText("approval-default"); + + const result = await sendMessageIMessage("chat_id:42", approvalText, { + config: IMESSAGE_TEST_CFG, + client, + runCliJson, + resolveSentMessageGuidImpl, + }); + + expect(result.messageId).toBe("p:0/default-db-guid"); + expect(runCliJson).not.toHaveBeenCalled(); + expect(resolveSentMessageGuidImpl).toHaveBeenCalledWith({ + dbPath: "/Users/me/Library/Messages/chat.db", + target: expect.objectContaining({ kind: "chat_id", chatId: 42 }), + text: expect.stringContaining("ID: approval-default"), + sentAfterMs: expect.any(Number), + }); + }); + + it("uses the default local chat.db path for Homebrew imsg paths", async () => { + vi.stubEnv("HOME", "/Users/me"); + const client = createRejectingClient(new Error("imsg rpc timeout (send)")); + const runCliJson = vi.fn(); + const resolveSentMessageGuidImpl = vi.fn(async () => "p:0/homebrew-guid"); + const approvalText = createApprovalText("approval-homebrew"); + + const result = await sendMessageIMessage("chat_id:42", approvalText, { + config: IMESSAGE_TEST_CFG, + client, + cliPath: "/opt/homebrew/bin/imsg", + runCliJson, + resolveSentMessageGuidImpl, + }); + + expect(result.messageId).toBe("p:0/homebrew-guid"); + expect(runCliJson).not.toHaveBeenCalled(); + expect(resolveSentMessageGuidImpl).toHaveBeenCalledWith({ + dbPath: "/Users/me/Library/Messages/chat.db", + target: expect.objectContaining({ kind: "chat_id", chatId: 42 }), + text: expect.stringContaining("ID: approval-homebrew"), + sentAfterMs: expect.any(Number), + }); + }); + + it("does not use the local default chat.db path for custom cliPath wrappers", async () => { + vi.stubEnv("HOME", "/Users/me"); + const client = createRejectingClient(new Error("imsg rpc timeout (send)")); + const runCliJson = vi.fn(); + const resolveSentMessageGuidImpl = vi.fn(async () => null); + const approvalText = createApprovalText("approval-remote"); + + await expect( + sendMessageIMessage("chat_id:42", approvalText, { + config: { + channels: { + imessage: { + accounts: { + default: { + remoteHost: "bot@gateway-host", + }, + }, + }, + }, + }, + client, + cliPath: "/Users/me/.openclaw/scripts/imsg", + runCliJson, + resolveSentMessageGuidImpl, + }), + ).rejects.toThrow("imsg rpc timeout (send)"); + + expect(runCliJson).not.toHaveBeenCalled(); + expect(resolveSentMessageGuidImpl).toHaveBeenCalledWith({ + dbPath: undefined, + target: expect.objectContaining({ kind: "chat_id", chatId: 42 }), + text: expect.stringContaining("ID: approval-remote"), + sentAfterMs: expect.any(Number), + }); + }); + + it("does not use the local default chat.db path for auto-detected ssh wrappers", async () => { + vi.stubEnv("HOME", "/Users/me"); + const wrapperDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-wrapper-")); + const wrapperPath = path.join(wrapperDir, "imsg"); + fs.writeFileSync(wrapperPath, '#!/bin/sh\nexec ssh -T gateway-host imsg "$@"\n'); + const client = createRejectingClient(new Error("imsg rpc timeout (send)")); + const runCliJson = vi.fn(); + const resolveSentMessageGuidImpl = vi.fn(async () => null); + const approvalText = createApprovalText("approval-ssh-wrapper"); + + try { + await expect( + sendMessageIMessage("chat_id:42", approvalText, { + config: IMESSAGE_TEST_CFG, + client, + cliPath: wrapperPath, + runCliJson, + resolveSentMessageGuidImpl, + }), + ).rejects.toThrow("imsg rpc timeout (send)"); + } finally { + fs.rmSync(wrapperDir, { recursive: true, force: true }); + } + + expect(runCliJson).not.toHaveBeenCalled(); + expect(resolveSentMessageGuidImpl).toHaveBeenCalledWith({ + dbPath: undefined, + target: expect.objectContaining({ kind: "chat_id", chatId: 42 }), + text: expect.stringContaining("ID: approval-ssh-wrapper"), + sentAfterMs: expect.any(Number), + }); + }); + + it("throws the rpc timeout without resending for generic text", async () => { + const client = createRejectingClient(new Error("imsg rpc timeout (send)")); + const runCliJson = vi.fn(); + const resolveSentMessageGuidImpl = vi.fn(async () => "p:0/stale-guid"); + + 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(resolveSentMessageGuidImpl).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(); + const resolveSentMessageGuidImpl = vi.fn(async () => null); + const approvalText = createApprovalText(); + + await expect( + sendMessageIMessage("chat_id:42", approvalText, { + 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(resolveSentMessageGuidImpl).toHaveBeenCalled(); + }); + + it("recovers a GUID for approval prompts when rpc send returns only sent status", async () => { + const client = createClient({ status: "sent" }); + const resolveSentMessageGuidImpl = vi.fn(async () => "p:0/recovered-guid"); + const approvalText = createApprovalText(); + + const result = await sendMessageIMessage("chat_id:42", approvalText, { + config: IMESSAGE_TEST_CFG, + client, + dbPath: "/Users/me/Library/Messages/chat.db", + resolveSentMessageGuidImpl, + }); + + expect(result.messageId).toBe("ok"); + expect(result.guid).toBe("p:0/recovered-guid"); + await expect( + resolveIMessageApprovalReactionTargetWithPersistence({ + accountId: "default", + conversation: { chatId: 42 }, + messageId: "p:0/recovered-guid", + reactionKey: "πŸ‘", + }), + ).resolves.toEqual({ + approvalId: "approval-123", + decision: "allow-once", + }); + expect(resolveSentMessageGuidImpl).toHaveBeenCalledWith({ + dbPath: "/Users/me/Library/Messages/chat.db", + target: expect.objectContaining({ kind: "chat_id", chatId: 42 }), + text: expect.stringContaining("ID: approval-123"), + sentAfterMs: expect.any(Number), + }); + }); + + it("does not poll for approval prompt GUIDs when chat.db is unavailable", async () => { + const client = createClient({ status: "sent" }); + const approvalText = createApprovalText(); + const startedAt = performance.now(); + + const result = await sendMessageIMessage("chat_id:42", approvalText, { + config: IMESSAGE_TEST_CFG, + client, + dbPath: "/path/to/missing/chat.db", + }); + + expect(performance.now() - startedAt).toBeLessThan(250); + expect(result.messageId).toBe("ok"); + expect(result.guid).toBeUndefined(); + }); + + it("does not use one-shot imsg fallback for non-timeout rpc send errors", async () => { + const client = createRejectingClient(new Error("imsg rpc error (send)")); + const runCliJson = vi.fn(); + + await expect( + sendMessageIMessage("chat_id:42", "hello", { + config: IMESSAGE_TEST_CFG, + client, + runCliJson, + }), + ).rejects.toThrow("imsg rpc error (send)"); + + expect(runCliJson).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index 86babc9e5f0..7e6e443f70f 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -1,4 +1,8 @@ import { spawn } from "node:child_process"; +import { constants, accessSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; import { createMessageReceiptFromOutboundResults, type MessageReceipt, @@ -14,6 +18,7 @@ import { stripInlineDirectiveTagsForDelivery } from "openclaw/plugin-sdk/text-ch import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { appendIMessageApprovalReactionHintForOutboundMessage, + extractIMessageApprovalPromptBinding, type IMessageApprovalConversationKey, registerIMessageApprovalReactionTargetForOutboundMessage, } from "./approval-reactions.js"; @@ -28,6 +33,9 @@ import { parseIMessageTarget, } from "./targets.js"; +const require = createRequire(import.meta.url); +type ParsedIMessageTarget = ReturnType; + type IMessageSendOpts = { cliPath?: string; dbPath?: string; @@ -54,6 +62,16 @@ type IMessageSendOpts = { ) => Promise<{ path: string; contentType?: string }>; createClient?: (params: { cliPath: string; dbPath?: string }) => Promise; runCliJson?: (args: readonly string[]) => Promise>; + resolveMessageGuidImpl?: (params: { + dbPath?: string; + messageId: string; + }) => Promise | string | null; + resolveSentMessageGuidImpl?: (params: { + dbPath?: string; + target: ParsedIMessageTarget; + text: string; + sentAfterMs?: number; + }) => Promise | string | null; }; export type IMessageSendResult = { @@ -78,6 +96,72 @@ export type IMessageSendResult = { }; const MAX_REPLY_TO_ID_LENGTH = 256; +const sshWrapperCliPathCache = new Map(); + +function safeHomeDir(): string | undefined { + const home = process.env.HOME?.trim(); + if (home) { + return home; + } + try { + return os.homedir().trim() || undefined; + } catch { + return undefined; + } +} + +function expandCliPathForInspection(cliPath: string): string { + if (!cliPath.startsWith("~")) { + return cliPath; + } + const home = safeHomeDir(); + return home ? cliPath.replace(/^~(?=$|[\\/])/, home) : cliPath; +} + +function isSshIMessageCliWrapper(cliPath: string): boolean { + if (cliPath === "imsg") { + return false; + } + const cached = sshWrapperCliPathCache.get(cliPath); + if (cached !== undefined) { + return cached; + } + let detected = false; + try { + const content = readFileSync(expandCliPathForInspection(cliPath), "utf8"); + detected = /\bssh\b[\s\S]*\bimsg\b/u.test(content); + } catch { + detected = false; + } + // cliPath scripts are process-stable channel metadata; cache inspection so + // repeated sends do not poll wrapper files on the hot path. + sshWrapperCliPathCache.set(cliPath, detected); + return detected; +} + +function isLocalIMessageCliPath(params: { cliPath: string; remoteHost?: string }): boolean { + const cliPath = params.cliPath.trim(); + if (params.remoteHost?.trim() || isSshIMessageCliWrapper(cliPath)) { + return false; + } + return cliPath === "imsg" || path.basename(cliPath) === "imsg"; +} + +function resolveChatDbLookupPath(params: { + cliPath: string; + dbPath?: string; + remoteHost?: string; +}): string | undefined { + const configured = params.dbPath?.trim(); + if (configured) { + return configured; + } + if (!isLocalIMessageCliPath({ cliPath: params.cliPath, remoteHost: params.remoteHost })) { + return undefined; + } + const home = safeHomeDir(); + return home ? path.join(home, "Library", "Messages", "chat.db") : undefined; +} function stripUnsafeReplyTagChars(value: string): string { let next = ""; @@ -147,6 +231,227 @@ function resolveOutboundMessageGuid( return null; } +function isNumericMessageRowId(value: string | null | undefined): value is string { + return typeof value === "string" && /^\d+$/.test(value.trim()); +} + +function normalizeResolvedMessageGuid(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed && !isNumericMessageRowId(trimmed) ? trimmed : null; +} + +function loadNodeSqlite(): typeof import("node:sqlite") | null { + try { + return require("node:sqlite") as typeof import("node:sqlite"); + } catch { + return null; + } +} + +function resolveMessageGuidFromChatDb(params: { + dbPath?: string; + messageId: string; +}): string | null { + const dbPath = params.dbPath?.trim(); + const messageId = params.messageId.trim(); + if (!dbPath || !isNumericMessageRowId(messageId)) { + return null; + } + const sqlite = loadNodeSqlite(); + if (!sqlite) { + return null; + } + let db: import("node:sqlite").DatabaseSync | null = null; + try { + db = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + const row = db.prepare("SELECT guid FROM message WHERE ROWID = ?").get(messageId) as + | { guid?: unknown } + | undefined; + return normalizeResolvedMessageGuid(row?.guid); + } catch { + return null; + } finally { + try { + db?.close(); + } catch { + // best-effort cleanup + } + } +} + +function getStringRowValue(row: Record | undefined, key: string): string | null { + return normalizeResolvedMessageGuid(row?.[key]); +} + +function appleMessageDateLowerBoundMs(sentAfterMs: number | undefined): number | null { + if (!Number.isFinite(sentAfterMs)) { + return null; + } + // chat.db stores message.date as nanoseconds since 2001-01-01. Give the + // bridge a small amount of clock/write skew so a just-sent row is included. + return Math.max(0, Math.floor(((sentAfterMs as number) - 978_307_200_000 - 5_000) * 1_000_000)); +} + +function resolveLatestSentMessageGuidFromChatDb(params: { + dbPath?: string; + target: ParsedIMessageTarget; + text: string; + sentAfterMs?: number; +}): string | null { + const dbPath = params.dbPath?.trim(); + if (!dbPath) { + return null; + } + const sqlite = loadNodeSqlite(); + if (!sqlite) { + return null; + } + let db: import("node:sqlite").DatabaseSync | null = null; + try { + db = new sqlite.DatabaseSync(dbPath, { readOnly: true }); + const targetClauses: string[] = []; + const targetParams: Array = []; + const lowerBound = appleMessageDateLowerBoundMs(params.sentAfterMs); + if (params.text) { + targetClauses.push("m.text = ?"); + targetParams.push(params.text); + } + if (lowerBound !== null) { + targetClauses.push("m.date >= ?"); + targetParams.push(lowerBound); + } + if (params.target.kind === "chat_id") { + targetClauses.push("cmj.chat_id = ?"); + targetParams.push(params.target.chatId); + } else if (params.target.kind === "chat_guid") { + targetClauses.push("c.guid = ?"); + targetParams.push(params.target.chatGuid); + } else if (params.target.kind === "chat_identifier") { + targetClauses.push("c.chat_identifier = ?"); + targetParams.push(params.target.chatIdentifier); + } else { + const normalizedHandle = normalizeIMessageHandle(params.target.to); + targetClauses.push("(h.id = ? OR h.uncanonicalized_id = ?)"); + targetParams.push(normalizedHandle, params.target.to); + } + const targetWhere = targetClauses.length ? `AND ${targetClauses.join(" AND ")}` : ""; + const selectSql = ` + SELECT m.guid + FROM message m + LEFT JOIN chat_message_join cmj ON cmj.message_id = m.ROWID + LEFT JOIN chat c ON c.ROWID = cmj.chat_id + LEFT JOIN handle h ON h.ROWID = m.handle_id + WHERE m.is_from_me = 1 + ${targetWhere} + ORDER BY m.date DESC, m.ROWID DESC + LIMIT 10 + `; + const rows = db.prepare(selectSql).all(...targetParams) as Array>; + return getStringRowValue(rows[0], "guid"); + } catch { + return null; + } finally { + try { + db?.close(); + } catch { + // best-effort cleanup + } + } +} + +function canResolveLatestSentMessageGuidFromChatDb(dbPath?: string): boolean { + const normalizedDbPath = dbPath?.trim(); + if (!normalizedDbPath || !loadNodeSqlite()) { + return false; + } + try { + accessSync(normalizedDbPath, constants.R_OK); + return true; + } catch { + return false; + } +} + +async function resolveApprovalBindingMessageGuid(params: { + dbPath?: string; + messageId: string | null; + result: Record | null | undefined; + resolveMessageGuidImpl?: IMessageSendOpts["resolveMessageGuidImpl"]; +}): Promise { + const immediateGuid = resolveOutboundMessageGuid(params.result); + if (immediateGuid) { + return immediateGuid; + } + const messageId = params.messageId?.trim(); + if (!messageId || !isNumericMessageRowId(messageId)) { + return null; + } + const resolver = params.resolveMessageGuidImpl ?? resolveMessageGuidFromChatDb; + return normalizeResolvedMessageGuid( + await resolver({ + dbPath: params.dbPath, + messageId, + }), + ); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function resolveFallbackSentMessageGuid(params: { + dbPath?: string; + target: ParsedIMessageTarget; + text: string; + sentAfterMs?: number; + resolveSentMessageGuidImpl?: IMessageSendOpts["resolveSentMessageGuidImpl"]; +}): Promise { + const resolver = params.resolveSentMessageGuidImpl ?? resolveLatestSentMessageGuidFromChatDb; + if ( + !params.resolveSentMessageGuidImpl && + !canResolveLatestSentMessageGuidFromChatDb(params.dbPath) + ) { + return null; + } + const deadlineMs = Date.now() + 5_000; + while (Date.now() <= deadlineMs) { + const resolved = normalizeResolvedMessageGuid( + await resolver({ + dbPath: params.dbPath, + target: params.target, + text: params.text, + sentAfterMs: params.sentAfterMs, + }), + ); + if (resolved) { + return resolved; + } + if (Date.now() >= deadlineMs) { + return null; + } + await delay(250); + } + return null; +} + +function shouldRecoverApprovalPromptGuid(params: { + message: string; + filePath?: string; + replyToId?: string | null; +}): boolean { + return ( + !params.filePath && + !params.replyToId && + Boolean(params.message.trim()) && + Boolean(extractIMessageApprovalPromptBinding(params.message)) + ); +} + function resolveOutboundEchoText(text: string, mediaContentType?: string): string | undefined { if (text.trim()) { return text; @@ -226,6 +531,11 @@ function resolveIMessageCliFailure(result: Record): string | nu : "iMessage action failed"; } +function isIMessageRpcSendTimeout(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /imsg rpc timeout \(send\)/i.test(message); +} + async function runIMessageCliJson( cliPath: string, dbPath: string | undefined, @@ -339,10 +649,12 @@ async function resolveAttachmentChatGuid(params: { async function trySendAttachmentForExplicitChat(params: { accountId: string; + dbPath?: string; target: ReturnType; filePath: string; echoText?: string; runCliJson: (args: readonly string[]) => Promise>; + resolveMessageGuidImpl?: IMessageSendOpts["resolveMessageGuidImpl"]; }): Promise { let attachmentChatGuid: string | null = null; try { @@ -387,7 +699,12 @@ async function trySendAttachmentForExplicitChat(params: { } const resolvedId = resolveMessageId(result); - const approvalBindingMessageId = resolveOutboundMessageGuid(result); + const approvalBindingMessageId = await resolveApprovalBindingMessageGuid({ + dbPath: params.dbPath, + messageId: resolvedId, + result, + resolveMessageGuidImpl: params.resolveMessageGuidImpl, + }); const messageId = resolvedId ?? (result.ok || result.success ? "ok" : "unknown"); const echoScope = resolveOutboundEchoScope({ accountId: params.accountId, @@ -437,6 +754,11 @@ export async function sendMessageIMessage( }); const cliPath = opts.cliPath?.trim() || account.config.cliPath?.trim() || "imsg"; const dbPath = opts.dbPath?.trim() || account.config.dbPath?.trim(); + const chatDbLookupPath = resolveChatDbLookupPath({ + cliPath, + dbPath, + remoteHost: account.config.remoteHost, + }); const target = parseIMessageTarget(opts.chatId ? formatIMessageChatTarget(opts.chatId) : to); const service = opts.service ?? @@ -498,10 +820,12 @@ export async function sendMessageIMessage( if (filePath && !message.trim() && !resolvedReplyToId) { const attachmentResult = await trySendAttachmentForExplicitChat({ accountId: account.accountId, + dbPath: chatDbLookupPath, target, filePath, echoText, runCliJson, + resolveMessageGuidImpl: opts.resolveMessageGuidImpl, }); if (attachmentResult) { return attachmentResult; @@ -537,17 +861,69 @@ export async function sendMessageIMessage( (opts.createClient ? await opts.createClient({ cliPath, dbPath }) : await createIMessageRpcClient({ cliPath, dbPath })); - const shouldClose = !opts.client; + let shouldClose = !opts.client; + let result: Record; + let sendStartedAtMs = Date.now(); try { - const result = await client.request<{ ok?: string }>("send", params, { - timeoutMs: opts.timeoutMs, - }); + try { + result = await client.request>("send", params, { + timeoutMs: opts.timeoutMs, + }); + } catch (error) { + if (filePath || resolvedReplyToId || !isIMessageRpcSendTimeout(error)) { + throw error; + } + if ( + !shouldRecoverApprovalPromptGuid({ + message, + filePath, + replyToId: resolvedReplyToId, + }) + ) { + throw error; + } + const recoveredGuid = await resolveFallbackSentMessageGuid({ + dbPath: chatDbLookupPath, + target, + text: message, + sentAfterMs: sendStartedAtMs, + resolveSentMessageGuidImpl: opts.resolveSentMessageGuidImpl, + }); + if (!recoveredGuid) { + throw error; + } + result = { guid: recoveredGuid, status: "sent" }; + } const resolvedId = resolveMessageId(result); - const messageId = resolvedId ?? (result?.ok ? "ok" : "unknown"); + const messageId = + resolvedId ?? (result?.ok || result?.success || result?.status === "sent" ? "ok" : "unknown"); // GUID-only id for approval-reaction binding (inbound `reacted_to_guid` // never carries a numeric ROWID, so the bind key must match). Undefined - // when the bridge only returned a numeric or placeholder id. - const approvalBindingMessageId = resolveOutboundMessageGuid(result); + // when the bridge only returned a placeholder id. Numeric ROWIDs are + // resolved through chat.db when available so chat_id sends can still bind + // to the stable GUID surfaced by inbound tapbacks. + let approvalBindingMessageId = await resolveApprovalBindingMessageGuid({ + dbPath: chatDbLookupPath, + messageId: resolvedId, + result, + resolveMessageGuidImpl: opts.resolveMessageGuidImpl, + }); + if ( + !approvalBindingMessageId && + shouldRecoverApprovalPromptGuid({ + message, + filePath, + replyToId: resolvedReplyToId, + }) + ) { + approvalBindingMessageId = await resolveFallbackSentMessageGuid({ + dbPath: chatDbLookupPath, + target, + text: message, + sentAfterMs: sendStartedAtMs, + resolveSentMessageGuidImpl: opts.resolveSentMessageGuidImpl, + }); + } const echoScope = resolveOutboundEchoScope({ accountId: account.accountId, target }); if (echoScope) { rememberPersistedIMessageEcho({ @@ -575,24 +951,22 @@ export async function sendMessageIMessage( timestamp: Date.now(), isFromMe: true, }); - if (message) { - if (approvalBindingMessageId) { - const handleForKey = - target.kind === "handle" ? normalizeIMessageHandle(target.to) : undefined; - const conversation: IMessageApprovalConversationKey = { - ...(target.kind === "chat_guid" ? { chatGuid: target.chatGuid } : {}), - ...(target.kind === "chat_identifier" ? { chatIdentifier: target.chatIdentifier } : {}), - ...(target.kind === "chat_id" ? { chatId: target.chatId } : {}), - ...(handleForKey ? { handle: handleForKey } : {}), - }; - registerIMessageApprovalReactionTargetForOutboundMessage({ - accountId: account.accountId, - conversation, - messageId: approvalBindingMessageId, - text: message, - }); - } - } + } + if (message && approvalBindingMessageId) { + const handleForKey = + target.kind === "handle" ? normalizeIMessageHandle(target.to) : undefined; + const conversation: IMessageApprovalConversationKey = { + ...(target.kind === "chat_guid" ? { chatGuid: target.chatGuid } : {}), + ...(target.kind === "chat_identifier" ? { chatIdentifier: target.chatIdentifier } : {}), + ...(target.kind === "chat_id" ? { chatId: target.chatId } : {}), + ...(handleForKey ? { handle: handleForKey } : {}), + }; + registerIMessageApprovalReactionTargetForOutboundMessage({ + accountId: account.accountId, + conversation, + messageId: approvalBindingMessageId, + text: message, + }); } return { messageId, diff --git a/extensions/imessage/src/test-plugin.test.ts b/extensions/imessage/src/test-plugin.test.ts index 17064894477..09d7b425628 100644 --- a/extensions/imessage/src/test-plugin.test.ts +++ b/extensions/imessage/src/test-plugin.test.ts @@ -3,6 +3,7 @@ import { verifyChannelMessageAdapterCapabilityProofs, verifyDurableFinalCapabilityProofs, } from "openclaw/plugin-sdk/channel-outbound"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { listImportedBundledPluginFacadeIds, resetFacadeRuntimeStateForTest, @@ -110,6 +111,48 @@ describe("createIMessageTestPlugin", () => { }); }); + it("preserves the local approval prompt suppressor through attached-result composition", () => { + const suppressor = imessagePlugin.outbound?.shouldSuppressLocalPayloadPrompt; + if (!suppressor) { + throw new Error("iMessage outbound approval suppressor unavailable"); + } + + expect( + suppressor({ + cfg: { + channels: { + imessage: { + enabled: true, + allowFrom: ["+15551230000"], + }, + }, + approvals: { + exec: { + enabled: true, + }, + }, + } as OpenClawConfig, + accountId: "default", + payload: { + text: "Approval required.", + channelData: { + execApproval: { + approvalId: "exec-1", + approvalSlug: "exec-1", + approvalKind: "exec", + sessionKey: "agent:main:imessage:+15551230000", + }, + }, + }, + hint: { + kind: "approval-pending", + approvalKind: "exec", + nativeRouteActive: true, + }, + }), + ).toBe(true); + }); + it("backs declared durable final capabilities with delivery proofs", async () => { const outbound = requireOutbound(); const sendText = requireOutboundSendText(outbound);