feat: add slack multi-account routing
This commit is contained in:
@@ -791,6 +791,7 @@ export async function getReplyFromConfig(
|
||||
sessionId: sessionIdFinal,
|
||||
sessionKey,
|
||||
messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: sessionCtx.AccountId,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
|
||||
149
src/auto-reply/reply/agent-runner.messaging-tools.test.ts
Normal file
149
src/auto-reply/reply/agent-runner.messaging-tools.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
|
||||
const runEmbeddedPiAgentMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: async ({
|
||||
provider,
|
||||
model,
|
||||
run,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => ({
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
scheduleFollowupDrain: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { runReplyAgent } from "./agent-runner.js";
|
||||
|
||||
function createRun(messageProvider = "slack") {
|
||||
const typing = createMockTypingController();
|
||||
const sessionCtx = {
|
||||
Provider: messageProvider,
|
||||
OriginatingTo: "channel:C1",
|
||||
AccountId: "primary",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext;
|
||||
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
||||
const followupRun = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
messageProvider,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "off",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
} as unknown as FollowupRun;
|
||||
|
||||
return runReplyAgent({
|
||||
commandBody: "hello",
|
||||
followupRun,
|
||||
queueKey: "main",
|
||||
resolvedQueue,
|
||||
shouldSteer: false,
|
||||
shouldFollowup: false,
|
||||
isActive: false,
|
||||
isStreaming: false,
|
||||
typing,
|
||||
sessionCtx,
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
resolvedVerboseLevel: "off",
|
||||
isNewSession: false,
|
||||
blockStreamingEnabled: false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
shouldInjectGroupIntro: false,
|
||||
typingMode: "instant",
|
||||
});
|
||||
}
|
||||
|
||||
describe("runReplyAgent messaging tool suppression", () => {
|
||||
it("drops replies when a messaging tool sent via the same provider + target", async () => {
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [
|
||||
{ tool: "slack", provider: "slack", to: "channel:C1" },
|
||||
],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const result = await createRun("slack");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("delivers replies when tool provider does not match", async () => {
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [
|
||||
{ tool: "discord", provider: "discord", to: "channel:C1" },
|
||||
],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const result = await createRun("slack");
|
||||
|
||||
expect(result).toMatchObject({ text: "hello world!" });
|
||||
});
|
||||
|
||||
it("delivers replies when account ids do not match", async () => {
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [
|
||||
{
|
||||
tool: "slack",
|
||||
provider: "slack",
|
||||
to: "channel:C1",
|
||||
accountId: "alt",
|
||||
},
|
||||
],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const result = await createRun("slack");
|
||||
|
||||
expect(result).toMatchObject({ text: "hello world!" });
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
applyReplyThreading,
|
||||
filterMessagingToolDuplicates,
|
||||
isRenderablePayload,
|
||||
shouldSuppressMessagingToolReplies,
|
||||
} from "./reply-payloads.js";
|
||||
import {
|
||||
createReplyToModeFilter,
|
||||
@@ -240,6 +241,7 @@ export async function runReplyAgent(params: {
|
||||
sessionKey,
|
||||
messageProvider:
|
||||
sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: sessionCtx.AccountId,
|
||||
sessionFile: followupRun.run.sessionFile,
|
||||
workspaceDir: followupRun.run.workspaceDir,
|
||||
agentDir: followupRun.run.agentDir,
|
||||
@@ -549,6 +551,13 @@ export async function runReplyAgent(params: {
|
||||
const shouldDropFinalPayloads =
|
||||
blockStreamingEnabled && didStreamBlockReply;
|
||||
const messagingToolSentTexts = runResult.messagingToolSentTexts ?? [];
|
||||
const messagingToolSentTargets = runResult.messagingToolSentTargets ?? [];
|
||||
const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({
|
||||
messageProvider: followupRun.run.messageProvider,
|
||||
messagingToolSentTargets,
|
||||
originatingTo: sessionCtx.OriginatingTo ?? sessionCtx.To,
|
||||
accountId: sessionCtx.AccountId,
|
||||
});
|
||||
const dedupedPayloads = filterMessagingToolDuplicates({
|
||||
payloads: replyTaggedPayloads,
|
||||
sentTexts: messagingToolSentTexts,
|
||||
@@ -560,10 +569,11 @@ export async function runReplyAgent(params: {
|
||||
(payload) => !streamedPayloadKeys.has(buildPayloadKey(payload)),
|
||||
)
|
||||
: dedupedPayloads;
|
||||
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
|
||||
|
||||
if (filteredPayloads.length === 0) return finalizeWithFollowup(undefined);
|
||||
if (replyPayloads.length === 0) return finalizeWithFollowup(undefined);
|
||||
|
||||
const shouldSignalTyping = filteredPayloads.some((payload) => {
|
||||
const shouldSignalTyping = replyPayloads.some((payload) => {
|
||||
const trimmed = payload.text?.trim();
|
||||
if (trimmed && trimmed !== SILENT_REPLY_TOKEN) return true;
|
||||
if (payload.mediaUrl) return true;
|
||||
@@ -628,7 +638,7 @@ export async function runReplyAgent(params: {
|
||||
}
|
||||
|
||||
// If verbose is enabled and this is a new session, prepend a session hint.
|
||||
let finalPayloads = filteredPayloads;
|
||||
let finalPayloads = replyPayloads;
|
||||
if (autoCompactionCompleted) {
|
||||
const count = await incrementCompactionCount({
|
||||
sessionEntry,
|
||||
|
||||
@@ -27,15 +27,17 @@ vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
|
||||
import { createFollowupRunner } from "./followup-runner.js";
|
||||
|
||||
const baseQueuedRun = (): FollowupRun =>
|
||||
const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun =>
|
||||
({
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
originatingTo: "channel:C1",
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
messageProvider: "whatsapp",
|
||||
messageProvider,
|
||||
agentAccountId: "primary",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
@@ -95,4 +97,27 @@ describe("createFollowupRunner messaging tool dedupe", () => {
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("suppresses replies when a messaging tool sent via the same provider + target", async () => {
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "hello world!" }],
|
||||
messagingToolSentTexts: ["different message"],
|
||||
messagingToolSentTargets: [
|
||||
{ tool: "slack", provider: "slack", to: "channel:C1" },
|
||||
],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const runner = createFollowupRunner({
|
||||
opts: { onBlockReply },
|
||||
typing: createMockTypingController(),
|
||||
typingMode: "instant",
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
});
|
||||
|
||||
await runner(baseQueuedRun("slack"));
|
||||
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { FollowupRun } from "./queue.js";
|
||||
import {
|
||||
applyReplyThreading,
|
||||
filterMessagingToolDuplicates,
|
||||
shouldSuppressMessagingToolReplies,
|
||||
} from "./reply-payloads.js";
|
||||
import {
|
||||
createReplyToModeFilter,
|
||||
@@ -136,6 +137,7 @@ export function createFollowupRunner(params: {
|
||||
sessionId: queued.run.sessionId,
|
||||
sessionKey: queued.run.sessionKey,
|
||||
messageProvider: queued.run.messageProvider,
|
||||
agentAccountId: queued.run.agentAccountId,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
config: queued.run.config,
|
||||
@@ -205,8 +207,15 @@ export function createFollowupRunner(params: {
|
||||
payloads: replyTaggedPayloads,
|
||||
sentTexts: runResult.messagingToolSentTexts ?? [],
|
||||
});
|
||||
const suppressMessagingToolReplies = shouldSuppressMessagingToolReplies({
|
||||
messageProvider: queued.run.messageProvider,
|
||||
messagingToolSentTargets: runResult.messagingToolSentTargets,
|
||||
originatingTo: queued.originatingTo,
|
||||
accountId: queued.run.agentAccountId,
|
||||
});
|
||||
const finalPayloads = suppressMessagingToolReplies ? [] : dedupedPayloads;
|
||||
|
||||
if (dedupedPayloads.length === 0) return;
|
||||
if (finalPayloads.length === 0) return;
|
||||
|
||||
if (autoCompactionCompleted) {
|
||||
const count = await incrementCompactionCount({
|
||||
@@ -217,7 +226,7 @@ export function createFollowupRunner(params: {
|
||||
});
|
||||
if (queued.run.verboseLevel === "on") {
|
||||
const suffix = typeof count === "number" ? ` (count ${count})` : "";
|
||||
replyTaggedPayloads.unshift({
|
||||
finalPayloads.unshift({
|
||||
text: `🧹 Auto-compaction complete${suffix}.`,
|
||||
});
|
||||
}
|
||||
@@ -271,7 +280,7 @@ export function createFollowupRunner(params: {
|
||||
}
|
||||
}
|
||||
|
||||
await sendFollowupPayloads(dedupedPayloads, queued);
|
||||
await sendFollowupPayloads(finalPayloads, queued);
|
||||
} finally {
|
||||
typing.markRunComplete();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
GroupKeyResolution,
|
||||
SessionEntry,
|
||||
} from "../../config/sessions.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
import { normalizeGroupActivation } from "../group-activation.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
|
||||
@@ -148,7 +149,8 @@ export function resolveGroupRequireMention(params: {
|
||||
return true;
|
||||
}
|
||||
if (provider === "slack") {
|
||||
const channels = cfg.slack?.channels ?? {};
|
||||
const account = resolveSlackAccount({ cfg, accountId: ctx.AccountId });
|
||||
const channels = account.channels ?? {};
|
||||
const keys = Object.keys(channels);
|
||||
if (keys.length === 0) return true;
|
||||
const channelId = groupId?.trim();
|
||||
|
||||
@@ -50,6 +50,7 @@ export type FollowupRun = {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config: ClawdbotConfig;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
|
||||
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { extractReplyToTag } from "./reply-tags.js";
|
||||
|
||||
@@ -50,3 +51,126 @@ export function filterMessagingToolDuplicates(params: {
|
||||
(payload) => !isMessagingToolDuplicate(payload.text ?? "", sentTexts),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSlackTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i);
|
||||
if (mentionMatch) return `user:${mentionMatch[1]}`.toLowerCase();
|
||||
if (trimmed.startsWith("user:")) {
|
||||
const id = trimmed.slice(5).trim();
|
||||
return id ? `user:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("channel:")) {
|
||||
const id = trimmed.slice(8).trim();
|
||||
return id ? `channel:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("slack:")) {
|
||||
const id = trimmed.slice(6).trim();
|
||||
return id ? `user:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `user:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("#")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `channel:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
return `channel:${trimmed}`.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeDiscordTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const mentionMatch = trimmed.match(/^<@!?(\d+)>$/);
|
||||
if (mentionMatch) return `user:${mentionMatch[1]}`.toLowerCase();
|
||||
if (trimmed.startsWith("user:")) {
|
||||
const id = trimmed.slice(5).trim();
|
||||
return id ? `user:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("channel:")) {
|
||||
const id = trimmed.slice(8).trim();
|
||||
return id ? `channel:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("discord:")) {
|
||||
const id = trimmed.slice(8).trim();
|
||||
return id ? `user:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const id = trimmed.slice(1).trim();
|
||||
return id ? `user:${id}`.toLowerCase() : undefined;
|
||||
}
|
||||
return `channel:${trimmed}`.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeTelegramTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
let normalized = trimmed;
|
||||
if (normalized.startsWith("telegram:")) {
|
||||
normalized = normalized.slice("telegram:".length).trim();
|
||||
} else if (normalized.startsWith("tg:")) {
|
||||
normalized = normalized.slice("tg:".length).trim();
|
||||
} else if (normalized.startsWith("group:")) {
|
||||
normalized = normalized.slice("group:".length).trim();
|
||||
}
|
||||
if (!normalized) return undefined;
|
||||
const tmeMatch =
|
||||
/^https?:\/\/t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized) ??
|
||||
/^t\.me\/([A-Za-z0-9_]+)$/i.exec(normalized);
|
||||
if (tmeMatch?.[1]) normalized = `@${tmeMatch[1]}`;
|
||||
if (!normalized) return undefined;
|
||||
return `telegram:${normalized}`.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeTargetForProvider(
|
||||
provider: string,
|
||||
raw?: string,
|
||||
): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
switch (provider) {
|
||||
case "slack":
|
||||
return normalizeSlackTarget(raw);
|
||||
case "discord":
|
||||
return normalizeDiscordTarget(raw);
|
||||
case "telegram":
|
||||
return normalizeTelegramTarget(raw);
|
||||
default:
|
||||
return raw.trim().toLowerCase() || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAccountId(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
export function shouldSuppressMessagingToolReplies(params: {
|
||||
messageProvider?: string;
|
||||
messagingToolSentTargets?: MessagingToolSend[];
|
||||
originatingTo?: string;
|
||||
accountId?: string;
|
||||
}): boolean {
|
||||
const provider = params.messageProvider?.trim().toLowerCase();
|
||||
if (!provider) return false;
|
||||
const originTarget = normalizeTargetForProvider(
|
||||
provider,
|
||||
params.originatingTo,
|
||||
);
|
||||
if (!originTarget) return false;
|
||||
const originAccount = normalizeAccountId(params.accountId);
|
||||
const sentTargets = params.messagingToolSentTargets ?? [];
|
||||
if (sentTargets.length === 0) return false;
|
||||
return sentTargets.some((target) => {
|
||||
if (!target?.provider) return false;
|
||||
if (target.provider.trim().toLowerCase() !== provider) return false;
|
||||
const targetKey = normalizeTargetForProvider(provider, target.to);
|
||||
if (!targetKey) return false;
|
||||
const targetAccount = normalizeAccountId(target.accountId);
|
||||
if (originAccount && targetAccount && originAccount !== targetAccount) {
|
||||
return false;
|
||||
}
|
||||
return targetKey === originTarget;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user