feat: support all chat types in channel discovery and enrich bot messages

Channel Discovery:
- Remove channel/supergroup filter from getAccountChats — all chat types
  (private, groups, Saved Messages, etc.) are now discoverable as sources
- Detect and label the self-chat as "Saved Messages" via getMe
- Update channel picker dialog to accept any chat type string

Bot Rich Messages:
- Enhance package send preview with creator, file count, tags, and source
  channel info in MarkdownV2 caption
- Include tags in new_package subscription notifications
- Expand getPendingSendRequest to fetch richer package data

Performance:
- Reviewed pipeline for many-channel load — getChats pagination fix and
  per-channel getChat pre-load from prior commit address the main concerns
- Channels with no new messages skip in 2-3 API calls

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 18:27:48 +01:00
parent 5fd341dfc4
commit 29e95f780c
5 changed files with 54 additions and 14 deletions

View File

@@ -115,9 +115,15 @@ export async function getPendingSendRequest(requestId: string) {
select: { select: {
id: true, id: true,
fileName: true, fileName: true,
fileSize: true,
fileCount: true,
creator: true,
tags: true,
archiveType: true,
destChannelId: true, destChannelId: true,
destMessageId: true, destMessageId: true,
previewData: true, previewData: true,
sourceChannel: { select: { title: true, telegramId: true } },
}, },
}, },
telegramLink: true, telegramLink: true,

View File

@@ -134,9 +134,22 @@ async function processSendRequest(requestId: string): Promise<void> {
throw new Error("No global destination channel configured"); throw new Error("No global destination channel configured");
} }
// Send preview if available // Send preview with rich caption if available
if (pkg.previewData) { if (pkg.previewData) {
const caption = `📦 *${pkg.fileName}*\n\nSent from Dragon's Stash`; const lines: string[] = [];
lines.push(`📦 *${escapeMarkdown(pkg.fileName)}*`);
if (pkg.creator) lines.push(`👤 ${escapeMarkdown(pkg.creator)}`);
if (pkg.fileCount > 0) lines.push(`📁 ${pkg.fileCount} files`);
if (pkg.tags && pkg.tags.length > 0) {
lines.push(`🏷️ ${pkg.tags.map((t: string) => escapeMarkdown(t)).join(", ")}`);
}
if (pkg.sourceChannel) {
lines.push(`📡 Source: ${escapeMarkdown(pkg.sourceChannel.title)}`);
}
lines.push("");
lines.push("_Sent from Dragon's Stash_");
const caption = lines.join("\n");
await sendPhotoMessage(targetUserId, Buffer.from(pkg.previewData), caption); await sendPhotoMessage(targetUserId, Buffer.from(pkg.previewData), caption);
} }
@@ -189,6 +202,9 @@ async function handleNewPackage(payload: string): Promise<void> {
`🔔 <b>New package matching your subscriptions:</b>`, `🔔 <b>New package matching your subscriptions:</b>`,
``, ``,
`📦 <b>${escapeHtml(data.fileName)}</b>${creator}`, `📦 <b>${escapeHtml(data.fileName)}</b>${creator}`,
...(data.tags && data.tags.length > 0
? [`🏷️ ${data.tags.map((t: string) => escapeHtml(t)).join(", ")}`]
: []),
``, ``,
`Matched: ${patterns.map((p) => `"${escapeHtml(p)}"`).join(", ")}`, `Matched: ${patterns.map((p) => `"${escapeHtml(p)}"`).join(", ")}`,
``, ``,
@@ -213,3 +229,7 @@ async function handleNewPackage(payload: string): Promise<void> {
function escapeHtml(text: string): string { function escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
} }
function escapeMarkdown(text: string): string {
return text.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
}

View File

@@ -21,7 +21,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
interface FetchedChannel { interface FetchedChannel {
chatId: string; chatId: string;
title: string; title: string;
type: "channel" | "supergroup"; type: string;
isForum: boolean; isForum: boolean;
memberCount: number | null; memberCount: number | null;
alreadyLinked: boolean; alreadyLinked: boolean;

View File

@@ -150,6 +150,7 @@ export async function createPackageWithFiles(input: CreatePackageInput) {
packageId: pkg.id, packageId: pkg.id,
fileName: input.fileName, fileName: input.fileName,
creator: input.creator ?? null, creator: input.creator ?? null,
tags: input.tags ?? [],
}) })
); );
} catch { } catch {

View File

@@ -16,13 +16,24 @@ export interface TelegramChatInfo {
/** /**
* Fetch all chats the account is a member of. * Fetch all chats the account is a member of.
* Uses TDLib's getChats to load the chat list, then getChat for details. * Uses TDLib's getChats to load the chat list, then getChat for details.
* Filters to channels and supergroups only (groups/privates are not useful for ingestion). * Returns ALL chat types: channels, supergroups, groups, private chats,
* and the special "Saved Messages" (self) chat.
*/ */
export async function getAccountChats( export async function getAccountChats(
client: Client client: Client
): Promise<TelegramChatInfo[]> { ): Promise<TelegramChatInfo[]> {
const chats: 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");
}
// Load ALL chats from the main list by paginating getChats. // Load ALL chats from the main list by paginating getChats.
// TDLib's getChats returns batches — keep calling until it returns // TDLib's getChats returns batches — keep calling until it returns
// an empty list, which signals all chats have been loaded. // an empty list, which signals all chats have been loaded.
@@ -56,6 +67,7 @@ export async function getAccountChats(
const chatType = chat.type?._; const chatType = chat.type?._;
let type: TelegramChatInfo["type"] = "other"; let type: TelegramChatInfo["type"] = "other";
let isForum = false; let isForum = false;
let title = chat.title ?? `Chat ${chatId}`;
if (chatType === "chatTypeSupergroup") { if (chatType === "chatTypeSupergroup") {
// Get supergroup details to check if it's a channel or group // Get supergroup details to check if it's a channel or group
@@ -78,17 +90,18 @@ export async function getAccountChats(
type = "group"; type = "group";
} else if (chatType === "chatTypePrivate" || chatType === "chatTypeSecret") { } else if (chatType === "chatTypePrivate" || chatType === "chatTypeSecret") {
type = "private"; type = "private";
// Label the self-chat as "Saved Messages"
if (selfUserId !== null && chat.type?.user_id === selfUserId) {
title = "Saved Messages";
}
} }
// Only include channels and supergroups chats.push({
if (type === "channel" || type === "supergroup") { chatId: BigInt(chatId),
chats.push({ title,
chatId: BigInt(chatId), type,
title: chat.title ?? `Chat ${chatId}`, isForum,
type, });
isForum,
});
}
} catch (err) { } catch (err) {
log.warn({ chatId, err }, "Failed to get chat details, skipping"); log.warn({ chatId, err }, "Failed to get chat details, skipping");
} }
@@ -99,7 +112,7 @@ export async function getAccountChats(
log.info( log.info(
{ total: chats.length }, { total: chats.length },
"Fetched channels/supergroups from Telegram" "Fetched all chats from Telegram"
); );
return chats; return chats;