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,7 +1079,8 @@ const AgentDefaultsSchema = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
export const ClawdbotSchema = z.object({
|
export const ClawdbotSchema = z
|
||||||
|
.object({
|
||||||
env: z
|
env: z
|
||||||
.object({
|
.object({
|
||||||
shellEnv: z
|
shellEnv: z
|
||||||
@@ -1395,7 +1396,9 @@ export const ClawdbotSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
auth: z
|
auth: z
|
||||||
.object({
|
.object({
|
||||||
mode: z.union([z.literal("token"), z.literal("password")]).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(),
|
||||||
allowTailscale: z.boolean().optional(),
|
allowTailscale: z.boolean().optional(),
|
||||||
@@ -1404,7 +1407,11 @@ export const ClawdbotSchema = z.object({
|
|||||||
tailscale: z
|
tailscale: z
|
||||||
.object({
|
.object({
|
||||||
mode: z
|
mode: z
|
||||||
.union([z.literal("off"), z.literal("serve"), z.literal("funnel")])
|
.union([
|
||||||
|
z.literal("off"),
|
||||||
|
z.literal("serve"),
|
||||||
|
z.literal("funnel"),
|
||||||
|
])
|
||||||
.optional(),
|
.optional(),
|
||||||
resetOnExit: z.boolean().optional(),
|
resetOnExit: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
@@ -1468,4 +1475,27 @@ export const ClawdbotSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
})
|
||||||
|
.superRefine((cfg, ctx) => {
|
||||||
|
const agents = cfg.agents?.list ?? [];
|
||||||
|
if (agents.length === 0) return;
|
||||||
|
const agentIds = new Set(agents.map((agent) => agent.id));
|
||||||
|
|
||||||
|
const broadcast = cfg.broadcast;
|
||||||
|
if (!broadcast) return;
|
||||||
|
|
||||||
|
for (const [peerId, ids] of Object.entries(broadcast)) {
|
||||||
|
if (peerId === "strategy") continue;
|
||||||
|
if (!Array.isArray(ids)) continue;
|
||||||
|
for (let idx = 0; idx < ids.length; idx += 1) {
|
||||||
|
const agentId = ids[idx];
|
||||||
|
if (!agentIds.has(agentId)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
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