mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-29 23:28:45 +00:00
* feat(imessage): support thumb approval reactions Mirrors openclaw#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>
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
import {
|
|
createChannelApprovalNativeRuntimeAdapter,
|
|
type ExpiredApprovalView,
|
|
type PendingApprovalView,
|
|
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 {
|
|
buildApprovalResolvedReplyPayload,
|
|
buildPluginApprovalExpiredMessage,
|
|
buildPluginApprovalPendingReplyPayload,
|
|
buildPluginApprovalResolvedMessage,
|
|
type ExecApprovalRequest,
|
|
type ExecApprovalResolved,
|
|
type PluginApprovalRequest,
|
|
type PluginApprovalResolved,
|
|
} from "openclaw/plugin-sdk/approval-runtime";
|
|
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";
|
|
|
|
const log = createSubsystemLogger("imessage/approvals");
|
|
|
|
type ApprovalRequest = ExecApprovalRequest | PluginApprovalRequest;
|
|
type ApprovalResolved = ExecApprovalResolved | PluginApprovalResolved;
|
|
type IMessagePendingDelivery = {
|
|
text: string;
|
|
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
|
};
|
|
type PreparedIMessageApprovalTarget = {
|
|
to: string;
|
|
accountId?: string;
|
|
};
|
|
type PendingIMessageApprovalEntry = {
|
|
accountId?: string;
|
|
to: string;
|
|
conversation: IMessageApprovalConversationKey;
|
|
messageId: string;
|
|
};
|
|
type IMessageFinalPayload = {
|
|
text: string;
|
|
};
|
|
|
|
function buildPendingPayload(params: {
|
|
request: ApprovalRequest;
|
|
approvalKind: "exec" | "plugin";
|
|
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);
|
|
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,
|
|
};
|
|
}
|
|
|
|
function buildResolvedText(params: {
|
|
request: ApprovalRequest;
|
|
resolved: ApprovalResolved;
|
|
view: ResolvedApprovalView;
|
|
}): string {
|
|
if (params.view.approvalKind === "plugin") {
|
|
return buildPluginApprovalResolvedMessage(params.resolved as PluginApprovalResolved);
|
|
}
|
|
const resolvedByText = params.resolved.resolvedBy
|
|
? ` Resolved by ${params.resolved.resolvedBy}.`
|
|
: "";
|
|
const payload = buildApprovalResolvedReplyPayload({
|
|
approvalId: params.request.id,
|
|
approvalSlug: params.request.id.slice(0, 8),
|
|
text: `✅ Exec approval ${params.resolved.decision}.${resolvedByText} ID: ${params.request.id}`,
|
|
});
|
|
return payload.text ?? "";
|
|
}
|
|
|
|
function buildExpiredText(params: { request: ApprovalRequest; view: ExpiredApprovalView }): string {
|
|
if (params.view.approvalKind === "plugin") {
|
|
return buildPluginApprovalExpiredMessage(params.request as PluginApprovalRequest);
|
|
}
|
|
return `⏱️ Exec approval expired. ID: ${params.request.id}`;
|
|
}
|
|
|
|
function resolvePreparedAccountId(params: {
|
|
plannedAccountId?: string | null;
|
|
contextAccountId?: string | null;
|
|
}): string | undefined {
|
|
return (
|
|
normalizeOptionalString(params.plannedAccountId) ??
|
|
normalizeOptionalString(params.contextAccountId)
|
|
);
|
|
}
|
|
|
|
function buildConversationKeyForTarget(to: string): IMessageApprovalConversationKey | null {
|
|
try {
|
|
const parsed = parseIMessageTarget(to);
|
|
if (parsed.kind === "chat_id") {
|
|
return { chatId: parsed.chatId };
|
|
}
|
|
if (parsed.kind === "chat_guid") {
|
|
return { chatGuid: parsed.chatGuid };
|
|
}
|
|
if (parsed.kind === "chat_identifier") {
|
|
return { chatIdentifier: parsed.chatIdentifier };
|
|
}
|
|
const handle = normalizeIMessageHandle(parsed.to);
|
|
return handle ? { handle } : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export const imessageApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
|
|
IMessagePendingDelivery,
|
|
PreparedIMessageApprovalTarget,
|
|
PendingIMessageApprovalEntry,
|
|
true,
|
|
IMessageFinalPayload
|
|
>({
|
|
eventKinds: ["exec", "plugin"],
|
|
availability: {
|
|
isConfigured: ({ context }) => Boolean(context),
|
|
shouldHandle: ({ context }) => Boolean(context),
|
|
},
|
|
presentation: {
|
|
buildPendingPayload: ({ request, approvalKind, nowMs, view }) =>
|
|
buildPendingPayload({ request, approvalKind, nowMs, view }),
|
|
buildResolvedResult: ({ request, resolved, view }) => ({
|
|
kind: "update",
|
|
payload: { text: buildResolvedText({ request, resolved, view }) },
|
|
}),
|
|
buildExpiredResult: ({ request, view }) => ({
|
|
kind: "update",
|
|
payload: { text: buildExpiredText({ request, view }) },
|
|
}),
|
|
},
|
|
transport: {
|
|
prepareTarget: ({ plannedTarget, accountId }) => {
|
|
const to = normalizeIMessageMessagingTarget(plannedTarget.target.to);
|
|
if (!to) {
|
|
return null;
|
|
}
|
|
const prepared: PreparedIMessageApprovalTarget = {
|
|
to,
|
|
accountId: resolvePreparedAccountId({
|
|
plannedAccountId: (plannedTarget.target as { accountId?: string | null }).accountId,
|
|
contextAccountId: accountId,
|
|
}),
|
|
};
|
|
return {
|
|
dedupeKey: `${prepared.accountId ?? ""}:${buildChannelApprovalNativeTargetKey({
|
|
to: prepared.to,
|
|
})}`,
|
|
target: prepared,
|
|
};
|
|
},
|
|
deliverPending: async ({ cfg, preparedTarget, pendingPayload }) => {
|
|
const result = await sendMessageIMessage(preparedTarget.to, pendingPayload.text, {
|
|
config: cfg,
|
|
...(preparedTarget.accountId ? { accountId: preparedTarget.accountId } : {}),
|
|
});
|
|
// Approval reaction bindings must use the GUID-only id (matches the
|
|
// inbound tapback's `reacted_to_guid`). When the bridge only returned a
|
|
// numeric ROWID / `ok` / `unknown`, `result.guid` is undefined — refuse
|
|
// to bind so the reaction shortcut won't silently miss a real tap.
|
|
const guid = result.guid;
|
|
if (!guid) {
|
|
return null;
|
|
}
|
|
const conversation = buildConversationKeyForTarget(preparedTarget.to);
|
|
if (!conversation) {
|
|
return null;
|
|
}
|
|
return {
|
|
...(preparedTarget.accountId ? { accountId: preparedTarget.accountId } : {}),
|
|
to: preparedTarget.to,
|
|
conversation,
|
|
messageId: guid,
|
|
};
|
|
},
|
|
updateEntry: async ({ cfg, entry, payload }) => {
|
|
await sendMessageIMessage(entry.to, payload.text, {
|
|
config: cfg,
|
|
...(entry.accountId ? { accountId: entry.accountId } : {}),
|
|
replyToId: entry.messageId,
|
|
});
|
|
},
|
|
},
|
|
interactions: {
|
|
bindPending: ({ entry, request, view, pendingPayload }) => {
|
|
const accountId = entry.accountId?.trim();
|
|
if (!accountId) {
|
|
// An empty accountId would silently fail buildReactionTargetKey and
|
|
// leave the prompt with no way to be resolved via reaction. Surface
|
|
// this loudly instead of returning null with no signal.
|
|
log.error(
|
|
`imessage approvals: refusing to bind reaction target for ${request.id}; missing accountId in prepared entry`,
|
|
);
|
|
return null;
|
|
}
|
|
// If the approval is already past expiry by the time we bind (clock skew
|
|
// or delayed delivery), don't pretend to honor a 1ms TTL — refuse the
|
|
// binding so callers see an honest "no binding" and the prompt remains
|
|
// resolvable only via the /approve text fallback.
|
|
const ttlMs = view.expiresAtMs - Date.now();
|
|
if (ttlMs <= 0) {
|
|
log.error(
|
|
`imessage approvals: refusing to bind reaction target for ${request.id}; approval already expired at bind time`,
|
|
);
|
|
return null;
|
|
}
|
|
return registerIMessageApprovalReactionTarget({
|
|
accountId,
|
|
conversation: entry.conversation,
|
|
messageId: entry.messageId,
|
|
approvalId: request.id,
|
|
allowedDecisions: pendingPayload.allowedDecisions,
|
|
ttlMs,
|
|
})
|
|
? true
|
|
: null;
|
|
},
|
|
unbindPending: ({ entry }) => {
|
|
const accountId = entry.accountId?.trim();
|
|
if (!accountId) {
|
|
return;
|
|
}
|
|
unregisterIMessageApprovalReactionTarget({
|
|
accountId,
|
|
conversation: entry.conversation,
|
|
messageId: entry.messageId,
|
|
});
|
|
},
|
|
cancelDelivered: ({ entry }) => {
|
|
const accountId = entry.accountId?.trim();
|
|
if (!accountId) {
|
|
return;
|
|
}
|
|
unregisterIMessageApprovalReactionTarget({
|
|
accountId,
|
|
conversation: entry.conversation,
|
|
messageId: entry.messageId,
|
|
});
|
|
},
|
|
},
|
|
observe: {
|
|
onDeliveryError: ({ error, request }) => {
|
|
log.error(`imessage approvals: failed to send request ${request.id}: ${String(error)}`);
|
|
},
|
|
},
|
|
});
|