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:
-
-
-
-
-
+
+
+
+
+
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;
+}