Skip to content

Commit d508693

Browse files
author
NehoraiHadad
committed
Add WhatsApp group listen windows
1 parent 6589788 commit d508693

7 files changed

Lines changed: 308 additions & 3 deletions

File tree

docs/channels/group-messages.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Goal: let OpenClaw sit in WhatsApp groups, wake up only when pinged, and keep th
1919
## Behavior
2020

2121
- Activation modes: `mention` (default) or `always`. `mention` requires a ping: a real WhatsApp @-mention (`mentionedJids`), a configured regex pattern, the bot's E.164 digits anywhere in the text, or a quoted reply to one of the bot's messages (except shared-number self-chat setups). `always` wakes the agent on every message, but the injected group prompt tells it to reply only when it adds value and to return the exact silent token `NO_REPLY` (case-insensitive) otherwise. Defaults come from config (`channels.whatsapp.groups` `requireMention`) and can be overridden per group via `/activation`.
22+
- Temporary listen windows: groups that normally require mentions can opt into `listenAfterMentionMs`. After a valid mention, follow-up messages in the same allowed group are admitted until the window expires; each admitted follow-up extends the window up to `listenAfterMentionMaxMs`. `/activation mention` clears the current window.
2223
- Group allowlist: when `channels.whatsapp.groups` is set, only listed group JIDs are admitted (include `"*"` to allow all); messages from unlisted groups are dropped with a log hint.
2324
- Group policy: `channels.whatsapp.groupPolicy` controls whether group messages are accepted (`open|disabled|allowlist`). `allowlist` uses `channels.whatsapp.groupAllowFrom` (fallback: explicit `channels.whatsapp.allowFrom`). Default is `allowlist` (blocked until you add senders).
2425
- Per-group sessions: session keys look like `agent:<agentId>:whatsapp:group:<jid>` (non-default accounts append `:thread:whatsapp-account-<accountId>`), so directives such as `/verbose on`, `/trace on`, or `/think high` (sent as standalone messages) are scoped to that group; personal DM state is untouched.
@@ -36,7 +37,11 @@ Make display-name pings work even when WhatsApp strips the visual `@` from the t
3637
channels: {
3738
whatsapp: {
3839
groups: {
39-
"*": { requireMention: true },
40+
"*": {
41+
requireMention: true,
42+
listenAfterMentionMs: 600000, // 10 minutes
43+
listenAfterMentionMaxMs: 1800000, // 30 minutes
44+
},
4045
},
4146
historyLimit: 50, // pending group context window (default 50)
4247
},

extensions/whatsapp/src/auto-reply/monitor/group-gating.audio-preflight.test.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Whatsapp tests cover group gating.audio preflight plugin behavior.
2-
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33

44
vi.mock("./group-activation.js", () => ({
55
resolveGroupActivationFor: vi.fn(async () => "mention"),
@@ -10,6 +10,7 @@ import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
1010
import type { MentionConfig } from "../mentions.js";
1111
import { resolveGroupActivationFor } from "./group-activation.js";
1212
import { applyGroupGating, type GroupHistoryEntry } from "./group-gating.js";
13+
import { clearGroupListenWindowsForTest } from "./group-listen-window.js";
1314

1415
function makeGroupAudioMsg(): AdmittedWebInboundMessage {
1516
return createTestWebAudioInboundMessage({
@@ -67,7 +68,13 @@ describe("applyGroupGating audio preflight mention text", () => {
6768
let groupHistories: Map<string, GroupHistoryEntry[]>;
6869

6970
beforeEach(() => {
71+
clearGroupListenWindowsForTest();
7072
groupHistories = new Map();
73+
vi.useFakeTimers({ now: new Date("2026-07-18T19:00:00.000Z") });
74+
});
75+
76+
afterEach(() => {
77+
vi.useRealTimers();
7178
});
7279

7380
it("defers a missing mention without storing placeholder history", async () => {
@@ -124,4 +131,110 @@ describe("applyGroupGating audio preflight mention text", () => {
124131
},
125132
]);
126133
});
134+
135+
it("accepts follow-up messages during a configured listen-after-mention window", async () => {
136+
const first = makeGroupAudioMsg();
137+
const firstParams = makeParams(first, groupHistories);
138+
(
139+
firstParams.cfg as never as { channels: { whatsapp: { groups?: unknown } } }
140+
).channels.whatsapp.groups = {
141+
142+
requireMention: true,
143+
listenAfterMentionMs: 10 * 60 * 1000,
144+
listenAfterMentionMaxMs: 30 * 60 * 1000,
145+
},
146+
};
147+
148+
await expect(
149+
applyGroupGating({
150+
...firstParams,
151+
mentionText: "openclaw please summarize the thread",
152+
}),
153+
).resolves.toEqual({ shouldProcess: true });
154+
155+
vi.setSystemTime(new Date("2026-07-18T19:05:00.000Z"));
156+
const followUp = makeGroupAudioMsg();
157+
const followUpParams = makeParams(followUp, groupHistories);
158+
(
159+
followUpParams.cfg as never as { channels: { whatsapp: { groups?: unknown } } }
160+
).channels.whatsapp.groups = (
161+
firstParams.cfg as never as { channels: { whatsapp: { groups?: unknown } } }
162+
).channels.whatsapp.groups;
163+
164+
await expect(applyGroupGating(followUpParams)).resolves.toEqual({ shouldProcess: true });
165+
expect(followUp.groupMention).toEqual({ wasMentioned: false, requireMention: false });
166+
});
167+
168+
it("stops extending the listen-after-mention window at the configured cap", async () => {
169+
const first = makeGroupAudioMsg();
170+
const firstParams = makeParams(first, groupHistories);
171+
(
172+
firstParams.cfg as never as { channels: { whatsapp: { groups?: unknown } } }
173+
).channels.whatsapp.groups = {
174+
"*": {
175+
listenAfterMentionMs: 10 * 60 * 1000,
176+
listenAfterMentionMaxMs: 15 * 60 * 1000,
177+
},
178+
};
179+
180+
await applyGroupGating({
181+
...firstParams,
182+
mentionText: "openclaw please summarize the thread",
183+
});
184+
185+
vi.setSystemTime(new Date("2026-07-18T19:09:00.000Z"));
186+
const extendingFollowUpParams = makeParams(makeGroupAudioMsg(), groupHistories);
187+
extendingFollowUpParams.cfg = firstParams.cfg;
188+
await expect(applyGroupGating(extendingFollowUpParams)).resolves.toEqual({
189+
shouldProcess: true,
190+
});
191+
192+
vi.setSystemTime(new Date("2026-07-18T19:14:00.000Z"));
193+
const cappedFollowUpParams = makeParams(makeGroupAudioMsg(), groupHistories);
194+
cappedFollowUpParams.cfg = firstParams.cfg;
195+
await expect(applyGroupGating(cappedFollowUpParams)).resolves.toEqual({
196+
shouldProcess: true,
197+
});
198+
199+
vi.setSystemTime(new Date("2026-07-18T19:16:00.000Z"));
200+
const expiredFollowUpParams = makeParams(makeGroupAudioMsg(), groupHistories);
201+
expiredFollowUpParams.cfg = firstParams.cfg;
202+
await expect(applyGroupGating(expiredFollowUpParams)).resolves.toEqual({
203+
shouldProcess: false,
204+
});
205+
});
206+
207+
it("does not re-open a listen-after-mention window from /activation mention", async () => {
208+
const first = makeGroupAudioMsg();
209+
const firstParams = makeParams(first, groupHistories);
210+
(
211+
firstParams.cfg as never as { channels: { whatsapp: { groups?: unknown } } }
212+
).channels.whatsapp.groups = {
213+
"*": {
214+
listenAfterMentionMs: 10 * 60 * 1000,
215+
},
216+
};
217+
(
218+
firstParams.cfg as never as { channels: { whatsapp: { allowFrom?: string[] } } }
219+
).channels.whatsapp.allowFrom = ["+15550000002"];
220+
221+
await applyGroupGating({
222+
...firstParams,
223+
mentionText: "openclaw please summarize the thread",
224+
});
225+
226+
vi.setSystemTime(new Date("2026-07-18T19:05:00.000Z"));
227+
const command = makeGroupAudioMsg();
228+
command.payload.body = "/activation mention";
229+
const commandParams = makeParams(command, groupHistories);
230+
commandParams.cfg = firstParams.cfg;
231+
await expect(applyGroupGating(commandParams)).resolves.toEqual({ shouldProcess: true });
232+
233+
vi.setSystemTime(new Date("2026-07-18T19:06:00.000Z"));
234+
const followUpParams = makeParams(makeGroupAudioMsg(), groupHistories);
235+
followUpParams.cfg = firstParams.cfg;
236+
await expect(applyGroupGating(followUpParams)).resolves.toEqual({
237+
shouldProcess: false,
238+
});
239+
});
127240
});

extensions/whatsapp/src/auto-reply/monitor/group-gating.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ import {
2525
createChannelHistoryWindow,
2626
resolveInboundMentionDecision,
2727
} from "./group-gating.runtime.js";
28+
import {
29+
armGroupListenWindow,
30+
clearGroupListenWindow,
31+
resolveGroupListenWindowConfig,
32+
resolveGroupListenWindowState,
33+
} from "./group-listen-window.js";
2834
import { noteGroupMember } from "./group-members.js";
2935

3036
export type GroupHistoryEntry = {
@@ -216,7 +222,31 @@ export async function applyGroupGating(params: ApplyGroupGatingParams) {
216222
sessionKey: params.sessionKey,
217223
conversationId,
218224
});
219-
const requireMention = activation !== "always";
225+
const baseRequireMention = activation !== "always";
226+
const listenWindowConfig = baseRequireMention
227+
? resolveGroupListenWindowConfig({
228+
cfg: params.cfg,
229+
accountId: inboundPolicy.account.accountId,
230+
conversationId,
231+
})
232+
: undefined;
233+
if (owner && activationCommand.hasCommand && activationCommand.mode === "mention") {
234+
clearGroupListenWindow({
235+
agentId: params.agentId,
236+
accountId: inboundPolicy.account.accountId,
237+
sessionKey: params.sessionKey,
238+
conversationId,
239+
});
240+
}
241+
const listenWindowActive =
242+
listenWindowConfig !== undefined &&
243+
resolveGroupListenWindowState({
244+
agentId: params.agentId,
245+
accountId: inboundPolicy.account.accountId,
246+
sessionKey: params.sessionKey,
247+
conversationId,
248+
}) !== undefined;
249+
const requireMention = baseRequireMention && !listenWindowActive;
220250
const replyContext = getReplyContext(params.msg, params.authDir);
221251
const sharedNumberSelfChat = params.selfChatMode === true;
222252
// Detect reply-to-bot: compare JIDs, LIDs, and E.164 numbers.
@@ -245,6 +275,19 @@ export async function applyGroupGating(params: ApplyGroupGatingParams) {
245275
},
246276
});
247277
const effectiveWasMentioned = mentionDecision.effectiveWasMentioned || shouldBypassMention;
278+
if (
279+
listenWindowConfig &&
280+
!activationCommand.hasCommand &&
281+
(effectiveWasMentioned || listenWindowActive)
282+
) {
283+
armGroupListenWindow({
284+
agentId: params.agentId,
285+
accountId: inboundPolicy.account.accountId,
286+
sessionKey: params.sessionKey,
287+
conversationId,
288+
config: listenWindowConfig,
289+
});
290+
}
248291
// Carry the session activation and mention result together. Dispatch needs
249292
// both facts to distinguish an always-on group from a blocked unmentioned turn.
250293
params.msg.groupMention = { wasMentioned: effectiveWasMentioned, requireMention };
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Whatsapp plugin module implements temporary group listen windows.
2+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
3+
import { resolveWhatsAppAccount } from "../../accounts.js";
4+
import { resolveWhatsAppGroupConversationId } from "../../inbound/group-conversation.js";
5+
6+
type ListenWindowConfig = {
7+
durationMs: number;
8+
maxMs: number;
9+
};
10+
11+
type ListenWindowState = {
12+
startedAtMs: number;
13+
untilMs: number;
14+
};
15+
16+
const listenWindows = new Map<string, ListenWindowState>();
17+
18+
function normalizeMilliseconds(value: unknown): number | undefined {
19+
return typeof value === "number" && Number.isFinite(value) && value >= 0
20+
? Math.floor(value)
21+
: undefined;
22+
}
23+
24+
function resolveGroupListenWindowKey(params: {
25+
agentId: string;
26+
accountId?: string | null;
27+
sessionKey: string;
28+
conversationId: string;
29+
}) {
30+
return [
31+
params.agentId,
32+
params.accountId?.trim() || "default",
33+
params.sessionKey,
34+
resolveWhatsAppGroupConversationId(params.conversationId),
35+
].join("\u0000");
36+
}
37+
38+
export function resolveGroupListenWindowConfig(params: {
39+
cfg: OpenClawConfig;
40+
accountId?: string | null;
41+
conversationId: string;
42+
}): ListenWindowConfig | undefined {
43+
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId });
44+
const conversationId = resolveWhatsAppGroupConversationId(params.conversationId);
45+
const group = account.groups?.[conversationId] ?? account.groups?.[params.conversationId];
46+
const wildcardGroup = account.groups?.["*"];
47+
const durationMs = normalizeMilliseconds(
48+
group?.listenAfterMentionMs ?? wildcardGroup?.listenAfterMentionMs,
49+
);
50+
if (durationMs === undefined || durationMs <= 0) {
51+
return undefined;
52+
}
53+
const configuredMaxMs = normalizeMilliseconds(
54+
group?.listenAfterMentionMaxMs ?? wildcardGroup?.listenAfterMentionMaxMs,
55+
);
56+
return {
57+
durationMs,
58+
maxMs:
59+
configuredMaxMs && configuredMaxMs > 0 ? Math.max(durationMs, configuredMaxMs) : durationMs,
60+
};
61+
}
62+
63+
export function resolveGroupListenWindowState(params: {
64+
agentId: string;
65+
accountId?: string | null;
66+
sessionKey: string;
67+
conversationId: string;
68+
nowMs?: number;
69+
}): ListenWindowState | undefined {
70+
const key = resolveGroupListenWindowKey(params);
71+
const state = listenWindows.get(key);
72+
if (!state) {
73+
return undefined;
74+
}
75+
const nowMs = params.nowMs ?? Date.now();
76+
if (state.untilMs <= nowMs) {
77+
listenWindows.delete(key);
78+
return undefined;
79+
}
80+
return state;
81+
}
82+
83+
export function armGroupListenWindow(params: {
84+
agentId: string;
85+
accountId?: string | null;
86+
sessionKey: string;
87+
conversationId: string;
88+
config: ListenWindowConfig;
89+
nowMs?: number;
90+
}): ListenWindowState {
91+
const nowMs = params.nowMs ?? Date.now();
92+
const key = resolveGroupListenWindowKey(params);
93+
const existing = listenWindows.get(key);
94+
const startedAtMs = existing && existing.untilMs > nowMs ? existing.startedAtMs : nowMs;
95+
const untilMs = Math.min(nowMs + params.config.durationMs, startedAtMs + params.config.maxMs);
96+
const state = { startedAtMs, untilMs };
97+
listenWindows.set(key, state);
98+
return state;
99+
}
100+
101+
export function clearGroupListenWindow(params: {
102+
agentId: string;
103+
accountId?: string | null;
104+
sessionKey: string;
105+
conversationId: string;
106+
}) {
107+
listenWindows.delete(resolveGroupListenWindowKey(params));
108+
}
109+
110+
export function clearGroupListenWindowsForTest() {
111+
listenWindows.clear();
112+
}

extensions/whatsapp/src/config-schema.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,32 @@ describe("whatsapp config schema", () => {
5353
}
5454
});
5555

56+
it("accepts listen-after-mention windows per group", () => {
57+
const res = expectWhatsAppConfigValid({
58+
groups: {
59+
60+
requireMention: true,
61+
listenAfterMentionMs: 600_000,
62+
listenAfterMentionMaxMs: 1_800_000,
63+
},
64+
},
65+
accounts: {
66+
work: {
67+
groups: {
68+
"*": {
69+
listenAfterMentionMs: 300_000,
70+
},
71+
},
72+
},
73+
},
74+
});
75+
76+
if (res.success) {
77+
expect(res.data.groups?.["[email protected]"]?.listenAfterMentionMs).toBe(600_000);
78+
expect(res.data.accounts?.work?.groups?.["*"]?.listenAfterMentionMs).toBe(300_000);
79+
}
80+
});
81+
5682
it("accepts textChunkLimit", () => {
5783
const res = expectWhatsAppConfigValid({
5884
allowFrom: ["+15555550123"],

src/config/types.whatsapp.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export type WhatsAppReactionLevel = ReactionLevel;
2727

2828
export type WhatsAppGroupConfig = {
2929
requireMention?: boolean;
30+
/** Temporarily accept follow-up messages after a mention in this group (ms). Default: disabled. */
31+
listenAfterMentionMs?: number;
32+
/** Maximum total duration for one listen-after-mention window (ms). */
33+
listenAfterMentionMaxMs?: number;
3034
tools?: GroupToolPolicyConfig;
3135
toolsBySender?: GroupToolPolicyBySenderConfig;
3236
/** Optional system prompt for this group. */

src/config/zod-schema.providers-whatsapp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const ToolPolicyBySenderSchema = z.record(z.string(), ToolPolicySchema).optional
2323
const WhatsAppGroupEntrySchema = z
2424
.object({
2525
requireMention: z.boolean().optional(),
26+
listenAfterMentionMs: z.number().int().nonnegative().optional(),
27+
listenAfterMentionMaxMs: z.number().int().nonnegative().optional(),
2628
tools: ToolPolicySchema,
2729
toolsBySender: ToolPolicyBySenderSchema,
2830
systemPrompt: z.string().optional(),

0 commit comments

Comments
 (0)