feat: add channel/topic overrides for skills + auto-reply
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user