Skip to content

Commit 2356938

Browse files
OpenClaw BotCopilot
andcommitted
Signal: add status reactions
Co-authored-by: Copilot <[email protected]>
1 parent d7f211b commit 2356938

2 files changed

Lines changed: 412 additions & 82 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
2+
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
const { dispatchInboundMessageMock, dispatchResult, sendReactionSignalMock, sendTypingMock } =
6+
vi.hoisted(() => {
7+
const result = { queuedFinal: true };
8+
return {
9+
dispatchInboundMessageMock: vi.fn(
10+
async (params: {
11+
ctx: MsgContext;
12+
replyOptions?: {
13+
onReplyStart?: () => void | Promise<void>;
14+
onToolStart?: (payload: { name?: string }) => void | Promise<void>;
15+
};
16+
}) => {
17+
await params.replyOptions?.onReplyStart?.();
18+
await params.replyOptions?.onToolStart?.({ name: "read_file" });
19+
return { ...result, counts: { tool: result.queuedFinal ? 1 : 0, block: 0, final: 0 } };
20+
},
21+
),
22+
dispatchResult: result,
23+
sendReactionSignalMock: vi.fn().mockResolvedValue({ ok: true }),
24+
sendTypingMock: vi.fn().mockResolvedValue(true),
25+
};
26+
});
27+
28+
vi.mock("../send-reactions.js", () => ({
29+
removeReactionSignal: vi.fn(),
30+
sendReactionSignal: sendReactionSignalMock,
31+
}));
32+
33+
vi.mock("../send.js", () => ({
34+
sendMessageSignal: vi.fn(),
35+
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
36+
sendTypingSignal: sendTypingMock,
37+
}));
38+
39+
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
40+
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
41+
"openclaw/plugin-sdk/reply-runtime",
42+
);
43+
return {
44+
...actual,
45+
dispatchInboundMessage: dispatchInboundMessageMock,
46+
};
47+
});
48+
49+
const [
50+
{ createBaseSignalEventHandlerDeps, createSignalReceiveEvent },
51+
{ createSignalEventHandler },
52+
] = await Promise.all([import("./event-handler.test-harness.js"), import("./event-handler.js")]);
53+
54+
function createStatusReactionConfig(
55+
overrides: {
56+
ackReactionScope?: NonNullable<OpenClawConfig["messages"]>["ackReactionScope"];
57+
requireMention?: boolean;
58+
} = {},
59+
): OpenClawConfig {
60+
return {
61+
messages: {
62+
ackReaction: "👀",
63+
ackReactionScope: overrides.ackReactionScope ?? "all",
64+
groupChat: {
65+
mentionPatterns: ["@bot"],
66+
},
67+
inbound: { debounceMs: 0 },
68+
statusReactions: {
69+
enabled: true,
70+
emojis: {
71+
done: "✅",
72+
},
73+
timing: {
74+
debounceMs: 60_000,
75+
stallSoftMs: 600_000,
76+
stallHardMs: 600_000,
77+
},
78+
},
79+
},
80+
channels: {
81+
signal: {
82+
dmPolicy: "open",
83+
allowFrom: ["*"],
84+
groups: {
85+
"*": {
86+
requireMention: overrides.requireMention ?? false,
87+
},
88+
},
89+
},
90+
},
91+
} as unknown as OpenClawConfig;
92+
}
93+
94+
describe("signal status reactions", () => {
95+
beforeEach(() => {
96+
dispatchResult.queuedFinal = true;
97+
dispatchInboundMessageMock.mockClear();
98+
sendReactionSignalMock.mockClear();
99+
sendTypingMock.mockClear();
100+
});
101+
102+
it("reacts to direct inbound messages with queued and done status", async () => {
103+
const handler = createSignalEventHandler(
104+
createBaseSignalEventHandlerDeps({
105+
cfg: createStatusReactionConfig(),
106+
historyLimit: 0,
107+
}),
108+
);
109+
110+
await handler(
111+
createSignalReceiveEvent({
112+
sourceNumber: "+15550002222",
113+
timestamp: 1700000001234,
114+
dataMessage: {
115+
message: "hello",
116+
attachments: [],
117+
},
118+
}),
119+
);
120+
121+
await vi.waitFor(() => {
122+
expect(sendReactionSignalMock).toHaveBeenCalledTimes(2);
123+
});
124+
expect(sendReactionSignalMock).toHaveBeenNthCalledWith(
125+
1,
126+
"+15550002222",
127+
1700000001234,
128+
"👀",
129+
expect.objectContaining({
130+
accountId: "default",
131+
targetAuthor: "+15550002222",
132+
}),
133+
);
134+
expect(sendReactionSignalMock).toHaveBeenNthCalledWith(
135+
2,
136+
"+15550002222",
137+
1700000001234,
138+
"✅",
139+
expect.objectContaining({
140+
accountId: "default",
141+
targetAuthor: "+15550002222",
142+
}),
143+
);
144+
});
145+
146+
it("routes group status reactions through groupId and targetAuthor", async () => {
147+
const handler = createSignalEventHandler(
148+
createBaseSignalEventHandlerDeps({
149+
cfg: createStatusReactionConfig({
150+
ackReactionScope: "group-mentions",
151+
requireMention: true,
152+
}),
153+
historyLimit: 0,
154+
}),
155+
);
156+
157+
await handler(
158+
createSignalReceiveEvent({
159+
sourceNumber: "+15550003333",
160+
timestamp: 1700000005678,
161+
dataMessage: {
162+
message: "hello @bot",
163+
attachments: [],
164+
groupInfo: { groupId: "group-1", groupName: "Test Group" },
165+
},
166+
}),
167+
);
168+
169+
await vi.waitFor(() => {
170+
expect(sendReactionSignalMock).toHaveBeenCalledTimes(2);
171+
});
172+
expect(sendReactionSignalMock).toHaveBeenNthCalledWith(
173+
1,
174+
"",
175+
1700000005678,
176+
"👀",
177+
expect.objectContaining({
178+
groupId: "group-1",
179+
targetAuthor: "+15550003333",
180+
}),
181+
);
182+
expect(sendReactionSignalMock).toHaveBeenNthCalledWith(
183+
2,
184+
"",
185+
1700000005678,
186+
"✅",
187+
expect.objectContaining({
188+
groupId: "group-1",
189+
targetAuthor: "+15550003333",
190+
}),
191+
);
192+
});
193+
194+
it("marks the message done when dispatch succeeds without a final reply", async () => {
195+
dispatchResult.queuedFinal = false;
196+
const handler = createSignalEventHandler(
197+
createBaseSignalEventHandlerDeps({
198+
cfg: createStatusReactionConfig(),
199+
historyLimit: 0,
200+
}),
201+
);
202+
203+
await handler(
204+
createSignalReceiveEvent({
205+
sourceNumber: "+15550004444",
206+
timestamp: 1700000009012,
207+
dataMessage: {
208+
message: "hello",
209+
attachments: [],
210+
},
211+
}),
212+
);
213+
214+
await vi.waitFor(() => {
215+
expect(sendReactionSignalMock).toHaveBeenCalled();
216+
});
217+
await Promise.resolve();
218+
const emojis = sendReactionSignalMock.mock.calls.map((call) => call[2]);
219+
expect(emojis).toContain("👀");
220+
expect(emojis).toContain("✅");
221+
});
222+
});

0 commit comments

Comments
 (0)