import type { TelegramForwardChat, TelegramForwardOrigin, TelegramForwardUser, TelegramForwardedMessage, TelegramLocation, TelegramMessage, TelegramStreamMode, TelegramVenue, } from "./types.js"; import { formatLocationText, type NormalizedLocation } from "../../channels/location.js"; const TELEGRAM_GENERAL_TOPIC_ID = 1; /** * Resolve the thread ID for Telegram forum topics. * For non-forum groups, returns undefined even if messageThreadId is present * (reply threads in regular groups should not create separate sessions). * For forum groups, returns the topic ID (or General topic ID=1 if unspecified). */ export function resolveTelegramForumThreadId(params: { isForum?: boolean; messageThreadId?: number | null; }) { // Non-forum groups: ignore message_thread_id (reply threads are not real topics) if (!params.isForum) { return undefined; } // Forum groups: use the topic ID, defaulting to General topic if (params.messageThreadId == null) { return TELEGRAM_GENERAL_TOPIC_ID; } return params.messageThreadId; } /** * Build thread params for Telegram API calls (messages, media). * General forum topic (id=1) must be treated like a regular supergroup send: * Telegram rejects sendMessage/sendMedia with message_thread_id=1 ("thread not found"). */ export function buildTelegramThreadParams(messageThreadId?: number) { if (messageThreadId == null) { return undefined; } const normalized = Math.trunc(messageThreadId); if (normalized === TELEGRAM_GENERAL_TOPIC_ID) { return undefined; } return { message_thread_id: normalized }; } /** * Build thread params for typing indicators (sendChatAction). * Empirically, General topic (id=1) needs message_thread_id for typing to appear. */ export function buildTypingThreadParams(messageThreadId?: number) { if (messageThreadId == null) { return undefined; } return { message_thread_id: Math.trunc(messageThreadId) }; } export function resolveTelegramStreamMode(telegramCfg?: { streamMode?: TelegramStreamMode; }): TelegramStreamMode { const raw = telegramCfg?.streamMode?.trim().toLowerCase(); if (raw === "off" || raw === "partial" || raw === "block") { return raw; } return "partial"; } export function buildTelegramGroupPeerId(chatId: number | string, messageThreadId?: number) { return messageThreadId != null ? `${chatId}:topic:${messageThreadId}` : String(chatId); } export function buildTelegramGroupFrom(chatId: number | string, messageThreadId?: number) { return `telegram:group:${buildTelegramGroupPeerId(chatId, messageThreadId)}`; } export function buildSenderName(msg: TelegramMessage) { const name = [msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() || msg.from?.username; return name || undefined; } export function buildSenderLabel(msg: TelegramMessage, senderId?: number | string) { const name = buildSenderName(msg); const username = msg.from?.username ? `@${msg.from.username}` : undefined; let label = name; if (name && username) { label = `${name} (${username})`; } else if (!name && username) { label = username; } const normalizedSenderId = senderId != null && `${senderId}`.trim() ? `${senderId}`.trim() : undefined; const fallbackId = normalizedSenderId ?? (msg.from?.id != null ? String(msg.from.id) : undefined); const idPart = fallbackId ? `id:${fallbackId}` : undefined; if (label && idPart) { return `${label} ${idPart}`; } if (label) { return label; } return idPart ?? "id:unknown"; } export function buildGroupLabel( msg: TelegramMessage, chatId: number | string, messageThreadId?: number, ) { const title = msg.chat?.title; const topicSuffix = messageThreadId != null ? ` topic:${messageThreadId}` : ""; if (title) { return `${title} id:${chatId}${topicSuffix}`; } return `group:${chatId}${topicSuffix}`; } export function hasBotMention(msg: TelegramMessage, botUsername: string) { const text = (msg.text ?? msg.caption ?? "").toLowerCase(); if (text.includes(`@${botUsername}`)) { return true; } const entities = msg.entities ?? msg.caption_entities ?? []; for (const ent of entities) { if (ent.type !== "mention") { continue; } const slice = (msg.text ?? msg.caption ?? "").slice(ent.offset, ent.offset + ent.length); if (slice.toLowerCase() === `@${botUsername}`) { return true; } } return false; } type TelegramTextLinkEntity = { type: string; offset: number; length: number; url?: string; }; export function expandTextLinks(text: string, entities?: TelegramTextLinkEntity[] | null): string { if (!text || !entities?.length) { return text; } const textLinks = entities .filter( (entity): entity is TelegramTextLinkEntity & { url: string } => entity.type === "text_link" && Boolean(entity.url), ) .toSorted((a, b) => b.offset - a.offset); if (textLinks.length === 0) { return text; } let result = text; for (const entity of textLinks) { const linkText = text.slice(entity.offset, entity.offset + entity.length); const markdown = `[${linkText}](${entity.url})`; result = result.slice(0, entity.offset) + markdown + result.slice(entity.offset + entity.length); } return result; } export function resolveTelegramReplyId(raw?: string): number | undefined { if (!raw) { return undefined; } const parsed = Number(raw); if (!Number.isFinite(parsed)) { return undefined; } return parsed; } export type TelegramReplyTarget = { id?: string; sender: string; body: string; kind: "reply" | "quote"; }; export function describeReplyTarget(msg: TelegramMessage): TelegramReplyTarget | null { const reply = msg.reply_to_message; const quote = msg.quote; let body = ""; let kind: TelegramReplyTarget["kind"] = "reply"; if (quote?.text) { body = quote.text.trim(); if (body) { kind = "quote"; } } if (!body && reply) { const replyBody = (reply.text ?? reply.caption ?? "").trim(); body = replyBody; if (!body) { if (reply.photo) { body = ""; } else if (reply.video) { body = ""; } else if (reply.audio || reply.voice) { body = ""; } else if (reply.document) { body = ""; } else { const locationData = extractTelegramLocation(reply); if (locationData) { body = formatLocationText(locationData); } } } } if (!body) { return null; } const sender = reply ? buildSenderName(reply) : undefined; const senderLabel = sender ? `${sender}` : "unknown sender"; return { id: reply?.message_id ? String(reply.message_id) : undefined, sender: senderLabel, body, kind, }; } export type TelegramForwardedContext = { from: string; date?: number; fromType: string; fromId?: string; fromUsername?: string; fromTitle?: string; fromSignature?: string; }; function normalizeForwardedUserLabel(user: TelegramForwardUser) { const name = [user.first_name, user.last_name].filter(Boolean).join(" ").trim(); const username = user.username?.trim() || undefined; const id = user.id != null ? String(user.id) : undefined; const display = (name && username ? `${name} (@${username})` : name || (username ? `@${username}` : undefined)) || (id ? `user:${id}` : undefined); return { display, name: name || undefined, username, id }; } function normalizeForwardedChatLabel(chat: TelegramForwardChat, fallbackKind: "chat" | "channel") { const title = chat.title?.trim() || undefined; const username = chat.username?.trim() || undefined; const id = chat.id != null ? String(chat.id) : undefined; const display = title || (username ? `@${username}` : undefined) || (id ? `${fallbackKind}:${id}` : undefined); return { display, title, username, id }; } function buildForwardedContextFromUser(params: { user: TelegramForwardUser; date?: number; type: string; }): TelegramForwardedContext | null { const { display, name, username, id } = normalizeForwardedUserLabel(params.user); if (!display) { return null; } return { from: display, date: params.date, fromType: params.type, fromId: id, fromUsername: username, fromTitle: name, }; } function buildForwardedContextFromHiddenName(params: { name?: string; date?: number; type: string; }): TelegramForwardedContext | null { const trimmed = params.name?.trim(); if (!trimmed) { return null; } return { from: trimmed, date: params.date, fromType: params.type, fromTitle: trimmed, }; } function buildForwardedContextFromChat(params: { chat: TelegramForwardChat; date?: number; type: string; signature?: string; }): TelegramForwardedContext | null { const fallbackKind = params.type === "channel" || params.type === "legacy_channel" ? "channel" : "chat"; const { display, title, username, id } = normalizeForwardedChatLabel(params.chat, fallbackKind); if (!display) { return null; } const signature = params.signature?.trim() || undefined; const from = signature ? `${display} (${signature})` : display; return { from, date: params.date, fromType: params.type, fromId: id, fromUsername: username, fromTitle: title, fromSignature: signature, }; } function resolveForwardOrigin( origin: TelegramForwardOrigin, signature?: string, ): TelegramForwardedContext | null { if (origin.type === "user" && origin.sender_user) { return buildForwardedContextFromUser({ user: origin.sender_user, date: origin.date, type: "user", }); } if (origin.type === "hidden_user") { return buildForwardedContextFromHiddenName({ name: origin.sender_user_name, date: origin.date, type: "hidden_user", }); } if (origin.type === "chat" && origin.sender_chat) { return buildForwardedContextFromChat({ chat: origin.sender_chat, date: origin.date, type: "chat", signature, }); } if (origin.type === "channel" && origin.chat) { return buildForwardedContextFromChat({ chat: origin.chat, date: origin.date, type: "channel", signature, }); } return null; } /** * Extract forwarded message origin info from Telegram message. * Supports both new forward_origin API and legacy forward_from/forward_from_chat fields. */ export function normalizeForwardedContext(msg: TelegramMessage): TelegramForwardedContext | null { const forwardMsg = msg as TelegramForwardedMessage; const signature = forwardMsg.forward_signature?.trim() || undefined; if (forwardMsg.forward_origin) { const originContext = resolveForwardOrigin(forwardMsg.forward_origin, signature); if (originContext) { return originContext; } } if (forwardMsg.forward_from_chat) { const legacyType = forwardMsg.forward_from_chat.type === "channel" ? "legacy_channel" : "legacy_chat"; const legacyContext = buildForwardedContextFromChat({ chat: forwardMsg.forward_from_chat, date: forwardMsg.forward_date, type: legacyType, signature, }); if (legacyContext) { return legacyContext; } } if (forwardMsg.forward_from) { const legacyContext = buildForwardedContextFromUser({ user: forwardMsg.forward_from, date: forwardMsg.forward_date, type: "legacy_user", }); if (legacyContext) { return legacyContext; } } const hiddenContext = buildForwardedContextFromHiddenName({ name: forwardMsg.forward_sender_name, date: forwardMsg.forward_date, type: "legacy_hidden_user", }); if (hiddenContext) { return hiddenContext; } return null; } export function extractTelegramLocation(msg: TelegramMessage): NormalizedLocation | null { const msgWithLocation = msg as { location?: TelegramLocation; venue?: TelegramVenue; }; const { venue, location } = msgWithLocation; if (venue) { return { latitude: venue.location.latitude, longitude: venue.location.longitude, accuracy: venue.location.horizontal_accuracy, name: venue.title, address: venue.address, source: "place", isLive: false, }; } if (location) { const isLive = typeof location.live_period === "number" && location.live_period > 0; return { latitude: location.latitude, longitude: location.longitude, accuracy: location.horizontal_accuracy, source: isLive ? "live" : "pin", isLive, }; } return null; }