Files
dragonsstash/worker/src/tdlib/topics.ts
xCyanGrizzly e8daabd28d
All checks were successful
continuous-integration/drone/push Build is passing
fix(tdlib): handle 1.8.64 renames in searchChatMessages + message reply_to
Audit of every TDLib call site against the live 1.8.64 schema in
node_modules/@prebuilt-tdlib/types/tdlib-types.d.ts surfaced three
additional silent breakages beyond the getForumTopics fix in 106700b.

1. searchChatMessages parameter restructure
   The top-level `message_thread_id` and `saved_messages_topic_id`
   request fields were collapsed into a single tagged-union
   `topic_id: MessageTopic$Input`. Three call sites affected:

   - topics.ts getTopicMessages — was passing message_thread_id, now
     sends topic_id with the messageTopicForum variant carrying
     forum_topic_id. Without this the topic scan returns the whole
     channel (or nothing) instead of just the topic.
   - download.ts getChannelMessages — used to pass message_thread_id: 0;
     just omit the topic_id field entirely for a flat scan.
   - rebuild.ts — same treatment.

2. message.reply_to_message_id replaced with reply_to tagged union
   On incoming messages, the flat `reply_to_message_id` field was
   replaced with `reply_to: MessageReplyTo` (messageReplyToMessage or
   messageReplyToStory). Our reply-chain grouping needs the message-ID
   case.

   Added extractReplyToMessageId() that reads both old and new shapes
   so a transition build or future downgrade still works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:45:06 +02:00

339 lines
10 KiB
TypeScript

import type { Client } from "tdl";
import { config } from "../util/config.js";
import { childLogger } from "../util/logger.js";
import { isArchiveAttachment } from "../archive/detect.js";
import type { TelegramMessage } from "../archive/multipart.js";
import type { TelegramPhoto } from "../preview/match.js";
import type { ChannelScanResult, ScanProgressCallback } from "./download.js";
import { invokeWithTimeout, MAX_SCAN_PAGES, INVOKE_TIMEOUT_MS } from "./download.js";
const log = childLogger("topics");
export interface ForumTopic {
topicId: bigint;
name: string;
}
/**
* Check if a chat is a forum supergroup (topics enabled).
*/
export async function isChatForum(
client: Client,
chatId: bigint
): Promise<boolean> {
try {
const chat = await invokeWithTimeout<{
type?: {
_: string;
supergroup_id?: number;
is_forum?: boolean;
};
}>(client, {
_: "getChat",
chat_id: Number(chatId),
});
if (chat.type?._ === "chatTypeSupergroup" && chat.type.is_forum) {
return true;
}
// Also check via getSupergroup for older TDLib versions
if (chat.type?._ === "chatTypeSupergroup" && chat.type.supergroup_id) {
const sg = await invokeWithTimeout<{ is_forum?: boolean }>(client, {
_: "getSupergroup",
supergroup_id: chat.type.supergroup_id,
});
return sg.is_forum === true;
}
return false;
} catch (err) {
log.warn({ err, chatId: chatId.toString() }, "Failed to check if chat is forum");
return false;
}
}
/**
* Get all forum topics in a supergroup.
* Includes stuck detection and timeout protection on API calls.
*/
export async function getForumTopicList(
client: Client,
chatId: bigint
): Promise<ForumTopic[]> {
const topics: ForumTopic[] = [];
let offsetDate = 0;
let offsetMessageId = 0;
// TDLib 1.8.64+ renamed `offset_message_thread_id` → `offset_forum_topic_id`
// in the getForumTopics request, and `next_offset_message_thread_id` →
// `next_offset_forum_topic_id` in the response. Individual topic infos
// also moved from `info.message_thread_id` → `info.forum_topic_id`.
let offsetForumTopicId = 0;
let pageCount = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
if (pageCount >= MAX_SCAN_PAGES) {
log.warn(
{ chatId: chatId.toString(), pageCount, topicCount: topics.length },
"Hit max page limit for topic enumeration, stopping"
);
break;
}
pageCount++;
const prevOffsetDate = offsetDate;
const prevOffsetMessageId = offsetMessageId;
const prevOffsetForumTopicId = offsetForumTopicId;
const result = await invokeWithTimeout<{
topics?: {
info?: {
// Both names — 1.8.50 used the first, 1.8.64+ uses the second.
// Read both so a future TDLib downgrade or transition build is
// still handled.
message_thread_id?: number;
forum_topic_id?: number;
name?: string;
is_general?: boolean;
};
}[];
next_offset_date?: number;
next_offset_message_id?: number;
next_offset_message_thread_id?: number;
next_offset_forum_topic_id?: number;
}>(client, {
_: "getForumTopics",
chat_id: Number(chatId),
query: "",
offset_date: offsetDate,
offset_message_id: offsetMessageId,
offset_forum_topic_id: offsetForumTopicId,
limit: 100,
});
if (!result.topics || result.topics.length === 0) break;
for (const t of result.topics) {
const topicId = t.info?.forum_topic_id ?? t.info?.message_thread_id;
if (!topicId) continue;
topics.push({
topicId: BigInt(topicId),
name: t.info?.is_general ? "General" : (t.info?.name ?? "Unnamed"),
});
}
// Check if there are more pages
const nextForumTopicId =
result.next_offset_forum_topic_id ?? result.next_offset_message_thread_id;
if (
!result.next_offset_date &&
!result.next_offset_message_id &&
!nextForumTopicId
) {
break;
}
offsetDate = result.next_offset_date ?? 0;
offsetMessageId = result.next_offset_message_id ?? 0;
offsetForumTopicId = nextForumTopicId ?? 0;
// Stuck detection: if offsets didn't advance, break
if (
offsetDate === prevOffsetDate &&
offsetMessageId === prevOffsetMessageId &&
offsetForumTopicId === prevOffsetForumTopicId
) {
log.warn(
{ chatId: chatId.toString(), topicCount: topics.length },
"Topic pagination stuck (offsets not advancing), breaking"
);
break;
}
await sleep(config.apiDelayMs);
}
log.info(
{ chatId: chatId.toString(), topicCount: topics.length },
"Enumerated forum topics"
);
return topics;
}
/**
* Fetch messages from a specific forum topic (thread), stopping once
* we've scanned past the last-processed boundary (with one page of lookback).
* Uses searchChatMessages with message_thread_id to scan within a topic.
*
* Returns messages in chronological order (oldest first).
*
* When `lastProcessedMessageId` is null (first run), scans everything.
* The worker applies a post-grouping filter to skip fully-processed sets,
* and keeps `packageExistsBySourceMessage` as a safety net.
*
* Safety features:
* - Max page limit to prevent infinite loops
* - Stuck detection: breaks if from_message_id stops advancing
* - Timeout on each TDLib API call
*/
export async function getTopicMessages(
client: Client,
chatId: bigint,
topicId: bigint,
lastProcessedMessageId?: bigint | null,
limit = 100,
onProgress?: ScanProgressCallback
): Promise<ChannelScanResult> {
const archives: TelegramMessage[] = [];
const photos: TelegramPhoto[] = [];
const boundary = lastProcessedMessageId ? Number(lastProcessedMessageId) : null;
let maxScannedMessageId: bigint | null = null;
let currentFromId = 0;
let totalScanned = 0;
let pageCount = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
if (pageCount >= MAX_SCAN_PAGES) {
log.warn(
{ chatId: chatId.toString(), topicId: topicId.toString(), pageCount, totalScanned },
"Hit max page limit for topic scan, stopping"
);
break;
}
pageCount++;
const previousFromId = currentFromId;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await invokeWithTimeout<{
messages?: {
id: number;
date: number;
media_album_id?: string;
content: {
_: string;
document?: {
file_name?: string;
document?: {
id: number;
size: number;
remote?: { unique_id?: string };
};
};
photo?: {
sizes?: {
type: string;
photo: { id: number; size: number; expected_size: number };
width: number;
height: number;
}[];
};
caption?: { text?: string };
};
}[];
}>(client, {
_: "searchChatMessages",
chat_id: Number(chatId),
// TDLib 1.8.64+ replaced the top-level `message_thread_id` and
// `saved_messages_topic_id` parameters with a single tagged-union
// `topic_id: MessageTopic$Input`. For a forum topic, use the
// messageTopicForum variant carrying the forum_topic_id.
topic_id: {
_: "messageTopicForum",
forum_topic_id: Number(topicId),
},
query: "",
from_message_id: currentFromId,
offset: 0,
limit: Math.min(limit, 100),
filter: null,
sender_id: null,
});
if (!result.messages || result.messages.length === 0) break;
totalScanned += result.messages.length;
// Track highest message ID (first message = newest, since results are newest-first)
const batchMaxId = BigInt(result.messages[0].id);
if (maxScannedMessageId === null || batchMaxId > maxScannedMessageId) {
maxScannedMessageId = batchMaxId;
}
for (const msg of result.messages) {
// Check for archive documents
const doc = msg.content?.document;
if (doc?.file_name && doc.document && isArchiveAttachment(doc.file_name)) {
archives.push({
id: BigInt(msg.id),
fileName: doc.file_name,
fileId: String(doc.document.id),
fileSize: BigInt(doc.document.size),
date: new Date(msg.date * 1000),
mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined,
remoteUniqueId: doc.document.remote?.unique_id || undefined,
});
continue;
}
// Check for photo messages (potential previews)
const photo = msg.content?.photo;
const caption = msg.content?.caption?.text ?? "";
if (photo?.sizes && photo.sizes.length > 0) {
const smallest = photo.sizes[0];
photos.push({
id: BigInt(msg.id),
date: new Date(msg.date * 1000),
caption,
fileId: String(smallest.photo.id),
fileSize: smallest.photo.size || smallest.photo.expected_size,
mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined,
});
}
}
// Report scanning progress after each page
onProgress?.(totalScanned);
currentFromId = result.messages[result.messages.length - 1].id;
// Stuck detection: if from_message_id didn't advance, break to prevent infinite loop
if (currentFromId === previousFromId) {
log.warn(
{ chatId: chatId.toString(), topicId: topicId.toString(), currentFromId, totalScanned },
"Topic pagination stuck (from_message_id not advancing), breaking"
);
break;
}
// Stop scanning once we've gone past the boundary (this page is the lookback)
if (boundary && currentFromId < boundary) break;
if (result.messages.length < Math.min(limit, 100)) break;
await sleep(config.apiDelayMs);
}
log.info(
{ chatId: chatId.toString(), topicId: topicId.toString(), archives: archives.length, photos: photos.length, totalScanned, pages: pageCount },
"Topic scan complete"
);
// Reverse to chronological order (oldest first) so worker processes old→new
return {
archives: archives.reverse(),
photos: photos.reverse(),
totalScanned,
maxScannedMessageId,
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}