mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
fix(whatsapp): isolate multi-account inbound state and align shared defaults (#65700)
* refactor(whatsapp): centralize inbound policy resolution * fix(whatsapp): scope named-account group session keys * fix(whatsapp): preserve legacy group activation during scoped-key migration * fix(whatsapp): wire shared defaults through accounts.default * fix(whatsapp): align schema, helpers, and monitor behavior * fix(whatsapp): restore verbose inbound diagnostics * chore(config): refresh whatsapp changelog and baseline hashes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
mergeAccountConfig,
|
||||
resolveAccountEntry,
|
||||
resolveMergedAccountConfig,
|
||||
type OpenClawConfig,
|
||||
@@ -10,6 +11,23 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-streaming";
|
||||
import type { WhatsAppAccountConfig } from "./account-types.js";
|
||||
|
||||
function resolveWhatsAppDefaultAccountSharedConfig(
|
||||
cfg: OpenClawConfig,
|
||||
): Partial<WhatsAppAccountConfig> | undefined {
|
||||
const defaultAccount = resolveAccountEntry(cfg.channels?.whatsapp?.accounts, DEFAULT_ACCOUNT_ID);
|
||||
if (!defaultAccount) {
|
||||
return undefined;
|
||||
}
|
||||
const {
|
||||
enabled: _ignoredEnabled,
|
||||
name: _ignoredName,
|
||||
authDir: _ignoredAuthDir,
|
||||
selfChatMode: _ignoredSelfChatMode,
|
||||
...sharedDefaults
|
||||
} = defaultAccount;
|
||||
return sharedDefaults;
|
||||
}
|
||||
|
||||
function _resolveWhatsAppAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
@@ -17,18 +35,39 @@ function _resolveWhatsAppAccountConfig(
|
||||
return resolveAccountEntry(cfg.channels?.whatsapp?.accounts, accountId);
|
||||
}
|
||||
|
||||
function resolveMergedNamedWhatsAppAccountConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
}): WhatsAppAccountConfig {
|
||||
const rootCfg = params.cfg.channels?.whatsapp;
|
||||
const accountConfig = _resolveWhatsAppAccountConfig(params.cfg, params.accountId);
|
||||
return {
|
||||
...mergeAccountConfig<WhatsAppAccountConfig>({
|
||||
channelConfig: rootCfg as WhatsAppAccountConfig | undefined,
|
||||
accountConfig: undefined,
|
||||
omitKeys: ["defaultAccount"],
|
||||
}),
|
||||
...resolveWhatsAppDefaultAccountSharedConfig(params.cfg),
|
||||
...accountConfig,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMergedWhatsAppAccountConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): WhatsAppAccountConfig & { accountId: string } {
|
||||
const rootCfg = params.cfg.channels?.whatsapp;
|
||||
const accountId = params.accountId?.trim() || rootCfg?.defaultAccount || DEFAULT_ACCOUNT_ID;
|
||||
const merged = resolveMergedAccountConfig<WhatsAppAccountConfig>({
|
||||
const base = resolveMergedAccountConfig<WhatsAppAccountConfig>({
|
||||
channelConfig: rootCfg as WhatsAppAccountConfig | undefined,
|
||||
accounts: rootCfg?.accounts as Record<string, Partial<WhatsAppAccountConfig>> | undefined,
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
});
|
||||
const merged =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? base
|
||||
: resolveMergedNamedWhatsAppAccountConfig({ cfg: params.cfg, accountId });
|
||||
return {
|
||||
accountId,
|
||||
...merged,
|
||||
|
||||
@@ -71,4 +71,112 @@ describe("resolveWhatsAppAuthDir", () => {
|
||||
expect(resolved.messagePrefix).toBe("[root]");
|
||||
expect(resolved.debounceMs).toBe(250);
|
||||
});
|
||||
|
||||
it("inherits shared defaults from accounts.default for named accounts", () => {
|
||||
const resolved = resolveWhatsAppAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
default: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["+15550001111"],
|
||||
groupPolicy: "open",
|
||||
groupAllowFrom: ["+15550002222"],
|
||||
defaultTo: "+15550003333",
|
||||
reactionLevel: "extensive",
|
||||
historyLimit: 42,
|
||||
mediaMaxMb: 12,
|
||||
},
|
||||
work: {
|
||||
authDir: "/tmp/work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.dmPolicy).toBe("allowlist");
|
||||
expect(resolved.allowFrom).toEqual(["+15550001111"]);
|
||||
expect(resolved.groupPolicy).toBe("open");
|
||||
expect(resolved.groupAllowFrom).toEqual(["+15550002222"]);
|
||||
expect(resolved.defaultTo).toBe("+15550003333");
|
||||
expect(resolved.reactionLevel).toBe("extensive");
|
||||
expect(resolved.historyLimit).toBe(42);
|
||||
expect(resolved.mediaMaxMb).toBe(12);
|
||||
});
|
||||
|
||||
it("prefers account overrides and accounts.default over root defaults", () => {
|
||||
const resolved = resolveWhatsAppAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupPolicy: "disabled",
|
||||
accounts: {
|
||||
default: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["+15550001111"],
|
||||
groupPolicy: "open",
|
||||
},
|
||||
work: {
|
||||
authDir: "/tmp/work",
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.dmPolicy).toBe("pairing");
|
||||
expect(resolved.allowFrom).toEqual(["+15550001111"]);
|
||||
expect(resolved.groupPolicy).toBe("open");
|
||||
});
|
||||
|
||||
it("does not inherit default-account authDir for named accounts", () => {
|
||||
const resolved = resolveWhatsAppAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
default: {
|
||||
authDir: "/tmp/default-auth",
|
||||
name: "Personal",
|
||||
},
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.authDir).toMatch(/whatsapp[/\\]work$/);
|
||||
expect(resolved.name).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inherit default-account selfChatMode for named accounts", () => {
|
||||
const resolved = resolveWhatsAppAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
default: {
|
||||
selfChatMode: true,
|
||||
},
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof resolveWhatsAppAccount>[0]["cfg"],
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.selfChatMode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: ["*"] } },
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
resetLoadConfigMock as _resetLoadConfigMock,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
export { resetBaileysMocks, resetLoadConfigMock, setLoadConfigMock } from "./test-helpers.js";
|
||||
export {
|
||||
resetBaileysMocks,
|
||||
resetLoadConfigMock,
|
||||
setLoadConfigMock,
|
||||
setRuntimeConfigSourceSnapshotMock,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
// Avoid exporting inferred vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||
type AnyExport = any;
|
||||
@@ -179,16 +184,27 @@ export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
|
||||
|
||||
export function createWebListenerFactoryCapture(): AnyExport {
|
||||
let capturedOnMessage: ((msg: WebInboundMessage) => Promise<void>) | undefined;
|
||||
let capturedOptions:
|
||||
| {
|
||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||
debounceMs?: number;
|
||||
selfChatMode?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||
debounceMs?: number;
|
||||
selfChatMode?: boolean;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
capturedOptions = opts;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
return {
|
||||
listenerFactory,
|
||||
getOnMessage: () => capturedOnMessage,
|
||||
getLastOptions: () => capturedOptions,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export {
|
||||
evaluateSessionFreshness,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
loadConfig,
|
||||
loadSessionStore,
|
||||
recordSessionMetaFromInbound,
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { WebInboundMsg } from "./types.js";
|
||||
export type MentionConfig = {
|
||||
mentionRegexes: RegExp[];
|
||||
allowFrom?: Array<string | number>;
|
||||
isSelfChat?: boolean;
|
||||
};
|
||||
|
||||
export type MentionTargets = {
|
||||
@@ -43,7 +44,10 @@ export function isBotMentionedFromTargets(
|
||||
// Remove zero-width and directionality markers WhatsApp injects around display names
|
||||
normalizeMentionText(text);
|
||||
|
||||
const isSelfChat = isSelfChatMode(targets.self.e164, mentionCfg.allowFrom);
|
||||
const isSelfChat =
|
||||
typeof mentionCfg.isSelfChat === "boolean"
|
||||
? mentionCfg.isSelfChat
|
||||
: isSelfChatMode(targets.self.e164, mentionCfg.allowFrom);
|
||||
|
||||
const hasMentions = targets.normalizedMentions.length > 0;
|
||||
if (hasMentions && !isSelfChat) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveAccountEntry } from "openclaw/plugin-sdk/account-core";
|
||||
import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
|
||||
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
sleepWithAbort,
|
||||
} from "../reconnect.js";
|
||||
import { formatError, getWebAuthAgeMs, readWebSelfId } from "../session.js";
|
||||
import { loadConfig } from "./config.runtime.js";
|
||||
import { getRuntimeConfigSourceSnapshot, loadConfig } from "./config.runtime.js";
|
||||
import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js";
|
||||
import { buildMentionConfig } from "./mentions.js";
|
||||
import { createWebChannelStatusController } from "./monitor-state.js";
|
||||
@@ -59,6 +60,31 @@ function isNoListenerReconnectError(lastError?: string): boolean {
|
||||
return typeof lastError === "string" && /No active WhatsApp Web listener/i.test(lastError);
|
||||
}
|
||||
|
||||
function resolveExplicitWhatsAppDebounceOverride(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
sourceCfg?: ReturnType<typeof loadConfig> | null;
|
||||
accountId: string;
|
||||
}): number | undefined {
|
||||
const channel = params.sourceCfg?.channels?.whatsapp;
|
||||
if (!channel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const accountId = normalizeReconnectAccountId(params.accountId);
|
||||
const accountDebounce = resolveAccountEntry(channel.accounts, accountId)?.debounceMs;
|
||||
if (accountDebounce !== undefined) {
|
||||
return accountDebounce;
|
||||
}
|
||||
if (accountId !== "default") {
|
||||
const defaultAccountDebounce = resolveAccountEntry(channel.accounts, "default")?.debounceMs;
|
||||
if (defaultAccountDebounce !== undefined) {
|
||||
return defaultAccountDebounce;
|
||||
}
|
||||
}
|
||||
|
||||
return channel.debounceMs;
|
||||
}
|
||||
|
||||
export async function monitorWebChannel(
|
||||
verbose: boolean,
|
||||
listenerFactory: typeof attachWebInboxToSocket | undefined = attachWebInboxToSocket,
|
||||
@@ -79,6 +105,7 @@ export async function monitorWebChannel(
|
||||
statusController.emit();
|
||||
|
||||
const baseCfg = loadConfig();
|
||||
const sourceCfg = getRuntimeConfigSourceSnapshot();
|
||||
const account = resolveWhatsAppAccount({
|
||||
cfg: baseCfg,
|
||||
accountId: tuning.accountId,
|
||||
@@ -108,7 +135,7 @@ export async function monitorWebChannel(
|
||||
const reconnectPolicy = resolveReconnectPolicy(cfg, tuning.reconnect);
|
||||
const baseMentionConfig = buildMentionConfig(cfg);
|
||||
const groupHistoryLimit =
|
||||
cfg.channels?.whatsapp?.accounts?.[tuning.accountId ?? ""]?.historyLimit ??
|
||||
account.historyLimit ??
|
||||
cfg.channels?.whatsapp?.historyLimit ??
|
||||
cfg.messages?.groupChat?.historyLimit ??
|
||||
DEFAULT_GROUP_HISTORY_LIMIT;
|
||||
@@ -166,7 +193,15 @@ export async function monitorWebChannel(
|
||||
}
|
||||
|
||||
const connectionId = newConnectionId();
|
||||
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "whatsapp" });
|
||||
const inboundDebounceMs = resolveInboundDebounceMs({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
overrideMs: resolveExplicitWhatsAppDebounceOverride({
|
||||
cfg,
|
||||
sourceCfg,
|
||||
accountId: account.accountId,
|
||||
}),
|
||||
});
|
||||
const shouldDebounce = (msg: WebInboundMsg) => {
|
||||
if (msg.mediaPath || msg.mediaType) {
|
||||
return false;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { formatError } from "../../session.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import { resolveGroupActivationFor } from "./group-activation.js";
|
||||
|
||||
export function maybeSendAckReaction(params: {
|
||||
export async function maybeSendAckReaction(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
msg: WebInboundMsg;
|
||||
agentId: string;
|
||||
@@ -41,8 +41,9 @@ export function maybeSendAckReaction(params: {
|
||||
|
||||
const activation =
|
||||
params.msg.chatType === "group"
|
||||
? resolveGroupActivationFor({
|
||||
? await resolveGroupActivationFor({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
conversationId: conversationIdForCheck,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeSessionStore } from "../../auto-reply.test-harness.js";
|
||||
import { loadSessionStore } from "../config.runtime.js";
|
||||
import { resolveGroupActivationFor } from "./group-activation.js";
|
||||
|
||||
describe("resolveGroupActivationFor", () => {
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) {
|
||||
await cleanups.pop()?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads legacy named-account group activation and backfills the scoped key", async () => {
|
||||
const sessionKey = "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work";
|
||||
const legacySessionKey = "agent:main:whatsapp:group:123@g.us";
|
||||
const { storePath, cleanup } = await makeSessionStore({
|
||||
[legacySessionKey]: {
|
||||
groupActivation: "always",
|
||||
sessionId: "legacy-session",
|
||||
updatedAt: 123,
|
||||
},
|
||||
});
|
||||
cleanups.push(cleanup);
|
||||
|
||||
const activation = await resolveGroupActivationFor({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
} as never,
|
||||
accountId: "work",
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
conversationId: "123@g.us",
|
||||
});
|
||||
|
||||
expect(activation).toBe("always");
|
||||
await vi.waitFor(() => {
|
||||
const scopedEntry = loadSessionStore(storePath, { skipCache: true })[sessionKey];
|
||||
expect(scopedEntry?.groupActivation).toBe("always");
|
||||
expect(scopedEntry?.sessionId).toBeUndefined();
|
||||
expect(scopedEntry?.updatedAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legacy group activation when the scoped entry already exists without activation", async () => {
|
||||
const sessionKey = "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work";
|
||||
const legacySessionKey = "agent:main:whatsapp:group:123@g.us";
|
||||
const { storePath, cleanup } = await makeSessionStore({
|
||||
[legacySessionKey]: {
|
||||
groupActivation: "always",
|
||||
},
|
||||
[sessionKey]: {
|
||||
sessionId: "scoped-session",
|
||||
},
|
||||
});
|
||||
cleanups.push(cleanup);
|
||||
|
||||
const activation = await resolveGroupActivationFor({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
accounts: {
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
} as never,
|
||||
accountId: "work",
|
||||
agentId: "main",
|
||||
sessionKey,
|
||||
conversationId: "123@g.us",
|
||||
});
|
||||
|
||||
expect(activation).toBe("always");
|
||||
await vi.waitFor(() => {
|
||||
const scopedEntry = loadSessionStore(storePath, { skipCache: true })[sessionKey];
|
||||
expect(scopedEntry?.groupActivation).toBe("always");
|
||||
expect(scopedEntry?.sessionId).toBe("scoped-session");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not wake the default account from an activation-only legacy group entry in multi-account setups", async () => {
|
||||
const defaultSessionKey = "agent:main:whatsapp:group:123@g.us";
|
||||
const workSessionKey = "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work";
|
||||
const { storePath, cleanup } = await makeSessionStore({
|
||||
[defaultSessionKey]: {
|
||||
groupActivation: "always",
|
||||
},
|
||||
});
|
||||
cleanups.push(cleanup);
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
} as never;
|
||||
|
||||
const workActivation = await resolveGroupActivationFor({
|
||||
cfg,
|
||||
accountId: "work",
|
||||
agentId: "main",
|
||||
sessionKey: workSessionKey,
|
||||
conversationId: "123@g.us",
|
||||
});
|
||||
|
||||
expect(workActivation).toBe("always");
|
||||
|
||||
const defaultActivation = await resolveGroupActivationFor({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: defaultSessionKey,
|
||||
conversationId: "123@g.us",
|
||||
});
|
||||
|
||||
expect(defaultActivation).toBe("mention");
|
||||
await vi.waitFor(() => {
|
||||
const scopedEntry = loadSessionStore(storePath, { skipCache: true })[workSessionKey];
|
||||
expect(scopedEntry?.groupActivation).toBe("always");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat mixed-case default account keys as named accounts", async () => {
|
||||
const defaultSessionKey = "agent:main:whatsapp:group:123@g.us";
|
||||
const { storePath, cleanup } = await makeSessionStore({
|
||||
[defaultSessionKey]: {
|
||||
groupActivation: "always",
|
||||
},
|
||||
});
|
||||
cleanups.push(cleanup);
|
||||
|
||||
const activation = await resolveGroupActivationFor({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
Default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
} as never,
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
sessionKey: defaultSessionKey,
|
||||
conversationId: "123@g.us",
|
||||
});
|
||||
|
||||
expect(activation).toBe("always");
|
||||
});
|
||||
});
|
||||
@@ -1,52 +1,36 @@
|
||||
import {
|
||||
resolveChannelGroupPolicy,
|
||||
resolveChannelGroupRequireMention,
|
||||
loadSessionStore,
|
||||
resolveGroupSessionKey,
|
||||
resolveStorePath,
|
||||
} from "../config.runtime.js";
|
||||
import { updateSessionStore } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import { resolveWhatsAppLegacyGroupSessionKey } from "../../group-session-key.js";
|
||||
import { resolveWhatsAppInboundPolicy } from "../../inbound-policy.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config.runtime.js";
|
||||
import { normalizeGroupActivation } from "./group-activation.runtime.js";
|
||||
|
||||
type LoadConfigFn = typeof import("../config.runtime.js").loadConfig;
|
||||
|
||||
export function resolveGroupPolicyFor(cfg: ReturnType<LoadConfigFn>, conversationId: string) {
|
||||
const groupId = resolveGroupSessionKey({
|
||||
From: conversationId,
|
||||
ChatType: "group",
|
||||
Provider: "whatsapp",
|
||||
})?.id;
|
||||
const whatsappCfg = cfg.channels?.whatsapp as
|
||||
| { groupAllowFrom?: string[]; allowFrom?: string[] }
|
||||
| undefined;
|
||||
const hasGroupAllowFrom = Boolean(
|
||||
whatsappCfg?.groupAllowFrom?.length || whatsappCfg?.allowFrom?.length,
|
||||
);
|
||||
return resolveChannelGroupPolicy({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
groupId: groupId ?? conversationId,
|
||||
hasGroupAllowFrom,
|
||||
});
|
||||
function hasNamedWhatsAppAccounts(cfg: ReturnType<LoadConfigFn>) {
|
||||
const accountIds = Object.keys(cfg.channels?.whatsapp?.accounts ?? {});
|
||||
return accountIds.some((accountId) => normalizeAccountId(accountId) !== DEFAULT_ACCOUNT_ID);
|
||||
}
|
||||
|
||||
export function resolveGroupRequireMentionFor(
|
||||
cfg: ReturnType<LoadConfigFn>,
|
||||
conversationId: string,
|
||||
function isActivationOnlyEntry(
|
||||
entry:
|
||||
| {
|
||||
groupActivation?: unknown;
|
||||
sessionId?: unknown;
|
||||
updatedAt?: unknown;
|
||||
}
|
||||
| undefined,
|
||||
) {
|
||||
const groupId = resolveGroupSessionKey({
|
||||
From: conversationId,
|
||||
ChatType: "group",
|
||||
Provider: "whatsapp",
|
||||
})?.id;
|
||||
return resolveChannelGroupRequireMention({
|
||||
cfg,
|
||||
channel: "whatsapp",
|
||||
groupId: groupId ?? conversationId,
|
||||
});
|
||||
return (
|
||||
entry?.groupActivation !== undefined &&
|
||||
typeof entry?.sessionId !== "string" &&
|
||||
typeof entry?.updatedAt !== "number"
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveGroupActivationFor(params: {
|
||||
export async function resolveGroupActivationFor(params: {
|
||||
cfg: ReturnType<LoadConfigFn>;
|
||||
accountId?: string | null;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
conversationId: string;
|
||||
@@ -55,8 +39,36 @@ export function resolveGroupActivationFor(params: {
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const requireMention = resolveGroupRequireMentionFor(params.cfg, params.conversationId);
|
||||
const legacySessionKey = resolveWhatsAppLegacyGroupSessionKey({
|
||||
sessionKey: params.sessionKey,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const legacyEntry = legacySessionKey ? store[legacySessionKey] : undefined;
|
||||
const scopedEntry = store[params.sessionKey];
|
||||
const normalizedAccountId = normalizeAccountId(params.accountId);
|
||||
const ignoreScopedActivation =
|
||||
normalizedAccountId === DEFAULT_ACCOUNT_ID &&
|
||||
hasNamedWhatsAppAccounts(params.cfg) &&
|
||||
isActivationOnlyEntry(scopedEntry);
|
||||
const activation =
|
||||
(ignoreScopedActivation ? undefined : scopedEntry?.groupActivation) ??
|
||||
legacyEntry?.groupActivation;
|
||||
if (activation !== undefined && scopedEntry?.groupActivation === undefined) {
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
const nextScopedEntry = nextStore[params.sessionKey];
|
||||
if (nextScopedEntry?.groupActivation !== undefined) {
|
||||
return;
|
||||
}
|
||||
nextStore[params.sessionKey] = {
|
||||
...nextScopedEntry,
|
||||
groupActivation: activation,
|
||||
};
|
||||
});
|
||||
}
|
||||
const requireMention = resolveWhatsAppInboundPolicy({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).resolveConversationRequireMention(params.conversationId);
|
||||
const defaultActivation = !requireMention ? "always" : "mention";
|
||||
return normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation;
|
||||
return normalizeGroupActivation(activation) ?? defaultActivation;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { resolveWhatsAppAccount } from "../../accounts.js";
|
||||
import { getPrimaryIdentityId, getSelfIdentity, getSenderIdentity } from "../../identity.js";
|
||||
import {
|
||||
resolveWhatsAppCommandAuthorized,
|
||||
resolveWhatsAppInboundPolicy,
|
||||
type ResolvedWhatsAppInboundPolicy,
|
||||
} from "../../inbound-policy.js";
|
||||
import { newConnectionId } from "../../reconnect.js";
|
||||
import { formatError } from "../../session.js";
|
||||
import { deliverWebReply } from "../deliver-reply.js";
|
||||
@@ -27,12 +31,10 @@ import {
|
||||
formatInboundEnvelope,
|
||||
logVerbose,
|
||||
normalizeE164,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
recordSessionMetaFromInbound,
|
||||
resolveChannelContextVisibilityMode,
|
||||
resolveInboundSessionEnvelopeContext,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
resolveDmGroupAccessWithCommandGate,
|
||||
shouldComputeCommandAuthorized,
|
||||
shouldLogVerbose,
|
||||
type getChildLogger,
|
||||
@@ -42,74 +44,13 @@ import {
|
||||
type resolveAgentRoute,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
async function resolveWhatsAppCommandAuthorized(params: {
|
||||
cfg: ReturnType<LoadConfigFn>;
|
||||
msg: WebInboundMsg;
|
||||
}): Promise<boolean> {
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
if (!useAccessGroups) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isGroup = params.msg.chatType === "group";
|
||||
const sender = getSenderIdentity(params.msg);
|
||||
const self = getSelfIdentity(params.msg);
|
||||
const senderE164 = normalizeE164(
|
||||
isGroup ? (sender.e164 ?? "") : (sender.e164 ?? params.msg.from ?? ""),
|
||||
);
|
||||
if (!senderE164) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId });
|
||||
const dmPolicy = account.dmPolicy ?? "pairing";
|
||||
const groupPolicy = account.groupPolicy ?? "allowlist";
|
||||
const configuredAllowFrom = account.allowFrom ?? [];
|
||||
const configuredGroupAllowFrom =
|
||||
account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
|
||||
|
||||
const storeAllowFrom = isGroup
|
||||
? []
|
||||
: await readStoreAllowFromForDmPolicy({
|
||||
provider: "whatsapp",
|
||||
accountId: params.msg.accountId,
|
||||
dmPolicy,
|
||||
});
|
||||
const dmAllowFrom =
|
||||
configuredAllowFrom.length > 0 ? configuredAllowFrom : self.e164 ? [self.e164] : [];
|
||||
const access = resolveDmGroupAccessWithCommandGate({
|
||||
isGroup,
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
allowFrom: dmAllowFrom,
|
||||
groupAllowFrom: configuredGroupAllowFrom,
|
||||
storeAllowFrom,
|
||||
isSenderAllowed: (allowEntries) => {
|
||||
if (allowEntries.includes("*")) {
|
||||
return true;
|
||||
}
|
||||
const normalizedEntries = allowEntries
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
return normalizedEntries.includes(senderE164);
|
||||
},
|
||||
command: {
|
||||
useAccessGroups,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
},
|
||||
});
|
||||
return access.commandAuthorized;
|
||||
}
|
||||
|
||||
function resolvePinnedMainDmRecipient(params: {
|
||||
cfg: ReturnType<LoadConfigFn>;
|
||||
msg: WebInboundMsg;
|
||||
allowFrom?: string[];
|
||||
}): string | null {
|
||||
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.msg.accountId });
|
||||
return resolvePinnedMainDmOwnerFromAllowlist({
|
||||
dmScope: params.cfg.session?.dmScope,
|
||||
allowFrom: account.allowFrom,
|
||||
allowFrom: params.allowFrom,
|
||||
normalizeEntry: (entry) => normalizeE164(entry),
|
||||
});
|
||||
}
|
||||
@@ -143,20 +84,18 @@ export async function processMessage(params: {
|
||||
suppressGroupHistoryClear?: boolean;
|
||||
}) {
|
||||
const conversationId = params.msg.conversationId ?? params.msg.from;
|
||||
const account = resolveWhatsAppAccount({
|
||||
const self = getSelfIdentity(params.msg);
|
||||
const inboundPolicy = resolveWhatsAppInboundPolicy({
|
||||
cfg: params.cfg,
|
||||
accountId: params.route.accountId ?? params.msg.accountId,
|
||||
selfE164: self.e164 ?? null,
|
||||
});
|
||||
const account = inboundPolicy.account;
|
||||
const contextVisibilityMode = resolveChannelContextVisibilityMode({
|
||||
cfg: params.cfg,
|
||||
channel: "whatsapp",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const configuredAllowFrom = account.allowFrom ?? [];
|
||||
const configuredGroupAllowFrom =
|
||||
account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
|
||||
const groupAllowFrom = configuredGroupAllowFrom ?? [];
|
||||
const groupPolicy = account.groupPolicy ?? "allowlist";
|
||||
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
|
||||
cfg: params.cfg,
|
||||
agentId: params.route.agentId,
|
||||
@@ -175,8 +114,8 @@ export async function processMessage(params: {
|
||||
? resolveVisibleWhatsAppGroupHistory({
|
||||
history: params.groupHistory ?? params.groupHistories.get(params.groupHistoryKey) ?? [],
|
||||
mode: contextVisibilityMode,
|
||||
groupPolicy,
|
||||
groupAllowFrom,
|
||||
groupPolicy: inboundPolicy.groupPolicy,
|
||||
groupAllowFrom: inboundPolicy.groupAllowFrom,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -220,7 +159,7 @@ export async function processMessage(params: {
|
||||
}
|
||||
|
||||
// Send ack reaction immediately upon message receipt (post-gating)
|
||||
maybeSendAckReaction({
|
||||
await maybeSendAckReaction({
|
||||
cfg: params.cfg,
|
||||
msg: params.msg,
|
||||
agentId: params.route.agentId,
|
||||
@@ -256,13 +195,12 @@ export async function processMessage(params: {
|
||||
}
|
||||
|
||||
const sender = getSenderIdentity(params.msg);
|
||||
const self = getSelfIdentity(params.msg);
|
||||
const visibleReplyTo = resolveVisibleWhatsAppReplyContext({
|
||||
msg: params.msg,
|
||||
authDir: account.authDir,
|
||||
mode: contextVisibilityMode,
|
||||
groupPolicy,
|
||||
groupAllowFrom,
|
||||
groupPolicy: inboundPolicy.groupPolicy,
|
||||
groupAllowFrom: inboundPolicy.groupAllowFrom,
|
||||
});
|
||||
const dmRouteTarget = resolveWhatsAppDmRouteTarget({
|
||||
msg: params.msg,
|
||||
@@ -270,7 +208,11 @@ export async function processMessage(params: {
|
||||
normalizeE164,
|
||||
});
|
||||
const commandAuthorized = shouldComputeCommandAuthorized(params.msg.body, params.cfg)
|
||||
? await resolveWhatsAppCommandAuthorized({ cfg: params.cfg, msg: params.msg })
|
||||
? await resolveWhatsAppCommandAuthorized({
|
||||
cfg: params.cfg,
|
||||
msg: params.msg,
|
||||
policy: inboundPolicy,
|
||||
})
|
||||
: undefined;
|
||||
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
|
||||
cfg: params.cfg,
|
||||
@@ -278,14 +220,10 @@ export async function processMessage(params: {
|
||||
channel: "whatsapp",
|
||||
accountId: params.route.accountId,
|
||||
});
|
||||
const isSelfChat =
|
||||
params.msg.chatType !== "group" &&
|
||||
Boolean(self.e164) &&
|
||||
normalizeE164(params.msg.from) === normalizeE164(self.e164 ?? "");
|
||||
const responsePrefix = resolveWhatsAppResponsePrefix({
|
||||
cfg: params.cfg,
|
||||
agentId: params.route.agentId,
|
||||
isSelfChat,
|
||||
isSelfChat: params.msg.chatType !== "group" && inboundPolicy.isSelfChat,
|
||||
pipelineResponsePrefix: replyPipeline.responsePrefix,
|
||||
});
|
||||
|
||||
@@ -307,7 +245,7 @@ export async function processMessage(params: {
|
||||
|
||||
const pinnedMainDmRecipient = resolvePinnedMainDmRecipient({
|
||||
cfg: params.cfg,
|
||||
msg: params.msg,
|
||||
allowFrom: inboundPolicy.configuredAllowFrom,
|
||||
});
|
||||
updateWhatsAppMainLastRoute({
|
||||
backgroundTasks: params.backgroundTasks,
|
||||
@@ -359,3 +297,10 @@ export async function processMessage(params: {
|
||||
shouldClearGroupHistory,
|
||||
});
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveWhatsAppCommandAuthorized,
|
||||
resolveWhatsAppInboundPolicy: (
|
||||
params: Parameters<typeof resolveWhatsAppInboundPolicy>[0],
|
||||
): ResolvedWhatsAppInboundPolicy => resolveWhatsAppInboundPolicy(params),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { monitorWebInbox } from "../inbound.js";
|
||||
import type { WebInboundMessage } from "../inbound/types.js";
|
||||
import type { ReconnectPolicy } from "../reconnect.js";
|
||||
|
||||
export type WebChannelHealthState =
|
||||
@@ -10,11 +10,7 @@ export type WebChannelHealthState =
|
||||
| "logged-out"
|
||||
| "stopped";
|
||||
|
||||
export type WebInboundMsg = Parameters<typeof monitorWebInbox>[0]["onMessage"] extends (
|
||||
msg: infer M,
|
||||
) => unknown
|
||||
? M
|
||||
: never;
|
||||
export type WebInboundMsg = WebInboundMessage;
|
||||
|
||||
export type WebChannelStatus = {
|
||||
running: boolean;
|
||||
|
||||
@@ -35,7 +35,7 @@ const makeConfig = (overrides: Record<string, unknown>) =>
|
||||
...overrides,
|
||||
}) as unknown as ReturnType<typeof import("openclaw/plugin-sdk/config-runtime").loadConfig>;
|
||||
|
||||
function runGroupGating(params: {
|
||||
async function runGroupGating(params: {
|
||||
cfg: ReturnType<typeof import("openclaw/plugin-sdk/config-runtime").loadConfig>;
|
||||
msg: Record<string, unknown>;
|
||||
conversationId?: string;
|
||||
@@ -47,7 +47,7 @@ function runGroupGating(params: {
|
||||
const agentId = params.agentId ?? "main";
|
||||
const sessionKey = `agent:${agentId}:whatsapp:group:${conversationId}`;
|
||||
const baseMentionConfig = buildMentionConfig(params.cfg, undefined);
|
||||
const result = applyGroupGating({
|
||||
const result = await applyGroupGating({
|
||||
cfg: params.cfg,
|
||||
msg: params.msg as any,
|
||||
conversationId,
|
||||
@@ -103,9 +103,9 @@ function makeInboundCfg(messagePrefix = "") {
|
||||
}
|
||||
|
||||
describe("applyGroupGating", () => {
|
||||
it("treats reply-to-bot as implicit mention", () => {
|
||||
it("treats reply-to-bot as implicit mention", async () => {
|
||||
const cfg = makeConfig({});
|
||||
const { result } = runGroupGating({
|
||||
const { result } = await runGroupGating({
|
||||
cfg,
|
||||
msg: createGroupMessage({
|
||||
id: "m1",
|
||||
@@ -126,7 +126,7 @@ describe("applyGroupGating", () => {
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat self-number quoted replies as implicit mention in selfChatMode groups", () => {
|
||||
it("does not treat self-number quoted replies as implicit mention in selfChatMode groups", async () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
@@ -136,7 +136,7 @@ describe("applyGroupGating", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const { result } = runGroupGating({
|
||||
const { result } = await runGroupGating({
|
||||
cfg,
|
||||
selfChatMode: true,
|
||||
msg: createGroupMessage({
|
||||
@@ -160,7 +160,7 @@ describe("applyGroupGating", () => {
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
});
|
||||
|
||||
it("still treats reply-to-bot as implicit mention in selfChatMode when sender is a different user", () => {
|
||||
it("still treats reply-to-bot as implicit mention in selfChatMode when sender is a different user", async () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
@@ -170,7 +170,7 @@ describe("applyGroupGating", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const { result } = runGroupGating({
|
||||
const { result } = await runGroupGating({
|
||||
cfg,
|
||||
selfChatMode: true,
|
||||
msg: createGroupMessage({
|
||||
@@ -194,7 +194,7 @@ describe("applyGroupGating", () => {
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it("honors per-account selfChatMode overrides before suppressing implicit mentions", () => {
|
||||
it("honors per-account selfChatMode overrides before suppressing implicit mentions", async () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
@@ -210,7 +210,7 @@ describe("applyGroupGating", () => {
|
||||
},
|
||||
});
|
||||
// Per-account override: work account has selfChatMode: false despite root being true
|
||||
const { result } = runGroupGating({
|
||||
const { result } = await runGroupGating({
|
||||
cfg,
|
||||
selfChatMode: false,
|
||||
msg: createGroupMessage({
|
||||
@@ -234,11 +234,173 @@ describe("applyGroupGating", () => {
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it("uses account-scoped groupPolicy and groupAllowFrom for named-account group gating", async () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groupPolicy: "allowlist",
|
||||
accounts: {
|
||||
work: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["+111"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await runGroupGating({
|
||||
cfg,
|
||||
msg: createGroupMessage({
|
||||
id: "g-account-policy",
|
||||
accountId: "work",
|
||||
body: "following up",
|
||||
senderE164: "+111",
|
||||
senderJid: "111@s.whatsapp.net",
|
||||
selfJid: "15551234567@s.whatsapp.net",
|
||||
selfE164: "+15551234567",
|
||||
replyToId: "m0",
|
||||
replyToBody: "bot said hi",
|
||||
replyToSender: "+15551234567",
|
||||
replyToSenderJid: "15551234567@s.whatsapp.net",
|
||||
replyToSenderE164: "+15551234567",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it("inherits group gating defaults from accounts.default for named accounts", async () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groupPolicy: "allowlist",
|
||||
accounts: {
|
||||
default: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await runGroupGating({
|
||||
cfg,
|
||||
msg: createGroupMessage({
|
||||
id: "g-default-inheritance",
|
||||
accountId: "work",
|
||||
body: "plain group message",
|
||||
senderE164: "+111",
|
||||
senderJid: "111@s.whatsapp.net",
|
||||
selfJid: "15551234567@s.whatsapp.net",
|
||||
selfE164: "+15551234567",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves allowFrom fallback for named-account group gating when groupAllowFrom is empty", async () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groupPolicy: "allowlist",
|
||||
accounts: {
|
||||
work: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["+111"],
|
||||
groupAllowFrom: [],
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await runGroupGating({
|
||||
cfg,
|
||||
msg: createGroupMessage({
|
||||
id: "g-empty-group-allow-fallback",
|
||||
accountId: "work",
|
||||
body: "plain group message",
|
||||
senderE164: "+111",
|
||||
senderJid: "111@s.whatsapp.net",
|
||||
selfJid: "15551234567@s.whatsapp.net",
|
||||
selfE164: "+15551234567",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it("uses account-scoped allowFrom when bypassing mention gating for owner commands", async () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
allowFrom: ["+999"],
|
||||
accounts: {
|
||||
work: {
|
||||
allowFrom: ["+111"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = await runGroupGating({
|
||||
cfg,
|
||||
msg: createGroupMessage({
|
||||
id: "g-account-owner",
|
||||
accountId: "work",
|
||||
body: "/new",
|
||||
senderE164: "+111",
|
||||
senderName: "Owner",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat group mention gating as self-chat under implicit self fallback", async () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groups: { "*": { requireMention: true } },
|
||||
},
|
||||
},
|
||||
messages: { groupChat: { mentionPatterns: ["@openclaw"] } },
|
||||
});
|
||||
|
||||
const { result, groupHistories } = await runGroupGating({
|
||||
cfg,
|
||||
msg: createGroupMessage({
|
||||
id: "g-other-mention",
|
||||
body: "@openclaw please check this",
|
||||
mentionedJids: ["15550000000@s.whatsapp.net"],
|
||||
selfE164: "+15551234567",
|
||||
selfJid: "15551234567@s.whatsapp.net",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
expect(groupHistories.get("whatsapp:default:group:123@g.us")?.length).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ id: "g-new", command: "/new" },
|
||||
{ id: "g-status", command: "/status" },
|
||||
])("bypasses mention gating for owner $command in group chats", ({ id, command }) => {
|
||||
const { result } = runGroupGating({
|
||||
])("bypasses mention gating for owner $command in group chats", async ({ id, command }) => {
|
||||
const { result } = await runGroupGating({
|
||||
cfg: makeOwnerGroupConfig(),
|
||||
msg: createGroupMessage({
|
||||
id,
|
||||
@@ -251,7 +413,7 @@ describe("applyGroupGating", () => {
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it("does not bypass mention gating for non-owner /new in group chats", () => {
|
||||
it("does not bypass mention gating for non-owner /new in group chats", async () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
@@ -261,7 +423,7 @@ describe("applyGroupGating", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { result, groupHistories } = runGroupGating({
|
||||
const { result, groupHistories } = await runGroupGating({
|
||||
cfg,
|
||||
msg: createGroupMessage({
|
||||
id: "g-new-unauth",
|
||||
@@ -275,7 +437,7 @@ describe("applyGroupGating", () => {
|
||||
expect(groupHistories.get("whatsapp:default:group:123@g.us")?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("uses per-agent mention patterns for group gating (routing + mentionPatterns)", () => {
|
||||
it("uses per-agent mention patterns for group gating (routing + mentionPatterns)", async () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
@@ -312,7 +474,7 @@ describe("applyGroupGating", () => {
|
||||
});
|
||||
expect(route.agentId).toBe("work");
|
||||
|
||||
const { result: globalMention } = runGroupGating({
|
||||
const { result: globalMention } = await runGroupGating({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
msg: createGroupMessage({
|
||||
@@ -324,7 +486,7 @@ describe("applyGroupGating", () => {
|
||||
});
|
||||
expect(globalMention.shouldProcess).toBe(false);
|
||||
|
||||
const { result: workMention } = runGroupGating({
|
||||
const { result: workMention } = await runGroupGating({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
msg: createGroupMessage({
|
||||
@@ -337,7 +499,7 @@ describe("applyGroupGating", () => {
|
||||
expect(workMention.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it("allows group messages when whatsapp groups default disables mention gating", () => {
|
||||
it("allows group messages when whatsapp groups default disables mention gating", async () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
@@ -348,7 +510,7 @@ describe("applyGroupGating", () => {
|
||||
messages: { groupChat: { mentionPatterns: ["@openclaw"] } },
|
||||
});
|
||||
|
||||
const { result } = runGroupGating({
|
||||
const { result } = await runGroupGating({
|
||||
cfg,
|
||||
msg: createGroupMessage(),
|
||||
});
|
||||
@@ -356,7 +518,7 @@ describe("applyGroupGating", () => {
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks group messages when whatsapp groups is set without a wildcard", () => {
|
||||
it("blocks group messages when whatsapp groups is set without a wildcard", async () => {
|
||||
const cfg = makeConfig({
|
||||
channels: {
|
||||
whatsapp: {
|
||||
@@ -368,7 +530,7 @@ describe("applyGroupGating", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = runGroupGating({
|
||||
const { result } = await runGroupGating({
|
||||
cfg,
|
||||
msg: createGroupMessage({
|
||||
body: "@workbot ping",
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("isBotMentionedFromTargets", () => {
|
||||
|
||||
function expectMentioned(
|
||||
msg: WebInboundMsg,
|
||||
cfg: { mentionRegexes: RegExp[]; allowFrom?: Array<string | number> },
|
||||
cfg: { mentionRegexes: RegExp[]; allowFrom?: Array<string | number>; isSelfChat?: boolean },
|
||||
expected: boolean,
|
||||
) {
|
||||
const targets = resolveMentionTargets(msg);
|
||||
@@ -88,6 +88,21 @@ describe("isBotMentionedFromTargets", () => {
|
||||
expectMentioned(msgTextMention, cfg, true);
|
||||
});
|
||||
|
||||
it("honors explicit self-chat overrides without recomputing from allowFrom", () => {
|
||||
const cfg = {
|
||||
mentionRegexes: [/\bopenclaw\b/i],
|
||||
allowFrom: ["+15551230000"],
|
||||
isSelfChat: true,
|
||||
};
|
||||
const msg = makeMsg({
|
||||
body: "@owner ping",
|
||||
mentionedJids: ["999@s.whatsapp.net"],
|
||||
selfE164: "+999",
|
||||
selfJid: "999@s.whatsapp.net",
|
||||
});
|
||||
expectMentioned(msg, cfg, false);
|
||||
});
|
||||
|
||||
it("matches fallback number mentions when regexes do not match", () => {
|
||||
const msg = makeMsg({
|
||||
body: "please check +1 555 123 4567",
|
||||
|
||||
@@ -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);
|
||||
|
||||
53
extensions/whatsapp/src/group-session-key.test.ts
Normal file
53
extensions/whatsapp/src/group-session-key.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWhatsAppGroupSessionRoute, __testing } from "./group-session-key.js";
|
||||
|
||||
describe("resolveWhatsAppGroupSessionRoute", () => {
|
||||
it("keeps default-account group routes unchanged", () => {
|
||||
const route = {
|
||||
agentId: "main",
|
||||
channel: "whatsapp",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||
mainSessionKey: "agent:main:main",
|
||||
lastRoutePolicy: "session",
|
||||
matchedBy: "default",
|
||||
} as const;
|
||||
|
||||
expect(resolveWhatsAppGroupSessionRoute(route)).toEqual(route);
|
||||
});
|
||||
|
||||
it("scopes named-account group routes through an account-specific thread suffix", () => {
|
||||
const route = {
|
||||
agentId: "main",
|
||||
channel: "whatsapp",
|
||||
accountId: "work",
|
||||
sessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||
mainSessionKey: "agent:main:main",
|
||||
lastRoutePolicy: "session",
|
||||
matchedBy: "default",
|
||||
} as const;
|
||||
|
||||
expect(resolveWhatsAppGroupSessionRoute(route)).toEqual({
|
||||
...route,
|
||||
sessionKey: "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives the legacy group session key from a named-account scoped group route", () => {
|
||||
expect(
|
||||
__testing.resolveWhatsAppLegacyGroupSessionKey({
|
||||
accountId: "work",
|
||||
sessionKey: "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work",
|
||||
}),
|
||||
).toBe("agent:main:whatsapp:group:123@g.us");
|
||||
});
|
||||
|
||||
it("normalizes mixed-case account ids when resolving legacy scoped group keys", () => {
|
||||
expect(
|
||||
__testing.resolveWhatsAppLegacyGroupSessionKey({
|
||||
accountId: "Work",
|
||||
sessionKey: "agent:main:whatsapp:group:123@g.us:thread:whatsapp-account-work",
|
||||
}),
|
||||
).toBe("agent:main:whatsapp:group:123@g.us");
|
||||
});
|
||||
});
|
||||
41
extensions/whatsapp/src/group-session-key.ts
Normal file
41
extensions/whatsapp/src/group-session-key.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
resolveThreadSessionKeys,
|
||||
type ResolvedAgentRoute,
|
||||
} from "openclaw/plugin-sdk/routing";
|
||||
|
||||
function resolveWhatsAppGroupAccountThreadId(accountId: string): string {
|
||||
return `whatsapp-account-${normalizeAccountId(accountId)}`;
|
||||
}
|
||||
|
||||
export function resolveWhatsAppLegacyGroupSessionKey(params: {
|
||||
sessionKey: string;
|
||||
accountId?: string | null;
|
||||
}): string | null {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
if (!accountId || accountId === DEFAULT_ACCOUNT_ID || !params.sessionKey.includes(":group:")) {
|
||||
return null;
|
||||
}
|
||||
const suffix = `:thread:${resolveWhatsAppGroupAccountThreadId(accountId)}`;
|
||||
return params.sessionKey.endsWith(suffix) ? params.sessionKey.slice(0, -suffix.length) : null;
|
||||
}
|
||||
|
||||
export function resolveWhatsAppGroupSessionRoute(route: ResolvedAgentRoute): ResolvedAgentRoute {
|
||||
if (route.accountId === DEFAULT_ACCOUNT_ID || !route.sessionKey.includes(":group:")) {
|
||||
return route;
|
||||
}
|
||||
const scopedSession = resolveThreadSessionKeys({
|
||||
baseSessionKey: route.sessionKey,
|
||||
threadId: resolveWhatsAppGroupAccountThreadId(route.accountId),
|
||||
});
|
||||
return {
|
||||
...route,
|
||||
sessionKey: scopedSession.sessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveWhatsAppGroupAccountThreadId,
|
||||
resolveWhatsAppLegacyGroupSessionKey,
|
||||
};
|
||||
196
extensions/whatsapp/src/inbound-policy.ts
Normal file
196
extensions/whatsapp/src/inbound-policy.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
resolveChannelGroupPolicy,
|
||||
resolveChannelGroupRequireMention,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveGroupSessionKey,
|
||||
type ChannelGroupPolicy,
|
||||
type DmPolicy,
|
||||
type GroupPolicy,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveEffectiveAllowFromLists,
|
||||
resolveDmGroupAccessWithCommandGate,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js";
|
||||
import { getSelfIdentity, getSenderIdentity } from "./identity.js";
|
||||
import type { WebInboundMessage } from "./inbound/types.js";
|
||||
import { resolveWhatsAppRuntimeGroupPolicy } from "./runtime-group-policy.js";
|
||||
import { isSelfChatMode, normalizeE164 } from "./text-runtime.js";
|
||||
|
||||
export type ResolvedWhatsAppInboundPolicy = {
|
||||
account: ResolvedWhatsAppAccount;
|
||||
dmPolicy: DmPolicy;
|
||||
groupPolicy: GroupPolicy;
|
||||
configuredAllowFrom: string[];
|
||||
dmAllowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
isSelfChat: boolean;
|
||||
providerMissingFallbackApplied: boolean;
|
||||
shouldReadStorePairingApprovals: boolean;
|
||||
isSamePhone: (value?: string | null) => boolean;
|
||||
isDmSenderAllowed: (allowEntries: string[], sender?: string | null) => boolean;
|
||||
isGroupSenderAllowed: (allowEntries: string[], sender?: string | null) => boolean;
|
||||
resolveConversationGroupPolicy: (conversationId: string) => ChannelGroupPolicy;
|
||||
resolveConversationRequireMention: (conversationId: string) => boolean;
|
||||
};
|
||||
|
||||
function resolveGroupConversationId(conversationId: string): string {
|
||||
return (
|
||||
resolveGroupSessionKey({
|
||||
From: conversationId,
|
||||
ChatType: "group",
|
||||
Provider: "whatsapp",
|
||||
})?.id ?? conversationId
|
||||
);
|
||||
}
|
||||
|
||||
function isNormalizedSenderAllowed(allowEntries: string[], sender?: string | null): boolean {
|
||||
if (allowEntries.includes("*")) {
|
||||
return true;
|
||||
}
|
||||
const normalizedSender = normalizeE164(sender ?? "");
|
||||
if (!normalizedSender) {
|
||||
return false;
|
||||
}
|
||||
const normalizedEntrySet = new Set(
|
||||
allowEntries
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry)),
|
||||
);
|
||||
return normalizedEntrySet.has(normalizedSender);
|
||||
}
|
||||
|
||||
function buildResolvedWhatsAppGroupConfig(params: {
|
||||
groupPolicy: GroupPolicy;
|
||||
groups: ResolvedWhatsAppAccount["groups"];
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groupPolicy: params.groupPolicy,
|
||||
groups: params.groups,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
export function resolveWhatsAppInboundPolicy(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
selfE164?: string | null;
|
||||
}): ResolvedWhatsAppInboundPolicy {
|
||||
const account = resolveWhatsAppAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const configuredAllowFrom = account.allowFrom ?? [];
|
||||
const dmPolicy = account.dmPolicy ?? "pairing";
|
||||
const dmAllowFrom =
|
||||
configuredAllowFrom.length > 0 ? configuredAllowFrom : params.selfE164 ? [params.selfE164] : [];
|
||||
const groupAllowFrom =
|
||||
account.groupAllowFrom ??
|
||||
(configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined) ??
|
||||
[];
|
||||
const { effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
|
||||
allowFrom: configuredAllowFrom,
|
||||
groupAllowFrom,
|
||||
});
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(params.cfg);
|
||||
const { groupPolicy, providerMissingFallbackApplied } = resolveWhatsAppRuntimeGroupPolicy({
|
||||
providerConfigPresent: params.cfg.channels?.whatsapp !== undefined,
|
||||
groupPolicy: account.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
const resolvedGroupCfg = buildResolvedWhatsAppGroupConfig({
|
||||
groupPolicy,
|
||||
groups: account.groups,
|
||||
});
|
||||
const isSamePhone = (value?: string | null) =>
|
||||
typeof value === "string" && typeof params.selfE164 === "string" && value === params.selfE164;
|
||||
return {
|
||||
account,
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
configuredAllowFrom,
|
||||
dmAllowFrom,
|
||||
groupAllowFrom,
|
||||
isSelfChat: account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom),
|
||||
providerMissingFallbackApplied,
|
||||
shouldReadStorePairingApprovals: dmPolicy !== "allowlist",
|
||||
isSamePhone,
|
||||
isDmSenderAllowed: (allowEntries, sender) =>
|
||||
isSamePhone(sender) || isNormalizedSenderAllowed(allowEntries, sender),
|
||||
isGroupSenderAllowed: (allowEntries, sender) => isNormalizedSenderAllowed(allowEntries, sender),
|
||||
resolveConversationGroupPolicy: (conversationId) =>
|
||||
resolveChannelGroupPolicy({
|
||||
cfg: resolvedGroupCfg,
|
||||
channel: "whatsapp",
|
||||
groupId: resolveGroupConversationId(conversationId),
|
||||
hasGroupAllowFrom: effectiveGroupAllowFrom.length > 0,
|
||||
}),
|
||||
resolveConversationRequireMention: (conversationId) =>
|
||||
resolveChannelGroupRequireMention({
|
||||
cfg: resolvedGroupCfg,
|
||||
channel: "whatsapp",
|
||||
groupId: resolveGroupConversationId(conversationId),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveWhatsAppCommandAuthorized(params: {
|
||||
cfg: OpenClawConfig;
|
||||
msg: WebInboundMessage;
|
||||
policy?: ResolvedWhatsAppInboundPolicy;
|
||||
}): Promise<boolean> {
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
if (!useAccessGroups) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const self = getSelfIdentity(params.msg);
|
||||
const policy =
|
||||
params.policy ??
|
||||
resolveWhatsAppInboundPolicy({
|
||||
cfg: params.cfg,
|
||||
accountId: params.msg.accountId,
|
||||
selfE164: self.e164 ?? null,
|
||||
});
|
||||
const isGroup = params.msg.chatType === "group";
|
||||
const sender = getSenderIdentity(params.msg);
|
||||
const dmSender = sender.e164 ?? params.msg.from ?? "";
|
||||
const groupSender = sender.e164 ?? "";
|
||||
const normalizedSender = normalizeE164(isGroup ? groupSender : dmSender);
|
||||
if (!normalizedSender) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const storeAllowFrom =
|
||||
isGroup || !policy.shouldReadStorePairingApprovals
|
||||
? []
|
||||
: await readStoreAllowFromForDmPolicy({
|
||||
provider: "whatsapp",
|
||||
accountId: policy.account.accountId,
|
||||
dmPolicy: policy.dmPolicy,
|
||||
shouldRead: policy.shouldReadStorePairingApprovals,
|
||||
});
|
||||
const access = resolveDmGroupAccessWithCommandGate({
|
||||
isGroup,
|
||||
dmPolicy: policy.dmPolicy,
|
||||
groupPolicy: policy.groupPolicy,
|
||||
allowFrom: policy.dmAllowFrom,
|
||||
groupAllowFrom: policy.groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
isSenderAllowed: (allowEntries) =>
|
||||
isGroup
|
||||
? policy.isGroupSenderAllowed(allowEntries, groupSender)
|
||||
: policy.isDmSenderAllowed(allowEntries, dmSender),
|
||||
command: {
|
||||
useAccessGroups,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
},
|
||||
});
|
||||
return access.commandAuthorized;
|
||||
}
|
||||
@@ -9,9 +9,11 @@ import {
|
||||
|
||||
setupAccessControlTestHarness();
|
||||
let checkInboundAccessControl: typeof import("./access-control.js").checkInboundAccessControl;
|
||||
let resolveWhatsAppCommandAuthorized: typeof import("../inbound-policy.js").resolveWhatsAppCommandAuthorized;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ checkInboundAccessControl } = await import("./access-control.js"));
|
||||
({ resolveWhatsAppCommandAuthorized } = await import("../inbound-policy.js"));
|
||||
});
|
||||
|
||||
async function checkUnauthorizedWorkDmSender() {
|
||||
@@ -34,6 +36,27 @@ function expectSilentlyBlocked(result: { allowed: boolean }) {
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function checkCommandAuthorizedForDm(params: {
|
||||
cfg: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
from?: string;
|
||||
senderE164?: string;
|
||||
selfE164?: string;
|
||||
}) {
|
||||
return await resolveWhatsAppCommandAuthorized({
|
||||
cfg: params.cfg as never,
|
||||
msg: {
|
||||
accountId: params.accountId ?? "work",
|
||||
chatType: "direct",
|
||||
from: params.from ?? "+15550001111",
|
||||
senderE164: params.senderE164 ?? params.from ?? "+15550001111",
|
||||
selfE164: params.selfE164 ?? "+15550009999",
|
||||
body: "/status",
|
||||
to: params.selfE164 ?? "+15550009999",
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
describe("checkInboundAccessControl pairing grace", () => {
|
||||
async function runPairingGraceCase(messageTimestampMs: number) {
|
||||
const connectedAtMs = 1_000_000;
|
||||
@@ -75,7 +98,7 @@ describe("WhatsApp dmPolicy precedence", () => {
|
||||
// Channel-level says "pairing" but the account-level says "allowlist".
|
||||
// The account-level override should take precedence, so an unauthorized
|
||||
// sender should be blocked silently (no pairing reply).
|
||||
setAccessControlTestConfig({
|
||||
const cfg = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "pairing",
|
||||
@@ -87,16 +110,19 @@ describe("WhatsApp dmPolicy precedence", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
setAccessControlTestConfig(cfg);
|
||||
|
||||
const result = await checkUnauthorizedWorkDmSender();
|
||||
const commandAuthorized = await checkCommandAuthorizedForDm({ cfg });
|
||||
expectSilentlyBlocked(result);
|
||||
expect(commandAuthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("inherits channel-level dmPolicy when account-level dmPolicy is unset", async () => {
|
||||
// Account has allowFrom set, but no dmPolicy override. Should inherit the channel default.
|
||||
// With dmPolicy=allowlist, unauthorized senders are silently blocked.
|
||||
setAccessControlTestConfig({
|
||||
const cfg = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "allowlist",
|
||||
@@ -107,14 +133,17 @@ describe("WhatsApp dmPolicy precedence", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
setAccessControlTestConfig(cfg);
|
||||
|
||||
const result = await checkUnauthorizedWorkDmSender();
|
||||
const commandAuthorized = await checkCommandAuthorizedForDm({ cfg });
|
||||
expectSilentlyBlocked(result);
|
||||
expect(commandAuthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("does not merge persisted pairing approvals in allowlist mode", async () => {
|
||||
setAccessControlTestConfig({
|
||||
const cfg = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "allowlist",
|
||||
@@ -125,24 +154,91 @@ describe("WhatsApp dmPolicy precedence", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
setAccessControlTestConfig(cfg);
|
||||
readAllowFromStoreMock.mockResolvedValue(["+15550001111"]);
|
||||
|
||||
const result = await checkUnauthorizedWorkDmSender();
|
||||
const commandAuthorized = await checkCommandAuthorizedForDm({ cfg });
|
||||
|
||||
expectSilentlyBlocked(result);
|
||||
expect(commandAuthorized).toBe(false);
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("always allows same-phone DMs even when allowFrom is restrictive", async () => {
|
||||
setAccessControlTestConfig({
|
||||
const cfg = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["+15550001111"],
|
||||
},
|
||||
},
|
||||
};
|
||||
setAccessControlTestConfig(cfg);
|
||||
|
||||
const result = await checkInboundAccessControl({
|
||||
accountId: "default",
|
||||
from: "+15550009999",
|
||||
selfE164: "+15550009999",
|
||||
senderE164: "+15550009999",
|
||||
group: false,
|
||||
pushName: "Owner",
|
||||
isFromMe: false,
|
||||
sock: { sendMessage: sendMessageMock },
|
||||
remoteJid: "15550009999@s.whatsapp.net",
|
||||
});
|
||||
const commandAuthorized = await checkCommandAuthorizedForDm({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
from: "+15550009999",
|
||||
senderE164: "+15550009999",
|
||||
selfE164: "+15550009999",
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(commandAuthorized).toBe(true);
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not broaden self-chat mode to every paired DM when allowFrom is empty", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
setAccessControlTestConfig(cfg);
|
||||
|
||||
const result = await checkInboundAccessControl({
|
||||
accountId: "default",
|
||||
from: "+15550001111",
|
||||
selfE164: "+15550009999",
|
||||
senderE164: "+15550001111",
|
||||
group: false,
|
||||
pushName: "Sam",
|
||||
isFromMe: false,
|
||||
sock: { sendMessage: sendMessageMock },
|
||||
remoteJid: "15550001111@s.whatsapp.net",
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.isSelfChat).toBe(false);
|
||||
});
|
||||
|
||||
it("treats same-phone DMs as self-chat only when explicitly configured", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["+15550009999"],
|
||||
},
|
||||
},
|
||||
};
|
||||
setAccessControlTestConfig(cfg);
|
||||
|
||||
const result = await checkInboundAccessControl({
|
||||
accountId: "default",
|
||||
@@ -157,7 +253,6 @@ describe("WhatsApp dmPolicy precedence", () => {
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
expect(result.isSelfChat).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import { warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import { resolveWhatsAppAccount } from "../accounts.js";
|
||||
import { resolveWhatsAppRuntimeGroupPolicy } from "../runtime-group-policy.js";
|
||||
import { isSelfChatMode, normalizeE164 } from "../text-runtime.js";
|
||||
import { resolveWhatsAppInboundPolicy } from "../inbound-policy.js";
|
||||
|
||||
export type InboundAccessControlResult = {
|
||||
allowed: boolean;
|
||||
@@ -23,6 +18,13 @@ export type InboundAccessControlResult = {
|
||||
|
||||
const PAIRING_REPLY_HISTORY_GRACE_MS = 30_000;
|
||||
|
||||
function logWhatsAppVerbose(enabled: boolean | undefined, message: string) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(message);
|
||||
}
|
||||
|
||||
export async function checkInboundAccessControl(params: {
|
||||
accountId: string;
|
||||
from: string;
|
||||
@@ -34,31 +36,24 @@ export async function checkInboundAccessControl(params: {
|
||||
messageTimestampMs?: number;
|
||||
connectedAtMs?: number;
|
||||
pairingGraceMs?: number;
|
||||
verbose?: boolean;
|
||||
sock: {
|
||||
sendMessage: (jid: string, content: { text: string }) => Promise<unknown>;
|
||||
};
|
||||
remoteJid: string;
|
||||
}): Promise<InboundAccessControlResult> {
|
||||
const cfg = loadConfig();
|
||||
const account = resolveWhatsAppAccount({
|
||||
const policy = resolveWhatsAppInboundPolicy({
|
||||
cfg,
|
||||
accountId: params.accountId,
|
||||
selfE164: params.selfE164,
|
||||
});
|
||||
const dmPolicy = account.dmPolicy ?? "pairing";
|
||||
const configuredAllowFrom = account.allowFrom ?? [];
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: "whatsapp",
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
accountId: policy.account.accountId,
|
||||
dmPolicy: policy.dmPolicy,
|
||||
shouldRead: policy.shouldReadStorePairingApprovals,
|
||||
});
|
||||
// Without user config, default to self-only DM access so the owner can talk to themselves.
|
||||
const defaultAllowFrom =
|
||||
configuredAllowFrom.length === 0 && params.selfE164 ? [params.selfE164] : [];
|
||||
const dmAllowFrom = configuredAllowFrom.length > 0 ? configuredAllowFrom : defaultAllowFrom;
|
||||
const groupAllowFrom =
|
||||
account.groupAllowFrom ?? (configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
|
||||
const isSamePhone = params.from === params.selfE164;
|
||||
const isSelfChat = account.selfChatMode ?? isSelfChatMode(params.selfE164, configuredAllowFrom);
|
||||
const pairingGraceMs =
|
||||
typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0
|
||||
? params.pairingGraceMs
|
||||
@@ -72,89 +67,74 @@ export async function checkInboundAccessControl(params: {
|
||||
// - "open": groups bypass allowFrom, only mention-gating applies
|
||||
// - "disabled": block all group messages entirely
|
||||
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy, providerMissingFallbackApplied } = resolveWhatsAppRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.whatsapp !== undefined,
|
||||
groupPolicy: account.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerMissingFallbackApplied: policy.providerMissingFallbackApplied,
|
||||
providerKey: "whatsapp",
|
||||
accountId: account.accountId,
|
||||
log: (message) => logVerbose(message),
|
||||
accountId: policy.account.accountId,
|
||||
log: (message) => logWhatsAppVerbose(params.verbose, message),
|
||||
});
|
||||
const normalizedDmSender = normalizeE164(params.from);
|
||||
const normalizedGroupSender =
|
||||
typeof params.senderE164 === "string" ? normalizeE164(params.senderE164) : null;
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup: params.group,
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
// Groups intentionally fall back to configured allowFrom only (not DM self-chat fallback).
|
||||
allowFrom: params.group ? configuredAllowFrom : dmAllowFrom,
|
||||
groupAllowFrom,
|
||||
dmPolicy: policy.dmPolicy,
|
||||
groupPolicy: policy.groupPolicy,
|
||||
allowFrom: params.group ? policy.configuredAllowFrom : policy.dmAllowFrom,
|
||||
groupAllowFrom: policy.groupAllowFrom,
|
||||
storeAllowFrom,
|
||||
isSenderAllowed: (allowEntries) => {
|
||||
const hasWildcard = allowEntries.includes("*");
|
||||
if (hasWildcard) {
|
||||
return true;
|
||||
}
|
||||
const normalizedEntrySet = new Set(
|
||||
allowEntries
|
||||
.map((entry) => normalizeE164(entry))
|
||||
.filter((entry): entry is string => Boolean(entry)),
|
||||
);
|
||||
if (!params.group && isSamePhone) {
|
||||
return true;
|
||||
}
|
||||
return params.group
|
||||
? Boolean(normalizedGroupSender && normalizedEntrySet.has(normalizedGroupSender))
|
||||
: normalizedEntrySet.has(normalizedDmSender);
|
||||
? policy.isGroupSenderAllowed(allowEntries, params.senderE164)
|
||||
: policy.isDmSenderAllowed(allowEntries, params.from);
|
||||
},
|
||||
});
|
||||
if (params.group && access.decision !== "allow") {
|
||||
if (access.reason === "groupPolicy=disabled") {
|
||||
logVerbose("Blocked group message (groupPolicy: disabled)");
|
||||
logWhatsAppVerbose(params.verbose, "Blocked group message (groupPolicy: disabled)");
|
||||
} else if (access.reason === "groupPolicy=allowlist (empty allowlist)") {
|
||||
logVerbose("Blocked group message (groupPolicy: allowlist, no groupAllowFrom)");
|
||||
logWhatsAppVerbose(
|
||||
params.verbose,
|
||||
"Blocked group message (groupPolicy: allowlist, no groupAllowFrom)",
|
||||
);
|
||||
} else {
|
||||
logVerbose(
|
||||
logWhatsAppVerbose(
|
||||
params.verbose,
|
||||
`Blocked group message from ${params.senderE164 ?? "unknown sender"} (groupPolicy: allowlist)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
shouldMarkRead: false,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
isSelfChat: policy.isSelfChat,
|
||||
resolvedAccountId: policy.account.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
// DM access control (secure defaults): "pairing" (default) / "allowlist" / "open" / "disabled".
|
||||
if (!params.group) {
|
||||
if (params.isFromMe && !isSamePhone) {
|
||||
logVerbose("Skipping outbound DM (fromMe); no pairing reply needed.");
|
||||
if (params.isFromMe && !policy.isSamePhone(params.from)) {
|
||||
logWhatsAppVerbose(params.verbose, "Skipping outbound DM (fromMe); no pairing reply needed.");
|
||||
return {
|
||||
allowed: false,
|
||||
shouldMarkRead: false,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
isSelfChat: policy.isSelfChat,
|
||||
resolvedAccountId: policy.account.accountId,
|
||||
};
|
||||
}
|
||||
if (access.decision === "block" && access.reason === "dmPolicy=disabled") {
|
||||
logVerbose("Blocked dm (dmPolicy: disabled)");
|
||||
logWhatsAppVerbose(params.verbose, "Blocked dm (dmPolicy: disabled)");
|
||||
return {
|
||||
allowed: false,
|
||||
shouldMarkRead: false,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
isSelfChat: policy.isSelfChat,
|
||||
resolvedAccountId: policy.account.accountId,
|
||||
};
|
||||
}
|
||||
if (access.decision === "pairing" && !isSamePhone) {
|
||||
if (access.decision === "pairing" && !policy.isSamePhone(params.from)) {
|
||||
const candidate = params.from;
|
||||
if (suppressPairingReply) {
|
||||
logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`);
|
||||
logWhatsAppVerbose(
|
||||
params.verbose,
|
||||
`Skipping pairing reply for historical DM from ${candidate}.`,
|
||||
);
|
||||
} else {
|
||||
await createChannelPairingChallengeIssuer({
|
||||
channel: "whatsapp",
|
||||
@@ -162,7 +142,7 @@ export async function checkInboundAccessControl(params: {
|
||||
await upsertChannelPairingRequest({
|
||||
channel: "whatsapp",
|
||||
id,
|
||||
accountId: account.accountId,
|
||||
accountId: policy.account.accountId,
|
||||
meta,
|
||||
}),
|
||||
})({
|
||||
@@ -170,7 +150,8 @@ export async function checkInboundAccessControl(params: {
|
||||
senderIdLine: `Your WhatsApp phone number: ${candidate}`,
|
||||
meta: { name: (params.pushName ?? "").trim() || undefined },
|
||||
onCreated: () => {
|
||||
logVerbose(
|
||||
logWhatsAppVerbose(
|
||||
params.verbose,
|
||||
`whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`,
|
||||
);
|
||||
},
|
||||
@@ -178,24 +159,30 @@ export async function checkInboundAccessControl(params: {
|
||||
await params.sock.sendMessage(params.remoteJid, { text });
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`);
|
||||
logWhatsAppVerbose(
|
||||
params.verbose,
|
||||
`whatsapp pairing reply failed for ${candidate}: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
shouldMarkRead: false,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
isSelfChat: policy.isSelfChat,
|
||||
resolvedAccountId: policy.account.accountId,
|
||||
};
|
||||
}
|
||||
if (access.decision !== "allow") {
|
||||
logVerbose(`Blocked unauthorized sender ${params.from} (dmPolicy=${dmPolicy})`);
|
||||
logWhatsAppVerbose(
|
||||
params.verbose,
|
||||
`Blocked unauthorized sender ${params.from} (dmPolicy=${policy.dmPolicy})`,
|
||||
);
|
||||
return {
|
||||
allowed: false,
|
||||
shouldMarkRead: false,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
isSelfChat: policy.isSelfChat,
|
||||
resolvedAccountId: policy.account.accountId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -203,11 +190,11 @@ export async function checkInboundAccessControl(params: {
|
||||
return {
|
||||
allowed: true,
|
||||
shouldMarkRead: true,
|
||||
isSelfChat,
|
||||
resolvedAccountId: account.accountId,
|
||||
isSelfChat: policy.isSelfChat,
|
||||
resolvedAccountId: policy.account.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveWhatsAppRuntimeGroupPolicy,
|
||||
resolveWhatsAppInboundPolicy,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import { vi, type Mock } from "vitest";
|
||||
|
||||
export type AsyncMock<TArgs extends unknown[] = unknown[], TResult = unknown> = {
|
||||
@@ -23,12 +18,13 @@ export function resetPairingSecurityMocks(config: Record<string, unknown>) {
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
}
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", () => {
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: (...args: unknown[]) => loadConfigMock(...args),
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,42 @@ function trimPromptText(value: string | null | undefined): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
function isDefaultWhatsAppAccountKey(accountId: string): boolean {
|
||||
return accountId.trim().toLowerCase() === DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function shouldWriteDefaultWhatsAppAccountConfigAtAccountScope(cfg: OpenClawConfig): boolean {
|
||||
const accounts = cfg.channels?.whatsapp?.accounts;
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
if (accounts.default) {
|
||||
return true;
|
||||
}
|
||||
return Object.keys(accounts).some((accountId) => !isDefaultWhatsAppAccountKey(accountId));
|
||||
}
|
||||
|
||||
function resolveDefaultWhatsAppAccountWriteKey(cfg: OpenClawConfig): string {
|
||||
const accounts = cfg.channels?.whatsapp?.accounts;
|
||||
if (!accounts) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
const match = Object.keys(accounts).find((accountId) => isDefaultWhatsAppAccountKey(accountId));
|
||||
return match ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveWhatsAppConfigPathPrefix(cfg: OpenClawConfig, accountId: string): string {
|
||||
if (
|
||||
accountId === DEFAULT_ACCOUNT_ID &&
|
||||
shouldWriteDefaultWhatsAppAccountConfigAtAccountScope(cfg)
|
||||
) {
|
||||
return `channels.whatsapp.accounts.${resolveDefaultWhatsAppAccountWriteKey(cfg)}`;
|
||||
}
|
||||
return accountId === DEFAULT_ACCOUNT_ID
|
||||
? "channels.whatsapp"
|
||||
: `channels.whatsapp.accounts.${accountId}`;
|
||||
}
|
||||
|
||||
function mergeWhatsAppConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
@@ -35,7 +71,8 @@ function mergeWhatsAppConfig(
|
||||
): OpenClawConfig {
|
||||
const channelConfig: WhatsAppConfig = { ...cfg.channels?.whatsapp };
|
||||
const mutableChannelConfig = channelConfig as Record<string, unknown>;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const targetPathPrefix = resolveWhatsAppConfigPathPrefix(cfg, accountId);
|
||||
if (targetPathPrefix === "channels.whatsapp") {
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) {
|
||||
if (options?.unsetOnUndefined?.includes(key)) {
|
||||
@@ -56,7 +93,16 @@ function mergeWhatsAppConfig(
|
||||
const accounts = {
|
||||
...(channelConfig.accounts as Record<string, WhatsAppAccountConfig> | undefined),
|
||||
};
|
||||
const nextAccount: WhatsAppAccountConfig = { ...accounts[accountId] };
|
||||
const targetAccountId =
|
||||
accountId === DEFAULT_ACCOUNT_ID ? resolveDefaultWhatsAppAccountWriteKey(cfg) : accountId;
|
||||
const lowerDefaultAccount =
|
||||
accountId === DEFAULT_ACCOUNT_ID && targetAccountId !== DEFAULT_ACCOUNT_ID
|
||||
? accounts[DEFAULT_ACCOUNT_ID]
|
||||
: undefined;
|
||||
const nextAccount: WhatsAppAccountConfig = {
|
||||
...accounts[targetAccountId],
|
||||
...lowerDefaultAccount,
|
||||
};
|
||||
const mutableNextAccount = nextAccount as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === undefined) {
|
||||
@@ -67,7 +113,10 @@ function mergeWhatsAppConfig(
|
||||
}
|
||||
mutableNextAccount[key] = value;
|
||||
}
|
||||
accounts[accountId] = nextAccount;
|
||||
accounts[targetAccountId] = nextAccount;
|
||||
if (lowerDefaultAccount) {
|
||||
delete accounts[DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
@@ -204,14 +253,9 @@ async function promptWhatsAppDmAccess(params: {
|
||||
const existingPolicy = account.dmPolicy ?? "pairing";
|
||||
const existingAllowFrom = account.allowFrom ?? [];
|
||||
const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
|
||||
const policyKey =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? "channels.whatsapp.dmPolicy"
|
||||
: `channels.whatsapp.accounts.${accountId}.dmPolicy`;
|
||||
const allowFromKey =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? "channels.whatsapp.allowFrom"
|
||||
: `channels.whatsapp.accounts.${accountId}.allowFrom`;
|
||||
const configPathPrefix = resolveWhatsAppConfigPathPrefix(params.cfg, accountId);
|
||||
const policyKey = `${configPathPrefix}.dmPolicy`;
|
||||
const allowFromKey = `${configPathPrefix}.allowFrom`;
|
||||
|
||||
if (params.forceAllowFrom) {
|
||||
return await applyWhatsAppOwnerAllowlist({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-core";
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution";
|
||||
import {
|
||||
@@ -5,7 +6,10 @@ import {
|
||||
createScopedChannelConfigAdapter,
|
||||
createScopedDmSecurityResolver,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
import {
|
||||
collectOpenGroupPolicyRouteAllowlistWarnings,
|
||||
createAllowlistProviderGroupPolicyWarningCollector,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { createChannelPluginBase, getChatChannelMeta } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
@@ -32,6 +36,56 @@ import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./session
|
||||
|
||||
export const WHATSAPP_CHANNEL = "whatsapp" as const;
|
||||
|
||||
const WHATSAPP_GROUP_SCOPE_FIELDS = ["groupPolicy", "groupAllowFrom", "groups"] as const;
|
||||
|
||||
type WhatsAppGroupScopeField = (typeof WHATSAPP_GROUP_SCOPE_FIELDS)[number];
|
||||
|
||||
function resolveWhatsAppAccountKey(
|
||||
accounts: Record<string, unknown> | undefined,
|
||||
accountId: string,
|
||||
): string | undefined {
|
||||
if (!accounts) {
|
||||
return undefined;
|
||||
}
|
||||
if (Object.hasOwn(accounts, accountId)) {
|
||||
return accountId;
|
||||
}
|
||||
const normalizedAccountId = accountId.trim().toLowerCase();
|
||||
return Object.keys(accounts).find((key) => key.trim().toLowerCase() === normalizedAccountId);
|
||||
}
|
||||
|
||||
function resolveWhatsAppGroupScopeBasePath(params: {
|
||||
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
|
||||
accountId?: string | null;
|
||||
}): string {
|
||||
const accountId =
|
||||
typeof params.accountId === "string"
|
||||
? params.accountId.trim() || DEFAULT_ACCOUNT_ID
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
const accounts = params.cfg.channels?.whatsapp?.accounts;
|
||||
const accountKey = resolveWhatsAppAccountKey(accounts, accountId);
|
||||
const defaultAccountKey = resolveWhatsAppAccountKey(accounts, DEFAULT_ACCOUNT_ID);
|
||||
const accountConfig = accountKey ? accounts?.[accountKey] : undefined;
|
||||
const defaultAccountConfig = defaultAccountKey ? accounts?.[defaultAccountKey] : undefined;
|
||||
const matchesAnyGroupScopeField = (config: Record<string, unknown> | undefined): boolean =>
|
||||
WHATSAPP_GROUP_SCOPE_FIELDS.some((field) => config?.[field] !== undefined);
|
||||
if (matchesAnyGroupScopeField(accountConfig)) {
|
||||
return `channels.whatsapp.accounts.${accountKey}`;
|
||||
}
|
||||
if (accountId !== DEFAULT_ACCOUNT_ID && matchesAnyGroupScopeField(defaultAccountConfig)) {
|
||||
return `channels.whatsapp.accounts.${defaultAccountKey}`;
|
||||
}
|
||||
return "channels.whatsapp";
|
||||
}
|
||||
|
||||
function resolveWhatsAppConfigPath(params: {
|
||||
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
|
||||
accountId?: string | null;
|
||||
field: WhatsAppGroupScopeField;
|
||||
}): string {
|
||||
return `${resolveWhatsAppGroupScopeBasePath(params)}.${params.field}`;
|
||||
}
|
||||
|
||||
export async function loadWhatsAppChannelRuntime() {
|
||||
return await import("./channel.runtime.js");
|
||||
}
|
||||
@@ -58,6 +112,7 @@ const whatsappResolveDmPolicy = createScopedDmSecurityResolver<ResolvedWhatsAppA
|
||||
resolveAllowFrom: (account) => account.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw) => normalizeE164(raw),
|
||||
inheritSharedDefaultsFromDefaultAccount: true,
|
||||
});
|
||||
|
||||
export function createWhatsAppSetupWizardProxy(
|
||||
@@ -99,26 +154,41 @@ export function createWhatsAppPluginBase(params: {
|
||||
setup: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["setup"]>;
|
||||
isConfigured: NonNullable<ChannelPlugin<ResolvedWhatsAppAccount>["config"]>["isConfigured"];
|
||||
}) {
|
||||
const collectWhatsAppSecurityWarnings =
|
||||
createAllowlistProviderRouteAllowlistWarningCollector<ResolvedWhatsAppAccount>({
|
||||
providerConfigPresent: (cfg) => cfg.channels?.whatsapp !== undefined,
|
||||
resolveGroupPolicy: (account) => account.groupPolicy,
|
||||
resolveRouteAllowlistConfigured: (account) =>
|
||||
Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0,
|
||||
restrictSenders: {
|
||||
surface: "WhatsApp groups",
|
||||
openScope: "any member in allowed groups",
|
||||
groupPolicyPath: "channels.whatsapp.groupPolicy",
|
||||
groupAllowFromPath: "channels.whatsapp.groupAllowFrom",
|
||||
},
|
||||
noRouteAllowlist: {
|
||||
surface: "WhatsApp groups",
|
||||
routeAllowlistPath: "channels.whatsapp.groups",
|
||||
routeScope: "group",
|
||||
groupPolicyPath: "channels.whatsapp.groupPolicy",
|
||||
groupAllowFromPath: "channels.whatsapp.groupAllowFrom",
|
||||
},
|
||||
});
|
||||
const collectWhatsAppSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{
|
||||
account: ResolvedWhatsAppAccount;
|
||||
cfg: Parameters<typeof resolveWhatsAppAccount>[0]["cfg"];
|
||||
accountId?: string | null;
|
||||
}>({
|
||||
providerConfigPresent: (cfg) => cfg.channels?.whatsapp !== undefined,
|
||||
resolveGroupPolicy: ({ account }) => account.groupPolicy,
|
||||
collect: ({ account, accountId, cfg, groupPolicy }) =>
|
||||
collectOpenGroupPolicyRouteAllowlistWarnings({
|
||||
groupPolicy,
|
||||
routeAllowlistConfigured:
|
||||
Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0,
|
||||
restrictSenders: {
|
||||
surface: "WhatsApp groups",
|
||||
openScope: "any member in allowed groups",
|
||||
groupPolicyPath: resolveWhatsAppConfigPath({ cfg, accountId, field: "groupPolicy" }),
|
||||
groupAllowFromPath: resolveWhatsAppConfigPath({
|
||||
cfg,
|
||||
accountId,
|
||||
field: "groupAllowFrom",
|
||||
}),
|
||||
},
|
||||
noRouteAllowlist: {
|
||||
surface: "WhatsApp groups",
|
||||
routeAllowlistPath: resolveWhatsAppConfigPath({ cfg, accountId, field: "groups" }),
|
||||
routeScope: "group",
|
||||
groupPolicyPath: resolveWhatsAppConfigPath({ cfg, accountId, field: "groupPolicy" }),
|
||||
groupAllowFromPath: resolveWhatsAppConfigPath({
|
||||
cfg,
|
||||
accountId,
|
||||
field: "groupAllowFrom",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const base = createChannelPluginBase({
|
||||
id: WHATSAPP_CHANNEL,
|
||||
meta: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createMockBaileys } from "../../../test/mocks/baileys.js";
|
||||
|
||||
// Use globalThis to store the mock config so it survives vi.mock hoisting
|
||||
const CONFIG_KEY = Symbol.for("openclaw:testConfigMock");
|
||||
const SOURCE_CONFIG_KEY = Symbol.for("openclaw:testSourceConfigMock");
|
||||
const DEFAULT_CONFIG = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
@@ -26,13 +27,22 @@ const DEFAULT_CONFIG = {
|
||||
if (!(globalThis as Record<symbol, unknown>)[CONFIG_KEY]) {
|
||||
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
|
||||
}
|
||||
if (!(globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY]) {
|
||||
(globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY] = () => loadConfigMock();
|
||||
}
|
||||
|
||||
export function setLoadConfigMock(fn: unknown) {
|
||||
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = typeof fn === "function" ? fn : () => fn;
|
||||
}
|
||||
|
||||
export function setRuntimeConfigSourceSnapshotMock(fn: unknown) {
|
||||
(globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY] =
|
||||
typeof fn === "function" ? fn : () => fn;
|
||||
}
|
||||
|
||||
export function resetLoadConfigMock() {
|
||||
(globalThis as Record<symbol, unknown>)[CONFIG_KEY] = () => DEFAULT_CONFIG;
|
||||
(globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY] = () => loadConfigMock();
|
||||
}
|
||||
|
||||
function resolveStorePathFallback(store?: string, opts?: { agentId?: string }) {
|
||||
@@ -58,6 +68,14 @@ function loadConfigMock() {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
function loadRuntimeConfigSourceSnapshotMock() {
|
||||
const getter = (globalThis as Record<symbol, unknown>)[SOURCE_CONFIG_KEY];
|
||||
if (typeof getter === "function") {
|
||||
return getter();
|
||||
}
|
||||
return loadConfigMock();
|
||||
}
|
||||
|
||||
async function updateLastRouteMock(params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
@@ -354,6 +372,7 @@ function resolveChannelGroupRequireMentionMock(params: {
|
||||
}
|
||||
|
||||
vi.mock("./auto-reply/config.runtime.js", () => ({
|
||||
getRuntimeConfigSourceSnapshot: loadRuntimeConfigSourceSnapshotMock,
|
||||
loadConfig: loadConfigMock,
|
||||
updateLastRoute: updateLastRouteMock,
|
||||
loadSessionStore: loadSessionStoreMock,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -44,15 +44,49 @@ export function buildAccountScopedDmSecurityPolicy(params: {
|
||||
approveChannelId?: string;
|
||||
approveHint?: string;
|
||||
normalizeEntry?: (raw: string) => string;
|
||||
inheritSharedDefaultsFromDefaultAccount?: boolean;
|
||||
}): ChannelSecurityDmPolicy {
|
||||
const resolvedAccountId = params.accountId ?? params.fallbackAccountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const channelConfig = (params.cfg.channels as Record<string, unknown> | undefined)?.[
|
||||
params.channelKey
|
||||
] as { accounts?: Record<string, unknown> } | undefined;
|
||||
const useAccountPath = Boolean(channelConfig?.accounts?.[resolvedAccountId]);
|
||||
const basePath = useAccountPath
|
||||
? `channels.${params.channelKey}.accounts.${resolvedAccountId}.`
|
||||
: `channels.${params.channelKey}.`;
|
||||
] as { accounts?: Record<string, Record<string, unknown>> } | undefined;
|
||||
const rootBasePath = `channels.${params.channelKey}.`;
|
||||
const accountBasePath = `channels.${params.channelKey}.accounts.${resolvedAccountId}.`;
|
||||
const defaultBasePath = `channels.${params.channelKey}.accounts.${DEFAULT_ACCOUNT_ID}.`;
|
||||
const accountConfig = channelConfig?.accounts?.[resolvedAccountId];
|
||||
const defaultAccountConfig =
|
||||
params.inheritSharedDefaultsFromDefaultAccount && resolvedAccountId !== DEFAULT_ACCOUNT_ID
|
||||
? channelConfig?.accounts?.[DEFAULT_ACCOUNT_ID]
|
||||
: undefined;
|
||||
const resolveFieldName = (suffix: string | undefined, fallbackField: string): string | null =>
|
||||
suffix == null || suffix === ""
|
||||
? fallbackField
|
||||
: /^[A-Za-z0-9_-]+$/.test(suffix)
|
||||
? suffix
|
||||
: null;
|
||||
const simplePolicyField = resolveFieldName(params.policyPathSuffix, "dmPolicy");
|
||||
const simpleAllowFromField = resolveFieldName(params.allowFromPathSuffix, "allowFrom");
|
||||
const matchesAnyField = (
|
||||
config: Record<string, unknown> | undefined,
|
||||
fields: Array<string | null>,
|
||||
) => fields.some((field) => field != null && config?.[field] !== undefined);
|
||||
const basePath =
|
||||
simplePolicyField || simpleAllowFromField
|
||||
? matchesAnyField(accountConfig, [simplePolicyField, simpleAllowFromField])
|
||||
? accountBasePath
|
||||
: matchesAnyField(defaultAccountConfig, [simplePolicyField, simpleAllowFromField])
|
||||
? defaultBasePath
|
||||
: matchesAnyField(channelConfig as Record<string, unknown> | undefined, [
|
||||
simplePolicyField,
|
||||
simpleAllowFromField,
|
||||
])
|
||||
? rootBasePath
|
||||
: accountConfig
|
||||
? accountBasePath
|
||||
: rootBasePath
|
||||
: accountConfig
|
||||
? accountBasePath
|
||||
: rootBasePath;
|
||||
const allowFromPath = `${basePath}${params.allowFromPathSuffix ?? ""}`;
|
||||
const policyPath =
|
||||
params.policyPathSuffix != null ? `${basePath}${params.policyPathSuffix}` : undefined;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,8 +2,10 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { createConfigIO } from "./io.js";
|
||||
import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.js";
|
||||
import { withTempHomeConfig } from "./test-helpers.js";
|
||||
|
||||
async function withTempHome(run: (home: string) => Promise<void>): Promise<void> {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-"));
|
||||
@@ -112,4 +114,35 @@ describe("config io paths", () => {
|
||||
});
|
||||
expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinTrustedDirs).toEqual(["/ops/bin"]);
|
||||
});
|
||||
|
||||
it("moves WhatsApp shared access defaults into accounts.default during loadConfig() runtime compat", async () => {
|
||||
await withTempHomeConfig(
|
||||
{
|
||||
channels: {
|
||||
whatsapp: {
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["+15550001111"],
|
||||
groupPolicy: "open",
|
||||
groupAllowFrom: [],
|
||||
accounts: {
|
||||
work: {
|
||||
enabled: true,
|
||||
authDir: "/tmp/wa-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const loaded = loadConfig();
|
||||
expect(loaded.channels?.whatsapp?.accounts?.default).toMatchObject({
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["+15550001111"],
|
||||
groupPolicy: "open",
|
||||
groupAllowFrom: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -66,15 +66,49 @@ function buildAccountScopedDmSecurityPolicy(params: {
|
||||
approveChannelId?: string;
|
||||
approveHint?: string;
|
||||
normalizeEntry?: (raw: string) => string;
|
||||
inheritSharedDefaultsFromDefaultAccount?: boolean;
|
||||
}) {
|
||||
const resolvedAccountId = params.accountId ?? params.fallbackAccountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const channelConfig = (params.cfg.channels as Record<string, unknown> | undefined)?.[
|
||||
params.channelKey
|
||||
] as { accounts?: Record<string, unknown> } | undefined;
|
||||
const useAccountPath = Boolean(channelConfig?.accounts?.[resolvedAccountId]);
|
||||
const basePath = useAccountPath
|
||||
? `channels.${params.channelKey}.accounts.${resolvedAccountId}.`
|
||||
: `channels.${params.channelKey}.`;
|
||||
] as { accounts?: Record<string, Record<string, unknown>> } | undefined;
|
||||
const rootBasePath = `channels.${params.channelKey}.`;
|
||||
const accountBasePath = `channels.${params.channelKey}.accounts.${resolvedAccountId}.`;
|
||||
const defaultBasePath = `channels.${params.channelKey}.accounts.${DEFAULT_ACCOUNT_ID}.`;
|
||||
const accountConfig = channelConfig?.accounts?.[resolvedAccountId];
|
||||
const defaultAccountConfig =
|
||||
params.inheritSharedDefaultsFromDefaultAccount && resolvedAccountId !== DEFAULT_ACCOUNT_ID
|
||||
? channelConfig?.accounts?.[DEFAULT_ACCOUNT_ID]
|
||||
: undefined;
|
||||
const resolveFieldName = (suffix: string | undefined, fallbackField: string): string | null =>
|
||||
suffix == null || suffix === ""
|
||||
? fallbackField
|
||||
: /^[A-Za-z0-9_-]+$/.test(suffix)
|
||||
? suffix
|
||||
: null;
|
||||
const simplePolicyField = resolveFieldName(params.policyPathSuffix, "dmPolicy");
|
||||
const simpleAllowFromField = resolveFieldName(params.allowFromPathSuffix, "allowFrom");
|
||||
const matchesAnyField = (
|
||||
config: Record<string, unknown> | undefined,
|
||||
fields: Array<string | null>,
|
||||
) => fields.some((field) => field != null && config?.[field] !== undefined);
|
||||
const basePath =
|
||||
simplePolicyField || simpleAllowFromField
|
||||
? matchesAnyField(accountConfig, [simplePolicyField, simpleAllowFromField])
|
||||
? accountBasePath
|
||||
: matchesAnyField(defaultAccountConfig, [simplePolicyField, simpleAllowFromField])
|
||||
? defaultBasePath
|
||||
: matchesAnyField(channelConfig as Record<string, unknown> | undefined, [
|
||||
simplePolicyField,
|
||||
simpleAllowFromField,
|
||||
])
|
||||
? rootBasePath
|
||||
: accountConfig
|
||||
? accountBasePath
|
||||
: rootBasePath
|
||||
: accountConfig
|
||||
? accountBasePath
|
||||
: rootBasePath;
|
||||
const allowFromPath = `${basePath}${params.allowFromPathSuffix ?? ""}`;
|
||||
const policyPath =
|
||||
params.policyPathSuffix != null ? `${basePath}${params.policyPathSuffix}` : undefined;
|
||||
@@ -655,6 +689,7 @@ export function createScopedDmSecurityResolver<
|
||||
approveChannelId?: string;
|
||||
approveHint?: string;
|
||||
normalizeEntry?: (raw: string) => string;
|
||||
inheritSharedDefaultsFromDefaultAccount?: boolean;
|
||||
}) {
|
||||
return ({
|
||||
cfg,
|
||||
@@ -678,6 +713,7 @@ export function createScopedDmSecurityResolver<
|
||||
approveChannelId: params.approveChannelId,
|
||||
approveHint: params.approveHint,
|
||||
normalizeEntry: params.normalizeEntry,
|
||||
inheritSharedDefaultsFromDefaultAccount: params.inheritSharedDefaultsFromDefaultAccount,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ export function createRestrictSendersChannelSecurity<
|
||||
approveChannelId?: string;
|
||||
approveHint?: string;
|
||||
normalizeDmEntry?: (raw: string) => string;
|
||||
inheritSharedDefaultsFromDefaultAccount?: boolean;
|
||||
}): ChannelSecurityAdapter<ResolvedAccount> {
|
||||
return {
|
||||
resolveDmPolicy: createScopedDmSecurityResolver<ResolvedAccount>({
|
||||
@@ -173,6 +174,7 @@ export function createRestrictSendersChannelSecurity<
|
||||
approveChannelId: params.approveChannelId,
|
||||
approveHint: params.approveHint,
|
||||
normalizeEntry: params.normalizeDmEntry,
|
||||
inheritSharedDefaultsFromDefaultAccount: params.inheritSharedDefaultsFromDefaultAccount,
|
||||
}),
|
||||
collectWarnings: createAllowlistProviderRestrictSendersWarningCollector<ResolvedAccount>({
|
||||
providerConfigPresent:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
export { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
export {
|
||||
clearRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
getRuntimeConfigSnapshot,
|
||||
loadConfig,
|
||||
readConfigFileSnapshotForWrite,
|
||||
|
||||
@@ -435,6 +435,7 @@ type ChatChannelSecurityOptions<TResolvedAccount extends { accountId?: string |
|
||||
approveChannelId?: string;
|
||||
approveHint?: string;
|
||||
normalizeEntry?: (raw: string) => string;
|
||||
inheritSharedDefaultsFromDefaultAccount?: boolean;
|
||||
};
|
||||
collectWarnings?: ChannelSecurityAdapter<TResolvedAccount>["collectWarnings"];
|
||||
collectAuditFindings?: ChannelSecurityAdapter<TResolvedAccount>["collectAuditFindings"];
|
||||
@@ -543,6 +544,8 @@ function resolveChatChannelSecurity<TResolvedAccount extends { accountId?: strin
|
||||
approveChannelId: security.dm.approveChannelId,
|
||||
approveHint: security.dm.approveHint,
|
||||
normalizeEntry: security.dm.normalizeEntry,
|
||||
inheritSharedDefaultsFromDefaultAccount:
|
||||
security.dm.inheritSharedDefaultsFromDefaultAccount,
|
||||
}),
|
||||
...(security.collectWarnings ? { collectWarnings: security.collectWarnings } : {}),
|
||||
...(security.collectAuditFindings
|
||||
|
||||
Reference in New Issue
Block a user