refactor: harden broadcast groups

This commit is contained in:
Peter Steinberger
2026-01-09 21:29:07 +01:00
parent 374aa856f2
commit 7641b142ad
5 changed files with 730 additions and 454 deletions

View File

@@ -51,6 +51,24 @@ Routing picks **one agent** for each inbound message:
The matched agent determines which workspace and session store are used. The matched agent determines which workspace and session store are used.
## Broadcast groups (run multiple agents)
Broadcast groups let you run **multiple agents** for the same peer **when Clawdbot would normally reply** (for example: in WhatsApp groups, after mention/activation gating).
Config:
```json5
{
broadcast: {
strategy: "parallel",
"120363403215116621@g.us": ["alfred", "baerbel"],
"+15555550123": ["support", "logger"]
}
}
```
See: [Broadcast Groups](/broadcast-groups).
## Config overview ## Config overview
- `agents.list`: named agent definitions (workspace, model, etc.). - `agents.list`: named agent definitions (workspace, model, etc.).

View File

@@ -1079,393 +1079,423 @@ const AgentDefaultsSchema = z
}) })
.optional(); .optional();
export const ClawdbotSchema = z.object({ export const ClawdbotSchema = z
env: z .object({
.object({ env: z
shellEnv: z .object({
.object({ shellEnv: z
enabled: z.boolean().optional(), .object({
timeoutMs: z.number().int().nonnegative().optional(), enabled: z.boolean().optional(),
}) timeoutMs: z.number().int().nonnegative().optional(),
.optional(), })
vars: z.record(z.string(), z.string()).optional(), .optional(),
}) vars: z.record(z.string(), z.string()).optional(),
.catchall(z.string()) })
.optional(), .catchall(z.string())
wizard: z .optional(),
.object({ wizard: z
lastRunAt: z.string().optional(), .object({
lastRunVersion: z.string().optional(), lastRunAt: z.string().optional(),
lastRunCommit: z.string().optional(), lastRunVersion: z.string().optional(),
lastRunCommand: z.string().optional(), lastRunCommit: z.string().optional(),
lastRunMode: z lastRunCommand: z.string().optional(),
.union([z.literal("local"), z.literal("remote")]) lastRunMode: z
.optional(), .union([z.literal("local"), z.literal("remote")])
}) .optional(),
.optional(), })
logging: z .optional(),
.object({ logging: z
level: z .object({
.union([ level: z
z.literal("silent"), .union([
z.literal("fatal"), z.literal("silent"),
z.literal("error"), z.literal("fatal"),
z.literal("warn"), z.literal("error"),
z.literal("info"), z.literal("warn"),
z.literal("debug"), z.literal("info"),
z.literal("trace"), z.literal("debug"),
]) z.literal("trace"),
.optional(), ])
file: z.string().optional(), .optional(),
consoleLevel: z file: z.string().optional(),
.union([ consoleLevel: z
z.literal("silent"), .union([
z.literal("fatal"), z.literal("silent"),
z.literal("error"), z.literal("fatal"),
z.literal("warn"), z.literal("error"),
z.literal("info"), z.literal("warn"),
z.literal("debug"), z.literal("info"),
z.literal("trace"), z.literal("debug"),
]) z.literal("trace"),
.optional(), ])
consoleStyle: z .optional(),
.union([z.literal("pretty"), z.literal("compact"), z.literal("json")]) consoleStyle: z
.optional(), .union([z.literal("pretty"), z.literal("compact"), z.literal("json")])
redactSensitive: z .optional(),
.union([z.literal("off"), z.literal("tools")]) redactSensitive: z
.optional(), .union([z.literal("off"), z.literal("tools")])
redactPatterns: z.array(z.string()).optional(), .optional(),
}) redactPatterns: z.array(z.string()).optional(),
.optional(), })
browser: z .optional(),
.object({ browser: z
enabled: z.boolean().optional(), .object({
controlUrl: z.string().optional(), enabled: z.boolean().optional(),
cdpUrl: z.string().optional(), controlUrl: z.string().optional(),
color: z.string().optional(), cdpUrl: z.string().optional(),
executablePath: z.string().optional(), color: z.string().optional(),
headless: z.boolean().optional(), executablePath: z.string().optional(),
noSandbox: z.boolean().optional(), headless: z.boolean().optional(),
attachOnly: z.boolean().optional(), noSandbox: z.boolean().optional(),
defaultProfile: z.string().optional(), attachOnly: z.boolean().optional(),
profiles: z defaultProfile: z.string().optional(),
.record( profiles: z
z .record(
.string() z
.regex( .string()
/^[a-z0-9-]+$/, .regex(
"Profile names must be alphanumeric with hyphens only", /^[a-z0-9-]+$/,
), "Profile names must be alphanumeric with hyphens only",
z ),
.object({ z
cdpPort: z.number().int().min(1).max(65535).optional(), .object({
cdpUrl: z.string().optional(), cdpPort: z.number().int().min(1).max(65535).optional(),
color: HexColorSchema, cdpUrl: z.string().optional(),
}) color: HexColorSchema,
.refine((value) => value.cdpPort || value.cdpUrl, { })
message: "Profile must set cdpPort or cdpUrl", .refine((value) => value.cdpPort || value.cdpUrl, {
message: "Profile must set cdpPort or cdpUrl",
}),
)
.optional(),
})
.optional(),
ui: z
.object({
seamColor: HexColorSchema.optional(),
})
.optional(),
auth: z
.object({
profiles: z
.record(
z.string(),
z.object({
provider: z.string(),
mode: z.union([
z.literal("api_key"),
z.literal("oauth"),
z.literal("token"),
]),
email: z.string().optional(),
}), }),
) )
.optional(), .optional(),
}) order: z.record(z.string(), z.array(z.string())).optional(),
.optional(), })
ui: z .optional(),
.object({ models: ModelsConfigSchema,
seamColor: HexColorSchema.optional(), agents: AgentsSchema,
}) tools: ToolsSchema,
.optional(), bindings: BindingsSchema,
auth: z broadcast: BroadcastSchema,
.object({ audio: AudioSchema,
profiles: z messages: MessagesSchema,
.record( commands: CommandsSchema,
z.string(), session: SessionSchema,
z.object({ cron: z
provider: z.string(), .object({
mode: z.union([ enabled: z.boolean().optional(),
z.literal("api_key"), store: z.string().optional(),
z.literal("oauth"), maxConcurrentRuns: z.number().int().positive().optional(),
z.literal("token"), })
]), .optional(),
email: z.string().optional(), hooks: z
}), .object({
) enabled: z.boolean().optional(),
.optional(), path: z.string().optional(),
order: z.record(z.string(), z.array(z.string())).optional(), token: z.string().optional(),
}) maxBodyBytes: z.number().int().positive().optional(),
.optional(), presets: z.array(z.string()).optional(),
models: ModelsConfigSchema, transformsDir: z.string().optional(),
agents: AgentsSchema, mappings: z.array(HookMappingSchema).optional(),
tools: ToolsSchema, gmail: HooksGmailSchema,
bindings: BindingsSchema, })
broadcast: BroadcastSchema, .optional(),
audio: AudioSchema, web: z
messages: MessagesSchema, .object({
commands: CommandsSchema, enabled: z.boolean().optional(),
session: SessionSchema, heartbeatSeconds: z.number().int().positive().optional(),
cron: z reconnect: z
.object({ .object({
enabled: z.boolean().optional(), initialMs: z.number().positive().optional(),
store: z.string().optional(), maxMs: z.number().positive().optional(),
maxConcurrentRuns: z.number().int().positive().optional(), factor: z.number().positive().optional(),
}) jitter: z.number().min(0).max(1).optional(),
.optional(), maxAttempts: z.number().int().min(0).optional(),
hooks: z })
.object({ .optional(),
enabled: z.boolean().optional(), })
path: z.string().optional(), .optional(),
token: z.string().optional(), whatsapp: z
maxBodyBytes: z.number().int().positive().optional(), .object({
presets: z.array(z.string()).optional(), accounts: z
transformsDir: z.string().optional(), .record(
mappings: z.array(HookMappingSchema).optional(), z.string(),
gmail: HooksGmailSchema, z
}) .object({
.optional(), name: z.string().optional(),
web: z capabilities: z.array(z.string()).optional(),
.object({ enabled: z.boolean().optional(),
enabled: z.boolean().optional(), messagePrefix: z.string().optional(),
heartbeatSeconds: z.number().int().positive().optional(), /** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */
reconnect: z authDir: z.string().optional(),
.object({ dmPolicy: DmPolicySchema.optional().default("pairing"),
initialMs: z.number().positive().optional(), selfChatMode: z.boolean().optional(),
maxMs: z.number().positive().optional(), allowFrom: z.array(z.string()).optional(),
factor: z.number().positive().optional(), groupAllowFrom: z.array(z.string()).optional(),
jitter: z.number().min(0).max(1).optional(), groupPolicy: GroupPolicySchema.optional().default("open"),
maxAttempts: z.number().int().min(0).optional(), textChunkLimit: z.number().int().positive().optional(),
}) mediaMaxMb: z.number().int().positive().optional(),
.optional(), blockStreaming: z.boolean().optional(),
}) blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
.optional(), groups: z
whatsapp: z .record(
.object({ z.string(),
accounts: z z
.record( .object({
z.string(), requireMention: z.boolean().optional(),
z })
.object({ .optional(),
name: z.string().optional(), )
capabilities: z.array(z.string()).optional(), .optional(),
enabled: z.boolean().optional(), })
messagePrefix: z.string().optional(), .superRefine((value, ctx) => {
/** Override auth directory for this WhatsApp account (Baileys multi-file auth state). */ if (value.dmPolicy !== "open") return;
authDir: z.string().optional(), const allow = (value.allowFrom ?? [])
dmPolicy: DmPolicySchema.optional().default("pairing"), .map((v) => String(v).trim())
selfChatMode: z.boolean().optional(), .filter(Boolean);
allowFrom: z.array(z.string()).optional(), if (allow.includes("*")) return;
groupAllowFrom: z.array(z.string()).optional(), ctx.addIssue({
groupPolicy: GroupPolicySchema.optional().default("open"), code: z.ZodIssueCode.custom,
textChunkLimit: z.number().int().positive().optional(), path: ["allowFrom"],
mediaMaxMb: z.number().int().positive().optional(), message:
blockStreaming: z.boolean().optional(), 'whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"',
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), });
groups: z })
.record( .optional(),
z.string(), )
z .optional(),
.object({ capabilities: z.array(z.string()).optional(),
requireMention: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"),
}) messagePrefix: z.string().optional(),
.optional(), selfChatMode: z.boolean().optional(),
) allowFrom: z.array(z.string()).optional(),
.optional(), groupAllowFrom: z.array(z.string()).optional(),
}) groupPolicy: GroupPolicySchema.optional().default("open"),
.superRefine((value, ctx) => { textChunkLimit: z.number().int().positive().optional(),
if (value.dmPolicy !== "open") return; mediaMaxMb: z.number().int().positive().optional().default(50),
const allow = (value.allowFrom ?? []) blockStreaming: z.boolean().optional(),
.map((v) => String(v).trim()) blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
.filter(Boolean); actions: z
if (allow.includes("*")) return; .object({
ctx.addIssue({ reactions: z.boolean().optional(),
code: z.ZodIssueCode.custom, sendMessage: z.boolean().optional(),
path: ["allowFrom"], polls: z.boolean().optional(),
message: })
'whatsapp.accounts.*.dmPolicy="open" requires allowFrom to include "*"', .optional(),
}); groups: z
}) .record(
.optional(), z.string(),
) z
.optional(), .object({
capabilities: z.array(z.string()).optional(), requireMention: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"), })
messagePrefix: z.string().optional(), .optional(),
selfChatMode: z.boolean().optional(), )
allowFrom: z.array(z.string()).optional(), .optional(),
groupAllowFrom: z.array(z.string()).optional(), })
groupPolicy: GroupPolicySchema.optional().default("open"), .superRefine((value, ctx) => {
textChunkLimit: z.number().int().positive().optional(), if (value.dmPolicy !== "open") return;
mediaMaxMb: z.number().int().positive().optional().default(50), const allow = (value.allowFrom ?? [])
blockStreaming: z.boolean().optional(), .map((v) => String(v).trim())
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), .filter(Boolean);
actions: z if (allow.includes("*")) return;
.object({ ctx.addIssue({
reactions: z.boolean().optional(), code: z.ZodIssueCode.custom,
sendMessage: z.boolean().optional(), path: ["allowFrom"],
polls: z.boolean().optional(), message:
}) 'whatsapp.dmPolicy="open" requires whatsapp.allowFrom to include "*"',
.optional(), });
groups: z })
.record( .optional(),
z.string(), telegram: TelegramConfigSchema.optional(),
z discord: DiscordConfigSchema.optional(),
.object({ slack: SlackConfigSchema.optional(),
requireMention: z.boolean().optional(), signal: SignalConfigSchema.optional(),
}) imessage: IMessageConfigSchema.optional(),
.optional(), msteams: MSTeamsConfigSchema.optional(),
) bridge: z
.optional(), .object({
}) enabled: z.boolean().optional(),
.superRefine((value, ctx) => { port: z.number().int().positive().optional(),
if (value.dmPolicy !== "open") return; bind: z
const allow = (value.allowFrom ?? []) .union([
.map((v) => String(v).trim()) z.literal("auto"),
.filter(Boolean); z.literal("lan"),
if (allow.includes("*")) return; z.literal("tailnet"),
ctx.addIssue({ z.literal("loopback"),
code: z.ZodIssueCode.custom, ])
path: ["allowFrom"], .optional(),
message: })
'whatsapp.dmPolicy="open" requires whatsapp.allowFrom to include "*"', .optional(),
}); discovery: z
}) .object({
.optional(), wideArea: z
telegram: TelegramConfigSchema.optional(), .object({
discord: DiscordConfigSchema.optional(), enabled: z.boolean().optional(),
slack: SlackConfigSchema.optional(), })
signal: SignalConfigSchema.optional(), .optional(),
imessage: IMessageConfigSchema.optional(), })
msteams: MSTeamsConfigSchema.optional(), .optional(),
bridge: z canvasHost: z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
port: z.number().int().positive().optional(), root: z.string().optional(),
bind: z port: z.number().int().positive().optional(),
.union([ liveReload: z.boolean().optional(),
z.literal("auto"), })
z.literal("lan"), .optional(),
z.literal("tailnet"), talk: z
z.literal("loopback"), .object({
]) voiceId: z.string().optional(),
.optional(), voiceAliases: z.record(z.string(), z.string()).optional(),
}) modelId: z.string().optional(),
.optional(), outputFormat: z.string().optional(),
discovery: z apiKey: z.string().optional(),
.object({ interruptOnSpeech: z.boolean().optional(),
wideArea: z })
.object({ .optional(),
enabled: z.boolean().optional(), gateway: z
}) .object({
.optional(), port: z.number().int().positive().optional(),
}) mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
.optional(), bind: z
canvasHost: z .union([
.object({ z.literal("auto"),
enabled: z.boolean().optional(), z.literal("lan"),
root: z.string().optional(), z.literal("tailnet"),
port: z.number().int().positive().optional(), z.literal("loopback"),
liveReload: z.boolean().optional(), ])
}) .optional(),
.optional(), controlUi: z
talk: z .object({
.object({ enabled: z.boolean().optional(),
voiceId: z.string().optional(), basePath: z.string().optional(),
voiceAliases: z.record(z.string(), z.string()).optional(), })
modelId: z.string().optional(), .optional(),
outputFormat: z.string().optional(), auth: z
apiKey: z.string().optional(), .object({
interruptOnSpeech: z.boolean().optional(), mode: z
}) .union([z.literal("token"), z.literal("password")])
.optional(), .optional(),
gateway: z token: z.string().optional(),
.object({ password: z.string().optional(),
port: z.number().int().positive().optional(), allowTailscale: z.boolean().optional(),
mode: z.union([z.literal("local"), z.literal("remote")]).optional(), })
bind: z .optional(),
.union([ tailscale: z
z.literal("auto"), .object({
z.literal("lan"), mode: z
z.literal("tailnet"), .union([
z.literal("loopback"), z.literal("off"),
]) z.literal("serve"),
.optional(), z.literal("funnel"),
controlUi: z ])
.object({ .optional(),
enabled: z.boolean().optional(), resetOnExit: z.boolean().optional(),
basePath: z.string().optional(), })
}) .optional(),
.optional(), remote: z
auth: z .object({
.object({ url: z.string().optional(),
mode: z.union([z.literal("token"), z.literal("password")]).optional(), token: z.string().optional(),
token: z.string().optional(), password: z.string().optional(),
password: z.string().optional(), sshTarget: z.string().optional(),
allowTailscale: z.boolean().optional(), sshIdentity: z.string().optional(),
}) })
.optional(), .optional(),
tailscale: z reload: z
.object({ .object({
mode: z mode: z
.union([z.literal("off"), z.literal("serve"), z.literal("funnel")]) .union([
.optional(), z.literal("off"),
resetOnExit: z.boolean().optional(), z.literal("restart"),
}) z.literal("hot"),
.optional(), z.literal("hybrid"),
remote: z ])
.object({ .optional(),
url: z.string().optional(), debounceMs: z.number().int().min(0).optional(),
token: z.string().optional(), })
password: z.string().optional(), .optional(),
sshTarget: z.string().optional(), })
sshIdentity: z.string().optional(), .optional(),
}) skills: z
.optional(), .object({
reload: z allowBundled: z.array(z.string()).optional(),
.object({ load: z
mode: z .object({
.union([ extraDirs: z.array(z.string()).optional(),
z.literal("off"), })
z.literal("restart"), .optional(),
z.literal("hot"), install: z
z.literal("hybrid"), .object({
]) preferBrew: z.boolean().optional(),
.optional(), nodeManager: z
debounceMs: z.number().int().min(0).optional(), .union([
}) z.literal("npm"),
.optional(), z.literal("pnpm"),
}) z.literal("yarn"),
.optional(), z.literal("bun"),
skills: z ])
.object({ .optional(),
allowBundled: z.array(z.string()).optional(), })
load: z .optional(),
.object({ entries: z
extraDirs: z.array(z.string()).optional(), .record(
}) z.string(),
.optional(), z
install: z .object({
.object({ enabled: z.boolean().optional(),
preferBrew: z.boolean().optional(), apiKey: z.string().optional(),
nodeManager: z env: z.record(z.string(), z.string()).optional(),
.union([ })
z.literal("npm"), .passthrough(),
z.literal("pnpm"), )
z.literal("yarn"), .optional(),
z.literal("bun"), })
]) .optional(),
.optional(), })
}) .superRefine((cfg, ctx) => {
.optional(), const agents = cfg.agents?.list ?? [];
entries: z if (agents.length === 0) return;
.record( const agentIds = new Set(agents.map((agent) => agent.id));
z.string(),
z const broadcast = cfg.broadcast;
.object({ if (!broadcast) return;
enabled: z.boolean().optional(),
apiKey: z.string().optional(), for (const [peerId, ids] of Object.entries(broadcast)) {
env: z.record(z.string(), z.string()).optional(), if (peerId === "strategy") continue;
}) if (!Array.isArray(ids)) continue;
.passthrough(), for (let idx = 0; idx < ids.length; idx += 1) {
) const agentId = ids[idx];
.optional(), if (!agentIds.has(agentId)) {
}) ctx.addIssue({
.optional(), code: z.ZodIssueCode.custom,
}); path: ["broadcast", peerId, idx],
message: `Unknown agent id "${agentId}" (not in agents.list).`,
});
}
}
}
});

View File

@@ -2,6 +2,10 @@ export const DEFAULT_AGENT_ID = "main";
export const DEFAULT_MAIN_KEY = "main"; export const DEFAULT_MAIN_KEY = "main";
export const DEFAULT_ACCOUNT_ID = "default"; export const DEFAULT_ACCOUNT_ID = "default";
function normalizeToken(value: string | undefined | null): string {
return (value ?? "").trim().toLowerCase();
}
export type ParsedAgentSessionKey = { export type ParsedAgentSessionKey = {
agentId: string; agentId: string;
rest: string; rest: string;
@@ -97,6 +101,18 @@ export function buildAgentPeerSessionKey(params: {
return `agent:${normalizeAgentId(params.agentId)}:${provider}:${peerKind}:${peerId}`; return `agent:${normalizeAgentId(params.agentId)}:${provider}:${peerKind}:${peerId}`;
} }
export function buildGroupHistoryKey(params: {
provider: string;
accountId?: string | null;
peerKind: "group" | "channel";
peerId: string;
}): string {
const provider = normalizeToken(params.provider) || "unknown";
const accountId = normalizeAccountId(params.accountId);
const peerId = params.peerId.trim() || "unknown";
return `${provider}:${accountId}:${params.peerKind}:${peerId}`;
}
export function resolveThreadSessionKeys(params: { export function resolveThreadSessionKeys(params: {
baseSessionKey: string; baseSessionKey: string;
threadId?: string | null; threadId?: string | null;

View File

@@ -2081,3 +2081,183 @@ describe("web auto-reply", () => {
resetLoadConfigMock(); resetLoadConfigMock();
}); });
}); });
describe("broadcast groups", () => {
it("broadcasts sequentially in configured order", async () => {
setLoadConfigMock({
whatsapp: { allowFrom: ["*"] },
agents: {
defaults: { maxConcurrent: 10 },
list: [{ id: "alfred" }, { id: "baerbel" }],
},
broadcast: {
strategy: "sequential",
"+1000": ["alfred", "baerbel"],
},
} satisfies ClawdbotConfig);
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const seen: string[] = [];
const resolver = vi.fn(async (ctx: { SessionKey?: unknown }) => {
seen.push(String(ctx.SessionKey));
return { text: "ok" };
});
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
await monitorWebProvider(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
id: "m1",
from: "+1000",
conversationId: "+1000",
to: "+2000",
body: "hello",
timestamp: Date.now(),
chatType: "direct",
chatId: "direct:+1000",
sendComposing,
reply,
sendMedia,
});
expect(resolver).toHaveBeenCalledTimes(2);
expect(seen[0]).toContain("agent:alfred:");
expect(seen[1]).toContain("agent:baerbel:");
resetLoadConfigMock();
});
it("broadcasts in parallel by default", async () => {
setLoadConfigMock({
whatsapp: { allowFrom: ["*"] },
agents: {
defaults: { maxConcurrent: 10 },
list: [{ id: "alfred" }, { id: "baerbel" }],
},
broadcast: {
strategy: "parallel",
"+1000": ["alfred", "baerbel"],
},
} satisfies ClawdbotConfig);
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
let started = 0;
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const resolver = vi.fn(async () => {
started += 1;
if (started < 2) {
await gate;
} else {
release?.();
}
return { text: "ok" };
});
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
await monitorWebProvider(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
id: "m1",
from: "+1000",
conversationId: "+1000",
to: "+2000",
body: "hello",
timestamp: Date.now(),
chatType: "direct",
chatId: "direct:+1000",
sendComposing,
reply,
sendMedia,
});
expect(resolver).toHaveBeenCalledTimes(2);
resetLoadConfigMock();
});
it("skips unknown broadcast agent ids when agents.list is present", async () => {
setLoadConfigMock({
whatsapp: { allowFrom: ["*"] },
agents: {
defaults: { maxConcurrent: 10 },
list: [{ id: "alfred" }],
},
broadcast: {
"+1000": ["alfred", "missing"],
},
} satisfies ClawdbotConfig);
const sendMedia = vi.fn();
const reply = vi.fn().mockResolvedValue(undefined);
const sendComposing = vi.fn();
const seen: string[] = [];
const resolver = vi.fn(async (ctx: { SessionKey?: unknown }) => {
seen.push(String(ctx.SessionKey));
return { text: "ok" };
});
let capturedOnMessage:
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
| undefined;
const listenerFactory = async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
return { close: vi.fn() };
};
await monitorWebProvider(false, listenerFactory, false, resolver);
expect(capturedOnMessage).toBeDefined();
await capturedOnMessage?.({
id: "m1",
from: "+1000",
conversationId: "+1000",
to: "+2000",
body: "hello",
timestamp: Date.now(),
chatType: "direct",
chatId: "direct:+1000",
sendComposing,
reply,
sendMedia,
});
expect(resolver).toHaveBeenCalledTimes(1);
expect(seen[0]).toContain("agent:alfred:");
resetLoadConfigMock();
});
});

View File

@@ -54,6 +54,7 @@ import {
} from "../routing/resolve-route.js"; } from "../routing/resolve-route.js";
import { import {
buildAgentMainSessionKey, buildAgentMainSessionKey,
buildGroupHistoryKey,
DEFAULT_MAIN_KEY, DEFAULT_MAIN_KEY,
normalizeAgentId, normalizeAgentId,
} from "../routing/session-key.js"; } from "../routing/session-key.js";
@@ -1001,14 +1002,27 @@ export async function monitorWebProvider(
// Track recently sent messages to prevent echo loops // Track recently sent messages to prevent echo loops
const recentlySent = new Set<string>(); const recentlySent = new Set<string>();
const MAX_RECENT_MESSAGES = 100; const MAX_RECENT_MESSAGES = 100;
const buildCombinedEchoKey = (params: {
sessionKey: string;
combinedBody: string;
}) => `combined:${params.sessionKey}:${params.combinedBody}`;
const rememberSentText = ( const rememberSentText = (
text: string | undefined, text: string | undefined,
opts: { combinedBody: string; logVerboseMessage?: boolean }, opts: {
combinedBody?: string;
combinedBodySessionKey?: string;
logVerboseMessage?: boolean;
},
) => { ) => {
if (!text) return; if (!text) return;
recentlySent.add(text); recentlySent.add(text);
if (opts.combinedBody) { if (opts.combinedBody && opts.combinedBodySessionKey) {
recentlySent.add(opts.combinedBody); recentlySent.add(
buildCombinedEchoKey({
sessionKey: opts.combinedBodySessionKey,
combinedBody: opts.combinedBody,
}),
);
} }
if (opts.logVerboseMessage) { if (opts.logVerboseMessage) {
logVerbose( logVerbose(
@@ -1117,9 +1131,13 @@ export async function monitorWebProvider(
} }
// Echo detection uses combined body so we don't respond twice. // Echo detection uses combined body so we don't respond twice.
if (recentlySent.has(combinedBody)) { const combinedEchoKey = buildCombinedEchoKey({
sessionKey: route.sessionKey,
combinedBody,
});
if (recentlySent.has(combinedEchoKey)) {
logVerbose(`Skipping auto-reply: detected echo for combined message`); logVerbose(`Skipping auto-reply: detected echo for combined message`);
recentlySent.delete(combinedBody); recentlySent.delete(combinedEchoKey);
return; return;
} }
@@ -1213,13 +1231,14 @@ export async function monitorWebProvider(
}); });
didSendReply = true; didSendReply = true;
if (info.kind === "tool") { if (info.kind === "tool") {
rememberSentText(payload.text, { combinedBody: "" }); rememberSentText(payload.text, {});
return; return;
} }
const shouldLog = const shouldLog =
info.kind === "final" && payload.text ? true : undefined; info.kind === "final" && payload.text ? true : undefined;
rememberSentText(payload.text, { rememberSentText(payload.text, {
combinedBody, combinedBody,
combinedBodySessionKey: route.sessionKey,
logVerboseMessage: shouldLog, logVerboseMessage: shouldLog,
}); });
if (info.kind === "final") { if (info.kind === "final") {
@@ -1274,7 +1293,7 @@ export async function monitorWebProvider(
GroupSubject: msg.groupSubject, GroupSubject: msg.groupSubject,
GroupMembers: formatGroupMembers( GroupMembers: formatGroupMembers(
msg.groupParticipants, msg.groupParticipants,
groupMemberNames.get(route.sessionKey), groupMemberNames.get(groupHistoryKey),
msg.senderE164, msg.senderE164,
), ),
SenderName: msg.senderName, SenderName: msg.senderName,
@@ -1313,6 +1332,70 @@ export async function monitorWebProvider(
} }
}; };
const maybeBroadcastMessage = async (params: {
msg: WebInboundMsg;
peerId: string;
route: ReturnType<typeof resolveAgentRoute>;
groupHistoryKey: string;
}): Promise<boolean> => {
const { msg, peerId, route, groupHistoryKey } = params;
const broadcastAgents = cfg.broadcast?.[peerId];
if (!broadcastAgents || !Array.isArray(broadcastAgents)) return false;
if (broadcastAgents.length === 0) return false;
const strategy = cfg.broadcast?.strategy || "parallel";
whatsappInboundLog.info(
`Broadcasting message to ${broadcastAgents.length} agents (${strategy})`,
);
const agentIds = cfg.agents?.list?.map((agent) =>
normalizeAgentId(agent.id),
);
const hasKnownAgents = (agentIds?.length ?? 0) > 0;
const processForAgent = (agentId: string) => {
const normalizedAgentId = normalizeAgentId(agentId);
if (hasKnownAgents && !agentIds?.includes(normalizedAgentId)) {
whatsappInboundLog.warn(
`Broadcast agent ${agentId} not found in agents.list; skipping`,
);
return Promise.resolve();
}
const agentRoute = {
...route,
agentId: normalizedAgentId,
sessionKey: buildAgentSessionKey({
agentId: normalizedAgentId,
provider: "whatsapp",
peer: {
kind: msg.chatType === "group" ? "group" : "dm",
id: peerId,
},
}),
mainSessionKey: buildAgentMainSessionKey({
agentId: normalizedAgentId,
mainKey: DEFAULT_MAIN_KEY,
}),
};
return processMessage(msg, agentRoute, groupHistoryKey).catch((err) => {
whatsappInboundLog.error(
`Broadcast agent ${agentId} failed: ${formatError(err)}`,
);
});
};
if (strategy === "sequential") {
for (const agentId of broadcastAgents) {
await processForAgent(agentId);
}
} else {
await Promise.allSettled(broadcastAgents.map(processForAgent));
}
return true;
};
const listener = await (listenerFactory ?? monitorWebInbox)({ const listener = await (listenerFactory ?? monitorWebInbox)({
verbose, verbose,
accountId: account.accountId, accountId: account.accountId,
@@ -1349,7 +1432,12 @@ export async function monitorWebProvider(
}); });
const groupHistoryKey = const groupHistoryKey =
msg.chatType === "group" msg.chatType === "group"
? `whatsapp:${route.accountId}:group:${peerId.trim() || "unknown"}` ? buildGroupHistoryKey({
provider: "whatsapp",
accountId: route.accountId,
peerKind: "group",
peerId,
})
: route.sessionKey; : route.sessionKey;
// Same-phone mode logging retained // Same-phone mode logging retained
@@ -1467,65 +1555,9 @@ export async function monitorWebProvider(
// Broadcast groups: when we'd reply anyway, run multiple agents. // Broadcast groups: when we'd reply anyway, run multiple agents.
// Does not bypass group mention/activation gating above (Option A). // Does not bypass group mention/activation gating above (Option A).
const broadcastAgents = cfg.broadcast?.[peerId];
if ( if (
broadcastAgents && await maybeBroadcastMessage({ msg, peerId, route, groupHistoryKey })
Array.isArray(broadcastAgents) &&
broadcastAgents.length > 0
) { ) {
const strategy = cfg.broadcast?.strategy || "parallel";
whatsappInboundLog.info(
`Broadcasting message to ${broadcastAgents.length} agents (${strategy})`,
);
const agentIds = cfg.agents?.list?.map((agent) =>
normalizeAgentId(agent.id),
);
const hasKnownAgents = (agentIds?.length ?? 0) > 0;
const processForAgent = (agentId: string) => {
const normalizedAgentId = normalizeAgentId(agentId);
if (hasKnownAgents && !agentIds?.includes(normalizedAgentId)) {
whatsappInboundLog.warn(
`Broadcast agent ${agentId} not found in agents.list; skipping`,
);
return Promise.resolve();
}
const agentRoute = {
...route,
agentId: normalizedAgentId,
sessionKey: buildAgentSessionKey({
agentId: normalizedAgentId,
provider: "whatsapp",
peer: {
kind: msg.chatType === "group" ? "group" : "dm",
id: peerId,
},
}),
mainSessionKey: buildAgentMainSessionKey({
agentId: normalizedAgentId,
mainKey: DEFAULT_MAIN_KEY,
}),
};
return processMessage(msg, agentRoute, groupHistoryKey).catch(
(err) => {
whatsappInboundLog.error(
`Broadcast agent ${agentId} failed: ${formatError(err)}`,
);
},
);
};
if (strategy === "sequential") {
for (const agentId of broadcastAgents) {
await processForAgent(agentId);
}
} else {
// Parallel processing (default)
await Promise.allSettled(broadcastAgents.map(processForAgent));
}
return; return;
} }