mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 10:01:37 -06:00
refactor(channels): carry typed approval-prompt bindings through signal/imessage send seams (#124742)
The Signal and iMessage channel plugins inferred approval prompts by regex-sniffing rendered message text (header matching like 'Exec approval required', /approve command parsing), violating the transport-only channel doctrine: approval actions must stay typed until channel encoding. The typed envelope already existed (channelData.execApproval with approvalId/approvalKind/allowedDecisions) and every payload-level delivery path consumed it; the regex paths were redundant re-derivation at the raw send seam plus restart recovery for in-memory iMessage poll state. - Signal: delete send-level prompt sniffing entirely; all approval sends already flow through typed structured-payload or native-handler paths. - iMessage: sendMessageIMessage takes a typed approvalPrompt binding (id/kind/decisions) from the native approval handler instead of an approvalKind flag plus text re-parsing. - iMessage poller: persist pending poll targets in the plugin keyed store so restart recovery no longer regex-scans chat history; typed recent-chat discovery for handle-only DM targets stays. Split poll-target ownership into approval-reaction-poll-targets.ts (max-lines). - Plugin SDK: remove extractApprovalReactionPromptBinding — beta-only surface, never in a stable release, so no deprecation window applies; AGENTS.md now records that rule. Accepted tradeoff: approval prompts delivered by a pre-upgrade process are not rediscovered from chat text after restart (<=24h transient state; persisted reaction bindings and event-driven tapbacks still work).
This commit is contained in:
committed by
GitHub
parent
ed1799315e
commit
a0f4d5462a
@@ -105,7 +105,7 @@ Skills own workflows; root owns hard policy and routing. Product direction and m
|
||||
- Nested CLI options: when a parent option semantically applies to a leaf subcommand, declare it on both the parent and every applicable leaf so positional parsing accepts the option before or after the subcommand. Resolve the leaf value only when its source is non-default, then inherit from ancestors with `inheritOptionFromParent`. Do not expose inherited options on leaves where the semantics differ. Add real-parser coverage that enumerates every applicable leaf.
|
||||
- New binary fallible-operation results use `Result` from `@openclaw/normalization-core/result`; domain-rich outcomes keep named discriminated unions.
|
||||
- Tests may use observed examples, but prod literals need a short contract reason.
|
||||
- Compatibility is opt-in. "Shipped" means reachable from a release Git tag; main/GitHub/PR/unreleased code is not shipped.
|
||||
- Compatibility is opt-in. "Shipped" means reachable from a stable release Git tag; betas, nightlies, main/GitHub/PR/unreleased code are not shipped. Plugin SDK surface in beta-only tags carries no compat obligation — remove, don't deprecate.
|
||||
- Refactor default: one canonical path — delete the old one. Keep old behavior only when the user explicitly asks or for an explicit public API/config/plugin SDK/data contract, tagged upgrade path, security/migration boundary, dependency contract, or observed prod state; cite it.
|
||||
- Reuse canonical coercion guards (`@openclaw/normalization-core/record-coerce`; plugins: `openclaw/plugin-sdk/string-coerce-runtime`) — no local `isRecord` copies. CI guard `pnpm check:coercion-helpers` owns the carve-outs; intentionally different semantics or a file that cannot use workspace resolution gets a reasoned carve-out entry there.
|
||||
- Core runtime consumes only current canonical shapes/config/data. Legacy or retired shapes normalize only in doctor/migration code before runtime; no runtime shims, aliases, or fallback readers.
|
||||
|
||||
@@ -505,7 +505,7 @@ describe("imessageApprovalNativeRuntime", () => {
|
||||
pollDeliverArgs.pendingPayload.pollText,
|
||||
expect.objectContaining({ conversationReadOrigin: "direct-operator" }),
|
||||
);
|
||||
expect(sendMock.sendMessageIMessage.mock.calls[0]?.[2]).not.toHaveProperty("approvalKind");
|
||||
expect(sendMock.sendMessageIMessage.mock.calls[0]?.[2]).not.toHaveProperty("approvalPrompt");
|
||||
expect(actionsMock.sendPoll).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatGuid: "iMessage;-;+15551230000",
|
||||
@@ -815,7 +815,11 @@ describe("imessageApprovalNativeRuntime", () => {
|
||||
"+15551230000",
|
||||
pollDeliverArgs.pendingPayload.text,
|
||||
expect.objectContaining({
|
||||
approvalKind: "exec",
|
||||
approvalPrompt: {
|
||||
approvalId: "exec-poll",
|
||||
approvalKind: "exec",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
},
|
||||
replyToId: "prompt-guid",
|
||||
}),
|
||||
);
|
||||
@@ -886,7 +890,11 @@ describe("imessageApprovalNativeRuntime", () => {
|
||||
"+15551230000",
|
||||
pollDeliverArgs.pendingPayload.text,
|
||||
expect.objectContaining({
|
||||
approvalKind: "exec",
|
||||
approvalPrompt: {
|
||||
approvalId: "exec-poll",
|
||||
approvalKind: "exec",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
},
|
||||
replyToId: "prompt-guid",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -68,6 +68,11 @@ type PreparedIMessageApprovalTarget = {
|
||||
to: string;
|
||||
accountId?: string;
|
||||
};
|
||||
type IMessageApprovalPromptBinding = {
|
||||
approvalId: string;
|
||||
approvalKind: "exec" | "plugin";
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
};
|
||||
type PendingIMessageApprovalEntry = {
|
||||
accountId?: string;
|
||||
to: string;
|
||||
@@ -363,12 +368,12 @@ async function recoverIMessageApprovalTextFallback(params: {
|
||||
target: PreparedIMessageApprovalTarget;
|
||||
promptMessageId?: string;
|
||||
fallbackText: string;
|
||||
approvalKind: "exec" | "plugin";
|
||||
approvalPrompt: IMessageApprovalPromptBinding;
|
||||
}): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await sendMessageIMessage(params.target.to, params.fallbackText, {
|
||||
config: params.cfg,
|
||||
approvalKind: params.approvalKind,
|
||||
approvalPrompt: params.approvalPrompt,
|
||||
conversationReadOrigin: "direct-operator",
|
||||
...(params.target.accountId ? { accountId: params.target.accountId } : {}),
|
||||
...(params.promptMessageId ? { replyToId: params.promptMessageId } : {}),
|
||||
@@ -543,9 +548,14 @@ export const imessageApprovalNativeRuntime = createChannelApprovalNativeRuntimeA
|
||||
// fallback visible until the send receipt confirms the actual transport.
|
||||
const reactionFallbackVisible = !expectPoll || targetTransport !== "imessage";
|
||||
const promptText = reactionFallbackVisible ? pendingPayload.text : pendingPayload.pollText;
|
||||
const approvalPrompt: IMessageApprovalPromptBinding = {
|
||||
approvalId: view.approvalId,
|
||||
approvalKind: view.approvalKind,
|
||||
allowedDecisions: pendingPayload.allowedDecisions,
|
||||
};
|
||||
const result = await sendMessageIMessage(preparedTarget.to, promptText, {
|
||||
config: cfg,
|
||||
...(reactionFallbackVisible ? { approvalKind: view.approvalKind } : {}),
|
||||
...(reactionFallbackVisible ? { approvalPrompt } : {}),
|
||||
// Approval delivery is host-originated: the target comes from the
|
||||
// approval's own routing (origin session or a configured approver),
|
||||
// never from model input. Attest that so #99905's conversation-read
|
||||
@@ -587,7 +597,7 @@ export const imessageApprovalNativeRuntime = createChannelApprovalNativeRuntimeA
|
||||
target: preparedTarget,
|
||||
promptMessageId: result.guid,
|
||||
fallbackText: pendingPayload.text,
|
||||
approvalKind: view.approvalKind,
|
||||
approvalPrompt,
|
||||
})
|
||||
: undefined;
|
||||
const entry: PendingIMessageApprovalEntry = {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// Imessage plugin module owns persisted approval reaction poll targets.
|
||||
import { readApprovalReactionDecisionList } from "openclaw/plugin-sdk/approval-reaction-runtime";
|
||||
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
createPluginStateErrorReporter,
|
||||
type PluginStateKeyedStore,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
normalizeConversationKey,
|
||||
normalizeIMessageGuid,
|
||||
type IMessageApprovalConversationKey,
|
||||
} from "./approval-target-keys.js";
|
||||
import { getOptionalIMessageRuntime } from "./runtime.js";
|
||||
|
||||
const PERSISTENT_POLL_TARGET_NAMESPACE = "imessage.approval-reaction-poll-targets";
|
||||
const PERSISTENT_MAX_ENTRIES = 1000;
|
||||
const DEFAULT_REACTION_TARGET_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export type PendingIMessageApprovalReactionPollTarget = {
|
||||
accountId: string;
|
||||
conversation: IMessageApprovalConversationKey;
|
||||
messageId: string;
|
||||
approvalId: string;
|
||||
approvalKind: "exec" | "plugin";
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
const pendingReactionPollTargets = new Map<string, PendingIMessageApprovalReactionPollTarget>();
|
||||
|
||||
function prunePendingReactionPollTargets(nowMs = Date.now()): void {
|
||||
for (const [key, target] of pendingReactionPollTargets.entries()) {
|
||||
if (!isFutureDateTimestampMs(target.expiresAtMs, { nowMs })) {
|
||||
pendingReactionPollTargets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePendingReactionPollExpiry(
|
||||
ttlMs: number | undefined,
|
||||
): { ttlMs: number; expiresAtMs: number } | undefined {
|
||||
const nowMs = asDateTimestampMs(Date.now());
|
||||
if (nowMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const expiresAtMs =
|
||||
resolveExpiresAtMsFromDurationMs(ttlMs ?? DEFAULT_REACTION_TARGET_TTL_MS, { nowMs }) ??
|
||||
resolveExpiresAtMsFromDurationMs(DEFAULT_REACTION_TARGET_TTL_MS, { nowMs });
|
||||
if (expiresAtMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
ttlMs: expiresAtMs - nowMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const reportPersistentApprovalReactionError = createPluginStateErrorReporter(
|
||||
getOptionalIMessageRuntime,
|
||||
"imessage",
|
||||
"approval-reaction-state",
|
||||
"iMessage persistent approval reaction state failed",
|
||||
);
|
||||
|
||||
let pendingReactionPollTargetStore:
|
||||
| PluginStateKeyedStore<PendingIMessageApprovalReactionPollTarget>
|
||||
| undefined;
|
||||
let pendingReactionPollTargetStoreDisabled = false;
|
||||
|
||||
function disablePendingReactionPollTargetStore(error: unknown): void {
|
||||
pendingReactionPollTargetStoreDisabled = true;
|
||||
pendingReactionPollTargetStore = undefined;
|
||||
reportPersistentApprovalReactionError(error);
|
||||
}
|
||||
|
||||
function getPendingReactionPollTargetStore():
|
||||
| PluginStateKeyedStore<PendingIMessageApprovalReactionPollTarget>
|
||||
| undefined {
|
||||
if (pendingReactionPollTargetStoreDisabled) {
|
||||
return undefined;
|
||||
}
|
||||
if (pendingReactionPollTargetStore) {
|
||||
return pendingReactionPollTargetStore;
|
||||
}
|
||||
try {
|
||||
pendingReactionPollTargetStore =
|
||||
getOptionalIMessageRuntime()?.state.openKeyedStore<PendingIMessageApprovalReactionPollTarget>(
|
||||
{
|
||||
namespace: PERSISTENT_POLL_TARGET_NAMESPACE,
|
||||
maxEntries: PERSISTENT_MAX_ENTRIES,
|
||||
defaultTtlMs: DEFAULT_REACTION_TARGET_TTL_MS,
|
||||
},
|
||||
);
|
||||
return pendingReactionPollTargetStore;
|
||||
} catch (error) {
|
||||
disablePendingReactionPollTargetStore(error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedPollTarget(value: unknown): PendingIMessageApprovalReactionPollTarget | null {
|
||||
const target = asOptionalRecord(value);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
const accountId = typeof target.accountId === "string" ? target.accountId.trim() : "";
|
||||
const messageId = typeof target.messageId === "string" ? target.messageId.trim() : "";
|
||||
const approvalId = typeof target.approvalId === "string" ? target.approvalId.trim() : "";
|
||||
const expiresAtMs = asDateTimestampMs(target.expiresAtMs);
|
||||
const allowedDecisions = readApprovalReactionDecisionList(target.allowedDecisions);
|
||||
const rawConversation = asOptionalRecord(target.conversation) ?? {};
|
||||
const conversation: IMessageApprovalConversationKey = {
|
||||
...(typeof rawConversation.chatGuid === "string"
|
||||
? { chatGuid: rawConversation.chatGuid.trim() }
|
||||
: {}),
|
||||
...(typeof rawConversation.chatIdentifier === "string"
|
||||
? { chatIdentifier: rawConversation.chatIdentifier.trim() }
|
||||
: {}),
|
||||
...(typeof rawConversation.chatId === "string" || typeof rawConversation.chatId === "number"
|
||||
? { chatId: rawConversation.chatId }
|
||||
: {}),
|
||||
...(typeof rawConversation.handle === "string"
|
||||
? { handle: rawConversation.handle.trim() }
|
||||
: {}),
|
||||
};
|
||||
if (
|
||||
!accountId ||
|
||||
!messageId ||
|
||||
!approvalId ||
|
||||
expiresAtMs === undefined ||
|
||||
!allowedDecisions ||
|
||||
(target.approvalKind !== "exec" && target.approvalKind !== "plugin") ||
|
||||
!normalizeConversationKey(conversation)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
accountId,
|
||||
conversation,
|
||||
messageId,
|
||||
approvalId,
|
||||
approvalKind: target.approvalKind,
|
||||
allowedDecisions,
|
||||
expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function recordIMessageApprovalReactionPollTarget(params: {
|
||||
keys: readonly string[];
|
||||
accountId: string;
|
||||
conversation: IMessageApprovalConversationKey;
|
||||
messageId: string;
|
||||
approvalId: string;
|
||||
approvalKind: "exec" | "plugin";
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
ttlMs?: number;
|
||||
}): { ttlMs: number; expiresAtMs: number } | null {
|
||||
const expiry = resolvePendingReactionPollExpiry(params.ttlMs);
|
||||
if (!expiry || params.keys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const target: PendingIMessageApprovalReactionPollTarget = {
|
||||
accountId: params.accountId,
|
||||
conversation: params.conversation,
|
||||
messageId: params.messageId,
|
||||
approvalId: params.approvalId,
|
||||
approvalKind: params.approvalKind,
|
||||
allowedDecisions: params.allowedDecisions,
|
||||
expiresAtMs: expiry.expiresAtMs,
|
||||
};
|
||||
const store = getPendingReactionPollTargetStore();
|
||||
for (const key of params.keys) {
|
||||
pendingReactionPollTargets.set(key, target);
|
||||
void store
|
||||
?.register(key, target, { ttlMs: expiry.ttlMs })
|
||||
.catch(disablePendingReactionPollTargetStore);
|
||||
}
|
||||
prunePendingReactionPollTargets();
|
||||
return expiry;
|
||||
}
|
||||
|
||||
export function deleteIMessageApprovalReactionPollTargets(keys: readonly string[]): void {
|
||||
const store = getPendingReactionPollTargetStore();
|
||||
for (const key of keys) {
|
||||
pendingReactionPollTargets.delete(key);
|
||||
void store?.delete(key).catch(disablePendingReactionPollTargetStore);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPendingIMessageApprovalReactionPollTargets(params: {
|
||||
accountId: string;
|
||||
}): Promise<PendingIMessageApprovalReactionPollTarget[]> {
|
||||
const accountId = params.accountId.trim();
|
||||
if (!accountId) {
|
||||
return [];
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const store = getPendingReactionPollTargetStore();
|
||||
if (store) {
|
||||
try {
|
||||
for (const entry of await store.entries()) {
|
||||
const target = readPersistedPollTarget(entry.value);
|
||||
if (!target || !isFutureDateTimestampMs(target.expiresAtMs, { nowMs })) {
|
||||
await store.delete(entry.key);
|
||||
continue;
|
||||
}
|
||||
pendingReactionPollTargets.set(entry.key, target);
|
||||
}
|
||||
} catch (error) {
|
||||
disablePendingReactionPollTargetStore(error);
|
||||
}
|
||||
}
|
||||
prunePendingReactionPollTargets(nowMs);
|
||||
const targetByApprovalAndMessage = new Map<string, PendingIMessageApprovalReactionPollTarget>();
|
||||
for (const target of pendingReactionPollTargets.values()) {
|
||||
if (target.accountId !== accountId) {
|
||||
continue;
|
||||
}
|
||||
const key = `${target.approvalId}:${normalizeIMessageGuid(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()];
|
||||
}
|
||||
|
||||
export function clearIMessageApprovalReactionPollTargetsForTest(): void {
|
||||
pendingReactionPollTargets.clear();
|
||||
pendingReactionPollTargetStore = undefined;
|
||||
pendingReactionPollTargetStoreDisabled = false;
|
||||
}
|
||||
@@ -150,7 +150,9 @@ describe("iMessage approval reaction poller", () => {
|
||||
it("does not scan recent chats during fast polling with no pending targets", async () => {
|
||||
const request = vi.fn();
|
||||
|
||||
await pollPendingIMessageApprovalReactions(buildPollParams(request));
|
||||
await pollPendingIMessageApprovalReactions(
|
||||
buildPollParams(request, { allowRecentChatDiscovery: true }),
|
||||
);
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -166,14 +168,22 @@ describe("iMessage approval reaction poller", () => {
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discovers observed approval prompts on the bounded recent-chat path", async () => {
|
||||
const request = createRpcRequest([buildApprovalMessage()], [42]);
|
||||
it("discovers typed handle-only targets through recent chat history", async () => {
|
||||
registerTarget({
|
||||
conversation: { handle: APPROVER },
|
||||
});
|
||||
const request = createRpcRequest([buildApprovalMessage()], [DEFAULT_CHAT_ID]);
|
||||
|
||||
await pollPendingIMessageApprovalReactions(
|
||||
buildPollParams(request, { allowRecentChatDiscovery: true }),
|
||||
);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("chats.list", { limit: 50 }, { timeoutMs: 10_000 });
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"messages.history",
|
||||
{ chat_id: DEFAULT_CHAT_ID, limit: 30 },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
expect(resolverMocks.resolveApprovalOverGateway).toHaveBeenCalledWith({
|
||||
accountId,
|
||||
cfg: buildApprovalConfig(),
|
||||
@@ -181,152 +191,11 @@ describe("iMessage approval reaction poller", () => {
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
channel: "imessage",
|
||||
senderId: "+15551230000",
|
||||
senderId: APPROVER,
|
||||
gatewayUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds no-target recent-chat discovery to one pass per account", async () => {
|
||||
const request = createRpcRequest([], [42]);
|
||||
|
||||
const pollParams = buildPollParams(request, { allowRecentChatDiscovery: true });
|
||||
|
||||
await pollPendingIMessageApprovalReactions(pollParams);
|
||||
await pollPendingIMessageApprovalReactions(pollParams);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(request).toHaveBeenCalledWith("chats.list", { limit: 50 }, { timeoutMs: 10_000 });
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"messages.history",
|
||||
{ chat_id: 42, limit: 30 },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds no-target discovery after resolving an observed reaction", async () => {
|
||||
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: [buildApprovalMessage()] };
|
||||
}
|
||||
if (method === "messages.history" && payload?.chat_id === 99) {
|
||||
return { messages: [] };
|
||||
}
|
||||
throw new Error(`unexpected request ${method} ${JSON.stringify(payload)}`);
|
||||
});
|
||||
|
||||
const pollParams = buildPollParams(request, { allowRecentChatDiscovery: true });
|
||||
|
||||
await pollPendingIMessageApprovalReactions(pollParams);
|
||||
await pollPendingIMessageApprovalReactions(pollParams);
|
||||
|
||||
expect(resolverMocks.resolveApprovalOverGateway).toHaveBeenCalledTimes(1);
|
||||
expect(request.mock.calls.filter(([method]) => method === "chats.list")).toHaveLength(1);
|
||||
expect(request.mock.calls.filter(([method]) => method === "messages.history")).toHaveLength(2);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"messages.history",
|
||||
{ chat_id: 99, limit: 30 },
|
||||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("retries no-target discovery after resolver failures expire observed targets", async () => {
|
||||
resolverMocks.resolveApprovalOverGateway.mockRejectedValue(new Error("gateway down"));
|
||||
const request = createRpcRequest([buildApprovalMessage()], [42]);
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(1_800_000_000_000);
|
||||
|
||||
try {
|
||||
const pollParams = buildPollParams(request, { allowRecentChatDiscovery: true });
|
||||
|
||||
await pollPendingIMessageApprovalReactions(pollParams);
|
||||
dateNow.mockReturnValue(1_800_000_301_000);
|
||||
await pollPendingIMessageApprovalReactions(pollParams);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
|
||||
expect(resolverMocks.resolveApprovalOverGateway).toHaveBeenCalledTimes(2);
|
||||
expect(request.mock.calls.filter(([method]) => method === "chats.list")).toHaveLength(2);
|
||||
});
|
||||
|
||||
[
|
||||
{
|
||||
title: "retries no-target recent-chat discovery after the first chat list fails",
|
||||
failingMethod: "chats.list",
|
||||
errorMessage: "temporary imsg failure",
|
||||
pollCount: 2,
|
||||
expectFirstPollToReject: true,
|
||||
expectedChatLists: 2,
|
||||
expectedHistories: 1,
|
||||
},
|
||||
{
|
||||
title: "retries no-target recent-chat discovery after the first history fetch fails",
|
||||
failingMethod: "messages.history",
|
||||
errorMessage: "temporary history failure",
|
||||
pollCount: 3,
|
||||
expectFirstPollToReject: false,
|
||||
expectedChatLists: 2,
|
||||
expectedHistories: 2,
|
||||
},
|
||||
].forEach((testCase) => {
|
||||
it(testCase.title, async () => {
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "chats.list") {
|
||||
if (testCase.failingMethod !== method) {
|
||||
return { chats: [{ id: 42 }] };
|
||||
}
|
||||
}
|
||||
if (method !== "chats.list" && method !== "messages.history") {
|
||||
throw new Error(`unexpected method ${method}`);
|
||||
}
|
||||
const methodCalls = request.mock.calls.filter(([calledMethod]) => calledMethod === method);
|
||||
if (testCase.failingMethod === method && methodCalls.length === 1) {
|
||||
throw new Error(testCase.errorMessage);
|
||||
}
|
||||
return method === "chats.list" ? { chats: [{ id: 42 }] } : { messages: [] };
|
||||
});
|
||||
|
||||
const pollParams = buildPollParams(request, { allowRecentChatDiscovery: true });
|
||||
const poll = () => pollPendingIMessageApprovalReactions(pollParams);
|
||||
|
||||
if (testCase.expectFirstPollToReject) {
|
||||
await expect(poll()).rejects.toThrow(testCase.errorMessage);
|
||||
} else {
|
||||
await poll();
|
||||
}
|
||||
for (let index = 1; index < testCase.pollCount; index += 1) {
|
||||
await poll();
|
||||
}
|
||||
|
||||
expect(request.mock.calls.filter(([method]) => method === "chats.list")).toHaveLength(
|
||||
testCase.expectedChatLists,
|
||||
);
|
||||
expect(request.mock.calls.filter(([method]) => method === "messages.history")).toHaveLength(
|
||||
testCase.expectedHistories,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not bind observed approval prompts when the process clock is invalid", async () => {
|
||||
const request = createRpcRequest(
|
||||
[buildApprovalMessage({ text: "Exec approval required\nID: exec-1" })],
|
||||
[42],
|
||||
);
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
|
||||
try {
|
||||
await pollPendingIMessageApprovalReactions(
|
||||
buildPollParams(request, { allowRecentChatDiscovery: true }),
|
||||
);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
|
||||
expect(resolverMocks.resolveApprovalOverGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses learned chat ids for fast scoped polling after discovery", async () => {
|
||||
registerTarget({
|
||||
conversation: { handle: "+15551230000" },
|
||||
@@ -346,79 +215,6 @@ describe("iMessage approval reaction poller", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes recent chats during discovery when scoped and unscoped targets are pending", async () => {
|
||||
registerTarget({
|
||||
conversation: { chatId: 42, chatGuid: "SMS;-;+15551230000" },
|
||||
messageId: "msg-scoped",
|
||||
approvalId: "exec-scoped",
|
||||
});
|
||||
registerTarget({
|
||||
conversation: { handle: "+15551239999" },
|
||||
messageId: "msg-handle",
|
||||
approvalId: "exec-handle",
|
||||
});
|
||||
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: [
|
||||
buildApprovalMessage({
|
||||
guid: "msg-handle",
|
||||
chat_id: 99,
|
||||
chat_guid: "SMS;-;+15551239999",
|
||||
chat_identifier: "+15551239999",
|
||||
sender: "+15551239999",
|
||||
text: "Exec approval required\nID: exec-handle",
|
||||
reactions: [
|
||||
buildReaction({
|
||||
id: 8,
|
||||
sender: "+15551239999",
|
||||
is_from_me: true,
|
||||
created_at: "2026-05-27T21:01:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected request ${method} ${JSON.stringify(payload)}`);
|
||||
});
|
||||
|
||||
await pollPendingIMessageApprovalReactions(
|
||||
buildPollParams(request, {
|
||||
cfg: buildApprovalConfig("+15551239999"),
|
||||
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.resolveApprovalOverGateway).toHaveBeenCalledWith({
|
||||
accountId,
|
||||
cfg: buildApprovalConfig("+15551239999"),
|
||||
approvalId: "exec-handle",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
channel: "imessage",
|
||||
senderId: "+15551239999",
|
||||
gatewayUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("continues scanning after an unauthorized reaction leaves the approval pending", async () => {
|
||||
registerIMessageApprovalReactionTarget({
|
||||
accountId,
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
// Imessage plugin module implements approval reaction poller behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
asPositiveFiniteNumber,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { asDateTimestampMs, asPositiveFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { IMessageApprovalGatewayRuntime } from "./approval-gateway-types.js";
|
||||
import {
|
||||
extractIMessageApprovalPromptBinding,
|
||||
handleIMessageApprovalReaction,
|
||||
listPendingIMessageApprovalReactionPollTargets,
|
||||
registerIMessageApprovalReactionTarget,
|
||||
type PendingIMessageApprovalReactionPollTarget,
|
||||
} from "./approval-reaction-poll-targets.js";
|
||||
import {
|
||||
handleIMessageApprovalReaction,
|
||||
registerIMessageApprovalReactionTarget,
|
||||
type IMessageApprovalConversationKey,
|
||||
} from "./approval-reactions.js";
|
||||
import type { IMessageRpcClient } from "./client.js";
|
||||
@@ -19,9 +16,6 @@ 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;
|
||||
|
||||
const accountIdsWithCompletedNoTargetDiscovery = new Set<string>();
|
||||
|
||||
type ChatListEntry = {
|
||||
id?: number | null;
|
||||
@@ -191,39 +185,6 @@ function bindObservedConversation(params: {
|
||||
}
|
||||
}
|
||||
|
||||
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 expiresAtMs = resolveExpiresAtMsFromDurationMs(OBSERVED_APPROVAL_PROMPT_TARGET_TTL_MS);
|
||||
if (expiresAtMs === undefined) {
|
||||
return null;
|
||||
}
|
||||
const target: PendingIMessageApprovalReactionPollTarget = {
|
||||
accountId: params.accountId,
|
||||
conversation,
|
||||
messageId,
|
||||
approvalId: binding.approvalId,
|
||||
approvalKind: binding.approvalKind,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
expiresAtMs,
|
||||
};
|
||||
bindObservedConversation({ target, message: params.message });
|
||||
return target;
|
||||
}
|
||||
|
||||
export async function pollPendingIMessageApprovalReactions(params: {
|
||||
client: IMessageRpcClient;
|
||||
cfg: OpenClawConfig;
|
||||
@@ -232,37 +193,29 @@ export async function pollPendingIMessageApprovalReactions(params: {
|
||||
gatewayRuntime?: IMessageApprovalGatewayRuntime;
|
||||
logVerboseMessage?: (message: string) => void;
|
||||
}): Promise<void> {
|
||||
const targets = listPendingIMessageApprovalReactionPollTargets({
|
||||
const targets = await listPendingIMessageApprovalReactionPollTargets({
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const shouldAttemptNoTargetDiscovery =
|
||||
targets.length === 0 &&
|
||||
params.allowRecentChatDiscovery === true &&
|
||||
!accountIdsWithCompletedNoTargetDiscovery.has(params.accountId);
|
||||
if (targets.length === 0 && !shouldAttemptNoTargetDiscovery) {
|
||||
if (targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
const pendingByMessageId = buildPendingTargetsByMessageId(targets);
|
||||
const explicitChatIds = listTargetChatIds(targets);
|
||||
// Send-side DM registration may know only a handle, not a chat id. Scan recent chats
|
||||
// for those typed GUID targets or a watch-missed tapback would silently resolve nothing.
|
||||
const shouldDiscoverRecentChats =
|
||||
params.allowRecentChatDiscovery === true &&
|
||||
(targets.length === 0 || hasUnscopedTarget(targets));
|
||||
params.allowRecentChatDiscovery === true && targets.length > 0 && hasUnscopedTarget(targets);
|
||||
const chatIds = shouldDiscoverRecentChats
|
||||
? uniqueChatIds([...explicitChatIds, ...(await listRecentChatIds(params.client))])
|
||||
: explicitChatIds;
|
||||
if (chatIds.length === 0) {
|
||||
if (shouldAttemptNoTargetDiscovery) {
|
||||
accountIdsWithCompletedNoTargetDiscovery.add(params.accountId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let hadHistoryFetchError = false;
|
||||
for (const chatId of chatIds) {
|
||||
let messages: HistoryMessage[];
|
||||
try {
|
||||
messages = await fetchRecentHistory({ client: params.client, chatId });
|
||||
} catch (err) {
|
||||
hadHistoryFetchError = true;
|
||||
params.logVerboseMessage?.(
|
||||
`imessage: approval reaction poll skipped chat_id=${chatId}: ${String(err)}`,
|
||||
);
|
||||
@@ -275,11 +228,7 @@ export async function pollPendingIMessageApprovalReactions(params: {
|
||||
}
|
||||
const target =
|
||||
pendingByMessageId.get(targetGuid) ??
|
||||
pendingByMessageId.get(normalizeMessageGuid(targetGuid)) ??
|
||||
bindObservedApprovalPrompt({
|
||||
accountId: params.accountId,
|
||||
message,
|
||||
});
|
||||
pendingByMessageId.get(normalizeMessageGuid(targetGuid));
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
@@ -298,15 +247,9 @@ export async function pollPendingIMessageApprovalReactions(params: {
|
||||
logVerboseMessage: params.logVerboseMessage,
|
||||
});
|
||||
if (handled.stopPolling) {
|
||||
if (shouldAttemptNoTargetDiscovery && handled.stopPollingReason !== "resolver-error") {
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldAttemptNoTargetDiscovery && !hadHistoryFetchError) {
|
||||
accountIdsWithCompletedNoTargetDiscovery.add(params.accountId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,20 @@
|
||||
import { buildTypedExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { listPendingIMessageApprovalReactionPollTargets } from "./approval-reaction-poll-targets.js";
|
||||
import {
|
||||
addIMessageApprovalReactionHintToStructuredPayload,
|
||||
appendIMessageApprovalReactionHintForOutboundMessage,
|
||||
buildIMessageApprovalConversationKeyForTarget,
|
||||
buildIMessageApprovalReactionHint,
|
||||
clearIMessageApprovalReactionTargetsForTest,
|
||||
extractIMessageApprovalPromptBinding,
|
||||
handleIMessageApprovalReaction,
|
||||
listPendingIMessageApprovalReactionPollTargets,
|
||||
maybeResolveIMessageApprovalReaction,
|
||||
registerIMessageApprovalReactionTargetForDeliveredPayload,
|
||||
registerIMessageApprovalReactionTargetForOutboundMessage,
|
||||
registerIMessageApprovalReactionTarget as registerIMessageApprovalReactionTargetRaw,
|
||||
resolveIMessageApprovalReactionTargetWithPersistence,
|
||||
} from "./approval-reactions.js";
|
||||
import type { IMessagePayload } from "./monitor/types.js";
|
||||
import { installIMessageStateRuntimeForTest } from "./test-support/runtime.js";
|
||||
|
||||
const resolverMocks = vi.hoisted(() => ({
|
||||
resolveApprovalOverGateway: vi.fn(),
|
||||
@@ -85,30 +83,6 @@ describe("iMessage approval reactions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("appends thumbs-only reaction choices to outbound approval prompts", () => {
|
||||
expect(
|
||||
appendIMessageApprovalReactionHintForOutboundMessage(
|
||||
"Exec approval required\nID: exec-1\n\nReply with: /approve exec-1 allow-once|deny",
|
||||
),
|
||||
).toBe(
|
||||
"Exec approval required\nID: exec-1\n\nReact with:\n\n👍 Allow Once\n👎 Deny\n\nReply with: /approve exec-1 allow-once|deny",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate reaction choices on native approval prompts", () => {
|
||||
const prompt = [
|
||||
"Plugin approval required",
|
||||
"Reply with: /approve plugin:abc allow-once|allow-always|deny",
|
||||
"",
|
||||
"React with:",
|
||||
"",
|
||||
"👍 Allow Once",
|
||||
"👎 Deny",
|
||||
].join("\n");
|
||||
|
||||
expect(appendIMessageApprovalReactionHintForOutboundMessage(prompt)).toBe(prompt);
|
||||
});
|
||||
|
||||
it("uses typed metadata to prepare shared forwarded prompts", () => {
|
||||
const payload: ReplyPayload = {
|
||||
text: [
|
||||
@@ -509,7 +483,7 @@ describe("iMessage approval reactions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("merges learned chat ids into pending poll targets", () => {
|
||||
it("merges learned chat ids into pending poll targets", async () => {
|
||||
registerIMessageApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
conversation: { handle: "+15551230000" },
|
||||
@@ -529,7 +503,7 @@ describe("iMessage approval reactions", () => {
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
});
|
||||
|
||||
expect(listPendingIMessageApprovalReactionPollTargets({ accountId: "default" })).toEqual([
|
||||
expect(await listPendingIMessageApprovalReactionPollTargets({ accountId: "default" })).toEqual([
|
||||
expect.objectContaining({
|
||||
approvalId: "exec-1",
|
||||
conversation: {
|
||||
@@ -543,7 +517,7 @@ describe("iMessage approval reactions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not keep pending poll targets when the process clock is invalid", () => {
|
||||
it("does not keep pending poll targets when the process clock is invalid", async () => {
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
try {
|
||||
expect(
|
||||
@@ -559,10 +533,12 @@ describe("iMessage approval reactions", () => {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
|
||||
expect(listPendingIMessageApprovalReactionPollTargets({ accountId: "default" })).toEqual([]);
|
||||
expect(await listPendingIMessageApprovalReactionPollTargets({ accountId: "default" })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the default pending poll target ttl for invalid explicit ttl values", () => {
|
||||
it("falls back to the default pending poll target ttl for invalid explicit ttl values", async () => {
|
||||
const nowMs = 1_800_000_000_000;
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(nowMs);
|
||||
try {
|
||||
@@ -578,7 +554,7 @@ describe("iMessage approval reactions", () => {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
|
||||
expect(listPendingIMessageApprovalReactionPollTargets({ accountId: "default" })).toEqual([
|
||||
expect(await listPendingIMessageApprovalReactionPollTargets({ accountId: "default" })).toEqual([
|
||||
expect.objectContaining({
|
||||
approvalId: "exec-invalid-ttl",
|
||||
expiresAtMs: nowMs + 24 * 60 * 60 * 1000,
|
||||
@@ -586,6 +562,35 @@ describe("iMessage approval reactions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores pending poll targets from plugin state after a process-local reset", async () => {
|
||||
installIMessageStateRuntimeForTest();
|
||||
clearIMessageApprovalReactionTargetsForTest();
|
||||
registerIMessageApprovalReactionTarget({
|
||||
accountId: "restart-account",
|
||||
conversation: { chatId: 42, chatGuid: "iMessage;+;restart" },
|
||||
messageId: "restart-message",
|
||||
approvalId: "exec-restart",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
expect(
|
||||
await listPendingIMessageApprovalReactionPollTargets({ accountId: "restart-account" }),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
clearIMessageApprovalReactionTargetsForTest();
|
||||
|
||||
expect(
|
||||
await listPendingIMessageApprovalReactionPollTargets({ accountId: "restart-account" }),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
approvalId: "exec-restart",
|
||||
messageId: "restart-message",
|
||||
conversation: expect.objectContaining({ chatId: 42, chatGuid: "iMessage;+;restart" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves a registered group reaction target keyed by chat_guid", async () => {
|
||||
registerIMessageApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
@@ -610,135 +615,6 @@ describe("iMessage approval reactions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("binds prompts whose headers and labels are bold", () => {
|
||||
// The prompt builder emits **Exec approval required** / **ID:** …; binding
|
||||
// must still correlate the delivered prompt (reaction/tapback approvals).
|
||||
expect(
|
||||
extractIMessageApprovalPromptBinding(
|
||||
[
|
||||
"**Exec approval required**",
|
||||
"**ID:** exec-bold",
|
||||
"**Pending command:**",
|
||||
"```sh",
|
||||
"echo hi",
|
||||
"```",
|
||||
"**Full id:** `exec-bold`",
|
||||
"Reply with: /approve exec-bold allow-once|deny",
|
||||
].join("\n"),
|
||||
),
|
||||
).toEqual({
|
||||
approvalId: "exec-bold",
|
||||
approvalKind: "exec",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts approval bindings from explicit outbound prompts", async () => {
|
||||
expect(
|
||||
extractIMessageApprovalPromptBinding(
|
||||
[
|
||||
"Plugin approval required",
|
||||
"ID: plugin:abc",
|
||||
"Reply with: /approve plugin:abc allow-once|allow-always|deny",
|
||||
].join("\n"),
|
||||
),
|
||||
).toEqual({
|
||||
approvalId: "plugin:abc",
|
||||
approvalKind: "plugin",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
});
|
||||
|
||||
expect(
|
||||
registerIMessageApprovalReactionTargetForOutboundMessage({
|
||||
accountId: "default",
|
||||
conversation: { handle: "+15551230000" },
|
||||
messageId: "prompt-message",
|
||||
approvalKind: "exec",
|
||||
text: [
|
||||
"Exec approval required",
|
||||
"ID: exec-1",
|
||||
"",
|
||||
"Reply with: /approve exec-1 allow-once|deny",
|
||||
].join("\n"),
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await expect(
|
||||
resolveIMessageApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversation: { handle: "+15551230000" },
|
||||
messageId: "prompt-message",
|
||||
reactionKey: "👎",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
approvalId: "exec-1",
|
||||
approvalKind: "exec",
|
||||
decision: "deny",
|
||||
});
|
||||
|
||||
for (const reactionKey of ["1️⃣", "2️⃣", "3️⃣", "1", "2", "3", "❤️", "♾️"]) {
|
||||
await expect(
|
||||
resolveIMessageApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversation: { handle: "+15551230000" },
|
||||
messageId: "prompt-message",
|
||||
reactionKey,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not register a phantom binding when /approve text appears in a non-approval message", () => {
|
||||
// Agent help text quoting /approve syntax should NOT register a binding —
|
||||
// requiring a canonical `ID: <id>` header line is the gate.
|
||||
expect(
|
||||
registerIMessageApprovalReactionTargetForOutboundMessage({
|
||||
accountId: "default",
|
||||
conversation: { handle: "+15551230000" },
|
||||
messageId: "help-message",
|
||||
approvalKind: "exec",
|
||||
text: "Run /approve task-7 allow-once when you're ready.",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
extractIMessageApprovalPromptBinding("Run /approve task-7 allow-once when you're ready."),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects outbound prompt bindings whose approval kind does not match", () => {
|
||||
expect(
|
||||
registerIMessageApprovalReactionTargetForOutboundMessage({
|
||||
accountId: "default",
|
||||
conversation: { handle: "+15551230000" },
|
||||
messageId: "mismatched-prompt-message",
|
||||
approvalKind: "exec",
|
||||
text: [
|
||||
"Plugin approval required",
|
||||
"ID: plugin:abc",
|
||||
"Reply with: /approve plugin:abc allow-once|deny",
|
||||
].join("\n"),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("escapes `$` sequences in approvalId when interpolating into outbound text", () => {
|
||||
// The shared replaceApprovalIdPlaceholder helper guards against
|
||||
// String.prototype.replace interpreting `$1`/`$&`/`$$` in the
|
||||
// replacement string. Verified indirectly via the binding extractor:
|
||||
// a prompt rendered for approvalId "exec-$1abc" must keep the id intact.
|
||||
const text = [
|
||||
"Exec approval required",
|
||||
"ID: exec-1abc",
|
||||
"Reply with: /approve exec-1abc allow-once",
|
||||
].join("\n");
|
||||
expect(extractIMessageApprovalPromptBinding(text)).toEqual({
|
||||
approvalId: "exec-1abc",
|
||||
approvalKind: "exec",
|
||||
allowedDecisions: ["allow-once"],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves is_from_me tapbacks when the actor is an explicit approver", async () => {
|
||||
registerIMessageApprovalReactionTarget({
|
||||
accountId: "default",
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
approvalReactionDecisionSetsMatch,
|
||||
buildApprovalReactionHint,
|
||||
createApprovalReactionTargetStore,
|
||||
extractApprovalReactionPromptBinding,
|
||||
hasApprovalReactionHintText,
|
||||
listApprovalReactionBindings,
|
||||
normalizeApprovalReactionDecision,
|
||||
readApprovalReactionDeliveredBinding,
|
||||
@@ -21,21 +19,20 @@ import type { OutboundDeliveryResult } from "openclaw/plugin-sdk/channel-send-re
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { isApprovalNotFoundError } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { createLazyRuntimeSurface } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { createPluginStateErrorReporter } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { getIMessageApprovalApprovers, imessageApprovalAuth } from "./approval-auth.js";
|
||||
import type { IMessageApprovalGatewayRuntime } from "./approval-gateway-types.js";
|
||||
import {
|
||||
clearIMessageApprovalReactionPollTargetsForTest,
|
||||
deleteIMessageApprovalReactionPollTargets,
|
||||
recordIMessageApprovalReactionPollTarget,
|
||||
} from "./approval-reaction-poll-targets.js";
|
||||
import {
|
||||
buildIMessageApprovalConversationKeyForInbound,
|
||||
buildIMessageApprovalConversationKeyForTarget,
|
||||
enumerateApprovalTargetKeys,
|
||||
normalizeConversationKey,
|
||||
normalizeIMessageGuid,
|
||||
type IMessageApprovalConversationKey,
|
||||
} from "./approval-target-keys.js";
|
||||
import { resolveIMessageReactionContext } from "./monitor/reaction-context.js";
|
||||
@@ -69,89 +66,10 @@ type IMessageApprovalReactionTarget = ApprovalReactionTargetRecord & {
|
||||
|
||||
export type { IMessageApprovalConversationKey } from "./approval-target-keys.js";
|
||||
|
||||
export type PendingIMessageApprovalReactionPollTarget = {
|
||||
accountId: string;
|
||||
conversation: IMessageApprovalConversationKey;
|
||||
messageId: string;
|
||||
approvalId: string;
|
||||
approvalKind: "exec" | "plugin";
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
const loadResolveApprovalOverGateway = createLazyRuntimeSurface(
|
||||
() => import("openclaw/plugin-sdk/approval-gateway-runtime"),
|
||||
(runtime) => runtime.resolveApprovalOverGateway,
|
||||
);
|
||||
const pendingReactionPollTargets = new Map<string, PendingIMessageApprovalReactionPollTarget>();
|
||||
|
||||
function prunePendingReactionPollTargets(nowMs = Date.now()): void {
|
||||
for (const [key, target] of pendingReactionPollTargets.entries()) {
|
||||
if (!isFutureDateTimestampMs(target.expiresAtMs, { nowMs })) {
|
||||
pendingReactionPollTargets.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePendingReactionPollExpiry(
|
||||
ttlMs: number | undefined,
|
||||
): { ttlMs: number; expiresAtMs: number } | undefined {
|
||||
const nowMs = asDateTimestampMs(Date.now());
|
||||
if (nowMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const expiresAtMs =
|
||||
resolveExpiresAtMsFromDurationMs(ttlMs ?? DEFAULT_REACTION_TARGET_TTL_MS, { nowMs }) ??
|
||||
resolveExpiresAtMsFromDurationMs(DEFAULT_REACTION_TARGET_TTL_MS, { nowMs });
|
||||
if (expiresAtMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
ttlMs: expiresAtMs - nowMs,
|
||||
expiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
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<string, PendingIMessageApprovalReactionPollTarget>();
|
||||
for (const target of pendingReactionPollTargets.values()) {
|
||||
if (target.accountId !== accountId) {
|
||||
continue;
|
||||
}
|
||||
const key = `${target.approvalId}:${normalizeIMessageGuid(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()];
|
||||
}
|
||||
|
||||
const reportPersistentApprovalReactionError = createPluginStateErrorReporter(
|
||||
getOptionalIMessageRuntime,
|
||||
"imessage",
|
||||
@@ -232,20 +150,6 @@ export function addIMessageApprovalReactionHintToText(params: {
|
||||
return addApprovalReactionHintToText(params);
|
||||
}
|
||||
|
||||
export function appendIMessageApprovalReactionHintForOutboundMessage(text: string): string {
|
||||
if (hasApprovalReactionHintText(text)) {
|
||||
return text;
|
||||
}
|
||||
const binding = extractIMessageApprovalPromptBinding(text);
|
||||
if (!binding) {
|
||||
return text;
|
||||
}
|
||||
return addIMessageApprovalReactionHintToText({
|
||||
text,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
});
|
||||
}
|
||||
|
||||
type IMessageApprovalDeliveryBinding = ApprovalReactionDeliveryBinding & {
|
||||
approvalSlug: string;
|
||||
};
|
||||
@@ -344,14 +248,6 @@ export function addIMessageApprovalReactionHintToStructuredPayload(params: {
|
||||
|
||||
const APPROVE_COMMAND_LINE_RE = /\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(.+)$/i;
|
||||
|
||||
export function extractIMessageApprovalPromptBinding(text: string): {
|
||||
approvalId: string;
|
||||
approvalKind: "exec" | "plugin";
|
||||
allowedDecisions: ExecApprovalReplyDecision[];
|
||||
} | null {
|
||||
return extractApprovalReactionPromptBinding({ text });
|
||||
}
|
||||
|
||||
export function registerIMessageApprovalReactionTarget(params: {
|
||||
accountId: string;
|
||||
conversation: IMessageApprovalConversationKey;
|
||||
@@ -361,11 +257,15 @@ export function registerIMessageApprovalReactionTarget(params: {
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
ttlMs?: number;
|
||||
}): IMessageApprovalReactionTarget | null {
|
||||
const accountId = params.accountId.trim();
|
||||
const messageId = params.messageId.trim();
|
||||
const approvalId = params.approvalId.trim();
|
||||
const allowedDecisions = listIMessageApprovalReactionBindings(params.allowedDecisions).map(
|
||||
(binding) => binding.decision,
|
||||
);
|
||||
if (
|
||||
!accountId ||
|
||||
!messageId ||
|
||||
!approvalId ||
|
||||
(params.approvalKind !== "exec" && params.approvalKind !== "plugin") ||
|
||||
allowedDecisions.length === 0
|
||||
@@ -373,10 +273,6 @@ export function registerIMessageApprovalReactionTarget(params: {
|
||||
return null;
|
||||
}
|
||||
const target = { approvalId, approvalKind: params.approvalKind, allowedDecisions };
|
||||
const expiry = resolvePendingReactionPollExpiry(params.ttlMs);
|
||||
if (!expiry) {
|
||||
return null;
|
||||
}
|
||||
// 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
|
||||
@@ -384,54 +280,32 @@ export function registerIMessageApprovalReactionTarget(params: {
|
||||
// Indexing under every available key keeps send/inbound symmetric without
|
||||
// forcing the caller to know which key the bridge will pick.
|
||||
const keys = enumerateApprovalTargetKeys({
|
||||
accountId: params.accountId,
|
||||
accountId,
|
||||
conversation: params.conversation,
|
||||
messageId: params.messageId,
|
||||
messageId,
|
||||
});
|
||||
if (keys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const expiry = recordIMessageApprovalReactionPollTarget({
|
||||
keys,
|
||||
accountId,
|
||||
conversation: params.conversation,
|
||||
messageId,
|
||||
approvalId,
|
||||
approvalKind: params.approvalKind,
|
||||
allowedDecisions,
|
||||
ttlMs: params.ttlMs,
|
||||
});
|
||||
if (!expiry) {
|
||||
return null;
|
||||
}
|
||||
for (const key of keys) {
|
||||
imessageApprovalReactionTargets.register(key, target, { ttlMs: expiry.ttlMs });
|
||||
pendingReactionPollTargets.set(key, {
|
||||
accountId: params.accountId,
|
||||
conversation: params.conversation,
|
||||
messageId: params.messageId,
|
||||
approvalId,
|
||||
approvalKind: params.approvalKind,
|
||||
allowedDecisions,
|
||||
expiresAtMs: expiry.expiresAtMs,
|
||||
});
|
||||
}
|
||||
prunePendingReactionPollTargets();
|
||||
return target;
|
||||
}
|
||||
|
||||
export function registerIMessageApprovalReactionTargetForOutboundMessage(params: {
|
||||
accountId: string;
|
||||
conversation: IMessageApprovalConversationKey;
|
||||
messageId: string;
|
||||
text: string;
|
||||
approvalKind: "exec" | "plugin";
|
||||
ttlMs?: number;
|
||||
}): boolean {
|
||||
const binding = extractIMessageApprovalPromptBinding(params.text);
|
||||
if (!binding || binding.approvalKind !== params.approvalKind) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
registerIMessageApprovalReactionTarget({
|
||||
accountId: params.accountId,
|
||||
conversation: params.conversation,
|
||||
messageId: params.messageId,
|
||||
approvalId: binding.approvalId,
|
||||
approvalKind: params.approvalKind,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
ttlMs: params.ttlMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export { buildIMessageApprovalConversationKeyForTarget };
|
||||
|
||||
function listDeliveredIMessageApprovalGuids(params: {
|
||||
@@ -522,8 +396,8 @@ export function unregisterIMessageApprovalReactionTarget(params: {
|
||||
const keys = enumerateApprovalTargetKeys(params);
|
||||
for (const key of keys) {
|
||||
imessageApprovalReactionTargets.delete(key);
|
||||
pendingReactionPollTargets.delete(key);
|
||||
}
|
||||
deleteIMessageApprovalReactionPollTargets(keys);
|
||||
}
|
||||
|
||||
function resolveTarget(params: {
|
||||
@@ -762,6 +636,6 @@ export async function maybeResolveIMessageApprovalReaction(params: {
|
||||
|
||||
export function clearIMessageApprovalReactionTargetsForTest(): void {
|
||||
imessageApprovalReactionTargets.clearForTest();
|
||||
pendingReactionPollTargets.clear();
|
||||
clearIMessageApprovalReactionPollTargetsForTest();
|
||||
loadResolveApprovalOverGateway.clear();
|
||||
}
|
||||
|
||||
@@ -88,6 +88,14 @@ function createApprovalText(id = "approval-123"): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function createApprovalPrompt(id = "approval-123") {
|
||||
return {
|
||||
approvalId: id,
|
||||
approvalKind: "exec" as const,
|
||||
allowedDecisions: ["allow-once", "deny"] as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sendMessageIMessage receipts", () => {
|
||||
let openClawState: OpenClawTestState;
|
||||
|
||||
@@ -3244,6 +3252,7 @@ describe("sendMessageIMessage receipts", () => {
|
||||
|
||||
const result = await sendMessageIMessage("chat_id:42", approvalText, {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
approvalPrompt: createApprovalPrompt(),
|
||||
createClient: createClientLocal,
|
||||
runCliJson,
|
||||
service: "sms",
|
||||
@@ -3272,6 +3281,7 @@ describe("sendMessageIMessage receipts", () => {
|
||||
|
||||
const result = await sendMessageIMessage("chat_id:42", approvalText, {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
approvalPrompt: createApprovalPrompt("approval-default"),
|
||||
client,
|
||||
runCliJson,
|
||||
resolveSentMessageGuidImpl,
|
||||
@@ -3296,6 +3306,7 @@ describe("sendMessageIMessage receipts", () => {
|
||||
|
||||
const result = await sendMessageIMessage("chat_id:42", approvalText, {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
approvalPrompt: createApprovalPrompt("approval-homebrew"),
|
||||
client,
|
||||
cliPath: "/opt/homebrew/bin/imsg",
|
||||
runCliJson,
|
||||
@@ -3331,6 +3342,7 @@ describe("sendMessageIMessage receipts", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
approvalPrompt: createApprovalPrompt("approval-remote"),
|
||||
client,
|
||||
cliPath: "/Users/me/.openclaw/scripts/imsg",
|
||||
runCliJson,
|
||||
@@ -3363,6 +3375,7 @@ describe("sendMessageIMessage receipts", () => {
|
||||
const rejection = expect(
|
||||
sendMessageIMessage("chat_id:42", approvalText, {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
approvalPrompt: createApprovalPrompt("approval-ssh-wrapper"),
|
||||
client,
|
||||
cliPath: wrapperPath,
|
||||
runCliJson,
|
||||
@@ -3474,6 +3487,7 @@ describe("sendMessageIMessage receipts", () => {
|
||||
const rejection = expect(
|
||||
sendMessageIMessage("chat_id:42", approvalText, {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
approvalPrompt: createApprovalPrompt(),
|
||||
client,
|
||||
runCliJson,
|
||||
dbPath: "/Users/me/Library/Messages/chat.db",
|
||||
@@ -3503,7 +3517,7 @@ describe("sendMessageIMessage receipts", () => {
|
||||
|
||||
const result = await sendMessageIMessage("chat_id:42", approvalText, {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
approvalKind: "exec",
|
||||
approvalPrompt: createApprovalPrompt(),
|
||||
client,
|
||||
dbPath: "/Users/me/Library/Messages/chat.db",
|
||||
resolveSentMessageGuidImpl,
|
||||
@@ -3538,6 +3552,7 @@ describe("sendMessageIMessage receipts", () => {
|
||||
|
||||
const result = await sendMessageIMessage("chat_id:42", approvalText, {
|
||||
config: IMESSAGE_TEST_CFG,
|
||||
approvalPrompt: createApprovalPrompt(),
|
||||
client,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Imessage plugin module implements send behavior.
|
||||
import { constants, accessSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import type { ExecApprovalReplyDecision } from "openclaw/plugin-sdk/approval-reply-runtime";
|
||||
import {
|
||||
createChannelPartialDeliveryError,
|
||||
type MediaPlaceholderTextFact,
|
||||
@@ -38,10 +39,9 @@ import {
|
||||
type ResolvedIMessageAccount,
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
appendIMessageApprovalReactionHintForOutboundMessage,
|
||||
extractIMessageApprovalPromptBinding,
|
||||
addIMessageApprovalReactionHintToText,
|
||||
type IMessageApprovalConversationKey,
|
||||
registerIMessageApprovalReactionTargetForOutboundMessage,
|
||||
registerIMessageApprovalReactionTarget,
|
||||
} from "./approval-reactions.js";
|
||||
import { chatContextFromIMessageTarget } from "./chat-context.js";
|
||||
import { runIMessageCliJsonCommand } from "./cli-output.js";
|
||||
@@ -76,6 +76,12 @@ const MIN_PENDING_PERSISTED_ECHO_TTL_MS = 60_000;
|
||||
const PENDING_PERSISTED_ECHO_GRACE_MS = 5_000;
|
||||
type IMessageSendTransport = "auto" | "bridge" | "applescript";
|
||||
|
||||
type IMessageApprovalPromptBinding = {
|
||||
approvalId: string;
|
||||
approvalKind: "exec" | "plugin";
|
||||
allowedDecisions: readonly ExecApprovalReplyDecision[];
|
||||
};
|
||||
|
||||
type IMessageSendOpts = {
|
||||
cliPath?: string;
|
||||
dbPath?: string;
|
||||
@@ -95,7 +101,7 @@ type IMessageSendOpts = {
|
||||
client?: IMessageRpcClient;
|
||||
config: OpenClawConfig;
|
||||
account?: ResolvedIMessageAccount;
|
||||
approvalKind?: "exec" | "plugin";
|
||||
approvalPrompt?: IMessageApprovalPromptBinding;
|
||||
resolveAttachmentImpl?: (
|
||||
mediaUrl: string,
|
||||
maxBytes: number,
|
||||
@@ -387,16 +393,11 @@ async function resolveFallbackSentMessageGuid(params: {
|
||||
}
|
||||
|
||||
function shouldRecoverApprovalPromptGuid(params: {
|
||||
message: string;
|
||||
approvalPrompt?: IMessageApprovalPromptBinding;
|
||||
filePath?: string;
|
||||
replyToId?: string | null;
|
||||
}): boolean {
|
||||
return (
|
||||
!params.filePath &&
|
||||
!params.replyToId &&
|
||||
Boolean(params.message.trim()) &&
|
||||
Boolean(extractIMessageApprovalPromptBinding(params.message))
|
||||
);
|
||||
return Boolean(params.approvalPrompt && !params.filePath && !params.replyToId);
|
||||
}
|
||||
|
||||
function canCheckSentMessageAfterRpcTimeout(params: {
|
||||
@@ -880,8 +881,12 @@ export async function sendMessageIMessage(
|
||||
: typeof account.config.mediaMaxMb === "number"
|
||||
? account.config.mediaMaxMb * 1024 * 1024
|
||||
: 16 * 1024 * 1024;
|
||||
let message =
|
||||
text && opts.approvalKind ? appendIMessageApprovalReactionHintForOutboundMessage(text) : text;
|
||||
let message = opts.approvalPrompt
|
||||
? addIMessageApprovalReactionHintToText({
|
||||
text,
|
||||
allowedDecisions: opts.approvalPrompt.allowedDecisions,
|
||||
})
|
||||
: text;
|
||||
const protectedRoles = protectIMessageFencedRoleMarkers(message);
|
||||
message = protectedRoles.text;
|
||||
let filePath: string | undefined;
|
||||
@@ -1113,7 +1118,7 @@ export async function sendMessageIMessage(
|
||||
throw error;
|
||||
} else if (
|
||||
!shouldRecoverApprovalPromptGuid({
|
||||
message,
|
||||
approvalPrompt: opts.approvalPrompt,
|
||||
filePath,
|
||||
replyToId: resolvedReplyToId,
|
||||
}) ||
|
||||
@@ -1155,7 +1160,7 @@ export async function sendMessageIMessage(
|
||||
if (
|
||||
!approvalBindingMessageId &&
|
||||
shouldRecoverApprovalPromptGuid({
|
||||
message,
|
||||
approvalPrompt: opts.approvalPrompt,
|
||||
filePath,
|
||||
replyToId: effectiveReplyToId,
|
||||
})
|
||||
@@ -1196,7 +1201,7 @@ export async function sendMessageIMessage(
|
||||
isFromMe: true,
|
||||
});
|
||||
}
|
||||
if (message && approvalBindingMessageId && opts.approvalKind) {
|
||||
if (message && approvalBindingMessageId && opts.approvalPrompt) {
|
||||
const handleForKey =
|
||||
target.kind === "handle" ? normalizeIMessageHandle(target.to) : undefined;
|
||||
const conversation: IMessageApprovalConversationKey = {
|
||||
@@ -1205,12 +1210,13 @@ export async function sendMessageIMessage(
|
||||
...(target.kind === "chat_id" ? { chatId: target.chatId } : {}),
|
||||
...(handleForKey ? { handle: handleForKey } : {}),
|
||||
};
|
||||
registerIMessageApprovalReactionTargetForOutboundMessage({
|
||||
registerIMessageApprovalReactionTarget({
|
||||
accountId: account.accountId,
|
||||
conversation,
|
||||
messageId: approvalBindingMessageId,
|
||||
text: message,
|
||||
approvalKind: opts.approvalKind,
|
||||
approvalId: opts.approvalPrompt.approvalId,
|
||||
approvalKind: opts.approvalPrompt.approvalKind,
|
||||
allowedDecisions: opts.approvalPrompt.allowedDecisions,
|
||||
});
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
addApprovalReactionHintToText,
|
||||
buildApprovalReactionHint,
|
||||
createApprovalReactionTargetStore,
|
||||
extractApprovalReactionPromptBinding,
|
||||
hasApprovalReactionHintText,
|
||||
listApprovalReactionBindings,
|
||||
resolveTypedApprovalReactionTarget,
|
||||
@@ -369,36 +368,6 @@ export function addSignalApprovalReactionHintToText(params: {
|
||||
return addApprovalReactionHintToText(params);
|
||||
}
|
||||
|
||||
function resolveStandaloneApprovalPromptKind(text: string): ApprovalKind | null {
|
||||
// Strip bold markers (**Exec approval required**) before matching the header.
|
||||
const firstLine = text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/\*\*/g, "").trim())
|
||||
.find(Boolean);
|
||||
if (/^(?:🔒\s*)?Exec approval required$/.test(firstLine ?? "")) {
|
||||
return "exec";
|
||||
}
|
||||
if (/^(?:(?:🛡️|🛡|🚨|ℹ️|ℹ)\s*)?Plugin approval required$/.test(firstLine ?? "")) {
|
||||
return "plugin";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isStandaloneApprovalPromptText(text: string): boolean {
|
||||
return resolveStandaloneApprovalPromptKind(text) !== null;
|
||||
}
|
||||
|
||||
function extractSignalApprovalPromptBinding(text: string): {
|
||||
approvalId: string;
|
||||
approvalKind: ApprovalKind;
|
||||
allowedDecisions: ExecApprovalReplyDecision[];
|
||||
} | null {
|
||||
const approvalKind = resolveStandaloneApprovalPromptKind(text);
|
||||
return approvalKind
|
||||
? extractApprovalReactionPromptBinding({ text, approvalKind, replyInstructionOnly: true })
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildTargetRoute(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
@@ -440,68 +409,6 @@ function buildTargetRoute(params: {
|
||||
: null;
|
||||
}
|
||||
|
||||
function shouldAppendSignalApprovalReactionHintForOutboundMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
text: string;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}): boolean {
|
||||
const binding = extractSignalApprovalPromptBinding(params.text);
|
||||
if (!binding) {
|
||||
return false;
|
||||
}
|
||||
if (resolveSignalApprovalTargetAuthorKeys(params).length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
approvalId: binding.approvalId,
|
||||
approvalKind: binding.approvalKind,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function appendSignalApprovalReactionHintForOutboundMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
to: string;
|
||||
text: string;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
}): string {
|
||||
if (!isStandaloneApprovalPromptText(params.text)) {
|
||||
return params.text;
|
||||
}
|
||||
const binding = extractSignalApprovalPromptBinding(params.text);
|
||||
if (
|
||||
!binding ||
|
||||
!shouldAppendSignalApprovalReactionHintForOutboundMessage({
|
||||
...params,
|
||||
text: params.text,
|
||||
})
|
||||
) {
|
||||
return params.text;
|
||||
}
|
||||
return addSignalApprovalReactionHintToText({
|
||||
text: params.text,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasSignalApprovalReactionApprovers(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
@@ -726,64 +633,6 @@ export function registerSignalApprovalReactionTargetForDeliveredPayload(params:
|
||||
return registered;
|
||||
}
|
||||
|
||||
export function registerSignalApprovalReactionTargetForOutboundMessage(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
to: string;
|
||||
messageId: string;
|
||||
text: string;
|
||||
targetAuthor?: string | null;
|
||||
targetAuthorUuid?: string | null;
|
||||
agentId?: string | null;
|
||||
sessionKey?: string | null;
|
||||
ttlMs?: number;
|
||||
}): boolean {
|
||||
if (!isStandaloneApprovalPromptText(params.text)) {
|
||||
return false;
|
||||
}
|
||||
const binding = extractSignalApprovalPromptBinding(params.text);
|
||||
if (!binding) {
|
||||
return false;
|
||||
}
|
||||
if (!hasSignalApprovalReactionApprovers({ cfg: params.cfg, accountId: params.accountId })) {
|
||||
return false;
|
||||
}
|
||||
const targetAuthorKeys = resolveSignalApprovalTargetAuthorKeys(params);
|
||||
if (targetAuthorKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const conversationKey = resolveSignalApprovalConversationKey(params.to);
|
||||
if (!conversationKey) {
|
||||
return false;
|
||||
}
|
||||
const route = buildTargetRoute({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
to: params.to,
|
||||
approvalId: binding.approvalId,
|
||||
approvalKind: binding.approvalKind,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!route) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
registerSignalApprovalReactionTarget({
|
||||
accountId: params.accountId,
|
||||
conversationKey,
|
||||
messageId: params.messageId,
|
||||
approvalId: binding.approvalId,
|
||||
approvalKind: binding.approvalKind,
|
||||
allowedDecisions: binding.allowedDecisions,
|
||||
targetAuthorKeys,
|
||||
route,
|
||||
routeAllowed: true,
|
||||
ttlMs: params.ttlMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function unregisterSignalApprovalReactionTarget(params: {
|
||||
accountId: string;
|
||||
conversationKey: string;
|
||||
|
||||
@@ -455,162 +455,10 @@ describe("sendMessageSignal receipts", () => {
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("adds reaction approval hints for non-presentation approval payload text", async () => {
|
||||
it("keeps prompt-looking bare text inert", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567896 });
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
accounts: {
|
||||
default: {
|
||||
transport: { kind: "external-native" as const, url: "http://signal.test" },
|
||||
account: "+15550001111",
|
||||
allowFrom: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
plugin: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551234567" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await sendMessageSignal(
|
||||
"+15551234567",
|
||||
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny",
|
||||
{ cfg },
|
||||
);
|
||||
|
||||
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
message: expect.stringContaining("React with:\n\n👍 Allow Once\n👎 Deny"),
|
||||
});
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551234567",
|
||||
messageId: "1234567896",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550001111",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "plugin:abc",
|
||||
approvalKind: "plugin",
|
||||
decision: "allow-once",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps standalone plugin approval prompts on plugin reaction config without a plugin-prefixed id", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567897 });
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
accounts: {
|
||||
default: {
|
||||
transport: { kind: "external-native" as const, url: "http://signal.test" },
|
||||
account: "+15550001111",
|
||||
allowFrom: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
plugin: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551234567" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await sendMessageSignal(
|
||||
"+15551234567",
|
||||
"Plugin approval required\nID: abc\n\nReply with: /approve abc allow-once|deny",
|
||||
{ cfg },
|
||||
);
|
||||
|
||||
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
message: expect.stringContaining("React with:\n\n👍 Allow Once\n👎 Deny"),
|
||||
});
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551234567",
|
||||
messageId: "1234567897",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550001111",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "abc",
|
||||
approvalKind: "plugin",
|
||||
decision: "allow-once",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "exec",
|
||||
approvalKind: "exec",
|
||||
text: "🔒 Exec approval required\nID: exec:abc\n\nReply with: /approve exec:abc allow-once|deny",
|
||||
approvalId: "exec:abc",
|
||||
},
|
||||
{
|
||||
name: "plugin",
|
||||
approvalKind: "plugin",
|
||||
text: "🛡️ Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny",
|
||||
approvalId: "plugin:abc",
|
||||
},
|
||||
])("adds reaction approval hints for icon-prefixed $name approval text", async (testCase) => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567898 });
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
accounts: {
|
||||
default: {
|
||||
transport: { kind: "external-native" as const, url: "http://signal.test" },
|
||||
account: "+15550001111",
|
||||
allowFrom: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
[testCase.approvalKind]: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551234567" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await sendMessageSignal("+15551234567", testCase.text, { cfg });
|
||||
|
||||
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
message: expect.stringContaining("React with:\n\n👍 Allow Once\n👎 Deny"),
|
||||
});
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551234567",
|
||||
messageId: "1234567898",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550001111",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: testCase.approvalId,
|
||||
approvalKind: testCase.approvalKind,
|
||||
decision: "allow-once",
|
||||
});
|
||||
});
|
||||
|
||||
it("binds approval reactions to the canonical prompt id", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567898 });
|
||||
|
||||
const text =
|
||||
"Exec approval required\nID: exec-bare\n\nReply with: /approve exec-bare allow-once|deny";
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
@@ -631,80 +479,19 @@ describe("sendMessageSignal receipts", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const text = [
|
||||
"Exec approval required",
|
||||
"ID: exec-real",
|
||||
"Command: printf '/approve fake allow-once'",
|
||||
"",
|
||||
"Reply with: /approve exec-real allow-once|deny",
|
||||
].join("\n");
|
||||
|
||||
await sendMessageSignal("+15551234567", text, { cfg });
|
||||
|
||||
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
message: expect.stringContaining("React with:\n\n👍 Allow Once\n👎 Deny"),
|
||||
});
|
||||
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toMatchObject({ message: text });
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551234567",
|
||||
messageId: "1234567898",
|
||||
messageId: "1234567896",
|
||||
reactionKey: "👍",
|
||||
targetAuthor: "+15550001111",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "exec-real",
|
||||
approvalKind: "exec",
|
||||
decision: "allow-once",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds reaction approval hints for non-presentation approval text with UUID-only accounts", async () => {
|
||||
signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567899 });
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
signal: {
|
||||
accounts: {
|
||||
default: {
|
||||
transport: { kind: "external-native" as const, url: "http://signal.test" },
|
||||
accountUuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||
allowFrom: ["+15551234567"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
plugin: {
|
||||
enabled: true,
|
||||
mode: "targets" as const,
|
||||
targets: [{ channel: "signal", to: "+15551234567" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await sendMessageSignal(
|
||||
"+15551234567",
|
||||
"Plugin approval required\nID: plugin:abc\n\nReply with: /approve plugin:abc allow-once|deny",
|
||||
{ cfg },
|
||||
);
|
||||
|
||||
expect(signalRpcRequestMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
message: expect.stringContaining("React with:\n\n👍 Allow Once\n👎 Deny"),
|
||||
});
|
||||
await expect(
|
||||
resolveSignalApprovalReactionTargetWithPersistence({
|
||||
accountId: "default",
|
||||
conversationKey: "+15551234567",
|
||||
messageId: "1234567899",
|
||||
reactionKey: "👍",
|
||||
targetAuthorUuid: "123e4567-e89b-12d3-a456-426614174000",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
approvalId: "plugin:abc",
|
||||
approvalKind: "plugin",
|
||||
decision: "allow-once",
|
||||
});
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -19,10 +19,6 @@ import {
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import {
|
||||
appendSignalApprovalReactionHintForOutboundMessage,
|
||||
registerSignalApprovalReactionTargetForOutboundMessage,
|
||||
} from "./approval-reactions.js";
|
||||
import { signalRpcRequest, type SignalTransportKind } from "./client-adapter.js";
|
||||
import { markdownToSignalText, type SignalTextStyleRange } from "./format.js";
|
||||
import { normalizeSignalMessagingTarget } from "./normalize.js";
|
||||
@@ -303,14 +299,7 @@ export async function sendMessageSignal(
|
||||
const target = parseTarget(to);
|
||||
const targetAuthor = normalizeOptionalString(account);
|
||||
const targetAuthorUuid = normalizeOptionalString(accountInfo.config.accountUuid);
|
||||
const outboundText = appendSignalApprovalReactionHintForOutboundMessage({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
to,
|
||||
text: text ?? "",
|
||||
targetAuthor,
|
||||
targetAuthorUuid,
|
||||
});
|
||||
const outboundText = text ?? "";
|
||||
let message = outboundText;
|
||||
let outboundMedia: MediaPlaceholderTextFact | undefined;
|
||||
let textStyles: SignalTextStyleRange[] = [];
|
||||
@@ -426,15 +415,6 @@ export async function sendMessageSignal(
|
||||
sourceTimestamp: timestamp,
|
||||
});
|
||||
}
|
||||
registerSignalApprovalReactionTargetForOutboundMessage({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
to,
|
||||
messageId,
|
||||
text: outboundText,
|
||||
targetAuthor,
|
||||
targetAuthorUuid,
|
||||
});
|
||||
return {
|
||||
messageId,
|
||||
timestamp,
|
||||
|
||||
@@ -43,49 +43,6 @@ export function normalizeApprovalReactionDecision(value: string): ExecApprovalRe
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Read only canonical approval prompts; unrelated `/approve` help must never gain controls. */
|
||||
export function extractApprovalReactionPromptBinding(params: {
|
||||
text: string;
|
||||
approvalKind?: ApprovalKind;
|
||||
replyInstructionOnly?: boolean;
|
||||
}): ApprovalReactionDeliveryBinding | null {
|
||||
const lines = params.text.split(/\r?\n/).map((line) => line.replace(/\*\*/g, ""));
|
||||
let approvalKind = params.approvalKind;
|
||||
if (!approvalKind) {
|
||||
const exec = lines.some((line) => /^\s*[^A-Za-z0-9]*Exec approval required\s*$/i.test(line));
|
||||
const plugin = lines.some((line) =>
|
||||
/^\s*[^A-Za-z0-9]*Plugin approval required\s*$/i.test(line),
|
||||
);
|
||||
if (exec === plugin) {
|
||||
return null;
|
||||
}
|
||||
approvalKind = plugin ? "plugin" : "exec";
|
||||
}
|
||||
const approvalId = lines
|
||||
.map((line) => line.match(/^\s*ID:\s*([A-Za-z0-9][A-Za-z0-9._:-]*)\s*$/i))
|
||||
.find(Boolean)?.[1];
|
||||
if (!approvalId) {
|
||||
return null;
|
||||
}
|
||||
const commandPattern = params.replyInstructionOnly
|
||||
? /^\s*Reply with:\s*\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(.+)$/i
|
||||
: /\/approve(?:@[^\s]+)?\s+([A-Za-z0-9][A-Za-z0-9._:-]*)\s+(.+)$/i;
|
||||
const allowedDecisions: ExecApprovalReplyDecision[] = [];
|
||||
for (const line of lines) {
|
||||
const match = line.match(commandPattern);
|
||||
if (match?.[1] !== approvalId || !match[2]) {
|
||||
continue;
|
||||
}
|
||||
for (const token of match[2].split(/[\s|,]+/)) {
|
||||
const decision = normalizeApprovalReactionDecision(token);
|
||||
if (decision && !allowedDecisions.includes(decision)) {
|
||||
allowedDecisions.push(decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
return allowedDecisions.length ? { approvalId, approvalKind, allowedDecisions } : null;
|
||||
}
|
||||
|
||||
/** Compare approved decision sets independently of presentation order. */
|
||||
export function approvalReactionDecisionSetsMatch(
|
||||
left: readonly ExecApprovalReplyDecision[],
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
buildApprovalReactionPromptPayloadForRequest,
|
||||
buildApprovalReactionHint,
|
||||
createApprovalReactionTargetStore,
|
||||
extractApprovalReactionPromptBinding,
|
||||
listApprovalReactionBindings,
|
||||
normalizeApprovalReactionEmoji,
|
||||
readApprovalReactionDecisionList,
|
||||
@@ -91,32 +90,6 @@ describe("plugin-sdk/approval-reaction-runtime", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("extracts only canonical approval prompts and preserves strict reply-only channels", () => {
|
||||
const text = [
|
||||
"**Plugin approval required**",
|
||||
"**ID:** plugin:approval-123",
|
||||
"Allow Once: /approve plugin:approval-123 allow-once",
|
||||
"Reply with: /approve plugin:approval-123 deny|always",
|
||||
].join("\n");
|
||||
expect(extractApprovalReactionPromptBinding({ text })).toEqual({
|
||||
approvalId: "plugin:approval-123",
|
||||
approvalKind: "plugin",
|
||||
allowedDecisions: ["allow-once", "deny", "allow-always"],
|
||||
});
|
||||
expect(
|
||||
extractApprovalReactionPromptBinding({
|
||||
text,
|
||||
approvalKind: "plugin",
|
||||
replyInstructionOnly: true,
|
||||
}),
|
||||
).toMatchObject({ allowedDecisions: ["deny", "allow-always"] });
|
||||
expect(
|
||||
extractApprovalReactionPromptBinding({
|
||||
text: "Helpful example:\n/approve plugin:approval-123 allow-once",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("fails closed when typed approval presentation or delivery marker disagrees", () => {
|
||||
const metadata = {
|
||||
approvalId: "plugin:approval-123",
|
||||
|
||||
@@ -23,7 +23,6 @@ import type { ReplyPayload } from "./reply-payload.js";
|
||||
export { shouldSuppressLocalNativeExecApprovalPrompt } from "./approval-native-helpers.js";
|
||||
export {
|
||||
approvalReactionDecisionSetsMatch,
|
||||
extractApprovalReactionPromptBinding,
|
||||
normalizeApprovalReactionDecision,
|
||||
readApprovalReactionDecisionList,
|
||||
readApprovalReactionDeliveredBinding,
|
||||
|
||||
Reference in New Issue
Block a user