diff --git a/CHANGELOG.md b/CHANGELOG.md index cb91212f1..dfddb45e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Docs: link Hetzner guide from install + platforms docs. (#592) — thanks @steipete - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro +- Slack: configurable reply threading (`slack.replyToMode`) + proper mrkdwn formatting for outbound messages. (#464) — thanks @austinm911 - Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete - Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow - Discord: log gateway disconnect/reconnect events at info and add verbose gateway metrics. (#595) — thanks @steipete diff --git a/docs/providers/slack.md b/docs/providers/slack.md index cabeaa53e..c95e23a0a 100644 --- a/docs/providers/slack.md +++ b/docs/providers/slack.md @@ -199,12 +199,20 @@ Ack reactions are controlled globally via `messages.ackReaction` + - Media uploads are capped by `slack.mediaMaxMb` (default 20). ## Reply threading -Slack supports optional threaded replies via tags: -- `[[reply_to_current]]` — reply to the triggering message. -- `[[reply_to:]]` — reply to a specific message id. +By default, Clawdbot replies in the main channel. Use `slack.replyToMode` to control automatic threading: -Controlled by `slack.replyToMode`: -- `off` (default), `first`, `all`. +| Mode | Behavior | +| --- | --- | +| `off` | **Default.** Reply in main channel. Only thread if the triggering message was already in a thread. | +| `first` | First reply goes to thread (under the triggering message), subsequent replies go to main channel. Useful for keeping context visible while avoiding thread clutter. | +| `all` | All replies go to thread. Keeps conversations contained but may reduce visibility. | + +The mode applies to both auto-replies and agent tool calls (`slack sendMessage`). + +### Manual threading tags +For fine-grained control, use these tags in agent responses: +- `[[reply_to_current]]` — reply to the triggering message (start/continue thread). +- `[[reply_to:]]` — reply to a specific message id. ## Sessions + routing - DMs share the `main` session (like WhatsApp/Telegram). diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 0b5287e60..7ba144ce7 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -22,6 +22,14 @@ export function createClawdbotTools(options?: { agentDir?: string; sandboxed?: boolean; config?: ClawdbotConfig; + /** Current channel ID for auto-threading (Slack). */ + currentChannelId?: string; + /** Current thread timestamp for auto-threading (Slack). */ + currentThreadTs?: string; + /** Reply-to mode for Slack auto-threading. */ + replyToMode?: "off" | "first" | "all"; + /** Mutable ref to track if a reply was sent (for "first" mode). */ + hasRepliedRef?: { value: boolean }; }): AnyAgentTool[] { const imageTool = createImageTool({ config: options?.config, @@ -35,6 +43,10 @@ export function createClawdbotTools(options?: { createMessageTool({ agentAccountId: options?.agentAccountId, config: options?.config, + currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + replyToMode: options?.replyToMode, + hasRepliedRef: options?.hasRepliedRef, }), createGatewayTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index ca9635ebd..0123d26bd 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -869,6 +869,7 @@ export async function compactEmbeddedPiSession(params: { sessionKey: params.sessionKey ?? params.sessionId, agentDir, config: params.config, + // No currentChannelId/currentThreadTs for compaction - not in message context }); const machineName = await getMachineDisplayName(); const runtimeProvider = normalizeMessageProvider( @@ -1000,6 +1001,14 @@ export async function runEmbeddedPiAgent(params: { sessionKey?: string; messageProvider?: string; agentAccountId?: string; + /** Current channel ID for auto-threading (Slack). */ + currentChannelId?: string; + /** Current thread timestamp for auto-threading (Slack). */ + currentThreadTs?: string; + /** Reply-to mode for Slack auto-threading. */ + replyToMode?: "off" | "first" | "all"; + /** Mutable ref to track if a reply was sent (for "first" mode). */ + hasRepliedRef?: { value: boolean }; sessionFile: string; workspaceDir: string; agentDir?: string; @@ -1201,6 +1210,10 @@ export async function runEmbeddedPiAgent(params: { sessionKey: params.sessionKey ?? params.sessionId, agentDir, config: params.config, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + replyToMode: params.replyToMode, + hasRepliedRef: params.hasRepliedRef, }); const machineName = await getMachineDisplayName(); const runtimeInfo = { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index cf0794842..c52a38a07 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -510,6 +510,14 @@ export function createClawdbotCodingTools(options?: { sessionKey?: string; agentDir?: string; config?: ClawdbotConfig; + /** Current channel ID for auto-threading (Slack). */ + currentChannelId?: string; + /** Current thread timestamp for auto-threading (Slack). */ + currentThreadTs?: string; + /** Reply-to mode for Slack auto-threading. */ + replyToMode?: "off" | "first" | "all"; + /** Mutable ref to track if a reply was sent (for "first" mode). */ + hasRepliedRef?: { value: boolean }; }): AnyAgentTool[] { const bashToolName = "bash"; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; @@ -580,6 +588,10 @@ export function createClawdbotCodingTools(options?: { agentDir: options?.agentDir, sandboxed: !!sandbox, config: options?.config, + currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + replyToMode: options?.replyToMode, + hasRepliedRef: options?.hasRepliedRef, }), ]; const toolsFiltered = effectiveToolsPolicy diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index d0d8d5d8f..b903364fd 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -134,6 +134,14 @@ const MessageToolSchema = Type.Object({ type MessageToolOptions = { agentAccountId?: string; config?: ClawdbotConfig; + /** Current channel ID for auto-threading (Slack). */ + currentChannelId?: string; + /** Current thread timestamp for auto-threading (Slack). */ + currentThreadTs?: string; + /** Reply-to mode for Slack auto-threading. */ + replyToMode?: "off" | "first" | "all"; + /** Mutable ref to track if a reply was sent (for "first" mode). */ + hasRepliedRef?: { value: boolean }; }; function hasTelegramInlineButtons(cfg: ClawdbotConfig): boolean { @@ -385,6 +393,12 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { threadTs: threadId ?? replyTo ?? undefined, }, cfg, + { + currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + replyToMode: options?.replyToMode, + hasRepliedRef: options?.hasRepliedRef, + }, ); } if (provider === "telegram") { diff --git a/src/agents/tools/slack-actions.test.ts b/src/agents/tools/slack-actions.test.ts index 8f2c3e9f8..e2e85c7b1 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/src/agents/tools/slack-actions.test.ts @@ -110,4 +110,252 @@ describe("handleSlackAction", () => { ), ).rejects.toThrow(/Slack reactions are disabled/); }); + + it("passes threadTs to sendSlackMessage for thread replies", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content: "Hello thread", + threadTs: "1234567890.123456", + }, + cfg, + ); + expect(sendSlackMessage).toHaveBeenCalledWith( + "channel:C123", + "Hello thread", + { + mediaUrl: undefined, + threadTs: "1234567890.123456", + }, + ); + }); + + it("auto-injects threadTs from context when replyToMode=all", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content: "Auto-threaded", + }, + cfg, + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "all", + }, + ); + expect(sendSlackMessage).toHaveBeenCalledWith( + "channel:C123", + "Auto-threaded", + { + mediaUrl: undefined, + threadTs: "1111111111.111111", + }, + ); + }); + + it("replyToMode=first threads first message then stops", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + const hasRepliedRef = { value: false }; + const context = { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "first" as const, + hasRepliedRef, + }; + + // First message should be threaded + await handleSlackAction( + { action: "sendMessage", to: "channel:C123", content: "First" }, + cfg, + context, + ); + expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", "First", { + mediaUrl: undefined, + threadTs: "1111111111.111111", + }); + expect(hasRepliedRef.value).toBe(true); + + // Second message should NOT be threaded + await handleSlackAction( + { action: "sendMessage", to: "channel:C123", content: "Second" }, + cfg, + context, + ); + expect(sendSlackMessage).toHaveBeenLastCalledWith( + "channel:C123", + "Second", + { + mediaUrl: undefined, + threadTs: undefined, + }, + ); + }); + + it("replyToMode=first marks hasRepliedRef even when threadTs is explicit", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + const hasRepliedRef = { value: false }; + const context = { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "first" as const, + hasRepliedRef, + }; + + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content: "Explicit", + threadTs: "2222222222.222222", + }, + cfg, + context, + ); + expect(sendSlackMessage).toHaveBeenLastCalledWith( + "channel:C123", + "Explicit", + { + mediaUrl: undefined, + threadTs: "2222222222.222222", + }, + ); + expect(hasRepliedRef.value).toBe(true); + + await handleSlackAction( + { action: "sendMessage", to: "channel:C123", content: "Second" }, + cfg, + context, + ); + expect(sendSlackMessage).toHaveBeenLastCalledWith( + "channel:C123", + "Second", + { + mediaUrl: undefined, + threadTs: undefined, + }, + ); + }); + + it("replyToMode=first without hasRepliedRef does not thread", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { action: "sendMessage", to: "channel:C123", content: "No ref" }, + cfg, + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "first", + // no hasRepliedRef + }, + ); + expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "No ref", { + mediaUrl: undefined, + threadTs: undefined, + }); + }); + + it("does not auto-inject threadTs when replyToMode=off", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content: "Off mode", + }, + cfg, + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "off", + }, + ); + expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Off mode", { + mediaUrl: undefined, + threadTs: undefined, + }); + }); + + it("does not auto-inject threadTs when sending to different channel", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C999", + content: "Different channel", + }, + cfg, + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "all", + }, + ); + expect(sendSlackMessage).toHaveBeenCalledWith( + "channel:C999", + "Different channel", + { + mediaUrl: undefined, + threadTs: undefined, + }, + ); + }); + + it("explicit threadTs overrides context threadTs", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content: "Explicit thread", + threadTs: "2222222222.222222", + }, + cfg, + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "all", + }, + ); + expect(sendSlackMessage).toHaveBeenCalledWith( + "channel:C123", + "Explicit thread", + { + mediaUrl: undefined, + threadTs: "2222222222.222222", + }, + ); + }); + + it("handles channel target without prefix when replyToMode=all", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { + action: "sendMessage", + to: "C123", + content: "No prefix", + }, + cfg, + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "all", + }, + ); + expect(sendSlackMessage).toHaveBeenCalledWith("C123", "No prefix", { + mediaUrl: undefined, + threadTs: "1111111111.111111", + }); + }); }); diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index ae3d4c712..863e921d6 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -34,9 +34,60 @@ const messagingActions = new Set([ const reactionsActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); +export type SlackActionContext = { + /** Current channel ID for auto-threading. */ + currentChannelId?: string; + /** Current thread timestamp for auto-threading. */ + currentThreadTs?: string; + /** Reply-to mode for auto-threading. */ + replyToMode?: "off" | "first" | "all"; + /** Mutable ref to track if a reply was sent (for "first" mode). */ + hasRepliedRef?: { value: boolean }; +}; + +/** + * Resolve threadTs for a Slack message based on context and replyToMode. + * - "all": always inject threadTs + * - "first": inject only for first message (updates hasRepliedRef) + * - "off": never auto-inject + */ +function resolveThreadTsFromContext( + explicitThreadTs: string | undefined, + targetChannel: string, + context: SlackActionContext | undefined, +): string | undefined { + // Agent explicitly provided threadTs - use it + if (explicitThreadTs) return explicitThreadTs; + // No context or missing required fields + if (!context?.currentThreadTs || !context?.currentChannelId) return undefined; + + // Normalize target (strip "channel:" prefix if present) + const normalizedTarget = targetChannel.startsWith("channel:") + ? targetChannel.slice("channel:".length) + : targetChannel; + + // Different channel - don't inject + if (normalizedTarget !== context.currentChannelId) return undefined; + + // Check replyToMode + if (context.replyToMode === "all") { + return context.currentThreadTs; + } + if ( + context.replyToMode === "first" && + context.hasRepliedRef && + !context.hasRepliedRef.value + ) { + context.hasRepliedRef.value = true; + return context.currentThreadTs; + } + return undefined; +} + export async function handleSlackAction( params: Record, cfg: ClawdbotConfig, + context?: SlackActionContext, ): Promise> { const action = readStringParam(params, "action", { required: true }); const accountId = readStringParam(params, "accountId"); @@ -91,12 +142,29 @@ export async function handleSlackAction( const to = readStringParam(params, "to", { required: true }); const content = readStringParam(params, "content", { required: true }); const mediaUrl = readStringParam(params, "mediaUrl"); - const threadTs = readStringParam(params, "threadTs"); + const threadTs = resolveThreadTsFromContext( + readStringParam(params, "threadTs"), + to, + context, + ); const result = await sendSlackMessage(to, content, { accountId: accountId ?? undefined, mediaUrl: mediaUrl ?? undefined, threadTs: threadTs ?? undefined, }); + + // Keep "first" mode consistent even when the agent explicitly provided + // threadTs: once we send a message to the current channel, consider the + // first reply "used" so later tool calls don't auto-thread again. + if (context?.hasRepliedRef && context.currentChannelId) { + const normalizedTarget = to.startsWith("channel:") + ? to.slice("channel:".length) + : to; + if (normalizedTarget === context.currentChannelId) { + context.hasRepliedRef.value = true; + } + } + return jsonResult({ ok: true, result }); } case "editMessage": { diff --git a/src/agents/tools/slack-tool.ts b/src/agents/tools/slack-tool.ts index fabb63388..afac983b1 100644 --- a/src/agents/tools/slack-tool.ts +++ b/src/agents/tools/slack-tool.ts @@ -9,6 +9,14 @@ import { SlackToolSchema } from "./slack-schema.js"; type SlackToolOptions = { agentAccountId?: string; config?: ClawdbotConfig; + /** Current channel ID for auto-threading. */ + currentChannelId?: string; + /** Current thread timestamp for auto-threading. */ + currentThreadTs?: string; + /** Reply-to mode for auto-threading. */ + replyToMode?: "off" | "first" | "all"; + /** Mutable ref to track if a reply was sent (for "first" mode). */ + hasRepliedRef?: { value: boolean }; }; function resolveAgentAccountId(value?: string): string | undefined { @@ -63,7 +71,12 @@ export function createSlackTool(options?: SlackToolOptions): AnyAgentTool { ).trim()}`, ); } - return await handleSlackAction(resolvedParams, cfg); + return await handleSlackAction(resolvedParams, cfg, { + currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + replyToMode: options?.replyToMode, + hasRepliedRef: options?.hasRepliedRef, + }); }, }; } diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 137720b14..03ab41f75 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -62,6 +62,50 @@ import { createTypingSignaler } from "./typing-mode.js"; const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i; const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000; +/** + * Build Slack-specific threading context for tool auto-injection. + * Returns undefined values for non-Slack providers. + */ +function buildSlackThreadingContext(params: { + sessionCtx: TemplateContext; + config: { slack?: { replyToMode?: "off" | "first" | "all" } } | undefined; + hasRepliedRef: { value: boolean } | undefined; +}): { + currentChannelId: string | undefined; + currentThreadTs: string | undefined; + replyToMode: "off" | "first" | "all" | undefined; + hasRepliedRef: { value: boolean } | undefined; +} { + const { sessionCtx, config, hasRepliedRef } = params; + const isSlack = sessionCtx.Provider?.toLowerCase() === "slack"; + if (!isSlack) { + return { + currentChannelId: undefined, + currentThreadTs: undefined, + replyToMode: undefined, + hasRepliedRef: undefined, + }; + } + + // If we're already inside a thread, never jump replies out of it (even in + // replyToMode="off"/"first"). This keeps tool calls consistent with the + // auto-reply path. + const configuredReplyToMode = config?.slack?.replyToMode ?? "off"; + const effectiveReplyToMode = sessionCtx.ThreadLabel + ? ("all" as const) + : configuredReplyToMode; + + return { + // Extract channel from "channel:C123" format + currentChannelId: sessionCtx.To?.startsWith("channel:") + ? sessionCtx.To.slice("channel:".length) + : undefined, + currentThreadTs: sessionCtx.ReplyToId, + replyToMode: effectiveReplyToMode, + hasRepliedRef, + }; +} + const isBunFetchSocketError = (message?: string) => Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message)); @@ -375,6 +419,12 @@ export async function runReplyAgent(params: { messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined, agentAccountId: sessionCtx.AccountId, + // Slack threading context for tool auto-injection + ...buildSlackThreadingContext({ + sessionCtx, + config: followupRun.run.config, + hasRepliedRef: opts?.hasRepliedRef, + }), sessionFile: followupRun.run.sessionFile, workspaceDir: followupRun.run.workspaceDir, agentDir: followupRun.run.agentDir, diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index a276fe66d..fb7b8321a 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -21,6 +21,8 @@ export type GetReplyOptions = { blockReplyTimeoutMs?: number; /** If provided, only load these skills for this session (empty = no skills). */ skillFilter?: string[]; + /** Mutable ref to track if a reply was sent (for Slack "first" threading mode). */ + hasRepliedRef?: { value: boolean }; }; export type ReplyPayload = { diff --git a/src/slack/format.test.ts b/src/slack/format.test.ts new file mode 100644 index 000000000..6a5f7fdde --- /dev/null +++ b/src/slack/format.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; + +import { markdownToSlackMrkdwn } from "./format.js"; + +describe("markdownToSlackMrkdwn", () => { + it("converts bold from double asterisks to single", () => { + const res = markdownToSlackMrkdwn("**bold text**"); + expect(res).toBe("*bold text*"); + }); + + it("preserves italic underscore format", () => { + const res = markdownToSlackMrkdwn("_italic text_"); + expect(res).toBe("_italic text_"); + }); + + it("converts strikethrough from double tilde to single", () => { + const res = markdownToSlackMrkdwn("~~strikethrough~~"); + expect(res).toBe("~strikethrough~"); + }); + + it("renders basic inline formatting together", () => { + const res = markdownToSlackMrkdwn("hi _there_ **boss** `code`"); + expect(res).toBe("hi _there_ *boss* `code`"); + }); + + it("renders inline code", () => { + const res = markdownToSlackMrkdwn("use `npm install`"); + expect(res).toBe("use `npm install`"); + }); + + it("renders fenced code blocks", () => { + const res = markdownToSlackMrkdwn("```js\nconst x = 1;\n```"); + expect(res).toBe("```\nconst x = 1;\n```"); + }); + + it("renders links with URL in parentheses", () => { + const res = markdownToSlackMrkdwn("see [docs](https://example.com)"); + expect(res).toBe("see docs (https://example.com)"); + }); + + it("does not duplicate bare URLs", () => { + const res = markdownToSlackMrkdwn("see https://example.com"); + expect(res).toBe("see https://example.com"); + }); + + it("escapes unsafe characters", () => { + const res = markdownToSlackMrkdwn("a & b < c > d"); + expect(res).toBe("a & b < c > d"); + }); + + it("preserves Slack angle-bracket markup (mentions/links)", () => { + const res = markdownToSlackMrkdwn( + "hi <@U123> see and ", + ); + expect(res).toBe("hi <@U123> see and "); + }); + + it("escapes raw HTML", () => { + const res = markdownToSlackMrkdwn("nope"); + expect(res).toBe("<b>nope</b>"); + }); + + it("renders paragraphs with blank lines", () => { + const res = markdownToSlackMrkdwn("first\n\nsecond"); + expect(res).toBe("first\n\nsecond"); + }); + + it("renders bullet lists", () => { + const res = markdownToSlackMrkdwn("- one\n- two"); + expect(res).toBe("• one\n• two"); + }); + + it("renders ordered lists with numbering", () => { + const res = markdownToSlackMrkdwn("2. two\n3. three"); + expect(res).toBe("2. two\n3. three"); + }); + + it("renders headings as bold text", () => { + const res = markdownToSlackMrkdwn("# Title"); + expect(res).toBe("*Title*"); + }); + + it("renders blockquotes", () => { + const res = markdownToSlackMrkdwn("> Quote"); + expect(res).toBe("> Quote"); + }); + + it("handles adjacent list items", () => { + const res = markdownToSlackMrkdwn("- item\n - nested"); + // markdown-it treats indented items as continuation, not nesting + expect(res).toBe("• item • nested"); + }); + + it("handles complex message with multiple elements", () => { + const res = markdownToSlackMrkdwn( + "**Important:** Check the _docs_ at [link](https://example.com)\n\n- first\n- second", + ); + expect(res).toBe( + "*Important:* Check the _docs_ at link (https://example.com)\n\n• first\n• second", + ); + }); +}); diff --git a/src/slack/format.ts b/src/slack/format.ts new file mode 100644 index 000000000..3d43a50d6 --- /dev/null +++ b/src/slack/format.ts @@ -0,0 +1,244 @@ +import MarkdownIt from "markdown-it"; + +type ListState = { + type: "bullet" | "ordered"; + index: number; +}; + +type RenderEnv = { + slackListStack?: ListState[]; + slackLinkStack?: { href: string }[]; +}; + +const md = new MarkdownIt({ + html: false, + // Slack will auto-link plain URLs; keeping linkify off avoids double-rendering + // (e.g. "https://x.com" becoming "https://x.com (https://x.com)"). + linkify: false, + breaks: false, + typographer: false, +}); + +md.enable("strikethrough"); + +/** + * Escape special characters for Slack mrkdwn format. + * + * By default, Slack uses angle-bracket markup for mentions and links + * (e.g. "<@U123>", ""). We preserve those tokens so agents + * can intentionally include them, while escaping other uses of "<" and ">". + */ +function escapeSlackMrkdwnSegment(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +const SLACK_ANGLE_TOKEN_RE = /<[^>\n]+>/g; + +function isAllowedSlackAngleToken(token: string): boolean { + if (!token.startsWith("<") || !token.endsWith(">")) return false; + const inner = token.slice(1, -1); + return ( + inner.startsWith("@") || + inner.startsWith("#") || + inner.startsWith("!") || + inner.startsWith("mailto:") || + inner.startsWith("tel:") || + inner.startsWith("http://") || + inner.startsWith("https://") || + inner.startsWith("slack://") + ); +} + +function escapeSlackMrkdwnText(text: string): string { + if (!text.includes("&") && !text.includes("<") && !text.includes(">")) { + return text; + } + + SLACK_ANGLE_TOKEN_RE.lastIndex = 0; + const out: string[] = []; + let lastIndex = 0; + + for ( + let match = SLACK_ANGLE_TOKEN_RE.exec(text); + match; + match = SLACK_ANGLE_TOKEN_RE.exec(text) + ) { + const matchIndex = match.index ?? 0; + out.push(escapeSlackMrkdwnSegment(text.slice(lastIndex, matchIndex))); + const token = match[0] ?? ""; + out.push( + isAllowedSlackAngleToken(token) ? token : escapeSlackMrkdwnSegment(token), + ); + lastIndex = matchIndex + token.length; + } + + out.push(escapeSlackMrkdwnSegment(text.slice(lastIndex))); + return out.join(""); +} + +function getListStack(env: RenderEnv): ListState[] { + if (!env.slackListStack) env.slackListStack = []; + return env.slackListStack; +} + +function getLinkStack(env: RenderEnv): { href: string }[] { + if (!env.slackLinkStack) env.slackLinkStack = []; + return env.slackLinkStack; +} + +md.renderer.rules.text = (tokens, idx) => + escapeSlackMrkdwnText(tokens[idx]?.content ?? ""); + +md.renderer.rules.softbreak = () => "\n"; +md.renderer.rules.hardbreak = () => "\n"; + +md.renderer.rules.paragraph_open = () => ""; +md.renderer.rules.paragraph_close = (_tokens, _idx, _opts, env) => { + const stack = getListStack(env as RenderEnv); + return stack.length ? "" : "\n\n"; +}; + +md.renderer.rules.heading_open = () => "*"; +md.renderer.rules.heading_close = () => "*\n\n"; + +md.renderer.rules.blockquote_open = () => "> "; +md.renderer.rules.blockquote_close = () => "\n"; + +md.renderer.rules.bullet_list_open = (_tokens, _idx, _opts, env) => { + getListStack(env as RenderEnv).push({ type: "bullet", index: 0 }); + return ""; +}; +md.renderer.rules.bullet_list_close = (_tokens, _idx, _opts, env) => { + getListStack(env as RenderEnv).pop(); + return ""; +}; +md.renderer.rules.ordered_list_open = (tokens, idx, _opts, env) => { + const start = Number(tokens[idx]?.attrGet("start") ?? "1"); + getListStack(env as RenderEnv).push({ type: "ordered", index: start - 1 }); + return ""; +}; +md.renderer.rules.ordered_list_close = (_tokens, _idx, _opts, env) => { + getListStack(env as RenderEnv).pop(); + return ""; +}; +md.renderer.rules.list_item_open = (_tokens, _idx, _opts, env) => { + const stack = getListStack(env as RenderEnv); + const top = stack[stack.length - 1]; + if (!top) return ""; + top.index += 1; + const indent = " ".repeat(Math.max(0, stack.length - 1)); + const prefix = top.type === "ordered" ? `${top.index}. ` : "• "; + return `${indent}${prefix}`; +}; +md.renderer.rules.list_item_close = () => "\n"; + +// Slack mrkdwn uses _text_ for italic (same as markdown) +md.renderer.rules.em_open = () => "_"; +md.renderer.rules.em_close = () => "_"; + +// Slack mrkdwn uses *text* for bold (single asterisk, not double) +md.renderer.rules.strong_open = () => "*"; +md.renderer.rules.strong_close = () => "*"; + +// Slack mrkdwn uses ~text~ for strikethrough (single tilde) +md.renderer.rules.s_open = () => "~"; +md.renderer.rules.s_close = () => "~"; + +md.renderer.rules.code_inline = (tokens, idx) => + `\`${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\``; + +md.renderer.rules.code_block = (tokens, idx) => + `\`\`\`\n${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\`\`\`\n`; + +md.renderer.rules.fence = (tokens, idx) => + `\`\`\`\n${escapeSlackMrkdwnSegment(tokens[idx]?.content ?? "")}\`\`\`\n`; + +md.renderer.rules.link_open = (tokens, idx, _opts, env) => { + const href = tokens[idx]?.attrGet("href") ?? ""; + const stack = getLinkStack(env as RenderEnv); + stack.push({ href }); + return ""; +}; +md.renderer.rules.link_close = (_tokens, _idx, _opts, env) => { + const stack = getLinkStack(env as RenderEnv); + const link = stack.pop(); + if (link?.href) { + return ` (${escapeSlackMrkdwnSegment(link.href)})`; + } + return ""; +}; + +md.renderer.rules.image = (tokens, idx) => { + const alt = tokens[idx]?.content ?? ""; + return escapeSlackMrkdwnSegment(alt); +}; + +md.renderer.rules.html_block = (tokens, idx) => + escapeSlackMrkdwnSegment(tokens[idx]?.content ?? ""); +md.renderer.rules.html_inline = (tokens, idx) => + escapeSlackMrkdwnSegment(tokens[idx]?.content ?? ""); + +md.renderer.rules.table_open = () => ""; +md.renderer.rules.table_close = () => ""; +md.renderer.rules.thead_open = () => ""; +md.renderer.rules.thead_close = () => ""; +md.renderer.rules.tbody_open = () => ""; +md.renderer.rules.tbody_close = () => ""; +md.renderer.rules.tr_open = () => ""; +md.renderer.rules.tr_close = () => "\n"; +md.renderer.rules.th_open = () => ""; +md.renderer.rules.th_close = () => "\t"; +md.renderer.rules.td_open = () => ""; +md.renderer.rules.td_close = () => "\t"; + +md.renderer.rules.hr = () => "\n"; + +function protectSlackAngleLinks(markdown: string): { + markdown: string; + tokens: string[]; +} { + const tokens: string[] = []; + const protectedMarkdown = (markdown ?? "").replace( + /<(?:https?:\/\/|mailto:|tel:|slack:\/\/)[^>\n]+>/g, + (match) => { + const id = tokens.length; + tokens.push(match); + return `⟦clawdbot-slacktok:${id}⟧`; + }, + ); + return { markdown: protectedMarkdown, tokens }; +} + +function restoreSlackAngleLinks(text: string, tokens: string[]): string { + let out = text; + for (let i = 0; i < tokens.length; i++) { + out = out.replaceAll(`⟦clawdbot-slacktok:${i}⟧`, tokens[i] ?? ""); + } + return out; +} + +/** + * Convert standard Markdown to Slack mrkdwn format. + * + * Slack mrkdwn differences from standard Markdown: + * - Bold: *text* (single asterisk, not double) + * - Italic: _text_ (same) + * - Strikethrough: ~text~ (single tilde) + * - Code: `code` (same) + * - Links: or plain URL + * - Escape &, <, > as &, <, > + */ +export function markdownToSlackMrkdwn(markdown: string): string { + const env: RenderEnv = {}; + const protectedLinks = protectSlackAngleLinks(markdown ?? ""); + const rendered = md.render(protectedLinks.markdown, env); + const normalized = rendered + .replace(/[ \t]+\n/g, "\n") + .replace(/\t+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trimEnd(); + return restoreSlackAngleLinks(normalized, protectedLinks.tokens); +} diff --git a/src/slack/monitor.test.ts b/src/slack/monitor.test.ts index baa5a7397..0a2b4dba6 100644 --- a/src/slack/monitor.test.ts +++ b/src/slack/monitor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isSlackRoomAllowedByPolicy } from "./monitor.js"; +import { isSlackRoomAllowedByPolicy, resolveSlackThreadTs } from "./monitor.js"; describe("slack groupPolicy gating", () => { it("allows when policy is open", () => { @@ -53,3 +53,102 @@ describe("slack groupPolicy gating", () => { ).toBe(false); }); }); + +describe("resolveSlackThreadTs", () => { + const threadTs = "1234567890.123456"; + const messageTs = "9999999999.999999"; + + describe("replyToMode=off", () => { + it("returns incomingThreadTs when in a thread", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "off", + incomingThreadTs: threadTs, + messageTs, + hasReplied: false, + }), + ).toBe(threadTs); + }); + + it("returns incomingThreadTs even after replies (stays in thread)", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "off", + incomingThreadTs: threadTs, + messageTs, + hasReplied: true, + }), + ).toBe(threadTs); + }); + + it("returns undefined when not in a thread", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "off", + incomingThreadTs: undefined, + messageTs, + hasReplied: false, + }), + ).toBeUndefined(); + }); + }); + + describe("replyToMode=first", () => { + it("returns incomingThreadTs when in a thread (always stays threaded)", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "first", + incomingThreadTs: threadTs, + messageTs, + hasReplied: false, + }), + ).toBe(threadTs); + }); + + it("returns messageTs for first reply when not in a thread", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "first", + incomingThreadTs: undefined, + messageTs, + hasReplied: false, + }), + ).toBe(messageTs); + }); + + it("returns undefined for subsequent replies when not in a thread (goes to main channel)", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "first", + incomingThreadTs: undefined, + messageTs, + hasReplied: true, + }), + ).toBeUndefined(); + }); + }); + + describe("replyToMode=all", () => { + it("returns incomingThreadTs when in a thread", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "all", + incomingThreadTs: threadTs, + messageTs, + hasReplied: false, + }), + ).toBe(threadTs); + }); + + it("returns messageTs when not in a thread (starts thread)", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "all", + incomingThreadTs: undefined, + messageTs, + hasReplied: true, + }), + ).toBe(messageTs); + }); + }); +}); diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index d67fe25ab..387f81c73 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -601,8 +601,52 @@ describe("monitorSlackProvider tool results", () => { expect(ctx.ParentSessionKey).toBe("agent:support:slack:channel:C1"); }); - it("keeps replies in channel root when message is not threaded", async () => { + it("keeps replies in channel root when message is not threaded (replyToMode off)", async () => { replyMock.mockResolvedValue({ text: "root reply" }); + config = { + messages: { + responsePrefix: "PFX", + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + replyToMode: "off", + }, + }; + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "hello", + ts: "789", + channel: "C1", + channel_type: "im", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(sendMock).toHaveBeenCalledTimes(1); + expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined }); + }); + + it("threads first reply when replyToMode is first and message is not threaded", async () => { + replyMock.mockResolvedValue({ text: "first reply" }); config = { messages: { responsePrefix: "PFX", @@ -642,7 +686,8 @@ describe("monitorSlackProvider tool results", () => { await run; expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: undefined }); + // First reply starts a thread under the incoming message + expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "789" }); }); it("forces thread replies when replyToId is set", async () => { diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index ea02865a9..a83d1452f 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -1069,11 +1069,17 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ); } - const { replyThreadTs, statusThreadTs } = resolveSlackThreadTargets({ + // Use helper for status thread; compute baseThreadTs for "first" mode support + const { statusThreadTs } = resolveSlackThreadTargets({ message, replyToMode, }); + const messageTs = message.ts ?? message.event_ts; + const incomingThreadTs = message.thread_ts; let didSetStatus = false; + // Shared mutable ref for tracking if a reply was sent (used by both + // auto-reply path and tool path for "first" threading mode). + const hasRepliedRef = { value: false }; const onReplyStart = async () => { didSetStatus = true; await setSlackThreadStatus({ @@ -1087,6 +1093,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId) .responsePrefix, deliver: async (payload) => { + const effectiveThreadTs = resolveSlackThreadTs({ + replyToMode, + incomingThreadTs, + messageTs, + hasReplied: hasRepliedRef.value, + }); await deliverReplies({ replies: [payload], target: replyTarget, @@ -1094,8 +1106,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { accountId: account.accountId, runtime, textLimit, - replyThreadTs, + replyThreadTs: effectiveThreadTs, }); + hasRepliedRef.value = true; }, onError: (err, info) => { runtime.error?.( @@ -1119,6 +1132,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { replyOptions: { ...replyOptions, skillFilter: channelConfig?.skills, + hasRepliedRef, disableBlockStreaming: typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming @@ -1958,6 +1972,33 @@ export function isSlackRoomAllowedByPolicy(params: { return channelAllowed; } +/** + * Compute effective threadTs for a Slack reply based on replyToMode. + * - "off": stay in thread if already in one, otherwise main channel + * - "first": first reply goes to thread, subsequent replies to main channel + * - "all": all replies go to thread + */ +export function resolveSlackThreadTs(params: { + replyToMode: "off" | "first" | "all"; + incomingThreadTs: string | undefined; + messageTs: string | undefined; + hasReplied: boolean; +}): string | undefined { + const { replyToMode, incomingThreadTs, messageTs, hasReplied } = params; + if (incomingThreadTs) return incomingThreadTs; + if (!messageTs) return undefined; + if (replyToMode === "all") { + // All replies go to thread + return messageTs; + } + if (replyToMode === "first") { + // "first": only first reply goes to thread + return hasReplied ? undefined : messageTs; + } + // "off": never start a thread + return undefined; +} + async function deliverSlackSlashReplies(params: { replies: ReplyPayload[]; respond: SlackRespondFn; diff --git a/src/slack/send.ts b/src/slack/send.ts index 3323186a6..077130550 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -9,6 +9,7 @@ import { logVerbose } from "../globals.js"; import { loadWebMedia } from "../web/media.js"; import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; +import { markdownToSlackMrkdwn } from "./format.js"; import { resolveSlackBotToken } from "./token.js"; const SLACK_TEXT_LIMIT = 4000; @@ -169,7 +170,8 @@ export async function sendMessageSlack( const { channelId } = await resolveChannelId(client, recipient); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT); - const chunks = chunkMarkdownText(trimmedMessage, chunkLimit); + const slackFormatted = markdownToSlackMrkdwn(trimmedMessage); + const chunks = chunkMarkdownText(slackFormatted, chunkLimit); const mediaMaxBytes = typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb * 1024 * 1024