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.
This commit is contained in:
@@ -304,7 +304,8 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
|
|||||||
"policy": "pairing",
|
"policy": "pairing",
|
||||||
"allowFrom": ["U123", "U456", "*"],
|
"allowFrom": ["U123", "U456", "*"],
|
||||||
"groupEnabled": false,
|
"groupEnabled": false,
|
||||||
"groupChannels": ["G123"]
|
"groupChannels": ["G123"],
|
||||||
|
"replyToMode": "all"
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
"C123": { "allow": true, "requireMention": true },
|
"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`).
|
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
|
### Manual threading tags
|
||||||
For fine-grained control, use these tags in agent responses:
|
For fine-grained control, use these tags in agent responses:
|
||||||
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
readStringParam,
|
readStringParam,
|
||||||
resolveDefaultSlackAccountId,
|
resolveDefaultSlackAccountId,
|
||||||
resolveSlackAccount,
|
resolveSlackAccount,
|
||||||
|
resolveSlackReplyToMode,
|
||||||
resolveSlackGroupRequireMention,
|
resolveSlackGroupRequireMention,
|
||||||
buildSlackThreadingToolContext,
|
buildSlackThreadingToolContext,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
@@ -162,8 +163,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||||
allowTagsWhenOff: true,
|
allowTagsWhenOff: true,
|
||||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export async function runReplyAgent(params: {
|
|||||||
followupRun.run.config,
|
followupRun.run.config,
|
||||||
replyToChannel,
|
replyToChannel,
|
||||||
sessionCtx.AccountId,
|
sessionCtx.AccountId,
|
||||||
|
sessionCtx.ChatType,
|
||||||
);
|
);
|
||||||
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
||||||
const cfg = followupRun.run.config;
|
const cfg = followupRun.run.config;
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export function createFollowupRunner(params: {
|
|||||||
queued.run.config,
|
queued.run.config,
|
||||||
replyToChannel,
|
replyToChannel,
|
||||||
queued.originatingAccountId,
|
queued.originatingAccountId,
|
||||||
|
queued.originatingChatType,
|
||||||
);
|
);
|
||||||
|
|
||||||
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
||||||
|
|||||||
@@ -358,6 +358,7 @@ export async function runPreparedReply(
|
|||||||
originatingTo: ctx.OriginatingTo,
|
originatingTo: ctx.OriginatingTo,
|
||||||
originatingAccountId: ctx.AccountId,
|
originatingAccountId: ctx.AccountId,
|
||||||
originatingThreadId: ctx.MessageThreadId,
|
originatingThreadId: ctx.MessageThreadId,
|
||||||
|
originatingChatType: ctx.ChatType,
|
||||||
run: {
|
run: {
|
||||||
agentId,
|
agentId,
|
||||||
agentDir,
|
agentDir,
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ export type FollowupRun = {
|
|||||||
originatingAccountId?: string;
|
originatingAccountId?: string;
|
||||||
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
|
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
|
||||||
originatingThreadId?: string | number;
|
originatingThreadId?: string | number;
|
||||||
|
/** Chat type for context-aware threading (e.g., DM vs channel). */
|
||||||
|
originatingChatType?: string;
|
||||||
run: {
|
run: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
agentDir: string;
|
agentDir: string;
|
||||||
|
|||||||
@@ -31,6 +31,33 @@ describe("resolveReplyToMode", () => {
|
|||||||
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
|
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
|
||||||
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
|
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", () => {
|
describe("createReplyToModeFilter", () => {
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ export function resolveReplyToMode(
|
|||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
channel?: OriginatingChannelType,
|
channel?: OriginatingChannelType,
|
||||||
accountId?: string | null,
|
accountId?: string | null,
|
||||||
|
chatType?: string | null,
|
||||||
): ReplyToMode {
|
): ReplyToMode {
|
||||||
const provider = normalizeChannelId(channel);
|
const provider = normalizeChannelId(channel);
|
||||||
if (!provider) return "all";
|
if (!provider) return "all";
|
||||||
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
|
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
|
||||||
cfg,
|
cfg,
|
||||||
accountId,
|
accountId,
|
||||||
|
chatType,
|
||||||
});
|
});
|
||||||
return resolved ?? "all";
|
return resolved ?? "all";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ClawdbotConfig } from "../config/config.js";
|
|||||||
import { resolveDiscordAccount } from "../discord/accounts.js";
|
import { resolveDiscordAccount } from "../discord/accounts.js";
|
||||||
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
||||||
import { resolveSignalAccount } from "../signal/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 { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
||||||
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
@@ -224,8 +224,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||||
allowTagsWhenOff: true,
|
allowTagsWhenOff: true,
|
||||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export type ChannelThreadingAdapter = {
|
|||||||
resolveReplyToMode?: (params: {
|
resolveReplyToMode?: (params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
chatType?: string | null;
|
||||||
}) => "off" | "first" | "all";
|
}) => "off" | "first" | "all";
|
||||||
allowTagsWhenOff?: boolean;
|
allowTagsWhenOff?: boolean;
|
||||||
buildToolContext?: (params: {
|
buildToolContext?: (params: {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type SlackDmConfig = {
|
|||||||
groupEnabled?: boolean;
|
groupEnabled?: boolean;
|
||||||
/** Optional allowlist for group DM channels (ids or slugs). */
|
/** Optional allowlist for group DM channels (ids or slugs). */
|
||||||
groupChannels?: Array<string | number>;
|
groupChannels?: Array<string | number>;
|
||||||
|
/** Control reply threading for DMs (off|first|all). Overrides top-level replyToMode for DMs. */
|
||||||
|
replyToMode?: ReplyToMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SlackChannelConfig = {
|
export type SlackChannelConfig = {
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export {
|
|||||||
listSlackAccountIds,
|
listSlackAccountIds,
|
||||||
resolveDefaultSlackAccountId,
|
resolveDefaultSlackAccountId,
|
||||||
resolveSlackAccount,
|
resolveSlackAccount,
|
||||||
|
resolveSlackReplyToMode,
|
||||||
type ResolvedSlackAccount,
|
type ResolvedSlackAccount,
|
||||||
} from "../slack/accounts.js";
|
} from "../slack/accounts.js";
|
||||||
export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js";
|
export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js";
|
||||||
|
|||||||
@@ -107,3 +107,13 @@ export function listEnabledSlackAccounts(cfg: ClawdbotConfig): ResolvedSlackAcco
|
|||||||
.map((accountId) => resolveSlackAccount({ cfg, accountId }))
|
.map((accountId) => resolveSlackAccount({ cfg, accountId }))
|
||||||
.filter((account) => account.enabled);
|
.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";
|
||||||
|
}
|
||||||
|
|||||||
96
src/slack/threading-tool-context.test.ts
Normal file
96
src/slack/threading-tool-context.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import type {
|
|||||||
ChannelThreadingToolContext,
|
ChannelThreadingToolContext,
|
||||||
} from "../channels/plugins/types.js";
|
} from "../channels/plugins/types.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { resolveSlackAccount } from "./accounts.js";
|
import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js";
|
||||||
|
|
||||||
export function buildSlackThreadingToolContext(params: {
|
export function buildSlackThreadingToolContext(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
@@ -11,11 +11,11 @@ export function buildSlackThreadingToolContext(params: {
|
|||||||
context: ChannelThreadingContext;
|
context: ChannelThreadingContext;
|
||||||
hasRepliedRef?: { value: boolean };
|
hasRepliedRef?: { value: boolean };
|
||||||
}): ChannelThreadingToolContext {
|
}): ChannelThreadingToolContext {
|
||||||
const configuredReplyToMode =
|
const account = resolveSlackAccount({
|
||||||
resolveSlackAccount({
|
cfg: params.cfg,
|
||||||
cfg: params.cfg,
|
accountId: params.accountId,
|
||||||
accountId: params.accountId,
|
});
|
||||||
}).replyToMode ?? "off";
|
const configuredReplyToMode = resolveSlackReplyToMode(account, params.context.ChatType);
|
||||||
const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode;
|
const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode;
|
||||||
const threadId = params.context.MessageThreadId ?? params.context.ReplyToId;
|
const threadId = params.context.MessageThreadId ?? params.context.ReplyToId;
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user