2 Commits

Author SHA1 Message Date
e8daabd28d fix(tdlib): handle 1.8.64 renames in searchChatMessages + message reply_to
All checks were successful
continuous-integration/drone/push Build is passing
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
106700b13f fix(topics): handle TDLib 1.8.64 renamed forum-topic fields
After the TDLib upgrade in 18a0efb, getForumTopicList returned 0 topics
for every forum channel. Confirmed in production logs:

  "title":"Model Printing Emporium","topicCount":0
  "title":"GB_Butler_Bot2","topicCount":0
  "title":"Darnascus 2 : Flamigos Miniatures","topicCount":0

Cycle results: messagesScanned=0, zipsFound=0 — main account's entire
ingestion pipeline was a no-op because all source channels are forums.

Root cause: TDLib 1.8.64 renamed three fields without bumping the
breaking-change indicator we'd notice:

  Request  offset_message_thread_id           → offset_forum_topic_id
  Response next_offset_message_thread_id      → next_offset_forum_topic_id
  Response topics[].info.message_thread_id    → topics[].info.forum_topic_id

The old field names became no-ops in the new TDLib, so every request
came back with an empty topic list and the "stuck pagination" detection
correctly bailed out.

Fix: send the new field name on the request side, read both old and
new names on the response side (so a future TDLib version change in
either direction stays handled).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:18:08 +02:00
3 changed files with 62 additions and 15 deletions

View File

@@ -308,14 +308,15 @@ async function scanDestinationChannel(
}>(client, { }>(client, {
_: "searchChatMessages", _: "searchChatMessages",
chat_id: Number(chatId), chat_id: Number(chatId),
// No topic context for a flat destination scan. TDLib 1.8.64+ replaced
// `message_thread_id` / `saved_messages_topic_id` with a single
// optional `topic_id`; for a flat scan we just omit it.
query: "", query: "",
from_message_id: currentFromId, from_message_id: currentFromId,
offset: 0, offset: 0,
limit: 100, limit: 100,
filter: { _: "searchMessagesFilterDocument" }, filter: { _: "searchMessagesFilterDocument" },
sender_id: null, sender_id: null,
message_thread_id: 0,
saved_messages_topic_id: 0,
}); });
if (!result.messages || result.messages.length === 0) break; if (!result.messages || result.messages.length === 0) break;

View File

@@ -39,7 +39,15 @@ interface TdMessage {
id: number; id: number;
date: number; date: number;
media_album_id?: string; media_album_id?: string;
// TDLib 1.8.50 exposed `reply_to_message_id` directly on the message.
// 1.8.64+ replaced it with a tagged-union `reply_to: MessageReplyTo`.
// Read both for resilience across versions.
reply_to_message_id?: number; reply_to_message_id?: number;
reply_to?: {
_: string;
chat_id?: number;
message_id?: number;
};
content: { content: {
_: string; _: string;
document?: { document?: {
@@ -66,6 +74,24 @@ interface TdMessage {
}; };
} }
/**
* Pick the right "the message I'm replying to" ID across TDLib versions.
* - 1.8.50 and earlier expose it directly as `reply_to_message_id`.
* - 1.8.64+ expose `reply_to: MessageReplyTo` (tagged union); a reply to
* a regular message has `_: "messageReplyToMessage"` with `message_id`.
* - Story replies (`_: "messageReplyToStory"`) intentionally return null
* here — they aren't useful for our reply-chain grouping.
*/
function extractReplyToMessageId(msg: TdMessage): bigint | undefined {
if (msg.reply_to_message_id) {
return BigInt(msg.reply_to_message_id);
}
if (msg.reply_to && msg.reply_to._ === "messageReplyToMessage" && msg.reply_to.message_id) {
return BigInt(msg.reply_to.message_id);
}
return undefined;
}
interface TdFile { interface TdFile {
id: number; id: number;
size: number; size: number;
@@ -202,12 +228,14 @@ export async function getChannelMessages(
const result = await invokeWithTimeout<{ messages: TdMessage[]; total_count?: number }>(client, { const result = await invokeWithTimeout<{ messages: TdMessage[]; total_count?: number }>(client, {
_: "searchChatMessages", _: "searchChatMessages",
chat_id: Number(chatId), chat_id: Number(chatId),
// No topic_id for a flat (non-forum) channel scan. TDLib 1.8.64+
// dropped the top-level `message_thread_id: 0` we used to pass; the
// type-narrow now is "omit the field entirely if not in a topic".
query: "", query: "",
from_message_id: fromMessageId, from_message_id: fromMessageId,
offset: 0, offset: 0,
limit: Math.min(limit, 100), limit: Math.min(limit, 100),
filter, filter,
message_thread_id: 0,
}); });
if (!result.messages || result.messages.length === 0) break; if (!result.messages || result.messages.length === 0) break;
@@ -233,7 +261,7 @@ export async function getChannelMessages(
fileSize: BigInt(doc.document.size), fileSize: BigInt(doc.document.size),
date: new Date(msg.date * 1000), date: new Date(msg.date * 1000),
mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined, mediaAlbumId: msg.media_album_id && msg.media_album_id !== "0" ? msg.media_album_id : undefined,
replyToMessageId: msg.reply_to_message_id ? BigInt(msg.reply_to_message_id) : undefined, replyToMessageId: extractReplyToMessageId(msg),
caption: msg.content?.caption?.text || undefined, caption: msg.content?.caption?.text || undefined,
remoteUniqueId: doc.document.remote?.unique_id || undefined, remoteUniqueId: doc.document.remote?.unique_id || undefined,
}); });

View File

@@ -64,7 +64,11 @@ export async function getForumTopicList(
const topics: ForumTopic[] = []; const topics: ForumTopic[] = [];
let offsetDate = 0; let offsetDate = 0;
let offsetMessageId = 0; let offsetMessageId = 0;
let offsetMessageThreadId = 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; let pageCount = 0;
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
@@ -80,12 +84,16 @@ export async function getForumTopicList(
const prevOffsetDate = offsetDate; const prevOffsetDate = offsetDate;
const prevOffsetMessageId = offsetMessageId; const prevOffsetMessageId = offsetMessageId;
const prevOffsetMessageThreadId = offsetMessageThreadId; const prevOffsetForumTopicId = offsetForumTopicId;
const result = await invokeWithTimeout<{ const result = await invokeWithTimeout<{
topics?: { topics?: {
info?: { 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; message_thread_id?: number;
forum_topic_id?: number;
name?: string; name?: string;
is_general?: boolean; is_general?: boolean;
}; };
@@ -93,45 +101,49 @@ export async function getForumTopicList(
next_offset_date?: number; next_offset_date?: number;
next_offset_message_id?: number; next_offset_message_id?: number;
next_offset_message_thread_id?: number; next_offset_message_thread_id?: number;
next_offset_forum_topic_id?: number;
}>(client, { }>(client, {
_: "getForumTopics", _: "getForumTopics",
chat_id: Number(chatId), chat_id: Number(chatId),
query: "", query: "",
offset_date: offsetDate, offset_date: offsetDate,
offset_message_id: offsetMessageId, offset_message_id: offsetMessageId,
offset_message_thread_id: offsetMessageThreadId, offset_forum_topic_id: offsetForumTopicId,
limit: 100, limit: 100,
}); });
if (!result.topics || result.topics.length === 0) break; if (!result.topics || result.topics.length === 0) break;
for (const t of result.topics) { for (const t of result.topics) {
if (!t.info?.message_thread_id) continue; const topicId = t.info?.forum_topic_id ?? t.info?.message_thread_id;
if (!topicId) continue;
topics.push({ topics.push({
topicId: BigInt(t.info.message_thread_id), topicId: BigInt(topicId),
name: t.info.is_general ? "General" : (t.info.name ?? "Unnamed"), name: t.info?.is_general ? "General" : (t.info?.name ?? "Unnamed"),
}); });
} }
// Check if there are more pages // Check if there are more pages
const nextForumTopicId =
result.next_offset_forum_topic_id ?? result.next_offset_message_thread_id;
if ( if (
!result.next_offset_date && !result.next_offset_date &&
!result.next_offset_message_id && !result.next_offset_message_id &&
!result.next_offset_message_thread_id !nextForumTopicId
) { ) {
break; break;
} }
offsetDate = result.next_offset_date ?? 0; offsetDate = result.next_offset_date ?? 0;
offsetMessageId = result.next_offset_message_id ?? 0; offsetMessageId = result.next_offset_message_id ?? 0;
offsetMessageThreadId = result.next_offset_message_thread_id ?? 0; offsetForumTopicId = nextForumTopicId ?? 0;
// Stuck detection: if offsets didn't advance, break // Stuck detection: if offsets didn't advance, break
if ( if (
offsetDate === prevOffsetDate && offsetDate === prevOffsetDate &&
offsetMessageId === prevOffsetMessageId && offsetMessageId === prevOffsetMessageId &&
offsetMessageThreadId === prevOffsetMessageThreadId offsetForumTopicId === prevOffsetForumTopicId
) { ) {
log.warn( log.warn(
{ chatId: chatId.toString(), topicCount: topics.length }, { chatId: chatId.toString(), topicCount: topics.length },
@@ -227,14 +239,20 @@ export async function getTopicMessages(
}>(client, { }>(client, {
_: "searchChatMessages", _: "searchChatMessages",
chat_id: Number(chatId), 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: "", query: "",
message_thread_id: Number(topicId),
from_message_id: currentFromId, from_message_id: currentFromId,
offset: 0, offset: 0,
limit: Math.min(limit, 100), limit: Math.min(limit, 100),
filter: null, filter: null,
sender_id: null, sender_id: null,
saved_messages_topic_id: 0,
}); });
if (!result.messages || result.messages.length === 0) break; if (!result.messages || result.messages.length === 0) break;