Spaces:
Paused
Paused
| 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 = "<media:image>"; | |
| } else if (reply.video) { | |
| body = "<media:video>"; | |
| } else if (reply.audio || reply.voice) { | |
| body = "<media:audio>"; | |
| } else if (reply.document) { | |
| body = "<media:document>"; | |
| } 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; | |
| } | |