Regular Telegram groups (without Topics/Forums enabled) can send message_thread_id when users reply to messages. This was incorrectly being used to create separate session keys like '-123:topic:42', causing each reply chain to get its own conversation context. Now resolveTelegramForumThreadId only returns a thread ID when the chat is actually a forum (is_forum=true). For regular groups, the thread ID is ignored, ensuring all messages share the same session. DMs continue to use messageThreadId for thread sessions as before.
408 lines
12 KiB
TypeScript
408 lines
12 KiB
TypeScript
import { formatLocationText, type NormalizedLocation } from "../../channels/location.js";
|
|
import type { TelegramAccountConfig } from "../../config/types.telegram.js";
|
|
import type {
|
|
TelegramForwardChat,
|
|
TelegramForwardOrigin,
|
|
TelegramForwardUser,
|
|
TelegramForwardedMessage,
|
|
TelegramLocation,
|
|
TelegramMessage,
|
|
TelegramStreamMode,
|
|
TelegramVenue,
|
|
} from "./types.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: Pick<TelegramAccountConfig, "streamMode"> | undefined,
|
|
): 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),
|
|
)
|
|
.sort((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;
|
|
}
|