feat: add slack replyToModeByChatType overrides
This commit is contained in:
@@ -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
|
- 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
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||||
|
|||||||
@@ -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`).
|
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
||||||
|
|
||||||
### DM-specific threading
|
### Per-chat-type threading
|
||||||
You can configure different threading behavior for DMs vs channels by setting `channels.slack.dm.replyToMode`:
|
You can configure different threading behavior per chat type by setting `channels.slack.replyToModeByChatType`:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
channels: {
|
||||||
slack: {
|
slack: {
|
||||||
replyToMode: "off", // default for channels
|
replyToMode: "off", // default for channels
|
||||||
dm: {
|
replyToModeByChatType: {
|
||||||
replyToMode: "all" // DMs always thread
|
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
|
### Manual threading tags
|
||||||
For fine-grained control, use these tags in agent responses:
|
For fine-grained control, use these tags in agent responses:
|
||||||
|
|||||||
@@ -32,7 +32,34 @@ describe("resolveReplyToMode", () => {
|
|||||||
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
|
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 = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
slack: {
|
slack: {
|
||||||
@@ -43,20 +70,6 @@ describe("resolveReplyToMode", () => {
|
|||||||
} as ClawdbotConfig;
|
} as ClawdbotConfig;
|
||||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ 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. */
|
/** @deprecated Prefer channels.slack.replyToModeByChatType.direct. */
|
||||||
replyToMode?: ReplyToMode;
|
replyToMode?: ReplyToMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,6 +119,11 @@ export type SlackAccountConfig = {
|
|||||||
reactionAllowlist?: Array<string | number>;
|
reactionAllowlist?: Array<string | number>;
|
||||||
/** Control reply threading when reply tags are present (off|first|all). */
|
/** Control reply threading when reply tags are present (off|first|all). */
|
||||||
replyToMode?: ReplyToMode;
|
replyToMode?: ReplyToMode;
|
||||||
|
/**
|
||||||
|
* Optional per-chat-type reply threading overrides.
|
||||||
|
* Example: { direct: "all", group: "first", channel: "off" }.
|
||||||
|
*/
|
||||||
|
replyToModeByChatType?: Partial<Record<"direct" | "group" | "channel", ReplyToMode>>;
|
||||||
/** Thread session behavior. */
|
/** Thread session behavior. */
|
||||||
thread?: SlackThreadConfig;
|
thread?: SlackThreadConfig;
|
||||||
actions?: SlackActionConfig;
|
actions?: SlackActionConfig;
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ export const SlackDmSchema = z
|
|||||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
groupEnabled: z.boolean().optional(),
|
groupEnabled: z.boolean().optional(),
|
||||||
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
|
replyToMode: ReplyToModeSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
@@ -280,6 +281,14 @@ export const SlackThreadSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const SlackReplyToModeByChatTypeSchema = z
|
||||||
|
.object({
|
||||||
|
direct: ReplyToModeSchema.optional(),
|
||||||
|
group: ReplyToModeSchema.optional(),
|
||||||
|
channel: ReplyToModeSchema.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export const SlackAccountSchema = z
|
export const SlackAccountSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
@@ -307,6 +316,7 @@ export const SlackAccountSchema = z
|
|||||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||||
replyToMode: ReplyToModeSchema.optional(),
|
replyToMode: ReplyToModeSchema.optional(),
|
||||||
|
replyToModeByChatType: SlackReplyToModeByChatTypeSchema.optional(),
|
||||||
thread: SlackThreadSchema.optional(),
|
thread: SlackThreadSchema.optional(),
|
||||||
actions: z
|
actions: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import type { SlackAccountConfig } from "../config/types.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 { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||||
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ export type ResolvedSlackAccount = {
|
|||||||
reactionNotifications?: SlackAccountConfig["reactionNotifications"];
|
reactionNotifications?: SlackAccountConfig["reactionNotifications"];
|
||||||
reactionAllowlist?: SlackAccountConfig["reactionAllowlist"];
|
reactionAllowlist?: SlackAccountConfig["reactionAllowlist"];
|
||||||
replyToMode?: SlackAccountConfig["replyToMode"];
|
replyToMode?: SlackAccountConfig["replyToMode"];
|
||||||
|
replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"];
|
||||||
actions?: SlackAccountConfig["actions"];
|
actions?: SlackAccountConfig["actions"];
|
||||||
slashCommand?: SlackAccountConfig["slashCommand"];
|
slashCommand?: SlackAccountConfig["slashCommand"];
|
||||||
dm?: SlackAccountConfig["dm"];
|
dm?: SlackAccountConfig["dm"];
|
||||||
@@ -95,6 +97,7 @@ export function resolveSlackAccount(params: {
|
|||||||
reactionNotifications: merged.reactionNotifications,
|
reactionNotifications: merged.reactionNotifications,
|
||||||
reactionAllowlist: merged.reactionAllowlist,
|
reactionAllowlist: merged.reactionAllowlist,
|
||||||
replyToMode: merged.replyToMode,
|
replyToMode: merged.replyToMode,
|
||||||
|
replyToModeByChatType: merged.replyToModeByChatType,
|
||||||
actions: merged.actions,
|
actions: merged.actions,
|
||||||
slashCommand: merged.slashCommand,
|
slashCommand: merged.slashCommand,
|
||||||
dm: merged.dm,
|
dm: merged.dm,
|
||||||
@@ -112,7 +115,11 @@ export function resolveSlackReplyToMode(
|
|||||||
account: ResolvedSlackAccount,
|
account: ResolvedSlackAccount,
|
||||||
chatType?: string | null,
|
chatType?: string | null,
|
||||||
): "off" | "first" | "all" {
|
): "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.dm.replyToMode;
|
||||||
}
|
}
|
||||||
return account.replyToMode ?? "off";
|
return account.replyToMode ?? "off";
|
||||||
|
|||||||
@@ -20,7 +20,57 @@ describe("buildSlackThreadingToolContext", () => {
|
|||||||
expect(result.replyToMode).toBe("first");
|
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 = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
slack: {
|
slack: {
|
||||||
@@ -37,40 +87,6 @@ describe("buildSlackThreadingToolContext", () => {
|
|||||||
expect(result.replyToMode).toBe("all");
|
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", () => {
|
it("uses all mode when ThreadLabel is present", () => {
|
||||||
const cfg = {
|
const cfg = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
Reference in New Issue
Block a user