mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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,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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user