Skip to content

Commit 2031784

Browse files
committed
fix(signal): allow defaultTo approval reactions
1 parent b177009 commit 2031784

4 files changed

Lines changed: 132 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ Docs: https://docs.openclaw.ai
1313
- Migrate: import supported Hermes, OpenCode, and Codex auth credentials into OpenClaw auth profiles when credential migration is selected, with explicit opt-out and non-interactive controls. (#85667) Thanks @fuller-stack-dev.
1414
- iOS: improve Talk mode with direct realtime voice sessions, compact toolbar status, and responsive voice waveform feedback. (#86355) Thanks @ngutman.
1515
- Media: replace the Sharp image backend with Photon for metadata, resizing, EXIF orientation, and PNG alpha-preserving optimization so OpenClaw no longer installs Sharp or the WhatsApp Jimp fallback for image processing. (#86437)
16-
- Signal: support native exec and plugin approval prompts with 👍 allow-once and 👎 deny reactions, while keeping `allow-always` on the `/approve` command path.
1716

1817
### Fixes
1918

@@ -87,7 +86,6 @@ Docs: https://docs.openclaw.ai
8786
- Gateway/perf: fail startup benchmark samples when the Gateway process exits before benchmark teardown, including signal deaths after readiness probes.
8887
- Gateway/perf: fail restart benchmark samples when the Gateway exits before benchmark teardown, including clean exits and signal deaths after successful restart probes.
8988
- Agents/tests: keep model catalog visibility on static selection helpers so catalog visibility checks avoid the broad model-selection barrel import.
90-
- Signal: suppress duplicate direct exec approval fallback messages after native approval delivery succeeds.
9189
- Agents/commitments: serialize commitment store load-modify-save writes so concurrent heartbeat and CLI updates no longer lose dismissal, sent, or attempt state. (#81153) Thanks @ai-hpc.
9290
- xAI/LM Studio: promote plain-text tool-call fallbacks into structured tool calls and strip leaked internal tool syntax before user-facing delivery. (#86222) Thanks @fuller-stack-dev.
9391
- CLI: suppress benign self-update version-skew warnings during package post-update finalization.

extensions/signal/src/approval-reactions.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,64 @@ describe("Signal approval reactions", () => {
287287
});
288288
});
289289

290+
it("authorizes reactions using Signal defaultTo approvers", async () => {
291+
registerSignalApprovalReactionTarget({
292+
accountId: "default",
293+
conversationKey: "+15551230000",
294+
messageId: "1700000000008",
295+
approvalId: "exec-default-to",
296+
allowedDecisions: ["allow-once"],
297+
targetAuthorKeys: ["+15550009999"],
298+
route: approvalRoute,
299+
routeAllowed: true,
300+
});
301+
302+
const handled = await maybeResolveSignalApprovalReaction({
303+
cfg: {
304+
channels: {
305+
signal: {
306+
allowFrom: [],
307+
defaultTo: "+15551230000",
308+
},
309+
},
310+
approvals: {
311+
exec: {
312+
enabled: true,
313+
mode: "session",
314+
},
315+
},
316+
},
317+
accountId: "default",
318+
conversationKey: "+15551230000",
319+
messageId: "1700000000008",
320+
reactionKey: "👍",
321+
actorId: "+15551230000",
322+
targetAuthor: "+15550009999",
323+
});
324+
325+
expect(handled).toBe(true);
326+
expect(resolverMocks.resolveSignalApproval).toHaveBeenCalledWith({
327+
cfg: {
328+
channels: {
329+
signal: {
330+
allowFrom: [],
331+
defaultTo: "+15551230000",
332+
},
333+
},
334+
approvals: {
335+
exec: {
336+
enabled: true,
337+
mode: "session",
338+
},
339+
},
340+
},
341+
approvalId: "exec-default-to",
342+
decision: "allow-once",
343+
senderId: "+15551230000",
344+
gatewayUrl: undefined,
345+
});
346+
});
347+
290348
it("requires explicit approvers for approval reactions", async () => {
291349
registerSignalApprovalReactionTarget({
292350
accountId: "default",

extensions/signal/src/monitor/event-handler.inbound-context.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ const {
3636
};
3737
});
3838

39+
const approvalReactionMocks = vi.hoisted(() => ({
40+
maybeResolveSignalApprovalReaction: vi.fn(async () => false),
41+
}));
42+
3943
vi.mock("../send.js", () => ({
4044
sendMessageSignal: vi.fn(),
4145
sendTypingSignal: sendTypingMock,
@@ -76,6 +80,16 @@ vi.mock("openclaw/plugin-sdk/system-event-runtime", async () => {
7680
};
7781
});
7882

83+
vi.mock("../approval-reactions.js", async () => {
84+
const actual = await vi.importActual<typeof import("../approval-reactions.js")>(
85+
"../approval-reactions.js",
86+
);
87+
return {
88+
...actual,
89+
maybeResolveSignalApprovalReaction: approvalReactionMocks.maybeResolveSignalApprovalReaction,
90+
};
91+
});
92+
7993
function requireCapturedContext(): MsgContext {
8094
if (!capture.ctx) {
8195
throw new Error("expected inbound MsgContext");
@@ -91,6 +105,7 @@ describe("signal createSignalEventHandler inbound context", () => {
91105
enqueueSystemEventMock.mockReset();
92106
recordInboundSessionMock.mockReset().mockResolvedValue(undefined);
93107
dispatchInboundMessageMock.mockClear();
108+
approvalReactionMocks.maybeResolveSignalApprovalReaction.mockReset().mockResolvedValue(false);
94109
});
95110

96111
it("passes a finalized MsgContext to dispatchInboundMessage", async () => {
@@ -521,6 +536,59 @@ describe("signal createSignalEventHandler inbound context", () => {
521536
});
522537
});
523538

539+
it("checks approval reactions before dropping defaultTo-only senders at the generic access gate", async () => {
540+
approvalReactionMocks.maybeResolveSignalApprovalReaction.mockResolvedValueOnce(true);
541+
const cfg = {
542+
messages: { inbound: { debounceMs: 0 } },
543+
channels: {
544+
signal: {
545+
dmPolicy: "allowlist",
546+
allowFrom: [],
547+
defaultTo: "+15550001111",
548+
},
549+
},
550+
};
551+
const handler = createSignalEventHandler(
552+
createBaseSignalEventHandlerDeps({
553+
cfg: cfg as any,
554+
dmPolicy: "allowlist",
555+
allowFrom: [],
556+
reactionMode: "all",
557+
isSignalReactionMessage: (reaction): reaction is SignalReactionMessage => Boolean(reaction),
558+
shouldEmitSignalReactionNotification: () => true,
559+
resolveSignalReactionTargets: () => [
560+
{ kind: "phone", id: "+15550001111", display: "+15550001111" },
561+
],
562+
buildSignalReactionSystemEventText: () => "reaction added",
563+
historyLimit: 0,
564+
}),
565+
);
566+
567+
await handler(
568+
createSignalReceiveEvent({
569+
reactionMessage: {
570+
emoji: "👍",
571+
targetAuthor: "+15550009999",
572+
targetSentTimestamp: 1700000000000,
573+
},
574+
}),
575+
);
576+
577+
expect(approvalReactionMocks.maybeResolveSignalApprovalReaction).toHaveBeenCalledWith(
578+
expect.objectContaining({
579+
cfg,
580+
accountId: "default",
581+
conversationKey: "+15550001111",
582+
messageId: "1700000000000",
583+
reactionKey: "👍",
584+
actorId: "+15550001111",
585+
targetAuthor: "+15550009999",
586+
}),
587+
);
588+
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
589+
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
590+
});
591+
524592
it("drops quote-only group context from non-allowlisted quoted senders in allowlist mode", async () => {
525593
const handler = createSignalEventHandler(
526594
createBaseSignalEventHandlerDeps({

extensions/signal/src/monitor/event-handler.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -442,12 +442,6 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
442442
const groupId = params.reaction.groupInfo?.groupId ?? undefined;
443443
const groupName = params.reaction.groupInfo?.groupName ?? undefined;
444444
const isGroup = Boolean(groupId);
445-
if (params.accessDecision.decision !== "allow") {
446-
logVerbose(
447-
`Blocked signal reaction sender ${params.senderDisplay} (${params.accessDecision.reasonCode})`,
448-
);
449-
return true;
450-
}
451445
const messageId = params.reaction.targetSentTimestamp
452446
? String(params.reaction.targetSentTimestamp)
453447
: "unknown";
@@ -470,6 +464,12 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
470464
) {
471465
return true;
472466
}
467+
if (params.accessDecision.decision !== "allow") {
468+
logVerbose(
469+
`Blocked signal reaction sender ${params.senderDisplay} (${params.accessDecision.reasonCode})`,
470+
);
471+
return true;
472+
}
473473
const targets = deps.resolveSignalReactionTargets(params.reaction);
474474
const shouldNotify = deps.shouldEmitSignalReactionNotification({
475475
mode: deps.reactionMode,

0 commit comments

Comments
 (0)