Telegram-user: add group support

This commit is contained in:
Muhammed Mukhthar CM 2026-01-23 06:51:03 +00:00
parent bd3a8c3c91
commit d7b7242e9e
5 changed files with 306 additions and 44 deletions

View File

@ -40,7 +40,7 @@ const meta = {
detailLabel: "Telegram User", detailLabel: "Telegram User",
docsPath: "/channels/telegram-user", docsPath: "/channels/telegram-user",
docsLabel: "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, order: 12,
quickstartAllowFrom: true, quickstartAllowFrom: true,
}; };
@ -68,7 +68,7 @@ export const telegramUserPlugin: ChannelPlugin<ResolvedTelegramUserAccount> = {
}, },
}, },
capabilities: { capabilities: {
chatTypes: ["direct"], chatTypes: ["direct", "group"],
reactions: false, reactions: false,
threads: false, threads: false,
media: true, media: true,

View File

@ -2,6 +2,27 @@ import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]); 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 const TelegramUserAccountSchema = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
@ -12,6 +33,9 @@ const TelegramUserAccountSchema = z
allowFrom: z.array(allowFromEntry).optional(), allowFrom: z.array(allowFromEntry).optional(),
textChunkLimit: z.number().int().positive().optional(), textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().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(); .strict();

View File

@ -2,7 +2,7 @@ import type { TelegramClient } from "@mtcute/node";
import type { MessageContext } from "@mtcute/dispatcher"; import type { MessageContext } from "@mtcute/dispatcher";
import type { RuntimeEnv } from "clawdbot/plugin-sdk"; 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 { getTelegramUserRuntime } from "../runtime.js";
import type { CoreConfig, TelegramUserAccountConfig } from "../types.js"; import type { CoreConfig, TelegramUserAccountConfig } from "../types.js";
import { sendMediaTelegramUser, sendMessageTelegramUser } from "../send.js"; import { sendMediaTelegramUser, sendMessageTelegramUser } from "../send.js";
@ -16,6 +16,7 @@ type TelegramUserHandlerParams = {
runtime: RuntimeEnv; runtime: RuntimeEnv;
accountId: string; accountId: string;
accountConfig: TelegramUserAccountConfig; accountConfig: TelegramUserAccountConfig;
self?: { id: number; username?: string | null };
}; };
function normalizeAllowEntry(raw: string): string { function normalizeAllowEntry(raw: string): string {
@ -42,7 +43,7 @@ function parseAllowlist(entries: Array<string | number> | undefined) {
const username = entry.startsWith("@") ? entry.slice(1) : entry; const username = entry.startsWith("@") ? entry.slice(1) : entry;
if (username) usernames.add(username); if (username) usernames.add(username);
} }
return { hasWildcard, usernames, ids }; return { hasWildcard, usernames, ids, hasEntries: normalized.length > 0 };
} }
function isSenderAllowed(params: { function isSenderAllowed(params: {
@ -66,6 +67,46 @@ function resolveTelegramUserPeer(target: string): number | string {
return target; return target;
} }
function firstDefined<T>(...values: Array<T | undefined>): 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: { async function resolveMediaAttachment(params: {
client: TelegramClient; client: TelegramClient;
mediaMaxMb: number; mediaMaxMb: number;
@ -102,17 +143,21 @@ async function resolveMediaAttachment(params: {
} }
export function createTelegramUserMessageHandler(params: TelegramUserHandlerParams) { export function createTelegramUserMessageHandler(params: TelegramUserHandlerParams) {
const { client, cfg, runtime, accountId, accountConfig } = params; const { client, cfg, runtime, accountId, accountConfig, self } = params;
const core = getTelegramUserRuntime(); const core = getTelegramUserRuntime();
const textLimit = accountConfig.textChunkLimit ?? DEFAULT_TEXT_LIMIT; const textLimit = accountConfig.textChunkLimit ?? DEFAULT_TEXT_LIMIT;
const mediaMaxMb = accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; const mediaMaxMb = accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const dmPolicy = accountConfig.dmPolicy ?? "pairing"; const dmPolicy = accountConfig.dmPolicy ?? "pairing";
const allowFrom = accountConfig.allowFrom ?? []; const allowFrom = accountConfig.allowFrom ?? [];
const groupAllowFrom = accountConfig.groupAllowFrom ?? allowFrom;
return async (msg: MessageContext) => { return async (msg: MessageContext) => {
try { try {
if (msg.isOutgoing || msg.isService) return; 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); const sender = await msg.getCompleteSender().catch(() => msg.sender);
if (sender.type !== "user") return; if (sender.type !== "user") return;
@ -126,11 +171,36 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara
.readAllowFromStore("telegram-user") .readAllowFromStore("telegram-user")
.catch(() => []); .catch(() => []);
const combinedAllowFrom = [...allowFrom, ...storeAllowFrom]; 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 };
const groupAllowOverride = firstDefined(
topicConfig?.allowFrom,
groupConfig?.allowFrom,
);
const groupAllowEntries = [
...((groupAllowOverride ?? groupAllowFrom) as Array<string | number>),
...storeAllowFrom,
];
const effectiveGroupAllow = parseAllowlist(groupAllowEntries);
const effectiveDmAllow = parseAllowlist(combinedAllowFrom);
if (isDirect) {
if (dmPolicy === "disabled") return; if (dmPolicy === "disabled") return;
if ( if (
dmPolicy !== "open" && dmPolicy !== "open" &&
!isSenderAllowed({ allowFrom: combinedAllowFrom, senderId, senderUsername }) !isSenderAllowed({
allowFrom: combinedAllowFrom,
senderId,
senderUsername,
})
) { ) {
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
const pairing = await core.channel.pairing.upsertPairingRequest({ const pairing = await core.channel.pairing.upsertPairingRequest({
@ -153,6 +223,44 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara
} }
return; 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,
});
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",
groupId: String(chatId),
accountId,
});
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) return;
}
}
const text = msg.text?.trim() ?? ""; const text = msg.text?.trim() ?? "";
const media = await resolveMediaAttachment({ const media = await resolveMediaAttachment({
@ -171,24 +279,95 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara
direction: "inbound", direction: "inbound",
}); });
const groupPeerId =
isGroup && chatId != null
? buildTelegramUserGroupPeerId(chatId, threadId)
: null;
const route = core.channel.routing.resolveAgentRoute({ const route = core.channel.routing.resolveAgentRoute({
cfg, cfg,
channel: "telegram-user", channel: "telegram-user",
accountId, accountId,
peer: { peer: {
kind: "dm", kind: isGroup ? "group" : "dm",
id: senderId, 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 ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const ackReaction = resolveAckReaction(cfg, route.agentId); const ackReaction = resolveAckReaction(cfg, route.agentId);
const shouldAckReaction = const shouldAckReaction = () => {
Boolean(ackReaction) && (ackReactionScope === "all" || ackReactionScope === "direct"); if (!ackReaction) return false;
const ackReactionPromise = shouldAckReaction 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 ? client
.sendReaction({ .sendReaction({
chatId: senderPeer, chatId: isGroup && chatId != null ? chatId : senderPeer,
message: msg.id, message: msg.id,
emoji: ackReaction, emoji: ackReaction,
}) })
@ -206,6 +385,10 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara
storePath, storePath,
sessionKey: route.sessionKey, 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({ const body = core.channel.reply.formatAgentEnvelope({
channel: "Telegram User", channel: "Telegram User",
from: senderName, from: senderName,
@ -219,12 +402,14 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara
Body: body, Body: body,
RawBody: text, RawBody: text,
CommandBody: text, CommandBody: text,
From: `telegram-user:${senderId}`, From: isGroup && chatId != null ? buildTelegramUserGroupFrom(chatId, threadId) : `telegram-user:${senderId}`,
To: `telegram-user:${senderId}`, To: isGroup && chatId != null ? buildTelegramUserGroupFrom(chatId, threadId) : `telegram-user:${senderId}`,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
AccountId: route.accountId, AccountId: route.accountId,
ChatType: "direct", ChatType: isGroup ? "group" : "direct",
ConversationLabel: senderName, ConversationLabel: conversationLabel,
GroupSubject: isGroup ? groupTitle ?? undefined : undefined,
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt ?? undefined : undefined,
SenderName: senderName, SenderName: senderName,
SenderId: senderId, SenderId: senderId,
SenderUsername: senderUsername ?? undefined, SenderUsername: senderUsername ?? undefined,
@ -236,10 +421,14 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara
MediaPath: media?.path, MediaPath: media?.path,
MediaType: media?.contentType, MediaType: media?.contentType,
MediaUrl: media?.path, MediaUrl: media?.path,
CommandAuthorized: true, CommandAuthorized: commandAuthorized,
CommandSource: "text" as const, CommandSource: "text" as const,
OriginatingChannel: "telegram-user" 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 void core.channel.session
@ -262,6 +451,10 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara
}); });
let hasReplied = false; 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 } = const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({ core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
@ -272,7 +465,7 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara
const replyText = payload.text ?? ""; const replyText = payload.text ?? "";
const mediaUrl = payload.mediaUrl; const mediaUrl = payload.mediaUrl;
if (mediaUrl) { if (mediaUrl) {
await sendMediaTelegramUser(`telegram-user:${senderId}`, replyText, { await sendMediaTelegramUser(replyTarget, replyText, {
client, client,
accountId, accountId,
replyToId, replyToId,
@ -291,7 +484,7 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara
for (const chunk of core.channel.text.chunkMarkdownText(replyText, textLimit)) { for (const chunk of core.channel.text.chunkMarkdownText(replyText, textLimit)) {
const trimmed = chunk.trim(); const trimmed = chunk.trim();
if (!trimmed) continue; if (!trimmed) continue;
await sendMessageTelegramUser(`telegram-user:${senderId}`, trimmed, { await sendMessageTelegramUser(replyTarget, trimmed, {
client, client,
accountId, accountId,
replyToId, replyToId,
@ -306,7 +499,7 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara
} }
}, },
onReplyStart: async () => { onReplyStart: async () => {
await client.sendTyping(senderPeer).catch((err) => { await client.sendTyping(typingTarget, "typing", typingParams).catch((err) => {
runtime.error?.(`telegram-user typing failed: ${String(err)}`); runtime.error?.(`telegram-user typing failed: ${String(err)}`);
}); });
}, },
@ -328,7 +521,7 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara
if (didAck) { if (didAck) {
await client await client
.sendReaction({ .sendReaction({
chatId: senderPeer, chatId: isGroup && chatId != null ? chatId : senderPeer,
message: msg.id, message: msg.id,
emoji: null, emoji: null,
}) })

View File

@ -67,15 +67,27 @@ export async function monitorTelegramUserProvider(opts: MonitorTelegramUserOpts
await client.start(); await client.start();
const dispatcher = Dispatcher.for(client); const dispatcher = Dispatcher.for(client);
const self = await client.getMe().catch(() => undefined);
const handleMessage = createTelegramUserMessageHandler({ const handleMessage = createTelegramUserMessageHandler({
client, client,
cfg, cfg,
runtime, runtime,
accountId: account.accountId, accountId: account.accountId,
accountConfig: account.config, 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<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
client.onError.add((err) => { client.onError.add((err) => {

View File

@ -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<string | number>;
systemPrompt?: string;
};
export type TelegramUserGroupConfig = {
requireMention?: boolean;
skills?: string[];
topics?: Record<string, TelegramUserTopicConfig>;
enabled?: boolean;
allowFrom?: Array<string | number>;
systemPrompt?: string;
};
export type TelegramUserAccountConfig = { export type TelegramUserAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */ /** Optional display name for this account (used in CLI/UI lists). */
@ -17,6 +34,12 @@ export type TelegramUserAccountConfig = {
textChunkLimit?: number; textChunkLimit?: number;
/** Max outbound media size in MB. */ /** Max outbound media size in MB. */
mediaMaxMb?: number; mediaMaxMb?: number;
/** Optional allowlist for Telegram group senders (user ids or usernames). */
groupAllowFrom?: Array<string | number>;
/** Controls how group messages are handled (open | disabled | allowlist). */
groupPolicy?: GroupPolicy;
/** Group-specific overrides (keyed by chat id). */
groups?: Record<string, TelegramUserGroupConfig>;
}; };
export type TelegramUserConfig = TelegramUserAccountConfig & { export type TelegramUserConfig = TelegramUserAccountConfig & {
@ -25,7 +48,17 @@ export type TelegramUserConfig = TelegramUserAccountConfig & {
export type CoreConfig = { export type CoreConfig = {
channels?: { channels?: {
defaults?: {
groupPolicy?: GroupPolicy;
};
"telegram-user"?: TelegramUserConfig; "telegram-user"?: TelegramUserConfig;
}; };
commands?: {
useAccessGroups?: boolean;
};
messages?: {
ackReactionScope?: "off" | "group-mentions" | "group-all" | "direct" | "all";
removeAckAfterReply?: boolean;
};
[key: string]: unknown; [key: string]: unknown;
}; };