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);