fix(whatsapp): isolate multi-account inbound state and align shared defaults (#65700)

* refactor(whatsapp): centralize inbound policy resolution

* fix(whatsapp): scope named-account group session keys

* fix(whatsapp): preserve legacy group activation during scoped-key migration

* fix(whatsapp): wire shared defaults through accounts.default

* fix(whatsapp): align schema, helpers, and monitor behavior

* fix(whatsapp): restore verbose inbound diagnostics

* chore(config): refresh whatsapp changelog and baseline hashes
This commit is contained in:
Marcus Castro
2026-04-18 01:37:38 -03:00
committed by GitHub
parent 996eb9a024
commit 458a52610a
47 changed files with 2160 additions and 391 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/bootstrap: resolve bootstrap from workspace truth instead of stale session transcript markers, keep embedded bootstrap instructions on a hidden user-context prelude, suppress normal `/new` and `/reset` greetings while `BOOTSTRAP.md` is still pending, and make the embedded runner read the bootstrap ritual before replying normally.
- WhatsApp/multi-account: centralize named-account inbound policy, isolate per-account group activation and scoped session keys, preserve legacy activation backfill, and keep `accounts.default` shared defaults aligned across runtime, setup, and compat migration paths. Thanks @mcaxtr.
- Onboarding/non-interactive: preserve existing gateway auth tokens during re-onboard so active local gateway clients are not disconnected by an implicit token rotation. (#67821) Thanks @BKF-Gitty.
- Gateway/hello-ok: always report negotiated auth metadata for successful shared-auth handshakes, including control-ui bypass coverage when no device token is issued. (#67810) Thanks @BunsDev.
- OpenAI Codex/Responses: unify native Responses API capability detection so Codex OAuth requests emit the required `store: false` field on the native Responses path. (#67918) Thanks @obviyus.

View File

@@ -1,5 +1,6 @@
import {
DEFAULT_ACCOUNT_ID,
mergeAccountConfig,
resolveAccountEntry,
resolveMergedAccountConfig,
type OpenClawConfig,
@@ -10,6 +11,23 @@ import {
} from "openclaw/plugin-sdk/channel-streaming";
import type { WhatsAppAccountConfig } from "./account-types.js";
function resolveWhatsAppDefaultAccountSharedConfig(
cfg: OpenClawConfig,
): Partial<WhatsAppAccountConfig> | undefined {
const defaultAccount = resolveAccountEntry(cfg.channels?.whatsapp?.accounts, DEFAULT_ACCOUNT_ID);
if (!defaultAccount) {
return undefined;
}
const {
enabled: _ignoredEnabled,
name: _ignoredName,
authDir: _ignoredAuthDir,
selfChatMode: _ignoredSelfChatMode,
...sharedDefaults
} = defaultAccount;
return sharedDefaults;
}
function _resolveWhatsAppAccountConfig(
cfg: OpenClawConfig,
accountId: string,
@@ -17,18 +35,39 @@ function _resolveWhatsAppAccountConfig(
return resolveAccountEntry(cfg.channels?.whatsapp?.accounts, accountId);
}
function resolveMergedNamedWhatsAppAccountConfig(params: {
cfg: OpenClawConfig;
accountId: string;
}): WhatsAppAccountConfig {
const rootCfg = params.cfg.channels?.whatsapp;
const accountConfig = _resolveWhatsAppAccountConfig(params.cfg, params.accountId);
return {
...mergeAccountConfig<WhatsAppAccountConfig>({
channelConfig: rootCfg as WhatsAppAccountConfig | undefined,
accountConfig: undefined,
omitKeys: ["defaultAccount"],
}),
...resolveWhatsAppDefaultAccountSharedConfig(params.cfg),
...accountConfig,
};
}
export function resolveMergedWhatsAppAccountConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): WhatsAppAccountConfig & { accountId: string } {
const rootCfg = params.cfg.channels?.whatsapp;
const accountId = params.accountId?.trim() || rootCfg?.defaultAccount || DEFAULT_ACCOUNT_ID;
const merged = resolveMergedAccountConfig<WhatsAppAccountConfig>({
const base = resolveMergedAccountConfig<WhatsAppAccountConfig>({
channelConfig: rootCfg as WhatsAppAccountConfig | undefined,
accounts: rootCfg?.accounts as Record<string, Partial<WhatsAppAccountConfig>> | undefined,
accountId,
omitKeys: ["defaultAccount"],
});
const merged =
accountId === DEFAULT_ACCOUNT_ID
? base
: resolveMergedNamedWhatsAppAccountConfig({ cfg: params.cfg, accountId });
return {
accountId,
...merged,

View File

@@ -71,4 +71,112 @@ describe("resolveWhatsAppAuthDir", () => {
expect(resolved.messagePrefix).toBe("[root]");
expect(resolved.debounceMs).toBe(250);
});
it("inherits shared defaults from accounts.default for named accounts", () => {
const resolved = resolveWhatsAppAccount({
cfg: {
channels: {
whatsapp: {
accounts: {
default: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
groupPolicy: "open",
groupAllowFrom: ["+15550002222"],
defaultTo: "+15550003333",
reactionLevel: "extensive",
historyLimit: 42,
mediaMaxMb: 12,
},
work: {
authDir: "/tmp/work",
},
},
},
},
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
accountId: "work",
});
expect(resolved.dmPolicy).toBe("allowlist");
expect(resolved.allowFrom).toEqual(["+15550001111"]);
expect(resolved.groupPolicy).toBe("open");
expect(resolved.groupAllowFrom).toEqual(["+15550002222"]);
expect(resolved.defaultTo).toBe("+15550003333");
expect(resolved.reactionLevel).toBe("extensive");
expect(resolved.historyLimit).toBe(42);
expect(resolved.mediaMaxMb).toBe(12);
});
it("prefers account overrides and accounts.default over root defaults", () => {
const resolved = resolveWhatsAppAccount({
cfg: {
channels: {
whatsapp: {
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "disabled",
accounts: {
default: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
groupPolicy: "open",
},
work: {
authDir: "/tmp/work",
dmPolicy: "pairing",
},
},
},
},
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
accountId: "work",
});
expect(resolved.dmPolicy).toBe("pairing");
expect(resolved.allowFrom).toEqual(["+15550001111"]);
expect(resolved.groupPolicy).toBe("open");
});
it("does not inherit default-account authDir for named accounts", () => {
const resolved = resolveWhatsAppAccount({
cfg: {
channels: {
whatsapp: {
accounts: {
default: {
authDir: "/tmp/default-auth",
name: "Personal",
},
work: {},
},
},
},
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
accountId: "work",
});
expect(resolved.authDir).toMatch(/whatsapp[/\\]work$/);
expect(resolved.name).toBeUndefined();
});
it("does not inherit default-account selfChatMode for named accounts", () => {
const resolved = resolveWhatsAppAccount({
cfg: {
channels: {
whatsapp: {
accounts: {
default: {
selfChatMode: true,
},
work: {},
},
},
},
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
accountId: "work",
});
expect(resolved.selfChatMode).toBeUndefined();
});
});

View File

@@ -28,6 +28,7 @@ export type ResolvedWhatsAppAccount = {
groupAllowFrom?: string[];
groupPolicy?: GroupPolicy;
dmPolicy?: DmPolicy;
historyLimit?: number;
textChunkLimit?: number;
chunkMode?: "length" | "newline";
mediaMaxMb?: number;
@@ -141,6 +142,7 @@ export function resolveWhatsAppAccount(params: {
allowFrom: merged.allowFrom,
groupAllowFrom: merged.groupAllowFrom,
groupPolicy: merged.groupPolicy,
historyLimit: merged.historyLimit,
textChunkLimit: merged.textChunkLimit,
chunkMode: merged.chunkMode,
mediaMaxMb: merged.mediaMaxMb,

View File

@@ -138,6 +138,57 @@ describe("broadcast groups", () => {
resetLoadConfigMock();
});
it("keeps named-account group broadcast routes on the scoped session key", async () => {
setLoadConfigMock({
channels: {
whatsapp: {
allowFrom: ["*"],
accounts: {
work: {
allowFrom: ["*"],
},
},
},
},
agents: {
defaults: { maxConcurrent: 10 },
list: [{ id: "alfred" }, { id: "baerbel" }],
},
broadcast: {
strategy: "sequential",
"123@g.us": ["alfred", "baerbel"],
},
} satisfies OpenClawConfig);
const seen: string[] = [];
const resolver = vi.fn(async (ctx: { SessionKey?: unknown }) => {
seen.push(String(ctx.SessionKey));
return { text: "ok" };
});
const { spies, onMessage } = await monitorWebChannelWithCapture(resolver);
await sendWebGroupInboundMessage({
onMessage,
spies,
body: "@bot ping",
id: "g-work-1",
senderE164: "+111",
senderName: "Alice",
mentionedJids: ["999@s.whatsapp.net"],
selfE164: "+999",
selfJid: "999@s.whatsapp.net",
accountId: "work",
});
expect(resolver).toHaveBeenCalledTimes(2);
expect(seen).toEqual([
"agent:alfred:whatsapp:group:123@g.us:thread:whatsapp-account-work",
"agent:baerbel:whatsapp:group:123@g.us:thread:whatsapp-account-work",
]);
resetLoadConfigMock();
});
it("broadcasts in parallel by default", async () => {
setLoadConfigMock({
channels: { whatsapp: { allowFrom: ["*"] } },

View File

@@ -12,7 +12,12 @@ import {
resetLoadConfigMock as _resetLoadConfigMock,
} from "./test-helpers.js";
export { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
export {
resetBaileysMocks,
resetLoadConfigMock,
setLoadConfigMock,
setRuntimeConfigSourceSnapshotMock,
} from "./test-helpers.js";
// Avoid exporting inferred vitest mock types (TS2742 under pnpm + d.ts emit).
type AnyExport = any;
@@ -179,16 +184,27 @@ export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
export function createWebListenerFactoryCapture(): AnyExport {
let capturedOnMessage: ((msg: WebInboundMessage) => Promise<void>) | undefined;
let capturedOptions:
| {
onMessage: (msg: WebInboundMessage) => Promise<void>;
debounceMs?: number;
selfChatMode?: boolean;
}
| undefined;
const listenerFactory = async (opts: {
onMessage: (msg: WebInboundMessage) => Promise<void>;
debounceMs?: number;
selfChatMode?: boolean;
}) => {
capturedOnMessage = opts.onMessage;
capturedOptions = opts;
return { close: vi.fn() };
};
return {
listenerFactory,
getOnMessage: () => capturedOnMessage,
getLastOptions: () => capturedOptions,
};
}

View File

@@ -17,6 +17,7 @@ import {
resetLoadConfigMock,
sendWebDirectInboundMessage,
setLoadConfigMock,
setRuntimeConfigSourceSnapshotMock,
startWebAutoReplyMonitor,
} from "./auto-reply.test-harness.js";
@@ -241,6 +242,109 @@ describe("web auto-reply connection", () => {
}
});
it("passes accounts.default debounceMs into the live listener for named accounts", async () => {
const capture = createWebListenerFactoryCapture();
setLoadConfigMock({
channels: {
whatsapp: {
accounts: {
default: {
debounceMs: 250,
},
work: {
authDir: "/tmp/work",
},
},
},
},
} as OpenClawConfig);
await monitorWebChannel(
false,
capture.listenerFactory as never,
false,
async () => ({ text: "ok" }),
undefined,
undefined,
{
accountId: "work",
},
);
resetLoadConfigMock();
expect(capture.getLastOptions()?.debounceMs).toBe(250);
});
it("matches per-account debounce overrides case-insensitively", async () => {
const capture = createWebListenerFactoryCapture();
setLoadConfigMock({
channels: {
whatsapp: {
accounts: {
work: {
authDir: "/tmp/work",
debounceMs: 250,
},
},
},
},
} as OpenClawConfig);
await monitorWebChannel(
false,
capture.listenerFactory as never,
false,
async () => ({ text: "ok" }),
undefined,
undefined,
{
accountId: "Work",
},
);
resetLoadConfigMock();
expect(capture.getLastOptions()?.debounceMs).toBe(250);
});
it("keeps the global inbound debounce fallback when WhatsApp debounceMs is only the schema default", async () => {
const capture = createWebListenerFactoryCapture();
setLoadConfigMock({
messages: {
inbound: {
debounceMs: 250,
},
},
channels: {
whatsapp: {
accounts: {
work: {
authDir: "/tmp/work",
},
},
},
},
} as OpenClawConfig);
setRuntimeConfigSourceSnapshotMock(null);
await monitorWebChannel(
false,
capture.listenerFactory as never,
false,
async () => ({ text: "ok" }),
undefined,
undefined,
{
accountId: "work",
},
);
resetLoadConfigMock();
expect(capture.getLastOptions()?.debounceMs).toBe(250);
});
it("processes inbound messages without batching and preserves timestamps", async () => {
await withEnvAsync({ TZ: "Europe/Vienna" }, async () => {
const originalMax = process.getMaxListeners();

View File

@@ -1,5 +1,6 @@
export {
evaluateSessionFreshness,
getRuntimeConfigSourceSnapshot,
loadConfig,
loadSessionStore,
recordSessionMetaFromInbound,

View File

@@ -13,6 +13,7 @@ import type { WebInboundMsg } from "./types.js";
export type MentionConfig = {
mentionRegexes: RegExp[];
allowFrom?: Array<string | number>;
isSelfChat?: boolean;
};
export type MentionTargets = {
@@ -43,7 +44,10 @@ export function isBotMentionedFromTargets(
// Remove zero-width and directionality markers WhatsApp injects around display names
normalizeMentionText(text);
const isSelfChat = isSelfChatMode(targets.self.e164, mentionCfg.allowFrom);
const isSelfChat =
typeof mentionCfg.isSelfChat === "boolean"
? mentionCfg.isSelfChat
: isSelfChatMode(targets.self.e164, mentionCfg.allowFrom);
const hasMentions = targets.normalizedMentions.length > 0;
if (hasMentions && !isSelfChat) {

View File

@@ -1,3 +1,4 @@
import { resolveAccountEntry } from "openclaw/plugin-sdk/account-core";
import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/channel-inbound";
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
@@ -26,7 +27,7 @@ import {
sleepWithAbort,
} from "../reconnect.js";
import { formatError, getWebAuthAgeMs, readWebSelfId } from "../session.js";
import { loadConfig } from "./config.runtime.js";
import { getRuntimeConfigSourceSnapshot, loadConfig } from "./config.runtime.js";
import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js";
import { buildMentionConfig } from "./mentions.js";
import { createWebChannelStatusController } from "./monitor-state.js";
@@ -59,6 +60,31 @@ function isNoListenerReconnectError(lastError?: string): boolean {
return typeof lastError === "string" && /No active WhatsApp Web listener/i.test(lastError);
}
function resolveExplicitWhatsAppDebounceOverride(params: {
cfg: ReturnType<typeof loadConfig>;
sourceCfg?: ReturnType<typeof loadConfig> | null;
accountId: string;
}): number | undefined {
const channel = params.sourceCfg?.channels?.whatsapp;
if (!channel) {
return undefined;
}
const accountId = normalizeReconnectAccountId(params.accountId);
const accountDebounce = resolveAccountEntry(channel.accounts, accountId)?.debounceMs;
if (accountDebounce !== undefined) {
return accountDebounce;
}
if (accountId !== "default") {
const defaultAccountDebounce = resolveAccountEntry(channel.accounts, "default")?.debounceMs;
if (defaultAccountDebounce !== undefined) {
return defaultAccountDebounce;
}
}
return channel.debounceMs;
}
export async function monitorWebChannel(
verbose: boolean,
listenerFactory: typeof attachWebInboxToSocket | undefined = attachWebInboxToSocket,
@@ -79,6 +105,7 @@ export async function monitorWebChannel(
statusController.emit();
const baseCfg = loadConfig();
const sourceCfg = getRuntimeConfigSourceSnapshot();
const account = resolveWhatsAppAccount({
cfg: baseCfg,
accountId: tuning.accountId,
@@ -108,7 +135,7 @@ export async function monitorWebChannel(
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
const baseMentionConfig = buildMentionConfig(cfg);
const groupHistoryLimit =
cfg.channels?.whatsapp?.accounts?.[tuning.accountId ?? ""]?.historyLimit ??
account.historyLimit ??
cfg.channels?.whatsapp?.historyLimit ??
cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT;
@@ -166,7 +193,15 @@ export async function monitorWebChannel(
}
const connectionId = newConnectionId();
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "whatsapp" });
const inboundDebounceMs = resolveInboundDebounceMs({
cfg,
channel: "whatsapp",
overrideMs: resolveExplicitWhatsAppDebounceOverride({
cfg,
sourceCfg,
accountId: account.accountId,
}),
});
const shouldDebounce = (msg: WebInboundMsg) => {
if (msg.mediaPath || msg.mediaType) {
return false;

View File

@@ -54,8 +54,8 @@ describe("maybeSendAckReaction", () => {
it.each(["ack", "minimal", "extensive"] as const)(
"sends ack reactions when reactionLevel is %s",
(reactionLevel) => {
maybeSendAckReaction({
async (reactionLevel) => {
await maybeSendAckReaction({
cfg: createConfig(reactionLevel),
msg: createMessage(),
agentId: "agent",
@@ -81,8 +81,8 @@ describe("maybeSendAckReaction", () => {
},
);
it("suppresses ack reactions when reactionLevel is off", () => {
maybeSendAckReaction({
it("suppresses ack reactions when reactionLevel is off", async () => {
await maybeSendAckReaction({
cfg: createConfig("off"),
msg: createMessage(),
agentId: "agent",
@@ -97,8 +97,8 @@ describe("maybeSendAckReaction", () => {
expect(hoisted.sendReactionWhatsApp).not.toHaveBeenCalled();
});
it("uses the active account reactionLevel override for ack gating", () => {
maybeSendAckReaction({
it("uses the active account reactionLevel override for ack gating", async () => {
await maybeSendAckReaction({
cfg: createConfig("off", {
accounts: {
work: {

View File

@@ -8,7 +8,7 @@ import { formatError } from "../../session.js";
import type { WebInboundMsg } from "../types.js";
import { resolveGroupActivationFor } from "./group-activation.js";
export function maybeSendAckReaction(params: {
export async function maybeSendAckReaction(params: {
cfg: ReturnType<typeof loadConfig>;
msg: WebInboundMsg;
agentId: string;
@@ -41,8 +41,9 @@ export function maybeSendAckReaction(params: {
const activation =
params.msg.chatType === "group"
? resolveGroupActivationFor({
? await resolveGroupActivationFor({
cfg: params.cfg,
accountId: params.accountId,
agentId: params.agentId,
sessionKey: params.sessionKey,
conversationId: conversationIdForCheck,

View File

@@ -6,6 +6,7 @@ import {
DEFAULT_MAIN_KEY,
normalizeAgentId,
} from "openclaw/plugin-sdk/routing";
import { resolveWhatsAppGroupSessionRoute } from "../../group-session-key.js";
import { formatError } from "../../session.js";
import { whatsappInboundLog } from "../loggers.js";
import type { WebInboundMsg } from "../types.js";
@@ -92,11 +93,15 @@ export async function maybeBroadcastMessage(params: {
peerId: params.peerId,
agentId: normalizedAgentId,
});
const agentRoute = {
const baseAgentRoute = {
...params.route,
agentId: normalizedAgentId,
...routeKeys,
};
const agentRoute =
params.msg.chatType === "group"
? resolveWhatsAppGroupSessionRoute(baseAgentRoute)
: baseAgentRoute;
try {
return await params.processMessage(params.msg, agentRoute, params.groupHistoryKey, {

View File

@@ -0,0 +1,175 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { makeSessionStore } from "../../auto-reply.test-harness.js";
import { loadSessionStore } from "../config.runtime.js";
import { resolveGroupActivationFor } from "./group-activation.js";
describe("resolveGroupActivationFor", () => {
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
while (cleanups.length > 0) {
await cleanups.pop()?.();
}
});
it("reads legacy named-account group activation and backfills the scoped key", async () => {
const sessionKey = "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work";
const legacySessionKey = "agent:main:whatsapp:group:123@g.us";
const { storePath, cleanup } = await makeSessionStore({
[legacySessionKey]: {
groupActivation: "always",
sessionId: "legacy-session",
updatedAt: 123,
},
});
cleanups.push(cleanup);
const activation = await resolveGroupActivationFor({
cfg: {
channels: {
whatsapp: {
accounts: {
work: {},
},
},
},
session: { store: storePath },
} as never,
accountId: "work",
agentId: "main",
sessionKey,
conversationId: "123@g.us",
});
expect(activation).toBe("always");
await vi.waitFor(() => {
const scopedEntry = loadSessionStore(storePath, { skipCache: true })[sessionKey];
expect(scopedEntry?.groupActivation).toBe("always");
expect(scopedEntry?.sessionId).toBeUndefined();
expect(scopedEntry?.updatedAt).toBeUndefined();
});
});
it("preserves legacy group activation when the scoped entry already exists without activation", async () => {
const sessionKey = "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work";
const legacySessionKey = "agent:main:whatsapp:group:123@g.us";
const { storePath, cleanup } = await makeSessionStore({
[legacySessionKey]: {
groupActivation: "always",
},
[sessionKey]: {
sessionId: "scoped-session",
},
});
cleanups.push(cleanup);
const activation = await resolveGroupActivationFor({
cfg: {
channels: {
whatsapp: {
accounts: {
work: {},
},
},
},
session: { store: storePath },
} as never,
accountId: "work",
agentId: "main",
sessionKey,
conversationId: "123@g.us",
});
expect(activation).toBe("always");
await vi.waitFor(() => {
const scopedEntry = loadSessionStore(storePath, { skipCache: true })[sessionKey];
expect(scopedEntry?.groupActivation).toBe("always");
expect(scopedEntry?.sessionId).toBe("scoped-session");
});
});
it("does not wake the default account from an activation-only legacy group entry in multi-account setups", async () => {
const defaultSessionKey = "agent:main:whatsapp:group:123@g.us";
const workSessionKey = "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work";
const { storePath, cleanup } = await makeSessionStore({
[defaultSessionKey]: {
groupActivation: "always",
},
});
cleanups.push(cleanup);
const cfg = {
channels: {
whatsapp: {
groups: {
"*": {
requireMention: true,
},
},
accounts: {
work: {},
},
},
},
session: { store: storePath },
} as never;
const workActivation = await resolveGroupActivationFor({
cfg,
accountId: "work",
agentId: "main",
sessionKey: workSessionKey,
conversationId: "123@g.us",
});
expect(workActivation).toBe("always");
const defaultActivation = await resolveGroupActivationFor({
cfg,
accountId: "default",
agentId: "main",
sessionKey: defaultSessionKey,
conversationId: "123@g.us",
});
expect(defaultActivation).toBe("mention");
await vi.waitFor(() => {
const scopedEntry = loadSessionStore(storePath, { skipCache: true })[workSessionKey];
expect(scopedEntry?.groupActivation).toBe("always");
});
});
it("does not treat mixed-case default account keys as named accounts", async () => {
const defaultSessionKey = "agent:main:whatsapp:group:123@g.us";
const { storePath, cleanup } = await makeSessionStore({
[defaultSessionKey]: {
groupActivation: "always",
},
});
cleanups.push(cleanup);
const activation = await resolveGroupActivationFor({
cfg: {
channels: {
whatsapp: {
groups: {
"*": {
requireMention: true,
},
},
accounts: {
Default: {},
},
},
},
session: { store: storePath },
} as never,
accountId: "default",
agentId: "main",
sessionKey: defaultSessionKey,
conversationId: "123@g.us",
});
expect(activation).toBe("always");
});
});

View File

@@ -1,52 +1,36 @@
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
loadSessionStore,
resolveGroupSessionKey,
resolveStorePath,
} from "../config.runtime.js";
import { updateSessionStore } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { resolveWhatsAppLegacyGroupSessionKey } from "../../group-session-key.js";
import { resolveWhatsAppInboundPolicy } from "../../inbound-policy.js";
import { loadSessionStore, resolveStorePath } from "../config.runtime.js";
import { normalizeGroupActivation } from "./group-activation.runtime.js";
type LoadConfigFn = typeof import("../config.runtime.js").loadConfig;
export function resolveGroupPolicyFor(cfg: ReturnType<LoadConfigFn>, conversationId: string) {
const groupId = resolveGroupSessionKey({
From: conversationId,
ChatType: "group",
Provider: "whatsapp",
})?.id;
const whatsappCfg = cfg.channels?.whatsapp as
| { groupAllowFrom?: string[]; allowFrom?: string[] }
| undefined;
const hasGroupAllowFrom = Boolean(
whatsappCfg?.groupAllowFrom?.length || whatsappCfg?.allowFrom?.length,
);
return resolveChannelGroupPolicy({
cfg,
channel: "whatsapp",
groupId: groupId ?? conversationId,
hasGroupAllowFrom,
});
function hasNamedWhatsAppAccounts(cfg: ReturnType<LoadConfigFn>) {
const accountIds = Object.keys(cfg.channels?.whatsapp?.accounts ?? {});
return accountIds.some((accountId) => normalizeAccountId(accountId) !== DEFAULT_ACCOUNT_ID);
}
export function resolveGroupRequireMentionFor(
cfg: ReturnType<LoadConfigFn>,
conversationId: string,
function isActivationOnlyEntry(
entry:
| {
groupActivation?: unknown;
sessionId?: unknown;
updatedAt?: unknown;
}
| undefined,
) {
const groupId = resolveGroupSessionKey({
From: conversationId,
ChatType: "group",
Provider: "whatsapp",
})?.id;
return resolveChannelGroupRequireMention({
cfg,
channel: "whatsapp",
groupId: groupId ?? conversationId,
});
return (
entry?.groupActivation !== undefined &&
typeof entry?.sessionId !== "string" &&
typeof entry?.updatedAt !== "number"
);
}
export function resolveGroupActivationFor(params: {
export async function resolveGroupActivationFor(params: {
cfg: ReturnType<LoadConfigFn>;
accountId?: string | null;
agentId: string;
sessionKey: string;
conversationId: string;
@@ -55,8 +39,36 @@ export function resolveGroupActivationFor(params: {
agentId: params.agentId,
});
const store = loadSessionStore(storePath);
const entry = store[params.sessionKey];
const requireMention = resolveGroupRequireMentionFor(params.cfg, params.conversationId);
const legacySessionKey = resolveWhatsAppLegacyGroupSessionKey({
sessionKey: params.sessionKey,
accountId: params.accountId,
});
const legacyEntry = legacySessionKey ? store[legacySessionKey] : undefined;
const scopedEntry = store[params.sessionKey];
const normalizedAccountId = normalizeAccountId(params.accountId);
const ignoreScopedActivation =
normalizedAccountId === DEFAULT_ACCOUNT_ID &&
hasNamedWhatsAppAccounts(params.cfg) &&
isActivationOnlyEntry(scopedEntry);
const activation =
(ignoreScopedActivation ? undefined : scopedEntry?.groupActivation) ??
legacyEntry?.groupActivation;
if (activation !== undefined && scopedEntry?.groupActivation === undefined) {
await updateSessionStore(storePath, (nextStore) => {
const nextScopedEntry = nextStore[params.sessionKey];
if (nextScopedEntry?.groupActivation !== undefined) {
return;
}
nextStore[params.sessionKey] = {
...nextScopedEntry,
groupActivation: activation,
};
});
}
const requireMention = resolveWhatsAppInboundPolicy({
cfg: params.cfg,
accountId: params.accountId,
}).resolveConversationRequireMention(params.conversationId);
const defaultActivation = !requireMention ? "always" : "mention";
return normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation;
return normalizeGroupActivation(activation) ?? defaultActivation;
}

View File

@@ -6,11 +6,12 @@ import {
getSenderIdentity,
identitiesOverlap,
} from "../../identity.js";
import { resolveWhatsAppInboundPolicy } from "../../inbound-policy.js";
import type { MentionConfig } from "../mentions.js";
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
import type { WebInboundMsg } from "../types.js";
import { stripMentionsForCommand } from "./commands.js";
import { resolveGroupActivationFor, resolveGroupPolicyFor } from "./group-activation.js";
import { resolveGroupActivationFor } from "./group-activation.js";
import {
hasControlCommand,
implicitMentionKindWhen,
@@ -94,11 +95,18 @@ function skipGroupMessageAndStoreHistory(params: ApplyGroupGatingParams, verbose
return { shouldProcess: false } as const;
}
export function applyGroupGating(params: ApplyGroupGatingParams) {
export async function applyGroupGating(params: ApplyGroupGatingParams) {
const sender = getSenderIdentity(params.msg);
const self = getSelfIdentity(params.msg, params.authDir);
const groupPolicy = resolveGroupPolicyFor(params.cfg, params.conversationId);
if (groupPolicy.allowlistEnabled && !groupPolicy.allowed) {
const inboundPolicy = resolveWhatsAppInboundPolicy({
cfg: params.cfg,
accountId: params.msg.accountId,
selfE164: self.e164 ?? null,
});
const conversationGroupPolicy = inboundPolicy.resolveConversationGroupPolicy(
params.conversationId,
);
if (conversationGroupPolicy.allowlistEnabled && !conversationGroupPolicy.allowed) {
params.logVerbose(`Skipping group message ${params.conversationId} (not in allowlist)`);
return { shouldProcess: false };
}
@@ -110,14 +118,21 @@ export function applyGroupGating(params: ApplyGroupGatingParams) {
sender.name ?? undefined,
);
const mentionConfig = buildMentionConfig(params.cfg, params.agentId);
const baseMentionConfig = {
...params.baseMentionConfig,
allowFrom: inboundPolicy.configuredAllowFrom,
};
const mentionConfig = {
...buildMentionConfig(params.cfg, params.agentId),
allowFrom: inboundPolicy.configuredAllowFrom,
};
const commandBody = stripMentionsForCommand(
params.msg.body,
mentionConfig.mentionRegexes,
self.e164,
);
const activationCommand = parseActivationCommand(commandBody);
const owner = isOwnerSender(params.baseMentionConfig, params.msg);
const owner = isOwnerSender(baseMentionConfig, params.msg);
const shouldBypassMention = owner && hasControlCommand(commandBody, params.cfg);
if (activationCommand.hasCommand && !owner) {
@@ -137,8 +152,9 @@ export function applyGroupGating(params: ApplyGroupGatingParams) {
"group mention debug",
);
const wasMentioned = mentionDebug.wasMentioned;
const activation = resolveGroupActivationFor({
const activation = await resolveGroupActivationFor({
cfg: params.cfg,
accountId: inboundPolicy.account.accountId,
agentId: params.agentId,
sessionKey: params.sessionKey,
conversationId: params.conversationId,

View File

@@ -3,6 +3,7 @@ import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { buildGroupHistoryKey } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveWhatsAppGroupSessionRoute } from "../../group-session-key.js";
import { getPrimaryIdentityId, getSenderIdentity } from "../../identity.js";
import { normalizeE164 } from "../../text-runtime.js";
import { loadConfig } from "../config.runtime.js";
@@ -65,7 +66,7 @@ export function createWebOnMessageHandler(params: {
const conversationId = msg.conversationId ?? msg.from;
const peerId = resolvePeerId(msg);
// Fresh config for bindings lookup; other routing inputs are payload-derived.
const route = resolveAgentRoute({
const baseRoute = resolveAgentRoute({
cfg: loadConfig(),
channel: "whatsapp",
accountId: msg.accountId,
@@ -74,6 +75,8 @@ export function createWebOnMessageHandler(params: {
id: peerId,
},
});
const route =
msg.chatType === "group" ? resolveWhatsAppGroupSessionRoute(baseRoute) : baseRoute;
const groupHistoryKey =
msg.chatType === "group"
? buildGroupHistoryKey({
@@ -126,7 +129,7 @@ export function createWebOnMessageHandler(params: {
warn: params.replyLogger.warn.bind(params.replyLogger),
});
const gating = applyGroupGating({
const gating = await applyGroupGating({
cfg: params.cfg,
msg,
conversationId,

View File

@@ -1,5 +1,9 @@
import { resolveWhatsAppAccount } from "../../accounts.js";
import { getPrimaryIdentityId, getSelfIdentity, getSenderIdentity } from "../../identity.js";
import {
resolveWhatsAppCommandAuthorized,
resolveWhatsAppInboundPolicy,
type ResolvedWhatsAppInboundPolicy,
} from "../../inbound-policy.js";
import { newConnectionId } from "../../reconnect.js";
import { formatError } from "../../session.js";
import { deliverWebReply } from "../deliver-reply.js";
@@ -27,12 +31,10 @@ import {
formatInboundEnvelope,
logVerbose,
normalizeE164,
readStoreAllowFromForDmPolicy,
recordSessionMetaFromInbound,
resolveChannelContextVisibilityMode,
resolveInboundSessionEnvelopeContext,
resolvePinnedMainDmOwnerFromAllowlist,
resolveDmGroupAccessWithCommandGate,
shouldComputeCommandAuthorized,
shouldLogVerbose,
type getChildLogger,
@@ -42,74 +44,13 @@ import {
type resolveAgentRoute,
} from "./runtime-api.js";
async function resolveWhatsAppCommandAuthorized(params: {
cfg: ReturnType<LoadConfigFn>;
msg: WebInboundMsg;
}): Promise<boolean> {
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups) {
return true;
}
const isGroup = params.msg.chatType === "group";
const sender = getSenderIdentity(params.msg);
const self = getSelfIdentity(params.msg);
const senderE164 = normalizeE164(
isGroup ? (sender.e164 ?? "") : (sender.e164 ?? params.msg.from ?? ""),
);
if (!senderE164) {
return false;
}
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId });
const dmPolicy = account.dmPolicy ?? "pairing";
const groupPolicy = account.groupPolicy ?? "allowlist";
const configuredAllowFrom = account.allowFrom ?? [];
const configuredGroupAllowFrom =
account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
const storeAllowFrom = isGroup
? []
: await readStoreAllowFromForDmPolicy({
provider: "whatsapp",
accountId: params.msg.accountId,
dmPolicy,
});
const dmAllowFrom =
configuredAllowFrom.length > 0 ? configuredAllowFrom : self.e164 ? [self.e164] : [];
const access = resolveDmGroupAccessWithCommandGate({
isGroup,
dmPolicy,
groupPolicy,
allowFrom: dmAllowFrom,
groupAllowFrom: configuredGroupAllowFrom,
storeAllowFrom,
isSenderAllowed: (allowEntries) => {
if (allowEntries.includes("*")) {
return true;
}
const normalizedEntries = allowEntries
.map((entry) => normalizeE164(entry))
.filter((entry): entry is string => Boolean(entry));
return normalizedEntries.includes(senderE164);
},
command: {
useAccessGroups,
allowTextCommands: true,
hasControlCommand: true,
},
});
return access.commandAuthorized;
}
function resolvePinnedMainDmRecipient(params: {
cfg: ReturnType<LoadConfigFn>;
msg: WebInboundMsg;
allowFrom?: string[];
}): string | null {
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId });
return resolvePinnedMainDmOwnerFromAllowlist({
dmScope: params.cfg.session?.dmScope,
allowFrom: account.allowFrom,
allowFrom: params.allowFrom,
normalizeEntry: (entry) => normalizeE164(entry),
});
}
@@ -143,20 +84,18 @@ export async function processMessage(params: {
suppressGroupHistoryClear?: boolean;
}) {
const conversationId = params.msg.conversationId ?? params.msg.from;
const account = resolveWhatsAppAccount({
const self = getSelfIdentity(params.msg);
const inboundPolicy = resolveWhatsAppInboundPolicy({
cfg: params.cfg,
accountId: params.route.accountId ?? params.msg.accountId,
selfE164: self.e164 ?? null,
});
const account = inboundPolicy.account;
const contextVisibilityMode = resolveChannelContextVisibilityMode({
cfg: params.cfg,
channel: "whatsapp",
accountId: account.accountId,
});
const configuredAllowFrom = account.allowFrom ?? [];
const configuredGroupAllowFrom =
account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
const groupAllowFrom = configuredGroupAllowFrom ?? [];
const groupPolicy = account.groupPolicy ?? "allowlist";
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
cfg: params.cfg,
agentId: params.route.agentId,
@@ -175,8 +114,8 @@ export async function processMessage(params: {
? resolveVisibleWhatsAppGroupHistory({
history: params.groupHistory ?? params.groupHistories.get(params.groupHistoryKey) ?? [],
mode: contextVisibilityMode,
groupPolicy,
groupAllowFrom,
groupPolicy: inboundPolicy.groupPolicy,
groupAllowFrom: inboundPolicy.groupAllowFrom,
})
: undefined;
@@ -220,7 +159,7 @@ export async function processMessage(params: {
}
// Send ack reaction immediately upon message receipt (post-gating)
maybeSendAckReaction({
await maybeSendAckReaction({
cfg: params.cfg,
msg: params.msg,
agentId: params.route.agentId,
@@ -256,13 +195,12 @@ export async function processMessage(params: {
}
const sender = getSenderIdentity(params.msg);
const self = getSelfIdentity(params.msg);
const visibleReplyTo = resolveVisibleWhatsAppReplyContext({
msg: params.msg,
authDir: account.authDir,
mode: contextVisibilityMode,
groupPolicy,
groupAllowFrom,
groupPolicy: inboundPolicy.groupPolicy,
groupAllowFrom: inboundPolicy.groupAllowFrom,
});
const dmRouteTarget = resolveWhatsAppDmRouteTarget({
msg: params.msg,
@@ -270,7 +208,11 @@ export async function processMessage(params: {
normalizeE164,
});
const commandAuthorized = shouldComputeCommandAuthorized(params.msg.body, params.cfg)
? await resolveWhatsAppCommandAuthorized({ cfg: params.cfg, msg: params.msg })
? await resolveWhatsAppCommandAuthorized({
cfg: params.cfg,
msg: params.msg,
policy: inboundPolicy,
})
: undefined;
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
cfg: params.cfg,
@@ -278,14 +220,10 @@ export async function processMessage(params: {
channel: "whatsapp",
accountId: params.route.accountId,
});
const isSelfChat =
params.msg.chatType !== "group" &&
Boolean(self.e164) &&
normalizeE164(params.msg.from) === normalizeE164(self.e164 ?? "");
const responsePrefix = resolveWhatsAppResponsePrefix({
cfg: params.cfg,
agentId: params.route.agentId,
isSelfChat,
isSelfChat: params.msg.chatType !== "group" && inboundPolicy.isSelfChat,
pipelineResponsePrefix: replyPipeline.responsePrefix,
});
@@ -307,7 +245,7 @@ export async function processMessage(params: {
const pinnedMainDmRecipient = resolvePinnedMainDmRecipient({
cfg: params.cfg,
msg: params.msg,
allowFrom: inboundPolicy.configuredAllowFrom,
});
updateWhatsAppMainLastRoute({
backgroundTasks: params.backgroundTasks,
@@ -359,3 +297,10 @@ export async function processMessage(params: {
shouldClearGroupHistory,
});
}
export const __testing = {
resolveWhatsAppCommandAuthorized,
resolveWhatsAppInboundPolicy: (
params: Parameters<typeof resolveWhatsAppInboundPolicy>[0],
): ResolvedWhatsAppInboundPolicy => resolveWhatsAppInboundPolicy(params),
};

View File

@@ -1,4 +1,4 @@
import type { monitorWebInbox } from "../inbound.js";
import type { WebInboundMessage } from "../inbound/types.js";
import type { ReconnectPolicy } from "../reconnect.js";
export type WebChannelHealthState =
@@ -10,11 +10,7 @@ export type WebChannelHealthState =
| "logged-out"
| "stopped";
export type WebInboundMsg = Parameters<typeof monitorWebInbox>[0]["onMessage"] extends (
msg: infer M,
) => unknown
? M
: never;
export type WebInboundMsg = WebInboundMessage;
export type WebChannelStatus = {
running: boolean;

View File

@@ -35,7 +35,7 @@ const makeConfig = (overrides: Record<string, unknown>) =>
...overrides,
}) as unknown as ReturnType<typeof import("openclaw/plugin-sdk/config-runtime").loadConfig>;
function runGroupGating(params: {
async function runGroupGating(params: {
cfg: ReturnType<typeof import("openclaw/plugin-sdk/config-runtime").loadConfig>;
msg: Record<string, unknown>;
conversationId?: string;
@@ -47,7 +47,7 @@ function runGroupGating(params: {
const agentId = params.agentId ?? "main";
const sessionKey = `agent:${agentId}:whatsapp:group:${conversationId}`;
const baseMentionConfig = buildMentionConfig(params.cfg, undefined);
const result = applyGroupGating({
const result = await applyGroupGating({
cfg: params.cfg,
msg: params.msg as any,
conversationId,
@@ -103,9 +103,9 @@ function makeInboundCfg(messagePrefix = "") {
}
describe("applyGroupGating", () => {
it("treats reply-to-bot as implicit mention", () => {
it("treats reply-to-bot as implicit mention", async () => {
const cfg = makeConfig({});
const { result } = runGroupGating({
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "m1",
@@ -126,7 +126,7 @@ describe("applyGroupGating", () => {
expect(result.shouldProcess).toBe(true);
});
it("does not treat self-number quoted replies as implicit mention in selfChatMode groups", () => {
it("does not treat self-number quoted replies as implicit mention in selfChatMode groups", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
@@ -136,7 +136,7 @@ describe("applyGroupGating", () => {
},
},
});
const { result } = runGroupGating({
const { result } = await runGroupGating({
cfg,
selfChatMode: true,
msg: createGroupMessage({
@@ -160,7 +160,7 @@ describe("applyGroupGating", () => {
expect(result.shouldProcess).toBe(false);
});
it("still treats reply-to-bot as implicit mention in selfChatMode when sender is a different user", () => {
it("still treats reply-to-bot as implicit mention in selfChatMode when sender is a different user", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
@@ -170,7 +170,7 @@ describe("applyGroupGating", () => {
},
},
});
const { result } = runGroupGating({
const { result } = await runGroupGating({
cfg,
selfChatMode: true,
msg: createGroupMessage({
@@ -194,7 +194,7 @@ describe("applyGroupGating", () => {
expect(result.shouldProcess).toBe(true);
});
it("honors per-account selfChatMode overrides before suppressing implicit mentions", () => {
it("honors per-account selfChatMode overrides before suppressing implicit mentions", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
@@ -210,7 +210,7 @@ describe("applyGroupGating", () => {
},
});
// Per-account override: work account has selfChatMode: false despite root being true
const { result } = runGroupGating({
const { result } = await runGroupGating({
cfg,
selfChatMode: false,
msg: createGroupMessage({
@@ -234,11 +234,173 @@ describe("applyGroupGating", () => {
expect(result.shouldProcess).toBe(true);
});
it("uses account-scoped groupPolicy and groupAllowFrom for named-account group gating", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
groupPolicy: "allowlist",
accounts: {
work: {
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
},
},
},
},
});
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "g-account-policy",
accountId: "work",
body: "following up",
senderE164: "+111",
senderJid: "111@s.whatsapp.net",
selfJid: "15551234567@s.whatsapp.net",
selfE164: "+15551234567",
replyToId: "m0",
replyToBody: "bot said hi",
replyToSender: "+15551234567",
replyToSenderJid: "15551234567@s.whatsapp.net",
replyToSenderE164: "+15551234567",
}),
});
expect(result.shouldProcess).toBe(true);
});
it("inherits group gating defaults from accounts.default for named accounts", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
groupPolicy: "allowlist",
accounts: {
default: {
groupPolicy: "open",
groups: {
"*": {
requireMention: false,
},
},
},
work: {},
},
},
},
});
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "g-default-inheritance",
accountId: "work",
body: "plain group message",
senderE164: "+111",
senderJid: "111@s.whatsapp.net",
selfJid: "15551234567@s.whatsapp.net",
selfE164: "+15551234567",
}),
});
expect(result.shouldProcess).toBe(true);
});
it("preserves allowFrom fallback for named-account group gating when groupAllowFrom is empty", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
groupPolicy: "allowlist",
accounts: {
work: {
groupPolicy: "allowlist",
allowFrom: ["+111"],
groupAllowFrom: [],
groups: {
"*": {
requireMention: false,
},
},
},
},
},
},
});
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "g-empty-group-allow-fallback",
accountId: "work",
body: "plain group message",
senderE164: "+111",
senderJid: "111@s.whatsapp.net",
selfJid: "15551234567@s.whatsapp.net",
selfE164: "+15551234567",
}),
});
expect(result.shouldProcess).toBe(true);
});
it("uses account-scoped allowFrom when bypassing mention gating for owner commands", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
allowFrom: ["+999"],
accounts: {
work: {
allowFrom: ["+111"],
},
},
},
},
});
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "g-account-owner",
accountId: "work",
body: "/new",
senderE164: "+111",
senderName: "Owner",
}),
});
expect(result.shouldProcess).toBe(true);
});
it("does not treat group mention gating as self-chat under implicit self fallback", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
groups: { "*": { requireMention: true } },
},
},
messages: { groupChat: { mentionPatterns: ["@openclaw"] } },
});
const { result, groupHistories } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "g-other-mention",
body: "@openclaw please check this",
mentionedJids: ["15550000000@s.whatsapp.net"],
selfE164: "+15551234567",
selfJid: "15551234567@s.whatsapp.net",
}),
});
expect(result.shouldProcess).toBe(false);
expect(groupHistories.get("whatsapp:default:group:123@g.us")?.length).toBe(1);
});
it.each([
{ id: "g-new", command: "/new" },
{ id: "g-status", command: "/status" },
])("bypasses mention gating for owner $command in group chats", ({ id, command }) => {
const { result } = runGroupGating({
])("bypasses mention gating for owner $command in group chats", async ({ id, command }) => {
const { result } = await runGroupGating({
cfg: makeOwnerGroupConfig(),
msg: createGroupMessage({
id,
@@ -251,7 +413,7 @@ describe("applyGroupGating", () => {
expect(result.shouldProcess).toBe(true);
});
it("does not bypass mention gating for non-owner /new in group chats", () => {
it("does not bypass mention gating for non-owner /new in group chats", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
@@ -261,7 +423,7 @@ describe("applyGroupGating", () => {
},
});
const { result, groupHistories } = runGroupGating({
const { result, groupHistories } = await runGroupGating({
cfg,
msg: createGroupMessage({
id: "g-new-unauth",
@@ -275,7 +437,7 @@ describe("applyGroupGating", () => {
expect(groupHistories.get("whatsapp:default:group:123@g.us")?.length).toBe(1);
});
it("uses per-agent mention patterns for group gating (routing + mentionPatterns)", () => {
it("uses per-agent mention patterns for group gating (routing + mentionPatterns)", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
@@ -312,7 +474,7 @@ describe("applyGroupGating", () => {
});
expect(route.agentId).toBe("work");
const { result: globalMention } = runGroupGating({
const { result: globalMention } = await runGroupGating({
cfg,
agentId: route.agentId,
msg: createGroupMessage({
@@ -324,7 +486,7 @@ describe("applyGroupGating", () => {
});
expect(globalMention.shouldProcess).toBe(false);
const { result: workMention } = runGroupGating({
const { result: workMention } = await runGroupGating({
cfg,
agentId: route.agentId,
msg: createGroupMessage({
@@ -337,7 +499,7 @@ describe("applyGroupGating", () => {
expect(workMention.shouldProcess).toBe(true);
});
it("allows group messages when whatsapp groups default disables mention gating", () => {
it("allows group messages when whatsapp groups default disables mention gating", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
@@ -348,7 +510,7 @@ describe("applyGroupGating", () => {
messages: { groupChat: { mentionPatterns: ["@openclaw"] } },
});
const { result } = runGroupGating({
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage(),
});
@@ -356,7 +518,7 @@ describe("applyGroupGating", () => {
expect(result.shouldProcess).toBe(true);
});
it("blocks group messages when whatsapp groups is set without a wildcard", () => {
it("blocks group messages when whatsapp groups is set without a wildcard", async () => {
const cfg = makeConfig({
channels: {
whatsapp: {
@@ -368,7 +530,7 @@ describe("applyGroupGating", () => {
},
});
const { result } = runGroupGating({
const { result } = await runGroupGating({
cfg,
msg: createGroupMessage({
body: "@workbot ping",

View File

@@ -34,7 +34,7 @@ describe("isBotMentionedFromTargets", () => {
function expectMentioned(
msg: WebInboundMsg,
cfg: { mentionRegexes: RegExp[]; allowFrom?: Array<string | number> },
cfg: { mentionRegexes: RegExp[]; allowFrom?: Array<string | number>; isSelfChat?: boolean },
expected: boolean,
) {
const targets = resolveMentionTargets(msg);
@@ -88,6 +88,21 @@ describe("isBotMentionedFromTargets", () => {
expectMentioned(msgTextMention, cfg, true);
});
it("honors explicit self-chat overrides without recomputing from allowFrom", () => {
const cfg = {
mentionRegexes: [/\bopenclaw\b/i],
allowFrom: ["+15551230000"],
isSelfChat: true,
};
const msg = makeMsg({
body: "@owner ping",
mentionedJids: ["999@s.whatsapp.net"],
selfE164: "+999",
selfJid: "999@s.whatsapp.net",
});
expectMentioned(msg, cfg, false);
});
it("matches fallback number mentions when regexes do not match", () => {
const msg = makeMsg({
body: "please check +1 555 123 4567",

View File

@@ -9,6 +9,7 @@ import type { OpenClawConfig } from "./runtime-api.js";
import { finalizeWhatsAppSetup } from "./setup-finalize.js";
import {
createWhatsAppAllowlistModeInput,
expectWhatsAppDefaultAccountAccessNote,
createWhatsAppLinkingHarness,
createWhatsAppOwnerAllowlistHarness,
createWhatsAppPersonalPhoneHarness,
@@ -219,6 +220,128 @@ describe("whatsapp setup wizard", () => {
expectWhatsAppOpenPolicySetup(result.cfg, harness);
});
it("surfaces accounts.default group warning paths for named accounts", () => {
const warnings = whatsappPlugin.security?.collectWarnings?.({
cfg: {
channels: {
whatsapp: {
accounts: {
default: {
groupPolicy: "open",
},
work: {
authDir: "/tmp/work",
},
},
},
},
} as OpenClawConfig,
accountId: "work",
account: {
accountId: "work",
enabled: true,
sendReadReceipts: true,
authDir: "/tmp/work",
isLegacyAuthDir: false,
groupPolicy: "open",
},
});
expect(warnings).toEqual([
'- WhatsApp groups: groupPolicy="open" with no channels.whatsapp.accounts.default.groups allowlist; any group can add + ping (mention-gated). Set channels.whatsapp.accounts.default.groupPolicy="allowlist" + channels.whatsapp.accounts.default.groupAllowFrom or configure channels.whatsapp.accounts.default.groups.',
]);
});
it("surfaces mixed-case default-account group warning paths for named accounts", () => {
const warnings = whatsappPlugin.security?.collectWarnings?.({
cfg: {
channels: {
whatsapp: {
accounts: {
Default: {
groupPolicy: "open",
},
work: {
authDir: "/tmp/work",
},
},
},
},
} as OpenClawConfig,
accountId: "work",
account: {
accountId: "work",
enabled: true,
sendReadReceipts: true,
authDir: "/tmp/work",
isLegacyAuthDir: false,
groupPolicy: "open",
},
});
expect(warnings).toEqual([
'- WhatsApp groups: groupPolicy="open" with no channels.whatsapp.accounts.Default.groups allowlist; any group can add + ping (mention-gated). Set channels.whatsapp.accounts.Default.groupPolicy="allowlist" + channels.whatsapp.accounts.Default.groupAllowFrom or configure channels.whatsapp.accounts.Default.groups.',
]);
});
it("writes default-account DM config into accounts.default for multi-account setups", async () => {
hoisted.pathExists.mockResolvedValue(true);
const harness = createSeparatePhoneHarness({
selectValues: ["separate", "open"],
});
const result = await runConfigureWithHarness({
harness,
cfg: {
channels: {
whatsapp: {
accounts: {
work: {
authDir: "/tmp/work",
},
},
},
},
} as OpenClawConfig,
});
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBeUndefined();
expect(result.cfg.channels?.whatsapp?.allowFrom).toBeUndefined();
expect(result.cfg.channels?.whatsapp?.accounts?.default?.dmPolicy).toBe("open");
expect(result.cfg.channels?.whatsapp?.accounts?.default?.allowFrom).toEqual(["*"]);
expectWhatsAppDefaultAccountAccessNote(harness);
});
it("updates an existing mixed-case default-account key during setup", async () => {
hoisted.pathExists.mockResolvedValue(true);
const harness = createSeparatePhoneHarness({
selectValues: ["separate", "open"],
});
const result = await runConfigureWithHarness({
harness,
cfg: {
channels: {
whatsapp: {
accounts: {
Default: {
authDir: "/tmp/default-auth",
},
work: {
authDir: "/tmp/work",
},
},
},
},
} as OpenClawConfig,
});
expect(result.cfg.channels?.whatsapp?.accounts?.Default?.authDir).toBe("/tmp/default-auth");
expect(result.cfg.channels?.whatsapp?.accounts?.Default?.dmPolicy).toBe("open");
expect(result.cfg.channels?.whatsapp?.accounts?.Default?.allowFrom).toEqual(["*"]);
expect(result.cfg.channels?.whatsapp?.accounts?.default).toBeUndefined();
});
it("runs WhatsApp login when not linked and user confirms linking", async () => {
hoisted.pathExists.mockResolvedValue(false);
const harness = createWhatsAppLinkingHarness(createQueuedWizardPrompter);

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { resolveWhatsAppGroupSessionRoute, __testing } from "./group-session-key.js";
describe("resolveWhatsAppGroupSessionRoute", () => {
it("keeps default-account group routes unchanged", () => {
const route = {
agentId: "main",
channel: "whatsapp",
accountId: "default",
sessionKey: "agent:main:whatsapp:group:123@g.us",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session",
matchedBy: "default",
} as const;
expect(resolveWhatsAppGroupSessionRoute(route)).toEqual(route);
});
it("scopes named-account group routes through an account-specific thread suffix", () => {
const route = {
agentId: "main",
channel: "whatsapp",
accountId: "work",
sessionKey: "agent:main:whatsapp:group:123@g.us",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session",
matchedBy: "default",
} as const;
expect(resolveWhatsAppGroupSessionRoute(route)).toEqual({
...route,
sessionKey: "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work",
});
});
it("derives the legacy group session key from a named-account scoped group route", () => {
expect(
__testing.resolveWhatsAppLegacyGroupSessionKey({
accountId: "work",
sessionKey: "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work",
}),
).toBe("agent:main:whatsapp:group:123@g.us");
});
it("normalizes mixed-case account ids when resolving legacy scoped group keys", () => {
expect(
__testing.resolveWhatsAppLegacyGroupSessionKey({
accountId: "Work",
sessionKey: "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work",
}),
).toBe("agent:main:whatsapp:group:123@g.us");
});
});

View File

@@ -0,0 +1,41 @@
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
resolveThreadSessionKeys,
type ResolvedAgentRoute,
} from "openclaw/plugin-sdk/routing";
function resolveWhatsAppGroupAccountThreadId(accountId: string): string {
return `whatsapp-account-${normalizeAccountId(accountId)}`;
}
export function resolveWhatsAppLegacyGroupSessionKey(params: {
sessionKey: string;
accountId?: string | null;
}): string | null {
const accountId = normalizeAccountId(params.accountId);
if (!accountId || accountId === DEFAULT_ACCOUNT_ID || !params.sessionKey.includes(":group:")) {
return null;
}
const suffix = `:thread:${resolveWhatsAppGroupAccountThreadId(accountId)}`;
return params.sessionKey.endsWith(suffix) ? params.sessionKey.slice(0, -suffix.length) : null;
}
export function resolveWhatsAppGroupSessionRoute(route: ResolvedAgentRoute): ResolvedAgentRoute {
if (route.accountId === DEFAULT_ACCOUNT_ID || !route.sessionKey.includes(":group:")) {
return route;
}
const scopedSession = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
threadId: resolveWhatsAppGroupAccountThreadId(route.accountId),
});
return {
...route,
sessionKey: scopedSession.sessionKey,
};
}
export const __testing = {
resolveWhatsAppGroupAccountThreadId,
resolveWhatsAppLegacyGroupSessionKey,
};

View File

@@ -0,0 +1,196 @@
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
resolveDefaultGroupPolicy,
resolveGroupSessionKey,
type ChannelGroupPolicy,
type DmPolicy,
type GroupPolicy,
type OpenClawConfig,
} from "openclaw/plugin-sdk/config-runtime";
import {
readStoreAllowFromForDmPolicy,
resolveEffectiveAllowFromLists,
resolveDmGroupAccessWithCommandGate,
} from "openclaw/plugin-sdk/security-runtime";
import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js";
import { getSelfIdentity, getSenderIdentity } from "./identity.js";
import type { WebInboundMessage } from "./inbound/types.js";
import { resolveWhatsAppRuntimeGroupPolicy } from "./runtime-group-policy.js";
import { isSelfChatMode, normalizeE164 } from "./text-runtime.js";
export type ResolvedWhatsAppInboundPolicy = {
account: ResolvedWhatsAppAccount;
dmPolicy: DmPolicy;
groupPolicy: GroupPolicy;
configuredAllowFrom: string[];
dmAllowFrom: string[];
groupAllowFrom: string[];
isSelfChat: boolean;
providerMissingFallbackApplied: boolean;
shouldReadStorePairingApprovals: boolean;
isSamePhone: (value?: string | null) => boolean;
isDmSenderAllowed: (allowEntries: string[], sender?: string | null) => boolean;
isGroupSenderAllowed: (allowEntries: string[], sender?: string | null) => boolean;
resolveConversationGroupPolicy: (conversationId: string) => ChannelGroupPolicy;
resolveConversationRequireMention: (conversationId: string) => boolean;
};
function resolveGroupConversationId(conversationId: string): string {
return (
resolveGroupSessionKey({
From: conversationId,
ChatType: "group",
Provider: "whatsapp",
})?.id ?? conversationId
);
}
function isNormalizedSenderAllowed(allowEntries: string[], sender?: string | null): boolean {
if (allowEntries.includes("*")) {
return true;
}
const normalizedSender = normalizeE164(sender ?? "");
if (!normalizedSender) {
return false;
}
const normalizedEntrySet = new Set(
allowEntries
.map((entry) => normalizeE164(entry))
.filter((entry): entry is string => Boolean(entry)),
);
return normalizedEntrySet.has(normalizedSender);
}
function buildResolvedWhatsAppGroupConfig(params: {
groupPolicy: GroupPolicy;
groups: ResolvedWhatsAppAccount["groups"];
}): OpenClawConfig {
return {
channels: {
whatsapp: {
groupPolicy: params.groupPolicy,
groups: params.groups,
},
},
} as OpenClawConfig;
}
export function resolveWhatsAppInboundPolicy(params: {
cfg: OpenClawConfig;
accountId?: string | null;
selfE164?: string | null;
}): ResolvedWhatsAppInboundPolicy {
const account = resolveWhatsAppAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const configuredAllowFrom = account.allowFrom ?? [];
const dmPolicy = account.dmPolicy ?? "pairing";
const dmAllowFrom =
configuredAllowFrom.length > 0 ? configuredAllowFrom : params.selfE164 ? [params.selfE164] : [];
const groupAllowFrom =
account.groupAllowFrom ??
(configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined) ??
[];
const { effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
allowFrom: configuredAllowFrom,
groupAllowFrom,
});
const defaultGroupPolicy = resolveDefaultGroupPolicy(params.cfg);
const { groupPolicy, providerMissingFallbackApplied } = resolveWhatsAppRuntimeGroupPolicy({
providerConfigPresent: params.cfg.channels?.whatsapp !== undefined,
groupPolicy: account.groupPolicy,
defaultGroupPolicy,
});
const resolvedGroupCfg = buildResolvedWhatsAppGroupConfig({
groupPolicy,
groups: account.groups,
});
const isSamePhone = (value?: string | null) =>
typeof value === "string" && typeof params.selfE164 === "string" && value === params.selfE164;
return {
account,
dmPolicy,
groupPolicy,
configuredAllowFrom,
dmAllowFrom,
groupAllowFrom,
isSelfChat: account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom),
providerMissingFallbackApplied,
shouldReadStorePairingApprovals: dmPolicy !== "allowlist",
isSamePhone,
isDmSenderAllowed: (allowEntries, sender) =>
isSamePhone(sender) || isNormalizedSenderAllowed(allowEntries, sender),
isGroupSenderAllowed: (allowEntries, sender) => isNormalizedSenderAllowed(allowEntries, sender),
resolveConversationGroupPolicy: (conversationId) =>
resolveChannelGroupPolicy({
cfg: resolvedGroupCfg,
channel: "whatsapp",
groupId: resolveGroupConversationId(conversationId),
hasGroupAllowFrom: effectiveGroupAllowFrom.length > 0,
}),
resolveConversationRequireMention: (conversationId) =>
resolveChannelGroupRequireMention({
cfg: resolvedGroupCfg,
channel: "whatsapp",
groupId: resolveGroupConversationId(conversationId),
}),
};
}
export async function resolveWhatsAppCommandAuthorized(params: {
cfg: OpenClawConfig;
msg: WebInboundMessage;
policy?: ResolvedWhatsAppInboundPolicy;
}): Promise<boolean> {
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups) {
return true;
}
const self = getSelfIdentity(params.msg);
const policy =
params.policy ??
resolveWhatsAppInboundPolicy({
cfg: params.cfg,
accountId: params.msg.accountId,
selfE164: self.e164 ?? null,
});
const isGroup = params.msg.chatType === "group";
const sender = getSenderIdentity(params.msg);
const dmSender = sender.e164 ?? params.msg.from ?? "";
const groupSender = sender.e164 ?? "";
const normalizedSender = normalizeE164(isGroup ? groupSender : dmSender);
if (!normalizedSender) {
return false;
}
const storeAllowFrom =
isGroup || !policy.shouldReadStorePairingApprovals
? []
: await readStoreAllowFromForDmPolicy({
provider: "whatsapp",
accountId: policy.account.accountId,
dmPolicy: policy.dmPolicy,
shouldRead: policy.shouldReadStorePairingApprovals,
});
const access = resolveDmGroupAccessWithCommandGate({
isGroup,
dmPolicy: policy.dmPolicy,
groupPolicy: policy.groupPolicy,
allowFrom: policy.dmAllowFrom,
groupAllowFrom: policy.groupAllowFrom,
storeAllowFrom,
isSenderAllowed: (allowEntries) =>
isGroup
? policy.isGroupSenderAllowed(allowEntries, groupSender)
: policy.isDmSenderAllowed(allowEntries, dmSender),
command: {
useAccessGroups,
allowTextCommands: true,
hasControlCommand: true,
},
});
return access.commandAuthorized;
}

View File

@@ -9,9 +9,11 @@ import {
setupAccessControlTestHarness();
let checkInboundAccessControl: typeof import("./access-control.js").checkInboundAccessControl;
let resolveWhatsAppCommandAuthorized: typeof import("../inbound-policy.js").resolveWhatsAppCommandAuthorized;
beforeAll(async () => {
({ checkInboundAccessControl } = await import("./access-control.js"));
({ resolveWhatsAppCommandAuthorized } = await import("../inbound-policy.js"));
});
async function checkUnauthorizedWorkDmSender() {
@@ -34,6 +36,27 @@ function expectSilentlyBlocked(result: { allowed: boolean }) {
expect(sendMessageMock).not.toHaveBeenCalled();
}
async function checkCommandAuthorizedForDm(params: {
cfg: Record<string, unknown>;
accountId?: string;
from?: string;
senderE164?: string;
selfE164?: string;
}) {
return await resolveWhatsAppCommandAuthorized({
cfg: params.cfg as never,
msg: {
accountId: params.accountId ?? "work",
chatType: "direct",
from: params.from ?? "+15550001111",
senderE164: params.senderE164 ?? params.from ?? "+15550001111",
selfE164: params.selfE164 ?? "+15550009999",
body: "/status",
to: params.selfE164 ?? "+15550009999",
} as never,
});
}
describe("checkInboundAccessControl pairing grace", () => {
async function runPairingGraceCase(messageTimestampMs: number) {
const connectedAtMs = 1_000_000;
@@ -75,7 +98,7 @@ describe("WhatsApp dmPolicy precedence", () => {
// Channel-level says "pairing" but the account-level says "allowlist".
// The account-level override should take precedence, so an unauthorized
// sender should be blocked silently (no pairing reply).
setAccessControlTestConfig({
const cfg = {
channels: {
whatsapp: {
dmPolicy: "pairing",
@@ -87,16 +110,19 @@ describe("WhatsApp dmPolicy precedence", () => {
},
},
},
});
};
setAccessControlTestConfig(cfg);
const result = await checkUnauthorizedWorkDmSender();
const commandAuthorized = await checkCommandAuthorizedForDm({ cfg });
expectSilentlyBlocked(result);
expect(commandAuthorized).toBe(false);
});
it("inherits channel-level dmPolicy when account-level dmPolicy is unset", async () => {
// Account has allowFrom set, but no dmPolicy override. Should inherit the channel default.
// With dmPolicy=allowlist, unauthorized senders are silently blocked.
setAccessControlTestConfig({
const cfg = {
channels: {
whatsapp: {
dmPolicy: "allowlist",
@@ -107,14 +133,17 @@ describe("WhatsApp dmPolicy precedence", () => {
},
},
},
});
};
setAccessControlTestConfig(cfg);
const result = await checkUnauthorizedWorkDmSender();
const commandAuthorized = await checkCommandAuthorizedForDm({ cfg });
expectSilentlyBlocked(result);
expect(commandAuthorized).toBe(false);
});
it("does not merge persisted pairing approvals in allowlist mode", async () => {
setAccessControlTestConfig({
const cfg = {
channels: {
whatsapp: {
dmPolicy: "allowlist",
@@ -125,24 +154,91 @@ describe("WhatsApp dmPolicy precedence", () => {
},
},
},
});
};
setAccessControlTestConfig(cfg);
readAllowFromStoreMock.mockResolvedValue(["+15550001111"]);
const result = await checkUnauthorizedWorkDmSender();
const commandAuthorized = await checkCommandAuthorizedForDm({ cfg });
expectSilentlyBlocked(result);
expect(commandAuthorized).toBe(false);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
it("always allows same-phone DMs even when allowFrom is restrictive", async () => {
setAccessControlTestConfig({
const cfg = {
channels: {
whatsapp: {
dmPolicy: "pairing",
allowFrom: ["+15550001111"],
},
},
};
setAccessControlTestConfig(cfg);
const result = await checkInboundAccessControl({
accountId: "default",
from: "+15550009999",
selfE164: "+15550009999",
senderE164: "+15550009999",
group: false,
pushName: "Owner",
isFromMe: false,
sock: { sendMessage: sendMessageMock },
remoteJid: "15550009999@s.whatsapp.net",
});
const commandAuthorized = await checkCommandAuthorizedForDm({
cfg,
accountId: "default",
from: "+15550009999",
senderE164: "+15550009999",
selfE164: "+15550009999",
});
expect(result.allowed).toBe(true);
expect(commandAuthorized).toBe(true);
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(sendMessageMock).not.toHaveBeenCalled();
});
it("does not broaden self-chat mode to every paired DM when allowFrom is empty", async () => {
const cfg = {
channels: {
whatsapp: {
dmPolicy: "pairing",
allowFrom: [],
},
},
};
setAccessControlTestConfig(cfg);
const result = await checkInboundAccessControl({
accountId: "default",
from: "+15550001111",
selfE164: "+15550009999",
senderE164: "+15550001111",
group: false,
pushName: "Sam",
isFromMe: false,
sock: { sendMessage: sendMessageMock },
remoteJid: "15550001111@s.whatsapp.net",
});
expect(result.allowed).toBe(false);
expect(result.isSelfChat).toBe(false);
});
it("treats same-phone DMs as self-chat only when explicitly configured", async () => {
const cfg = {
channels: {
whatsapp: {
dmPolicy: "pairing",
allowFrom: ["+15550009999"],
},
},
};
setAccessControlTestConfig(cfg);
const result = await checkInboundAccessControl({
accountId: "default",
@@ -157,7 +253,6 @@ describe("WhatsApp dmPolicy precedence", () => {
});
expect(result.allowed).toBe(true);
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(sendMessageMock).not.toHaveBeenCalled();
expect(result.isSelfChat).toBe(true);
});
});

View File

@@ -1,18 +1,13 @@
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import {
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/config-runtime";
import { warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/config-runtime";
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
import {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "openclaw/plugin-sdk/security-runtime";
import { resolveWhatsAppAccount } from "../accounts.js";
import { resolveWhatsAppRuntimeGroupPolicy } from "../runtime-group-policy.js";
import { isSelfChatMode, normalizeE164 } from "../text-runtime.js";
import { resolveWhatsAppInboundPolicy } from "../inbound-policy.js";
export type InboundAccessControlResult = {
allowed: boolean;
@@ -23,6 +18,13 @@ export type InboundAccessControlResult = {
const PAIRING_REPLY_HISTORY_GRACE_MS = 30_000;
function logWhatsAppVerbose(enabled: boolean | undefined, message: string) {
if (!enabled) {
return;
}
defaultRuntime.log(message);
}
export async function checkInboundAccessControl(params: {
accountId: string;
from: string;
@@ -34,31 +36,24 @@ export async function checkInboundAccessControl(params: {
messageTimestampMs?: number;
connectedAtMs?: number;
pairingGraceMs?: number;
verbose?: boolean;
sock: {
sendMessage: (jid: string, content: { text: string }) => Promise<unknown>;
};
remoteJid: string;
}): Promise<InboundAccessControlResult> {
const cfg = loadConfig();
const account = resolveWhatsAppAccount({
const policy = resolveWhatsAppInboundPolicy({
cfg,
accountId: params.accountId,
selfE164: params.selfE164,
});
const dmPolicy = account.dmPolicy ?? "pairing";
const configuredAllowFrom = account.allowFrom ?? [];
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "whatsapp",
accountId: account.accountId,
dmPolicy,
accountId: policy.account.accountId,
dmPolicy: policy.dmPolicy,
shouldRead: policy.shouldReadStorePairingApprovals,
});
// Without user config, default to self-only DM access so the owner can talk to themselves.
const defaultAllowFrom =
configuredAllowFrom.length === 0 && params.selfE164 ? [params.selfE164] : [];
const dmAllowFrom = configuredAllowFrom.length > 0 ? configuredAllowFrom : defaultAllowFrom;
const groupAllowFrom =
account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
const isSamePhone = params.from === params.selfE164;
const isSelfChat = account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom);
const pairingGraceMs =
typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0
? params.pairingGraceMs
@@ -72,89 +67,74 @@ export async function checkInboundAccessControl(params: {
// - "open": groups bypass allowFrom, only mention-gating applies
// - "disabled": block all group messages entirely
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy, providerMissingFallbackApplied } = resolveWhatsAppRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.whatsapp !== undefined,
groupPolicy: account.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerMissingFallbackApplied: policy.providerMissingFallbackApplied,
providerKey: "whatsapp",
accountId: account.accountId,
log: (message) => logVerbose(message),
accountId: policy.account.accountId,
log: (message) => logWhatsAppVerbose(params.verbose, message),
});
const normalizedDmSender = normalizeE164(params.from);
const normalizedGroupSender =
typeof params.senderE164 === "string" ? normalizeE164(params.senderE164) : null;
const access = resolveDmGroupAccessWithLists({
isGroup: params.group,
dmPolicy,
groupPolicy,
// Groups intentionally fall back to configured allowFrom only (not DM self-chat fallback).
allowFrom: params.group ? configuredAllowFrom : dmAllowFrom,
groupAllowFrom,
dmPolicy: policy.dmPolicy,
groupPolicy: policy.groupPolicy,
allowFrom: params.group ? policy.configuredAllowFrom : policy.dmAllowFrom,
groupAllowFrom: policy.groupAllowFrom,
storeAllowFrom,
isSenderAllowed: (allowEntries) => {
const hasWildcard = allowEntries.includes("*");
if (hasWildcard) {
return true;
}
const normalizedEntrySet = new Set(
allowEntries
.map((entry) => normalizeE164(entry))
.filter((entry): entry is string => Boolean(entry)),
);
if (!params.group && isSamePhone) {
return true;
}
return params.group
? Boolean(normalizedGroupSender && normalizedEntrySet.has(normalizedGroupSender))
: normalizedEntrySet.has(normalizedDmSender);
? policy.isGroupSenderAllowed(allowEntries, params.senderE164)
: policy.isDmSenderAllowed(allowEntries, params.from);
},
});
if (params.group && access.decision !== "allow") {
if (access.reason === "groupPolicy=disabled") {
logVerbose("Blocked group message (groupPolicy: disabled)");
logWhatsAppVerbose(params.verbose, "Blocked group message (groupPolicy: disabled)");
} else if (access.reason === "groupPolicy=allowlist (empty allowlist)") {
logVerbose("Blocked group message (groupPolicy: allowlist, no groupAllowFrom)");
logWhatsAppVerbose(
params.verbose,
"Blocked group message (groupPolicy: allowlist, no groupAllowFrom)",
);
} else {
logVerbose(
logWhatsAppVerbose(
params.verbose,
`Blocked group message from ${params.senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`,
);
}
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
isSelfChat: policy.isSelfChat,
resolvedAccountId: policy.account.accountId,
};
}
// DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled".
if (!params.group) {
if (params.isFromMe && !isSamePhone) {
logVerbose("Skipping outbound DM (fromMe); no pairing reply needed.");
if (params.isFromMe && !policy.isSamePhone(params.from)) {
logWhatsAppVerbose(params.verbose, "Skipping outbound DM (fromMe); no pairing reply needed.");
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
isSelfChat: policy.isSelfChat,
resolvedAccountId: policy.account.accountId,
};
}
if (access.decision === "block" && access.reason === "dmPolicy=disabled") {
logVerbose("Blocked dm (dmPolicy: disabled)");
logWhatsAppVerbose(params.verbose, "Blocked dm (dmPolicy: disabled)");
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
isSelfChat: policy.isSelfChat,
resolvedAccountId: policy.account.accountId,
};
}
if (access.decision === "pairing" && !isSamePhone) {
if (access.decision === "pairing" && !policy.isSamePhone(params.from)) {
const candidate = params.from;
if (suppressPairingReply) {
logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`);
logWhatsAppVerbose(
params.verbose,
`Skipping pairing reply for historical DM from ${candidate}.`,
);
} else {
await createChannelPairingChallengeIssuer({
channel: "whatsapp",
@@ -162,7 +142,7 @@ export async function checkInboundAccessControl(params: {
await upsertChannelPairingRequest({
channel: "whatsapp",
id,
accountId: account.accountId,
accountId: policy.account.accountId,
meta,
}),
})({
@@ -170,7 +150,8 @@ export async function checkInboundAccessControl(params: {
senderIdLine: `Your WhatsApp phone number: ${candidate}`,
meta: { name: (params.pushName ?? "").trim() || undefined },
onCreated: () => {
logVerbose(
logWhatsAppVerbose(
params.verbose,
`whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`,
);
},
@@ -178,24 +159,30 @@ export async function checkInboundAccessControl(params: {
await params.sock.sendMessage(params.remoteJid, { text });
},
onReplyError: (err) => {
logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`);
logWhatsAppVerbose(
params.verbose,
`whatsapp pairing reply failed for ${candidate}: ${String(err)}`,
);
},
});
}
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
isSelfChat: policy.isSelfChat,
resolvedAccountId: policy.account.accountId,
};
}
if (access.decision !== "allow") {
logVerbose(`Blocked unauthorized sender ${params.from} (dmPolicy=${dmPolicy})`);
logWhatsAppVerbose(
params.verbose,
`Blocked unauthorized sender ${params.from} (dmPolicy=${policy.dmPolicy})`,
);
return {
allowed: false,
shouldMarkRead: false,
isSelfChat,
resolvedAccountId: account.accountId,
isSelfChat: policy.isSelfChat,
resolvedAccountId: policy.account.accountId,
};
}
}
@@ -203,11 +190,11 @@ export async function checkInboundAccessControl(params: {
return {
allowed: true,
shouldMarkRead: true,
isSelfChat,
resolvedAccountId: account.accountId,
isSelfChat: policy.isSelfChat,
resolvedAccountId: policy.account.accountId,
};
}
export const __testing = {
resolveWhatsAppRuntimeGroupPolicy,
resolveWhatsAppInboundPolicy,
};

View File

@@ -1,7 +1,7 @@
import type { AnyMessageContent, proto, WAMessage, WASocket } from "@whiskeysockets/baileys";
import { createInboundDebouncer, formatLocationText } from "openclaw/plugin-sdk/channel-inbound";
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/text-runtime";
import { readWebSelfIdentity } from "../auth-store.js";
@@ -34,6 +34,13 @@ import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
const RECONNECT_IN_PROGRESS_ERROR = "no active socket - reconnection in progress";
function logWhatsAppVerbose(enabled: boolean | undefined, message: string) {
if (!enabled) {
return;
}
defaultRuntime.log(message);
}
function isGroupJid(jid: string): boolean {
return (typeof isJidGroup === "function" ? isJidGroup(jid) : jid.endsWith("@g.us")) === true;
}
@@ -116,11 +123,12 @@ export async function attachWebInboxToSocket(
try {
await sock.sendPresenceUpdate(presence);
if (shouldLogVerbose()) {
logVerbose(`Sent global '${presence}' presence on connect`);
}
logWhatsAppVerbose(options.verbose, `Sent global '${presence}' presence on connect`);
} catch (err) {
logVerbose(`Failed to send '${presence}' presence on connect: ${String(err)}`);
logWhatsAppVerbose(
options.verbose,
`Failed to send '${presence}' presence on connect: ${String(err)}`,
);
}
const self = await readWebSelfIdentity(
@@ -260,7 +268,8 @@ export async function attachWebInboxToSocket(
throw lastErr;
}
const delayMs = computeBackoff(disconnectRetryPolicy, attempt);
logVerbose(
logWhatsAppVerbose(
options.verbose,
`Waiting ${delayMs}ms for WhatsApp reconnect before retrying send to ${jid}: ${formatError(lastErr)}`,
);
try {
@@ -295,7 +304,10 @@ export async function attachWebInboxToSocket(
groupMetaCache.set(jid, entry);
return entry;
} catch (err) {
logVerbose(`Failed to fetch group metadata for ${jid}: ${String(err)}`);
logWhatsAppVerbose(
options.verbose,
`Failed to fetch group metadata for ${jid}: ${String(err)}`,
);
return { expires: Date.now() + GROUP_META_TTL_MS };
}
};
@@ -338,7 +350,10 @@ export async function attachWebInboxToSocket(
messageId: id,
})
) {
logVerbose(`Skipping recent outbound WhatsApp echo ${id} for ${remoteJid}`);
logWhatsAppVerbose(
options.verbose,
`Skipping recent outbound WhatsApp echo ${id} for ${remoteJid}`,
);
return null;
}
const participantJid = msg.key?.participant ?? undefined;
@@ -373,6 +388,7 @@ export async function attachWebInboxToSocket(
isFromMe: Boolean(msg.key?.fromMe),
messageTimestampMs,
connectedAtMs,
verbose: options.verbose,
sock: { sendMessage: (jid, content) => sendTrackedMessage(jid, content) },
remoteJid,
});
@@ -399,16 +415,17 @@ export async function attachWebInboxToSocket(
if (id && !access.isSelfChat && options.sendReadReceipts !== false) {
try {
await sock.readMessages([{ remoteJid, id, participant: participantJid, fromMe: false }]);
if (shouldLogVerbose()) {
const suffix = participantJid ? ` (participant ${participantJid})` : "";
logVerbose(`Marked message ${id} as read for ${remoteJid}${suffix}`);
}
const suffix = participantJid ? ` (participant ${participantJid})` : "";
logWhatsAppVerbose(
options.verbose,
`Marked message ${id} as read for ${remoteJid}${suffix}`,
);
} catch (err) {
logVerbose(`Failed to mark message ${id} read: ${String(err)}`);
logWhatsAppVerbose(options.verbose, `Failed to mark message ${id} read: ${String(err)}`);
}
} else if (id && access.isSelfChat && shouldLogVerbose()) {
} else if (id && access.isSelfChat && options.verbose) {
// Self-chat mode: never auto-send read receipts (blue ticks) on behalf of the owner.
logVerbose(`Self-chat mode: skipping read receipt for ${id}`);
logWhatsAppVerbose(options.verbose, `Self-chat mode: skipping read receipt for ${id}`);
}
};
@@ -459,7 +476,7 @@ export async function attachWebInboxToSocket(
mediaFileName = inboundMedia.fileName;
}
} catch (err) {
logVerbose(`Inbound media download failed: ${String(err)}`);
logWhatsAppVerbose(options.verbose, `Inbound media download failed: ${String(err)}`);
}
return {
@@ -486,7 +503,7 @@ export async function attachWebInboxToSocket(
try {
await currentSock.sendPresenceUpdate("composing", chatJid);
} catch (err) {
logVerbose(`Presence update failed: ${String(err)}`);
logWhatsAppVerbose(options.verbose, `Presence update failed: ${String(err)}`);
}
};
const reply = async (text: string) => {
@@ -649,14 +666,18 @@ export async function attachWebInboxToSocket(
void (async () => {
try {
const groups = await sock.groupFetchAllParticipating();
if (shouldLogVerbose()) {
logVerbose(`Hydrated ${Object.keys(groups ?? {}).length} participating groups on connect`);
}
logWhatsAppVerbose(
options.verbose,
`Hydrated ${Object.keys(groups ?? {}).length} participating groups on connect`,
);
} catch (err) {
const error = String(err);
inboundLogger.warn({ error }, "failed hydrating participating groups on connect");
inboundConsoleLog.warn(`Failed hydrating participating groups on connect: ${error}`);
logVerbose(`Failed to hydrate participating groups on connect: ${error}`);
logWhatsAppVerbose(
options.verbose,
`Failed to hydrate participating groups on connect: ${error}`,
);
}
})();
@@ -681,7 +702,7 @@ export async function attachWebInboxToSocket(
detachConnectionUpdate();
closeInboundMonitorSocket(sock);
} catch (err) {
logVerbose(`Socket close failed: ${String(err)}`);
logWhatsAppVerbose(options.verbose, `Socket close failed: ${String(err)}`);
}
},
onClose,

View File

@@ -1,8 +1,3 @@
import {
resolveDefaultGroupPolicy,
resolveOpenProviderRuntimeGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy";
import { vi, type Mock } from "vitest";
export type AsyncMock<TArgs extends unknown[] = unknown[], TResult = unknown> = {
@@ -23,12 +18,13 @@ export function resetPairingSecurityMocks(config: Record<string, unknown>) {
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
}
vi.mock("openclaw/plugin-sdk/config-runtime", () => {
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
loadConfig: (...args: unknown[]) => loadConfigMock(...args),
resolveDefaultGroupPolicy,
resolveOpenProviderRuntimeGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
};
});

View File

@@ -27,6 +27,42 @@ function trimPromptText(value: string | null | undefined): string {
return value?.trim() ?? "";
}
function isDefaultWhatsAppAccountKey(accountId: string): boolean {
return accountId.trim().toLowerCase() === DEFAULT_ACCOUNT_ID;
}
function shouldWriteDefaultWhatsAppAccountConfigAtAccountScope(cfg: OpenClawConfig): boolean {
const accounts = cfg.channels?.whatsapp?.accounts;
if (!accounts) {
return false;
}
if (accounts.default) {
return true;
}
return Object.keys(accounts).some((accountId) => !isDefaultWhatsAppAccountKey(accountId));
}
function resolveDefaultWhatsAppAccountWriteKey(cfg: OpenClawConfig): string {
const accounts = cfg.channels?.whatsapp?.accounts;
if (!accounts) {
return DEFAULT_ACCOUNT_ID;
}
const match = Object.keys(accounts).find((accountId) => isDefaultWhatsAppAccountKey(accountId));
return match ?? DEFAULT_ACCOUNT_ID;
}
function resolveWhatsAppConfigPathPrefix(cfg: OpenClawConfig, accountId: string): string {
if (
accountId === DEFAULT_ACCOUNT_ID &&
shouldWriteDefaultWhatsAppAccountConfigAtAccountScope(cfg)
) {
return `channels.whatsapp.accounts.${resolveDefaultWhatsAppAccountWriteKey(cfg)}`;
}
return accountId === DEFAULT_ACCOUNT_ID
? "channels.whatsapp"
: `channels.whatsapp.accounts.${accountId}`;
}
function mergeWhatsAppConfig(
cfg: OpenClawConfig,
accountId: string,
@@ -35,7 +71,8 @@ function mergeWhatsAppConfig(
): OpenClawConfig {
const channelConfig: WhatsAppConfig = { ...cfg.channels?.whatsapp };
const mutableChannelConfig = channelConfig as Record<string, unknown>;
if (accountId === DEFAULT_ACCOUNT_ID) {
const targetPathPrefix = resolveWhatsAppConfigPathPrefix(cfg, accountId);
if (targetPathPrefix === "channels.whatsapp") {
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) {
if (options?.unsetOnUndefined?.includes(key)) {
@@ -56,7 +93,16 @@ function mergeWhatsAppConfig(
const accounts = {
...(channelConfig.accounts as Record<string, WhatsAppAccountConfig> | undefined),
};
const nextAccount: WhatsAppAccountConfig = { ...accounts[accountId] };
const targetAccountId =
accountId === DEFAULT_ACCOUNT_ID ? resolveDefaultWhatsAppAccountWriteKey(cfg) : accountId;
const lowerDefaultAccount =
accountId === DEFAULT_ACCOUNT_ID && targetAccountId !== DEFAULT_ACCOUNT_ID
? accounts[DEFAULT_ACCOUNT_ID]
: undefined;
const nextAccount: WhatsAppAccountConfig = {
...accounts[targetAccountId],
...lowerDefaultAccount,
};
const mutableNextAccount = nextAccount as Record<string, unknown>;
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) {
@@ -67,7 +113,10 @@ function mergeWhatsAppConfig(
}
mutableNextAccount[key] = value;
}
accounts[accountId] = nextAccount;
accounts[targetAccountId] = nextAccount;
if (lowerDefaultAccount) {
delete accounts[DEFAULT_ACCOUNT_ID];
}
return {
...cfg,
channels: {
@@ -204,14 +253,9 @@ async function promptWhatsAppDmAccess(params: {
const existingPolicy = account.dmPolicy ?? "pairing";
const existingAllowFrom = account.allowFrom ?? [];
const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
const policyKey =
accountId === DEFAULT_ACCOUNT_ID
? "channels.whatsapp.dmPolicy"
: `channels.whatsapp.accounts.${accountId}.dmPolicy`;
const allowFromKey =
accountId === DEFAULT_ACCOUNT_ID
? "channels.whatsapp.allowFrom"
: `channels.whatsapp.accounts.${accountId}.allowFrom`;
const configPathPrefix = resolveWhatsAppConfigPathPrefix(params.cfg, accountId);
const policyKey = `${configPathPrefix}.dmPolicy`;
const allowFromKey = `${configPathPrefix}.allowFrom`;
if (params.forceAllowFrom) {
return await applyWhatsAppOwnerAllowlist({

View File

@@ -205,3 +205,12 @@ export function expectWhatsAppWorkAccountAccessNote(harness: WizardPromptHarness
WHATSAPP_ACCESS_NOTE_TITLE,
);
}
export function expectWhatsAppDefaultAccountAccessNote(harness: WizardPromptHarness): void {
expect(harness.note).toHaveBeenCalledWith(
expect.stringContaining(
"`channels.whatsapp.accounts.default.dmPolicy` + `channels.whatsapp.accounts.default.allowFrom`",
),
WHATSAPP_ACCESS_NOTE_TITLE,
);
}

View File

@@ -1,3 +1,4 @@
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 {
@@ -5,7 +6,10 @@ import {
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
collectOpenGroupPolicyRouteAllowlistWarnings,
createAllowlistProviderGroupPolicyWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { createChannelPluginBase, getChatChannelMeta } from "openclaw/plugin-sdk/core";
import {
@@ -32,6 +36,56 @@ import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./session
export 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");
}
@@ -58,6 +112,7 @@ const whatsappResolveDmPolicy = createScopedDmSecurityResolver<ResolvedWhatsAppA
resolveAllowFrom: (account) => account.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeE164(raw),
inheritSharedDefaultsFromDefaultAccount: true,
});
export function createWhatsAppSetupWizardProxy(
@@ -99,26 +154,41 @@ export function createWhatsAppPluginBase(params: {
setup: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["setup"]>;
isConfigured: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["config"]>["isConfigured"];
}) {
const collectWhatsAppSecurityWarnings =
createAllowlistProviderRouteAllowlistWarningCollector<ResolvedWhatsAppAccount>({
providerConfigPresent: (cfg) => cfg.channels?.whatsapp !== undefined,
resolveGroupPolicy: (account) => account.groupPolicy,
resolveRouteAllowlistConfigured: (account) =>
Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0,
restrictSenders: {
surface: "WhatsApp groups",
openScope: "any member in allowed groups",
groupPolicyPath: "channels.whatsapp.groupPolicy",
groupAllowFromPath: "channels.whatsapp.groupAllowFrom",
},
noRouteAllowlist: {
surface: "WhatsApp groups",
routeAllowlistPath: "channels.whatsapp.groups",
routeScope: "group",
groupPolicyPath: "channels.whatsapp.groupPolicy",
groupAllowFromPath: "channels.whatsapp.groupAllowFrom",
},
});
const collectWhatsAppSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{
account: ResolvedWhatsAppAccount;
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
accountId?: string | null;
}>({
providerConfigPresent: (cfg) => cfg.channels?.whatsapp !== undefined,
resolveGroupPolicy: ({ account }) => account.groupPolicy,
collect: ({ account, accountId, cfg, groupPolicy }) =>
collectOpenGroupPolicyRouteAllowlistWarnings({
groupPolicy,
routeAllowlistConfigured:
Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0,
restrictSenders: {
surface: "WhatsApp groups",
openScope: "any member in allowed groups",
groupPolicyPath: resolveWhatsAppConfigPath({ cfg, accountId, field: "groupPolicy" }),
groupAllowFromPath: resolveWhatsAppConfigPath({
cfg,
accountId,
field: "groupAllowFrom",
}),
},
noRouteAllowlist: {
surface: "WhatsApp groups",
routeAllowlistPath: resolveWhatsAppConfigPath({ cfg, accountId, field: "groups" }),
routeScope: "group",
groupPolicyPath: resolveWhatsAppConfigPath({ cfg, accountId, field: "groupPolicy" }),
groupAllowFromPath: resolveWhatsAppConfigPath({
cfg,
accountId,
field: "groupAllowFrom",
}),
},
}),
});
const base = createChannelPluginBase({
id: WHATSAPP_CHANNEL,
meta: {

View File

@@ -9,6 +9,7 @@ import { createMockBaileys } from "../../../test/mocks/baileys.js";
// Use globalThis to store the mock config so it survives vi.mock hoisting
const CONFIG_KEY = Symbol.for("openclaw:testConfigMock");
const SOURCE_CONFIG_KEY = Symbol.for("openclaw:testSourceConfigMock");
const DEFAULT_CONFIG = {
channels: {
whatsapp: {
@@ -26,13 +27,22 @@ const DEFAULT_CONFIG = {
if (!(globalThis as Record<symbol, unknown>)[CONFIG_KEY]) {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
}
if (!(globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY]) {
(globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY] = () => loadConfigMock();
}
export function setLoadConfigMock(fn: unknown) {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = typeof fn === "function" ? fn : () => fn;
}
export function setRuntimeConfigSourceSnapshotMock(fn: unknown) {
(globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY] =
typeof fn === "function" ? fn : () => fn;
}
export function resetLoadConfigMock() {
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
(globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY] = () => loadConfigMock();
}
function resolveStorePathFallback(store?: string, opts?: { agentId?: string }) {
@@ -58,6 +68,14 @@ function loadConfigMock() {
return DEFAULT_CONFIG;
}
function loadRuntimeConfigSourceSnapshotMock() {
const getter = (globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY];
if (typeof getter === "function") {
return getter();
}
return loadConfigMock();
}
async function updateLastRouteMock(params: {
storePath: string;
sessionKey: string;
@@ -354,6 +372,7 @@ function resolveChannelGroupRequireMentionMock(params: {
}
vi.mock("./auto-reply/config.runtime.js", () => ({
getRuntimeConfigSourceSnapshot: loadRuntimeConfigSourceSnapshotMock,
loadConfig: loadConfigMock,
updateLastRoute: updateLastRouteMock,
loadSessionStore: loadSessionStoreMock,

View File

@@ -1527,6 +1527,48 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
}
}
});
it("starts a fresh session when a scoped WhatsApp group entry only contains activation state", async () => {
const sessionKey =
"agent:main:whatsapp:group:120363406150318674@g.us:thread:whatsapp-account-work";
const storePath = await createStorePath("openclaw-group-activation-backfill-");
await writeSessionStoreFast(storePath, {
[sessionKey]: {
groupActivation: "always",
},
});
const cfg = makeCfg({
storePath,
allowFrom: ["+41796666864"],
});
const result = await initSessionState({
ctx: {
Body: "hello without mention",
RawBody: "hello without mention",
CommandBody: "hello without mention",
From: "120363406150318674@g.us",
To: "+41779241027",
ChatType: "group",
SessionKey: sessionKey,
Provider: "whatsapp",
Surface: "whatsapp",
SenderName: "Peschiño",
SenderE164: "+41796666864",
SenderId: "41796666864:0@s.whatsapp.net",
},
cfg,
commandAuthorized: false,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
expect(result.sessionEntry.groupActivation).toBe("always");
expect(result.sessionEntry.sessionId).toBe(result.sessionId);
expect(typeof result.sessionEntry.updatedAt).toBe("number");
});
});
describe("initSessionState reset triggers in Slack channels", () => {

View File

@@ -451,7 +451,12 @@ export async function initSessionState(params: {
previousSessionId: previousSessionEntry?.sessionId,
});
if (!isNewSession && freshEntry) {
const canReuseExistingEntry =
Boolean(entry?.sessionId) &&
typeof entry?.updatedAt === "number" &&
Number.isFinite(entry.updatedAt);
if (!isNewSession && freshEntry && canReuseExistingEntry) {
sessionId = entry.sessionId;
systemSent = entry.systemSent ?? false;
abortedLastRun = entry.abortedLastRun ?? false;
@@ -608,6 +613,8 @@ export async function initSessionState(params: {
subject: baseEntry?.subject,
groupChannel: baseEntry?.groupChannel,
space: baseEntry?.space,
groupActivation: entry?.groupActivation,
groupActivationNeedsSystemIntro: entry?.groupActivationNeedsSystemIntro,
deliveryContext: deliveryFields.deliveryContext,
// Track originating channel for subagent announce routing.
lastChannel,

View File

@@ -74,6 +74,67 @@ describe("buildAccountScopedDmSecurityPolicy", () => {
normalizeEntry: undefined,
},
},
{
name: "uses accounts.default paths when shared defaults are inherited",
input: {
cfg: cfgWithChannel("demo-default-account", {
default: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
},
work: {},
}),
channelKey: "demo-default-account",
accountId: "work",
fallbackAccountId: "default",
policy: "allowlist",
allowFrom: ["+15550001111"],
policyPathSuffix: "dmPolicy",
inheritSharedDefaultsFromDefaultAccount: true,
},
expected: {
policy: "allowlist",
allowFrom: ["+15550001111"],
policyPath: "channels.demo-default-account.accounts.default.dmPolicy",
allowFromPath: "channels.demo-default-account.accounts.default.",
approveHint: formatPairingApproveHint("demo-default-account"),
normalizeEntry: undefined,
},
},
{
name: "ignores accounts.default paths unless the channel opts into shared default-account inheritance",
input: {
cfg: {
channels: {
"demo-root": {
dmPolicy: "pairing",
allowFrom: ["*"],
accounts: {
default: {
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
},
work: {},
},
},
},
} as unknown as OpenClawConfig,
channelKey: "demo-root",
accountId: "work",
fallbackAccountId: "default",
policy: "pairing",
allowFrom: ["*"],
policyPathSuffix: "dmPolicy",
},
expected: {
policy: "pairing",
allowFrom: ["*"],
policyPath: "channels.demo-root.dmPolicy",
allowFromPath: "channels.demo-root.",
approveHint: formatPairingApproveHint("demo-root"),
normalizeEntry: undefined,
},
},
{
name: "supports custom defaults and approve hints",
input: {

View File

@@ -44,15 +44,49 @@ export function buildAccountScopedDmSecurityPolicy(params: {
approveChannelId?: string;
approveHint?: string;
normalizeEntry?: (raw: string) => string;
inheritSharedDefaultsFromDefaultAccount?: boolean;
}): ChannelSecurityDmPolicy {
const resolvedAccountId = params.accountId ?? params.fallbackAccountId ?? DEFAULT_ACCOUNT_ID;
const channelConfig = (params.cfg.channels as Record<string, unknown> | undefined)?.[
params.channelKey
] as { accounts?: Record<string, unknown> } | undefined;
const useAccountPath = Boolean(channelConfig?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `channels.${params.channelKey}.accounts.${resolvedAccountId}.`
: `channels.${params.channelKey}.`;
] as { accounts?: Record<string, Record<string, unknown>> } | undefined;
const rootBasePath = `channels.${params.channelKey}.`;
const accountBasePath = `channels.${params.channelKey}.accounts.${resolvedAccountId}.`;
const defaultBasePath = `channels.${params.channelKey}.accounts.${DEFAULT_ACCOUNT_ID}.`;
const accountConfig = channelConfig?.accounts?.[resolvedAccountId];
const defaultAccountConfig =
params.inheritSharedDefaultsFromDefaultAccount && resolvedAccountId !== DEFAULT_ACCOUNT_ID
? channelConfig?.accounts?.[DEFAULT_ACCOUNT_ID]
: undefined;
const resolveFieldName = (suffix: string | undefined, fallbackField: string): string | null =>
suffix == null || suffix === ""
? fallbackField
: /^[A-Za-z0-9_-]+$/.test(suffix)
? suffix
: null;
const simplePolicyField = resolveFieldName(params.policyPathSuffix, "dmPolicy");
const simpleAllowFromField = resolveFieldName(params.allowFromPathSuffix, "allowFrom");
const matchesAnyField = (
config: Record<string, unknown> | undefined,
fields: Array<string | null>,
) => fields.some((field) => field != null && config?.[field] !== undefined);
const basePath =
simplePolicyField || simpleAllowFromField
? matchesAnyField(accountConfig, [simplePolicyField, simpleAllowFromField])
? accountBasePath
: matchesAnyField(defaultAccountConfig, [simplePolicyField, simpleAllowFromField])
? defaultBasePath
: matchesAnyField(channelConfig as Record<string, unknown> | undefined, [
simplePolicyField,
simpleAllowFromField,
])
? rootBasePath
: accountConfig
? accountBasePath
: rootBasePath
: accountConfig
? accountBasePath
: rootBasePath;
const allowFromPath = `${basePath}${params.allowFromPathSuffix ?? ""}`;
const policyPath =
params.policyPathSuffix != null ? `${basePath}${params.policyPathSuffix}` : undefined;

View File

@@ -115,6 +115,39 @@ describe("normalizeCompatibilityConfigValues", () => {
});
});
it("moves WhatsApp access defaults into accounts.default for named accounts", () => {
const res = normalizeCompatibilityConfigValues({
channels: {
whatsapp: {
enabled: true,
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
groupPolicy: "open",
groupAllowFrom: [],
accounts: {
work: {
enabled: true,
authDir: "/tmp/wa-work",
},
},
},
},
});
expect(res.config.channels?.whatsapp?.dmPolicy).toBeUndefined();
expect(res.config.channels?.whatsapp?.allowFrom).toBeUndefined();
expect(res.config.channels?.whatsapp?.groupPolicy).toBeUndefined();
expect(res.config.channels?.whatsapp?.groupAllowFrom).toBeUndefined();
expect(res.config.channels?.whatsapp?.accounts?.default).toMatchObject({
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
groupPolicy: "open",
groupAllowFrom: [],
});
expect(res.changes).toContain(
"Moved channels.whatsapp single-account top-level values into channels.whatsapp.accounts.default.",
);
});
it("migrates browser ssrfPolicy allowPrivateNetwork to dangerouslyAllowPrivateNetwork", () => {
const res = normalizeCompatibilityConfigValues({
browser: {

View File

@@ -45,6 +45,61 @@ describe("config schema regressions", () => {
expect(res.success).toBe(true);
});
it("keeps inherited WhatsApp account defaults unset at account scope", () => {
const res = WhatsAppConfigSchema.safeParse({
dmPolicy: "allowlist",
groupPolicy: "open",
debounceMs: 250,
allowFrom: ["+15550001111"],
accounts: {
work: {
allowFrom: ["+15550002222"],
},
},
});
expect(res.success).toBe(true);
if (!res.success) {
return;
}
expect(res.data.dmPolicy).toBe("allowlist");
expect(res.data.groupPolicy).toBe("open");
expect(res.data.debounceMs).toBe(250);
expect(res.data.accounts?.work?.dmPolicy).toBeUndefined();
expect(res.data.accounts?.work?.groupPolicy).toBeUndefined();
expect(res.data.accounts?.work?.debounceMs).toBeUndefined();
});
it("accepts WhatsApp allowlist accounts inheriting allowFrom from accounts.default", () => {
const res = WhatsAppConfigSchema.safeParse({
accounts: {
default: {
allowFrom: ["+15550001111"],
},
work: {
dmPolicy: "allowlist",
},
},
});
expect(res.success).toBe(true);
});
it("accepts WhatsApp allowlist accounts inheriting allowFrom from mixed-case accounts.Default", () => {
const res = WhatsAppConfigSchema.safeParse({
accounts: {
Default: {
allowFrom: ["+15550001111"],
},
work: {
dmPolicy: "allowlist",
},
},
});
expect(res.success).toBe(true);
});
it("accepts signal accountUuid for loop protection", () => {
const res = SignalConfigSchema.safeParse({
accountUuid: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",

View File

@@ -2,8 +2,10 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadConfig } from "./config.js";
import { createConfigIO } from "./io.js";
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
import { withTempHomeConfig } from "./test-helpers.js";
async function withTempHome(run: (home: string) => Promise<void>): Promise<void> {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-"));
@@ -112,4 +114,35 @@ describe("config io paths", () => {
});
expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinTrustedDirs).toEqual(["/ops/bin"]);
});
it("moves WhatsApp shared access defaults into accounts.default during loadConfig() runtime compat", async () => {
await withTempHomeConfig(
{
channels: {
whatsapp: {
enabled: true,
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
groupPolicy: "open",
groupAllowFrom: [],
accounts: {
work: {
enabled: true,
authDir: "/tmp/wa-work",
},
},
},
},
},
async () => {
const loaded = loadConfig();
expect(loaded.channels?.whatsapp?.accounts?.default).toMatchObject({
dmPolicy: "allowlist",
allowFrom: ["+15550001111"],
groupPolicy: "open",
groupAllowFrom: [],
});
},
);
});
});

View File

@@ -978,7 +978,10 @@ function resolveLegacyConfigForRead(
listPluginDoctorLegacyConfigRules({ pluginIds }),
);
if (!resolvedConfigRaw || typeof resolvedConfigRaw !== "object") {
return { effectiveConfigRaw: resolvedConfigRaw, sourceLegacyIssues };
return {
effectiveConfigRaw: resolvedConfigRaw,
sourceLegacyIssues,
};
}
const compat = applyRuntimeLegacyConfigMigrations(resolvedConfigRaw);
return {

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { resolveAccountEntry } from "../routing/account-lookup.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
import {
@@ -36,35 +37,43 @@ const WhatsAppAckReactionSchema = z
.strict()
.optional();
const WhatsAppSharedSchema = z.object({
enabled: z.boolean().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(),
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
contextVisibility: ContextVisibilityModeSchema.optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
groups: WhatsAppGroupsSchema,
ackReaction: WhatsAppAckReactionSchema,
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
debounceMs: z.number().int().nonnegative().optional().default(0),
heartbeat: ChannelHeartbeatVisibilitySchema,
healthMonitor: ChannelHealthMonitorSchema,
});
function buildWhatsAppCommonShape(params: { useDefaults: boolean }) {
return {
enabled: z.boolean().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
configWrites: z.boolean().optional(),
sendReadReceipts: z.boolean().optional(),
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),
dmPolicy: params.useDefaults
? DmPolicySchema.optional().default("pairing")
: DmPolicySchema.optional(),
selfChatMode: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
defaultTo: z.string().optional(),
groupAllowFrom: z.array(z.string()).optional(),
groupPolicy: params.useDefaults
? GroupPolicySchema.optional().default("allowlist")
: GroupPolicySchema.optional(),
contextVisibility: ContextVisibilityModeSchema.optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
groups: WhatsAppGroupsSchema,
ackReaction: WhatsAppAckReactionSchema,
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
debounceMs: params.useDefaults
? z.number().int().nonnegative().optional().default(0)
: z.number().int().nonnegative().optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
healthMonitor: ChannelHealthMonitorSchema,
};
}
function enforceOpenDmPolicyAllowFromStar(params: {
dmPolicy: unknown;
@@ -108,29 +117,35 @@ function enforceAllowlistDmPolicyAllowFrom(params: {
});
}
export const WhatsAppAccountSchema = WhatsAppSharedSchema.extend({
name: z.string().optional(),
enabled: z.boolean().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
authDir: z.string().optional(),
mediaMaxMb: z.number().int().positive().optional(),
}).strict();
export const WhatsAppAccountSchema = z
.object({
...buildWhatsAppCommonShape({ useDefaults: false }),
name: z.string().optional(),
enabled: z.boolean().optional(),
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
authDir: z.string().optional(),
mediaMaxMb: z.number().int().positive().optional(),
})
.strict();
export const WhatsAppConfigSchema = WhatsAppSharedSchema.extend({
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
mediaMaxMb: z.number().int().positive().optional().default(50),
actions: z
.object({
reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(),
polls: z.boolean().optional(),
})
.strict()
.optional(),
})
export const WhatsAppConfigSchema = z
.object({
...buildWhatsAppCommonShape({ useDefaults: true }),
accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
mediaMaxMb: z.number().int().positive().optional().default(50),
actions: z
.object({
reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(),
polls: z.boolean().optional(),
})
.strict()
.optional(),
})
.strict()
.superRefine((value, ctx) => {
const defaultAccount = resolveAccountEntry(value.accounts, "default");
enforceOpenDmPolicyAllowFromStar({
dmPolicy: value.dmPolicy,
allowFrom: value.allowFrom,
@@ -152,8 +167,14 @@ export const WhatsAppConfigSchema = WhatsAppSharedSchema.extend({
if (!account) {
continue;
}
const effectivePolicy = account.dmPolicy ?? value.dmPolicy;
const effectiveAllowFrom = account.allowFrom ?? value.allowFrom;
const effectivePolicy =
account.dmPolicy ??
(accountId === "default" ? undefined : defaultAccount?.dmPolicy) ??
value.dmPolicy;
const effectiveAllowFrom =
account.allowFrom ??
(accountId === "default" ? undefined : defaultAccount?.allowFrom) ??
value.allowFrom;
enforceOpenDmPolicyAllowFromStar({
dmPolicy: effectivePolicy,
allowFrom: effectiveAllowFrom,

View File

@@ -350,6 +350,99 @@ describe("createScopedDmSecurityResolver", () => {
normalizeEntry: expect.any(Function),
});
});
it("uses accounts.default paths when named accounts inherit shared defaults", () => {
const resolveDmPolicy = createScopedDmSecurityResolver<{
accountId?: string | null;
dmPolicy?: string;
allowFrom?: string[];
}>({
channelKey: "demo",
resolvePolicy: (account) => account.dmPolicy,
resolveAllowFrom: (account) => account.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.toLowerCase(),
inheritSharedDefaultsFromDefaultAccount: true,
});
expect(
resolveDmPolicy({
cfg: {
channels: {
demo: {
accounts: {
default: {
dmPolicy: "allowlist",
allowFrom: ["Owner"],
},
alt: {},
},
},
},
},
accountId: "alt",
account: {
accountId: "alt",
dmPolicy: "allowlist",
allowFrom: ["Owner"],
},
}),
).toEqual({
policy: "allowlist",
allowFrom: ["Owner"],
policyPath: "channels.demo.accounts.default.dmPolicy",
allowFromPath: "channels.demo.accounts.default.",
approveHint: formatPairingApproveHint("demo"),
normalizeEntry: expect.any(Function),
});
});
it("ignores accounts.default paths unless the channel opts into shared default-account inheritance", () => {
const resolveDmPolicy = createScopedDmSecurityResolver<{
accountId?: string | null;
dmPolicy?: string;
allowFrom?: string[];
}>({
channelKey: "demo",
resolvePolicy: (account) => account.dmPolicy,
resolveAllowFrom: (account) => account.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.toLowerCase(),
});
expect(
resolveDmPolicy({
cfg: {
channels: {
demo: {
dmPolicy: "pairing",
allowFrom: ["*"],
accounts: {
default: {
dmPolicy: "allowlist",
allowFrom: ["Owner"],
},
alt: {},
},
},
},
},
accountId: "alt",
account: {
accountId: "alt",
dmPolicy: "pairing",
allowFrom: ["*"],
},
}),
).toEqual({
policy: "pairing",
allowFrom: ["*"],
policyPath: "channels.demo.dmPolicy",
allowFromPath: "channels.demo.",
approveHint: formatPairingApproveHint("demo"),
normalizeEntry: expect.any(Function),
});
});
});
describe("createTopLevelChannelConfigBase", () => {

View File

@@ -66,15 +66,49 @@ function buildAccountScopedDmSecurityPolicy(params: {
approveChannelId?: string;
approveHint?: string;
normalizeEntry?: (raw: string) => string;
inheritSharedDefaultsFromDefaultAccount?: boolean;
}) {
const resolvedAccountId = params.accountId ?? params.fallbackAccountId ?? DEFAULT_ACCOUNT_ID;
const channelConfig = (params.cfg.channels as Record<string, unknown> | undefined)?.[
params.channelKey
] as { accounts?: Record<string, unknown> } | undefined;
const useAccountPath = Boolean(channelConfig?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `channels.${params.channelKey}.accounts.${resolvedAccountId}.`
: `channels.${params.channelKey}.`;
] as { accounts?: Record<string, Record<string, unknown>> } | undefined;
const rootBasePath = `channels.${params.channelKey}.`;
const accountBasePath = `channels.${params.channelKey}.accounts.${resolvedAccountId}.`;
const defaultBasePath = `channels.${params.channelKey}.accounts.${DEFAULT_ACCOUNT_ID}.`;
const accountConfig = channelConfig?.accounts?.[resolvedAccountId];
const defaultAccountConfig =
params.inheritSharedDefaultsFromDefaultAccount && resolvedAccountId !== DEFAULT_ACCOUNT_ID
? channelConfig?.accounts?.[DEFAULT_ACCOUNT_ID]
: undefined;
const resolveFieldName = (suffix: string | undefined, fallbackField: string): string | null =>
suffix == null || suffix === ""
? fallbackField
: /^[A-Za-z0-9_-]+$/.test(suffix)
? suffix
: null;
const simplePolicyField = resolveFieldName(params.policyPathSuffix, "dmPolicy");
const simpleAllowFromField = resolveFieldName(params.allowFromPathSuffix, "allowFrom");
const matchesAnyField = (
config: Record<string, unknown> | undefined,
fields: Array<string | null>,
) => fields.some((field) => field != null && config?.[field] !== undefined);
const basePath =
simplePolicyField || simpleAllowFromField
? matchesAnyField(accountConfig, [simplePolicyField, simpleAllowFromField])
? accountBasePath
: matchesAnyField(defaultAccountConfig, [simplePolicyField, simpleAllowFromField])
? defaultBasePath
: matchesAnyField(channelConfig as Record<string, unknown> | undefined, [
simplePolicyField,
simpleAllowFromField,
])
? rootBasePath
: accountConfig
? accountBasePath
: rootBasePath
: accountConfig
? accountBasePath
: rootBasePath;
const allowFromPath = `${basePath}${params.allowFromPathSuffix ?? ""}`;
const policyPath =
params.policyPathSuffix != null ? `${basePath}${params.policyPathSuffix}` : undefined;
@@ -655,6 +689,7 @@ export function createScopedDmSecurityResolver<
approveChannelId?: string;
approveHint?: string;
normalizeEntry?: (raw: string) => string;
inheritSharedDefaultsFromDefaultAccount?: boolean;
}) {
return ({
cfg,
@@ -678,6 +713,7 @@ export function createScopedDmSecurityResolver<
approveChannelId: params.approveChannelId,
approveHint: params.approveHint,
normalizeEntry: params.normalizeEntry,
inheritSharedDefaultsFromDefaultAccount: params.inheritSharedDefaultsFromDefaultAccount,
});
}

View File

@@ -160,6 +160,7 @@ export function createRestrictSendersChannelSecurity<
approveChannelId?: string;
approveHint?: string;
normalizeDmEntry?: (raw: string) => string;
inheritSharedDefaultsFromDefaultAccount?: boolean;
}): ChannelSecurityAdapter<ResolvedAccount> {
return {
resolveDmPolicy: createScopedDmSecurityResolver<ResolvedAccount>({
@@ -173,6 +174,7 @@ export function createRestrictSendersChannelSecurity<
approveChannelId: params.approveChannelId,
approveHint: params.approveHint,
normalizeEntry: params.normalizeDmEntry,
inheritSharedDefaultsFromDefaultAccount: params.inheritSharedDefaultsFromDefaultAccount,
}),
collectWarnings: createAllowlistProviderRestrictSendersWarningCollector<ResolvedAccount>({
providerConfigPresent:

View File

@@ -4,6 +4,7 @@
export { resolveDefaultAgentId } from "../agents/agent-scope.js";
export {
clearRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
getRuntimeConfigSnapshot,
loadConfig,
readConfigFileSnapshotForWrite,

View File

@@ -435,6 +435,7 @@ type ChatChannelSecurityOptions<TResolvedAccount extends { accountId?: string |
approveChannelId?: string;
approveHint?: string;
normalizeEntry?: (raw: string) => string;
inheritSharedDefaultsFromDefaultAccount?: boolean;
};
collectWarnings?: ChannelSecurityAdapter<TResolvedAccount>["collectWarnings"];
collectAuditFindings?: ChannelSecurityAdapter<TResolvedAccount>["collectAuditFindings"];
@@ -543,6 +544,8 @@ function resolveChatChannelSecurity<TResolvedAccount extends { accountId?: strin
approveChannelId: security.dm.approveChannelId,
approveHint: security.dm.approveHint,
normalizeEntry: security.dm.normalizeEntry,
inheritSharedDefaultsFromDefaultAccount:
security.dm.inheritSharedDefaultsFromDefaultAccount,
}),
...(security.collectWarnings ? { collectWarnings: security.collectWarnings } : {}),
...(security.collectAuditFindings