feat: fix channel scanning bugs, add package tags, and kickstarters tab

Bug fixes:
- Fix channels not being scanned by paginating TDLib getChats (was only
  loading first batch, additional channels were unknown to TDLib)
- Add per-channel getChat pre-load as safety net before scanning
- Fix preview pictures not loading by checking previewData instead of
  previewMsgId for hasPreview flag
- Prevent previewMsgId from being set when preview download fails

Package Tags:
- Add tags Text[] column to Package with migration backfilling from
  channel categories
- Worker auto-inherits source channel category as initial tag
- Tag filter dropdown and Tags column in STL Files table
- Server actions for individual and bulk tag editing

Kickstarters Tab:
- New KickstarterHost, Kickstarter, and KickstarterPackage models
- Full CRUD with delivery status, payment status, host management
- Package linking (many-to-many with existing packages)
- Sidebar entry with Gift icon
- Table with search, filters, modal forms

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 18:17:44 +01:00
parent e2dd3bb9d0
commit 5fd341dfc4
23 changed files with 1375 additions and 32 deletions

View File

@@ -103,6 +103,7 @@ export interface CreatePackageInput {
partCount: number;
ingestionRunId: string;
creator?: string | null;
tags?: string[];
previewData?: Buffer | null;
previewMsgId?: bigint | null;
files: {
@@ -132,6 +133,7 @@ export async function createPackageWithFiles(input: CreatePackageInput) {
fileCount: input.files.length,
ingestionRunId: input.ingestionRunId,
creator: input.creator ?? undefined,
tags: input.tags && input.tags.length > 0 ? input.tags : undefined,
previewData: input.previewData ? new Uint8Array(input.previewData) : undefined,
previewMsgId: input.previewMsgId ?? undefined,
files: {

View File

@@ -23,12 +23,11 @@ export async function getAccountChats(
): Promise<TelegramChatInfo[]> {
const chats: TelegramChatInfo[] = [];
// Load main chat list — TDLib loads in batches
let offsetOrder = "9223372036854775807"; // max int64 as string
let offsetChatId = 0;
let hasMore = true;
while (hasMore) {
// Load ALL chats from the main list by paginating getChats.
// TDLib's getChats returns batches — keep calling until it returns
// an empty list, which signals all chats have been loaded.
const MAX_PAGES = 50; // safety limit (50 × 100 = 5000 chats)
for (let page = 0; page < MAX_PAGES; page++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (await withFloodWait(
() => client.invoke({
@@ -95,10 +94,6 @@ export async function getAccountChats(
}
}
// getChats with chatListMain returns all chats at once in newer TDLib versions
// So we break after the first batch
hasMore = false;
await sleep(config.apiDelayMs);
}

View File

@@ -335,14 +335,18 @@ export async function runWorkerForAccount(
phone: account.phone,
});
// Load the chat list so TDLib knows about all chats
// Without this, getChat/getChatHistory fail with "Chat not found"
// Load the full chat list so TDLib knows about all chats.
// Without this, getChat/searchChatMessages fail with "Chat not found".
// TDLib returns chats in batches — keep calling until empty.
try {
await client.invoke({
_: "getChats",
chat_list: { _: "chatListMain" },
limit: 1000,
});
for (let page = 0; page < 50; page++) {
const chatResult = await client.invoke({
_: "getChats",
chat_list: { _: "chatListMain" },
limit: 100,
}) as { chat_ids?: number[] };
if (!chatResult.chat_ids || chatResult.chat_ids.length === 0) break;
}
} catch {
// Ignore — chat list may already be loaded
}
@@ -377,6 +381,22 @@ export async function runWorkerForAccount(
: channel.title;
try {
// ── Ensure TDLib knows about this chat ──
// getChats may not have loaded all channels (pagination, archive folder, etc.)
// so we explicitly load each channel before scanning.
try {
await client.invoke({
_: "getChat",
chat_id: Number(channel.telegramId),
});
} catch (chatErr) {
accountLog.warn(
{ err: chatErr, channelId: channel.id, title: channel.title, telegramId: channel.telegramId.toString() },
"TDLib does not know about this chat — it may not be accessible to this account. Skipping."
);
continue;
}
// ── Check if channel is a forum ──
const forum = await isChatForum(client, channel.telegramId);
if (forum !== channel.isForum) {
@@ -969,7 +989,11 @@ async function processOneArchiveSet(
totalFiles: totalSets,
});
previewData = await downloadPhotoThumbnail(client, matchedPhoto.fileId);
previewMsgId = matchedPhoto.id;
// Only set previewMsgId if we actually got the image data —
// otherwise the UI thinks there's a preview but the API returns 404
if (previewData) {
previewMsgId = matchedPhoto.id;
}
}
// ── Fallback: extract preview image from inside the archive ──
@@ -1008,6 +1032,12 @@ async function processOneArchiveSet(
// Clean up any orphaned record (same hash but no dest upload) before creating
await deleteOrphanedPackageByHash(contentHash);
// Auto-inherit source channel category as initial tag
const tags: string[] = [];
if (channel.category) {
tags.push(channel.category);
}
await createPackageWithFiles({
contentHash,
fileName: archiveName,
@@ -1023,6 +1053,7 @@ async function processOneArchiveSet(
partCount: uploadPaths.length,
ingestionRunId,
creator,
tags,
previewData,
previewMsgId,
files: entries,