Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/user-guide/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ Core commands:
- `clisbot bots set-credentials --channel slack [--bot <id>] --app-token <ENV_NAME|${ENV_NAME}|literal> --bot-token <ENV_NAME|${ENV_NAME}|literal> [--persist]`
- `clisbot bots get-dm-policy --channel <channel-name> [--bot <id>]`
- `clisbot bots set-dm-policy --channel <channel-name> [--bot <id>] --policy <disabled|pairing|allowlist|open>`
- `clisbot bots get-dm-session-scope --channel slack [--bot <id>]`
- `clisbot bots set-dm-session-scope --channel slack [--bot <id>] --scope <peer|thread>`

Token aliases:

Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions docs/user-guide/slack-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/channels/slack/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}),
});
Expand All @@ -31,6 +32,7 @@ const slackChannelSchemaContract = defineChannelSchemaContract({
ackReaction: "",
typingReaction: "",
replyToMode: "thread",
dmSessionScope: "peer",
processingStatus: {
enabled: true,
status: "Working...",
Expand All @@ -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...",
Expand Down
1 change: 1 addition & 0 deletions src/channels/slack/config-template-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const slackChannelTemplateContract = defineChannelTemplateContract({
ackReaction: "",
typingReaction: "",
replyToMode: "thread",
dmSessionScope: "peer",
processingStatus: {
enabled: true,
status: "Working...",
Expand Down
7 changes: 7 additions & 0 deletions src/channels/slack/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type SlackBotCredentialConfig = {
botToken: string;
};

export type SlackDmSessionScope = "peer" | "thread";

type SlackProcessingStatusConfig = {
enabled: boolean;
status: string;
Expand All @@ -29,6 +31,7 @@ type SlackProviderDefaults = ChannelProviderDefaults & {
ackReaction?: string;
typingReaction?: string;
replyToMode?: "thread" | "all";
dmSessionScope?: SlackDmSessionScope;
processingStatus?: SlackProcessingStatusConfig;
};

Expand All @@ -37,6 +40,7 @@ type SlackBotRecord = ChannelBotRecord & {
ackReaction?: string;
typingReaction?: string;
replyToMode?: "thread" | "all";
dmSessionScope?: SlackDmSessionScope;
processingStatus?: SlackProcessingStatusConfig;
};

Expand All @@ -48,6 +52,7 @@ export type ResolvedSlackBotConfig = ResolvedChannelBotConfig & {
ackReaction: string;
typingReaction: string;
replyToMode: "thread" | "all";
dmSessionScope: SlackDmSessionScope;
processingStatus: SlackProcessingStatusConfig;
};

Expand Down Expand Up @@ -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 ?? {
Expand Down
2 changes: 2 additions & 0 deletions src/channels/slack/legacy-config-migration-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const slackLegacyConfigMigrationContract = {
"ackReaction",
"typingReaction",
"replyToMode",
"dmSessionScope",
"processingStatus",
],
botFields: [
Expand All @@ -17,6 +18,7 @@ const slackLegacyConfigMigrationContract = {
"ackReaction",
"typingReaction",
"replyToMode",
"dmSessionScope",
"processingStatus",
],
legacyGroupKey: "channels",
Expand Down
39 changes: 29 additions & 10 deletions src/channels/slack/session-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
}
Expand Down
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const ROOT_COMMAND_TREE: CommandTreeSpec<ParsedCliCommand> = {
" 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,
Expand Down
39 changes: 39 additions & 0 deletions src/control/commands/bots-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ function renderBotsHelp() {
...renderZaloPersonalLifecycleHelpLines().map((line) => ` ${line}`),
` ${renderCliCommand(`bots get-dm-policy --channel ${channelName} [--bot <id>]`)}`,
` ${renderCliCommand(`bots set-dm-policy --channel ${channelName} [--bot <id>] --policy <disabled|pairing|allowlist|open>`)}`,
` ${renderCliCommand("bots get-dm-session-scope --channel slack [--bot <id>]")}`,
` ${renderCliCommand("bots set-dm-session-scope --channel slack [--bot <id>] --scope <peer|thread>")}`,
"",
"Notes:",
" - `add` creates only; if the bot already exists, use `set-agent`, `set-credentials`, or another `set-<key>` command",
Expand All @@ -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:<id>` override",
` - ${renderSupportedChannelsNote()}`,
].join("\n");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions test/bots-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
2 changes: 2 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down Expand Up @@ -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...",
Expand Down
39 changes: 39 additions & 0 deletions test/slack-session-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down