diff --git a/docs/channels/index.md b/docs/channels/index.md index 4402e777c..203b43259 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -13,7 +13,7 @@ Text is supported everywhere; media and reactions vary by channel. - [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing. - [Telegram](/channels/telegram) — Bot API via grammY; supports groups. -- [Telegram User](/channels/telegram-user) — MTProto user account; DM-only for now (plugin, installed separately). +- [Telegram User](/channels/telegram-user) — MTProto user account with DM + group support (plugin, installed separately). - [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs. - [Slack](/channels/slack) — Bolt SDK; workspace apps. - [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook. diff --git a/docs/channels/telegram-user.md b/docs/channels/telegram-user.md index 1d959950b..36d59ae37 100644 --- a/docs/channels/telegram-user.md +++ b/docs/channels/telegram-user.md @@ -1,10 +1,10 @@ --- -summary: "Connect a Telegram user account via MTProto (DM-only)" +summary: "Connect a Telegram user account via MTProto (DMs + groups)" --- # Telegram User Telegram User connects Clawdbot to a **personal Telegram account** using MTProto. -Use this when you need user-level DMs or want to message from your own account. +Use this when you need user-level DMs or want to message from your own account in groups. ## Requirements @@ -64,5 +64,5 @@ See [Pairing](/start/pairing) for details. ## Limitations -- DM-only (no groups or channels yet). +- Broadcast channels are not supported. - Calls are not supported. diff --git a/extensions/telegram-user/package.json b/extensions/telegram-user/package.json index 65f64a576..3585d1f73 100644 --- a/extensions/telegram-user/package.json +++ b/extensions/telegram-user/package.json @@ -14,7 +14,7 @@ "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 }, diff --git a/extensions/telegram-user/src/channel.ts b/extensions/telegram-user/src/channel.ts index 0a31e3f14..2199a98cd 100644 --- a/extensions/telegram-user/src/channel.ts +++ b/extensions/telegram-user/src/channel.ts @@ -181,20 +181,25 @@ export const telegramUserPlugin: ChannelPlugin = { getTelegramUserRuntime().channel.text.chunkMarkdownText(text, limit), textChunkLimit: 4000, pollMaxOptions: 10, - sendText: async ({ to, text, accountId }) => { - const result = await sendMessageTelegramUser(to, text, { accountId: accountId ?? undefined }); - return { channel: "telegram-user", ...result }; - }, - sendMedia: async ({ to, text, mediaUrl, accountId }) => { - const result = await sendMediaTelegramUser(to, text, { + sendText: async ({ to, text, accountId, threadId }) => { + const result = await sendMessageTelegramUser(to, text, { accountId: accountId ?? undefined, - mediaUrl, + threadId, }); return { channel: "telegram-user", ...result }; }, - sendPoll: async ({ to, poll, accountId }) => { + sendMedia: async ({ to, text, mediaUrl, accountId, threadId }) => { + const result = await sendMediaTelegramUser(to, text, { + accountId: accountId ?? undefined, + mediaUrl, + threadId, + }); + return { channel: "telegram-user", ...result }; + }, + sendPoll: async ({ to, poll, accountId, threadId }) => { const result = await sendPollTelegramUser(to, poll, { accountId: accountId ?? undefined, + threadId, }); return { channel: "telegram-user", ...result }; }, diff --git a/extensions/telegram-user/src/config-schema.ts b/extensions/telegram-user/src/config-schema.ts index a1b791566..134f97050 100644 --- a/extensions/telegram-user/src/config-schema.ts +++ b/extensions/telegram-user/src/config-schema.ts @@ -31,6 +31,7 @@ const TelegramUserAccountSchema = z apiHash: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), allowFrom: z.array(allowFromEntry).optional(), + replyToMode: z.enum(["off", "first", "all"]).optional(), textChunkLimit: z.number().int().positive().optional(), mediaMaxMb: z.number().positive().optional(), groupAllowFrom: z.array(allowFromEntry).optional(), diff --git a/extensions/telegram-user/src/monitor/handler.ts b/extensions/telegram-user/src/monitor/handler.ts index c66cd9f10..748bba880 100644 --- a/extensions/telegram-user/src/monitor/handler.ts +++ b/extensions/telegram-user/src/monitor/handler.ts @@ -661,6 +661,7 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara client, accountId, replyToId, + threadId, mediaUrl, maxBytes: mediaMaxMb * 1024 * 1024, }); @@ -685,6 +686,7 @@ export function createTelegramUserMessageHandler(params: TelegramUserHandlerPara client, accountId, replyToId, + threadId, }); } catch (err) { if (isDestroyedClientError(err)) return; diff --git a/extensions/telegram-user/src/send.ts b/extensions/telegram-user/src/send.ts index 5572f69c8..c2ec04885 100644 --- a/extensions/telegram-user/src/send.ts +++ b/extensions/telegram-user/src/send.ts @@ -29,17 +29,37 @@ export type TelegramUserSendOpts = { client?: TelegramClient; accountId?: string; replyToId?: number; + threadId?: string | number | null; mediaUrl?: string; }; -const normalizeTarget = (raw: string): string => { +function normalizeTarget(raw: string): string { const trimmed = raw.trim(); if (!trimmed) throw new Error("Recipient is required for Telegram User sends"); const withoutProvider = trimmed.replace(/^(telegram-user|telegram|tg):/i, "").trim(); const withoutPrefix = withoutProvider.replace(/^(user|group|channel|chat):/i, "").trim(); - const topicSplit = withoutPrefix.split(/:topic:/i); - return (topicSplit[0] ?? withoutPrefix).trim(); -}; + if (!withoutPrefix) throw new Error("Recipient is required for Telegram User sends"); + return withoutPrefix; +} + +function parseThreadId(value: string | number | null | undefined): number | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? Math.trunc(value) : undefined; + } + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) return undefined; + const parsed = Number.parseInt(trimmed, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function resolveTargetAndThread(raw: string, threadId?: string | number | null) { + const normalized = normalizeTarget(raw); + const [base, topicRaw] = normalized.split(/:topic:/i); + const parsedThreadId = parseThreadId(threadId ?? topicRaw); + const target = (base ?? normalized).trim(); + if (!target) throw new Error("Recipient is required for Telegram User sends"); + return { target, threadId: parsedThreadId }; +} export function normalizeTelegramUserMessagingTarget(raw: string): string { return normalizeTarget(raw); @@ -50,6 +70,7 @@ export function looksLikeTelegramUserTargetId(value: string): boolean { if (!trimmed) return false; if (/^telegram-user:/i.test(trimmed)) return true; if (/^(user|group|channel|chat):/i.test(trimmed)) return true; + if (/^-?\d+:topic:\d+$/i.test(trimmed)) return true; return /^-?\d+$/.test(trimmed) || /^@?[a-z0-9_]{5,}$/i.test(trimmed); } @@ -125,11 +146,13 @@ export async function sendMessageTelegramUser( accountId: opts.accountId, }); try { - const target = resolveTelegramUserPeer(normalizeTarget(to)); + const resolved = resolveTargetAndThread(to, opts.threadId); + const target = resolveTelegramUserPeer(resolved.target); let message: Awaited> | null = null; try { message = await client.sendText(target, text, { ...(opts.replyToId ? { replyTo: opts.replyToId } : {}), + ...(resolved.threadId ? { threadId: resolved.threadId } : {}), }); } catch (err) { if (!isDestroyedClientError(err)) throw err; @@ -157,7 +180,8 @@ export async function sendMediaTelegramUser( accountId: opts.accountId, }); try { - const target = resolveTelegramUserPeer(normalizeTarget(to)); + const resolved = resolveTargetAndThread(to, opts.threadId); + const target = resolveTelegramUserPeer(resolved.target); const media = await getTelegramUserRuntime().media.loadWebMedia(opts.mediaUrl, opts.maxBytes); const input = InputMedia.auto(media.buffer, { fileName: media.fileName ?? undefined, @@ -168,6 +192,7 @@ export async function sendMediaTelegramUser( try { message = await client.sendMedia(target, input, { ...(opts.replyToId ? { replyTo: opts.replyToId } : {}), + ...(resolved.threadId ? { threadId: resolved.threadId } : {}), }); } catch (err) { if (!isDestroyedClientError(err)) throw err; @@ -195,7 +220,8 @@ export async function sendPollTelegramUser( accountId: opts.accountId, }); try { - const target = resolveTelegramUserPeer(normalizeTarget(to)); + const resolved = resolveTargetAndThread(to, opts.threadId); + const target = resolveTelegramUserPeer(resolved.target); const normalized = normalizePollInput(poll); const input = InputMedia.poll({ question: normalized.question, @@ -206,6 +232,7 @@ export async function sendPollTelegramUser( try { message = await client.sendMedia(target, input, { ...(opts.replyToId ? { replyTo: opts.replyToId } : {}), + ...(resolved.threadId ? { threadId: resolved.threadId } : {}), }); } catch (err) { if (!isDestroyedClientError(err)) throw err; diff --git a/extensions/telegram-user/src/types.ts b/extensions/telegram-user/src/types.ts index 06085e1ed..26649e011 100644 --- a/extensions/telegram-user/src/types.ts +++ b/extensions/telegram-user/src/types.ts @@ -30,6 +30,8 @@ export type TelegramUserAccountConfig = { dmPolicy?: DmPolicy; /** Allowlist for DM senders (user ids or usernames, or "*"). */ allowFrom?: Array; + /** Control reply threading when reply tags are present (off|first|all). */ + replyToMode?: "off" | "first" | "all"; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; /** Max outbound media size in MB. */