Fix multiple issues

This commit is contained in:
2026-03-07 21:33:40 +01:00
parent 6926df9a2c
commit 2763de2711
11 changed files with 524 additions and 156 deletions

View File

@@ -12,13 +12,26 @@ import { copyMessageToUser, sendTextMessage, sendPhotoMessage } from "./tdlib/cl
const log = childLogger("send-listener");
let pgClient: pg.PoolClient | null = null;
let stopped = false;
/** Delay (ms) before attempting to reconnect after a connection loss. */
const RECONNECT_DELAY_MS = 5_000;
/**
* Start listening for pg_notify signals:
* - `bot_send` — payload = requestId → send a package to a user
* - `new_package` — payload = JSON { packageId, fileName, creator } → notify subscribers
*
* If the underlying connection is lost, the listener automatically reconnects
* so that pg_notify signals are never silently dropped.
*/
export async function startSendListener(): Promise<void> {
stopped = false;
await connectListener();
}
async function connectListener(): Promise<void> {
try {
pgClient = await pool.connect();
await pgClient.query("LISTEN bot_send");
await pgClient.query("LISTEN new_package");
@@ -31,10 +44,46 @@ export async function startSendListener(): Promise<void> {
}
});
// Reconnect automatically when the connection ends unexpectedly
pgClient.on("end", () => {
if (!stopped) {
log.warn("Send listener connection lost — reconnecting");
pgClient = null;
scheduleReconnect();
}
});
pgClient.on("error", (err) => {
log.error({ err }, "Send listener connection error");
if (!stopped && pgClient) {
try {
pgClient.release(true);
} catch (releaseErr) {
log.debug({ err: releaseErr }, "Failed to release pg client after error");
}
pgClient = null;
scheduleReconnect();
}
});
log.info("Send listener started (bot_send, new_package)");
} catch (err) {
log.error({ err }, "Failed to start send listener — retrying");
scheduleReconnect();
}
}
function scheduleReconnect(): void {
if (stopped) return;
setTimeout(() => {
if (!stopped) {
connectListener();
}
}, RECONNECT_DELAY_MS);
}
export function stopSendListener(): void {
stopped = true;
if (pgClient) {
pgClient.release();
pgClient = null;

View File

@@ -33,7 +33,7 @@ export async function createBotClient(): Promise<tdl.Client> {
await client.login(() => ({
type: "bot",
token: config.botToken,
getToken: () => Promise.resolve(config.botToken),
}));
log.info("Bot client authenticated successfully");
@@ -54,7 +54,10 @@ export async function closeBotClient(): Promise<void> {
/**
* Forward a message from a channel to a user's DM.
* Uses copyMessage to make it appear as sent by the bot.
* Uses forwardMessages with send_copy to make it appear as sent by the bot.
*
* The fromChatId is the TDLib chat ID stored in the DB — already in the correct
* format (negative for supergroups/channels, e.g. -1001234567890).
*/
export async function copyMessageToUser(
fromChatId: bigint,
@@ -63,14 +66,10 @@ export async function copyMessageToUser(
): Promise<void> {
if (!client) throw new Error("Bot client not initialized");
// TDLib uses negative chat IDs for channels/supergroups
// The telegramId from the DB is the raw Telegram ID; for channels it needs -100 prefix
const fromChatIdNum = Number(-100n * 1n) + Number(fromChatId);
await client.invoke({
_: "forwardMessages",
chat_id: Number(toUserId),
from_chat_id: Number(fromChatId) > 0 ? -Number(fromChatId) : Number(fromChatId),
from_chat_id: Number(fromChatId),
message_ids: [Number(messageId)],
send_copy: true,
remove_caption: false,

View File

@@ -94,7 +94,7 @@ export function ChannelsTab({ channels, globalDestination, accounts }: ChannelsT
return (
<div className="space-y-4">
<DestinationCard destination={globalDestination} />
<DestinationCard destination={globalDestination} channels={channels} />
<div className="flex items-center gap-2">
<Button

View File

@@ -1,9 +1,9 @@
"use client";
import { useState, useEffect, useTransition } from "react";
import { Database, AlertTriangle, Link2, Plus, Loader2 } from "lucide-react";
import { Database, AlertTriangle, Link2, Plus, Loader2, ArrowRight } from "lucide-react";
import { toast } from "sonner";
import { createDestinationViaWorker } from "../actions";
import { createDestinationViaWorker, setGlobalDestination } from "../actions";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -17,10 +17,19 @@ import {
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import type { GlobalDestination } from "@/lib/telegram/admin-queries";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { GlobalDestination, ChannelRow } from "@/lib/telegram/admin-queries";
interface DestinationCardProps {
destination: GlobalDestination;
channels?: ChannelRow[];
}
type CreateState =
@@ -29,11 +38,17 @@ type CreateState =
| { phase: "done"; title: string; telegramId: string }
| { phase: "error"; message: string };
export function DestinationCard({ destination }: DestinationCardProps) {
export function DestinationCard({ destination, channels = [] }: DestinationCardProps) {
const [isPending, startTransition] = useTransition();
const [createOpen, setCreateOpen] = useState(false);
const [title, setTitle] = useState("dragonsstash db");
const [createState, setCreateState] = useState<CreateState>({ phase: "idle" });
const [selectedChannelId, setSelectedChannelId] = useState<string>("");
// Channels that can be assigned as destination (SOURCE channels only, exclude current destination)
const assignableChannels = channels.filter(
(c) => c.type === "SOURCE" && c.id !== destination?.id
);
// Poll for worker result when creating
useEffect(() => {
@@ -103,6 +118,21 @@ export function DestinationCard({ destination }: DestinationCardProps) {
});
};
const handleAssignExisting = () => {
if (!selectedChannelId) return;
startTransition(async () => {
const result = await setGlobalDestination(selectedChannelId);
if (result.success) {
toast.success("Channel set as destination!");
setCreateOpen(false);
setSelectedChannelId("");
} else {
toast.error(result.error ?? "Failed to set destination");
}
});
};
const handleOpenChange = (open: boolean) => {
setCreateOpen(open);
if (!open) {
@@ -110,6 +140,7 @@ export function DestinationCard({ destination }: DestinationCardProps) {
if (createState.phase !== "creating") {
setCreateState({ phase: "idle" });
}
setSelectedChannelId("");
}
};
@@ -132,19 +163,23 @@ export function DestinationCard({ destination }: DestinationCardProps) {
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Create Destination
Set Destination
</Button>
</CardContent>
</Card>
<CreateDestinationDialog
<DestinationDialog
open={createOpen}
onOpenChange={handleOpenChange}
title={title}
setTitle={setTitle}
onSubmit={handleCreate}
onSubmitCreate={handleCreate}
createState={createState}
isPending={isPending}
assignableChannels={assignableChannels}
selectedChannelId={selectedChannelId}
setSelectedChannelId={setSelectedChannelId}
onSubmitAssign={handleAssignExisting}
/>
</>
);
@@ -187,46 +222,59 @@ export function DestinationCard({ destination }: DestinationCardProps) {
</CardContent>
</Card>
<CreateDestinationDialog
<DestinationDialog
open={createOpen}
onOpenChange={handleOpenChange}
title={title}
setTitle={setTitle}
onSubmit={handleCreate}
onSubmitCreate={handleCreate}
createState={createState}
isPending={isPending}
assignableChannels={assignableChannels}
selectedChannelId={selectedChannelId}
setSelectedChannelId={setSelectedChannelId}
onSubmitAssign={handleAssignExisting}
/>
</>
);
}
function CreateDestinationDialog({
function DestinationDialog({
open,
onOpenChange,
title,
setTitle,
onSubmit,
onSubmitCreate,
createState,
isPending,
assignableChannels,
selectedChannelId,
setSelectedChannelId,
onSubmitAssign,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
setTitle: (v: string) => void;
onSubmit: () => void;
onSubmitCreate: () => void;
createState: CreateState;
isPending: boolean;
assignableChannels: ChannelRow[];
selectedChannelId: string;
setSelectedChannelId: (v: string) => void;
onSubmitAssign: () => void;
}) {
const isCreating = createState.phase === "creating";
const hasAssignable = assignableChannels.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create Destination Channel</DialogTitle>
<DialogTitle>Set Destination Channel</DialogTitle>
<DialogDescription>
A private Telegram group will be created automatically using one of
your authenticated accounts. All accounts will write archives here.
Choose an existing channel or create a new private group. All
accounts will write archives to this destination.
</DialogDescription>
</DialogHeader>
@@ -241,7 +289,71 @@ function CreateDestinationDialog({
</p>
</div>
) : (
<div className="space-y-4">
<Tabs defaultValue={hasAssignable ? "existing" : "create"} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="existing" disabled={!hasAssignable}>
<ArrowRight className="mr-1.5 h-3.5 w-3.5" />
Use Existing
</TabsTrigger>
<TabsTrigger value="create">
<Plus className="mr-1.5 h-3.5 w-3.5" />
Create New
</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="space-y-4 pt-2">
{createState.phase === "error" && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="text-sm text-destructive">{createState.message}</p>
</div>
)}
<div className="space-y-2">
<Label>Select Channel</Label>
<Select
value={selectedChannelId}
onValueChange={setSelectedChannelId}
>
<SelectTrigger>
<SelectValue placeholder="Pick a channel..." />
</SelectTrigger>
<SelectContent>
{assignableChannels.map((ch) => (
<SelectItem key={ch.id} value={ch.id}>
{ch.title}{" "}
<span className="text-muted-foreground text-xs">
({ch.telegramId})
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
The selected channel will become the destination. All accounts
will be linked as writers automatically.
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
onClick={onSubmitAssign}
disabled={isPending || !selectedChannelId}
>
{isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Set as Destination
</Button>
</DialogFooter>
</TabsContent>
<TabsContent value="create" className="space-y-4 pt-2">
{createState.phase === "error" && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="text-sm text-destructive">{createState.message}</p>
@@ -257,30 +369,31 @@ function CreateDestinationDialog({
onChange={(e) => setTitle(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
This will be the name of the Telegram group. You can rename it later in Telegram.
A new private Telegram group will be created using one of your
authenticated accounts. You can rename it later in Telegram.
</p>
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isCreating}
>
Cancel
</Button>
<Button
onClick={onSubmit}
disabled={isPending || isCreating || !title.trim()}
onClick={onSubmitCreate}
disabled={isPending || !title.trim()}
>
{(isPending || isCreating) && (
{isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Group
</Button>
</DialogFooter>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
);

View File

@@ -270,6 +270,13 @@ export async function setChannelType(
if (!existing) return { success: false, error: "Channel not found" };
try {
if (type === "DESTINATION") {
// Setting as destination: use the full global destination logic
// so it updates the global settings key, creates WRITER links, etc.
return await setGlobalDestination(id);
}
// Setting as SOURCE — just change the type
await prisma.telegramChannel.update({
where: { id },
data: { type },

View File

@@ -1,8 +1,16 @@
import type pg from "pg";
import { pool } from "./client.js";
import { childLogger } from "../util/logger.js";
const log = childLogger("locks");
/**
* Holds the pooled connection for each active advisory lock.
* Session-level advisory locks are tied to the specific PostgreSQL connection,
* so we MUST keep the same connection checked out for the entire lock duration.
*/
const heldConnections = new Map<string, pg.PoolClient>();
/**
* Derive a stable 32-bit integer lock ID from an account ID string.
* PostgreSQL advisory locks use bigint, but we use 32-bit for safety.
@@ -20,6 +28,9 @@ function hashToLockId(accountId: string): number {
/**
* Try to acquire a PostgreSQL advisory lock for an account.
* Returns true if acquired, false if already held by another session.
*
* IMPORTANT: The pooled connection is kept checked out for the duration
* of the lock. You MUST call releaseLock() when done to return it to the pool.
*/
export async function tryAcquireLock(accountId: string): Promise<boolean> {
const lockId = hashToLockId(accountId);
@@ -31,26 +42,40 @@ export async function tryAcquireLock(accountId: string): Promise<boolean> {
);
const acquired = result.rows[0]?.pg_try_advisory_lock ?? false;
if (acquired) {
// Keep the connection checked out — lock is tied to this connection
heldConnections.set(accountId, client);
log.debug({ accountId, lockId }, "Advisory lock acquired");
return true;
} else {
log.debug({ accountId, lockId }, "Advisory lock already held");
}
return acquired;
} finally {
// Lock not acquired — release the connection back to the pool
client.release();
log.debug({ accountId, lockId }, "Advisory lock already held");
return false;
}
} catch (err) {
client.release();
throw err;
}
}
/**
* Release the advisory lock for an account.
* Uses the SAME connection that acquired the lock, then returns it to the pool.
*/
export async function releaseLock(accountId: string): Promise<void> {
const lockId = hashToLockId(accountId);
const client = await pool.connect();
const client = heldConnections.get(accountId);
if (!client) {
log.warn({ accountId, lockId }, "No held connection for lock release — lock may have already been released");
return;
}
try {
await client.query("SELECT pg_advisory_unlock($1)", [lockId]);
log.debug({ accountId, lockId }, "Advisory lock released");
} finally {
heldConnections.delete(accountId);
client.release();
}
}

View File

@@ -1,6 +1,7 @@
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");
@@ -29,11 +30,14 @@ export async function getAccountChats(
while (hasMore) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (await client.invoke({
const result = (await withFloodWait(
() => client.invoke({
_: "getChats",
chat_list: { _: "chatListMain" },
limit: 100,
})) as { chat_ids: number[] };
}),
"getChats"
)) as { chat_ids: number[] };
if (!result.chat_ids || result.chat_ids.length === 0) {
break;
@@ -42,10 +46,13 @@ export async function getAccountChats(
for (const chatId of result.chat_ids) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chat = (await client.invoke({
const chat = (await withFloodWait(
() => client.invoke({
_: "getChat",
chat_id: chatId,
})) as any;
}),
"getChat"
)) as any;
const chatType = chat.type?._;
let type: TelegramChatInfo["type"] = "other";
@@ -55,10 +62,13 @@ export async function getAccountChats(
// Get supergroup details to check if it's a channel or group
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sg = (await client.invoke({
const sg = (await withFloodWait(
() => client.invoke({
_: "getSupergroup",
supergroup_id: chat.type.supergroup_id,
})) as any;
}),
"getSupergroup"
)) as any;
type = sg.is_channel ? "channel" : "supergroup";
isForum = sg.is_forum ?? false;
@@ -109,12 +119,15 @@ export async function generateInviteLink(
chatId: bigint
): Promise<string> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (await client.invoke({
const result = (await withFloodWait(
() => client.invoke({
_: "createChatInviteLink",
chat_id: Number(chatId),
name: "DragonsStash Auto-Join",
creates_join_request: false,
})) as any;
}),
"createChatInviteLink"
)) as any;
const link = result.invite_link as string;
log.info({ chatId: chatId.toString(), link }, "Generated invite link");
@@ -130,13 +143,16 @@ export async function createSupergroup(
title: string
): Promise<{ chatId: bigint; title: string }> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = (await client.invoke({
const result = (await withFloodWait(
() => client.invoke({
_: "createNewSupergroupChat",
title,
is_forum: false,
is_channel: false,
description: "DragonsStash archive destination — all accounts write here",
})) as any;
}),
"createNewSupergroupChat"
)) as any;
const chatId = BigInt(result.id);
log.info({ chatId: chatId.toString(), title }, "Created new supergroup");
@@ -150,10 +166,13 @@ export async function joinChatByInviteLink(
client: Client,
inviteLink: string
): Promise<void> {
await client.invoke({
await withFloodWait(
() => client.invoke({
_: "joinChatByInviteLink",
invite_link: inviteLink,
});
}),
"joinChatByInviteLink"
);
log.info({ inviteLink }, "Joined chat by invite link");
}

View File

@@ -2,6 +2,7 @@ import type { Client } from "tdl";
import { readFile, rename, copyFile, unlink, stat } from "fs/promises";
import { config } from "../util/config.js";
import { childLogger } from "../util/logger.js";
import { withFloodWait } from "../util/retry.js";
import { isArchiveAttachment } from "../archive/detect.js";
import type { TelegramMessage } from "../archive/multipart.js";
import type { TelegramPhoto } from "../preview/match.js";
@@ -78,8 +79,12 @@ export interface ChannelScanResult {
export type ScanProgressCallback = (messagesScanned: number) => void;
/**
* Invoke a TDLib method with a timeout to prevent indefinite hangs.
* Invoke a TDLib method with a timeout to prevent indefinite hangs,
* and automatic retry on FLOOD_WAIT rate-limit errors.
*
* If TDLib does not respond within the timeout, the promise rejects.
* If Telegram returns a rate limit error, sleeps for the required
* duration and retries (up to maxRetries times).
*/
export async function invokeWithTimeout<T>(
client: Client,
@@ -87,13 +92,19 @@ export async function invokeWithTimeout<T>(
request: Record<string, any>,
timeoutMs = INVOKE_TIMEOUT_MS
): Promise<T> {
return new Promise<T>((resolve, reject) => {
return withFloodWait(
() =>
new Promise<T>((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
reject(new Error(`TDLib invoke timed out after ${timeoutMs}ms for ${request._}`));
reject(
new Error(
`TDLib invoke timed out after ${timeoutMs}ms for ${request._}`
)
);
}
}, timeoutMs);
@@ -112,7 +123,9 @@ export async function invokeWithTimeout<T>(
reject(err);
}
});
});
}),
`TDLib:${request._}`
);
}
/**
@@ -415,15 +428,20 @@ export async function downloadFile(
client.on("update", handleUpdate);
// Start async download (non-blocking — progress via updateFile events)
client
.invoke({
// Wrapped in withFloodWait: if the initial invoke is rate-limited,
// it will sleep and retry before the download event loop begins.
withFloodWait(
() =>
client.invoke({
_: "downloadFile",
file_id: numericId,
priority: 32,
offset: 0,
limit: 0,
synchronous: false,
})
}),
`downloadFile:${fileName}`
)
.then((result: unknown) => {
// If the file was already cached locally, invoke returns immediately
const file = result as TdFile | undefined;

View File

@@ -3,6 +3,7 @@ import { stat } from "fs/promises";
import type { Client } from "tdl";
import { config } from "../util/config.js";
import { childLogger } from "../util/logger.js";
import { withFloodWait } from "../util/retry.js";
const log = childLogger("upload");
@@ -84,8 +85,11 @@ async function sendAndWaitForUpload(
fileName: string,
fileSizeMB: number
): Promise<bigint> {
// Send the message — this returns a temporary message immediately
const tempMsg = (await client.invoke({
// Send the message — this returns a temporary message immediately.
// Wrapped in withFloodWait to handle Telegram rate limits on upload.
const tempMsg = (await withFloodWait(
() =>
client.invoke({
_: "sendMessage",
chat_id: Number(chatId),
input_message_content: {
@@ -101,7 +105,9 @@ async function sendAndWaitForUpload(
}
: undefined,
},
})) as { id: number };
}),
"sendMessage:upload"
)) as { id: number };
const tempMsgId = tempMsg.id;

109
worker/src/util/retry.ts Normal file
View File

@@ -0,0 +1,109 @@
import { childLogger } from "./logger.js";
import { config } from "./config.js";
const log = childLogger("retry");
/**
* Extract the FLOOD_WAIT duration (in seconds) from a TDLib error.
*
* TDLib errors for rate limiting look like:
* - Error message: "Too Many Requests: retry after 30"
* - Error message: "FLOOD_WAIT_30"
* - Error code: 429
*/
export function extractFloodWaitSeconds(err: unknown): number | null {
if (!err || typeof err !== "object") return null;
const message = (err as { message?: string }).message ?? "";
const code = (err as { code?: number }).code;
// Match "FLOOD_WAIT_<seconds>" pattern
const floodMatch = message.match(/FLOOD_WAIT_(\d+)/i);
if (floodMatch) {
return parseInt(floodMatch[1], 10);
}
// Match "retry after <seconds>" pattern (from Telegram HTTP API style errors)
const retryMatch = message.match(/retry after (\d+)/i);
if (retryMatch) {
return parseInt(retryMatch[1], 10);
}
// If error code is 429 but no explicit wait time, default to 30 seconds
if (code === 429) {
return 30;
}
return null;
}
/**
* Sleep for a given number of milliseconds, with a descriptive log message.
*/
function sleepMs(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Wraps a TDLib invoke operation with FLOOD_WAIT-aware retry logic.
*
* When Telegram returns a rate limit error (FLOOD_WAIT / 429), this:
* 1. Extracts the required wait time from the error
* 2. Logs a warning with the wait duration
* 3. Sleeps for the required duration + small jitter
* 4. Retries the operation (up to maxRetries times)
*
* Non-rate-limit errors are re-thrown immediately.
*
* Usage:
* const result = await withFloodWait(() => client.invoke({ ... }));
*/
export async function withFloodWait<T>(
fn: () => Promise<T>,
context?: string,
maxRetries?: number
): Promise<T> {
const limit = maxRetries ?? config.maxRetries;
let lastError: unknown;
for (let attempt = 0; attempt <= limit; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
const waitSeconds = extractFloodWaitSeconds(err);
if (waitSeconds === null) {
// Not a rate limit error — re-throw immediately
throw err;
}
if (attempt >= limit) {
log.error(
{ context, attempt, waitSeconds },
"Rate limit exceeded max retries — giving up"
);
throw err;
}
// Add small jitter (15 seconds) to avoid multiple clients retrying simultaneously
const jitter = 1000 + Math.random() * 4000;
const totalWaitMs = waitSeconds * 1000 + jitter;
log.warn(
{
context,
attempt: attempt + 1,
maxRetries: limit,
waitSeconds,
totalWaitMs: Math.round(totalWaitMs),
},
`Rate-limited by Telegram — sleeping ${waitSeconds}s before retry`
);
await sleepMs(totalWaitMs);
}
}
throw lastError;
}

View File

@@ -716,6 +716,29 @@ async function processOneArchiveSet(
return;
}
// ── Size guard: skip archives that exceed WORKER_MAX_ZIP_SIZE_MB ──
const totalArchiveSize = archiveSet.parts.reduce((sum, p) => sum + p.fileSize, 0n);
const maxSizeBytes = BigInt(config.maxZipSizeMB) * 1024n * 1024n;
if (totalArchiveSize > maxSizeBytes) {
accountLog.warn(
{
fileName: archiveName,
totalSizeMB: Number(totalArchiveSize / (1024n * 1024n)),
maxSizeMB: config.maxZipSizeMB,
},
"Archive exceeds max size limit, skipping"
);
await updateRunActivity(runId, {
currentActivity: `Skipped ${archiveName} (exceeds ${config.maxZipSizeMB}MB limit)`,
currentStep: "skipping",
currentChannel: channelTitle,
currentFile: archiveName,
currentFileNum: setIdx + 1,
totalFiles: totalSets,
});
return;
}
const tempPaths: string[] = [];
let splitPaths: string[] = [];