From 7b40d1b261e3c32351ea56f63c6e1a663cd97453 Mon Sep 17 00:00:00 2001 From: Stefan Galescu <52995748+stefangalescu@users.noreply.github.com> Date: Fri, 23 Jan 2026 07:13:23 +0200 Subject: [PATCH] feat(slack): add dm-specific replyToMode configuration (#1442) Adds support for separate replyToMode settings for DMs vs channels: - Add channels.slack.dm.replyToMode for DM-specific threading - Keep channels.slack.replyToMode as default for channels - Add resolveSlackReplyToMode helper to centralize logic - Pass chatType through threading resolution chain Usage: ```json5 { channels: { slack: { replyToMode: "off", // channels dm: { replyToMode: "all" // DMs always thread } } } } ``` When dm.replyToMode is set, DMs use that mode; channels use the top-level replyToMode. Backward compatible when not configured. --- docs/channels/slack.md | 21 ++++- extensions/slack/src/channel.ts | 5 +- src/auto-reply/reply/agent-runner.ts | 1 + src/auto-reply/reply/followup-runner.ts | 1 + src/auto-reply/reply/get-reply-run.ts | 1 + src/auto-reply/reply/queue/types.ts | 2 + src/auto-reply/reply/reply-threading.test.ts | 27 ++++++ src/auto-reply/reply/reply-threading.ts | 2 + src/channels/dock.ts | 6 +- src/channels/plugins/types.core.ts | 1 + src/config/types.slack.ts | 2 + src/plugin-sdk/index.ts | 1 + src/slack/accounts.ts | 10 ++ src/slack/threading-tool-context.test.ts | 96 ++++++++++++++++++++ src/slack/threading-tool-context.ts | 12 +-- 15 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 src/slack/threading-tool-context.test.ts diff --git a/docs/channels/slack.md b/docs/channels/slack.md index d0ac4526c..71349d185 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -304,7 +304,8 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens: "policy": "pairing", "allowFrom": ["U123", "U456", "*"], "groupEnabled": false, - "groupChannels": ["G123"] + "groupChannels": ["G123"], + "replyToMode": "all" }, "channels": { "C123": { "allow": true, "requireMention": true }, @@ -361,6 +362,24 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod The mode applies to both auto-replies and agent tool calls (`slack sendMessage`). +### DM-specific threading +You can configure different threading behavior for DMs vs channels by setting `channels.slack.dm.replyToMode`: + +```json5 +{ + channels: { + slack: { + replyToMode: "off", // default for channels + dm: { + replyToMode: "all" // DMs always thread + } + } + } +} +``` + +When `dm.replyToMode` is set, DMs use that mode; channels use the top-level `replyToMode`. If `dm.replyToMode` is not set, both DMs and channels use the top-level setting. + ### Manual threading tags For fine-grained control, use these tags in agent responses: - `[[reply_to_current]]` — reply to the triggering message (start/continue thread). diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 9e17ca8bd..851b9ebb2 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -19,6 +19,7 @@ import { readStringParam, resolveDefaultSlackAccountId, resolveSlackAccount, + resolveSlackReplyToMode, resolveSlackGroupRequireMention, buildSlackThreadingToolContext, setAccountEnabledInConfigSection, @@ -162,8 +163,8 @@ export const slackPlugin: ChannelPlugin = { resolveRequireMention: resolveSlackGroupRequireMention, }, threading: { - resolveReplyToMode: ({ cfg, accountId }) => - resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off", + resolveReplyToMode: ({ cfg, accountId, chatType }) => + resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), allowTagsWhenOff: true, buildToolContext: (params) => buildSlackThreadingToolContext(params), }, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 8b3f39851..1aeb8e78f 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -136,6 +136,7 @@ export async function runReplyAgent(params: { followupRun.run.config, replyToChannel, sessionCtx.AccountId, + sessionCtx.ChatType, ); const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel); const cfg = followupRun.run.config; diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index f3adeeee9..76b2c4c2a 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -204,6 +204,7 @@ export function createFollowupRunner(params: { queued.run.config, replyToChannel, queued.originatingAccountId, + queued.originatingChatType, ); const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({ diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 28c9e24cb..a5db8a73c 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -358,6 +358,7 @@ export async function runPreparedReply( originatingTo: ctx.OriginatingTo, originatingAccountId: ctx.AccountId, originatingThreadId: ctx.MessageThreadId, + originatingChatType: ctx.ChatType, run: { agentId, agentDir, diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index f5bff0832..a57b65813 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -39,6 +39,8 @@ export type FollowupRun = { originatingAccountId?: string; /** Thread id for reply routing (Telegram topic id or Matrix thread event id). */ originatingThreadId?: string | number; + /** Chat type for context-aware threading (e.g., DM vs channel). */ + originatingChatType?: string; run: { agentId: string; agentDir: string; diff --git a/src/auto-reply/reply/reply-threading.test.ts b/src/auto-reply/reply/reply-threading.test.ts index 38f28b11e..beb4dc292 100644 --- a/src/auto-reply/reply/reply-threading.test.ts +++ b/src/auto-reply/reply/reply-threading.test.ts @@ -31,6 +31,33 @@ describe("resolveReplyToMode", () => { expect(resolveReplyToMode(cfg, "discord")).toBe("first"); expect(resolveReplyToMode(cfg, "slack")).toBe("all"); }); + + it("uses dm-specific replyToMode for Slack DMs when configured", () => { + const cfg = { + channels: { + slack: { + replyToMode: "off", + dm: { replyToMode: "all" }, + }, + }, + } as ClawdbotConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); + expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off"); + }); + + it("falls back to top-level replyToMode when dm.replyToMode is not configured", () => { + const cfg = { + channels: { + slack: { + replyToMode: "first", + dm: { enabled: true }, + }, + }, + } as ClawdbotConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first"); + }); }); describe("createReplyToModeFilter", () => { diff --git a/src/auto-reply/reply/reply-threading.ts b/src/auto-reply/reply/reply-threading.ts index fc9a0f2cd..0856e2838 100644 --- a/src/auto-reply/reply/reply-threading.ts +++ b/src/auto-reply/reply/reply-threading.ts @@ -9,12 +9,14 @@ export function resolveReplyToMode( cfg: ClawdbotConfig, channel?: OriginatingChannelType, accountId?: string | null, + chatType?: string | null, ): ReplyToMode { const provider = normalizeChannelId(channel); if (!provider) return "all"; const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({ cfg, accountId, + chatType, }); return resolved ?? "all"; } diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 81b07c36a..43fa07b6b 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -2,7 +2,7 @@ import type { ClawdbotConfig } from "../config/config.js"; import { resolveDiscordAccount } from "../discord/accounts.js"; import { resolveIMessageAccount } from "../imessage/accounts.js"; import { resolveSignalAccount } from "../signal/accounts.js"; -import { resolveSlackAccount } from "../slack/accounts.js"; +import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js"; import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; import { resolveTelegramAccount } from "../telegram/accounts.js"; import { normalizeE164 } from "../utils.js"; @@ -224,8 +224,8 @@ const DOCKS: Record = { resolveRequireMention: resolveSlackGroupRequireMention, }, threading: { - resolveReplyToMode: ({ cfg, accountId }) => - resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off", + resolveReplyToMode: ({ cfg, accountId, chatType }) => + resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), allowTagsWhenOff: true, buildToolContext: (params) => buildSlackThreadingToolContext(params), }, diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 4e526ec6b..5b0dbd1fc 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -198,6 +198,7 @@ export type ChannelThreadingAdapter = { resolveReplyToMode?: (params: { cfg: ClawdbotConfig; accountId?: string | null; + chatType?: string | null; }) => "off" | "first" | "all"; allowTagsWhenOff?: boolean; buildToolContext?: (params: { diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 956b4c029..2437cb9e5 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -17,6 +17,8 @@ export type SlackDmConfig = { groupEnabled?: boolean; /** Optional allowlist for group DM channels (ids or slugs). */ groupChannels?: Array; + /** Control reply threading for DMs (off|first|all). Overrides top-level replyToMode for DMs. */ + replyToMode?: ReplyToMode; }; export type SlackChannelConfig = { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 1da3650fe..45a4681c7 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -230,6 +230,7 @@ export { listSlackAccountIds, resolveDefaultSlackAccountId, resolveSlackAccount, + resolveSlackReplyToMode, type ResolvedSlackAccount, } from "../slack/accounts.js"; export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js"; diff --git a/src/slack/accounts.ts b/src/slack/accounts.ts index a8ba2317d..9929dcb3c 100644 --- a/src/slack/accounts.ts +++ b/src/slack/accounts.ts @@ -107,3 +107,13 @@ export function listEnabledSlackAccounts(cfg: ClawdbotConfig): ResolvedSlackAcco .map((accountId) => resolveSlackAccount({ cfg, accountId })) .filter((account) => account.enabled); } + +export function resolveSlackReplyToMode( + account: ResolvedSlackAccount, + chatType?: string | null, +): "off" | "first" | "all" { + if (chatType === "direct" && account.dm?.replyToMode !== undefined) { + return account.dm.replyToMode; + } + return account.replyToMode ?? "off"; +} diff --git a/src/slack/threading-tool-context.test.ts b/src/slack/threading-tool-context.test.ts new file mode 100644 index 000000000..5a3ae7a03 --- /dev/null +++ b/src/slack/threading-tool-context.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; + +const emptyCfg = {} as ClawdbotConfig; + +describe("buildSlackThreadingToolContext", () => { + it("uses top-level replyToMode by default", () => { + const cfg = { + channels: { + slack: { replyToMode: "first" }, + }, + } as ClawdbotConfig; + const result = buildSlackThreadingToolContext({ + cfg, + accountId: null, + context: { ChatType: "channel" }, + }); + expect(result.replyToMode).toBe("first"); + }); + + it("uses dm.replyToMode for direct messages when configured", () => { + const cfg = { + channels: { + slack: { + replyToMode: "off", + dm: { replyToMode: "all" }, + }, + }, + } as ClawdbotConfig; + const result = buildSlackThreadingToolContext({ + cfg, + accountId: null, + context: { ChatType: "direct" }, + }); + expect(result.replyToMode).toBe("all"); + }); + + it("uses top-level replyToMode for channels even when dm.replyToMode is set", () => { + const cfg = { + channels: { + slack: { + replyToMode: "off", + dm: { replyToMode: "all" }, + }, + }, + } as ClawdbotConfig; + const result = buildSlackThreadingToolContext({ + cfg, + accountId: null, + context: { ChatType: "channel" }, + }); + expect(result.replyToMode).toBe("off"); + }); + + it("falls back to top-level when dm.replyToMode is not set", () => { + const cfg = { + channels: { + slack: { + replyToMode: "first", + dm: { enabled: true }, + }, + }, + } as ClawdbotConfig; + const result = buildSlackThreadingToolContext({ + cfg, + accountId: null, + context: { ChatType: "direct" }, + }); + expect(result.replyToMode).toBe("first"); + }); + + it("uses all mode when ThreadLabel is present", () => { + const cfg = { + channels: { + slack: { replyToMode: "off" }, + }, + } as ClawdbotConfig; + const result = buildSlackThreadingToolContext({ + cfg, + accountId: null, + context: { ChatType: "channel", ThreadLabel: "some-thread" }, + }); + expect(result.replyToMode).toBe("all"); + }); + + it("defaults to off when no replyToMode is configured", () => { + const result = buildSlackThreadingToolContext({ + cfg: emptyCfg, + accountId: null, + context: { ChatType: "direct" }, + }); + expect(result.replyToMode).toBe("off"); + }); +}); diff --git a/src/slack/threading-tool-context.ts b/src/slack/threading-tool-context.ts index eeba12277..47efd9f12 100644 --- a/src/slack/threading-tool-context.ts +++ b/src/slack/threading-tool-context.ts @@ -3,7 +3,7 @@ import type { ChannelThreadingToolContext, } from "../channels/plugins/types.js"; import type { ClawdbotConfig } from "../config/config.js"; -import { resolveSlackAccount } from "./accounts.js"; +import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js"; export function buildSlackThreadingToolContext(params: { cfg: ClawdbotConfig; @@ -11,11 +11,11 @@ export function buildSlackThreadingToolContext(params: { context: ChannelThreadingContext; hasRepliedRef?: { value: boolean }; }): ChannelThreadingToolContext { - const configuredReplyToMode = - resolveSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }).replyToMode ?? "off"; + const account = resolveSlackAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + const configuredReplyToMode = resolveSlackReplyToMode(account, params.context.ChatType); const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode; const threadId = params.context.MessageThreadId ?? params.context.ReplyToId; return {