From 43c6bb7595701ffaa47a4850244a9c2c963a9695 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 11:23:04 +0100 Subject: [PATCH] feat: add channel/topic overrides for skills + auto-reply --- src/auto-reply/reply/groups.ts | 74 +++++++++++-- src/config/types.ts | 58 ++++++++-- src/config/zod-schema.ts | 33 ++++++ src/discord/monitor.test.ts | 15 ++- src/discord/monitor.ts | 191 +++++++++++++++++++++++++-------- src/slack/monitor.ts | 182 +++++++++++++++++++++++++++---- src/telegram/bot.test.ts | 55 ++++++++++ src/telegram/bot.ts | 184 +++++++++++++++++++++++++++++-- 8 files changed, 706 insertions(+), 86 deletions(-) diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index eccb7674c..b28481d6d 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -26,6 +26,56 @@ function normalizeSlackSlug(raw?: string | null) { return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, ""); } +function parseTelegramGroupId(value?: string | null) { + const raw = value?.trim() ?? ""; + if (!raw) return { chatId: undefined, topicId: undefined }; + const parts = raw.split(":").filter(Boolean); + if ( + parts.length >= 3 && + parts[1] === "topic" && + /^-?\d+$/.test(parts[0]) && + /^\d+$/.test(parts[2]) + ) { + return { chatId: parts[0], topicId: parts[2] }; + } + if (parts.length >= 2 && /^-?\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) { + return { chatId: parts[0], topicId: parts[1] }; + } + 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: { + cfg: ClawdbotConfig; + chatId?: string; + topicId?: string; +}): boolean | undefined { + const { cfg, chatId, topicId } = params; + if (!chatId) return undefined; + const groupConfig = cfg.telegram?.groups?.[chatId]; + const groupDefault = cfg.telegram?.groups?.["*"]; + const topicConfig = + 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 (hasOwn(defaultTopicConfig, "autoReply")) { + return (defaultTopicConfig as { autoReply?: boolean }).autoReply; + } + if (hasOwn(groupConfig, "autoReply")) { + return (groupConfig as { autoReply?: boolean }).autoReply; + } + if (hasOwn(groupDefault, "autoReply")) { + return (groupDefault as { autoReply?: boolean }).autoReply; + } + return undefined; +} + function resolveDiscordGuildEntry( guilds: NonNullable["guilds"], groupSpace?: string, @@ -55,11 +105,17 @@ export function resolveGroupRequireMention(params: { const groupId = groupResolution?.id ?? ctx.From?.replace(/^group:/, ""); const groupRoom = ctx.GroupRoom?.trim() ?? ctx.GroupSubject?.trim(); const groupSpace = ctx.GroupSpace?.trim(); - if ( - provider === "telegram" || - provider === "whatsapp" || - provider === "imessage" - ) { + if (provider === "telegram") { + const { chatId, topicId } = parseTelegramGroupId(groupId); + const autoReply = resolveTelegramAutoReply({ cfg, chatId, topicId }); + if (typeof autoReply === "boolean") return !autoReply; + return resolveProviderGroupRequireMention({ + cfg, + provider, + groupId: chatId ?? groupId, + }); + } + if (provider === "whatsapp" || provider === "imessage") { return resolveProviderGroupRequireMention({ cfg, provider, @@ -82,6 +138,9 @@ 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; } @@ -104,7 +163,7 @@ export function resolveGroupRequireMention(params: { channelName ?? "", normalizedName, ].filter(Boolean); - let matched: { requireMention?: boolean } | undefined; + let matched: { requireMention?: boolean; autoReply?: boolean } | undefined; for (const candidate of candidates) { if (candidate && channels[candidate]) { matched = channels[candidate]; @@ -113,6 +172,9 @@ 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; } diff --git a/src/config/types.ts b/src/config/types.ts index 474cb9b1f..caae79a9b 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -236,6 +236,35 @@ export type TelegramActionConfig = { reactions?: boolean; }; +export type TelegramTopicConfig = { + /** 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; + /** Optional system prompt snippet for this topic. */ + systemPrompt?: string; +}; + +export type TelegramGroupConfig = { + requireMention?: boolean; + /** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */ + skills?: string[]; + /** Per-topic configuration (key is message_thread_id as string) */ + topics?: Record; + /** 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; + /** Optional system prompt snippet for this group. */ + systemPrompt?: string; +}; + export type TelegramConfig = { /** * Controls how Telegram direct chats (DMs) are handled: @@ -252,12 +281,7 @@ export type TelegramConfig = { tokenFile?: string; /** Control reply threading when reply tags are present (off|first|all). */ replyToMode?: ReplyToMode; - groups?: Record< - string, - { - requireMention?: boolean; - } - >; + groups?: Record; allowFrom?: Array; /** Optional allowlist for Telegram group senders (user ids or usernames). */ groupAllowFrom?: Array; @@ -297,6 +321,16 @@ export type DiscordDmConfig = { export type DiscordGuildChannelConfig = { allow?: boolean; requireMention?: boolean; + /** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */ + 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; + /** Optional system prompt snippet for this channel. */ + systemPrompt?: string; }; export type DiscordReactionNotificationMode = @@ -372,8 +406,20 @@ export type SlackDmConfig = { }; export type SlackChannelConfig = { + /** If false, disable the bot in this channel. (Alias for allow: false.) */ + enabled?: boolean; + /** Legacy channel allow toggle; prefer enabled. */ 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; + /** Optional skill filter for this channel. */ + skills?: string[]; + /** Optional system prompt for this channel. */ + systemPrompt?: string; }; export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist"; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 761a43e3d..fc701fe5d 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -785,6 +785,27 @@ export const ClawdbotSchema = z.object({ 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 + .record( + z.string(), + z + .object({ + 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(), + }) + .optional(), + ) + .optional(), }) .optional(), ) @@ -890,6 +911,13 @@ export const ClawdbotSchema = z.object({ .object({ allow: z.boolean().optional(), 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(), + systemPrompt: z.string().optional(), }) .optional(), ) @@ -959,8 +987,13 @@ export const ClawdbotSchema = z.object({ z.string(), 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(), }) .optional(), ) diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index 9caeb419f..8afe6d33c 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -96,7 +96,15 @@ describe("discord guild/channel resolution", () => { const guildInfo: DiscordGuildEntryResolved = { channels: { general: { allow: true }, - help: { allow: true, requireMention: true }, + help: { + allow: true, + requireMention: true, + skills: ["search"], + enabled: false, + autoReply: true, + users: ["123"], + systemPrompt: "Use short answers.", + }, }, }; const channel = resolveDiscordChannelConfig({ @@ -116,6 +124,11 @@ describe("discord guild/channel resolution", () => { }); expect(help?.allowed).toBe(true); 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."); }); it("denies channel when config present but no match", () => { diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 1f9743707..aa3cbc2a2 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -94,12 +94,28 @@ export type DiscordGuildEntryResolved = { requireMention?: boolean; reactionNotifications?: "off" | "own" | "all" | "allowlist"; users?: Array; - channels?: Record; + channels?: Record< + string, + { + allow?: boolean; + requireMention?: boolean; + skills?: string[]; + enabled?: boolean; + autoReply?: boolean; + users?: Array; + systemPrompt?: string; + } + >; }; export type DiscordChannelConfigResolved = { allowed: boolean; requireMention?: boolean; + skills?: string[]; + enabled?: boolean; + autoReply?: boolean; + users?: Array; + systemPrompt?: string; }; export type DiscordMessageEvent = Parameters< @@ -518,6 +534,12 @@ export function createDiscordMessageHandler(params: { channelSlug, }) : null; + if (isGuildMessage && channelConfig?.enabled === false) { + logVerbose( + `Blocked discord channel ${message.channelId} (channel disabled)`, + ); + return; + } const groupDmAllowed = isGroupDm && @@ -579,8 +601,14 @@ export function createDiscordMessageHandler(params: { guildHistories.set(message.channelId, history); } - const resolvedRequireMention = + const baseRequireMention = channelConfig?.requireMention ?? guildInfo?.requireMention ?? true; + const shouldRequireMention = + channelConfig?.autoReply === true + ? false + : channelConfig?.autoReply === false + ? true + : baseRequireMention; const hasAnyMention = Boolean( !isDirectMessage && (message.mentionedEveryone || @@ -602,13 +630,13 @@ export function createDiscordMessageHandler(params: { const shouldBypassMention = allowTextCommands && isGuildMessage && - resolvedRequireMention && + shouldRequireMention && !wasMentioned && !hasAnyMention && commandAuthorized && hasControlCommand(baseText); const canDetectMention = Boolean(botId) || mentionRegexes.length > 0; - if (isGuildMessage && resolvedRequireMention) { + if (isGuildMessage && shouldRequireMention) { if (botId && !wasMentioned && !shouldBypassMention) { logVerbose( `discord: drop guild message (mention required, botId=${botId})`, @@ -625,22 +653,17 @@ export function createDiscordMessageHandler(params: { } if (isGuildMessage) { - const userAllow = guildInfo?.users; - if (Array.isArray(userAllow) && userAllow.length > 0) { - const users = normalizeDiscordAllowList(userAllow, [ - "discord:", - "user:", - ]); - const userOk = - !users || - allowListMatches(users, { - id: author.id, - name: author.username, - tag: formatDiscordUserTag(author), - }); + const channelUsers = channelConfig?.users ?? guildInfo?.users; + if (Array.isArray(channelUsers) && channelUsers.length > 0) { + const userOk = resolveDiscordUserAllowed({ + allowList: channelUsers, + userId: author.id, + userName: author.username, + userTag: formatDiscordUserTag(author), + }); if (!userOk) { logVerbose( - `Blocked discord guild sender ${author.id} (not in guild users allowlist)`, + `Blocked discord guild sender ${author.id} (not in channel users allowlist)`, ); return; } @@ -676,7 +699,7 @@ export function createDiscordMessageHandler(params: { if (ackReactionScope === "group-all") return isGroupChat; if (ackReactionScope === "group-mentions") { if (!isGuildMessage) return false; - if (!resolvedRequireMention) return false; + if (!shouldRequireMention) return false; if (!canDetectMention) return false; return wasMentioned || shouldBypassMention; } @@ -702,6 +725,15 @@ export function createDiscordMessageHandler(params: { const groupRoom = isGuildMessage && channelSlug ? `#${channelSlug}` : undefined; const groupSubject = isDirectMessage ? undefined : groupRoom; + const channelDescription = channelInfo?.topic?.trim(); + const systemPromptParts = [ + channelDescription ? `Channel topic: ${channelDescription}` : null, + channelConfig?.systemPrompt?.trim() || null, + ].filter((entry): entry is string => Boolean(entry)); + const groupSystemPrompt = + systemPromptParts.length > 0 + ? systemPromptParts.join("\n\n") + : undefined; let combinedBody = formatAgentEnvelope({ provider: "Discord", from: fromLabel, @@ -755,6 +787,7 @@ export function createDiscordMessageHandler(params: { SenderTag: formatDiscordUserTag(author), GroupSubject: groupSubject, GroupRoom: groupRoom, + GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined, GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined, @@ -825,7 +858,7 @@ export function createDiscordMessageHandler(params: { ctx: ctxPayload, cfg, dispatcher, - replyOptions, + replyOptions: { ...replyOptions, skillFilter: channelConfig?.skills }, }); markDispatchIdle(); if (!queuedFinal) { @@ -1053,13 +1086,27 @@ function createDiscordNativeCommand(params: { guild: interaction.guild ?? undefined, guildEntries: cfg.discord?.guilds, }); - if (useAccessGroups && interaction.guild) { - const channelConfig = resolveDiscordChannelConfig({ - guildInfo, - channelId: channel?.id ?? "", - channelName, - channelSlug, + const channelConfig = interaction.guild + ? resolveDiscordChannelConfig({ + guildInfo, + channelId: channel?.id ?? "", + channelName, + channelSlug, + }) + : null; + if (channelConfig?.enabled === false) { + await interaction.reply({ + content: "This channel is disabled.", }); + return; + } + if (interaction.guild && channelConfig?.allowed === false) { + await interaction.reply({ + content: "This channel is not allowed.", + }); + return; + } + if (useAccessGroups && interaction.guild) { const channelAllowlistConfigured = Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0; @@ -1138,23 +1185,21 @@ function createDiscordNativeCommand(params: { commandAuthorized = true; } } - if (guildInfo?.users && !isDirectMessage) { - const allowList = normalizeDiscordAllowList(guildInfo.users, [ - "discord:", - "user:", - ]); - if ( - allowList && - !allowListMatches(allowList, { - id: user.id, - name: user.username, - tag: formatDiscordUserTag(user), - }) - ) { - await interaction.reply({ - content: "You are not authorized to use this command.", + if (!isDirectMessage) { + const channelUsers = channelConfig?.users ?? guildInfo?.users; + if (Array.isArray(channelUsers) && channelUsers.length > 0) { + const userOk = resolveDiscordUserAllowed({ + allowList: channelUsers, + userId: user.id, + userName: user.username, + userTag: formatDiscordUserTag(user), }); - return; + if (!userOk) { + await interaction.reply({ + content: "You are not authorized to use this command.", + }); + return; + } } } if (isGroupDm && cfg.discord?.dm?.groupEnabled === false) { @@ -1183,6 +1228,24 @@ function createDiscordNativeCommand(params: { AccountId: route.accountId, ChatType: isDirectMessage ? "direct" : "group", GroupSubject: isGuild ? interaction.guild?.name : undefined, + GroupSystemPrompt: isGuild + ? (() => { + const channelTopic = + channel && "topic" in channel + ? (channel.topic ?? undefined) + : undefined; + const channelDescription = channelTopic?.trim(); + const systemPromptParts = [ + channelDescription + ? `Channel topic: ${channelDescription}` + : null, + channelConfig?.systemPrompt?.trim() || null, + ].filter((entry): entry is string => Boolean(entry)); + return systemPromptParts.length > 0 + ? systemPromptParts.join("\n\n") + : undefined; + })() + : undefined, SenderName: user.globalName ?? user.username, SenderId: user.id, SenderUsername: user.username, @@ -1213,7 +1276,11 @@ function createDiscordNativeCommand(params: { }, }); - const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg); + const replyResult = await getReplyFromConfig( + ctxPayload, + { skillFilter: channelConfig?.skills }, + cfg, + ); const replies = replyResult ? Array.isArray(replyResult) ? replyResult @@ -1339,12 +1406,13 @@ async function deliverDiscordReply(params: { async function resolveDiscordChannelInfo( client: Client, channelId: string, -): Promise<{ type: ChannelType; name?: string } | null> { +): Promise<{ type: ChannelType; name?: string; topic?: string } | null> { try { const channel = await client.fetchChannel(channelId); if (!channel) return null; const name = "name" in channel ? (channel.name ?? undefined) : undefined; - return { type: channel.type, name }; + const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; + return { type: channel.type, name, topic }; } catch (err) { logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`); return null; @@ -1671,6 +1739,24 @@ export function allowListMatches( return false; } +function resolveDiscordUserAllowed(params: { + allowList?: Array; + userId: string; + userName?: string; + userTag?: string; +}) { + const allowList = normalizeDiscordAllowList(params.allowList, [ + "discord:", + "user:", + ]); + if (!allowList) return true; + return allowListMatches(allowList, { + id: params.userId, + name: params.userName, + tag: params.userTag, + }); +} + export function resolveDiscordCommandAuthorized(params: { isDirectMessage: boolean; allowFrom?: Array; @@ -1722,12 +1808,22 @@ export function resolveDiscordChannelConfig(params: { return { allowed: byId.allow !== false, requireMention: byId.requireMention, + skills: byId.skills, + enabled: byId.enabled, + autoReply: byId.autoReply, + users: byId.users, + systemPrompt: byId.systemPrompt, }; if (channelSlug && channels[channelSlug]) { const entry = channels[channelSlug]; return { allowed: entry.allow !== false, requireMention: entry.requireMention, + skills: entry.skills, + enabled: entry.enabled, + autoReply: entry.autoReply, + users: entry.users, + systemPrompt: entry.systemPrompt, }; } if (channelName && channels[channelName]) { @@ -1735,6 +1831,11 @@ export function resolveDiscordChannelConfig(params: { return { allowed: entry.allow !== false, requireMention: entry.requireMention, + skills: entry.skills, + enabled: entry.enabled, + autoReply: entry.autoReply, + users: entry.users, + systemPrompt: entry.systemPrompt, }; } return { allowed: false }; diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 6db05d41e..ff6229656 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -159,6 +159,10 @@ type SlackThreadBroadcastEvent = { type SlackChannelConfigResolved = { allowed: boolean; requireMention: boolean; + autoReply?: boolean; + users?: Array; + skills?: string[]; + systemPrompt?: string; }; function normalizeSlackSlug(raw?: string) { @@ -177,6 +181,13 @@ function normalizeAllowListLower(list?: Array) { return normalizeAllowList(list).map((entry) => entry.toLowerCase()); } +function firstDefined(...values: Array) { + for (const value of values) { + if (typeof value !== "undefined") return value; + } + return undefined; +} + function allowListMatches(params: { allowList: string[]; id?: string; @@ -199,6 +210,20 @@ function allowListMatches(params: { return candidates.some((value) => allowList.includes(value)); } +function resolveSlackUserAllowed(params: { + allowList?: Array; + userId?: string; + userName?: string; +}) { + const allowList = normalizeAllowListLower(params.allowList); + if (allowList.length === 0) return true; + return allowListMatches({ + allowList, + id: params.userId, + name: params.userName, + }); +} + function resolveSlackSlashCommandConfig( raw?: SlackSlashCommandConfig, ): Required { @@ -253,7 +278,18 @@ function resolveSlackChannelLabel(params: { function resolveSlackChannelConfig(params: { channelId: string; channelName?: string; - channels?: Record; + channels?: Record< + string, + { + enabled?: boolean; + allow?: boolean; + requireMention?: boolean; + autoReply?: boolean; + users?: Array; + skills?: string[]; + systemPrompt?: string; + } + >; }): SlackChannelConfigResolved | null { const { channelId, channelName, channels } = params; const entries = channels ?? {}; @@ -267,7 +303,17 @@ function resolveSlackChannelConfig(params: { normalizedName, ].filter(Boolean); - let matched: { allow?: boolean; requireMention?: boolean } | undefined; + let matched: + | { + enabled?: boolean; + allow?: boolean; + requireMention?: boolean; + autoReply?: boolean; + users?: Array; + skills?: string[]; + systemPrompt?: string; + } + | undefined; for (const candidate of candidates) { if (candidate && entries[candidate]) { matched = entries[candidate]; @@ -284,10 +330,25 @@ function resolveSlackChannelConfig(params: { } const resolved = matched ?? fallback ?? {}; - const allowed = resolved.allow ?? true; + const allowed = + firstDefined( + resolved.enabled, + resolved.allow, + fallback?.enabled, + fallback?.allow, + true, + ) ?? true; const requireMention = - resolved.requireMention ?? fallback?.requireMention ?? true; - return { allowed, 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 }; } async function resolveSlackMedia(params: { @@ -410,7 +471,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const logger = getChildLogger({ module: "slack-auto-reply" }); const channelCache = new Map< string, - { name?: string; type?: SlackMessageEvent["channel_type"] } + { + name?: string; + type?: SlackMessageEvent["channel_type"]; + topic?: string; + purpose?: string; + } >(); const userCache = new Map(); const seenMessages = new Map(); @@ -469,7 +535,15 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { : channel?.is_group ? "group" : undefined; - const entry = { name, type }; + const topic = + channel && "topic" in channel + ? (channel.topic?.value ?? undefined) + : undefined; + const purpose = + channel && "purpose" in channel + ? (channel.purpose?.value ?? undefined) + : undefined; + const entry = { name, type, topic, purpose }; channelCache.set(channelId, entry); return entry; } catch { @@ -606,6 +680,8 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { let channelInfo: { name?: string; type?: SlackMessageEvent["channel_type"]; + topic?: string; + purpose?: string; } = {}; let channelType = message.channel_type; if (!channelType || channelType !== "im") { @@ -706,23 +782,44 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { matchesMentionPatterns(message.text ?? "", mentionRegexes))); const sender = await resolveUserName(message.user); const senderName = sender?.name ?? message.user; + const channelUserAuthorized = isRoom + ? resolveSlackUserAllowed({ + allowList: channelConfig?.users, + userId: message.user, + userName: senderName, + }) + : true; + if (isRoom && !channelUserAuthorized) { + logVerbose( + `Blocked unauthorized slack sender ${message.user} (not in channel users)`, + ); + return; + } const allowList = effectiveAllowFromLower; const commandAuthorized = - allowList.length === 0 || - allowListMatches({ - allowList, - id: message.user, - name: senderName, - }); + (allowList.length === 0 || + allowListMatches({ + allowList, + id: message.user, + name: senderName, + })) && + channelUserAuthorized; const hasAnyMention = /<@[^>]+>/.test(message.text ?? ""); const allowTextCommands = shouldHandleTextCommands({ cfg, surface: "slack", }); + const shouldRequireMention = isRoom + ? channelConfig?.autoReply === true + ? false + : channelConfig?.autoReply === false + ? true + : (channelConfig?.requireMention ?? true) + : false; const shouldBypassMention = allowTextCommands && isRoom && - channelConfig?.requireMention && + shouldRequireMention && !wasMentioned && !hasAnyMention && commandAuthorized && @@ -730,7 +827,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const canDetectMention = Boolean(botUserId) || mentionRegexes.length > 0; if ( isRoom && - channelConfig?.requireMention && + shouldRequireMention && canDetectMention && !wasMentioned && !shouldBypassMention @@ -757,7 +854,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { if (ackReactionScope === "group-all") return isGroupChat; if (ackReactionScope === "group-mentions") { if (!isRoom) return false; - if (!channelConfig?.requireMention) return false; + if (!shouldRequireMention) return false; if (!canDetectMention) return false; return wasMentioned || shouldBypassMention; } @@ -812,6 +909,17 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const slackTo = isDirectMessage ? `user:${message.user}` : `channel:${message.channel}`; + const channelDescription = [channelInfo?.topic, channelInfo?.purpose] + .map((entry) => entry?.trim()) + .filter((entry): entry is string => Boolean(entry)) + .filter((entry, index, list) => list.indexOf(entry) === index) + .join("\n"); + const systemPromptParts = [ + channelDescription ? `Channel description: ${channelDescription}` : null, + channelConfig?.systemPrompt?.trim() || null, + ].filter((entry): entry is string => Boolean(entry)); + const groupSystemPrompt = + systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; const ctxPayload = { Body: body, From: slackFrom, @@ -820,6 +928,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { AccountId: route.accountId, ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", GroupSubject: isRoomish ? roomLabel : undefined, + GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined, SenderName: senderName, SenderId: message.user, Provider: "slack" as const, @@ -907,7 +1016,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ctx: ctxPayload, cfg, dispatcher, - replyOptions, + replyOptions: { ...replyOptions, skillFilter: channelConfig?.skills }, }); markDispatchIdle(); if (didSetStatus) { @@ -1457,6 +1566,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { normalizeAllowListLower(effectiveAllowFrom); let commandAuthorized = true; + let channelConfig: SlackChannelConfigResolved | null = null; if (isDirectMessage) { if (!dmEnabled || dmPolicy === "disabled") { await respond({ @@ -1506,7 +1616,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { } if (isRoom) { - const channelConfig = resolveSlackChannelConfig({ + channelConfig = resolveSlackChannelConfig({ channelId: command.channel_id, channelName: channelInfo?.name, channels: channelsConfig, @@ -1538,6 +1648,20 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const sender = await resolveUserName(command.user_id); const senderName = sender?.name ?? command.user_name ?? command.user_id; + const channelUserAllowed = isRoom + ? resolveSlackUserAllowed({ + allowList: channelConfig?.users, + userId: command.user_id, + userName: senderName, + }) + : true; + if (isRoom && !channelUserAllowed) { + await respond({ + text: "You are not authorized to use this command here.", + response_type: "ephemeral", + }); + return; + } const channelName = channelInfo?.name; const roomLabel = channelName ? `#${channelName}` @@ -1552,6 +1676,21 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { id: isDirectMessage ? command.user_id : command.channel_id, }, }); + const channelDescription = [channelInfo?.topic, channelInfo?.purpose] + .map((entry) => entry?.trim()) + .filter((entry): entry is string => Boolean(entry)) + .filter((entry, index, list) => list.indexOf(entry) === index) + .join("\n"); + const systemPromptParts = [ + channelDescription + ? `Channel description: ${channelDescription}` + : null, + channelConfig?.systemPrompt?.trim() || null, + ].filter((entry): entry is string => Boolean(entry)); + const groupSystemPrompt = + systemPromptParts.length > 0 + ? systemPromptParts.join("\n\n") + : undefined; const ctxPayload = { Body: prompt, @@ -1563,6 +1702,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { To: `slash:${command.user_id}`, ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", GroupSubject: isRoomish ? roomLabel : undefined, + GroupSystemPrompt: isRoomish ? groupSystemPrompt : undefined, SenderName: senderName, SenderId: command.user_id, Provider: "slack" as const, @@ -1580,7 +1720,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { OriginatingTo: `user:${command.user_id}`, }; - const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg); + const replyResult = await getReplyFromConfig( + ctxPayload, + { skillFilter: channelConfig?.skills }, + cfg, + ); const replies = replyResult ? Array.isArray(replyResult) ? replyResult diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 18152a772..48073b216 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -1407,6 +1407,61 @@ describe("createTelegramBot", () => { }); }); + it("applies topic skill filters and system prompts", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + loadConfig.mockReturnValue({ + telegram: { + groups: { + "-1001234567890": { + requireMention: false, + systemPrompt: "Group prompt", + skills: ["group-skill"], + topics: { + "99": { + skills: [], + systemPrompt: "Topic prompt", + }, + }, + }, + }, + }, + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { + id: -1001234567890, + type: "supergroup", + title: "Forum Group", + is_forum: true, + }, + from: { id: 12345, username: "testuser" }, + text: "hello", + date: 1736380800, + message_id: 42, + message_thread_id: 99, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt"); + const opts = replySpy.mock.calls[0][1]; + expect(opts?.skillFilter).toEqual([]); + }); + it("passes message_thread_id to topic replies", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index 63ae862d9..7bfc48e43 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -153,6 +153,12 @@ export function createTelegramBot(opts: TelegramBotOptions) { hasEntries: entries.length > 0, }; }; + const firstDefined = (...values: Array) => { + for (const value of values) { + if (typeof value !== "undefined") return value; + } + return undefined; + }; const isSenderAllowed = (params: { allow: ReturnType; senderId?: string; @@ -210,6 +216,20 @@ export function createTelegramBot(opts: TelegramBotOptions) { requireMentionOverride: opts.requireMention, overrideOrder: "after-config", }); + const resolveTelegramGroupConfig = ( + chatId: string | number, + messageThreadId?: number, + ) => { + const groups = cfg.telegram?.groups; + if (!groups) return { groupConfig: undefined, topicConfig: undefined }; + const groupKey = String(chatId); + const groupConfig = groups[groupKey] ?? groups["*"]; + const topicConfig = + messageThreadId != null + ? groupConfig?.topics?.[String(messageThreadId)] + : undefined; + return { groupConfig, topicConfig }; + }; const processMessage = async ( primaryCtx: TelegramContext, @@ -222,14 +242,34 @@ export function createTelegramBot(opts: TelegramBotOptions) { const messageThreadId = (msg as { message_thread_id?: number }) .message_thread_id; const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; + const { groupConfig, topicConfig } = resolveTelegramGroupConfig( + chatId, + messageThreadId, + ); const effectiveDmAllow = normalizeAllowFrom([ ...(allowFrom ?? []), ...storeAllowFrom, ]); + const groupAllowOverride = firstDefined( + topicConfig?.allowFrom, + groupConfig?.allowFrom, + ); const effectiveGroupAllow = normalizeAllowFrom([ - ...(groupAllowFrom ?? []), + ...(groupAllowOverride ?? groupAllowFrom ?? []), ...storeAllowFrom, ]); + const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; + + if (isGroup && groupConfig?.enabled === false) { + logVerbose(`Blocked telegram group ${chatId} (group disabled)`); + return; + } + if (isGroup && topicConfig?.enabled === false) { + logVerbose( + `Blocked telegram topic ${chatId} (${messageThreadId ?? "unknown"}) (topic disabled)`, + ); + return; + } const sendTyping = async () => { try { @@ -316,6 +356,19 @@ export function createTelegramBot(opts: TelegramBotOptions) { const botUsername = primaryCtx.me?.username?.toLowerCase(); const senderId = msg.from?.id ? String(msg.from.id) : ""; const senderUsername = msg.from?.username ?? ""; + if (isGroup && hasGroupAllowOverride) { + const allowed = isSenderAllowed({ + allow: effectiveGroupAllow, + senderId, + senderUsername, + }); + if (!allowed) { + logVerbose( + `Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`, + ); + return; + } + } const commandAuthorized = isSenderAllowed({ allow: isGroup ? effectiveGroupAllow : effectiveDmAllow, senderId, @@ -327,7 +380,17 @@ export function createTelegramBot(opts: TelegramBotOptions) { const hasAnyMention = (msg.entities ?? msg.caption_entities ?? []).some( (ent) => ent.type === "mention", ); - const requireMention = resolveGroupRequireMention(chatId); + const baseRequireMention = resolveGroupRequireMention(chatId); + const autoReplySetting = firstDefined( + topicConfig?.autoReply, + groupConfig?.autoReply, + ); + const requireMention = + autoReplySetting === true + ? false + : autoReplySetting === false + ? true + : baseRequireMention; const shouldBypassMention = isGroup && requireMention && @@ -423,6 +486,13 @@ export function createTelegramBot(opts: TelegramBotOptions) { : buildTelegramDmPeerId(chatId, messageThreadId), }, }); + const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills); + const systemPromptParts = [ + groupConfig?.systemPrompt?.trim() || null, + topicConfig?.systemPrompt?.trim() || null, + ].filter((entry): entry is string => Boolean(entry)); + const groupSystemPrompt = + systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; const ctxPayload = { Body: body, From: isGroup @@ -433,6 +503,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { AccountId: route.accountId, ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, + GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, SenderName: buildSenderName(msg), SenderId: senderId || undefined, SenderUsername: senderUsername || undefined, @@ -601,6 +672,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { dispatcher, replyOptions: { ...replyOptions, + skillFilter, onPartialReply: draftStream ? (payload) => updateDraftFromPartial(payload.text) : undefined, @@ -642,6 +714,49 @@ export function createTelegramBot(opts: TelegramBotOptions) { const messageThreadId = (msg as { message_thread_id?: number }) .message_thread_id; const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; + const storeAllowFrom = await readTelegramAllowFromStore().catch( + () => [], + ); + const { groupConfig, topicConfig } = resolveTelegramGroupConfig( + chatId, + messageThreadId, + ); + const groupAllowOverride = firstDefined( + topicConfig?.allowFrom, + groupConfig?.allowFrom, + ); + const effectiveGroupAllow = normalizeAllowFrom([ + ...(groupAllowOverride ?? groupAllowFrom ?? []), + ...storeAllowFrom, + ]); + const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; + + if (isGroup && groupConfig?.enabled === false) { + await bot.api.sendMessage(chatId, "This group is disabled."); + return; + } + if (isGroup && topicConfig?.enabled === false) { + await bot.api.sendMessage(chatId, "This topic is disabled."); + return; + } + if (isGroup && hasGroupAllowOverride) { + const senderId = msg.from?.id; + const senderUsername = msg.from?.username ?? ""; + if ( + senderId == null || + !isSenderAllowed({ + allow: effectiveGroupAllow, + senderId: String(senderId), + senderUsername, + }) + ) { + await bot.api.sendMessage( + chatId, + "You are not authorized to use this command.", + ); + return; + } + } if (isGroup && useAccessGroups) { const groupPolicy = cfg.telegram?.groupPolicy ?? "open"; @@ -664,7 +779,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const senderUsername = msg.from?.username ?? ""; if ( !isSenderAllowed({ - allow: groupAllow, + allow: effectiveGroupAllow, senderId: String(senderId), senderUsername, }) @@ -718,6 +833,18 @@ export function createTelegramBot(opts: TelegramBotOptions) { : buildTelegramDmPeerId(chatId, messageThreadId), }, }); + const skillFilter = firstDefined( + topicConfig?.skills, + groupConfig?.skills, + ); + const systemPromptParts = [ + groupConfig?.systemPrompt?.trim() || null, + topicConfig?.systemPrompt?.trim() || null, + ].filter((entry): entry is string => Boolean(entry)); + const groupSystemPrompt = + systemPromptParts.length > 0 + ? systemPromptParts.join("\n\n") + : undefined; const ctxPayload = { Body: prompt, From: isGroup @@ -726,6 +853,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { To: `slash:${senderId || chatId}`, ChatType: isGroup ? "group" : "direct", GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, + GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, SenderName: buildSenderName(msg), SenderId: senderId || undefined, SenderUsername: senderUsername || undefined, @@ -743,7 +871,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { const replyResult = await getReplyFromConfig( ctxPayload, - undefined, + { skillFilter }, cfg, ); const replies = replyResult @@ -777,9 +905,51 @@ export function createTelegramBot(opts: TelegramBotOptions) { const chatId = msg.chat.id; const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + const messageThreadId = (msg as { message_thread_id?: number }) + .message_thread_id; const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); + const { groupConfig, topicConfig } = resolveTelegramGroupConfig( + chatId, + messageThreadId, + ); + const groupAllowOverride = firstDefined( + topicConfig?.allowFrom, + groupConfig?.allowFrom, + ); + const effectiveGroupAllow = normalizeAllowFrom([ + ...(groupAllowOverride ?? groupAllowFrom ?? []), + ...storeAllowFrom, + ]); + const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; if (isGroup) { + if (groupConfig?.enabled === false) { + logVerbose(`Blocked telegram group ${chatId} (group disabled)`); + return; + } + if (topicConfig?.enabled === false) { + logVerbose( + `Blocked telegram topic ${chatId} (${messageThreadId ?? "unknown"}) (topic disabled)`, + ); + return; + } + if (hasGroupAllowOverride) { + const senderId = msg.from?.id; + const senderUsername = msg.from?.username ?? ""; + const allowed = + senderId != null && + isSenderAllowed({ + allow: effectiveGroupAllow, + senderId: String(senderId), + senderUsername, + }); + if (!allowed) { + logVerbose( + `Blocked telegram group sender ${senderId ?? "unknown"} (group allowFrom override)`, + ); + return; + } + } // Group policy filtering: controls how group messages are handled // - "open" (default): groups bypass allowFrom, only mention-gating applies // - "disabled": block all group messages entirely @@ -790,10 +960,6 @@ export function createTelegramBot(opts: TelegramBotOptions) { return; } if (groupPolicy === "allowlist") { - const effectiveGroupAllow = normalizeAllowFrom([ - ...(groupAllowFrom ?? []), - ...storeAllowFrom, - ]); // For allowlist mode, the sender (msg.from.id) must be in allowFrom const senderId = msg.from?.id; if (senderId == null) { @@ -804,7 +970,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { } if (!effectiveGroupAllow.hasEntries) { logVerbose( - "Blocked telegram group message (groupPolicy: allowlist, no groupAllowFrom)", + "Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)", ); return; }