Telegram user: preserve threads and update docs

This commit is contained in:
Muhammed Mukhthar CM 2026-01-23 17:17:18 +00:00
parent 39d9eb4589
commit 63929bd70c
8 changed files with 57 additions and 20 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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
},

View File

@ -181,20 +181,25 @@ export const telegramUserPlugin: ChannelPlugin<ResolvedTelegramUserAccount> = {
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 };
},

View File

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

View File

@ -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;

View File

@ -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<ReturnType<TelegramClient["sendText"]>> | 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;

View File

@ -30,6 +30,8 @@ export type TelegramUserAccountConfig = {
dmPolicy?: DmPolicy;
/** Allowlist for DM senders (user ids or usernames, or "*"). */
allowFrom?: Array<string | number>;
/** 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. */