Files
dragonsstash/worker/src/tdlib/chats.ts
T
adminandClaude Opus 4.7 c6b23715e8 feat(tdlib): add getChannelLastMessageId / getForumTopicLastMessageId
Both read the server-side last message ID from TDLib's local cache.
Used by the channel-scan-skip guard to short-circuit a paginated
searchChatMessages when last_message.id <= our watermark.

getForumTopic uses forum_topic_id (renamed from message_thread_id in
TDLib 1.8.64, same pattern as searchChatMessages / getForumTopics).

Returns null on any failure so the caller can fall back to scanning —
we'd rather waste a scan than miss new content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:54:53 +02:00

370 lines
11 KiB
TypeScript

import type { Client } from "tdl";
import { childLogger } from "../util/logger.js";
import { config } from "../util/config.js";
import { withFloodWait } from "../util/retry.js";
const log = childLogger("chats");
/**
* Collect chat folder IDs to widen the loadChats sweep across all folder
* chat lists. In TDLib 1.8.64+ there's no synchronous getChatFolders call —
* the folder list arrives via updateChatFolders. We listen for it briefly
* (200ms) and fall back to an empty list if nothing arrives; chats inside
* folders are still reachable via chatListMain so this only loses some
* preemptive cache warming.
*/
async function collectFolderIds(
client: Client
): Promise<{ _: "chatListFolder"; chat_folder_id: number }[]> {
return new Promise((resolve) => {
const ids: number[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handler = (update: any) => {
if (update?._ === "updateChatFolders") {
const folders = update.chat_folders as { id: number }[] | undefined;
if (folders) {
for (const f of folders) ids.push(f.id);
}
}
};
client.on("update", handler);
setTimeout(() => {
client.off("update", handler);
resolve(ids.map((id) => ({ _: "chatListFolder" as const, chat_folder_id: id })));
}, 200);
});
}
export interface TelegramChatInfo {
chatId: bigint;
title: string;
type: "channel" | "supergroup" | "group" | "private" | "other";
isForum: boolean;
memberCount?: number;
}
/**
* Fetch all chats the account is a member of.
* Uses TDLib's getChats to load the chat list, then getChat for details.
* Returns ALL chat types: channels, supergroups, groups, private chats,
* and the special "Saved Messages" (self) chat.
*/
export async function getAccountChats(
client: Client
): Promise<TelegramChatInfo[]> {
const chats: TelegramChatInfo[] = [];
// Get the current user's ID so we can label Saved Messages
let selfUserId: number | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const me = (await client.invoke({ _: "getMe" })) as any;
selfUserId = me.id;
} catch {
log.warn("Failed to get current user via getMe");
}
// First, load all chats into TDLib's cache using loadChats (the proper API).
// loadChats returns 404 when all chats have been loaded.
// Then use getChats to retrieve the IDs for enrichment.
//
// Folder-specific loading (chatListFolder) was removed in TDLib 1.8.64+ —
// getChatFolders (plural) is no longer a callable method, only the
// updateChatFolders event. The chats inside folders are still reachable
// via chatListMain so this isn't a functional regression.
const folderLists: { _: "chatListFolder"; chat_folder_id: number }[] =
await collectFolderIds(client);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chatLists: any[] = [
{ _: "chatListMain" },
{ _: "chatListArchive" },
...folderLists,
];
// Phase 1: Load all chats into TDLib's cache
for (const chatList of chatLists) {
try {
for (let page = 0; page < 500; page++) {
await withFloodWait(
() => client.invoke({ _: "loadChats", chat_list: chatList, limit: 100 }),
"loadChats"
);
}
} catch {
// 404 = all chats loaded (expected), or unsupported list type
}
}
// Phase 2: Retrieve chat IDs and enrich with details
const seenChatIds = new Set<number>();
for (const chatList of chatLists) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let result: { chat_ids: number[] };
try {
result = (await withFloodWait(
() => client.invoke({
_: "getChats",
chat_list: chatList,
limit: 50000,
}),
"getChats"
)) as { chat_ids: number[] };
} catch {
continue;
}
if (!result.chat_ids || result.chat_ids.length === 0) continue;
for (const chatId of result.chat_ids) {
if (seenChatIds.has(chatId)) continue;
seenChatIds.add(chatId);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chat = (await withFloodWait(
() => client.invoke({
_: "getChat",
chat_id: chatId,
}),
"getChat"
)) as any;
const chatType = chat.type?._;
let type: TelegramChatInfo["type"] = "other";
let isForum = false;
let title = chat.title ?? `Chat ${chatId}`;
if (chatType === "chatTypeSupergroup") {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sg = (await withFloodWait(
() => client.invoke({
_: "getSupergroup",
supergroup_id: chat.type.supergroup_id,
}),
"getSupergroup"
)) as any;
type = sg.is_channel ? "channel" : "supergroup";
isForum = sg.is_forum ?? false;
} catch {
type = "supergroup";
}
} else if (chatType === "chatTypeBasicGroup") {
type = "group";
} else if (chatType === "chatTypePrivate" || chatType === "chatTypeSecret") {
type = "private";
if (selfUserId !== null && chat.type?.user_id === selfUserId) {
title = "Saved Messages";
}
}
chats.push({
chatId: BigInt(chatId),
title,
type,
isForum,
});
} catch (err) {
log.warn({ chatId, err }, "Failed to get chat details, skipping");
}
}
}
log.info(
{ total: chats.length },
"Fetched all chats from Telegram (main + archive + folders)"
);
return chats;
}
/**
* Generate an invite link for a chat. The account must be an admin or have
* invite link permissions.
*/
export async function generateInviteLink(
client: Client,
chatId: bigint
): Promise<string> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (await withFloodWait(
() => client.invoke({
_: "createChatInviteLink",
chat_id: Number(chatId),
name: "DragonsStash Auto-Join",
creates_join_request: false,
}),
"createChatInviteLink"
)) as any;
const link = result.invite_link as string;
log.info({ chatId: chatId.toString(), link }, "Generated invite link");
return link;
}
/**
* Create a new supergroup (private group) via TDLib.
* Returns the chat ID and title.
*/
export async function createSupergroup(
client: Client,
title: string
): Promise<{ chatId: bigint; title: string }> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (await withFloodWait(
() => client.invoke({
_: "createNewSupergroupChat",
title,
is_forum: false,
is_channel: false,
description: "DragonsStash archive destination — all accounts write here",
}),
"createNewSupergroupChat"
)) as any;
const chatId = BigInt(result.id);
log.info({ chatId: chatId.toString(), title }, "Created new supergroup");
return { chatId, title: result.title ?? title };
}
/**
* Join a chat using an invite link.
*/
export async function joinChatByInviteLink(
client: Client,
inviteLink: string
): Promise<void> {
await withFloodWait(
() => client.invoke({
_: "joinChatByInviteLink",
invite_link: inviteLink,
}),
"joinChatByInviteLink"
);
log.info({ inviteLink }, "Joined chat by invite link");
}
/**
* Search for a public chat by username.
* Returns the chat info if found, or null if not found.
*/
export async function searchPublicChat(
client: Client,
username: string
): Promise<TelegramChatInfo | null> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chat = (await withFloodWait(
() => client.invoke({
_: "searchPublicChat",
username,
}),
"searchPublicChat"
)) as any;
const chatType = chat.type?._;
let type: TelegramChatInfo["type"] = "other";
let isForum = false;
if (chatType === "chatTypeSupergroup") {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sg = (await withFloodWait(
() => client.invoke({
_: "getSupergroup",
supergroup_id: chat.type.supergroup_id,
}),
"getSupergroup"
)) as any;
type = sg.is_channel ? "channel" : "supergroup";
isForum = sg.is_forum ?? false;
} catch {
type = "supergroup";
}
} else if (chatType === "chatTypeBasicGroup") {
type = "group";
} else if (chatType === "chatTypePrivate" || chatType === "chatTypeSecret") {
type = "private";
}
log.info({ username, chatId: chat.id, type }, "Found public chat");
return {
chatId: BigInt(chat.id),
title: chat.title ?? username,
type,
isForum,
};
} catch (err) {
log.warn({ username, err }, "Public chat not found");
return null;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Return the chat's server-side last_message.id from TDLib's local cache.
* Used by the channel-scan-skip guard to short-circuit a paginated
* searchChatMessages when nothing has changed since our watermark.
*
* Returns null when the chat has no last_message (empty channel) or the
* call fails — callers must treat null as "unknown" and run the scan.
*/
export async function getChannelLastMessageId(
client: Client,
chatId: bigint
): Promise<bigint | null> {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chat = (await client.invoke({
_: "getChat",
chat_id: Number(chatId),
})) as { last_message?: { id?: number } };
const id = chat.last_message?.id;
return id ? BigInt(id) : null;
} catch (err) {
log.debug({ err, chatId: chatId.toString() }, "getChannelLastMessageId failed");
return null;
}
}
/**
* Return the forum topic's last_message_id from TDLib. Same purpose as
* getChannelLastMessageId but scoped to a single topic in a forum
* supergroup. TDLib's `getForumTopic` returns a `forumTopic` whose `info`
* field contains the last_message_id.
*
* Returns null on failure or empty topic — caller treats as "unknown".
*/
export async function getForumTopicLastMessageId(
client: Client,
chatId: bigint,
topicId: bigint
): Promise<bigint | null> {
try {
// TDLib 1.8.64 uses `forum_topic_id` (renamed from `message_thread_id`
// in the request) — consistent with the rest of the forum-topic API
// surface in this version.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const topic = (await client.invoke({
_: "getForumTopic",
chat_id: Number(chatId),
forum_topic_id: Number(topicId),
})) as { last_message?: { id?: number }; info?: { last_message_id?: number } };
const id = topic.last_message?.id ?? topic.info?.last_message_id;
return id ? BigInt(id) : null;
} catch (err) {
log.debug(
{ err, chatId: chatId.toString(), topicId: topicId.toString() },
"getForumTopicLastMessageId failed"
);
return null;
}
}