Files
openclaw/extensions/imessage/src/approval-handler.runtime.ts
2026-05-31 07:17:57 +01:00

233 lines
7.7 KiB
TypeScript

import {
buildChannelApprovalExpiredText,
buildChannelApprovalResolvedText,
createChannelApprovalNativeRuntimeAdapter,
type PendingApprovalView,
resolvePreparedApprovalAccountId,
} from "openclaw/plugin-sdk/approval-handler-runtime";
import { buildChannelApprovalNativeTargetKey } from "openclaw/plugin-sdk/approval-native-runtime";
import { buildApprovalReactionPendingContent } from "openclaw/plugin-sdk/approval-reaction-runtime";
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
import type {
ExecApprovalRequest,
PluginApprovalRequest,
} from "openclaw/plugin-sdk/approval-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import {
registerIMessageApprovalReactionTarget,
unregisterIMessageApprovalReactionTarget,
type IMessageApprovalConversationKey,
} from "./approval-reactions.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 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 pendingContent = buildApprovalReactionPendingContent({
request: params.request,
view: params.view as never,
nowMs: params.nowMs,
});
return {
text: pendingContent.reactionPayload.text ?? "",
allowedDecisions: pendingContent.reactionPayload.allowedDecisions,
};
}
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;
}
}
function shouldThreadApprovalUpdate(to: string): boolean {
try {
const parsed = parseIMessageTarget(to);
if (parsed.kind === "handle" && parsed.service === "sms") {
return false;
}
} catch {
return true;
}
return true;
}
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: buildChannelApprovalResolvedText({ request, resolved, view }) },
}),
buildExpiredResult: ({ request, view }) => ({
kind: "update",
payload: { text: buildChannelApprovalExpiredText({ request, view }) },
}),
},
transport: {
prepareTarget: ({ plannedTarget, accountId }) => {
const to = normalizeIMessageMessagingTarget(plannedTarget.target.to);
if (!to) {
return null;
}
const prepared: PreparedIMessageApprovalTarget = {
to,
accountId: resolvePreparedApprovalAccountId({
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 } : {}),
...(shouldThreadApprovalUpdate(entry.to) ? { 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)}`);
},
},
});