feat: add channel/topic overrides for skills + auto-reply

This commit is contained in:
Peter Steinberger
2026-01-07 11:23:04 +01:00
parent 61f720b945
commit 43c6bb7595
8 changed files with 706 additions and 86 deletions

View File

@@ -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<ClawdbotConfig["discord"]>["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;
}

View File

@@ -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<string | number>;
/** 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<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. */
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<string, TelegramGroupConfig>;
allowFrom?: Array<string | number>;
/** Optional allowlist for Telegram group senders (user ids or usernames). */
groupAllowFrom?: Array<string | number>;
@@ -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<string | number>;
/** 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<string | number>;
/** Optional skill filter for this channel. */
skills?: string[];
/** Optional system prompt for this channel. */
systemPrompt?: string;
};
export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist";

View File

@@ -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(),
)

View File

@@ -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", () => {

View File

@@ -94,12 +94,28 @@ export type DiscordGuildEntryResolved = {
requireMention?: boolean;
reactionNotifications?: "off" | "own" | "all" | "allowlist";
users?: Array<string | number>;
channels?: Record<string, { allow?: boolean; requireMention?: boolean }>;
channels?: Record<
string,
{
allow?: boolean;
requireMention?: boolean;
skills?: string[];
enabled?: boolean;
autoReply?: boolean;
users?: Array<string | number>;
systemPrompt?: string;
}
>;
};
export type DiscordChannelConfigResolved = {
allowed: boolean;
requireMention?: boolean;
skills?: string[];
enabled?: boolean;
autoReply?: boolean;
users?: Array<string | number>;
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<string | number>;
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<string | number>;
@@ -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 };

View File

@@ -159,6 +159,10 @@ type SlackThreadBroadcastEvent = {
type SlackChannelConfigResolved = {
allowed: boolean;
requireMention: boolean;
autoReply?: boolean;
users?: Array<string | number>;
skills?: string[];
systemPrompt?: string;
};
function normalizeSlackSlug(raw?: string) {
@@ -177,6 +181,13 @@ function normalizeAllowListLower(list?: Array<string | number>) {
return normalizeAllowList(list).map((entry) => entry.toLowerCase());
}
function firstDefined<T>(...values: Array<T | undefined>) {
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<string | number>;
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<SlackSlashCommandConfig> {
@@ -253,7 +278,18 @@ function resolveSlackChannelLabel(params: {
function resolveSlackChannelConfig(params: {
channelId: string;
channelName?: string;
channels?: Record<string, { allow?: boolean; requireMention?: boolean }>;
channels?: Record<
string,
{
enabled?: boolean;
allow?: boolean;
requireMention?: boolean;
autoReply?: boolean;
users?: Array<string | number>;
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<string | number>;
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<string, { name?: string }>();
const seenMessages = new Map<string, number>();
@@ -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

View File

@@ -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<string, unknown>,
) => Promise<void>;
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();

View File

@@ -153,6 +153,12 @@ export function createTelegramBot(opts: TelegramBotOptions) {
hasEntries: entries.length > 0,
};
};
const firstDefined = <T>(...values: Array<T | undefined>) => {
for (const value of values) {
if (typeof value !== "undefined") return value;
}
return undefined;
};
const isSenderAllowed = (params: {
allow: ReturnType<typeof normalizeAllowFrom>;
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;
}