fix(whatsapp): warn once when group inbound dropped for missing channels.whatsapp.groups entry (#83833)

Merged via squash.

Prepared head SHA: 8fc5243210
Co-authored-by: zhang-liz <13132583+zhang-liz@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
This commit is contained in:
Liz Zhang
2026-05-25 21:15:24 -07:00
committed by GitHub
parent e752f9bca1
commit 72a7d6a8dc
6 changed files with 345 additions and 54 deletions
@@ -0,0 +1,208 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./group-activation.js", () => ({
resolveGroupActivationFor: vi.fn(async () => "mention"),
}));
import type { MentionConfig } from "../mentions.js";
import type { WebInboundMsg } from "../types.js";
import {
resetGroupDropWarningsForTests,
applyGroupGating,
type GroupHistoryEntry,
} from "./group-gating.js";
function makeUnregisteredGroupMsg(
conversationId: string,
accountId: string = "default",
): WebInboundMsg {
return {
id: `msg-${conversationId}`,
from: conversationId,
to: "+15550000001",
body: "@openclaw hello",
chatId: conversationId,
chatType: "group",
conversationId,
timestamp: 1700000000,
accountId,
sender: { e164: "+15550000002", name: "Alice" },
} as WebInboundMsg;
}
type WarnLogger = (obj: unknown, msg: string) => void;
type ApplyGroupGatingParams = Parameters<typeof applyGroupGating>[0];
function makeParams(
msg: WebInboundMsg,
warn: WarnLogger,
cfg: ApplyGroupGatingParams["cfg"] = {
channels: {
whatsapp: {
groupPolicy: "allowlist",
groups: {
"registered@g.us": {},
},
accounts: {
work: {
groupPolicy: "allowlist",
groups: {
"registered@g.us": {},
},
},
},
},
},
messages: {
groupChat: {
mentionPatterns: ["\\bopenclaw\\b"],
},
},
} as never,
) {
return {
cfg,
msg,
conversationId: msg.conversationId,
groupHistoryKey: `whatsapp:group:${msg.conversationId}`,
agentId: "main",
sessionKey: `agent:main:whatsapp:group:${msg.conversationId}`,
baseMentionConfig: { mentionRegexes: [/\bopenclaw\b/i] } satisfies MentionConfig,
groupHistories: new Map<string, GroupHistoryEntry[]>(),
groupHistoryLimit: 20,
groupMemberNames: new Map<string, Map<string, string>>(),
logVerbose: vi.fn(),
replyLogger: { debug: vi.fn(), warn },
};
}
describe("applyGroupGating allowlist drop warning", () => {
beforeEach(() => {
resetGroupDropWarningsForTests();
});
it("emits a warn log naming the root groups path for the default account", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("unregistered@g.us");
const params = makeParams(msg, warn);
const result = await applyGroupGating(params);
expect(result).toEqual({ shouldProcess: false });
expect(warn).toHaveBeenCalledTimes(1);
expect(params.logVerbose).toHaveBeenCalledWith(
'Dropping message from unregistered WhatsApp group unregistered@g.us. Add the group JID to channels.whatsapp.groups, or add "*" there to admit all groups. Sender authorization still applies.',
);
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({
conversationId: "unregistered@g.us",
accountId: "default",
groupsPath: "channels.whatsapp.groups",
});
expect(message).toContain("unregistered@g.us");
expect(message).toContain("channels.whatsapp.groups");
});
it("names the account-scoped groups path for non-default accounts", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("unregistered@g.us", "work");
await applyGroupGating(makeParams(msg, warn));
expect(warn).toHaveBeenCalledTimes(1);
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({
conversationId: "unregistered@g.us",
accountId: "work",
groupsPath: "channels.whatsapp.accounts.work.groups",
});
expect(message).toContain("channels.whatsapp.accounts.work.groups");
});
it("names the root groups path for non-default accounts inheriting root groups", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("unregistered@g.us", "work");
const cfg = {
channels: {
whatsapp: {
groupPolicy: "allowlist",
groups: {
"registered@g.us": {},
},
accounts: {
work: {
groupPolicy: "allowlist",
},
},
},
},
messages: {
groupChat: {
mentionPatterns: ["\\bopenclaw\\b"],
},
},
} as ApplyGroupGatingParams["cfg"];
await applyGroupGating(makeParams(msg, warn, cfg));
expect(warn).toHaveBeenCalledTimes(1);
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({
conversationId: "unregistered@g.us",
accountId: "work",
groupsPath: "channels.whatsapp.groups",
});
expect(message).toContain("channels.whatsapp.groups");
});
it("warns once but keeps verbose diagnostics per dropped message", async () => {
const warn = vi.fn<WarnLogger>();
const first = makeParams(makeUnregisteredGroupMsg("loud@g.us"), warn);
const second = makeParams(makeUnregisteredGroupMsg("loud@g.us"), warn);
const third = makeParams(makeUnregisteredGroupMsg("loud@g.us"), warn);
await applyGroupGating(first);
await applyGroupGating(second);
await applyGroupGating(third);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[1]).toContain("loud@g.us");
expect(first.logVerbose).toHaveBeenCalledTimes(1);
expect(second.logVerbose).toHaveBeenCalledTimes(1);
expect(third.logVerbose).toHaveBeenCalledTimes(1);
});
it("warns separately for distinct conversations", async () => {
const warn = vi.fn<WarnLogger>();
await applyGroupGating(makeParams(makeUnregisteredGroupMsg("a@g.us"), warn));
await applyGroupGating(makeParams(makeUnregisteredGroupMsg("b@g.us"), warn));
expect(warn).toHaveBeenCalledTimes(2);
expect(warn.mock.calls[0]?.[1]).toContain("a@g.us");
expect(warn.mock.calls[1]?.[1]).toContain("b@g.us");
});
it("evicts old warning keys instead of growing without bound", async () => {
const warn = vi.fn<WarnLogger>();
await applyGroupGating(makeParams(makeUnregisteredGroupMsg("evicted@g.us"), warn));
for (let index = 0; index < 100; index += 1) {
await applyGroupGating(makeParams(makeUnregisteredGroupMsg(`overflow-${index}@g.us`), warn));
}
await applyGroupGating(makeParams(makeUnregisteredGroupMsg("evicted@g.us"), warn));
expect(warn).toHaveBeenCalledTimes(102);
expect(warn.mock.calls[0]?.[1]).toContain("evicted@g.us");
expect(warn.mock.calls[101]?.[1]).toContain("evicted@g.us");
});
it("does not warn when the group is registered", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("registered@g.us");
await applyGroupGating(makeParams(msg, warn));
expect(warn).not.toHaveBeenCalled();
});
});
@@ -49,7 +49,7 @@ function makeParams(msg: WebInboundMsg, groupHistories: Map<string, GroupHistory
groupHistoryLimit: 20,
groupMemberNames: new Map<string, Map<string, string>>(),
logVerbose: vi.fn(),
replyLogger: { debug: vi.fn() },
replyLogger: { debug: vi.fn(), warn: vi.fn() },
};
}
@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveWhatsAppGroupsConfigPath } from "../../group-config-path.js";
import {
getPrimaryIdentityId,
getReplyContext,
@@ -46,9 +47,34 @@ type ApplyGroupGatingParams = {
groupMemberNames: Map<string, Map<string, string>>;
selfChatMode?: boolean;
logVerbose: (msg: string) => void;
replyLogger: { debug: (obj: unknown, msg: string) => void };
replyLogger: {
debug: (obj: unknown, msg: string) => void;
warn: (obj: unknown, msg: string) => void;
};
};
const MAX_GROUP_DROP_WARNINGS = 100;
const groupDropWarned = new Set<string>();
export function resetGroupDropWarningsForTests() {
groupDropWarned.clear();
}
function shouldWarnForGroupDrop(warnKey: string): boolean {
if (groupDropWarned.has(warnKey)) {
return false;
}
groupDropWarned.add(warnKey);
while (groupDropWarned.size > MAX_GROUP_DROP_WARNINGS) {
const oldest = groupDropWarned.values().next().value;
if (!oldest) {
break;
}
groupDropWarned.delete(oldest);
}
return true;
}
function isOwnerSender(baseMentionConfig: MentionConfig, msg: WebInboundMsg) {
const sender = normalizeE164(getSenderIdentity(msg).e164 ?? "");
if (!sender) {
@@ -114,6 +140,15 @@ export async function applyGroupGating(params: ApplyGroupGatingParams) {
params.conversationId,
);
if (conversationGroupPolicy.allowlistEnabled && !conversationGroupPolicy.allowed) {
const accountId = inboundPolicy.account.accountId;
const warnKey = `${accountId}:${params.conversationId}`;
if (shouldWarnForGroupDrop(warnKey)) {
const groupsPath = resolveWhatsAppGroupsConfigPath({ cfg: params.cfg, accountId });
params.replyLogger.warn(
{ conversationId: params.conversationId, accountId, groupsPath },
`WhatsApp group ${params.conversationId} not in ${groupsPath} — inbound dropped. Add the group JID to ${groupsPath} (or add "*" there to admit all groups). Sender authorization still applies.`,
);
}
params.logVerbose(
`Dropping message from unregistered WhatsApp group ${params.conversationId}. Add the group JID to channels.whatsapp.groups, or add "*" there to admit all groups. Sender authorization still applies.`,
);
@@ -75,7 +75,7 @@ async function runGroupGating(params: {
groupHistoryLimit: 10,
groupMemberNames: new Map(),
logVerbose: (message) => verboseLogs.push(message),
replyLogger: { debug: () => {} },
replyLogger: { debug: () => {}, warn: () => {} },
});
return { result, groupHistories, verboseLogs };
}
@@ -0,0 +1,98 @@
import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "openclaw/plugin-sdk/account-core";
const WHATSAPP_GROUP_SCOPE_FIELDS = ["groupPolicy", "groupAllowFrom", "groups"] as const;
type WhatsAppGroupScopeField = (typeof WHATSAPP_GROUP_SCOPE_FIELDS)[number];
function resolveWhatsAppAccountKey(
accounts: Record<string, unknown> | undefined,
accountId: string,
): string | undefined {
if (!accounts) {
return undefined;
}
if (Object.hasOwn(accounts, accountId)) {
return accountId;
}
const normalizedAccountId = accountId.trim().toLowerCase();
return Object.keys(accounts).find((key) => key.trim().toLowerCase() === normalizedAccountId);
}
function normalizePathAccountId(accountId?: string | null): string {
return typeof accountId === "string"
? accountId.trim() || DEFAULT_ACCOUNT_ID
: DEFAULT_ACCOUNT_ID;
}
function hasConfiguredField(config: unknown, field: WhatsAppGroupScopeField): boolean {
return Boolean(
config &&
typeof config === "object" &&
Object.hasOwn(config as Record<string, unknown>, field) &&
(config as Record<string, unknown>)[field] !== undefined,
);
}
function resolveSpecificFieldBasePath(params: {
cfg: OpenClawConfig;
accountId?: string | null;
field: WhatsAppGroupScopeField;
}): string | undefined {
const accountId = normalizePathAccountId(params.accountId);
const whatsapp = params.cfg.channels?.whatsapp;
const accounts = whatsapp?.accounts as Record<string, unknown> | undefined;
const accountKey = resolveWhatsAppAccountKey(accounts, accountId);
const defaultAccountKey = resolveWhatsAppAccountKey(accounts, DEFAULT_ACCOUNT_ID);
const accountConfig = accountKey ? accounts?.[accountKey] : undefined;
const defaultAccountConfig = defaultAccountKey ? accounts?.[defaultAccountKey] : undefined;
if (hasConfiguredField(accountConfig, params.field)) {
return `channels.whatsapp.accounts.${accountKey}`;
}
if (accountId !== DEFAULT_ACCOUNT_ID && hasConfiguredField(defaultAccountConfig, params.field)) {
return `channels.whatsapp.accounts.${defaultAccountKey}`;
}
if (hasConfiguredField(whatsapp, params.field)) {
return "channels.whatsapp";
}
return undefined;
}
function resolveWhatsAppGroupScopeBasePath(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string {
const accountId = normalizePathAccountId(params.accountId);
const whatsapp = params.cfg.channels?.whatsapp;
const accounts = whatsapp?.accounts as Record<string, unknown> | undefined;
const accountKey = resolveWhatsAppAccountKey(accounts, accountId);
const defaultAccountKey = resolveWhatsAppAccountKey(accounts, DEFAULT_ACCOUNT_ID);
const accountConfig = accountKey ? accounts?.[accountKey] : undefined;
const defaultAccountConfig = defaultAccountKey ? accounts?.[defaultAccountKey] : undefined;
const matchesAnyGroupScopeField = (config: unknown): boolean =>
WHATSAPP_GROUP_SCOPE_FIELDS.some((field) => hasConfiguredField(config, field));
if (matchesAnyGroupScopeField(accountConfig)) {
return `channels.whatsapp.accounts.${accountKey}`;
}
if (accountId !== DEFAULT_ACCOUNT_ID && matchesAnyGroupScopeField(defaultAccountConfig)) {
return `channels.whatsapp.accounts.${defaultAccountKey}`;
}
return "channels.whatsapp";
}
export function resolveWhatsAppConfigPath(params: {
cfg: OpenClawConfig;
accountId?: string | null;
field: WhatsAppGroupScopeField;
}): string {
return `${resolveWhatsAppGroupScopeBasePath(params)}.${params.field}`;
}
export function resolveWhatsAppGroupsConfigPath(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string {
return `${
resolveSpecificFieldBasePath({ ...params, field: "groups" }) ??
resolveWhatsAppGroupScopeBasePath(params)
}.groups`;
}
+1 -51
View File
@@ -1,4 +1,3 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-core";
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
import {
@@ -26,6 +25,7 @@ import {
import { formatWhatsAppConfigAllowFromEntries } from "./config-accessors.js";
import { WhatsAppChannelConfigSchema } from "./config-schema.js";
import { whatsappDoctor } from "./doctor.js";
import { resolveWhatsAppConfigPath } from "./group-config-path.js";
import { resolveLegacyGroupSessionKey } from "./group-session-contract.js";
import {
collectUnsupportedSecretRefConfigCandidates,
@@ -40,56 +40,6 @@ import {
const WHATSAPP_CHANNEL = "whatsapp" as const;
const WHATSAPP_GROUP_SCOPE_FIELDS = ["groupPolicy", "groupAllowFrom", "groups"] as const;
type WhatsAppGroupScopeField = (typeof WHATSAPP_GROUP_SCOPE_FIELDS)[number];
function resolveWhatsAppAccountKey(
accounts: Record<string, unknown> | undefined,
accountId: string,
): string | undefined {
if (!accounts) {
return undefined;
}
if (Object.hasOwn(accounts, accountId)) {
return accountId;
}
const normalizedAccountId = accountId.trim().toLowerCase();
return Object.keys(accounts).find((key) => key.trim().toLowerCase() === normalizedAccountId);
}
function resolveWhatsAppGroupScopeBasePath(params: {
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
accountId?: string | null;
}): string {
const accountId =
typeof params.accountId === "string"
? params.accountId.trim() || DEFAULT_ACCOUNT_ID
: DEFAULT_ACCOUNT_ID;
const accounts = params.cfg.channels?.whatsapp?.accounts;
const accountKey = resolveWhatsAppAccountKey(accounts, accountId);
const defaultAccountKey = resolveWhatsAppAccountKey(accounts, DEFAULT_ACCOUNT_ID);
const accountConfig = accountKey ? accounts?.[accountKey] : undefined;
const defaultAccountConfig = defaultAccountKey ? accounts?.[defaultAccountKey] : undefined;
const matchesAnyGroupScopeField = (config: Record<string, unknown> | undefined): boolean =>
WHATSAPP_GROUP_SCOPE_FIELDS.some((field) => config?.[field] !== undefined);
if (matchesAnyGroupScopeField(accountConfig)) {
return `channels.whatsapp.accounts.${accountKey}`;
}
if (accountId !== DEFAULT_ACCOUNT_ID && matchesAnyGroupScopeField(defaultAccountConfig)) {
return `channels.whatsapp.accounts.${defaultAccountKey}`;
}
return "channels.whatsapp";
}
function resolveWhatsAppConfigPath(params: {
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
accountId?: string | null;
field: WhatsAppGroupScopeField;
}): string {
return `${resolveWhatsAppGroupScopeBasePath(params)}.${params.field}`;
}
export async function loadWhatsAppChannelRuntime() {
return await import("./channel.runtime.js");
}