diff --git a/CHANGELOG.md b/CHANGELOG.md index 19284603d1d..e6fde32e12d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/whatsapp/src/account-config.ts b/extensions/whatsapp/src/account-config.ts index bfae8ac90a4..2b86b15e2f1 100644 --- a/extensions/whatsapp/src/account-config.ts +++ b/extensions/whatsapp/src/account-config.ts @@ -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 | 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({ + 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({ + const base = resolveMergedAccountConfig({ channelConfig: rootCfg as WhatsAppAccountConfig | undefined, accounts: rootCfg?.accounts as Record> | undefined, accountId, omitKeys: ["defaultAccount"], }); + const merged = + accountId === DEFAULT_ACCOUNT_ID + ? base + : resolveMergedNamedWhatsAppAccountConfig({ cfg: params.cfg, accountId }); return { accountId, ...merged, diff --git a/extensions/whatsapp/src/accounts.test.ts b/extensions/whatsapp/src/accounts.test.ts index c9d88c3ce2b..b25ac810ce5 100644 --- a/extensions/whatsapp/src/accounts.test.ts +++ b/extensions/whatsapp/src/accounts.test.ts @@ -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[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[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[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[0]["cfg"], + accountId: "work", + }); + + expect(resolved.selfChatMode).toBeUndefined(); + }); }); diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index 60795bdb999..f1237ef8bf0 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -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, diff --git a/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts b/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts index 0ef070cdb5c..85d04adc245 100644 --- a/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts +++ b/extensions/whatsapp/src/auto-reply.broadcast-groups.combined.test.ts @@ -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: ["*"] } }, diff --git a/extensions/whatsapp/src/auto-reply.test-harness.ts b/extensions/whatsapp/src/auto-reply.test-harness.ts index 6f419736720..b1aa4ad83f5 100644 --- a/extensions/whatsapp/src/auto-reply.test-harness.ts +++ b/extensions/whatsapp/src/auto-reply.test-harness.ts @@ -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) | undefined; + let capturedOptions: + | { + onMessage: (msg: WebInboundMessage) => Promise; + debounceMs?: number; + selfChatMode?: boolean; + } + | undefined; const listenerFactory = async (opts: { onMessage: (msg: WebInboundMessage) => Promise; + debounceMs?: number; + selfChatMode?: boolean; }) => { capturedOnMessage = opts.onMessage; + capturedOptions = opts; return { close: vi.fn() }; }; return { listenerFactory, getOnMessage: () => capturedOnMessage, + getLastOptions: () => capturedOptions, }; } diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 743202f9027..ee90c4fcf3d 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -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(); diff --git a/extensions/whatsapp/src/auto-reply/config.runtime.ts b/extensions/whatsapp/src/auto-reply/config.runtime.ts index 9af96d32c16..eec78cd4c7c 100644 --- a/extensions/whatsapp/src/auto-reply/config.runtime.ts +++ b/extensions/whatsapp/src/auto-reply/config.runtime.ts @@ -1,5 +1,6 @@ export { evaluateSessionFreshness, + getRuntimeConfigSourceSnapshot, loadConfig, loadSessionStore, recordSessionMetaFromInbound, diff --git a/extensions/whatsapp/src/auto-reply/mentions.ts b/extensions/whatsapp/src/auto-reply/mentions.ts index df1130fd794..b9967a77961 100644 --- a/extensions/whatsapp/src/auto-reply/mentions.ts +++ b/extensions/whatsapp/src/auto-reply/mentions.ts @@ -13,6 +13,7 @@ import type { WebInboundMsg } from "./types.js"; export type MentionConfig = { mentionRegexes: RegExp[]; allowFrom?: Array; + 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) { diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index 801f39ba26c..718a5cae371 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -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; + sourceCfg?: ReturnType | 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; diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts index 594d6a4df2b..1cdf9aee6b9 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts @@ -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: { diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts index cb9cf8ad27f..d06fd34ed8a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts @@ -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; 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, diff --git a/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts b/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts index 2ba31112042..597df193458 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/broadcast.ts @@ -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, { diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-activation.test.ts b/extensions/whatsapp/src/auto-reply/monitor/group-activation.test.ts new file mode 100644 index 00000000000..008b8849946 --- /dev/null +++ b/extensions/whatsapp/src/auto-reply/monitor/group-activation.test.ts @@ -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> = []; + + 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"); + }); +}); diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts b/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts index 584d4135872..4ea8bcd17a5 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-activation.ts @@ -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, 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) { + const accountIds = Object.keys(cfg.channels?.whatsapp?.accounts ?? {}); + return accountIds.some((accountId) => normalizeAccountId(accountId) !== DEFAULT_ACCOUNT_ID); } -export function resolveGroupRequireMentionFor( - cfg: ReturnType, - 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; + 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; } diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts index ebb5583de6e..867531f6a5f 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts @@ -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, diff --git a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts index 46d8413d5f4..281c49f91f9 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/on-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/on-message.ts @@ -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, diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index 3c3e483d7d5..90e2a4eeae7 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -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; - msg: WebInboundMsg; -}): Promise { - 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; - 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[0], + ): ResolvedWhatsAppInboundPolicy => resolveWhatsAppInboundPolicy(params), +}; diff --git a/extensions/whatsapp/src/auto-reply/types.ts b/extensions/whatsapp/src/auto-reply/types.ts index 1d954102d0d..f8fa115795d 100644 --- a/extensions/whatsapp/src/auto-reply/types.ts +++ b/extensions/whatsapp/src/auto-reply/types.ts @@ -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[0]["onMessage"] extends ( - msg: infer M, -) => unknown - ? M - : never; +export type WebInboundMsg = WebInboundMessage; export type WebChannelStatus = { running: boolean; diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts index 48c35d035da..ca6d0c4c969 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-monitor.test.ts @@ -35,7 +35,7 @@ const makeConfig = (overrides: Record) => ...overrides, }) as unknown as ReturnType; -function runGroupGating(params: { +async function runGroupGating(params: { cfg: ReturnType; msg: Record; 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", diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts index d05014f9f03..7c593f1ba4f 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts @@ -34,7 +34,7 @@ describe("isBotMentionedFromTargets", () => { function expectMentioned( msg: WebInboundMsg, - cfg: { mentionRegexes: RegExp[]; allowFrom?: Array }, + cfg: { mentionRegexes: RegExp[]; allowFrom?: Array; 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", diff --git a/extensions/whatsapp/src/channel.setup.test.ts b/extensions/whatsapp/src/channel.setup.test.ts index 4021c7eec77..cbf91219c1c 100644 --- a/extensions/whatsapp/src/channel.setup.test.ts +++ b/extensions/whatsapp/src/channel.setup.test.ts @@ -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); diff --git a/extensions/whatsapp/src/group-session-key.test.ts b/extensions/whatsapp/src/group-session-key.test.ts new file mode 100644 index 00000000000..53a5581e992 --- /dev/null +++ b/extensions/whatsapp/src/group-session-key.test.ts @@ -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"); + }); +}); diff --git a/extensions/whatsapp/src/group-session-key.ts b/extensions/whatsapp/src/group-session-key.ts new file mode 100644 index 00000000000..bb187911734 --- /dev/null +++ b/extensions/whatsapp/src/group-session-key.ts @@ -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, +}; diff --git a/extensions/whatsapp/src/inbound-policy.ts b/extensions/whatsapp/src/inbound-policy.ts new file mode 100644 index 00000000000..4468ad0ad8e --- /dev/null +++ b/extensions/whatsapp/src/inbound-policy.ts @@ -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 { + 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; +} diff --git a/extensions/whatsapp/src/inbound/access-control.test.ts b/extensions/whatsapp/src/inbound/access-control.test.ts index 4654c54091b..240d9bbc58e 100644 --- a/extensions/whatsapp/src/inbound/access-control.test.ts +++ b/extensions/whatsapp/src/inbound/access-control.test.ts @@ -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; + 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); }); }); diff --git a/extensions/whatsapp/src/inbound/access-control.ts b/extensions/whatsapp/src/inbound/access-control.ts index 2104dff10e7..903953e38f1 100644 --- a/extensions/whatsapp/src/inbound/access-control.ts +++ b/extensions/whatsapp/src/inbound/access-control.ts @@ -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; }; remoteJid: string; }): Promise { 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, }; diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts index f7e2517b467..45aca63325e 100644 --- a/extensions/whatsapp/src/inbound/monitor.ts +++ b/extensions/whatsapp/src/inbound/monitor.ts @@ -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, diff --git a/extensions/whatsapp/src/pairing-security.test-harness.ts b/extensions/whatsapp/src/pairing-security.test-harness.ts index fb9af991cc7..3c3aef83344 100644 --- a/extensions/whatsapp/src/pairing-security.test-harness.ts +++ b/extensions/whatsapp/src/pairing-security.test-harness.ts @@ -1,8 +1,3 @@ -import { - resolveDefaultGroupPolicy, - resolveOpenProviderRuntimeGroupPolicy, - warnMissingProviderGroupPolicyFallbackOnce, -} from "openclaw/plugin-sdk/runtime-group-policy"; import { vi, type Mock } from "vitest"; export type AsyncMock = { @@ -23,12 +18,13 @@ export function resetPairingSecurityMocks(config: Record) { 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( + "openclaw/plugin-sdk/config-runtime", + ); return { + ...actual, loadConfig: (...args: unknown[]) => loadConfigMock(...args), - resolveDefaultGroupPolicy, - resolveOpenProviderRuntimeGroupPolicy, - warnMissingProviderGroupPolicyFallbackOnce, }; }); diff --git a/extensions/whatsapp/src/setup-finalize.ts b/extensions/whatsapp/src/setup-finalize.ts index 5efcce46393..edd9eed17d0 100644 --- a/extensions/whatsapp/src/setup-finalize.ts +++ b/extensions/whatsapp/src/setup-finalize.ts @@ -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; - 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 | 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; 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({ diff --git a/extensions/whatsapp/src/setup-test-helpers.ts b/extensions/whatsapp/src/setup-test-helpers.ts index 317d9c81a5e..ad2f14d6841 100644 --- a/extensions/whatsapp/src/setup-test-helpers.ts +++ b/extensions/whatsapp/src/setup-test-helpers.ts @@ -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, + ); +} diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index a1360e5d888..22822ebb728 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -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 | 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[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 | 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[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 account.allowFrom, policyPathSuffix: "dmPolicy", normalizeEntry: (raw) => normalizeE164(raw), + inheritSharedDefaultsFromDefaultAccount: true, }); export function createWhatsAppSetupWizardProxy( @@ -99,26 +154,41 @@ export function createWhatsAppPluginBase(params: { setup: NonNullable["setup"]>; isConfigured: NonNullable["config"]>["isConfigured"]; }) { - const collectWhatsAppSecurityWarnings = - createAllowlistProviderRouteAllowlistWarningCollector({ - 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[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: { diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index 1e51948a7b0..da856b2d311 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -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)[CONFIG_KEY]) { (globalThis as Record)[CONFIG_KEY] = () => DEFAULT_CONFIG; } +if (!(globalThis as Record)[SOURCE_CONFIG_KEY]) { + (globalThis as Record)[SOURCE_CONFIG_KEY] = () => loadConfigMock(); +} export function setLoadConfigMock(fn: unknown) { (globalThis as Record)[CONFIG_KEY] = typeof fn === "function" ? fn : () => fn; } +export function setRuntimeConfigSourceSnapshotMock(fn: unknown) { + (globalThis as Record)[SOURCE_CONFIG_KEY] = + typeof fn === "function" ? fn : () => fn; +} + export function resetLoadConfigMock() { (globalThis as Record)[CONFIG_KEY] = () => DEFAULT_CONFIG; + (globalThis as Record)[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)[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, diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 872fb5d4af9..624d73d7fd0 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -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", () => { diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index d72b6152995..86ff340a0dc 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -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, diff --git a/src/channels/plugins/helpers.test.ts b/src/channels/plugins/helpers.test.ts index 3b3330316a2..96d7cd8d3d3 100644 --- a/src/channels/plugins/helpers.test.ts +++ b/src/channels/plugins/helpers.test.ts @@ -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: { diff --git a/src/channels/plugins/helpers.ts b/src/channels/plugins/helpers.ts index 91983b0dd58..6442df802f5 100644 --- a/src/channels/plugins/helpers.ts +++ b/src/channels/plugins/helpers.ts @@ -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 | undefined)?.[ params.channelKey - ] as { accounts?: Record } | undefined; - const useAccountPath = Boolean(channelConfig?.accounts?.[resolvedAccountId]); - const basePath = useAccountPath - ? `channels.${params.channelKey}.accounts.${resolvedAccountId}.` - : `channels.${params.channelKey}.`; + ] as { accounts?: Record> } | 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 | undefined, + fields: Array, + ) => 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 | undefined, [ + simplePolicyField, + simpleAllowFromField, + ]) + ? rootBasePath + : accountConfig + ? accountBasePath + : rootBasePath + : accountConfig + ? accountBasePath + : rootBasePath; const allowFromPath = `${basePath}${params.allowFromPathSuffix ?? ""}`; const policyPath = params.policyPathSuffix != null ? `${basePath}${params.policyPathSuffix}` : undefined; diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index ef74147aec3..8d299d2eaaa 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -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: { diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 91908ca1154..bf2449e1021 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -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", diff --git a/src/config/io.compat.test.ts b/src/config/io.compat.test.ts index fade73e9e1a..3d249c7d4a2 100644 --- a/src/config/io.compat.test.ts +++ b/src/config/io.compat.test.ts @@ -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): Promise { 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: [], + }); + }, + ); + }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 5241474f25c..f3111d732d5 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -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 { diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 1ff2b001981..d26f75130bb 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -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, diff --git a/src/plugin-sdk/channel-config-helpers.test.ts b/src/plugin-sdk/channel-config-helpers.test.ts index c9651e85816..88ef61d6b5d 100644 --- a/src/plugin-sdk/channel-config-helpers.test.ts +++ b/src/plugin-sdk/channel-config-helpers.test.ts @@ -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", () => { diff --git a/src/plugin-sdk/channel-config-helpers.ts b/src/plugin-sdk/channel-config-helpers.ts index ea538dc84cc..ae1ce3ddbf3 100644 --- a/src/plugin-sdk/channel-config-helpers.ts +++ b/src/plugin-sdk/channel-config-helpers.ts @@ -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 | undefined)?.[ params.channelKey - ] as { accounts?: Record } | undefined; - const useAccountPath = Boolean(channelConfig?.accounts?.[resolvedAccountId]); - const basePath = useAccountPath - ? `channels.${params.channelKey}.accounts.${resolvedAccountId}.` - : `channels.${params.channelKey}.`; + ] as { accounts?: Record> } | 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 | undefined, + fields: Array, + ) => 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 | 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, }); } diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index c0bd2cb0be0..360e3b85784 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -160,6 +160,7 @@ export function createRestrictSendersChannelSecurity< approveChannelId?: string; approveHint?: string; normalizeDmEntry?: (raw: string) => string; + inheritSharedDefaultsFromDefaultAccount?: boolean; }): ChannelSecurityAdapter { return { resolveDmPolicy: createScopedDmSecurityResolver({ @@ -173,6 +174,7 @@ export function createRestrictSendersChannelSecurity< approveChannelId: params.approveChannelId, approveHint: params.approveHint, normalizeEntry: params.normalizeDmEntry, + inheritSharedDefaultsFromDefaultAccount: params.inheritSharedDefaultsFromDefaultAccount, }), collectWarnings: createAllowlistProviderRestrictSendersWarningCollector({ providerConfigPresent: diff --git a/src/plugin-sdk/config-runtime.ts b/src/plugin-sdk/config-runtime.ts index c9461f0f7c2..02d049c170e 100644 --- a/src/plugin-sdk/config-runtime.ts +++ b/src/plugin-sdk/config-runtime.ts @@ -4,6 +4,7 @@ export { resolveDefaultAgentId } from "../agents/agent-scope.js"; export { clearRuntimeConfigSnapshot, + getRuntimeConfigSourceSnapshot, getRuntimeConfigSnapshot, loadConfig, readConfigFileSnapshotForWrite, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 5599e318474..467b193b62e 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -435,6 +435,7 @@ type ChatChannelSecurityOptions string; + inheritSharedDefaultsFromDefaultAccount?: boolean; }; collectWarnings?: ChannelSecurityAdapter["collectWarnings"]; collectAuditFindings?: ChannelSecurityAdapter["collectAuditFindings"]; @@ -543,6 +544,8 @@ function resolveChatChannelSecurity