Skip to content

Commit 5c7980f

Browse files
authored
feat(imessage): support thumb approval reactions (#85952)
* feat(imessage): support thumb approval reactions Mirrors #85477 (WhatsApp) for the iMessage channel. iMessage can now deliver exec/plugin approval prompts via the existing imsg/BlueBubbles transport and resolve approvals from 👍 (allow-once) / 👎 (deny) tapbacks. Allow-always remains on the manual /approve <id> allow-always fallback. What changed: - New approval surfaces under extensions/imessage/src/: approval-auth.ts, approval-resolver.ts, approval-reactions.ts, approval-handler.runtime.ts, approval-native.ts (+ tests for each). - channel.ts wires base.approvalCapability to the new iMessage capability. - send.ts appends the 👍/👎 hint to outbound /approve prompts and registers the reaction binding (keyed by accountId + chat_guid/chat_identifier/ chat_id/handle + messageId) after a successful send. - monitor/monitor-provider.ts resolves approval reactions ahead of the normal inbound decision pipeline so resolution bypasses reactionNotifications gating and runs its own actor authorization. - runtime.ts now exports getIMessageRuntime / getOptionalIMessageRuntime so approval-reactions can open a persistent keyed store for binding state across gateway restarts. What did NOT change: - Core approval surfaces in src/gateway/server-methods/* and src/infra/* remain channel-agnostic; the channels.imessage.allowFrom field already exists and is reused as the approver list for reactions. - Other channels and the manual /approve sender-authorized path are untouched. * fix(imessage): address codex review findings on thumb approvals Addresses 15 findings from the multi-angle codex review: Critical (correctness / blocking): - Register CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY in the iMessage monitor so the gateway can actually deliver native approval prompts via approval-handler.runtime.ts (it was dead code without the context lease). - DM tapback approvals never resolved because send keyed by handle while inbound preferred chat_guid. Register and look up under EVERY available conversation key (chat_guid / chat_identifier / chat_id / handle); inbound probes them all and accepts the first hit. - Reaction binding now requires the bridge's GUID string (rejecting numeric ROWIDs) so the binding key matches inbound reacted_to_guid. - Outbound regex now requires both a canonical `ID: <approvalId>` header AND a matching `/approve <id> <decision>` line, so non-approval messages that legitimately mention /approve syntax no longer get a phantom reaction binding (and can no longer resolve a colliding live approval). - Drop is_from_me reaction events so cross-device echoes of the operator's own tap cannot self-approve when their handle is in allowFrom. High (operability / cleanup): - Non-ApprovalNotFound errors now log at warn via the runtime child logger (no longer hidden behind OPENCLAW_LOG_LEVEL=debug). - In-memory binding is cleared on successful resolve so a toggle 👍→👎 (or chat.db replay) does not refire and emit a misleading 'expired approval' log line. Removed tapbacks are also owned by the shortcut and not surfaced as noisy reaction system events. - Move resolveIMessageReactionContext (and its helpers) to a slim monitor/reaction-context.ts so approval-reactions.ts no longer transitively pulls monitor/inbound-processing.ts (14+ heavy runtime modules) into the hot channel.ts entrypoint per extensions/CLAUDE.md. Medium (consistency / future-proofing): - Native runtime exec pending payload now passes agentId, ask, and sessionKey through buildExecApprovalPendingReplyPayload so the two delivery routes produce identical operator-visible prompts. - Both delivery paths now use addIMessageApprovalReactionHintToText (single insertion point after ID:) so the hint cannot be double-emitted by the native runtime path bypassing the idempotency guard. - Extract replaceApprovalIdPlaceholder into a shared approval-text.ts that escapes `$` in the replacement string so an approvalId containing `$&`/`$1`-`$9`/`$$` cannot interpolate into the outbound text. - In-memory Map now stores TTL alongside each entry and prunes expired bindings on each register so the gateway no longer accumulates an unbounded reaction-target Map. - bindPending refuses to bind when accountId is missing or the approval is already expired, with explicit error logs instead of silent no-ops. - Reject chat_id=0 as a synthetic key value (chat.db ROWIDs start at 1). - Drop dead getIMessageRuntime export — only the optional accessor is used. Documentation: - docs/channels/imessage.md gains an 'Approval reactions (👍 / 👎)' accordion documenting the reaction emoji map, allowFrom approver requirement, the /approve <id> allow-always manual fallback, and the deliberate change to /approve command authorization for users with non-empty allowFrom. - CHANGELOG.md entry added under 2026.5.24. Tests: 411 iMessage tests pass (was 406). Added explicit coverage for the DM key-mismatch fix, the regex-tightening fix, the is_from_me guard, the clear-on-success behavior, and the approval-id `$` escape. * test(imessage): match WhatsApp approval-native test coverage Backfills the nine cases from extensions/whatsapp/src/approval-native.test.ts that weren't mirrored in iMessage: - target-mode exec + plugin prompt rendering with the canonical hint - target-mode availability when no iMessage target matches - agentFilter / sessionFilter applied to native handling - account-scoped target enabled/disabled per account - shouldSuppressForwardingFallback session-origin exact-match cases - shouldSuppressForwardingFallback off when native cannot bind (locks down the targets-only forwarding path the Lobster live deploy exercised) - both-mode explicit + unscoped target suppression - group-origin tapback approvals require explicit approvers Tests: extensions/imessage/src/approval-native.test.ts 21 passed (was 11). Total iMessage approval-specific cases now 49 (was 40). * fix(imessage): preserve service-prefixed direct handles as approvers ClawSweeper P1 review finding on #85952. normalizeIMessageApproverId was calling looksLikeIMessageExplicitTargetId() to reject conversation-target prefixes, but that helper also matches the imessage:/sms:/auto: service prefixes — which are valid direct-handle forms. Any allowFrom entry like 'imessage:+15551230000' dropped to undefined, leaving approvers empty, which: - silently denied reaction resolution ('reactions require explicit approvers'), and - let text /approve fall back to implicit same-chat authorization. Fix: normalize first via normalizeIMessageHandle (strips the service prefix), then reject only chat_id:/chat_guid:/chat_identifier: conversation-target shapes that remain after normalization. Tests: - approval-auth.test.ts: assert the resolved approver list contains the normalized handle, plus the corollary that a non-matching sender is explicitly rejected (no longer masked by the implicit-same-chat fallback). Add a separate case covering chat_id/chat_guid/ chat_identifier rejection (with and without a service prefix). - approval-reactions.test.ts: reaction resolution end-to-end with a service-prefixed allowFrom entry — proves resolveIMessageApproval is called rather than silently denied. Focused suite: 48 passed (was 47). * test(imessage): satisfy strict buildPendingPayload signature in render tests CI check:test-types caught that the render.exec/render.plugin buildPendingPayload calls were passing accountId (not in the type signature). The signature is { cfg, request, target, nowMs }. Replace accountId with target on the four render-test sites so the strict test-types pass matches the SDK contract: - it('renders thumbs-only reaction hints in exec approval prompts') - it('renders thumbs-only reaction hints in plugin approval prompts ...') - it('renders target-mode exec prompts with concrete thumbs-only ...') - it('renders target-mode plugin prompts with concrete thumbs-only ...') Verified locally with pnpm check:test-types (tsgo:core:test + tsgo:extensions:test). 49 approval-specific tests still pass. * fix(imessage): probe every tapback GUID form for approval lookup ClawSweeper P1 review finding on #85952. readApprovalReactionEvent was only using reaction.targetGuid (the first/normalized form), but resolveIMessageReactionContext produces reaction.targetGuids = [normalized, raw] for both `abc-123` and `p:0/abc-123` forms. If the imsg bridge returned 'p:0/<guid>' from send() and send.ts registered the binding under that prefixed key, the inbound resolver probing only the unprefixed form would miss and the tapback would silently fall through. Fix: - Surface every GUID candidate in IMessageApprovalReactionEvent (messageIdCandidates). - maybeResolveIMessageApprovalReaction now probes each candidate in precedence order; first hit wins. - On success / ApprovalNotFoundError, clear the binding under all candidate keys so toggle/replay does not refire. Tests: extensions/imessage/src/approval-reactions.test.ts gains a 'resolves a reaction when the binding was registered under a p:0/… prefixed GUID and the tapback surfaces both forms' regression case; 22/22 reaction tests pass. Full iMessage suite: 424/424. * fix(imessage): native approval binding requires GUID, not numeric id ClawSweeper third P1 review finding on #85952. approval-handler.runtime.ts deliverPending was using result.messageId as the approval-reaction binding key, but that field can be a numeric ROWID coerced to a string ('12345') when the imsg bridge returns only message_id. Inbound tapbacks carry reacted_to_guid which is always a GUID, so a numeric-id binding can never match. Fix mirrors the send.ts forwarding-path treatment: - IMessageSendResult now exposes a separate guid?: string field, populated from the same resolveOutboundMessageGuid helper send.ts already uses for the forwarding-path binding. The generic messageId field is unchanged so reply-cache, echo-cache, and receipt-building paths still see the broadest id form. - deliverPending now binds against result.guid; when it's undefined (numeric ROWID or 'ok'/'unknown' placeholders), the function returns null instead of binding against an id the inbound tapback can't possibly match. Tests: approval-handler.runtime.test.ts gets a deliverPending GUID-only binding describe block with three regression cases (numeric ROWID refused, GUID accepted, ok/unknown placeholders refused). vi.mock isolates sendMessageIMessage so the cases run synchronously without spawning imsg. 11 tests pass across handler.runtime + send specs. --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
1 parent ad71a99 commit 5c7980f

20 files changed

Lines changed: 3628 additions & 112 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
3838

3939
### Changes
4040

41+
- iMessage: support thumb-approval reactions — `👍` (Like tapback) resolves an approval as `allow-once` and `👎` resolves as `deny`, with the explicit-approver allowlist read from `channels.imessage.allowFrom`; `allow-always` stays on the manual `/approve <id> allow-always` text fallback. Mirrors the WhatsApp behavior from #85477.
4142
- Gateway/perf: reuse process-stable channel catalog reads, avoid repeated bundled-channel boundary checks, and rotate gateway watch CPU profiles so benchmark runs do not accumulate unbounded artifacts.
4243
- Gateway/perf: cache stable install-record, channel-catalog, bundled-channel, and Telegram session-store metadata during process-local hot paths to reduce repeated JSON and manifest reads.
4344
- Gateway/perf: reuse immutable plugin metadata snapshots across startup, config, model, channel, setup, and secret metadata readers so hot paths avoid repeated plugin file stats and manifest registry reloads.

docs/channels/imessage.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,24 @@ When `imsg launch` is running and `openclaw channels status --probe` reports `pr
564564
Per-account overrides use `channels.imessage.accounts.<id>.reactionNotifications`.
565565

566566
</Accordion>
567+
568+
<Accordion title="Approval reactions (👍 / 👎)">
569+
When `approvals.exec.enabled` or `approvals.plugin.enabled` is true and the request routes to iMessage, the gateway delivers an approval prompt natively and accepts a tapback to resolve it:
570+
571+
- `👍` (Like tapback) → `allow-once`
572+
- `👎` (Dislike tapback) → `deny`
573+
- `allow-always` remains a manual fallback: send `/approve <id> allow-always` as a regular reply.
574+
575+
Reaction handling requires the reacting user's handle to be an explicit approver. The approver list is read from `channels.imessage.allowFrom` (or `channels.imessage.accounts.<id>.allowFrom`); add the user's phone number in E.164 form or their Apple ID email. The wildcard entry `"*"` is honored but allows any sender to approve. The reaction shortcut intentionally bypasses `reactionNotifications`, `dmPolicy`, and `groupAllowFrom` because the explicit-approver allowlist is the only gate that matters for approval resolution.
576+
577+
**Behavior change with this release:** When `channels.imessage.allowFrom` is non-empty, the `/approve <id> <decision>` text command is now authorized against that approver list (not the broader DM allowlist). Senders permitted on the DM allowlist but not in `allowFrom` will receive an explicit denial. Add every operator who should be able to approve via `/approve` (and via reactions) to `allowFrom` to preserve the previous behavior. When `allowFrom` is empty the legacy "same-chat fallback" stays in effect and `/approve` continues to authorize anyone the DM allowlist permits.
578+
579+
Operator notes:
580+
- The reaction binding is stored both in memory (with TTL matched to the approval expiry) and in the gateway's persistent keyed store, so a tapback that lands shortly after a gateway restart still resolves the approval.
581+
- Cross-device `is_from_me=true` tapbacks (the operator's own reaction on a paired Apple device) are intentionally ignored so the bot cannot self-approve.
582+
- Legacy text-style tapbacks (`Liked "…"` plain text from very old Apple clients) cannot resolve approvals because they carry no message GUID; reaction resolution requires the structured tapback metadata that current macOS / iOS clients emit.
583+
584+
</Accordion>
567585
</AccordionGroup>
568586

569587
## Config writes
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getIMessageApprovalApprovers, imessageApprovalAuth } from "./approval-auth.js";
3+
4+
describe("imessageApprovalAuth", () => {
5+
it("authorizes individual handles and ignores group/chat target entries", () => {
6+
expect(
7+
imessageApprovalAuth.authorizeActorAction({
8+
cfg: { channels: { imessage: { allowFrom: ["+1 (555) 123-0000"] } } },
9+
senderId: "+15551230000",
10+
action: "approve",
11+
approvalKind: "exec",
12+
}),
13+
).toEqual({ authorized: true });
14+
15+
expect(
16+
getIMessageApprovalApprovers({
17+
cfg: {
18+
channels: {
19+
imessage: {
20+
allowFrom: ["chat_guid:iMessage;+;chat123", "chat_id:42"],
21+
},
22+
},
23+
},
24+
}),
25+
).toEqual([]);
26+
27+
expect(
28+
imessageApprovalAuth.authorizeActorAction({
29+
cfg: { channels: { imessage: { allowFrom: ["+15551230000"] } } },
30+
senderId: "+15551239999",
31+
action: "approve",
32+
approvalKind: "exec",
33+
}),
34+
).toEqual({
35+
authorized: false,
36+
reason: "❌ You are not authorized to approve exec requests on iMessage.",
37+
});
38+
});
39+
40+
it("authorizes lowercase-normalized email senders against canonical allowFrom", () => {
41+
expect(
42+
imessageApprovalAuth.authorizeActorAction({
43+
cfg: { channels: { imessage: { allowFrom: ["Owner@Example.com"] } } },
44+
senderId: "owner@example.com",
45+
action: "approve",
46+
approvalKind: "plugin",
47+
}),
48+
).toEqual({ authorized: true });
49+
});
50+
51+
it("falls back to implicit same-chat authorization when no allowFrom is configured", () => {
52+
expect(
53+
getIMessageApprovalApprovers({
54+
cfg: { channels: { imessage: { allowFrom: [] } } },
55+
}),
56+
).toEqual([]);
57+
58+
expect(
59+
imessageApprovalAuth.authorizeActorAction({
60+
cfg: { channels: { imessage: { allowFrom: [] } } },
61+
senderId: "+15551230000",
62+
action: "approve",
63+
approvalKind: "exec",
64+
}),
65+
).toEqual({ authorized: true });
66+
});
67+
68+
it("supports explicit wildcard approval approvers", () => {
69+
expect(
70+
imessageApprovalAuth.authorizeActorAction({
71+
cfg: { channels: { imessage: { allowFrom: ["*"] } } },
72+
senderId: "+15551230000",
73+
action: "approve",
74+
approvalKind: "plugin",
75+
}),
76+
).toEqual({ authorized: true });
77+
});
78+
79+
it("strips imessage:/sms:/auto: service prefixes when normalizing approver entries", () => {
80+
// The resolved approver list itself must contain the bare normalized
81+
// handle — a previous bug rejected service-prefixed entries entirely,
82+
// which silently fell back to the empty-approvers implicit-same-chat
83+
// authorization and masked the regression. Assert the explicit list here
84+
// so reaction resolution (which requires a non-empty approver list) works
85+
// for service-prefixed allowFrom values too.
86+
expect(
87+
getIMessageApprovalApprovers({
88+
cfg: { channels: { imessage: { allowFrom: ["imessage:+15551230000"] } } },
89+
}),
90+
).toEqual(["+15551230000"]);
91+
expect(
92+
getIMessageApprovalApprovers({
93+
cfg: { channels: { imessage: { allowFrom: ["sms:+15551230001"] } } },
94+
}),
95+
).toEqual(["+15551230001"]);
96+
expect(
97+
getIMessageApprovalApprovers({
98+
cfg: { channels: { imessage: { allowFrom: ["auto:Owner@Example.com"] } } },
99+
}),
100+
).toEqual(["owner@example.com"]);
101+
102+
// A sender that matches the normalized handle is explicitly authorized
103+
// (not via the implicit same-chat fallback).
104+
expect(
105+
imessageApprovalAuth.authorizeActorAction({
106+
cfg: { channels: { imessage: { allowFrom: ["imessage:+15551230000"] } } },
107+
senderId: "+15551230000",
108+
action: "approve",
109+
approvalKind: "exec",
110+
}),
111+
).toEqual({ authorized: true });
112+
113+
// And a NON-matching sender is rejected — proving the entry was added
114+
// to the approver list rather than collapsing to empty.
115+
expect(
116+
imessageApprovalAuth.authorizeActorAction({
117+
cfg: { channels: { imessage: { allowFrom: ["imessage:+15551230000"] } } },
118+
senderId: "+15559999999",
119+
action: "approve",
120+
approvalKind: "exec",
121+
}),
122+
).toEqual({
123+
authorized: false,
124+
reason: "❌ You are not authorized to approve exec requests on iMessage.",
125+
});
126+
});
127+
128+
it("rejects chat_id / chat_guid / chat_identifier as approver entries even with service prefixes", () => {
129+
expect(
130+
getIMessageApprovalApprovers({
131+
cfg: {
132+
channels: {
133+
imessage: {
134+
allowFrom: [
135+
"chat_id:42",
136+
"chat_guid:iMessage;+;chat42",
137+
"chat_identifier:chat42@example.com",
138+
"imessage:chat_id:43",
139+
],
140+
},
141+
},
142+
},
143+
}),
144+
).toEqual([]);
145+
});
146+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
createResolvedApproverActionAuthAdapter,
3+
resolveApprovalApprovers,
4+
} from "openclaw/plugin-sdk/approval-auth-runtime";
5+
import { resolveIMessageAccount } from "./accounts.js";
6+
import { normalizeIMessageHandle } from "./targets.js";
7+
8+
type ApprovalKind = "exec" | "plugin";
9+
10+
export function normalizeIMessageApproverId(value: string | number): string | undefined {
11+
const raw = String(value).trim();
12+
if (!raw) {
13+
return undefined;
14+
}
15+
// Normalize first so service-prefixed direct handles (`imessage:+...`,
16+
// `sms:+...`, `auto:+...`) are stripped to their bare identifier before we
17+
// decide whether to reject the entry. After normalization only the
18+
// conversation-target prefixes (chat_id / chat_guid / chat_identifier) remain
19+
// as illegal approver shapes — service-prefixed direct handles are valid
20+
// approver values that map to a specific phone/email.
21+
const normalized = normalizeIMessageHandle(raw);
22+
if (
23+
!normalized ||
24+
normalized.startsWith("chat_id:") ||
25+
normalized.startsWith("chat_guid:") ||
26+
normalized.startsWith("chat_identifier:")
27+
) {
28+
return undefined;
29+
}
30+
return normalized;
31+
}
32+
33+
function normalizeIMessageApproverEntry(value: string | number): string | undefined {
34+
return String(value).trim() === "*" ? "*" : normalizeIMessageApproverId(value);
35+
}
36+
37+
export function getIMessageApprovalApprovers(params: {
38+
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
39+
accountId?: string | null;
40+
}): string[] {
41+
const account = resolveIMessageAccount({ cfg: params.cfg, accountId: params.accountId });
42+
return resolveApprovalApprovers({
43+
allowFrom: account.config.allowFrom,
44+
normalizeApprover: normalizeIMessageApproverEntry,
45+
});
46+
}
47+
48+
const imessageResolvedApproverAuth = createResolvedApproverActionAuthAdapter({
49+
channelLabel: "iMessage",
50+
resolveApprovers: ({ cfg, accountId }) => getIMessageApprovalApprovers({ cfg, accountId }),
51+
normalizeSenderId: (value) => normalizeIMessageApproverId(value),
52+
});
53+
54+
export const imessageApprovalAuth = {
55+
authorizeActorAction({
56+
cfg,
57+
accountId,
58+
senderId,
59+
approvalKind,
60+
}: {
61+
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
62+
accountId?: string | null;
63+
senderId?: string | null;
64+
action: "approve";
65+
approvalKind: ApprovalKind;
66+
}) {
67+
if (getIMessageApprovalApprovers({ cfg, accountId }).includes("*")) {
68+
return { authorized: true } as const;
69+
}
70+
return imessageResolvedApproverAuth.authorizeActorAction({
71+
cfg,
72+
accountId,
73+
senderId,
74+
action: "approve",
75+
approvalKind,
76+
});
77+
},
78+
};

0 commit comments

Comments
 (0)