Skip to content

Commit 4e7638d

Browse files
committed
fix: suppress only recent whatsapp group echoes (#53624) (thanks @w-sss)
1 parent df41757 commit 4e7638d

File tree

6 files changed

+173
-83
lines changed

6 files changed

+173
-83
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
3030
- Telegram/forum topics: recover `#General` topic `1` routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo
3131
- Discord/config types: add missing `autoArchiveDuration` to `DiscordGuildChannelConfig` so TypeScript config definitions match the existing schema and runtime support. (#43427) Thanks @davidguttman.
3232
- Feishu/startup: treat unresolved `SecretRef` app credentials as not configured during account resolution so CLI startup and read-only Feishu config surfaces stop crashing before runtime-backed secret resolution is available. (#53675) Thanks @hpt.
33+
- WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner `/status`, `/new`, and `/activation` commands from linked-account `fromMe` traffic. (#53624) Thanks @w-sss.
3334

3435
## 2026.3.23
3536

extensions/whatsapp/src/inbound/access-control.test.ts

Lines changed: 0 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -162,68 +162,3 @@ describe("WhatsApp dmPolicy precedence", () => {
162162
expect(sendMessageMock).not.toHaveBeenCalled();
163163
});
164164
});
165-
166-
describe("WhatsApp group fromMe filtering", () => {
167-
beforeEach(async () => {
168-
vi.resetModules();
169-
({ checkInboundAccessControl } = await import("./access-control.js"));
170-
});
171-
172-
it("blocks fromMe messages in groups to prevent infinite loops (#53386)", async () => {
173-
setAccessControlTestConfig({
174-
channels: {
175-
whatsapp: {
176-
accounts: {
177-
default: {
178-
groupPolicy: "open",
179-
},
180-
},
181-
},
182-
},
183-
});
184-
185-
const result = await checkInboundAccessControl({
186-
accountId: "default",
187-
188-
selfE164: "+15550009999",
189-
senderE164: "+15550009999",
190-
group: true,
191-
pushName: "Owner",
192-
isFromMe: true,
193-
sock: { sendMessage: sendMessageMock },
194-
remoteJid: "[email protected]",
195-
});
196-
197-
expect(result.allowed).toBe(false);
198-
expect(result.shouldMarkRead).toBe(false);
199-
});
200-
201-
it("allows fromMe=false messages in groups", async () => {
202-
setAccessControlTestConfig({
203-
channels: {
204-
whatsapp: {
205-
accounts: {
206-
default: {
207-
groupPolicy: "open",
208-
},
209-
},
210-
},
211-
},
212-
});
213-
214-
const result = await checkInboundAccessControl({
215-
accountId: "default",
216-
217-
selfE164: "+15550009999",
218-
senderE164: "+15550001111",
219-
group: true,
220-
pushName: "Other User",
221-
isFromMe: false,
222-
sock: { sendMessage: sendMessageMock },
223-
remoteJid: "[email protected]",
224-
});
225-
226-
expect(result.allowed).toBe(true);
227-
expect(result.shouldMarkRead).toBe(true);
228-
});
229-
});

extensions/whatsapp/src/inbound/access-control.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -83,19 +83,6 @@ export async function checkInboundAccessControl(params: {
8383
typeof params.messageTimestampMs === "number" &&
8484
params.messageTimestampMs < params.connectedAtMs - pairingGraceMs;
8585

86-
// Filter fromMe messages in groups to prevent infinite loops
87-
// When an agent sends a message to a group, WhatsApp echoes it back.
88-
// Without this filter, the agent would respond to its own message.
89-
if (params.group && params.isFromMe) {
90-
logVerbose("Skipping fromMe group message (prevents infinite loop)");
91-
return {
92-
allowed: false,
93-
shouldMarkRead: false,
94-
isSelfChat,
95-
resolvedAccountId: account.accountId,
96-
};
97-
}
98-
9986
// Group policy filtering:
10087
// - "open": groups bypass allowFrom, only mention-gating applies
10188
// - "disabled": block all group messages entirely

extensions/whatsapp/src/inbound/dedupe.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,61 @@ import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime";
22

33
const RECENT_WEB_MESSAGE_TTL_MS = 20 * 60_000;
44
const RECENT_WEB_MESSAGE_MAX = 5000;
5+
const RECENT_OUTBOUND_MESSAGE_TTL_MS = 20 * 60_000;
6+
const RECENT_OUTBOUND_MESSAGE_MAX = 5000;
57

68
const recentInboundMessages = createDedupeCache({
79
ttlMs: RECENT_WEB_MESSAGE_TTL_MS,
810
maxSize: RECENT_WEB_MESSAGE_MAX,
911
});
12+
const recentOutboundMessages = createDedupeCache({
13+
ttlMs: RECENT_OUTBOUND_MESSAGE_TTL_MS,
14+
maxSize: RECENT_OUTBOUND_MESSAGE_MAX,
15+
});
16+
17+
function buildMessageKey(params: {
18+
accountId: string;
19+
remoteJid: string;
20+
messageId: string;
21+
}): string | null {
22+
const accountId = params.accountId.trim();
23+
const remoteJid = params.remoteJid.trim();
24+
const messageId = params.messageId.trim();
25+
if (!accountId || !remoteJid || !messageId || messageId === "unknown") {
26+
return null;
27+
}
28+
return `${accountId}:${remoteJid}:${messageId}`;
29+
}
1030

1131
export function resetWebInboundDedupe(): void {
1232
recentInboundMessages.clear();
33+
recentOutboundMessages.clear();
1334
}
1435

1536
export function isRecentInboundMessage(key: string): boolean {
1637
return recentInboundMessages.check(key);
1738
}
39+
40+
export function rememberRecentOutboundMessage(params: {
41+
accountId: string;
42+
remoteJid: string;
43+
messageId: string;
44+
}): void {
45+
const key = buildMessageKey(params);
46+
if (!key) {
47+
return;
48+
}
49+
recentOutboundMessages.check(key);
50+
}
51+
52+
export function isRecentOutboundMessage(params: {
53+
accountId: string;
54+
remoteJid: string;
55+
messageId: string;
56+
}): boolean {
57+
const key = buildMessageKey(params);
58+
if (!key) {
59+
return false;
60+
}
61+
return recentOutboundMessages.peek(key);
62+
}

extensions/whatsapp/src/inbound/monitor.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { getChildLogger } from "openclaw/plugin-sdk/text-runtime";
99
import { jidToE164, resolveJidToE164 } from "openclaw/plugin-sdk/text-runtime";
1010
import { createWaSocket, getStatusCode, waitForWaConnection } from "../session.js";
1111
import { checkInboundAccessControl } from "./access-control.js";
12-
import { isRecentInboundMessage } from "./dedupe.js";
12+
import {
13+
isRecentInboundMessage,
14+
isRecentOutboundMessage,
15+
rememberRecentOutboundMessage,
16+
} from "./dedupe.js";
1317
import {
1418
describeReplyContext,
1519
extractLocationData,
@@ -128,6 +132,27 @@ export async function monitorWebInbox(options: {
128132
const resolveInboundJid = async (jid: string | null | undefined): Promise<string | null> =>
129133
resolveJidToE164(jid, { authDir: options.authDir, lidLookup });
130134

135+
const rememberOutboundMessage = (remoteJid: string, result: unknown) => {
136+
const messageId =
137+
typeof result === "object" && result && "key" in result
138+
? String((result as { key?: { id?: string } }).key?.id ?? "")
139+
: "";
140+
if (!messageId) {
141+
return;
142+
}
143+
rememberRecentOutboundMessage({
144+
accountId: options.accountId,
145+
remoteJid,
146+
messageId,
147+
});
148+
};
149+
150+
const sendTrackedMessage = async (jid: string, content: AnyMessageContent) => {
151+
const result = await sock.sendMessage(jid, content);
152+
rememberOutboundMessage(jid, result);
153+
return result;
154+
};
155+
131156
const getGroupMeta = async (jid: string) => {
132157
const cached = groupMetaCache.get(jid);
133158
if (cached && cached.expires > Date.now()) {
@@ -183,6 +208,19 @@ export async function monitorWebInbox(options: {
183208
}
184209

185210
const group = isGroupJid(remoteJid);
211+
if (
212+
group &&
213+
Boolean(msg.key?.fromMe) &&
214+
id &&
215+
isRecentOutboundMessage({
216+
accountId: options.accountId,
217+
remoteJid,
218+
messageId: id,
219+
})
220+
) {
221+
logVerbose(`Skipping recent outbound WhatsApp group echo ${id} for ${remoteJid}`);
222+
return null;
223+
}
186224
if (id) {
187225
const dedupeKey = `${options.accountId}:${remoteJid}:${id}`;
188226
if (isRecentInboundMessage(dedupeKey)) {
@@ -221,7 +259,7 @@ export async function monitorWebInbox(options: {
221259
isFromMe: Boolean(msg.key?.fromMe),
222260
messageTimestampMs,
223261
connectedAtMs,
224-
sock: { sendMessage: (jid, content) => sock.sendMessage(jid, content) },
262+
sock: { sendMessage: (jid, content) => sendTrackedMessage(jid, content) },
225263
remoteJid,
226264
});
227265
if (!access.allowed) {
@@ -334,10 +372,10 @@ export async function monitorWebInbox(options: {
334372
}
335373
};
336374
const reply = async (text: string) => {
337-
await sock.sendMessage(chatJid, { text });
375+
await sendTrackedMessage(chatJid, { text });
338376
};
339377
const sendMedia = async (payload: AnyMessageContent) => {
340-
await sock.sendMessage(chatJid, payload);
378+
await sendTrackedMessage(chatJid, payload);
341379
};
342380
const timestamp = inbound.messageTimestampMs;
343381
const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined);
@@ -474,7 +512,7 @@ export async function monitorWebInbox(options: {
474512

475513
const sendApi = createWebSendApi({
476514
sock: {
477-
sendMessage: (jid: string, content: AnyMessageContent) => sock.sendMessage(jid, content),
515+
sendMessage: (jid: string, content: AnyMessageContent) => sendTrackedMessage(jid, content),
478516
sendPresenceUpdate: (presence, jid?: string) => sock.sendPresenceUpdate(presence, jid),
479517
},
480518
defaultAccountId: options.accountId,

extensions/whatsapp/src/monitor-inbox.allows-messages-from-senders-allowfrom-list.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,90 @@ describe("web monitor inbox", () => {
214214
});
215215
});
216216

217+
it("allows owner fromMe group commands when they were not sent by the gateway", async () => {
218+
mockLoadConfig.mockReturnValue({
219+
channels: {
220+
whatsapp: {
221+
groupPolicy: "open",
222+
allowFrom: ["+123"],
223+
},
224+
},
225+
messages: DEFAULT_MESSAGES_CFG,
226+
});
227+
228+
const { onMessage, listener, sock } = await openInboxMonitor();
229+
230+
sock.ev.emit("messages.upsert", {
231+
type: "notify",
232+
messages: [
233+
{
234+
key: {
235+
id: "owner-group-1",
236+
fromMe: true,
237+
remoteJid: "[email protected]",
238+
participant: "[email protected]",
239+
},
240+
message: { conversation: "/status" },
241+
messageTimestamp: nowSeconds(),
242+
pushName: "Owner",
243+
},
244+
],
245+
});
246+
await waitForMessageCalls(onMessage, 1);
247+
248+
expect(onMessage).toHaveBeenCalledWith(
249+
expect.objectContaining({
250+
body: "/status",
251+
chatType: "group",
252+
253+
fromMe: true,
254+
senderE164: "+123",
255+
}),
256+
);
257+
258+
await listener.close();
259+
});
260+
261+
it("filters group fromMe echoes only when the gateway sent the matching message id", async () => {
262+
mockLoadConfig.mockReturnValue({
263+
channels: {
264+
whatsapp: {
265+
groupPolicy: "open",
266+
allowFrom: ["+123"],
267+
},
268+
},
269+
messages: DEFAULT_MESSAGES_CFG,
270+
});
271+
272+
const onMessage = vi.fn();
273+
const { listener, sock } = await startInboxMonitor(onMessage);
274+
275+
sock.sendMessage.mockResolvedValueOnce({ key: { id: "bot-group-echo-1" } });
276+
await listener.sendMessage("[email protected]", "gateway echo candidate");
277+
278+
sock.ev.emit("messages.upsert", {
279+
type: "notify",
280+
messages: [
281+
{
282+
key: {
283+
id: "bot-group-echo-1",
284+
fromMe: true,
285+
remoteJid: "[email protected]",
286+
participant: "[email protected]",
287+
},
288+
message: { conversation: "gateway echo candidate" },
289+
messageTimestamp: nowSeconds(),
290+
pushName: "Owner",
291+
},
292+
],
293+
});
294+
await settleInboundWork();
295+
296+
expect(onMessage).not.toHaveBeenCalled();
297+
298+
await listener.close();
299+
});
300+
217301
it("handles append messages by marking them read but skipping auto-reply", async () => {
218302
const { onMessage, listener, sock } = await openInboxMonitor();
219303
const staleTs = Math.floor(Date.now() / 1000) - 300;

0 commit comments

Comments
 (0)