From d80466070a679958aae833f4a42085f8eb92311e Mon Sep 17 00:00:00 2001 From: Haiyi Mei Date: Mon, 13 Jul 2026 15:49:21 +0000 Subject: [PATCH] Add per-thread Slack DM sessions --- docs/user-guide/cli-commands.md | 3 ++ docs/user-guide/slack-setup.md | 16 ++++++++ src/channels/slack/config-schema.ts | 3 ++ .../slack/config-template-contract.ts | 1 + src/channels/slack/config.ts | 7 ++++ .../slack/legacy-config-migration-contract.ts | 2 + src/channels/slack/session-routing.ts | 39 ++++++++++++++----- src/cli.ts | 1 + src/control/commands/bots-cli.ts | 39 +++++++++++++++++++ test/bots-cli.test.ts | 34 ++++++++++++++++ test/config.test.ts | 2 + test/slack-session-routing.test.ts | 39 +++++++++++++++++++ 12 files changed, 176 insertions(+), 10 deletions(-) diff --git a/docs/user-guide/cli-commands.md b/docs/user-guide/cli-commands.md index 89a681a5..a5a04049 100644 --- a/docs/user-guide/cli-commands.md +++ b/docs/user-guide/cli-commands.md @@ -145,6 +145,8 @@ Core commands: - `clisbot bots set-credentials --channel slack [--bot ] --app-token --bot-token [--persist]` - `clisbot bots get-dm-policy --channel [--bot ]` - `clisbot bots set-dm-policy --channel [--bot ] --policy ` +- `clisbot bots get-dm-session-scope --channel slack [--bot ]` +- `clisbot bots set-dm-session-scope --channel slack [--bot ] --scope ` Token aliases: @@ -163,6 +165,7 @@ Important behavior: - `bots enable` and `bots disable` are the fast toggle when you want to keep config but stop or resume handling - `bots remove` fails while any route still references that bot - `bots set-agent` defines the bot-specific fallback agent +- Slack `dmSessionScope: thread` gives each DM root thread its own agent session; `peer` keeps one session per DM peer - if no bot-specific fallback agent is set, routing falls back to the app default agent - if `--agent` is passed on `bots add`, it binds an existing agent - if `--cli` and `--bot-type` are passed on `bots add`, the command creates and bootstraps a new agent for that bot diff --git a/docs/user-guide/slack-setup.md b/docs/user-guide/slack-setup.md index dc304b34..89a5da51 100644 --- a/docs/user-guide/slack-setup.md +++ b/docs/user-guide/slack-setup.md @@ -222,6 +222,22 @@ After the route is bound, `/whoami` is also a quick session check because it shows `sessionId` plus whether that value is already persisted for that conversation. +### Optional: isolate Slack DMs by root thread + +Slack DMs keep one agent session per peer by default. To give each DM root thread its own session while preserving context inside that thread, run: + +```bash +clisbot bots set-dm-session-scope --channel slack --bot default --scope thread +``` + +Inspect the effective setting with: + +```bash +clisbot bots get-dm-session-scope --channel slack --bot default +``` + +Set the scope back to `peer` to restore one session per DM peer. Changing the scope does not delete existing session state; it only changes how later Slack DM messages select a session key. + ## Step 6: Add The Bot To A Public Channel Invite the bot into the target Slack channel. diff --git a/src/channels/slack/config-schema.ts b/src/channels/slack/config-schema.ts index a4595ce3..ad1725d5 100644 --- a/src/channels/slack/config-schema.ts +++ b/src/channels/slack/config-schema.ts @@ -23,6 +23,7 @@ const slackChannelSchemaContract = defineChannelSchemaContract({ ackReaction: z.string().optional(), typingReaction: z.string().optional(), replyToMode: z.enum(["thread", "all"]).optional(), + dmSessionScope: z.enum(["peer", "thread"]).optional(), processingStatus: processingStatusSchema.optional(), groups: z.record(z.string(), params.botRouteSchema).default({}), }); @@ -31,6 +32,7 @@ const slackChannelSchemaContract = defineChannelSchemaContract({ ackReaction: "", typingReaction: "", replyToMode: "thread", + dmSessionScope: "peer", processingStatus: { enabled: true, status: "Working...", @@ -44,6 +46,7 @@ const slackChannelSchemaContract = defineChannelSchemaContract({ ackReaction: z.string().default(""), typingReaction: z.string().default(""), replyToMode: z.enum(["thread", "all"]).default("thread"), + dmSessionScope: z.enum(["peer", "thread"]).default("peer"), processingStatus: processingStatusSchema.default({ enabled: true, status: "Working...", diff --git a/src/channels/slack/config-template-contract.ts b/src/channels/slack/config-template-contract.ts index 53e3d7f3..2aed4b83 100644 --- a/src/channels/slack/config-template-contract.ts +++ b/src/channels/slack/config-template-contract.ts @@ -18,6 +18,7 @@ const slackChannelTemplateContract = defineChannelTemplateContract({ ackReaction: "", typingReaction: "", replyToMode: "thread", + dmSessionScope: "peer", processingStatus: { enabled: true, status: "Working...", diff --git a/src/channels/slack/config.ts b/src/channels/slack/config.ts index bb47e9fe..f8b67574 100644 --- a/src/channels/slack/config.ts +++ b/src/channels/slack/config.ts @@ -17,6 +17,8 @@ export type SlackBotCredentialConfig = { botToken: string; }; +export type SlackDmSessionScope = "peer" | "thread"; + type SlackProcessingStatusConfig = { enabled: boolean; status: string; @@ -29,6 +31,7 @@ type SlackProviderDefaults = ChannelProviderDefaults & { ackReaction?: string; typingReaction?: string; replyToMode?: "thread" | "all"; + dmSessionScope?: SlackDmSessionScope; processingStatus?: SlackProcessingStatusConfig; }; @@ -37,6 +40,7 @@ type SlackBotRecord = ChannelBotRecord & { ackReaction?: string; typingReaction?: string; replyToMode?: "thread" | "all"; + dmSessionScope?: SlackDmSessionScope; processingStatus?: SlackProcessingStatusConfig; }; @@ -48,6 +52,7 @@ export type ResolvedSlackBotConfig = ResolvedChannelBotConfig & { ackReaction: string; typingReaction: string; replyToMode: "thread" | "all"; + dmSessionScope: SlackDmSessionScope; processingStatus: SlackProcessingStatusConfig; }; @@ -79,6 +84,8 @@ export function resolveSlackBotConfig( ackReaction: botConfig.ackReaction ?? providerDefaults.ackReaction ?? "", typingReaction: botConfig.typingReaction ?? providerDefaults.typingReaction ?? "", replyToMode: botConfig.replyToMode ?? providerDefaults.replyToMode ?? "thread", + dmSessionScope: + botConfig.dmSessionScope ?? providerDefaults.dmSessionScope ?? "peer", processingStatus: botConfig.processingStatus ?? providerDefaults.processingStatus ?? { diff --git a/src/channels/slack/legacy-config-migration-contract.ts b/src/channels/slack/legacy-config-migration-contract.ts index 44fc6c05..2d60e093 100644 --- a/src/channels/slack/legacy-config-migration-contract.ts +++ b/src/channels/slack/legacy-config-migration-contract.ts @@ -8,6 +8,7 @@ const slackLegacyConfigMigrationContract = { "ackReaction", "typingReaction", "replyToMode", + "dmSessionScope", "processingStatus", ], botFields: [ @@ -17,6 +18,7 @@ const slackLegacyConfigMigrationContract = { "ackReaction", "typingReaction", "replyToMode", + "dmSessionScope", "processingStatus", ], legacyGroupKey: "channels", diff --git a/src/channels/slack/session-routing.ts b/src/channels/slack/session-routing.ts index 19f8cd15..3a358837 100644 --- a/src/channels/slack/session-routing.ts +++ b/src/channels/slack/session-routing.ts @@ -5,6 +5,7 @@ import { buildAgentPeerSessionKey, } from "../../agents/session/session-key.ts"; import { resolveProvidedBotId } from "../../config/channels/channel-bot-records.ts"; +import { resolveSlackBotConfig } from "./config.ts"; export type SlackConversationTarget = { agentId: string; @@ -36,18 +37,36 @@ export function resolveSlackConversationTarget(params: { const botId = resolveProvidedBotId(params) ?? "default"; if (params.conversationKind === "dm") { - return { + const parentSessionKey = buildAgentPeerSessionKey({ agentId: params.agentId, - sessionKey: buildAgentPeerSessionKey({ + mainKey: sessionConfig.mainKey, + channel: "slack", + botId, + peerKind: "dm", + peerId: params.userId ?? params.channelId, + dmScope: sessionConfig.dmScope, + identityLinks: sessionConfig.identityLinks, + }); + const dmSessionScope = resolveSlackBotConfig( + params.loadedConfig.raw.bots.slack, + botId, + ).dmSessionScope; + const threadId = (params.threadTs ?? "").trim() || + (params.replyToMode === "thread" ? (params.messageTs ?? "").trim() : ""); + + if (dmSessionScope === "thread" && threadId) { + return { agentId: params.agentId, - mainKey: sessionConfig.mainKey, - channel: "slack", - botId, - peerKind: "dm", - peerId: params.userId ?? params.channelId, - dmScope: sessionConfig.dmScope, - identityLinks: sessionConfig.identityLinks, - }), + sessionKey: appendThreadSessionKey(parentSessionKey, threadId), + mainSessionKey, + parentSessionKey, + threadId, + }; + } + + return { + agentId: params.agentId, + sessionKey: parentSessionKey, mainSessionKey, }; } diff --git a/src/cli.ts b/src/cli.ts index d0679ed5..50fdeeb9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -129,6 +129,7 @@ const ROOT_COMMAND_TREE: CommandTreeSpec = { " get-agent|set-agent|clear-agent", " get-credentials-source|set-credentials", " get-dm-policy|set-dm-policy", + " get-dm-session-scope|set-dm-session-scope (Slack)", " See `bots --help` for examples and credential behavior.", ], passthroughArgs: true, diff --git a/src/control/commands/bots-cli.ts b/src/control/commands/bots-cli.ts index 07574b6a..68512936 100644 --- a/src/control/commands/bots-cli.ts +++ b/src/control/commands/bots-cli.ts @@ -108,6 +108,8 @@ function renderBotsHelp() { ...renderZaloPersonalLifecycleHelpLines().map((line) => ` ${line}`), ` ${renderCliCommand(`bots get-dm-policy --channel ${channelName} [--bot ]`)}`, ` ${renderCliCommand(`bots set-dm-policy --channel ${channelName} [--bot ] --policy `)}`, + ` ${renderCliCommand("bots get-dm-session-scope --channel slack [--bot ]")}`, + ` ${renderCliCommand("bots set-dm-session-scope --channel slack [--bot ] --scope ")}`, "", "Notes:", " - `add` creates only; if the bot already exists, use `set-agent`, `set-credentials`, or another `set-` command", @@ -117,6 +119,7 @@ function renderBotsHelp() { " - prefer app, agent, or route timezone first; bot timezone is an advanced concrete-bot fallback", " - raw token input without `--persist` requires a running clisbot runtime", " - Zalo Personal login is QR-only; `--qr-path` only saves the QR image while the QR is still printed in the console", + " - Slack `dmSessionScope: thread` gives each DM root thread its own agent session; `peer` keeps one session per DM peer", " - normal shared-route admission now follows the bot's `group:*` default plus any exact `group:` override", ` - ${renderSupportedChannelsNote()}`, ].join("\n"); @@ -404,6 +407,34 @@ async function getOrSetBotPolicy(args: string[], action: string) { throw new Error(renderBotsHelp()); } +async function getOrSetSlackDmSessionScope(args: string[], action: string) { + const provider = parseProvider(args); + if (provider !== "slack") { + throw new Error("DM session scope is supported only for Slack bots."); + } + + const botId = getBotId(args); + const { config, configPath } = await readEditableConfig(getEditableConfigPath()); + const bot = requireChannelBotRecord(config, provider, botId); + const defaults = getChannelProviderDefaults(config, provider); + + if (action === "get-dm-session-scope") { + const scope = bot.dmSessionScope ?? defaults.dmSessionScope ?? "peer"; + console.log(`${provider}/${botId} dmSessionScope: ${scope}`); + console.log(`config: ${configPath}`); + return; + } + + const scope = parseOptionValue(args, "--scope"); + if (scope !== "peer" && scope !== "thread") { + throw new Error(renderBotsHelp()); + } + bot.dmSessionScope = scope; + await writeEditableConfig(configPath, config); + console.log(`set dmSessionScope for ${provider}/${botId} to ${scope}`); + console.log(`config: ${configPath}`); +} + async function getCredentialSource(args: string[]) { const provider = parseProvider(args); const botId = getBotId(args); @@ -504,6 +535,14 @@ export async function runBotsCli( return; } + if ( + action === "get-dm-session-scope" || + action === "set-dm-session-scope" + ) { + await getOrSetSlackDmSessionScope(args.slice(1), action); + return; + } + if (action === "get-credentials-source") { await getCredentialSource(args.slice(1)); return; diff --git a/test/bots-cli.test.ts b/test/bots-cli.test.ts index 5a517bf1..1ab34d0b 100644 --- a/test/bots-cli.test.ts +++ b/test/bots-cli.test.ts @@ -414,4 +414,38 @@ describe("bots cli", () => { expect(rawConfig.bots.zaloPersonal.default.directMessages["*"].policy).toBe("disabled"); }); + test("gets and sets Slack DM session scope", async () => { + tempDir = mkdtempSync(join(tmpdir(), "clisbot-bots-cli-")); + previousConfigPath = process.env.CLISBOT_CONFIG_PATH; + previousHome = process.env.CLISBOT_HOME; + process.env.CLISBOT_HOME = tempDir; + process.env.CLISBOT_CONFIG_PATH = join(tempDir, "clisbot.json"); + await seedConfig(); + + const output: string[] = []; + console.log = (value?: unknown) => { + output.push(String(value ?? "")); + }; + + await runBotsCli([ + "set-dm-session-scope", + "--channel", + "slack", + "--bot", + "default", + "--scope", + "thread", + ]); + await runBotsCli([ + "get-dm-session-scope", + "--channel", + "slack", + "--bot", + "default", + ]); + + const rawConfig = JSON.parse(readFileSync(process.env.CLISBOT_CONFIG_PATH!, "utf8")); + expect(rawConfig.bots.slack.default.dmSessionScope).toBe("thread"); + expect(output.join("\n")).toContain("slack/default dmSessionScope: thread"); + }); }); diff --git a/test/config.test.ts b/test/config.test.ts index 0f741005..09d15563 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -60,6 +60,7 @@ describe("loadConfig", () => { config.bots.defaults.dmScope = "main"; config.bots.slack.defaults.ackReaction = ":eyes:"; config.bots.slack.defaults.typingReaction = ":hourglass_flowing_sand:"; + config.bots.slack.defaults.dmSessionScope = "thread"; config.bots.slack.defaults.processingStatus = { enabled: true, status: "Summarizing findings...", @@ -131,6 +132,7 @@ describe("loadConfig", () => { expect(loaded.raw.session.identityLinks.alice).toEqual(["slack:U123"]); expect(resolvedSlackBot.ackReaction).toBe(":eyes:"); expect(resolvedSlackBot.typingReaction).toBe(":hourglass_flowing_sand:"); + expect(resolvedSlackBot.dmSessionScope).toBe("thread"); expect(resolvedSlackBot.processingStatus.status).toBe("Summarizing findings..."); expect(resolvedSlackBot.processingStatus.loadingMessages).toEqual([ "Reviewing context...", diff --git a/test/slack-session-routing.test.ts b/test/slack-session-routing.test.ts index 0e090995..d8748f1e 100644 --- a/test/slack-session-routing.test.ts +++ b/test/slack-session-routing.test.ts @@ -105,6 +105,45 @@ describe("Slack conversation target routing", () => { expect(target.mainSessionKey).toBe("agent:default:main"); }); + test("isolates direct messages by root thread when configured", () => { + const loadedConfig = createLoadedConfig(); + loadedConfig.raw.bots.slack.default.dmSessionScope = "thread"; + + const target = resolveSlackConversationTarget({ + loadedConfig, + agentId: "default", + channelId: "D123", + userId: "U123", + messageTs: "1775291908.430140", + threadTs: "1775291908.430139", + conversationKind: "dm", + replyToMode: "thread", + }); + + expect(target.parentSessionKey).toBe("agent:default:slack:dm:u123"); + expect(target.sessionKey).toBe( + "agent:default:slack:dm:u123:thread:1775291908.430139", + ); + expect(target.threadId).toBe("1775291908.430139"); + }); + + test("keeps the peer session when thread scope has no thread id", () => { + const loadedConfig = createLoadedConfig(); + loadedConfig.raw.bots.slack.default.dmSessionScope = "thread"; + + const target = resolveSlackConversationTarget({ + loadedConfig, + agentId: "default", + channelId: "D123", + userId: "U123", + conversationKind: "dm", + replyToMode: "all", + }); + + expect(target.sessionKey).toBe("agent:default:slack:dm:u123"); + expect(target.threadId).toBeUndefined(); + }); + test("resolves shared-route overrides from raw stored ids", () => { const resolved = resolveSlackConversationRoute( createLoadedConfig(),