mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 10:22:52 +00:00
233 lines
7.7 KiB
TypeScript
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)}`);
|
|
},
|
|
},
|
|
});
|