refactor: harden broadcast groups
This commit is contained in:
@@ -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.).
|
||||||
|
|||||||
@@ -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).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user