From 8930ec32cb9f89cd430cc0dbfa3fbd14c2f65cbe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 08:49:16 +0100 Subject: [PATCH] feat: add slack multi-account routing --- CHANGELOG.md | 1 + README.md | 10 +- docs/gateway/configuration.md | 6 +- docs/providers/slack.md | 2 + src/agents/clawdbot-tools.ts | 6 +- src/agents/pi-embedded-runner.ts | 15 ++ src/agents/pi-embedded-subscribe.ts | 144 +++++++++++++++++ src/agents/pi-tools.ts | 2 + src/agents/tools/slack-actions.ts | 65 ++++++-- src/agents/tools/slack-schema.ts | 13 ++ src/agents/tools/slack-tool.test.ts | 85 ++++++++++ src/agents/tools/slack-tool.ts | 57 ++++++- src/auto-reply/reply.ts | 1 + .../agent-runner.messaging-tools.test.ts | 149 ++++++++++++++++++ src/auto-reply/reply/agent-runner.ts | 16 +- .../followup-runner.messaging-tools.test.ts | 29 +++- src/auto-reply/reply/followup-runner.ts | 15 +- src/auto-reply/reply/groups.ts | 4 +- src/auto-reply/reply/queue.ts | 1 + src/auto-reply/reply/reply-payloads.ts | 124 +++++++++++++++ src/cli/progress.ts | 2 +- src/config/schema.ts | 3 + src/config/types.ts | 4 + src/config/zod-schema.ts | 4 +- src/slack/accounts.ts | 57 ++++--- src/slack/actions.ts | 20 ++- src/slack/index.ts | 6 + src/slack/monitor.ts | 85 +++++++--- src/slack/send.ts | 9 ++ src/telegram/bot.test.ts | 17 +- src/types/osc-progress.d.ts | 19 +++ 31 files changed, 878 insertions(+), 93 deletions(-) create mode 100644 src/agents/tools/slack-tool.test.ts create mode 100644 src/auto-reply/reply/agent-runner.messaging-tools.test.ts create mode 100644 src/types/osc-progress.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dede88abb..f15b73516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ - CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete. - CLI: add non-interactive flags for `agents add`, support `agents list --bindings`, and keep JSON output clean for scripting. - Discord/Slack: fork thread sessions (agent-scoped) and inject thread starters for context. Thanks @thewilloftheshadow for PR #400. +- Slack: route tool actions per account and suppress duplicate follow-ups per provider/target/account. Thanks @adam91holt for PR #457. - Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341. - Agent: add opt-in session pruning for tool results to reduce context bloat. Thanks @maxsumrall for PR #381. - Agent: protect bootstrap prefix from context pruning. Thanks @maxsumrall for PR #381. diff --git a/README.md b/README.md index 6fe4a3db3..b9147af68 100644 --- a/README.md +++ b/README.md @@ -451,9 +451,9 @@ Thanks to all clawtributors: nachoiacovino andranik-sahakyan nachx639 sircrumpet rafaelreis-r meaningfool ratulsarna lutr0 abhisekbasu1 emanuelst osolmaz kiranjd thewilloftheshadow CashWilliams manuelhettich minghinmatthewlam buddyh sheeek timkrase gupsammy mcinteerj azade-c imfing petter-b RandyVentures jalehman obviyus dan-dr iamadig manmal - VACInc zats Django Navarro pcty-nextgen-service-account Syhids fcatuhe jayhickey jverdi oswalpalash VAC - alejandro maza antons Asleep123 cash-echo-bot Clawd conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl hugobarauna - Jarvis jonasjancarik Jonathan D. Rhyne (DJ-D) Kit kitze kkarimi loukotal mrdbstn MSch nexty5870 - ngutman onutc reeltimeapps Rolf Fredheim Sash Catanzarite snopoke wstock Azade ddyo Erik - Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres Tobias Bischoff William Stock + VACInc zats Django Navarro pcty-nextgen-service-account Syhids fcatuhe jayhickey jverdi oswalpalash Sash Catanzarite + VAC alejandro maza antons Asleep123 cash-echo-bot Clawd conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl + hugobarauna Jarvis jonasjancarik Jonathan D. Rhyne (DJ-D) Kit kitze kkarimi loukotal mrdbstn MSch + nexty5870 ngutman onutc reeltimeapps RLTCmpe Rolf Fredheim snopoke wstock Azade ddyo + Erik Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres Tobias Bischoff William Stock

diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index b0741e479..334bd4de3 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -733,15 +733,17 @@ Slack runs in Socket Mode and requires both a bot token and app token: groupChannels: ["G123"] }, channels: { - C123: { allow: true, requireMention: true }, + C123: { allow: true, requireMention: true, allowBots: false }, "#general": { allow: true, requireMention: true, + allowBots: false, users: ["U123"], skills: ["docs"], systemPrompt: "Short answers only." } }, + allowBots: false, reactionNotifications: "own", // off | own | all | allowlist reactionAllowlist: ["U123"], replyToMode: "off", // off | first | all @@ -768,6 +770,8 @@ Multi-account support lives under `slack.accounts` (see the multi-account sectio Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:` (DM) or `channel:` when specifying delivery targets for cron/CLI commands. +Bot-authored messages are ignored by default. Enable with `slack.allowBots` or `slack.channels..allowBots`. + Reaction notification modes: - `off`: no reaction events. - `own`: reactions on the bot's own messages (default). diff --git a/docs/providers/slack.md b/docs/providers/slack.md index a3507bd52..d71e2e668 100644 --- a/docs/providers/slack.md +++ b/docs/providers/slack.md @@ -227,6 +227,7 @@ Controlled by `slack.replyToMode`: Channel options (`slack.channels.` or `slack.channels.`): - `allow`: allow/deny the channel when `groupPolicy="allowlist"`. - `requireMention`: mention gating for the channel. +- `allowBots`: allow bot-authored messages in this channel (default: false). - `users`: optional per-channel user allowlist. - `skills`: skill filter (omit = all skills, empty = none). - `systemPrompt`: extra system prompt for the channel (combined with topic/purpose). @@ -251,5 +252,6 @@ Slack tool actions can be gated with `slack.actions.*`: ## Notes - Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions. - Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`). +- Bot-authored messages are ignored by default; enable via `slack.allowBots` or `slack.channels..allowBots`. - For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions). - Attachments are downloaded to the media store when permitted and under the size limit. diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 9e90ba98c..59c4bc8db 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -20,6 +20,7 @@ export function createClawdbotTools(options?: { browserControlUrl?: string; agentSessionKey?: string; agentProvider?: string; + agentAccountId?: string; agentDir?: string; sandboxed?: boolean; config?: ClawdbotConfig; @@ -34,7 +35,10 @@ export function createClawdbotTools(options?: { createNodesTool(), createCronTool(), createDiscordTool(), - createSlackTool(), + createSlackTool({ + agentAccountId: options?.agentAccountId, + config: options?.config, + }), createTelegramTool(), createWhatsAppTool(), createGatewayTool({ agentSessionKey: options?.agentSessionKey }), diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 449e55b88..aff3640ac 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -276,6 +276,13 @@ type ApiKeyInfo = { source: string; }; +export type MessagingToolSend = { + tool: string; + provider: string; + accountId?: string; + to?: string; +}; + export type EmbeddedPiRunResult = { payloads?: Array<{ text?: string; @@ -290,6 +297,8 @@ export type EmbeddedPiRunResult = { didSendViaMessagingTool?: boolean; // Texts successfully sent via messaging tools during the run. messagingToolSentTexts?: string[]; + // Messaging tool targets that successfully sent a message during the run. + messagingToolSentTargets?: MessagingToolSend[]; }; export type EmbeddedPiCompactResult = { @@ -737,6 +746,7 @@ export async function compactEmbeddedPiSession(params: { sessionId: string; sessionKey?: string; messageProvider?: string; + agentAccountId?: string; sessionFile: string; workspaceDir: string; agentDir?: string; @@ -842,6 +852,7 @@ export async function compactEmbeddedPiSession(params: { }, sandbox, messageProvider: params.messageProvider, + agentAccountId: params.agentAccountId, sessionKey: params.sessionKey ?? params.sessionId, agentDir, config: params.config, @@ -962,6 +973,7 @@ export async function runEmbeddedPiAgent(params: { sessionId: string; sessionKey?: string; messageProvider?: string; + agentAccountId?: string; sessionFile: string; workspaceDir: string; agentDir?: string; @@ -1153,6 +1165,7 @@ export async function runEmbeddedPiAgent(params: { }, sandbox, messageProvider: params.messageProvider, + agentAccountId: params.agentAccountId, sessionKey: params.sessionKey ?? params.sessionId, agentDir, config: params.config, @@ -1283,6 +1296,7 @@ export async function runEmbeddedPiAgent(params: { unsubscribe, waitForCompactionRetry, getMessagingToolSentTexts, + getMessagingToolSentTargets, didSendViaMessagingTool, } = subscription; @@ -1567,6 +1581,7 @@ export async function runEmbeddedPiAgent(params: { }, didSendViaMessagingTool: didSendViaMessagingTool(), messagingToolSentTexts: getMessagingToolSentTexts(), + messagingToolSentTargets: getMessagingToolSentTargets(), }; } finally { restoreSkillEnv?.(); diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 20d4ebc46..a5d01251a 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -26,6 +26,13 @@ const log = createSubsystemLogger("agent/embedded"); export type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; +type MessagingToolSend = { + tool: string; + provider: string; + accountId?: string; + to?: string; +}; + function truncateToolText(text: string): string { if (text.length <= TOOL_RESULT_MAX_CHARS) return text; return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`; @@ -86,6 +93,127 @@ function stripUnpairedThinkingTags(text: string): string { return text; } +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]}`; + if (trimmed.startsWith("user:")) { + const id = trimmed.slice(5).trim(); + return id ? `user:${id}` : undefined; + } + if (trimmed.startsWith("channel:")) { + const id = trimmed.slice(8).trim(); + return id ? `channel:${id}` : undefined; + } + if (trimmed.startsWith("slack:")) { + const id = trimmed.slice(6).trim(); + return id ? `user:${id}` : undefined; + } + if (trimmed.startsWith("@")) { + const id = trimmed.slice(1).trim(); + return id ? `user:${id}` : undefined; + } + if (trimmed.startsWith("#")) { + const id = trimmed.slice(1).trim(); + return id ? `channel:${id}` : undefined; + } + return `channel:${trimmed}`; +} + +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]}`; + if (trimmed.startsWith("user:")) { + const id = trimmed.slice(5).trim(); + return id ? `user:${id}` : undefined; + } + if (trimmed.startsWith("channel:")) { + const id = trimmed.slice(8).trim(); + return id ? `channel:${id}` : undefined; + } + if (trimmed.startsWith("discord:")) { + const id = trimmed.slice(8).trim(); + return id ? `user:${id}` : undefined; + } + if (trimmed.startsWith("@")) { + const id = trimmed.slice(1).trim(); + return id ? `user:${id}` : undefined; + } + return `channel:${trimmed}`; +} + +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}`; +} + +function extractMessagingToolSend( + toolName: string, + args: Record, +): MessagingToolSend | undefined { + const action = typeof args.action === "string" ? args.action.trim() : ""; + const accountIdRaw = + typeof args.accountId === "string" ? args.accountId.trim() : undefined; + const accountId = accountIdRaw ? accountIdRaw : undefined; + if (toolName === "slack") { + if (action !== "sendMessage") return undefined; + const toRaw = typeof args.to === "string" ? args.to : undefined; + if (!toRaw) return undefined; + const to = normalizeSlackTarget(toRaw); + return to + ? { tool: toolName, provider: "slack", accountId, to } + : undefined; + } + if (toolName === "discord") { + if (action === "sendMessage") { + const toRaw = typeof args.to === "string" ? args.to : undefined; + if (!toRaw) return undefined; + const to = normalizeDiscordTarget(toRaw); + return to + ? { tool: toolName, provider: "discord", accountId, to } + : undefined; + } + if (action === "threadReply") { + const channelId = + typeof args.channelId === "string" ? args.channelId.trim() : ""; + if (!channelId) return undefined; + const to = normalizeDiscordTarget(`channel:${channelId}`); + return to + ? { tool: toolName, provider: "discord", accountId, to } + : undefined; + } + return undefined; + } + if (toolName === "telegram") { + if (action !== "sendMessage") return undefined; + const toRaw = typeof args.to === "string" ? args.to : undefined; + if (!toRaw) return undefined; + const to = normalizeTelegramTarget(toRaw); + return to + ? { tool: toolName, provider: "telegram", accountId, to } + : undefined; + } + return undefined; +} + export function subscribeEmbeddedPiSession(params: { session: AgentSession; runId: string; @@ -151,7 +279,9 @@ export function subscribeEmbeddedPiSession(params: { "sessions_send", ]); const messagingToolSentTexts: string[] = []; + const messagingToolSentTargets: MessagingToolSend[] = []; const pendingMessagingTexts = new Map(); + const pendingMessagingTargets = new Map(); const ensureCompactionPromise = () => { if (!compactionRetryPromise) { @@ -315,7 +445,9 @@ export function subscribeEmbeddedPiSession(params: { toolMetaById.clear(); toolSummaryById.clear(); messagingToolSentTexts.length = 0; + messagingToolSentTargets.length = 0; pendingMessagingTexts.clear(); + pendingMessagingTargets.clear(); deltaBuffer = ""; blockBuffer = ""; blockChunker?.reset(); @@ -398,6 +530,10 @@ export function subscribeEmbeddedPiSession(params: { action === "threadReply" || toolName === "sessions_send" ) { + const sendTarget = extractMessagingToolSend(toolName, argsRecord); + if (sendTarget) { + pendingMessagingTargets.set(toolCallId, sendTarget); + } // Field names vary by tool: Discord/Slack use "content", sessions_send uses "message" const text = (argsRecord.content as string) ?? (argsRecord.message as string); @@ -460,6 +596,7 @@ export function subscribeEmbeddedPiSession(params: { // Commit messaging tool text on success, discard on error const pendingText = pendingMessagingTexts.get(toolCallId); + const pendingTarget = pendingMessagingTargets.get(toolCallId); if (pendingText) { pendingMessagingTexts.delete(toolCallId); if (!isError) { @@ -469,6 +606,12 @@ export function subscribeEmbeddedPiSession(params: { ); } } + if (pendingTarget) { + pendingMessagingTargets.delete(toolCallId); + if (!isError) { + messagingToolSentTargets.push(pendingTarget); + } + } emitAgentEvent({ runId: params.runId, @@ -779,6 +922,7 @@ export function subscribeEmbeddedPiSession(params: { unsubscribe, isCompacting: () => compactionInFlight || pendingCompactionRetry > 0, getMessagingToolSentTexts: () => messagingToolSentTexts.slice(), + getMessagingToolSentTargets: () => messagingToolSentTargets.slice(), // Returns true if any messaging tool successfully sent a message. // Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!") // which is generated AFTER the tool sends the actual answer. diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 5e67bab0c..b4fb79069 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -625,6 +625,7 @@ function shouldIncludeWhatsAppTool(messageProvider?: string): boolean { export function createClawdbotCodingTools(options?: { bash?: BashToolDefaults & ProcessToolDefaults; messageProvider?: string; + agentAccountId?: string; sandbox?: SandboxContext | null; sessionKey?: string; agentDir?: string; @@ -695,6 +696,7 @@ export function createClawdbotCodingTools(options?: { browserControlUrl: sandbox?.browser?.controlUrl, agentSessionKey: options?.sessionKey, agentProvider: options?.messageProvider, + agentAccountId: options?.agentAccountId, agentDir: options?.agentDir, sandboxed: !!sandbox, config: options?.config, diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 48b5b9a18..19a55121a 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -1,6 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveSlackAccount } from "../../slack/accounts.js"; import { deleteSlackMessage, editSlackMessage, @@ -38,7 +39,11 @@ export async function handleSlackAction( cfg: ClawdbotConfig, ): Promise> { const action = readStringParam(params, "action", { required: true }); - const isActionEnabled = createActionGate(cfg.slack?.actions); + const accountId = readStringParam(params, "accountId"); + const accountOpts = accountId ? { accountId } : undefined; + const account = resolveSlackAccount({ cfg, accountId }); + const actionConfig = account.actions ?? cfg.slack?.actions; + const isActionEnabled = createActionGate(actionConfig); if (reactionsActions.has(action)) { if (!isActionEnabled("reactions")) { @@ -51,17 +56,29 @@ export async function handleSlackAction( removeErrorMessage: "Emoji is required to remove a Slack reaction.", }); if (remove) { - await removeSlackReaction(channelId, messageId, emoji); + if (accountOpts) { + await removeSlackReaction(channelId, messageId, emoji, accountOpts); + } else { + await removeSlackReaction(channelId, messageId, emoji); + } return jsonResult({ ok: true, removed: emoji }); } if (isEmpty) { - const removed = await removeOwnSlackReactions(channelId, messageId); + const removed = accountOpts + ? await removeOwnSlackReactions(channelId, messageId, accountOpts) + : await removeOwnSlackReactions(channelId, messageId); return jsonResult({ ok: true, removed }); } - await reactSlackMessage(channelId, messageId, emoji); + if (accountOpts) { + await reactSlackMessage(channelId, messageId, emoji, accountOpts); + } else { + await reactSlackMessage(channelId, messageId, emoji); + } return jsonResult({ ok: true, added: emoji }); } - const reactions = await listSlackReactions(channelId, messageId); + const reactions = accountOpts + ? await listSlackReactions(channelId, messageId, accountOpts) + : await listSlackReactions(channelId, messageId); return jsonResult({ ok: true, reactions }); } @@ -75,6 +92,7 @@ export async function handleSlackAction( const content = readStringParam(params, "content", { required: true }); const mediaUrl = readStringParam(params, "mediaUrl"); const result = await sendSlackMessage(to, content, { + accountId: accountId ?? undefined, mediaUrl: mediaUrl ?? undefined, }); return jsonResult({ ok: true, result }); @@ -89,7 +107,11 @@ export async function handleSlackAction( const content = readStringParam(params, "content", { required: true, }); - await editSlackMessage(channelId, messageId, content); + if (accountOpts) { + await editSlackMessage(channelId, messageId, content, accountOpts); + } else { + await editSlackMessage(channelId, messageId, content); + } return jsonResult({ ok: true }); } case "deleteMessage": { @@ -99,7 +121,11 @@ export async function handleSlackAction( const messageId = readStringParam(params, "messageId", { required: true, }); - await deleteSlackMessage(channelId, messageId); + if (accountOpts) { + await deleteSlackMessage(channelId, messageId, accountOpts); + } else { + await deleteSlackMessage(channelId, messageId); + } return jsonResult({ ok: true }); } case "readMessages": { @@ -114,6 +140,7 @@ export async function handleSlackAction( const before = readStringParam(params, "before"); const after = readStringParam(params, "after"); const result = await readSlackMessages(channelId, { + accountId: accountId ?? undefined, limit, before: before ?? undefined, after: after ?? undefined, @@ -134,17 +161,27 @@ export async function handleSlackAction( const messageId = readStringParam(params, "messageId", { required: true, }); - await pinSlackMessage(channelId, messageId); + if (accountOpts) { + await pinSlackMessage(channelId, messageId, accountOpts); + } else { + await pinSlackMessage(channelId, messageId); + } return jsonResult({ ok: true }); } if (action === "unpinMessage") { const messageId = readStringParam(params, "messageId", { required: true, }); - await unpinSlackMessage(channelId, messageId); + if (accountOpts) { + await unpinSlackMessage(channelId, messageId, accountOpts); + } else { + await unpinSlackMessage(channelId, messageId); + } return jsonResult({ ok: true }); } - const pins = await listSlackPins(channelId); + const pins = accountOpts + ? await listSlackPins(channelId, accountOpts) + : await listSlackPins(channelId); return jsonResult({ ok: true, pins }); } @@ -153,7 +190,9 @@ export async function handleSlackAction( throw new Error("Slack member info is disabled."); } const userId = readStringParam(params, "userId", { required: true }); - const info = await getSlackMemberInfo(userId); + const info = accountOpts + ? await getSlackMemberInfo(userId, accountOpts) + : await getSlackMemberInfo(userId); return jsonResult({ ok: true, info }); } @@ -161,7 +200,9 @@ export async function handleSlackAction( if (!isActionEnabled("emojiList")) { throw new Error("Slack emoji list is disabled."); } - const emojis = await listSlackEmojis(); + const emojis = accountOpts + ? await listSlackEmojis(accountOpts) + : await listSlackEmojis(); return jsonResult({ ok: true, emojis }); } diff --git a/src/agents/tools/slack-schema.ts b/src/agents/tools/slack-schema.ts index ee4682791..a1afaf8bc 100644 --- a/src/agents/tools/slack-schema.ts +++ b/src/agents/tools/slack-schema.ts @@ -9,28 +9,35 @@ export const SlackToolSchema = Type.Union([ messageId: Type.String(), }, includeRemove: true, + extras: { + accountId: Type.Optional(Type.String()), + }, }), Type.Object({ action: Type.Literal("reactions"), channelId: Type.String(), messageId: Type.String(), + accountId: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("sendMessage"), to: Type.String(), content: Type.String(), mediaUrl: Type.Optional(Type.String()), + accountId: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("editMessage"), channelId: Type.String(), messageId: Type.String(), content: Type.String(), + accountId: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("deleteMessage"), channelId: Type.String(), messageId: Type.String(), + accountId: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("readMessages"), @@ -38,26 +45,32 @@ export const SlackToolSchema = Type.Union([ limit: Type.Optional(Type.Number()), before: Type.Optional(Type.String()), after: Type.Optional(Type.String()), + accountId: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("pinMessage"), channelId: Type.String(), messageId: Type.String(), + accountId: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("unpinMessage"), channelId: Type.String(), messageId: Type.String(), + accountId: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("listPins"), channelId: Type.String(), + accountId: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("memberInfo"), userId: Type.String(), + accountId: Type.Optional(Type.String()), }), Type.Object({ action: Type.Literal("emojiList"), + accountId: Type.Optional(Type.String()), }), ]); diff --git a/src/agents/tools/slack-tool.test.ts b/src/agents/tools/slack-tool.test.ts new file mode 100644 index 000000000..d0213a4c5 --- /dev/null +++ b/src/agents/tools/slack-tool.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const handleSlackActionMock = vi.fn(); + +vi.mock("./slack-actions.js", () => ({ + handleSlackAction: (params: unknown, cfg: unknown) => + handleSlackActionMock(params, cfg), +})); + +import { createSlackTool } from "./slack-tool.js"; + +describe("slack tool", () => { + beforeEach(() => { + handleSlackActionMock.mockReset(); + handleSlackActionMock.mockResolvedValue({ + content: [], + details: { ok: true }, + }); + }); + + it("injects agentAccountId when accountId is missing", async () => { + const tool = createSlackTool({ + agentAccountId: " Kev ", + config: { slack: { accounts: { kev: {} } } }, + }); + + await tool.execute("call-1", { + action: "sendMessage", + to: "channel:C1", + content: "hello", + }); + + expect(handleSlackActionMock).toHaveBeenCalledTimes(1); + const [params] = handleSlackActionMock.mock.calls[0] ?? []; + expect(params).toMatchObject({ accountId: "kev" }); + }); + + it("keeps explicit accountId when provided", async () => { + const tool = createSlackTool({ + agentAccountId: "kev", + config: {}, + }); + + await tool.execute("call-2", { + action: "sendMessage", + to: "channel:C1", + content: "hello", + accountId: "rex", + }); + + expect(handleSlackActionMock).toHaveBeenCalledTimes(1); + const [params] = handleSlackActionMock.mock.calls[0] ?? []; + expect(params).toMatchObject({ accountId: "rex" }); + }); + + it("does not inject accountId when agentAccountId is missing", async () => { + const tool = createSlackTool({ config: {} }); + + await tool.execute("call-3", { + action: "sendMessage", + to: "channel:C1", + content: "hello", + }); + + expect(handleSlackActionMock).toHaveBeenCalledTimes(1); + const [params] = handleSlackActionMock.mock.calls[0] ?? []; + expect(params).not.toHaveProperty("accountId"); + }); + + it("does not inject unknown agentAccountId when not configured", async () => { + const tool = createSlackTool({ + agentAccountId: "unknown", + config: { slack: { accounts: { kev: {} } } }, + }); + + await tool.execute("call-4", { + action: "sendMessage", + to: "channel:C1", + content: "hello", + }); + + const [params] = handleSlackActionMock.mock.calls[0] ?? []; + expect(params).not.toHaveProperty("accountId"); + }); +}); diff --git a/src/agents/tools/slack-tool.ts b/src/agents/tools/slack-tool.ts index b0f2d0146..fabb63388 100644 --- a/src/agents/tools/slack-tool.ts +++ b/src/agents/tools/slack-tool.ts @@ -1,9 +1,44 @@ +import type { ClawdbotConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; +import { logVerbose } from "../../globals.js"; +import { normalizeAccountId } from "../../routing/session-key.js"; import type { AnyAgentTool } from "./common.js"; import { handleSlackAction } from "./slack-actions.js"; import { SlackToolSchema } from "./slack-schema.js"; -export function createSlackTool(): AnyAgentTool { +type SlackToolOptions = { + agentAccountId?: string; + config?: ClawdbotConfig; +}; + +function resolveAgentAccountId(value?: string): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + return normalizeAccountId(trimmed); +} + +function resolveConfiguredAccountId( + cfg: ClawdbotConfig, + accountId: string, +): string | undefined { + if (accountId === "default") return accountId; + const accounts = cfg.slack?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + if (accountId in accounts) return accountId; + const match = Object.keys(accounts).find( + (key) => key.toLowerCase() === accountId.toLowerCase(), + ); + return match; +} + +function hasAccountId(params: Record): boolean { + const raw = params.accountId; + if (typeof raw !== "string") return false; + return raw.trim().length > 0; +} + +export function createSlackTool(options?: SlackToolOptions): AnyAgentTool { + const agentAccountId = resolveAgentAccountId(options?.agentAccountId); return { label: "Slack", name: "slack", @@ -11,8 +46,24 @@ export function createSlackTool(): AnyAgentTool { parameters: SlackToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; - const cfg = loadConfig(); - return await handleSlackAction(params, cfg); + const cfg = options?.config ?? loadConfig(); + const resolvedAccountId = agentAccountId + ? resolveConfiguredAccountId(cfg, agentAccountId) + : undefined; + const resolvedParams = + resolvedAccountId && !hasAccountId(params) + ? { ...params, accountId: resolvedAccountId } + : params; + if (hasAccountId(resolvedParams)) { + const action = + typeof params.action === "string" ? params.action : "unknown"; + logVerbose( + `slack tool: action=${action} accountId=${String( + resolvedParams.accountId, + ).trim()}`, + ); + } + return await handleSlackAction(resolvedParams, cfg); }, }; } diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 224518f4d..7c7b06539 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -791,6 +791,7 @@ export async function getReplyFromConfig( sessionId: sessionIdFinal, sessionKey, messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined, + agentAccountId: sessionCtx.AccountId, sessionFile, workspaceDir, config: cfg, diff --git a/src/auto-reply/reply/agent-runner.messaging-tools.test.ts b/src/auto-reply/reply/agent-runner.messaging-tools.test.ts new file mode 100644 index 000000000..0e2673efb --- /dev/null +++ b/src/auto-reply/reply/agent-runner.messaging-tools.test.ts @@ -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; + }) => ({ + 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("./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!" }); + }); +}); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index bd0b270ed..76ba0d040 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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, diff --git a/src/auto-reply/reply/followup-runner.messaging-tools.test.ts b/src/auto-reply/reply/followup-runner.messaging-tools.test.ts index 5a04d4c2c..1b0962221 100644 --- a/src/auto-reply/reply/followup-runner.messaging-tools.test.ts +++ b/src/auto-reply/reply/followup-runner.messaging-tools.test.ts @@ -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(); + }); }); diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 82cccdc9e..895269adf 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -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(); } diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index 292a77712..a50023991 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -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(); diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index 6dccf37b0..86ae083a8 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -50,6 +50,7 @@ export type FollowupRun = { sessionId: string; sessionKey?: string; messageProvider?: string; + agentAccountId?: string; sessionFile: string; workspaceDir: string; config: ClawdbotConfig; diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index ac6bf6988..ad1b78309 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -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; + }); +} diff --git a/src/cli/progress.ts b/src/cli/progress.ts index a974d2853..976bd79c5 100644 --- a/src/cli/progress.ts +++ b/src/cli/progress.ts @@ -65,7 +65,7 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter { ? createOscProgressController({ env: process.env, isTty: stream.isTTY, - write: (chunk) => stream.write(chunk), + write: (chunk: string) => stream.write(chunk), }) : null; diff --git a/src/config/schema.ts b/src/config/schema.ts index 1410bab77..7c20fc02d 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -123,6 +123,7 @@ const FIELD_LABELS: Record = { "discord.retry.jitter": "Discord Retry Jitter", "discord.maxLinesPerMessage": "Discord Max Lines Per Message", "slack.dm.policy": "Slack DM Policy", + "slack.allowBots": "Slack Allow Bot Messages", "discord.token": "Discord Bot Token", "slack.botToken": "Slack Bot Token", "slack.appToken": "Slack App Token", @@ -141,6 +142,8 @@ const FIELD_HELP: Record = { 'Hot reload strategy for config changes ("hybrid" recommended).', "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", + "slack.allowBots": + "Allow bot-authored messages to trigger Slack replies (default: false).", "auth.profiles": "Named auth profiles (provider + mode + optional email).", "auth.order": "Ordered auth profile IDs per provider (used for automatic failover).", diff --git a/src/config/types.ts b/src/config/types.ts index 70b2cc45a..1040d51a1 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -455,6 +455,8 @@ export type SlackChannelConfig = { allow?: boolean; /** Require mentioning the bot to trigger replies. */ requireMention?: boolean; + /** Allow bot-authored messages to trigger replies (default: false). */ + allowBots?: boolean; /** Allowlist of users that can invoke the bot in this channel. */ users?: Array; /** Optional skill filter for this channel. */ @@ -494,6 +496,8 @@ export type SlackAccountConfig = { enabled?: boolean; botToken?: string; appToken?: string; + /** Allow bot-authored messages to trigger replies (default: false). */ + allowBots?: boolean; /** * Controls how channel messages are handled: * - "open" (default): channels bypass allowlists; mention-gating applies diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index d7e9713e0..00b3ebb7f 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -309,6 +309,7 @@ const SlackChannelSchema = z.object({ enabled: z.boolean().optional(), allow: z.boolean().optional(), requireMention: z.boolean().optional(), + allowBots: z.boolean().optional(), users: z.array(z.union([z.string(), z.number()])).optional(), skills: z.array(z.string()).optional(), systemPrompt: z.string().optional(), @@ -319,11 +320,13 @@ const SlackAccountSchema = z.object({ enabled: z.boolean().optional(), botToken: z.string().optional(), appToken: z.string().optional(), + allowBots: z.boolean().optional(), groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), + replyToMode: ReplyToModeSchema.optional(), actions: z .object({ reactions: z.boolean().optional(), @@ -1188,7 +1191,6 @@ export const ClawdbotSchema = z.object({ }); }) .optional(), - telegram: TelegramConfigSchema.optional(), discord: DiscordConfigSchema.optional(), slack: SlackConfigSchema.optional(), diff --git a/src/slack/accounts.ts b/src/slack/accounts.ts index 6030cce8b..14197c473 100644 --- a/src/slack/accounts.ts +++ b/src/slack/accounts.ts @@ -17,6 +17,16 @@ export type ResolvedSlackAccount = { botTokenSource: SlackTokenSource; appTokenSource: SlackTokenSource; config: SlackAccountConfig; + groupPolicy?: SlackAccountConfig["groupPolicy"]; + textChunkLimit?: SlackAccountConfig["textChunkLimit"]; + mediaMaxMb?: SlackAccountConfig["mediaMaxMb"]; + reactionNotifications?: SlackAccountConfig["reactionNotifications"]; + reactionAllowlist?: SlackAccountConfig["reactionAllowlist"]; + replyToMode?: SlackAccountConfig["replyToMode"]; + actions?: SlackAccountConfig["actions"]; + slashCommand?: SlackAccountConfig["slashCommand"]; + dm?: SlackAccountConfig["dm"]; + channels?: SlackAccountConfig["channels"]; }; function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { @@ -66,31 +76,26 @@ export function resolveSlackAccount(params: { const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; const allowEnv = accountId === DEFAULT_ACCOUNT_ID; - - const botToken = resolveSlackBotToken( - merged.botToken ?? - (allowEnv ? process.env.SLACK_BOT_TOKEN : undefined) ?? - (allowEnv ? params.cfg.slack?.botToken : undefined), - ); - const appToken = resolveSlackAppToken( - merged.appToken ?? - (allowEnv ? process.env.SLACK_APP_TOKEN : undefined) ?? - (allowEnv ? params.cfg.slack?.appToken : undefined), - ); - const botTokenSource: SlackTokenSource = merged.botToken + const envBot = allowEnv + ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) + : undefined; + const envApp = allowEnv + ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) + : undefined; + const configBot = resolveSlackBotToken(merged.botToken); + const configApp = resolveSlackAppToken(merged.appToken); + const botToken = configBot ?? envBot; + const appToken = configApp ?? envApp; + const botTokenSource: SlackTokenSource = configBot ? "config" - : allowEnv && process.env.SLACK_BOT_TOKEN + : envBot ? "env" - : allowEnv && params.cfg.slack?.botToken - ? "config" - : "none"; - const appTokenSource: SlackTokenSource = merged.appToken + : "none"; + const appTokenSource: SlackTokenSource = configApp ? "config" - : allowEnv && process.env.SLACK_APP_TOKEN + : envApp ? "env" - : allowEnv && params.cfg.slack?.appToken - ? "config" - : "none"; + : "none"; return { accountId, @@ -101,6 +106,16 @@ export function resolveSlackAccount(params: { botTokenSource, appTokenSource, config: merged, + groupPolicy: merged.groupPolicy, + textChunkLimit: merged.textChunkLimit, + mediaMaxMb: merged.mediaMaxMb, + reactionNotifications: merged.reactionNotifications, + reactionAllowlist: merged.reactionAllowlist, + replyToMode: merged.replyToMode, + actions: merged.actions, + slashCommand: merged.slashCommand, + dm: merged.dm, + channels: merged.channels, }; } diff --git a/src/slack/actions.ts b/src/slack/actions.ts index 0c1bd8193..9df6d32d2 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -1,10 +1,13 @@ import { WebClient } from "@slack/web-api"; import { loadConfig } from "../config/config.js"; +import { logVerbose } from "../globals.js"; +import { resolveSlackAccount } from "./accounts.js"; import { sendMessageSlack } from "./send.js"; import { resolveSlackBotToken } from "./token.js"; export type SlackActionClientOpts = { + accountId?: string; token?: string; client?: WebClient; }; @@ -28,12 +31,16 @@ export type SlackPin = { file?: { id?: string; name?: string }; }; -function resolveToken(explicit?: string) { - const cfgToken = loadConfig().slack?.botToken; - const token = resolveSlackBotToken( - explicit ?? process.env.SLACK_BOT_TOKEN ?? cfgToken ?? undefined, - ); +function resolveToken(explicit?: string, accountId?: string) { + const cfg = loadConfig(); + const account = resolveSlackAccount({ cfg, accountId }); + const token = resolveSlackBotToken(explicit ?? account.botToken ?? undefined); if (!token) { + logVerbose( + `slack actions: missing bot token for account=${account.accountId} explicit=${Boolean( + explicit, + )} source=${account.botTokenSource ?? "unknown"}`, + ); throw new Error( "SLACK_BOT_TOKEN or slack.botToken is required for Slack actions", ); @@ -50,7 +57,7 @@ function normalizeEmoji(raw: string) { } async function getClient(opts: SlackActionClientOpts = {}) { - const token = resolveToken(opts.token); + const token = resolveToken(opts.token, opts.accountId); return opts.client ?? new WebClient(token); } @@ -141,6 +148,7 @@ export async function sendSlackMessage( opts: SlackActionClientOpts & { mediaUrl?: string } = {}, ) { return await sendMessageSlack(to, content, { + accountId: opts.accountId, token: opts.token, mediaUrl: opts.mediaUrl, client: opts.client, diff --git a/src/slack/index.ts b/src/slack/index.ts index 2cc9bbdd9..7798ea9c6 100644 --- a/src/slack/index.ts +++ b/src/slack/index.ts @@ -1,3 +1,9 @@ +export { + listEnabledSlackAccounts, + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, +} from "./accounts.js"; export { deleteSlackMessage, editSlackMessage, diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 7d57c9f43..47b70b4c8 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -80,6 +80,7 @@ type SlackMessageEvent = { user?: string; bot_id?: string; subtype?: string; + username?: string; text?: string; ts?: string; thread_ts?: string; @@ -93,6 +94,7 @@ type SlackAppMentionEvent = { type: "app_mention"; user?: string; bot_id?: string; + username?: string; text?: string; ts?: string; thread_ts?: string; @@ -170,6 +172,7 @@ type SlackThreadBroadcastEvent = { type SlackChannelConfigResolved = { allowed: boolean; requireMention: boolean; + allowBots?: boolean; users?: Array; skills?: string[]; systemPrompt?: string; @@ -294,6 +297,7 @@ function resolveSlackChannelConfig(params: { enabled?: boolean; allow?: boolean; requireMention?: boolean; + allowBots?: boolean; users?: Array; skills?: string[]; systemPrompt?: string; @@ -317,6 +321,7 @@ function resolveSlackChannelConfig(params: { enabled?: boolean; allow?: boolean; requireMention?: boolean; + allowBots?: boolean; users?: Array; skills?: string[]; systemPrompt?: string; @@ -349,13 +354,14 @@ function resolveSlackChannelConfig(params: { const requireMention = firstDefined(resolved.requireMention, fallback?.requireMention, true) ?? true; + const allowBots = firstDefined(resolved.allowBots, fallback?.allowBots); const users = firstDefined(resolved.users, fallback?.users); const skills = firstDefined(resolved.skills, fallback?.skills); const systemPrompt = firstDefined( resolved.systemPrompt, fallback?.systemPrompt, ); - return { allowed, requireMention, users, skills, systemPrompt }; + return { allowed, requireMention, allowBots, users, skills, systemPrompt }; } async function resolveSlackMedia(params: { @@ -706,15 +712,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { opts: { source: "message" | "app_mention"; wasMentioned?: boolean }, ) => { if (opts.source === "message" && message.type !== "message") return; - if (message.bot_id) return; if ( opts.source === "message" && message.subtype && - message.subtype !== "file_share" + message.subtype !== "file_share" && + message.subtype !== "bot_message" ) { return; } - if (!message.user) return; if (markMessageSeen(message.channel, message.ts)) return; let channelInfo: { @@ -735,6 +740,40 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const isRoom = resolvedChannelType === "channel" || resolvedChannelType === "group"; + const channelConfig = isRoom + ? resolveSlackChannelConfig({ + channelId: message.channel, + channelName, + channels: channelsConfig, + }) + : null; + + const allowBots = + channelConfig?.allowBots ?? + account.config?.allowBots ?? + cfg.slack?.allowBots ?? + false; + const isBotMessage = Boolean(message.bot_id); + if (isBotMessage) { + if (message.user && botUserId && message.user === botUserId) return; + if (!allowBots) { + logVerbose( + `slack: drop bot message ${message.bot_id ?? "unknown"} (allowBots=false)`, + ); + return; + } + } + if (isDirectMessage && !message.user) { + logVerbose("slack: drop dm message (missing user id)"); + return; + } + const senderId = + message.user ?? (isBotMessage ? message.bot_id : undefined); + if (!senderId) { + logVerbose("slack: drop message (missing sender id)"); + return; + } + if ( !isChannelAllowed({ channelId: message.channel, @@ -756,6 +795,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const effectiveAllowFromLower = normalizeAllowListLower(effectiveAllowFrom); if (isDirectMessage) { + const directUserId = message.user; + if (!directUserId) { + logVerbose("slack: drop dm message (missing user id)"); + return; + } if (!dmEnabled || dmPolicy === "disabled") { logVerbose("slack: drop dm (dms disabled)"); return; @@ -763,20 +807,20 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { if (dmPolicy !== "open") { const permitted = allowListMatches({ allowList: effectiveAllowFromLower, - id: message.user, + id: directUserId, }); if (!permitted) { if (dmPolicy === "pairing") { - const sender = await resolveUserName(message.user); + const sender = await resolveUserName(directUserId); const senderName = sender?.name ?? undefined; const { code, created } = await upsertProviderPairingRequest({ provider: "slack", - id: message.user, + id: directUserId, meta: { name: senderName }, }); if (created) { logVerbose( - `slack pairing request sender=${message.user} name=${senderName ?? "unknown"}`, + `slack pairing request sender=${directUserId} name=${senderName ?? "unknown"}`, ); try { await sendMessageSlack( @@ -811,31 +855,28 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } } - const channelConfig = isRoom - ? resolveSlackChannelConfig({ - channelId: message.channel, - channelName, - channels: channelsConfig, - }) - : null; - const wasMentioned = opts.wasMentioned ?? (!isDirectMessage && (Boolean(botUserId && message.text?.includes(`<@${botUserId}>`)) || matchesMentionPatterns(message.text ?? "", mentionRegexes))); - const sender = await resolveUserName(message.user); - const senderName = sender?.name ?? message.user; + const sender = message.user ? await resolveUserName(message.user) : null; + const senderName = + sender?.name ?? + message.username?.trim() ?? + message.user ?? + message.bot_id ?? + "unknown"; const channelUserAuthorized = isRoom ? resolveSlackUserAllowed({ allowList: channelConfig?.users, - userId: message.user, + userId: senderId, userName: senderName, }) : true; if (isRoom && !channelUserAuthorized) { logVerbose( - `Blocked unauthorized slack sender ${message.user} (not in channel users)`, + `Blocked unauthorized slack sender ${senderId} (not in channel users)`, ); return; } @@ -844,7 +885,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { (allowList.length === 0 || allowListMatches({ allowList, - id: message.user, + id: senderId, name: senderName, })) && channelUserAuthorized; @@ -1010,7 +1051,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { GroupSubject: isRoomish ? roomLabel : undefined, GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined, SenderName: senderName, - SenderId: message.user, + SenderId: senderId, Provider: "slack" as const, Surface: "slack" as const, MessageSid: message.ts, diff --git a/src/slack/send.ts b/src/slack/send.ts index ad12fdbb8..3323186a6 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -5,7 +5,9 @@ import { resolveTextChunkLimit, } from "../auto-reply/chunk.js"; import { loadConfig } from "../config/config.js"; +import { logVerbose } from "../globals.js"; import { loadWebMedia } from "../web/media.js"; +import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; import { resolveSlackBotToken } from "./token.js"; @@ -38,11 +40,17 @@ function resolveToken(params: { explicit?: string; accountId: string; fallbackToken?: string; + fallbackSource?: SlackTokenSource; }) { const explicit = resolveSlackBotToken(params.explicit); if (explicit) return explicit; const fallback = resolveSlackBotToken(params.fallbackToken); if (!fallback) { + logVerbose( + `slack send: missing bot token for account=${params.accountId} explicit=${Boolean( + params.explicit, + )} source=${params.fallbackSource ?? "unknown"}`, + ); throw new Error( `Slack bot token missing for account "${params.accountId}" (set slack.accounts.${params.accountId}.botToken or SLACK_BOT_TOKEN for default).`, ); @@ -154,6 +162,7 @@ export async function sendMessageSlack( explicit: opts.token, accountId: account.accountId, fallbackToken: account.botToken, + fallbackSource: account.botTokenSource, }); const client = opts.client ?? new WebClient(token); const recipient = parseRecipient(to); diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 7e14497b0..8a98d6f27 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -140,17 +140,12 @@ describe("createTelegramBot", () => { globalThis.fetch = fetchSpy; try { createTelegramBot({ token: "tok" }); - const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun); - if (isBun) { - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ fetch: fetchSpy }), - }), - ); - } else { - expect(botCtorSpy).toHaveBeenCalledWith("tok", undefined); - } + expect(botCtorSpy).toHaveBeenCalledWith( + "tok", + expect.objectContaining({ + client: expect.objectContaining({ fetch: fetchSpy }), + }), + ); } finally { globalThis.fetch = originalFetch; } diff --git a/src/types/osc-progress.d.ts b/src/types/osc-progress.d.ts new file mode 100644 index 000000000..6c7241694 --- /dev/null +++ b/src/types/osc-progress.d.ts @@ -0,0 +1,19 @@ +declare module "osc-progress" { + export type OscProgressController = { + setIndeterminate: (label: string) => void; + setPercent: (label: string, percent: number) => void; + clear: () => void; + done?: () => void; + }; + + export function createOscProgressController(params: { + env: NodeJS.ProcessEnv; + isTty: boolean; + write: (chunk: string) => void; + }): OscProgressController; + + export function supportsOscProgress( + env: NodeJS.ProcessEnv, + isTty: boolean, + ): boolean; +}