refactor: drop autoReply, add topic requireMention

Co-authored-by: kitze <kristijan.mkd@gmail.com>
This commit is contained in:
Peter Steinberger
2026-01-07 11:59:48 +01:00
parent 25edac96cf
commit 1011640a13
13 changed files with 80 additions and 76 deletions

View File

@@ -15,6 +15,7 @@
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
### Fixes
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.

View File

@@ -468,12 +468,16 @@ Set `telegram.enabled: false` to disable automatic startup.
dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["tg:123456789"], // optional; "open" requires ["*"]
groups: {
"*": { requireMention: true, autoReply: false },
"*": { requireMention: true },
"-1001234567890": {
allowFrom: ["@admin"],
systemPrompt: "Keep answers brief.",
topics: {
"99": { skills: ["search"], systemPrompt: "Stay on topic." }
"99": {
requireMention: false,
skills: ["search"],
systemPrompt: "Stay on topic."
}
}
}
},
@@ -580,7 +584,7 @@ Slack runs in Socket Mode and requires both a bot token and app token:
C123: { allow: true, requireMention: true },
"#general": {
allow: true,
autoReply: false,
requireMention: true,
users: ["U123"],
skills: ["docs"],
systemPrompt: "Short answers only."

View File

@@ -202,8 +202,7 @@ Notes:
requireMention: true,
users: ["987654321098765432"],
skills: ["search", "docs"],
systemPrompt: "Keep answers short.",
autoReply: false
systemPrompt: "Keep answers short."
}
}
}
@@ -227,7 +226,6 @@ Ack reactions are controlled globally via `messages.ackReaction` +
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
- `guilds.<id>.channels.<channel>.allow`: allow/deny the channel when `groupPolicy="allowlist"`.
- `guilds.<id>.channels.<channel>.requireMention`: mention gating for the channel.
- `guilds.<id>.channels.<channel>.autoReply`: if `true`, reply to all messages (overrides `requireMention`).
- `guilds.<id>.channels.<channel>.users`: optional per-channel user allowlist.
- `guilds.<id>.channels.<channel>.skills`: skill filter (omit = all skills, empty = none).
- `guilds.<id>.channels.<channel>.systemPrompt`: extra system prompt for the channel (combined with channel topic).

View File

@@ -159,7 +159,7 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
"C123": { "allow": true, "requireMention": true },
"#general": {
"allow": true,
"autoReply": false,
"requireMention": true,
"users": ["U123"],
"skills": ["search", "docs"],
"systemPrompt": "Keep answers short."
@@ -212,7 +212,6 @@ Ack reactions are controlled globally via `messages.ackReaction` +
Channel options (`slack.channels.<id>` or `slack.channels.<name>`):
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
- `requireMention`: mention gating for the channel.
- `autoReply`: if `true`, reply to every message (overrides `requireMention`).
- `users`: optional per-channel user allowlist.
- `skills`: skill filter (omit = all skills, empty = none).
- `systemPrompt`: extra system prompt for the channel (combined with topic/purpose).

View File

@@ -117,12 +117,12 @@ Provider options:
- `telegram.groupAllowFrom`: group sender allowlist (ids/usernames).
- `telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
- `telegram.groups.<id>.requireMention`: mention gating default.
- `telegram.groups.<id>.autoReply`: reply to every message (overrides `requireMention`).
- `telegram.groups.<id>.skills`: skill filter (omit = all skills, empty = none).
- `telegram.groups.<id>.allowFrom`: per-group sender allowlist override.
- `telegram.groups.<id>.systemPrompt`: extra system prompt for the group.
- `telegram.groups.<id>.enabled`: disable the group when `false`.
- `telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
- `telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
- `telegram.replyToMode`: `off | first | all`.
- `telegram.textChunkLimit`: outbound chunk size (chars).
- `telegram.streamMode`: `off | partial | block` (draft streaming).

View File

@@ -44,11 +44,7 @@ function parseTelegramGroupId(value?: string | null) {
return { chatId: raw, topicId: undefined };
}
function hasOwn(obj: unknown, key: string): boolean {
return Boolean(obj && typeof obj === "object" && Object.hasOwn(obj, key));
}
function resolveTelegramAutoReply(params: {
function resolveTelegramRequireMention(params: {
cfg: ClawdbotConfig;
chatId?: string;
topicId?: string;
@@ -61,17 +57,17 @@ function resolveTelegramAutoReply(params: {
topicId && groupConfig?.topics ? groupConfig.topics[topicId] : undefined;
const defaultTopicConfig =
topicId && groupDefault?.topics ? groupDefault.topics[topicId] : undefined;
if (hasOwn(topicConfig, "autoReply")) {
return (topicConfig as { autoReply?: boolean }).autoReply;
if (typeof topicConfig?.requireMention === "boolean") {
return topicConfig.requireMention;
}
if (hasOwn(defaultTopicConfig, "autoReply")) {
return (defaultTopicConfig as { autoReply?: boolean }).autoReply;
if (typeof defaultTopicConfig?.requireMention === "boolean") {
return defaultTopicConfig.requireMention;
}
if (hasOwn(groupConfig, "autoReply")) {
return (groupConfig as { autoReply?: boolean }).autoReply;
if (typeof groupConfig?.requireMention === "boolean") {
return groupConfig.requireMention;
}
if (hasOwn(groupDefault, "autoReply")) {
return (groupDefault as { autoReply?: boolean }).autoReply;
if (typeof groupDefault?.requireMention === "boolean") {
return groupDefault.requireMention;
}
return undefined;
}
@@ -107,8 +103,12 @@ export function resolveGroupRequireMention(params: {
const groupSpace = ctx.GroupSpace?.trim();
if (provider === "telegram") {
const { chatId, topicId } = parseTelegramGroupId(groupId);
const autoReply = resolveTelegramAutoReply({ cfg, chatId, topicId });
if (typeof autoReply === "boolean") return !autoReply;
const requireMention = resolveTelegramRequireMention({
cfg,
chatId,
topicId,
});
if (typeof requireMention === "boolean") return requireMention;
return resolveProviderGroupRequireMention({
cfg,
provider,
@@ -138,9 +138,6 @@ export function resolveGroupRequireMention(params: {
(groupRoom
? channelEntries[normalizeDiscordSlug(groupRoom)]
: undefined);
if (entry && typeof entry.autoReply === "boolean") {
return !entry.autoReply;
}
if (entry && typeof entry.requireMention === "boolean") {
return entry.requireMention;
}
@@ -163,7 +160,7 @@ export function resolveGroupRequireMention(params: {
channelName ?? "",
normalizedName,
].filter(Boolean);
let matched: { requireMention?: boolean; autoReply?: boolean } | undefined;
let matched: { requireMention?: boolean } | undefined;
for (const candidate of candidates) {
if (candidate && channels[candidate]) {
matched = channels[candidate];
@@ -172,9 +169,6 @@ export function resolveGroupRequireMention(params: {
}
const fallback = channels["*"];
const resolved = matched ?? fallback;
if (typeof resolved?.autoReply === "boolean") {
return !resolved.autoReply;
}
if (typeof resolved?.requireMention === "boolean") {
return resolved.requireMention;
}

View File

@@ -237,12 +237,11 @@ export type TelegramActionConfig = {
};
export type TelegramTopicConfig = {
requireMention?: boolean;
/** If specified, only load these skills for this topic. Omit = all skills; empty = no skills. */
skills?: string[];
/** If false, disable the bot for this topic. */
enabled?: boolean;
/** If true, reply to every message (no mention required). */
autoReply?: boolean;
/** Optional allowlist for topic senders (ids or usernames). */
allowFrom?: Array<string | number>;
/** Optional system prompt snippet for this topic. */
@@ -257,8 +256,6 @@ export type TelegramGroupConfig = {
topics?: Record<string, TelegramTopicConfig>;
/** If false, disable the bot for this group (and its topics). */
enabled?: boolean;
/** If true, reply to every message (no mention required). */
autoReply?: boolean;
/** Optional allowlist for group senders (ids or usernames). */
allowFrom?: Array<string | number>;
/** Optional system prompt snippet for this group. */
@@ -325,8 +322,6 @@ export type DiscordGuildChannelConfig = {
skills?: string[];
/** If false, disable the bot for this channel. */
enabled?: boolean;
/** If true, reply to every message (no mention required). */
autoReply?: boolean;
/** Optional allowlist for channel senders (ids or names). */
users?: Array<string | number>;
/** Optional system prompt snippet for this channel. */
@@ -412,8 +407,6 @@ export type SlackChannelConfig = {
allow?: boolean;
/** Require mentioning the bot to trigger replies. */
requireMention?: boolean;
/** Reply to all messages without needing a mention. */
autoReply?: boolean;
/** Allowlist of users that can invoke the bot in this channel. */
users?: Array<string | number>;
/** Optional skill filter for this channel. */

View File

@@ -787,7 +787,6 @@ export const ClawdbotSchema = z.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
autoReply: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
topics: z
@@ -795,9 +794,9 @@ export const ClawdbotSchema = z.object({
z.string(),
z
.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
autoReply: z.boolean().optional(),
allowFrom: z
.array(z.union([z.string(), z.number()]))
.optional(),
@@ -913,7 +912,6 @@ export const ClawdbotSchema = z.object({
requireMention: z.boolean().optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
autoReply: z.boolean().optional(),
users: z
.array(z.union([z.string(), z.number()]))
.optional(),
@@ -990,7 +988,6 @@ export const ClawdbotSchema = z.object({
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),
autoReply: z.boolean().optional(),
users: z.array(z.union([z.string(), z.number()])).optional(),
skills: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),

View File

@@ -101,7 +101,6 @@ describe("discord guild/channel resolution", () => {
requireMention: true,
skills: ["search"],
enabled: false,
autoReply: true,
users: ["123"],
systemPrompt: "Use short answers.",
},
@@ -126,7 +125,6 @@ describe("discord guild/channel resolution", () => {
expect(help?.requireMention).toBe(true);
expect(help?.skills).toEqual(["search"]);
expect(help?.enabled).toBe(false);
expect(help?.autoReply).toBe(true);
expect(help?.users).toEqual(["123"]);
expect(help?.systemPrompt).toBe("Use short answers.");
});

View File

@@ -101,7 +101,6 @@ export type DiscordGuildEntryResolved = {
requireMention?: boolean;
skills?: string[];
enabled?: boolean;
autoReply?: boolean;
users?: Array<string | number>;
systemPrompt?: string;
}
@@ -113,7 +112,6 @@ export type DiscordChannelConfigResolved = {
requireMention?: boolean;
skills?: string[];
enabled?: boolean;
autoReply?: boolean;
users?: Array<string | number>;
systemPrompt?: string;
};
@@ -601,14 +599,8 @@ export function createDiscordMessageHandler(params: {
guildHistories.set(message.channelId, history);
}
const baseRequireMention =
channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
const shouldRequireMention =
channelConfig?.autoReply === true
? false
: channelConfig?.autoReply === false
? true
: baseRequireMention;
channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
const hasAnyMention = Boolean(
!isDirectMessage &&
(message.mentionedEveryone ||
@@ -1810,7 +1802,6 @@ export function resolveDiscordChannelConfig(params: {
requireMention: byId.requireMention,
skills: byId.skills,
enabled: byId.enabled,
autoReply: byId.autoReply,
users: byId.users,
systemPrompt: byId.systemPrompt,
};
@@ -1821,7 +1812,6 @@ export function resolveDiscordChannelConfig(params: {
requireMention: entry.requireMention,
skills: entry.skills,
enabled: entry.enabled,
autoReply: entry.autoReply,
users: entry.users,
systemPrompt: entry.systemPrompt,
};
@@ -1833,7 +1823,6 @@ export function resolveDiscordChannelConfig(params: {
requireMention: entry.requireMention,
skills: entry.skills,
enabled: entry.enabled,
autoReply: entry.autoReply,
users: entry.users,
systemPrompt: entry.systemPrompt,
};

View File

@@ -159,7 +159,6 @@ type SlackThreadBroadcastEvent = {
type SlackChannelConfigResolved = {
allowed: boolean;
requireMention: boolean;
autoReply?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
@@ -284,7 +283,6 @@ function resolveSlackChannelConfig(params: {
enabled?: boolean;
allow?: boolean;
requireMention?: boolean;
autoReply?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
@@ -308,7 +306,6 @@ function resolveSlackChannelConfig(params: {
enabled?: boolean;
allow?: boolean;
requireMention?: boolean;
autoReply?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
@@ -341,14 +338,13 @@ function resolveSlackChannelConfig(params: {
const requireMention =
firstDefined(resolved.requireMention, fallback?.requireMention, true) ??
true;
const autoReply = firstDefined(resolved.autoReply, fallback?.autoReply);
const users = firstDefined(resolved.users, fallback?.users);
const skills = firstDefined(resolved.skills, fallback?.skills);
const systemPrompt = firstDefined(
resolved.systemPrompt,
fallback?.systemPrompt,
);
return { allowed, requireMention, autoReply, users, skills, systemPrompt };
return { allowed, requireMention, users, skills, systemPrompt };
}
async function resolveSlackMedia(params: {
@@ -810,11 +806,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
surface: "slack",
});
const shouldRequireMention = isRoom
? channelConfig?.autoReply === true
? false
: channelConfig?.autoReply === false
? true
: (channelConfig?.requireMention ?? true)
? (channelConfig?.requireMention ?? true)
: false;
const shouldBypassMention =
allowTextCommands &&

View File

@@ -704,6 +704,50 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("allows per-topic requireMention override", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<
typeof vi.fn
>;
replySpy.mockReset();
loadConfig.mockReturnValue({
telegram: {
groups: {
"*": { requireMention: true },
"-1001234567890": {
requireMention: true,
topics: {
"99": { requireMention: false },
},
},
},
},
});
createTelegramBot({ token: "tok" });
const handler = onSpy.mock.calls[0][1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
await handler({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
text: "hello",
date: 1736380800,
message_thread_id: 99,
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("honors groups default when no explicit group override exists", async () => {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<

View File

@@ -381,16 +381,11 @@ export function createTelegramBot(opts: TelegramBotOptions) {
(ent) => ent.type === "mention",
);
const baseRequireMention = resolveGroupRequireMention(chatId);
const autoReplySetting = firstDefined(
topicConfig?.autoReply,
groupConfig?.autoReply,
const requireMention = firstDefined(
topicConfig?.requireMention,
groupConfig?.requireMention,
baseRequireMention,
);
const requireMention =
autoReplySetting === true
? false
: autoReplySetting === false
? true
: baseRequireMention;
const shouldBypassMention =
isGroup &&
requireMention &&