From d7b7242e9e8aadb2687bde1dca56c5549bbad8df Mon Sep 17 00:00:00 2001 From: Muhammed Mukhthar CM Date: Fri, 23 Jan 2026 06:51:03 +0000 Subject: [PATCH] Telegram-user: add group support --- extensions/telegram-user/src/channel.ts | 4 +- extensions/telegram-user/src/config-schema.ts | 24 ++ .../telegram-user/src/monitor/handler.ts | 273 +++++++++++++++--- extensions/telegram-user/src/monitor/index.ts | 14 +- extensions/telegram-user/src/types.ts | 35 ++- 5 files changed, 306 insertions(+), 44 deletions(-) diff --git a/extensions/telegram-user/src/channel.ts b/extensions/telegram-user/src/channel.ts index cf6ec193c..ce77e2ff9 100644 --- a/extensions/telegram-user/src/channel.ts +++ b/extensions/telegram-user/src/channel.ts @@ -40,7 +40,7 @@ const meta = { detailLabel: "Telegram User", docsPath: "/channels/telegram-user", docsLabel: "telegram-user", - blurb: "login as a Telegram user via QR; DM-only for now.", + blurb: "login as a Telegram user via QR or phone code; supports DMs + groups.", order: 12, quickstartAllowFrom: true, }; @@ -68,7 +68,7 @@ export const telegramUserPlugin: ChannelPlugin = { }, }, capabilities: { - chatTypes: ["direct"], + chatTypes: ["direct", "group"], reactions: false, threads: false, media: true, diff --git a/extensions/telegram-user/src/config-schema.ts b/extensions/telegram-user/src/config-schema.ts index d40d029ca..a1b791566 100644 --- a/extensions/telegram-user/src/config-schema.ts +++ b/extensions/telegram-user/src/config-schema.ts @@ -2,6 +2,27 @@ import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); +const TelegramUserTopicSchema = z + .object({ + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(allowFromEntry).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); + +const TelegramUserGroupSchema = z + .object({ + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + topics: z.record(z.string(), TelegramUserTopicSchema.optional()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(allowFromEntry).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); + const TelegramUserAccountSchema = z .object({ name: z.string().optional(), @@ -12,6 +33,9 @@ const TelegramUserAccountSchema = z allowFrom: z.array(allowFromEntry).optional(), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), + groupAllowFrom: z.array(allowFromEntry).optional(), + groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional(), + groups: z.record(z.string(), TelegramUserGroupSchema.optional()).optional(), }) .strict(); diff --git a/extensions/telegram-user/src/monitor/handler.ts b/extensions/telegram-user/src/monitor/handler.ts index 63f7f69aa..01309a365 100644 --- a/extensions/telegram-user/src/monitor/handler.ts +++ b/extensions/telegram-user/src/monitor/handler.ts @@ -2,7 +2,7 @@ import type { TelegramClient } from "@mtcute/node"; import type { MessageContext } from "@mtcute/dispatcher"; import type { RuntimeEnv } from "clawdbot/plugin-sdk"; -import { resolveAckReaction } from "clawdbot/plugin-sdk"; +import { resolveAckReaction, resolveMentionGatingWithBypass } from "clawdbot/plugin-sdk"; import { getTelegramUserRuntime } from "../runtime.js"; import type { CoreConfig, TelegramUserAccountConfig } from "../types.js"; import { sendMediaTelegramUser, sendMessageTelegramUser } from "../send.js"; @@ -16,6 +16,7 @@ type TelegramUserHandlerParams = { runtime: RuntimeEnv; accountId: string; accountConfig: TelegramUserAccountConfig; + self?: { id: number; username?: string | null }; }; function normalizeAllowEntry(raw: string): string { @@ -42,7 +43,7 @@ function parseAllowlist(entries: Array | undefined) { const username = entry.startsWith("@") ? entry.slice(1) : entry; if (username) usernames.add(username); } - return { hasWildcard, usernames, ids }; + return { hasWildcard, usernames, ids, hasEntries: normalized.length > 0 }; } function isSenderAllowed(params: { @@ -66,6 +67,46 @@ function resolveTelegramUserPeer(target: string): number | string { return target; } +function firstDefined(...values: Array): T | undefined { + for (const value of values) { + if (typeof value !== "undefined") return value; + } + return undefined; +} + +function buildTelegramUserGroupPeerId(chatId: number | string, threadId?: number) { + return threadId != null ? `${chatId}:topic:${threadId}` : String(chatId); +} + +function buildTelegramUserGroupFrom(chatId: number | string, threadId?: number) { + return `telegram-user:group:${buildTelegramUserGroupPeerId(chatId, threadId)}`; +} + +function buildTelegramUserGroupLabel( + title: string | undefined, + chatId: number | string, + threadId?: number, +) { + const topicSuffix = threadId != null ? ` topic:${threadId}` : ""; + if (title) return `${title} id:${chatId}${topicSuffix}`; + return `group:${chatId}${topicSuffix}`; +} + +function resolveTelegramUserGroupConfig( + accountConfig: TelegramUserAccountConfig, + chatId: number | string, + threadId?: number, +) { + const groups = accountConfig.groups ?? {}; + const chatKey = String(chatId); + const groupConfig = groups[chatKey] ?? groups["*"]; + if (!threadId) return { groupConfig, topicConfig: undefined }; + const topicKey = String(threadId); + const topicConfig = + groupConfig?.topics?.[topicKey] ?? groups["*"]?.topics?.[topicKey]; + return { groupConfig, topicConfig }; +} + async function resolveMediaAttachment(params: { client: TelegramClient; mediaMaxMb: number; @@ -102,17 +143,21 @@ async function resolveMediaAttachment(params: { } export function createTelegramUserMessageHandler(params: TelegramUserHandlerParams) { - const { client, cfg, runtime, accountId, accountConfig } = params; + const { client, cfg, runtime, accountId, accountConfig, self } = params; const core = getTelegramUserRuntime(); const textLimit = accountConfig.textChunkLimit ?? DEFAULT_TEXT_LIMIT; const mediaMaxMb = accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; const dmPolicy = accountConfig.dmPolicy ?? "pairing"; const allowFrom = accountConfig.allowFrom ?? []; + const groupAllowFrom = accountConfig.groupAllowFrom ?? allowFrom; return async (msg: MessageContext) => { try { if (msg.isOutgoing || msg.isService) return; - if (msg.chat.type !== "user") return; + const isDirect = msg.chat.type === "user"; + const isGroup = + msg.chat.type === "chat" && msg.chat.chatType !== "channel"; + if (!isDirect && !isGroup) return; const sender = await msg.getCompleteSender().catch(() => msg.sender); if (sender.type !== "user") return; @@ -126,32 +171,95 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara .readAllowFromStore("telegram-user") .catch(() => []); const combinedAllowFrom = [...allowFrom, ...storeAllowFrom]; + const chatId = msg.chat.type === "chat" ? msg.chat.id : undefined; + const threadId = + isGroup && msg.isTopicMessage + ? msg.replyToMessage?.threadId ?? undefined + : undefined; + const { groupConfig, topicConfig } = + isGroup && chatId != null + ? resolveTelegramUserGroupConfig(accountConfig, chatId, threadId) + : { groupConfig: undefined, topicConfig: undefined }; - if (dmPolicy === "disabled") return; - if ( - dmPolicy !== "open" && - !isSenderAllowed({ allowFrom: combinedAllowFrom, senderId, senderUsername }) - ) { - if (dmPolicy === "pairing") { - const pairing = await core.channel.pairing.upsertPairingRequest({ - channel: "telegram-user", - id: senderId, - meta: { - username: senderUsername ?? undefined, - name: senderName, - }, + const groupAllowOverride = firstDefined( + topicConfig?.allowFrom, + groupConfig?.allowFrom, + ); + const groupAllowEntries = [ + ...((groupAllowOverride ?? groupAllowFrom) as Array), + ...storeAllowFrom, + ]; + const effectiveGroupAllow = parseAllowlist(groupAllowEntries); + const effectiveDmAllow = parseAllowlist(combinedAllowFrom); + + if (isDirect) { + if (dmPolicy === "disabled") return; + if ( + dmPolicy !== "open" && + !isSenderAllowed({ + allowFrom: combinedAllowFrom, + senderId, + senderUsername, + }) + ) { + if (dmPolicy === "pairing") { + const pairing = await core.channel.pairing.upsertPairingRequest({ + channel: "telegram-user", + id: senderId, + meta: { + username: senderUsername ?? undefined, + name: senderName, + }, + }); + const reply = core.channel.pairing.buildPairingReply({ + channel: "telegram-user", + idLine: `Telegram user id: ${senderId}`, + code: pairing.code, + }); + await sendMessageTelegramUser(`telegram-user:${senderId}`, reply, { + client, + accountId, + }); + } + return; + } + } else if (isGroup) { + if (groupConfig?.enabled === false) return; + if (topicConfig?.enabled === false) return; + if (typeof groupAllowOverride !== "undefined") { + const allowed = isSenderAllowed({ + allowFrom: groupAllowEntries, + senderId, + senderUsername, }); - const reply = core.channel.pairing.buildPairingReply({ + if (!allowed) return; + } + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = + accountConfig.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy === "disabled") return; + if (groupPolicy === "allowlist") { + if (!senderId) return; + if (!effectiveGroupAllow.hasEntries) return; + if ( + !isSenderAllowed({ + allowFrom: groupAllowEntries, + senderId, + senderUsername, + }) + ) { + return; + } + } + if (chatId != null) { + const groupAllowlist = core.channel.groups.resolveGroupPolicy({ + cfg, channel: "telegram-user", - idLine: `Telegram user id: ${senderId}`, - code: pairing.code, - }); - await sendMessageTelegramUser(`telegram-user:${senderId}`, reply, { - client, + groupId: String(chatId), accountId, }); + if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) return; } - return; } const text = msg.text?.trim() ?? ""; @@ -171,24 +279,95 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara direction: "inbound", }); + const groupPeerId = + isGroup && chatId != null + ? buildTelegramUserGroupPeerId(chatId, threadId) + : null; const route = core.channel.routing.resolveAgentRoute({ cfg, channel: "telegram-user", accountId, peer: { - kind: "dm", - id: senderId, + kind: isGroup ? "group" : "dm", + id: isGroup && groupPeerId ? groupPeerId : senderId, }, }); + const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId); + const hasAnyMention = msg.entities.some( + (ent) => ent.kind === "mention" || ent.kind === "text_mention", + ); + const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg, { + botUsername: self?.username?.trim().toLowerCase(), + }); + const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow; + const senderAllowedForCommands = isSenderAllowed({ + allowFrom: isGroup ? groupAllowEntries : combinedAllowFrom, + senderId, + senderUsername, + }); + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }], + }); + if (isGroup && hasControlCommandInMessage && !commandAuthorized) return; + + const computedWasMentioned = + msg.isMention || core.channel.mentions.matchesMentionPatterns(text, mentionRegexes); + const baseRequireMention = isGroup + ? core.channel.groups.resolveRequireMention({ + cfg, + channel: "telegram-user", + groupId: chatId != null ? String(chatId) : undefined, + accountId, + }) + : false; + const requireMention = firstDefined( + topicConfig?.requireMention, + groupConfig?.requireMention, + baseRequireMention, + ); + const replySenderId = + msg.replyToMessage?.sender?.type === "user" + ? msg.replyToMessage.sender.id + : undefined; + const implicitMention = + isGroup && Boolean(requireMention) && self?.id != null && replySenderId === self.id; + const canDetectMention = + Boolean(self?.username) || mentionRegexes.length > 0 || msg.isMention; + const mentionGate = resolveMentionGatingWithBypass({ + isGroup, + requireMention: Boolean(requireMention), + canDetectMention, + wasMentioned: computedWasMentioned, + implicitMention, + hasAnyMention, + allowTextCommands: true, + hasControlCommand: hasControlCommandInMessage, + commandAuthorized, + }); + const effectiveWasMentioned = mentionGate.effectiveWasMentioned; + if (isGroup && requireMention && canDetectMention && mentionGate.shouldSkip) { + return; + } + const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions"; const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const ackReaction = resolveAckReaction(cfg, route.agentId); - const shouldAckReaction = - Boolean(ackReaction) && (ackReactionScope === "all" || ackReactionScope === "direct"); - const ackReactionPromise = shouldAckReaction + const shouldAckReaction = () => { + if (!ackReaction) return false; + if (ackReactionScope === "all") return true; + if (ackReactionScope === "direct") return !isGroup; + if (ackReactionScope === "group-all") return isGroup; + if (ackReactionScope === "group-mentions") { + return isGroup && Boolean(requireMention) && canDetectMention && effectiveWasMentioned; + } + return false; + }; + const ackReactionPromise = shouldAckReaction() ? client .sendReaction({ - chatId: senderPeer, + chatId: isGroup && chatId != null ? chatId : senderPeer, message: msg.id, emoji: ackReaction, }) @@ -206,6 +385,10 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara storePath, sessionKey: route.sessionKey, }); + const groupTitle = msg.chat.type === "chat" ? msg.chat.title : undefined; + const conversationLabel = isGroup && chatId != null + ? buildTelegramUserGroupLabel(groupTitle, chatId, threadId) + : senderName; const body = core.channel.reply.formatAgentEnvelope({ channel: "Telegram User", from: senderName, @@ -219,12 +402,14 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara Body: body, RawBody: text, CommandBody: text, - From: `telegram-user:${senderId}`, - To: `telegram-user:${senderId}`, + From: isGroup && chatId != null ? buildTelegramUserGroupFrom(chatId, threadId) : `telegram-user:${senderId}`, + To: isGroup && chatId != null ? buildTelegramUserGroupFrom(chatId, threadId) : `telegram-user:${senderId}`, SessionKey: route.sessionKey, AccountId: route.accountId, - ChatType: "direct", - ConversationLabel: senderName, + ChatType: isGroup ? "group" : "direct", + ConversationLabel: conversationLabel, + GroupSubject: isGroup ? groupTitle ?? undefined : undefined, + GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt ?? undefined : undefined, SenderName: senderName, SenderId: senderId, SenderUsername: senderUsername ?? undefined, @@ -236,10 +421,14 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara MediaPath: media?.path, MediaType: media?.contentType, MediaUrl: media?.path, - CommandAuthorized: true, + CommandAuthorized: commandAuthorized, CommandSource: "text" as const, OriginatingChannel: "telegram-user" as const, - OriginatingTo: `telegram-user:${senderId}`, + OriginatingTo: + isGroup && chatId != null + ? buildTelegramUserGroupFrom(chatId, threadId) + : `telegram-user:${senderId}`, + WasMentioned: isGroup ? effectiveWasMentioned : undefined, }); void core.channel.session @@ -262,6 +451,10 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara }); let hasReplied = false; + const replyTarget = + isGroup && chatId != null ? `telegram-user:${chatId}` : `telegram-user:${senderId}`; + const typingTarget = isGroup && chatId != null ? chatId : senderPeer; + const typingParams = isGroup && threadId != null ? { threadId } : undefined; const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) @@ -272,7 +465,7 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara const replyText = payload.text ?? ""; const mediaUrl = payload.mediaUrl; if (mediaUrl) { - await sendMediaTelegramUser(`telegram-user:${senderId}`, replyText, { + await sendMediaTelegramUser(replyTarget, replyText, { client, accountId, replyToId, @@ -291,7 +484,7 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara for (const chunk of core.channel.text.chunkMarkdownText(replyText, textLimit)) { const trimmed = chunk.trim(); if (!trimmed) continue; - await sendMessageTelegramUser(`telegram-user:${senderId}`, trimmed, { + await sendMessageTelegramUser(replyTarget, trimmed, { client, accountId, replyToId, @@ -306,7 +499,7 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara } }, onReplyStart: async () => { - await client.sendTyping(senderPeer).catch((err) => { + await client.sendTyping(typingTarget, "typing", typingParams).catch((err) => { runtime.error?.(`telegram-user typing failed: ${String(err)}`); }); }, @@ -328,7 +521,7 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara if (didAck) { await client .sendReaction({ - chatId: senderPeer, + chatId: isGroup && chatId != null ? chatId : senderPeer, message: msg.id, emoji: null, }) diff --git a/extensions/telegram-user/src/monitor/index.ts b/extensions/telegram-user/src/monitor/index.ts index 55d3d4203..3aff04f2a 100644 --- a/extensions/telegram-user/src/monitor/index.ts +++ b/extensions/telegram-user/src/monitor/index.ts @@ -67,15 +67,27 @@ export async function monitorTelegramUserProvider(opts: MonitorTelegramUserOpts await client.start(); const dispatcher = Dispatcher.for(client); + const self = await client.getMe().catch(() => undefined); const handleMessage = createTelegramUserMessageHandler({ client, cfg, runtime, accountId: account.accountId, accountConfig: account.config, + self: self + ? { id: self.id, username: "username" in self ? self.username : undefined } + : undefined, }); - dispatcher.onNewMessage(filters.chat("user"), handleMessage); + dispatcher.onNewMessage( + filters.or( + filters.chat("user"), + filters.chat("group"), + filters.chat("supergroup"), + filters.chat("gigagroup"), + ), + handleMessage, + ); await new Promise((resolve, reject) => { client.onError.add((err) => { diff --git a/extensions/telegram-user/src/types.ts b/extensions/telegram-user/src/types.ts index 2c7439af7..06085e1ed 100644 --- a/extensions/telegram-user/src/types.ts +++ b/extensions/telegram-user/src/types.ts @@ -1,4 +1,21 @@ -export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; +import type { DmPolicy, GroupPolicy } from "clawdbot/plugin-sdk"; + +export type TelegramUserTopicConfig = { + requireMention?: boolean; + skills?: string[]; + enabled?: boolean; + allowFrom?: Array; + systemPrompt?: string; +}; + +export type TelegramUserGroupConfig = { + requireMention?: boolean; + skills?: string[]; + topics?: Record; + enabled?: boolean; + allowFrom?: Array; + systemPrompt?: string; +}; export type TelegramUserAccountConfig = { /** Optional display name for this account (used in CLI/UI lists). */ @@ -17,6 +34,12 @@ export type TelegramUserAccountConfig = { textChunkLimit?: number; /** Max outbound media size in MB. */ mediaMaxMb?: number; + /** Optional allowlist for Telegram group senders (user ids or usernames). */ + groupAllowFrom?: Array; + /** Controls how group messages are handled (open | disabled | allowlist). */ + groupPolicy?: GroupPolicy; + /** Group-specific overrides (keyed by chat id). */ + groups?: Record; }; export type TelegramUserConfig = TelegramUserAccountConfig & { @@ -25,7 +48,17 @@ export type TelegramUserConfig = TelegramUserAccountConfig & { export type CoreConfig = { channels?: { + defaults?: { + groupPolicy?: GroupPolicy; + }; "telegram-user"?: TelegramUserConfig; }; + commands?: { + useAccessGroups?: boolean; + }; + messages?: { + ackReactionScope?: "off" | "group-mentions" | "group-all" | "direct" | "all"; + removeAckAfterReply?: boolean; + }; [key: string]: unknown; };