From 9bf295da485ffc63adda40b60e54b5d958354b91 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 Jan 2026 05:24:18 +0000 Subject: [PATCH] feat: add slack replyToModeByChatType overrides --- CHANGELOG.md | 1 + docs/channels/slack.md | 13 +-- src/auto-reply/reply/reply-threading.test.ts | 43 ++++++---- src/config/types.slack.ts | 7 +- src/config/zod-schema.providers-core.ts | 10 +++ src/slack/accounts.ts | 9 +- src/slack/threading-tool-context.test.ts | 86 ++++++++++++-------- 7 files changed, 111 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bba27755..dfe246fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot - Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster - Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster - Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. +- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu. - Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early. - BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell. - Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 71349d185..1eb88a459 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -362,23 +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`: +### Per-chat-type threading +You can configure different threading behavior per chat type by setting `channels.slack.replyToModeByChatType`: ```json5 { channels: { slack: { replyToMode: "off", // default for channels - dm: { - replyToMode: "all" // DMs always thread - } + replyToModeByChatType: { + direct: "all", // DMs always thread + group: "first" // group DMs/MPIM thread first reply + }, } } } ``` -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. +When a chat-type override is set, it takes precedence for that chat type. Otherwise the top-level `replyToMode` is used. Legacy `channels.slack.dm.replyToMode` is still accepted as a fallback for `direct` when no chat-type override is set. ### Manual threading tags For fine-grained control, use these tags in agent responses: diff --git a/src/auto-reply/reply/reply-threading.test.ts b/src/auto-reply/reply/reply-threading.test.ts index beb4dc292..2a4e9a7f3 100644 --- a/src/auto-reply/reply/reply-threading.test.ts +++ b/src/auto-reply/reply/reply-threading.test.ts @@ -32,7 +32,34 @@ describe("resolveReplyToMode", () => { expect(resolveReplyToMode(cfg, "slack")).toBe("all"); }); - it("uses dm-specific replyToMode for Slack DMs when configured", () => { + it("uses chat-type replyToMode overrides for Slack when configured", () => { + const cfg = { + channels: { + slack: { + replyToMode: "off", + replyToModeByChatType: { direct: "all", group: "first" }, + }, + }, + } as ClawdbotConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all"); + expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off"); + expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off"); + }); + + it("falls back to top-level replyToMode when no chat-type override is set", () => { + const cfg = { + channels: { + slack: { + replyToMode: "first", + }, + }, + } as ClawdbotConfig; + expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first"); + expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first"); + }); + + it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => { const cfg = { channels: { slack: { @@ -43,20 +70,6 @@ describe("resolveReplyToMode", () => { } 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"); }); }); diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index 2437cb9e5..f0e9e1f21 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -17,7 +17,7 @@ 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. */ + /** @deprecated Prefer channels.slack.replyToModeByChatType.direct. */ replyToMode?: ReplyToMode; }; @@ -119,6 +119,11 @@ export type SlackAccountConfig = { reactionAllowlist?: Array; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; + /** + * Optional per-chat-type reply threading overrides. + * Example: { direct: "all", group: "first", channel: "off" }. + */ + replyToModeByChatType?: Partial>; /** Thread session behavior. */ thread?: SlackThreadConfig; actions?: SlackActionConfig; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 68806c61f..1f687253c 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -248,6 +248,7 @@ export const SlackDmSchema = z allowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupEnabled: z.boolean().optional(), groupChannels: z.array(z.union([z.string(), z.number()])).optional(), + replyToMode: ReplyToModeSchema.optional(), }) .strict() .superRefine((value, ctx) => { @@ -280,6 +281,14 @@ export const SlackThreadSchema = z }) .strict(); +const SlackReplyToModeByChatTypeSchema = z + .object({ + direct: ReplyToModeSchema.optional(), + group: ReplyToModeSchema.optional(), + channel: ReplyToModeSchema.optional(), + }) + .strict(); + export const SlackAccountSchema = z .object({ name: z.string().optional(), @@ -307,6 +316,7 @@ export const SlackAccountSchema = z reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(), reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), replyToMode: ReplyToModeSchema.optional(), + replyToModeByChatType: SlackReplyToModeByChatTypeSchema.optional(), thread: SlackThreadSchema.optional(), actions: z .object({ diff --git a/src/slack/accounts.ts b/src/slack/accounts.ts index 9929dcb3c..3b61ea614 100644 --- a/src/slack/accounts.ts +++ b/src/slack/accounts.ts @@ -1,5 +1,6 @@ import type { ClawdbotConfig } from "../config/config.js"; import type { SlackAccountConfig } from "../config/types.js"; +import { normalizeChatType } from "../channels/chat-type.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; @@ -20,6 +21,7 @@ export type ResolvedSlackAccount = { reactionNotifications?: SlackAccountConfig["reactionNotifications"]; reactionAllowlist?: SlackAccountConfig["reactionAllowlist"]; replyToMode?: SlackAccountConfig["replyToMode"]; + replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"]; actions?: SlackAccountConfig["actions"]; slashCommand?: SlackAccountConfig["slashCommand"]; dm?: SlackAccountConfig["dm"]; @@ -95,6 +97,7 @@ export function resolveSlackAccount(params: { reactionNotifications: merged.reactionNotifications, reactionAllowlist: merged.reactionAllowlist, replyToMode: merged.replyToMode, + replyToModeByChatType: merged.replyToModeByChatType, actions: merged.actions, slashCommand: merged.slashCommand, dm: merged.dm, @@ -112,7 +115,11 @@ export function resolveSlackReplyToMode( account: ResolvedSlackAccount, chatType?: string | null, ): "off" | "first" | "all" { - if (chatType === "direct" && account.dm?.replyToMode !== undefined) { + const normalized = normalizeChatType(chatType ?? undefined); + if (normalized && account.replyToModeByChatType?.[normalized] !== undefined) { + return account.replyToModeByChatType[normalized] ?? "off"; + } + if (normalized === "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 index 5a3ae7a03..547d9ecce 100644 --- a/src/slack/threading-tool-context.test.ts +++ b/src/slack/threading-tool-context.test.ts @@ -20,7 +20,57 @@ describe("buildSlackThreadingToolContext", () => { expect(result.replyToMode).toBe("first"); }); - it("uses dm.replyToMode for direct messages when configured", () => { + it("uses chat-type replyToMode overrides for direct messages when configured", () => { + const cfg = { + channels: { + slack: { + replyToMode: "off", + replyToModeByChatType: { direct: "all" }, + }, + }, + } as ClawdbotConfig; + const result = buildSlackThreadingToolContext({ + cfg, + accountId: null, + context: { ChatType: "direct" }, + }); + expect(result.replyToMode).toBe("all"); + }); + + it("uses top-level replyToMode for channels when no channel override is set", () => { + const cfg = { + channels: { + slack: { + replyToMode: "off", + replyToModeByChatType: { direct: "all" }, + }, + }, + } as ClawdbotConfig; + const result = buildSlackThreadingToolContext({ + cfg, + accountId: null, + context: { ChatType: "channel" }, + }); + expect(result.replyToMode).toBe("off"); + }); + + it("falls back to top-level when no chat-type override is set", () => { + const cfg = { + channels: { + slack: { + replyToMode: "first", + }, + }, + } as ClawdbotConfig; + const result = buildSlackThreadingToolContext({ + cfg, + accountId: null, + context: { ChatType: "direct" }, + }); + expect(result.replyToMode).toBe("first"); + }); + + it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => { const cfg = { channels: { slack: { @@ -37,40 +87,6 @@ describe("buildSlackThreadingToolContext", () => { 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: {