feat(telegram): add resumable channel pipeline

This commit is contained in:
hicocos committed 2026-07-03 19:18:20 +08:00
1 parent e20d354e7e
commit 4b021a53ec
6 files changed
+1159 -244

No files matched your search

+527 -146
View File
@@ -4597,7 +4597,7 @@ function normalizeTelegramDownloadRefs(refs, defaultSourceEntity) {
if (!refs) return void 0;
return refs.filter((ref) => ref.id > 0).map((ref) => ({ ...ref, source: ref.source || defaultSourceEntity }));
}
async function downloadTelegramChannelRange(botClient, requestMessage, source, startMessageId, limit = 50, direction = "older", explicitIds, folderOverride, explicitRefs) {
async function downloadTelegramChannelRange(botClient, requestMessage, source, startMessageId, limit = 50, direction = "older", explicitIds, folderOverride, explicitRefs, onItemSettled) {
const userClient2 = getTelegramUserClient();
if (!userClient2 || !isTelegramUserClientReady()) {
throw new Error("Telegram \u7528\u6237\u8D26\u53F7\u4E0B\u8F7D\u5668\u672A\u5C31\u7EEA\uFF1A\u8BF7\u5148\u914D\u7F6E TELEGRAM_API_ID / TELEGRAM_API_HASH \u5E76\u751F\u6210 user session");
@@ -4630,6 +4630,7 @@ async function downloadTelegramChannelRange(botClient, requestMessage, source, s
if (!fileInfo) {
skipped += 1;
skippedMessageIds.push(ref.id);
await onItemSettled?.(ref, "skipped");
continue;
}
downloadableRefs.push({
@@ -4768,6 +4769,7 @@ async function downloadTelegramChannelRange(botClient, requestMessage, source, s
completed += 1;
failedMessageIds.push(item.id);
await refreshSegmentStatus(false, fileName);
await onItemSettled?.(item, "failed", "\u6D88\u606F\u4E0D\u5B58\u5728\u6216\u65E0\u6CD5\u91CD\u65B0\u8BFB\u53D6");
return;
}
const uploadItem = {
@@ -4798,14 +4800,17 @@ async function downloadTelegramChannelRange(botClient, requestMessage, source, s
if (uploadItem.status === "success") {
successful += 1;
successfulMessageIds.push(item.id);
await onItemSettled?.(item, "success");
} else {
failed += 1;
failedMessageIds.push(item.id);
await onItemSettled?.(item, "failed", uploadItem.error || "\u4E0B\u8F7D\u5931\u8D25");
}
} catch (err) {
console.error(`\u{1F916} \u9891\u9053\u5206\u6BB5\u4E0B\u8F7D\u4EFB\u52A1\u5F02\u5E38: ${fileName}`, err);
failed += 1;
failedMessageIds.push(item.id);
await onItemSettled?.(item, "failed", err instanceof Error ? err.message : String(err));
} finally {
completed += 1;
found += 1;
@@ -5192,8 +5197,14 @@ async function handleFileUpload(client2, event) {
init_db();
var SUBSCRIPTION_INTERVAL_MS = Math.max(6e4, parseInt(process.env.TELEGRAM_SUBSCRIPTION_INTERVAL_MS || "300000", 10) || 3e5);
var SUBSCRIPTION_SCAN_LIMIT = Math.max(1, parseInt(process.env.TELEGRAM_SUBSCRIPTION_SCAN_LIMIT || "100", 10) || 100);
var TG_JOB_RECOVERY_DELAY_MS = Math.max(1e3, parseInt(process.env.TG_JOB_RECOVERY_DELAY_MS || "10000", 10) || 1e4);
var TG_JOB_SCAN_SEGMENT_SIZE = Math.max(20, parseInt(process.env.TG_JOB_SCAN_SEGMENT_SIZE || "100", 10) || 100);
var TG_JOB_DOWNLOAD_BATCH_SIZE = Math.max(1, parseInt(process.env.TG_JOB_DOWNLOAD_BATCH_SIZE || "20", 10) || 20);
var TG_JOB_MAX_ATTEMPTS = Math.max(1, parseInt(process.env.TG_JOB_MAX_ATTEMPTS || "3", 10) || 3);
var TELEGRAM_COMMENTS_MAX_PER_POST = Math.max(1, parseInt(process.env.TELEGRAM_COMMENTS_MAX_PER_POST || "200", 10) || 200);
var subscriptionTimer = null;
var recoveryStarted = false;
var recoveryRunning = false;
function parseTelegramSourceAllowlist(raw) {
return (raw || "").split(",").map((item) => item.trim()).filter(Boolean).map((item) => normalizeSource(item).toLowerCase());
}
@@ -5264,27 +5275,6 @@ async function getLatestMessageId(userClient2, source) {
const [latest] = await userClient2.getMessages(source, { limit: 1 });
return latest?.id || 0;
}
async function getMessagesByDateRange(userClient2, source, startDate, endDate, maxScan = 5e3) {
const result = [];
let offsetId = 0;
while (result.length < maxScan) {
const batch = await userClient2.getMessages(source, { limit: Math.min(100, maxScan - result.length), offsetId });
if (!batch.length) break;
let reachedOlder = false;
for (const message of batch) {
offsetId = message.id;
const messageDate = new Date((message.date || 0) * 1e3);
if (messageDate > endDate) continue;
if (messageDate < startDate) {
reachedOlder = true;
break;
}
if (messageHasMedia(message)) result.push(message);
}
if (reachedOlder) break;
}
return result.sort((a, b) => a.id - b.id);
}
function messageGroupId(message) {
const groupedId = message?.groupedId;
return groupedId ? groupedId.toString() : void 0;
@@ -5307,13 +5297,37 @@ async function expandMessagesWithMediaGroups(userClient2, source, messages) {
}
return Array.from(byId.values()).sort((a, b) => a.id - b.id);
}
async function persistDownloadItems(jobId, source, messages) {
for (const message of messages) {
function sourcePeerKey(value, fallback) {
if (value === void 0 || value === null) return fallback;
return String(value);
}
async function persistDownloadRefs(jobId, source, refs, folderOverride) {
for (const ref of refs) {
await query(
`INSERT INTO telegram_download_items (job_id, source, message_id, grouped_id, status)
VALUES ($1, $2, $3, $4, 'pending')
ON CONFLICT (job_id, message_id) DO NOTHING`,
[jobId, source, message.id, messageGroupId(message) || null]
`INSERT INTO telegram_download_items (
job_id, source, source_peer, origin, message_id, grouped_id, channel_post_id,
file_name, mime_type, total_size, folder_override, status, error, last_error, locked_at, completed_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'pending', NULL, NULL, NULL, NULL)
ON CONFLICT (job_id, source_peer, message_id)
DO UPDATE SET
file_name = COALESCE(EXCLUDED.file_name, telegram_download_items.file_name),
mime_type = COALESCE(EXCLUDED.mime_type, telegram_download_items.mime_type),
total_size = COALESCE(EXCLUDED.total_size, telegram_download_items.total_size),
folder_override = EXCLUDED.folder_override,
updated_at = NOW()`,
[
jobId,
source,
sourcePeerKey(ref.source, source),
ref.origin || "channel",
ref.id,
null,
ref.channelPostId || null,
ref.fileInfo?.fileName || null,
ref.fileInfo?.mimeType || null,
ref.totalSize || 0,
folderOverride || null
]
);
}
}
@@ -5355,7 +5369,7 @@ async function getDiscussionMediaRefs(userClient2, source, postMessages, options
if (seen.has(sourceKey)) continue;
seen.add(sourceKey);
mediaFound += 1;
refs.push({
const ref = {
id: comment.id,
source: comment.chatId || source,
origin: "comment",
@@ -5363,7 +5377,9 @@ async function getDiscussionMediaRefs(userClient2, source, postMessages, options
fileInfo,
totalSize: getEstimatedFileSize(comment),
message: comment
});
};
refs.push(ref);
await options.onRefDiscovered?.(ref);
}
if (batch.length === 0 || scannedForPost >= maxPerPost) break;
}
@@ -5383,6 +5399,9 @@ function toChannelDownloadRef(source, message) {
}
async function buildDownloadScanResult(userClient2, source, messages, options = {}) {
const refs = messages.map((message) => toChannelDownloadRef(source, message)).filter((ref) => Boolean(ref));
for (const ref of refs) {
await options.onRefDiscovered?.(ref);
}
const commentScan = await getDiscussionMediaRefs(userClient2, source, messages, options);
refs.push(...commentScan.refs);
return {
@@ -5393,16 +5412,37 @@ async function buildDownloadScanResult(userClient2, source, messages, options =
commentMediaFound: commentScan.mediaFound
};
}
async function updateDownloadItemsStatus(jobId, messageIds, status, error) {
const ids = Array.from(new Set((messageIds || []).filter((id) => id > 0)));
if (ids.length === 0) return;
async function markDownloadRefsDownloading(jobId, refs) {
for (const ref of refs) {
const sourcePeer = sourcePeerKey(ref.source, ref.origin === "comment" ? "comment" : "channel");
await query(
`UPDATE telegram_download_items
SET status = 'downloading', locked_at = NOW(), updated_at = NOW()
WHERE job_id = $1 AND source_peer = $2 AND message_id = $3
AND status IN ('pending', 'failed')`,
[jobId, sourcePeer, ref.id]
);
}
}
async function markDownloadRefStatus(jobId, ref, status, error) {
const sourcePeer = sourcePeerKey(ref.source, ref.origin === "comment" ? "comment" : "channel");
await query(
`UPDATE telegram_download_items
SET status = $2, error = $3, updated_at = NOW()
WHERE job_id = $1 AND message_id = ANY($4::int[])`,
[jobId, status, error || null, ids]
SET status = $3,
error = $4,
last_error = $4,
attempts = CASE WHEN $3 = 'failed' THEN attempts + 1 ELSE attempts END,
completed_at = CASE WHEN $3 IN ('success', 'skipped') THEN NOW() ELSE completed_at END,
locked_at = NULL,
updated_at = NOW()
WHERE job_id = $1 AND source_peer = $2 AND message_id = $5`,
[jobId, sourcePeer, status, error || null, ref.id]
);
}
async function persistDownloadMessages(jobId, source, messages, folderOverride) {
const refs = messages.map((message) => toChannelDownloadRef(source, message)).filter((ref) => Boolean(ref));
await persistDownloadRefs(jobId, source, refs, folderOverride);
}
function parseDateOnly(value, endOfDay = false) {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) throw new Error("\u65E5\u671F\u683C\u5F0F\u5FC5\u987B\u662F YYYY-MM-DD");
@@ -5422,13 +5462,17 @@ function parseDateOnly(value, endOfDay = false) {
}
async function createJob(userId, chatId, kind, source, params) {
const result = await query(
`INSERT INTO telegram_background_jobs (user_id, chat_id, kind, source, params)
VALUES ($1, $2, $3, $4, $5)
`INSERT INTO telegram_background_jobs (user_id, chat_id, kind, source, params, status, scan_status, download_status, scan_cursor)
VALUES ($1, $2, $3, $4, $5, 'queued', 'pending', 'pending', '{}'::jsonb)
RETURNING id`,
[userId, chatId || null, kind, source, JSON.stringify(params)]
);
return result.rows[0].id;
}
async function getJob(jobId) {
const result = await query(`SELECT * FROM telegram_background_jobs WHERE id = $1`, [jobId]);
return result.rows[0] || null;
}
async function updateJob(jobId, updates) {
const entries = Object.entries(updates);
if (entries.length === 0) return;
@@ -5486,132 +5530,272 @@ async function unsubscribeTelegramChannel(userId, selector) {
);
return result.rows[0] || null;
}
async function enqueueTelegramDateDownload(botClient, requestMessage, userId, sourceInput, startDateText, endDateText, folderOverride, options = {}) {
async function getJobItemStats(jobId) {
const result = await query(
`SELECT status, COUNT(*)::int AS count
FROM telegram_download_items
WHERE job_id = $1
GROUP BY status`,
[jobId]
);
const stats = { pending: 0, downloading: 0, success: 0, failed: 0, skipped: 0 };
for (const row of result.rows) stats[row.status] = Number(row.count || 0);
return stats;
}
async function getJobProgress(jobId) {
const job = await getJob(jobId);
if (!job) return null;
const params = job.params || {};
const cursor = job.scan_cursor || params.scan || {};
const stats = await getJobItemStats(jobId);
return {
jobId,
source: job.source,
mode: job.kind === "tag_download" ? "tag" : "date",
status: job.status,
scanStatus: job.scan_status || "pending",
downloadStatus: job.download_status || "pending",
channelMessagesScanned: Number(cursor.channelMessagesScanned || 0),
channelMediaFound: Number(cursor.channelMediaFound || 0),
commentMessagesScanned: Number(cursor.commentMessagesScanned || 0),
commentMediaFound: Number(cursor.commentMediaFound || 0),
totalMediaFound: Number(job.total_count || 0),
completed: Number(stats.success || 0),
pending: Number(stats.pending || 0),
downloading: Number(stats.downloading || 0),
failed: Number(stats.failed || 0),
skipped: Number(stats.skipped || 0),
cooldownUntil: job.cooldown_until ? new Date(job.cooldown_until).toISOString() : null
};
}
async function notifyProgress(jobId, options) {
const progress = await getJobProgress(jobId);
if (progress) await options.onProgress?.(progress);
}
function isFloodWait(error) {
const anyErr = error;
const text = `${anyErr?.message || ""} ${anyErr?.errorMessage || ""}`;
const seconds = Number(anyErr?.seconds || anyErr?.value || text.match(/FLOOD_WAIT_?(\d+)/i)?.[1] || 0);
if (seconds > 0 || /FLOOD|Too many requests/i.test(text)) return { seconds: Math.max(30, seconds || 60) };
return null;
}
async function ensureJobCanRun(jobId) {
const job = await getJob(jobId);
if (!job) return "cancelled";
if (job.cancelled_at || job.status === "cancelled") return "cancelled";
if (job.paused_at || job.status === "paused") return "paused";
if (job.cooldown_until && new Date(job.cooldown_until).getTime() > Date.now()) return "cooldown";
return "run";
}
async function waitUntilRunnable(jobId, options) {
while (true) {
const state = await ensureJobCanRun(jobId);
if (state === "run") return true;
if (state === "cancelled") return false;
await notifyProgress(jobId, options);
await new Promise((resolve) => setTimeout(resolve, state === "cooldown" ? 5e3 : 2e3));
}
}
async function claimPendingDownloadRefs(jobId, limit = TG_JOB_DOWNLOAD_BATCH_SIZE) {
const result = await query(
`UPDATE telegram_download_items i
SET status = 'downloading', locked_at = NOW(), updated_at = NOW()
WHERE i.id IN (
SELECT id FROM telegram_download_items
WHERE job_id = $1 AND status = 'pending' AND attempts < $2
ORDER BY created_at ASC
LIMIT $3
)
RETURNING source, source_peer, origin, message_id, channel_post_id, file_name, mime_type, total_size, folder_override`,
[jobId, TG_JOB_MAX_ATTEMPTS, limit]
);
return result.rows.filter((row) => row.file_name && row.mime_type).map((row) => ({
id: Number(row.message_id),
source: row.source_peer || row.source,
origin: row.origin === "comment" ? "comment" : "channel",
channelPostId: row.channel_post_id || void 0,
fileInfo: { fileName: row.file_name, mimeType: row.mime_type },
totalSize: Number(row.total_size || 0)
}));
}
async function downloadClaimedRefs(botClient, requestMessage, jobId, source, refs, folderOverride, options) {
if (refs.length === 0) return { found: 0, skipped: 0, failed: 0, successful: 0 };
await updateJob(jobId, { status: "running", download_status: "active", error: null });
try {
const result = await downloadTelegramChannelRange(botClient, requestMessage, source, 0, refs.length, "older", refs.map((ref) => ref.id), folderOverride, refs, async (ref, status, error) => {
await markDownloadRefStatus(jobId, ref, status, error);
await notifyProgress(jobId, options);
});
return result;
} catch (error) {
const flood = isFloodWait(error);
if (flood) {
const cooldownUntil = new Date(Date.now() + flood.seconds * 1e3);
await updateJob(jobId, { status: "running", cooldown_until: cooldownUntil, error: `Telegram FloodWait\uFF0C\u51B7\u5374\u5230 ${cooldownUntil.toISOString()}` });
for (const ref of refs) await markDownloadRefStatus(jobId, ref, "failed", `FloodWait ${flood.seconds}s`);
return { found: 0, skipped: 0, failed: refs.length, successful: 0 };
}
for (const ref of refs) await markDownloadRefStatus(jobId, ref, "failed", error instanceof Error ? error.message : String(error));
throw error;
}
}
async function downloadPendingForJob(botClient, requestMessage, jobId, source, folderOverride, options, drain = false) {
let aggregate = { found: 0, skipped: 0, failed: 0, successful: 0 };
while (await waitUntilRunnable(jobId, options)) {
const refs = await claimPendingDownloadRefs(jobId);
if (refs.length === 0) break;
const result = await downloadClaimedRefs(botClient, requestMessage, jobId, source, refs, folderOverride, options);
aggregate = {
found: aggregate.found + (result.found || 0),
skipped: aggregate.skipped + (result.skipped || 0),
failed: aggregate.failed + (result.failed || 0),
successful: aggregate.successful + (result.successful || 0)
};
if (!drain) break;
}
return aggregate;
}
async function finalizeTelegramJob(jobId, options) {
const stats = await getJobItemStats(jobId);
const pending = Number(stats.pending || 0) + Number(stats.downloading || 0);
const failed = Number(stats.failed || 0);
const status = pending > 0 ? "running" : failed > 0 ? "completed_with_errors" : "completed";
await updateJob(jobId, {
status,
download_status: pending > 0 ? "active" : "done",
enqueued_count: Number(stats.success || 0),
skipped_count: Number(stats.skipped || 0),
error: failed > 0 ? `${failed} \u4E2A\u6587\u4EF6\u4E0B\u8F7D\u5931\u8D25` : null,
finished_at: pending > 0 ? null : /* @__PURE__ */ new Date()
});
await notifyProgress(jobId, options);
}
async function scanChannelSegment(userClient2, jobId, source, params, cursor, options) {
const mode = params.mode;
const offsetId = Number(cursor.offsetId || 0);
const batch = await userClient2.getMessages(source, {
limit: TG_JOB_SCAN_SEGMENT_SIZE,
offsetId,
...mode === "tag" ? { search: params.tag } : {}
});
if (!batch.length) return { messages: [], done: true, nextOffsetId: offsetId };
let done = false;
let nextOffsetId = offsetId;
const matched = [];
for (const message of batch) {
nextOffsetId = message.id;
if (mode === "date") {
const messageDate = new Date((message.date || 0) * 1e3);
const startDate = new Date(params.startDateIso);
const endDate = new Date(params.endDateIso);
if (messageDate > endDate) continue;
if (messageDate < startDate) {
done = true;
break;
}
if (messageHasMedia(message)) matched.push(message);
} else if (messageHasMedia(message) && messageMatchesHashtag(message, params.tag)) {
matched.push(message);
}
}
const expanded = await expandMessagesWithMediaGroups(userClient2, source, matched);
return { messages: expanded, done: done || batch.length < TG_JOB_SCAN_SEGMENT_SIZE, nextOffsetId };
}
async function runSegmentedTelegramJob(botClient, requestMessage, jobId, source, folderOverride, options) {
const userClient2 = requireUserClient();
const job = await getJob(jobId);
const params = job?.params || {};
let cursor = job?.scan_cursor || {};
const discoveredRefKeys = /* @__PURE__ */ new Set();
let totals = { found: 0, skipped: 0, failed: 0, successful: 0 };
await updateJob(jobId, { status: "running", scan_status: "scanning", download_status: "active", started_at: job?.started_at || /* @__PURE__ */ new Date(), error: null });
while (await waitUntilRunnable(jobId, options)) {
const current = await getJob(jobId);
cursor = current?.scan_cursor || cursor || {};
if (current?.scan_status === "done") break;
try {
const segment = await scanChannelSegment(userClient2, jobId, source, params, cursor, options);
const onRefDiscovered = async (ref) => {
const key = `${sourcePeerKey(ref.source, source)}:${ref.id}`;
if (discoveredRefKeys.has(key)) return;
discoveredRefKeys.add(key);
await persistDownloadRefs(jobId, source, [ref], folderOverride);
};
const scan = await buildDownloadScanResult(userClient2, source, segment.messages, {
...options,
tag: params.tag,
startDate: params.startDateIso ? new Date(params.startDateIso) : void 0,
endDate: params.endDateIso ? new Date(params.endDateIso) : void 0,
onRefDiscovered
});
cursor = {
...cursor,
phase: segment.done ? "done" : "channel",
offsetId: segment.nextOffsetId,
channelMessagesScanned: Number(cursor.channelMessagesScanned || 0) + segment.messages.length,
channelMediaFound: Number(cursor.channelMediaFound || 0) + scan.channelMediaFound,
commentMessagesScanned: Number(cursor.commentMessagesScanned || 0) + scan.commentMessagesScanned,
commentMediaFound: Number(cursor.commentMediaFound || 0) + scan.commentMediaFound
};
const stats = await getJobItemStats(jobId);
await updateJob(jobId, { scan_cursor: JSON.stringify(cursor), total_count: Number(stats.pending || 0) + Number(stats.downloading || 0) + Number(stats.success || 0) + Number(stats.failed || 0) + Number(stats.skipped || 0), scan_status: segment.done ? "done" : "scanning" });
await notifyProgress(jobId, options);
const partial = await downloadPendingForJob(botClient, requestMessage, jobId, source, folderOverride, options, false);
totals = { found: totals.found + partial.found, skipped: totals.skipped + partial.skipped, failed: totals.failed + partial.failed, successful: totals.successful + partial.successful };
if (segment.done) break;
} catch (error) {
const flood = isFloodWait(error);
if (!flood) throw error;
const cooldownUntil = new Date(Date.now() + flood.seconds * 1e3);
await updateJob(jobId, { cooldown_until: cooldownUntil, error: `Telegram FloodWait\uFF0C\u51B7\u5374\u5230 ${cooldownUntil.toISOString()}` });
}
}
const runnable = await waitUntilRunnable(jobId, options);
if (!runnable) {
await updateJob(jobId, { status: "cancelled", scan_status: "cancelled", download_status: "cancelled", finished_at: /* @__PURE__ */ new Date() });
return { jobId, ...totals, requested: 0, commentMessagesScanned: Number(cursor.commentMessagesScanned || 0), commentMediaFound: Number(cursor.commentMediaFound || 0) };
}
await updateJob(jobId, { scan_status: "done" });
const drained = await downloadPendingForJob(botClient, requestMessage, jobId, source, folderOverride, options, true);
totals = { found: totals.found + drained.found, skipped: totals.skipped + drained.skipped, failed: totals.failed + drained.failed, successful: totals.successful + drained.successful };
await finalizeTelegramJob(jobId, options);
return { jobId, ...totals, requested: totals.found + totals.skipped, commentMessagesScanned: Number(cursor.commentMessagesScanned || 0), commentMediaFound: Number(cursor.commentMediaFound || 0) };
}
async function enqueueTelegramDateDownload(botClient, requestMessage, userId, sourceInput, startDateText, endDateText, folderOverride, options = {}) {
const source = normalizeSource(sourceInput);
await assertTelegramSourceAllowed(source);
const startDate = parseDateOnly(startDateText);
const endDate = parseDateOnly(endDateText, true);
if (startDate > endDate) throw new Error("\u5F00\u59CB\u65E5\u671F\u4E0D\u80FD\u665A\u4E8E\u7ED3\u675F\u65E5\u671F");
const baseMessages = await getMessagesByDateRange(userClient2, source, startDate, endDate);
const messages = await expandMessagesWithMediaGroups(userClient2, source, baseMessages);
const scan = await buildDownloadScanResult(userClient2, source, messages, { ...options, startDate, endDate });
const commentLimit = options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST;
await options.onScanComplete?.({
source,
mode: "date",
channelMessagesScanned: messages.length,
channelMediaFound: scan.channelMediaFound,
commentMessagesScanned: scan.commentMessagesScanned,
commentMediaFound: scan.commentMediaFound,
totalMediaFound: scan.refs.length,
commentsEnabled: Boolean(options.includeComments),
commentsMaxPerPost: commentLimit
});
const messageIds = scan.refs.map((ref) => ref.id);
const jobId = await createJob(userId, requestMessage.chatId?.toString(), "date_range", source, {
mode: "date",
startDate: startDateText,
endDate: endDateText,
messageIds,
startDateIso: startDate.toISOString(),
endDateIso: endDate.toISOString(),
folderOverride: folderOverride || null,
includeComments: Boolean(options.includeComments),
commentsMaxPerPost: options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
commentMessagesScanned: scan.commentMessagesScanned,
commentMediaFound: scan.commentMediaFound
commentsMaxPerPost: options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST
});
await persistDownloadItems(jobId, source, messages);
await updateJob(jobId, { status: "running", started_at: /* @__PURE__ */ new Date(), total_count: scan.refs.length });
try {
const result = await downloadTelegramChannelRange(botClient, requestMessage, source, 0, scan.refs.length, "older", messageIds, folderOverride, scan.refs);
await updateDownloadItemsStatus(jobId, result.successfulMessageIds, "success");
await updateDownloadItemsStatus(jobId, result.failedMessageIds, "failed", "\u4E0B\u8F7D\u5931\u8D25");
await updateDownloadItemsStatus(jobId, result.skippedMessageIds, "skipped");
await updateJob(jobId, {
status: result.failed > 0 ? "completed_with_errors" : "completed",
enqueued_count: result.found,
skipped_count: result.skipped,
error: result.failed > 0 ? `${result.failed} \u4E2A\u6587\u4EF6\u4E0B\u8F7D\u5931\u8D25` : null,
finished_at: /* @__PURE__ */ new Date()
});
return { jobId, commentMessagesScanned: scan.commentMessagesScanned, commentMediaFound: scan.commentMediaFound, ...result };
} catch (error) {
await updateJob(jobId, { status: "failed", error: error instanceof Error ? error.message : String(error), finished_at: /* @__PURE__ */ new Date() });
throw error;
}
}
async function getMessagesByHashtag(userClient2, source, tag, maxScan = 1e4) {
const normalizedTag = normalizeHashtag(tag);
const result = [];
let offsetId = 0;
while (result.length < maxScan) {
const batch = await userClient2.getMessages(source, {
limit: Math.min(100, maxScan - result.length),
offsetId,
search: normalizedTag
});
if (!batch.length) break;
for (const message of batch) {
offsetId = message.id;
if (messageHasMedia(message) && messageMatchesHashtag(message, normalizedTag)) {
result.push(message);
}
}
}
return result.sort((a, b) => a.id - b.id);
return runSegmentedTelegramJob(botClient, requestMessage, jobId, source, folderOverride, options);
}
async function enqueueTelegramTagDownload(botClient, requestMessage, userId, sourceInput, tagInput, folderOverride, options = {}) {
const userClient2 = requireUserClient();
const source = normalizeSource(sourceInput);
await assertTelegramSourceAllowed(source);
const tag = normalizeHashtag(tagInput);
const baseMessages = await getMessagesByHashtag(userClient2, source, tag);
const messages = await expandMessagesWithMediaGroups(userClient2, source, baseMessages);
const scan = await buildDownloadScanResult(userClient2, source, messages, { ...options, tag });
const commentLimit = options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST;
await options.onScanComplete?.({
source,
mode: "tag",
channelMessagesScanned: messages.length,
channelMediaFound: scan.channelMediaFound,
commentMessagesScanned: scan.commentMessagesScanned,
commentMediaFound: scan.commentMediaFound,
totalMediaFound: scan.refs.length,
commentsEnabled: Boolean(options.includeComments),
commentsMaxPerPost: commentLimit
});
const messageIds = scan.refs.map((ref) => ref.id);
const jobId = await createJob(userId, requestMessage.chatId?.toString(), "tag_download", source, {
mode: "tag",
tag,
messageIds,
folderOverride: folderOverride || null,
includeComments: Boolean(options.includeComments),
commentsMaxPerPost: options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
commentMessagesScanned: scan.commentMessagesScanned,
commentMediaFound: scan.commentMediaFound
commentsMaxPerPost: options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST
});
await persistDownloadItems(jobId, source, messages);
await updateJob(jobId, { status: "running", started_at: /* @__PURE__ */ new Date(), total_count: scan.refs.length });
try {
const result = await downloadTelegramChannelRange(botClient, requestMessage, source, 0, scan.refs.length, "older", messageIds, folderOverride, scan.refs);
await updateDownloadItemsStatus(jobId, result.successfulMessageIds, "success");
await updateDownloadItemsStatus(jobId, result.failedMessageIds, "failed", "\u4E0B\u8F7D\u5931\u8D25");
await updateDownloadItemsStatus(jobId, result.skippedMessageIds, "skipped");
await updateJob(jobId, {
status: result.failed > 0 ? "completed_with_errors" : "completed",
enqueued_count: result.found,
skipped_count: result.skipped,
error: result.failed > 0 ? `${result.failed} \u4E2A\u6587\u4EF6\u4E0B\u8F7D\u5931\u8D25` : null,
finished_at: /* @__PURE__ */ new Date()
});
return { jobId, tag, commentMessagesScanned: scan.commentMessagesScanned, commentMediaFound: scan.commentMediaFound, ...result };
} catch (error) {
await updateJob(jobId, { status: "failed", error: error instanceof Error ? error.message : String(error), finished_at: /* @__PURE__ */ new Date() });
throw error;
}
const result = await runSegmentedTelegramJob(botClient, requestMessage, jobId, source, folderOverride, options);
return { ...result, tag };
}
async function listTelegramBackgroundJobs(userId, limit = 10) {
const result = await query(
`SELECT id, kind, source, status, total_count, enqueued_count, skipped_count, duplicate_count, error, created_at, updated_at
`SELECT id, kind, source, status, scan_status, download_status, scan_cursor, cooldown_until, paused_at, cancelled_at, total_count, enqueued_count, skipped_count, duplicate_count, error, created_at, updated_at
FROM telegram_background_jobs
WHERE user_id = $1
ORDER BY created_at DESC
@@ -5620,6 +5804,56 @@ async function listTelegramBackgroundJobs(userId, limit = 10) {
);
return result.rows;
}
async function pauseTelegramBackgroundJob(userId, selector) {
const result = await query(
`UPDATE telegram_background_jobs
SET status = 'paused', paused_at = NOW(), updated_at = NOW()
WHERE user_id = $1 AND id::text LIKE $2 AND status NOT IN ('completed', 'cancelled')
RETURNING id, source, status`,
[userId, `${selector}%`]
);
return result.rows[0] || null;
}
async function resumeTelegramBackgroundJob(userId, selector) {
const result = await query(
`UPDATE telegram_background_jobs
SET status = 'running', paused_at = NULL, updated_at = NOW()
WHERE user_id = $1 AND id::text LIKE $2 AND status = 'paused'
RETURNING id, source, status`,
[userId, `${selector}%`]
);
return result.rows[0] || null;
}
async function cancelTelegramBackgroundJob(userId, selector) {
const result = await query(
`UPDATE telegram_background_jobs
SET status = 'cancelled', scan_status = 'cancelled', download_status = 'cancelled', cancelled_at = NOW(), finished_at = NOW(), updated_at = NOW()
WHERE user_id = $1 AND id::text LIKE $2 AND status NOT IN ('completed', 'cancelled')
RETURNING id, source, status`,
[userId, `${selector}%`]
);
if (result.rows[0]) {
await query(`UPDATE telegram_download_items SET status = 'skipped', locked_at = NULL, updated_at = NOW() WHERE job_id = $1 AND status IN ('pending', 'downloading')`, [result.rows[0].id]);
}
return result.rows[0] || null;
}
async function retryTelegramBackgroundJob(userId, selector) {
const result = await query(
`SELECT id FROM telegram_background_jobs WHERE user_id = $1 AND id::text LIKE $2 LIMIT 1`,
[userId, `${selector}%`]
);
const row = result.rows[0];
if (!row) return null;
const retry = await query(
`UPDATE telegram_download_items
SET status = 'pending', locked_at = NULL, last_error = NULL, error = NULL, updated_at = NOW()
WHERE job_id = $1 AND status = 'failed'
RETURNING id`,
[row.id]
);
await updateJob(row.id, { status: "running", download_status: "active", error: null, finished_at: null });
return { id: row.id, retried: retry.rowCount || 0 };
}
async function runSubscriptionScan(botClient) {
const userClient2 = getTelegramUserClient();
if (!userClient2 || !isTelegramUserClientReady()) return;
@@ -5639,14 +5873,13 @@ async function runSubscriptionScan(botClient) {
const ids = Array.from({ length: count }, (_, index) => lastMessageId + index + 1);
const jobId = await createJob(Number(row.user_id), row.chat_id?.toString(), "subscription_sync", row.source, { fromId: lastMessageId + 1, toId: latestMessageId });
const candidateMessages = await expandMessagesWithMediaGroups(userClient2, row.source, (await userClient2.getMessages(row.source, { ids })).filter(Boolean));
await persistDownloadItems(jobId, row.source, candidateMessages);
await persistDownloadMessages(jobId, row.source, candidateMessages, row.folder_override || null);
await updateJob(jobId, { status: "running", started_at: /* @__PURE__ */ new Date(), total_count: ids.length });
const targetChat = row.chat_id || row.user_id;
const requestMessage = { chatId: targetChat, id: latestMessageId };
const downloadResult = await downloadTelegramChannelRange(botClient, requestMessage, row.source, 0, ids.length, "newer", ids, row.folder_override || null);
await updateDownloadItemsStatus(jobId, downloadResult.successfulMessageIds, "success");
await updateDownloadItemsStatus(jobId, downloadResult.failedMessageIds, "failed", "\u4E0B\u8F7D\u5931\u8D25");
await updateDownloadItemsStatus(jobId, downloadResult.skippedMessageIds, "skipped");
const subscriptionRefs = candidateMessages.map((message) => toChannelDownloadRef(row.source, message)).filter((ref) => Boolean(ref));
await markDownloadRefsDownloading(jobId, subscriptionRefs);
const downloadResult = await downloadTelegramChannelRange(botClient, requestMessage, row.source, 0, ids.length, "newer", ids, row.folder_override || null, subscriptionRefs, (ref, status, error) => markDownloadRefStatus(jobId, ref, status, error));
await updateJob(jobId, {
status: downloadResult.failed > 0 ? "completed_with_errors" : "completed",
enqueued_count: downloadResult.found,
@@ -5665,6 +5898,95 @@ async function runSubscriptionScan(botClient) {
}
}
}
async function recoverTelegramJob(botClient, job) {
const itemResult = await query(
`SELECT id, source, source_peer, origin, message_id, channel_post_id, file_name, mime_type, total_size, folder_override
FROM telegram_download_items
WHERE job_id = $1 AND status = 'pending'
ORDER BY created_at ASC`,
[job.id]
);
if (itemResult.rows.length === 0) return;
const refs = itemResult.rows.filter((row) => row.file_name && row.mime_type).map((row) => ({
id: Number(row.message_id),
source: row.source_peer || row.source,
origin: row.origin === "comment" ? "comment" : "channel",
channelPostId: row.channel_post_id || void 0,
fileInfo: { fileName: row.file_name, mimeType: row.mime_type },
totalSize: Number(row.total_size || 0)
}));
if (refs.length === 0) return;
const targetChat = job.chat_id || job.user_id;
const requestMessage = { chatId: targetChat, id: 0 };
console.log(`\u267B\uFE0F \u6062\u590D Telegram \u4E0B\u8F7D\u4EFB\u52A1 ${String(job.id).slice(0, 8)}\uFF0C\u5F85\u5904\u7406 ${refs.length} \u4E2A\u6587\u4EF6`);
await updateJob(job.id, { status: "running", started_at: job.started_at || /* @__PURE__ */ new Date(), error: null });
try {
const result = await downloadTelegramChannelRange(
botClient,
requestMessage,
job.source,
0,
refs.length,
"older",
refs.map((ref) => ref.id),
itemResult.rows[0]?.folder_override || null,
refs,
(ref, status, error) => markDownloadRefStatus(job.id, ref, status, error)
);
await updateJob(job.id, {
status: result.failed > 0 ? "completed_with_errors" : "completed",
enqueued_count: result.found,
skipped_count: result.skipped,
error: result.failed > 0 ? `${result.failed} \u4E2A\u6587\u4EF6\u4E0B\u8F7D\u5931\u8D25` : null,
finished_at: /* @__PURE__ */ new Date()
});
await botClient.sendMessage(targetChat, {
message: `\u267B\uFE0F \u5DF2\u6062\u590D\u5E76\u5B8C\u6210\u4EFB\u52A1 ${String(job.id).slice(0, 8)}\uFF1A\u6210\u529F ${result.successful}\uFF0C\u8DF3\u8FC7 ${result.skipped}\uFF0C\u5931\u8D25 ${result.failed}`
}).catch(() => void 0);
} catch (error) {
await updateJob(job.id, { status: "failed", error: error instanceof Error ? error.message : String(error), finished_at: /* @__PURE__ */ new Date() });
throw error;
}
}
async function recoverInterruptedTelegramJobs(botClient) {
if (recoveryRunning) return;
recoveryRunning = true;
try {
await query(
`UPDATE telegram_download_items
SET status = 'pending', locked_at = NULL, updated_at = NOW()
WHERE status = 'downloading'
AND (locked_at IS NULL OR locked_at < NOW() - INTERVAL '30 minutes')`
);
const jobs = await query(
`SELECT DISTINCT j.*
FROM telegram_background_jobs j
JOIN telegram_download_items i ON i.job_id = j.id
WHERE j.kind IN ('date_range', 'tag_download')
AND j.status IN ('pending', 'running', 'failed', 'completed_with_errors')
AND i.status = 'pending'
ORDER BY j.created_at ASC
LIMIT 5`
);
for (const job of jobs.rows) {
if (job.scan_status !== "done" && (job.kind === "date_range" || job.kind === "tag_download") && job.params?.mode) {
const targetChat = job.chat_id || job.user_id;
const requestMessage = { chatId: targetChat, id: 0 };
await runSegmentedTelegramJob(botClient, requestMessage, job.id, job.source, job.params?.folderOverride || null, {}).catch((error) => console.error("\u267B\uFE0F Telegram \u5206\u6BB5\u4EFB\u52A1\u6062\u590D\u5931\u8D25:", error));
} else {
await recoverTelegramJob(botClient, job).catch((error) => console.error("\u267B\uFE0F Telegram \u4EFB\u52A1\u6062\u590D\u5931\u8D25:", error));
}
}
} finally {
recoveryRunning = false;
}
}
function startTelegramJobRecoveryWorker(botClient) {
if (recoveryStarted) return;
recoveryStarted = true;
setTimeout(() => recoverInterruptedTelegramJobs(botClient).catch((error) => console.error("\u267B\uFE0F Telegram \u4EFB\u52A1\u6062\u590D\u626B\u63CF\u5931\u8D25:", error)), TG_JOB_RECOVERY_DELAY_MS);
setInterval(() => recoverInterruptedTelegramJobs(botClient).catch((error) => console.error("\u267B\uFE0F Telegram \u4EFB\u52A1\u6062\u590D\u626B\u63CF\u5931\u8D25:", error)), SUBSCRIPTION_INTERVAL_MS);
}
function startTelegramSubscriptionWorker(botClient) {
if (subscriptionTimer) return;
subscriptionTimer = setInterval(() => {
@@ -6384,6 +6706,15 @@ async function handleStopTasks(message) {
}
async function handlePauseTasks(message, args = []) {
const taskId = args[0];
const senderId = message.senderId?.toJSNumber();
if (taskId && senderId) {
const job = await pauseTelegramBackgroundJob(senderId, taskId);
if (job) {
await message.reply({ message: `\u23F8\uFE0F \u5DF2\u6682\u505C\u9891\u9053\u4EFB\u52A1 ${String(job.id).slice(0, 8)}
\u6765\u6E90\uFF1A${job.source}` });
return;
}
}
const result = pauseDownloadTasks(taskId);
await message.reply({ message: `\u23F8\uFE0F \u5DF2\u6682\u505C\u5168\u5C40\u4E0B\u8F7D\u961F\u5217${taskId ? `
\u4EFB\u52A1\u5361: ${taskId}` : ""}
@@ -6395,6 +6726,15 @@ async function handlePauseTasks(message, args = []) {
}
async function handleResumeTasks(message, args = []) {
const taskId = args[0];
const senderId = message.senderId?.toJSNumber();
if (taskId && senderId) {
const job = await resumeTelegramBackgroundJob(senderId, taskId);
if (job) {
await message.reply({ message: `\u25B6\uFE0F \u5DF2\u7EE7\u7EED\u9891\u9053\u4EFB\u52A1 ${String(job.id).slice(0, 8)}
\u6765\u6E90\uFF1A${job.source}` });
return;
}
}
const result = resumeDownloadTasks(taskId);
await message.reply({ message: `\u25B6\uFE0F \u5DF2\u7EE7\u7EED\u5168\u5C40\u4E0B\u8F7D\u961F\u5217${taskId ? `
\u4EFB\u52A1\u5361: ${taskId}` : ""}
@@ -6404,6 +6744,15 @@ async function handleResumeTasks(message, args = []) {
}
async function handleCancelTask(message, args) {
const selector = args.join(" ").trim() || "all";
const senderId = message.senderId?.toJSNumber();
if (selector !== "all" && senderId) {
const job = await cancelTelegramBackgroundJob(senderId, selector);
if (job) {
await message.reply({ message: `\u{1F6D1} \u5DF2\u53D6\u6D88\u9891\u9053\u4EFB\u52A1 ${String(job.id).slice(0, 8)}
\u6765\u6E90\uFF1A${job.source}` });
return;
}
}
const result = cancelDownloadTask(selector);
await message.reply({ message: result.total > 0 ? `\u{1F6D1} \u5DF2\u53D6\u6D88\u5339\u914D\u4EFB\u52A1
@@ -6412,6 +6761,16 @@ async function handleCancelTask(message, args) {
\u7B49\u5F85\u4E2D: ${result.pending}` : `\u{1F4EE} \u6CA1\u6709\u627E\u5230\u5339\u914D\u7684\u4EFB\u52A1\uFF0C\u672A\u6E05\u7A7A\u5168\u5C40\u961F\u5217\uFF1A${selector}` });
}
async function handleRetryFailedTasks(message, args) {
const senderId = message.senderId?.toJSNumber();
const jobSelector = args.find((arg) => /^[0-9a-f-]{4,}$/i.test(arg));
if (senderId && jobSelector) {
const jobRetry = await retryTelegramBackgroundJob(senderId, jobSelector);
if (jobRetry) {
await message.reply({ message: jobRetry.retried > 0 ? `\u{1F504} \u5DF2\u91CD\u65B0\u52A0\u5165\u9891\u9053\u4EFB\u52A1\u5931\u8D25\u9879 ${jobRetry.retried} \u4E2A
\u4EFB\u52A1: ${String(jobRetry.id).slice(0, 8)}` : "\u{1F4EE} \u8BE5\u9891\u9053\u4EFB\u52A1\u6CA1\u6709\u53EF\u91CD\u8BD5\u5931\u8D25\u9879" });
return;
}
}
const taskId = args.find((arg) => /^t[a-z0-9]+/i.test(arg));
const numericArg = args.find((arg) => /^\d+$/.test(arg));
const limit = Math.max(1, Math.min(50, parseInt(numericArg || "10", 10) || 10));
@@ -7180,6 +7539,25 @@ function buildTelegramWizardPrompt(state) {
function isDateOnly(text) {
return /^\d{4}-\d{2}-\d{2}$/.test(text.trim());
}
async function updateJobProgressMessage(statusMessage, summary) {
const totalDone = summary.completed + summary.failed + summary.skipped;
const lines = [
summary.status === "paused" ? `\u23F8\uFE0F **\u9891\u9053\u4E0B\u8F7D\u5DF2\u6682\u505C**` : summary.status === "cancelled" ? `\u{1F6D1} **\u9891\u9053\u4E0B\u8F7D\u5DF2\u53D6\u6D88**` : totalDone >= summary.totalMediaFound && summary.scanStatus === "done" ? `\u2705 **\u9891\u9053\u4EFB\u52A1\u5B8C\u6210**` : `\u{1F50E} **\u9891\u9053\u4EFB\u52A1\u8FD0\u884C\u4E2D**`,
`\u{1F194} job: ${summary.jobId.slice(0, 8)}`,
`\u{1F4CD} \u9891\u9053\uFF1A${summary.source}`,
``,
`\u{1F50E} \u626B\u63CF\uFF1A${summary.scanStatus}`,
`\u{1F4C4} \u9891\u9053\u6B63\u6587\uFF1A\u5DF2\u626B ${summary.channelMessagesScanned} \u6761\uFF0C\u53D1\u73B0 ${summary.channelMediaFound} \u4E2A\u6587\u4EF6`,
`\u{1F4AC} \u8BC4\u8BBA\u533A\uFF1A\u5DF2\u626B ${summary.commentMessagesScanned} \u6761\uFF0C\u53D1\u73B0 ${summary.commentMediaFound} \u4E2A\u6587\u4EF6`,
``,
`\u2B07\uFE0F \u4E0B\u8F7D\uFF1A${summary.downloadStatus}`,
`\u2705 \u6210\u529F ${summary.completed}\u3000\u23F3 \u5F85\u4E0B\u8F7D ${summary.pending}\u3000\u{1F504} \u4E0B\u8F7D\u4E2D ${summary.downloading}\u3000\u274C \u5931\u8D25 ${summary.failed}\u3000\u23ED \u8DF3\u8FC7 ${summary.skipped}`,
summary.cooldownUntil ? `\u23F3 FloodWait \u51B7\u5374\u5230\uFF1A${summary.cooldownUntil}` : "",
``,
`\u63A7\u5236\uFF1A/task_pause ${summary.jobId.slice(0, 8)} \xB7 /task_resume ${summary.jobId.slice(0, 8)} \xB7 /task_cancel ${summary.jobId.slice(0, 8)}`
].filter(Boolean);
await statusMessage.edit({ text: lines.join("\n") }).catch(() => void 0);
}
async function updateScanStatusMessage(statusMessage, summary) {
const lines = [
`\u{1F50E} **\u626B\u63CF\u5B8C\u6210\uFF0C\u5F00\u59CB\u4E0B\u8F7D**`,
@@ -7372,7 +7750,8 @@ ${buildPathPreviewLine(state.customFolder)}` : "\u{1F4C1} \u672C\u8BA2\u9605\u4F
await replyWithJobResult(queuedMsg, message, enqueueTelegramTagDownload(client, message, senderId, state.source, input, state.customFolder, {
includeComments: Boolean(state.includeComments),
commentsMaxPerPost: state.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
onScanComplete: (summary) => updateScanStatusMessage(queuedMsg, summary)
onScanComplete: (summary) => updateScanStatusMessage(queuedMsg, summary),
onProgress: (summary) => updateJobProgressMessage(queuedMsg, summary)
}), "tag");
} catch (error) {
await message.reply({ message: `\u274C \u6807\u7B7E\u4E0B\u8F7D\u5931\u8D25: ${error instanceof Error ? error.message : String(error)}` });
@@ -7400,7 +7779,8 @@ ${buildPathPreviewLine(state.customFolder)}` : "\u{1F4C1} \u672C\u8BA2\u9605\u4F
await replyWithJobResult(queuedMsg, message, enqueueTelegramDateDownload(client, message, senderId, state.source, state.startDate, input, state.customFolder, {
includeComments: Boolean(state.includeComments),
commentsMaxPerPost: state.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
onScanComplete: (summary) => updateScanStatusMessage(queuedMsg, summary)
onScanComplete: (summary) => updateScanStatusMessage(queuedMsg, summary),
onProgress: (summary) => updateJobProgressMessage(queuedMsg, summary)
}), "date");
} catch (error) {
await message.reply({ message: `\u274C \u65E5\u671F\u4E0B\u8F7D\u5931\u8D25: ${error instanceof Error ? error.message : String(error)}` });
@@ -7903,6 +8283,7 @@ async function initTelegramBot() {
}
startPeriodicCleanup();
startTelegramSubscriptionWorker(client);
startTelegramJobRecoveryWorker(client);
client.addEventHandler(async (event) => {
if (!client) return;
try {
+42
View File
@@ -116,6 +116,12 @@ CREATE TABLE IF NOT EXISTS telegram_background_jobs (
kind VARCHAR(50) NOT NULL,
source TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
scan_status TEXT DEFAULT 'pending',
download_status TEXT DEFAULT 'pending',
scan_cursor JSONB DEFAULT '{}'::jsonb,
cooldown_until TIMESTAMPTZ,
paused_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
params JSONB DEFAULT '{}'::jsonb,
total_count INT DEFAULT 0,
enqueued_count INT DEFAULT 0,
@@ -130,6 +136,14 @@ CREATE TABLE IF NOT EXISTS telegram_background_jobs (
CREATE INDEX IF NOT EXISTS idx_tg_background_jobs_user_created ON telegram_background_jobs(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_tg_background_jobs_status ON telegram_background_jobs(status);
ALTER TABLE telegram_background_jobs ADD COLUMN IF NOT EXISTS scan_status TEXT DEFAULT 'pending';
ALTER TABLE telegram_background_jobs ADD COLUMN IF NOT EXISTS download_status TEXT DEFAULT 'pending';
ALTER TABLE telegram_background_jobs ADD COLUMN IF NOT EXISTS scan_cursor JSONB DEFAULT '{}'::jsonb;
ALTER TABLE telegram_background_jobs ADD COLUMN IF NOT EXISTS cooldown_until TIMESTAMPTZ;
ALTER TABLE telegram_background_jobs ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ;
ALTER TABLE telegram_background_jobs ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_tg_background_jobs_pipeline ON telegram_background_jobs(status, scan_status, download_status);
CREATE INDEX IF NOT EXISTS idx_tg_background_jobs_cooldown ON telegram_background_jobs(cooldown_until);
CREATE OR REPLACE TRIGGER telegram_background_jobs_updated_at
BEFORE UPDATE ON telegram_background_jobs
@@ -141,16 +155,44 @@ CREATE TABLE IF NOT EXISTS telegram_download_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
job_id UUID REFERENCES telegram_background_jobs(id) ON DELETE CASCADE,
source TEXT NOT NULL,
source_peer TEXT,
origin TEXT DEFAULT 'channel',
message_id INT NOT NULL,
grouped_id TEXT,
channel_post_id INT,
file_name TEXT,
mime_type TEXT,
total_size BIGINT DEFAULT 0,
folder_override TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts INT DEFAULT 0,
error TEXT,
last_error TEXT,
locked_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(job_id, message_id)
);
ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS source_peer TEXT;
ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS origin TEXT DEFAULT 'channel';
ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS channel_post_id INT;
ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS file_name TEXT;
ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS mime_type TEXT;
ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS total_size BIGINT DEFAULT 0;
ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS folder_override TEXT;
ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS attempts INT DEFAULT 0;
ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS last_error TEXT;
ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
ALTER TABLE telegram_download_items ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
UPDATE telegram_download_items SET source_peer = COALESCE(source_peer, source) WHERE source_peer IS NULL;
ALTER TABLE telegram_download_items DROP CONSTRAINT IF EXISTS telegram_download_items_job_id_message_id_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_tg_download_items_job_peer_msg
ON telegram_download_items(job_id, source_peer, message_id);
CREATE INDEX IF NOT EXISTS idx_tg_download_items_job_status ON telegram_download_items(job_id, status);
CREATE INDEX IF NOT EXISTS idx_tg_download_items_recover ON telegram_download_items(status, locked_at);
CREATE OR REPLACE TRIGGER telegram_download_items_updated_at
BEFORE UPDATE ON telegram_download_items
+25
View File
@@ -14,6 +14,8 @@ import {
enqueueTelegramDateDownload,
enqueueTelegramTagDownload,
listTelegramSubscriptions,
type TelegramJobProgressSummary,
startTelegramJobRecoveryWorker,
startTelegramSubscriptionWorker,
subscribeTelegramChannel,
unsubscribeTelegramChannel,
@@ -297,6 +299,26 @@ interface TelegramDownloadScanSummary {
commentsMaxPerPost: number;
}
async function updateJobProgressMessage(statusMessage: Api.Message, summary: TelegramJobProgressSummary): Promise<void> {
const totalDone = summary.completed + summary.failed + summary.skipped;
const lines = [
summary.status === 'paused' ? `⏸️ **频道下载已暂停**` : summary.status === 'cancelled' ? `🛑 **频道下载已取消**` : totalDone >= summary.totalMediaFound && summary.scanStatus === 'done' ? `✅ **频道任务完成**` : `🔎 **频道任务运行中**`,
`🆔 job: ${summary.jobId.slice(0, 8)}`,
`📍 频道:${summary.source}`,
``,
`🔎 扫描:${summary.scanStatus}`,
`📄 频道正文:已扫 ${summary.channelMessagesScanned} 条,发现 ${summary.channelMediaFound} 个文件`,
`💬 评论区:已扫 ${summary.commentMessagesScanned} 条,发现 ${summary.commentMediaFound} 个文件`,
``,
`⬇️ 下载:${summary.downloadStatus}`,
`✅ 成功 ${summary.completed} ⏳ 待下载 ${summary.pending} 🔄 下载中 ${summary.downloading} ❌ 失败 ${summary.failed} ⏭ 跳过 ${summary.skipped}`,
summary.cooldownUntil ? `⏳ FloodWait 冷却到:${summary.cooldownUntil}` : '',
``,
`控制:/task_pause ${summary.jobId.slice(0, 8)} · /task_resume ${summary.jobId.slice(0, 8)} · /task_cancel ${summary.jobId.slice(0, 8)}`,
].filter(Boolean);
await statusMessage.edit({ text: lines.join('\n') }).catch(() => undefined);
}
async function updateScanStatusMessage(statusMessage: Api.Message, summary: TelegramDownloadScanSummary): Promise<void> {
const lines = [
`🔎 **扫描完成,开始下载**`,
@@ -498,6 +520,7 @@ async function handleTelegramWizardMessage(message: Api.Message, senderId: numbe
includeComments: Boolean(state.includeComments),
commentsMaxPerPost: state.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
onScanComplete: summary => updateScanStatusMessage(queuedMsg as Api.Message, summary),
onProgress: summary => updateJobProgressMessage(queuedMsg as Api.Message, summary),
}), 'tag');
} catch (error) {
await message.reply({ message: `❌ 标签下载失败: ${error instanceof Error ? error.message : String(error)}` });
@@ -529,6 +552,7 @@ async function handleTelegramWizardMessage(message: Api.Message, senderId: numbe
includeComments: Boolean(state.includeComments),
commentsMaxPerPost: state.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
onScanComplete: summary => updateScanStatusMessage(queuedMsg as Api.Message, summary),
onProgress: summary => updateJobProgressMessage(queuedMsg as Api.Message, summary),
}), 'date');
} catch (error) {
await message.reply({ message: `❌ 日期下载失败: ${error instanceof Error ? error.message : String(error)}` });
@@ -1087,6 +1111,7 @@ export async function initTelegramBot(): Promise<void> {
// 启动定期清理(每小时)
startPeriodicCleanup();
startTelegramSubscriptionWorker(client);
startTelegramJobRecoveryWorker(client);
// Handle Messages
client.addEventHandler(async (event: NewMessageEvent) => {
+525 -97
View File
@@ -8,8 +8,14 @@ import { extractFileInfo, getEstimatedFileSize } from '../utils/telegramMedia.js
const SUBSCRIPTION_INTERVAL_MS = Math.max(60_000, parseInt(process.env.TELEGRAM_SUBSCRIPTION_INTERVAL_MS || '300000', 10) || 300_000);
const SUBSCRIPTION_SCAN_LIMIT = Math.max(1, parseInt(process.env.TELEGRAM_SUBSCRIPTION_SCAN_LIMIT || '100', 10) || 100);
const TG_JOB_RECOVERY_DELAY_MS = Math.max(1000, parseInt(process.env.TG_JOB_RECOVERY_DELAY_MS || '10000', 10) || 10_000);
const TG_JOB_SCAN_SEGMENT_SIZE = Math.max(20, parseInt(process.env.TG_JOB_SCAN_SEGMENT_SIZE || '100', 10) || 100);
const TG_JOB_DOWNLOAD_BATCH_SIZE = Math.max(1, parseInt(process.env.TG_JOB_DOWNLOAD_BATCH_SIZE || '20', 10) || 20);
const TG_JOB_MAX_ATTEMPTS = Math.max(1, parseInt(process.env.TG_JOB_MAX_ATTEMPTS || '3', 10) || 3);
export const TELEGRAM_COMMENTS_MAX_PER_POST = Math.max(1, parseInt(process.env.TELEGRAM_COMMENTS_MAX_PER_POST || '200', 10) || 200);
let subscriptionTimer: NodeJS.Timeout | null = null;
let recoveryStarted = false;
let recoveryRunning = false;
function parseTelegramSourceAllowlist(raw: string | undefined): string[] {
return (raw || '')
@@ -149,13 +155,38 @@ async function expandMessagesWithMediaGroups(userClient: TelegramClient, source:
return Array.from(byId.values()).sort((a, b) => a.id - b.id);
}
async function persistDownloadItems(jobId: string, source: string, messages: Api.Message[]) {
for (const message of messages) {
function sourcePeerKey(value: unknown, fallback: string): string {
if (value === undefined || value === null) return fallback;
return String(value);
}
async function persistDownloadRefs(jobId: string, source: string, refs: TelegramDownloadMessageRef[], folderOverride?: string | null) {
for (const ref of refs) {
await query(
`INSERT INTO telegram_download_items (job_id, source, message_id, grouped_id, status)
VALUES ($1, $2, $3, $4, 'pending')
ON CONFLICT (job_id, message_id) DO NOTHING`,
[jobId, source, message.id, messageGroupId(message) || null]
`INSERT INTO telegram_download_items (
job_id, source, source_peer, origin, message_id, grouped_id, channel_post_id,
file_name, mime_type, total_size, folder_override, status, error, last_error, locked_at, completed_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'pending', NULL, NULL, NULL, NULL)
ON CONFLICT (job_id, source_peer, message_id)
DO UPDATE SET
file_name = COALESCE(EXCLUDED.file_name, telegram_download_items.file_name),
mime_type = COALESCE(EXCLUDED.mime_type, telegram_download_items.mime_type),
total_size = COALESCE(EXCLUDED.total_size, telegram_download_items.total_size),
folder_override = EXCLUDED.folder_override,
updated_at = NOW()`,
[
jobId,
source,
sourcePeerKey(ref.source, source),
ref.origin || 'channel',
ref.id,
null,
ref.channelPostId || null,
ref.fileInfo?.fileName || null,
ref.fileInfo?.mimeType || null,
ref.totalSize || 0,
folderOverride || null,
]
);
}
}
@@ -176,6 +207,29 @@ interface TelegramCommentScanOptions {
includeComments?: boolean;
commentsMaxPerPost?: number;
onScanComplete?: (summary: TelegramDownloadScanSummary) => Promise<void> | void;
onProgress?: (summary: TelegramJobProgressSummary) => Promise<void> | void;
onRefDiscovered?: (ref: TelegramDownloadMessageRef) => Promise<void> | void;
}
export interface TelegramJobProgressSummary {
jobId: string;
source: string;
mode: 'date' | 'tag';
status: string;
scanStatus: string;
downloadStatus: string;
channelMessagesScanned: number;
channelMediaFound: number;
commentMessagesScanned: number;
commentMediaFound: number;
totalMediaFound: number;
completed: number;
pending: number;
downloading: number;
failed: number;
skipped: number;
currentFileName?: string;
cooldownUntil?: string | null;
}
interface TelegramDownloadScanResult {
@@ -236,7 +290,7 @@ async function getDiscussionMediaRefs(
if (seen.has(sourceKey)) continue;
seen.add(sourceKey);
mediaFound += 1;
refs.push({
const ref: TelegramDownloadMessageRef = {
id: comment.id,
source: comment.chatId || source,
origin: 'comment',
@@ -244,7 +298,9 @@ async function getDiscussionMediaRefs(
fileInfo,
totalSize: getEstimatedFileSize(comment),
message: comment,
});
};
refs.push(ref);
await options.onRefDiscovered?.(ref);
}
if (batch.length === 0 || scannedForPost >= maxPerPost) break;
@@ -275,6 +331,9 @@ async function buildDownloadScanResult(
const refs = messages
.map(message => toChannelDownloadRef(source, message))
.filter((ref): ref is TelegramDownloadMessageRef => Boolean(ref));
for (const ref of refs) {
await options.onRefDiscovered?.(ref);
}
const commentScan = await getDiscussionMediaRefs(userClient, source, messages, options);
refs.push(...commentScan.refs);
return {
@@ -286,12 +345,50 @@ async function buildDownloadScanResult(
};
}
async function markDownloadRefsDownloading(jobId: string, refs: TelegramDownloadMessageRef[]) {
for (const ref of refs) {
const sourcePeer = sourcePeerKey(ref.source, ref.origin === 'comment' ? 'comment' : 'channel');
await query(
`UPDATE telegram_download_items
SET status = 'downloading', locked_at = NOW(), updated_at = NOW()
WHERE job_id = $1 AND source_peer = $2 AND message_id = $3
AND status IN ('pending', 'failed')`,
[jobId, sourcePeer, ref.id]
);
}
}
async function markDownloadRefStatus(jobId: string, ref: TelegramDownloadMessageRef, status: 'success' | 'failed' | 'skipped', error?: string) {
const sourcePeer = sourcePeerKey(ref.source, ref.origin === 'comment' ? 'comment' : 'channel');
await query(
`UPDATE telegram_download_items
SET status = $3,
error = $4,
last_error = $4,
attempts = CASE WHEN $3 = 'failed' THEN attempts + 1 ELSE attempts END,
completed_at = CASE WHEN $3 IN ('success', 'skipped') THEN NOW() ELSE completed_at END,
locked_at = NULL,
updated_at = NOW()
WHERE job_id = $1 AND source_peer = $2 AND message_id = $5`,
[jobId, sourcePeer, status, error || null, ref.id]
);
}
async function persistDownloadMessages(jobId: string, source: string, messages: Api.Message[], folderOverride?: string | null) {
const refs = messages
.map(message => toChannelDownloadRef(source, message))
.filter((ref): ref is TelegramDownloadMessageRef => Boolean(ref));
await persistDownloadRefs(jobId, source, refs, folderOverride);
}
async function updateDownloadItemsStatus(jobId: string, messageIds: number[] | undefined, status: 'success' | 'failed' | 'skipped', error?: string) {
const ids = Array.from(new Set((messageIds || []).filter(id => id > 0)));
if (ids.length === 0) return;
await query(
`UPDATE telegram_download_items
SET status = $2, error = $3, updated_at = NOW()
SET status = $2, error = $3, last_error = $3, updated_at = NOW(),
completed_at = CASE WHEN $2 IN ('success', 'skipped') THEN NOW() ELSE completed_at END,
locked_at = NULL
WHERE job_id = $1 AND message_id = ANY($4::int[])`,
[jobId, status, error || null, ids]
);
@@ -318,14 +415,19 @@ export function parseDateOnly(value: string, endOfDay = false): Date {
async function createJob(userId: number, chatId: string | undefined, kind: string, source: string, params: Record<string, unknown>) {
const result = await query(
`INSERT INTO telegram_background_jobs (user_id, chat_id, kind, source, params)
VALUES ($1, $2, $3, $4, $5)
`INSERT INTO telegram_background_jobs (user_id, chat_id, kind, source, params, status, scan_status, download_status, scan_cursor)
VALUES ($1, $2, $3, $4, $5, 'queued', 'pending', 'pending', '{}'::jsonb)
RETURNING id`,
[userId, chatId || null, kind, source, JSON.stringify(params)]
);
return result.rows[0].id as string;
}
async function getJob(jobId: string) {
const result = await query(`SELECT * FROM telegram_background_jobs WHERE id = $1`, [jobId]);
return result.rows[0] || null;
}
async function updateJob(jobId: string, updates: Record<string, unknown>) {
const entries = Object.entries(updates);
if (entries.length === 0) return;
@@ -391,60 +493,269 @@ export async function unsubscribeTelegramChannel(userId: number, selector: strin
return result.rows[0] || null;
}
export async function enqueueTelegramDateDownload(botClient: TelegramClient, requestMessage: Api.Message, userId: number, sourceInput: string, startDateText: string, endDateText: string, folderOverride?: string | null, options: TelegramCommentScanOptions = {}) {
async function getJobItemStats(jobId: string) {
const result = await query(
`SELECT status, COUNT(*)::int AS count
FROM telegram_download_items
WHERE job_id = $1
GROUP BY status`,
[jobId]
);
const stats: Record<string, number> = { pending: 0, downloading: 0, success: 0, failed: 0, skipped: 0 };
for (const row of result.rows) stats[row.status] = Number(row.count || 0);
return stats;
}
async function getJobProgress(jobId: string): Promise<TelegramJobProgressSummary | null> {
const job = await getJob(jobId);
if (!job) return null;
const params = job.params || {};
const cursor = job.scan_cursor || params.scan || {};
const stats = await getJobItemStats(jobId);
return {
jobId,
source: job.source,
mode: job.kind === 'tag_download' ? 'tag' : 'date',
status: job.status,
scanStatus: job.scan_status || 'pending',
downloadStatus: job.download_status || 'pending',
channelMessagesScanned: Number(cursor.channelMessagesScanned || 0),
channelMediaFound: Number(cursor.channelMediaFound || 0),
commentMessagesScanned: Number(cursor.commentMessagesScanned || 0),
commentMediaFound: Number(cursor.commentMediaFound || 0),
totalMediaFound: Number(job.total_count || 0),
completed: Number(stats.success || 0),
pending: Number(stats.pending || 0),
downloading: Number(stats.downloading || 0),
failed: Number(stats.failed || 0),
skipped: Number(stats.skipped || 0),
cooldownUntil: job.cooldown_until ? new Date(job.cooldown_until).toISOString() : null,
};
}
async function notifyProgress(jobId: string, options: TelegramCommentScanOptions) {
const progress = await getJobProgress(jobId);
if (progress) await options.onProgress?.(progress);
}
function isFloodWait(error: unknown): { seconds: number } | null {
const anyErr = error as any;
const text = `${anyErr?.message || ''} ${anyErr?.errorMessage || ''}`;
const seconds = Number(anyErr?.seconds || anyErr?.value || text.match(/FLOOD_WAIT_?(\d+)/i)?.[1] || 0);
if (seconds > 0 || /FLOOD|Too many requests/i.test(text)) return { seconds: Math.max(30, seconds || 60) };
return null;
}
async function ensureJobCanRun(jobId: string): Promise<'run' | 'paused' | 'cancelled' | 'cooldown'> {
const job = await getJob(jobId);
if (!job) return 'cancelled';
if (job.cancelled_at || job.status === 'cancelled') return 'cancelled';
if (job.paused_at || job.status === 'paused') return 'paused';
if (job.cooldown_until && new Date(job.cooldown_until).getTime() > Date.now()) return 'cooldown';
return 'run';
}
async function waitUntilRunnable(jobId: string, options: TelegramCommentScanOptions): Promise<boolean> {
while (true) {
const state = await ensureJobCanRun(jobId);
if (state === 'run') return true;
if (state === 'cancelled') return false;
await notifyProgress(jobId, options);
await new Promise(resolve => setTimeout(resolve, state === 'cooldown' ? 5000 : 2000));
}
}
async function claimPendingDownloadRefs(jobId: string, limit = TG_JOB_DOWNLOAD_BATCH_SIZE): Promise<TelegramDownloadMessageRef[]> {
const result = await query(
`UPDATE telegram_download_items i
SET status = 'downloading', locked_at = NOW(), updated_at = NOW()
WHERE i.id IN (
SELECT id FROM telegram_download_items
WHERE job_id = $1 AND status = 'pending' AND attempts < $2
ORDER BY created_at ASC
LIMIT $3
)
RETURNING source, source_peer, origin, message_id, channel_post_id, file_name, mime_type, total_size, folder_override`,
[jobId, TG_JOB_MAX_ATTEMPTS, limit]
);
return result.rows
.filter(row => row.file_name && row.mime_type)
.map(row => ({
id: Number(row.message_id),
source: row.source_peer || row.source,
origin: row.origin === 'comment' ? 'comment' : 'channel',
channelPostId: row.channel_post_id || undefined,
fileInfo: { fileName: row.file_name, mimeType: row.mime_type },
totalSize: Number(row.total_size || 0),
}));
}
async function downloadClaimedRefs(botClient: TelegramClient, requestMessage: Api.Message, jobId: string, source: string, refs: TelegramDownloadMessageRef[], folderOverride: string | null | undefined, options: TelegramCommentScanOptions) {
if (refs.length === 0) return { found: 0, skipped: 0, failed: 0, successful: 0 };
await updateJob(jobId, { status: 'running', download_status: 'active', error: null });
try {
const result = await downloadTelegramChannelRange(botClient, requestMessage, source, 0, refs.length, 'older', refs.map(ref => ref.id), folderOverride, refs, async (ref, status, error) => {
await markDownloadRefStatus(jobId, ref, status, error);
await notifyProgress(jobId, options);
});
return result;
} catch (error) {
const flood = isFloodWait(error);
if (flood) {
const cooldownUntil = new Date(Date.now() + flood.seconds * 1000);
await updateJob(jobId, { status: 'running', cooldown_until: cooldownUntil, error: `Telegram FloodWait,冷却到 ${cooldownUntil.toISOString()}` });
for (const ref of refs) await markDownloadRefStatus(jobId, ref, 'failed', `FloodWait ${flood.seconds}s`);
return { found: 0, skipped: 0, failed: refs.length, successful: 0 };
}
for (const ref of refs) await markDownloadRefStatus(jobId, ref, 'failed', error instanceof Error ? error.message : String(error));
throw error;
}
}
async function downloadPendingForJob(botClient: TelegramClient, requestMessage: Api.Message, jobId: string, source: string, folderOverride: string | null | undefined, options: TelegramCommentScanOptions, drain = false) {
let aggregate = { found: 0, skipped: 0, failed: 0, successful: 0 };
while (await waitUntilRunnable(jobId, options)) {
const refs = await claimPendingDownloadRefs(jobId);
if (refs.length === 0) break;
const result = await downloadClaimedRefs(botClient, requestMessage, jobId, source, refs, folderOverride, options);
aggregate = {
found: aggregate.found + (result.found || 0),
skipped: aggregate.skipped + (result.skipped || 0),
failed: aggregate.failed + (result.failed || 0),
successful: aggregate.successful + (result.successful || 0),
};
if (!drain) break;
}
return aggregate;
}
async function finalizeTelegramJob(jobId: string, options: TelegramCommentScanOptions) {
const stats = await getJobItemStats(jobId);
const pending = Number(stats.pending || 0) + Number(stats.downloading || 0);
const failed = Number(stats.failed || 0);
const status = pending > 0 ? 'running' : failed > 0 ? 'completed_with_errors' : 'completed';
await updateJob(jobId, {
status,
download_status: pending > 0 ? 'active' : 'done',
enqueued_count: Number(stats.success || 0),
skipped_count: Number(stats.skipped || 0),
error: failed > 0 ? `${failed} 个文件下载失败` : null,
finished_at: pending > 0 ? null : new Date(),
});
await notifyProgress(jobId, options);
}
async function scanChannelSegment(userClient: TelegramClient, jobId: string, source: string, params: any, cursor: any, options: TelegramCommentScanOptions): Promise<{ messages: Api.Message[]; done: boolean; nextOffsetId: number }> {
const mode = params.mode as 'date' | 'tag';
const offsetId = Number(cursor.offsetId || 0);
const batch = await userClient.getMessages(source as any, {
limit: TG_JOB_SCAN_SEGMENT_SIZE,
offsetId,
...(mode === 'tag' ? { search: params.tag } : {}),
});
if (!batch.length) return { messages: [], done: true, nextOffsetId: offsetId };
let done = false;
let nextOffsetId = offsetId;
const matched: Api.Message[] = [];
for (const message of batch) {
nextOffsetId = message.id;
if (mode === 'date') {
const messageDate = new Date((message.date || 0) * 1000);
const startDate = new Date(params.startDateIso);
const endDate = new Date(params.endDateIso);
if (messageDate > endDate) continue;
if (messageDate < startDate) { done = true; break; }
if (messageHasMedia(message)) matched.push(message);
} else if (messageHasMedia(message) && messageMatchesHashtag(message, params.tag)) {
matched.push(message);
}
}
const expanded = await expandMessagesWithMediaGroups(userClient, source, matched);
return { messages: expanded, done: done || batch.length < TG_JOB_SCAN_SEGMENT_SIZE, nextOffsetId };
}
async function runSegmentedTelegramJob(botClient: TelegramClient, requestMessage: Api.Message, jobId: string, source: string, folderOverride: string | null | undefined, options: TelegramCommentScanOptions) {
const userClient = requireUserClient();
const job = await getJob(jobId);
const params = job?.params || {};
let cursor = job?.scan_cursor || {};
const discoveredRefKeys = new Set<string>();
let totals = { found: 0, skipped: 0, failed: 0, successful: 0 };
await updateJob(jobId, { status: 'running', scan_status: 'scanning', download_status: 'active', started_at: job?.started_at || new Date(), error: null });
while (await waitUntilRunnable(jobId, options)) {
const current = await getJob(jobId);
cursor = current?.scan_cursor || cursor || {};
if (current?.scan_status === 'done') break;
try {
const segment = await scanChannelSegment(userClient, jobId, source, params, cursor, options);
const onRefDiscovered = async (ref: TelegramDownloadMessageRef) => {
const key = `${sourcePeerKey(ref.source, source)}:${ref.id}`;
if (discoveredRefKeys.has(key)) return;
discoveredRefKeys.add(key);
await persistDownloadRefs(jobId, source, [ref], folderOverride);
};
const scan = await buildDownloadScanResult(userClient, source, segment.messages, {
...options,
tag: params.tag,
startDate: params.startDateIso ? new Date(params.startDateIso) : undefined,
endDate: params.endDateIso ? new Date(params.endDateIso) : undefined,
onRefDiscovered,
});
cursor = {
...cursor,
phase: segment.done ? 'done' : 'channel',
offsetId: segment.nextOffsetId,
channelMessagesScanned: Number(cursor.channelMessagesScanned || 0) + segment.messages.length,
channelMediaFound: Number(cursor.channelMediaFound || 0) + scan.channelMediaFound,
commentMessagesScanned: Number(cursor.commentMessagesScanned || 0) + scan.commentMessagesScanned,
commentMediaFound: Number(cursor.commentMediaFound || 0) + scan.commentMediaFound,
};
const stats = await getJobItemStats(jobId);
await updateJob(jobId, { scan_cursor: JSON.stringify(cursor), total_count: Number(stats.pending || 0) + Number(stats.downloading || 0) + Number(stats.success || 0) + Number(stats.failed || 0) + Number(stats.skipped || 0), scan_status: segment.done ? 'done' : 'scanning' });
await notifyProgress(jobId, options);
const partial = await downloadPendingForJob(botClient, requestMessage, jobId, source, folderOverride, options, false);
totals = { found: totals.found + partial.found, skipped: totals.skipped + partial.skipped, failed: totals.failed + partial.failed, successful: totals.successful + partial.successful };
if (segment.done) break;
} catch (error) {
const flood = isFloodWait(error);
if (!flood) throw error;
const cooldownUntil = new Date(Date.now() + flood.seconds * 1000);
await updateJob(jobId, { cooldown_until: cooldownUntil, error: `Telegram FloodWait,冷却到 ${cooldownUntil.toISOString()}` });
}
}
const runnable = await waitUntilRunnable(jobId, options);
if (!runnable) {
await updateJob(jobId, { status: 'cancelled', scan_status: 'cancelled', download_status: 'cancelled', finished_at: new Date() });
return { jobId, ...totals, requested: 0, commentMessagesScanned: Number(cursor.commentMessagesScanned || 0), commentMediaFound: Number(cursor.commentMediaFound || 0) };
}
await updateJob(jobId, { scan_status: 'done' });
const drained = await downloadPendingForJob(botClient, requestMessage, jobId, source, folderOverride, options, true);
totals = { found: totals.found + drained.found, skipped: totals.skipped + drained.skipped, failed: totals.failed + drained.failed, successful: totals.successful + drained.successful };
await finalizeTelegramJob(jobId, options);
return { jobId, ...totals, requested: totals.found + totals.skipped, commentMessagesScanned: Number(cursor.commentMessagesScanned || 0), commentMediaFound: Number(cursor.commentMediaFound || 0) };
}
export async function enqueueTelegramDateDownload(botClient: TelegramClient, requestMessage: Api.Message, userId: number, sourceInput: string, startDateText: string, endDateText: string, folderOverride?: string | null, options: TelegramCommentScanOptions = {}) {
const source = normalizeSource(sourceInput);
await assertTelegramSourceAllowed(source);
const startDate = parseDateOnly(startDateText);
const endDate = parseDateOnly(endDateText, true);
if (startDate > endDate) throw new Error('开始日期不能晚于结束日期');
const baseMessages = await getMessagesByDateRange(userClient, source, startDate, endDate);
const messages = await expandMessagesWithMediaGroups(userClient, source, baseMessages);
const scan = await buildDownloadScanResult(userClient, source, messages, { ...options, startDate, endDate });
const commentLimit = options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST;
await options.onScanComplete?.({
source,
mode: 'date',
channelMessagesScanned: messages.length,
channelMediaFound: scan.channelMediaFound,
commentMessagesScanned: scan.commentMessagesScanned,
commentMediaFound: scan.commentMediaFound,
totalMediaFound: scan.refs.length,
commentsEnabled: Boolean(options.includeComments),
commentsMaxPerPost: commentLimit,
});
const messageIds = scan.refs.map(ref => ref.id);
const jobId = await createJob(userId, requestMessage.chatId?.toString(), 'date_range', source, {
mode: 'date',
startDate: startDateText,
endDate: endDateText,
messageIds,
startDateIso: startDate.toISOString(),
endDateIso: endDate.toISOString(),
folderOverride: folderOverride || null,
includeComments: Boolean(options.includeComments),
commentsMaxPerPost: options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
commentMessagesScanned: scan.commentMessagesScanned,
commentMediaFound: scan.commentMediaFound,
});
await persistDownloadItems(jobId, source, messages);
await updateJob(jobId, { status: 'running', started_at: new Date(), total_count: scan.refs.length });
try {
const result = await downloadTelegramChannelRange(botClient, requestMessage, source, 0, scan.refs.length, 'older', messageIds, folderOverride, scan.refs);
await updateDownloadItemsStatus(jobId, result.successfulMessageIds, 'success');
await updateDownloadItemsStatus(jobId, result.failedMessageIds, 'failed', '下载失败');
await updateDownloadItemsStatus(jobId, result.skippedMessageIds, 'skipped');
await updateJob(jobId, {
status: result.failed > 0 ? 'completed_with_errors' : 'completed',
enqueued_count: result.found,
skipped_count: result.skipped,
error: result.failed > 0 ? `${result.failed} 个文件下载失败` : null,
finished_at: new Date(),
});
return { jobId, commentMessagesScanned: scan.commentMessagesScanned, commentMediaFound: scan.commentMediaFound, ...result };
} catch (error) {
await updateJob(jobId, { status: 'failed', error: error instanceof Error ? error.message : String(error), finished_at: new Date() });
throw error;
}
return runSegmentedTelegramJob(botClient, requestMessage, jobId, source, folderOverride, options);
}
async function getMessagesByHashtag(userClient: TelegramClient, source: string, tag: string, maxScan = 10000): Promise<Api.Message[]> {
@@ -472,61 +783,24 @@ async function getMessagesByHashtag(userClient: TelegramClient, source: string,
}
export async function enqueueTelegramTagDownload(botClient: TelegramClient, requestMessage: Api.Message, userId: number, sourceInput: string, tagInput: string, folderOverride?: string | null, options: TelegramCommentScanOptions = {}) {
const userClient = requireUserClient();
const source = normalizeSource(sourceInput);
await assertTelegramSourceAllowed(source);
const tag = normalizeHashtag(tagInput);
const baseMessages = await getMessagesByHashtag(userClient, source, tag);
const messages = await expandMessagesWithMediaGroups(userClient, source, baseMessages);
const scan = await buildDownloadScanResult(userClient, source, messages, { ...options, tag });
const commentLimit = options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST;
await options.onScanComplete?.({
source,
mode: 'tag',
channelMessagesScanned: messages.length,
channelMediaFound: scan.channelMediaFound,
commentMessagesScanned: scan.commentMessagesScanned,
commentMediaFound: scan.commentMediaFound,
totalMediaFound: scan.refs.length,
commentsEnabled: Boolean(options.includeComments),
commentsMaxPerPost: commentLimit,
});
const messageIds = scan.refs.map(ref => ref.id);
const jobId = await createJob(userId, requestMessage.chatId?.toString(), 'tag_download', source, {
mode: 'tag',
tag,
messageIds,
folderOverride: folderOverride || null,
includeComments: Boolean(options.includeComments),
commentsMaxPerPost: options.commentsMaxPerPost || TELEGRAM_COMMENTS_MAX_PER_POST,
commentMessagesScanned: scan.commentMessagesScanned,
commentMediaFound: scan.commentMediaFound,
});
await persistDownloadItems(jobId, source, messages);
await updateJob(jobId, { status: 'running', started_at: new Date(), total_count: scan.refs.length });
try {
const result = await downloadTelegramChannelRange(botClient, requestMessage, source, 0, scan.refs.length, 'older', messageIds, folderOverride, scan.refs);
await updateDownloadItemsStatus(jobId, result.successfulMessageIds, 'success');
await updateDownloadItemsStatus(jobId, result.failedMessageIds, 'failed', '下载失败');
await updateDownloadItemsStatus(jobId, result.skippedMessageIds, 'skipped');
await updateJob(jobId, {
status: result.failed > 0 ? 'completed_with_errors' : 'completed',
enqueued_count: result.found,
skipped_count: result.skipped,
error: result.failed > 0 ? `${result.failed} 个文件下载失败` : null,
finished_at: new Date(),
});
return { jobId, tag, commentMessagesScanned: scan.commentMessagesScanned, commentMediaFound: scan.commentMediaFound, ...result };
} catch (error) {
await updateJob(jobId, { status: 'failed', error: error instanceof Error ? error.message : String(error), finished_at: new Date() });
throw error;
}
const result = await runSegmentedTelegramJob(botClient, requestMessage, jobId, source, folderOverride, options);
return { ...result, tag };
}
export async function listTelegramBackgroundJobs(userId: number, limit = 10) {
const result = await query(
`SELECT id, kind, source, status, total_count, enqueued_count, skipped_count, duplicate_count, error, created_at, updated_at
`SELECT id, kind, source, status, scan_status, download_status, scan_cursor, cooldown_until, paused_at, cancelled_at, total_count, enqueued_count, skipped_count, duplicate_count, error, created_at, updated_at
FROM telegram_background_jobs
WHERE user_id = $1
ORDER BY created_at DESC
@@ -536,6 +810,61 @@ export async function listTelegramBackgroundJobs(userId: number, limit = 10) {
return result.rows;
}
export async function pauseTelegramBackgroundJob(userId: number, selector: string) {
const result = await query(
`UPDATE telegram_background_jobs
SET status = 'paused', paused_at = NOW(), updated_at = NOW()
WHERE user_id = $1 AND id::text LIKE $2 AND status NOT IN ('completed', 'cancelled')
RETURNING id, source, status`,
[userId, `${selector}%`]
);
return result.rows[0] || null;
}
export async function resumeTelegramBackgroundJob(userId: number, selector: string) {
const result = await query(
`UPDATE telegram_background_jobs
SET status = 'running', paused_at = NULL, updated_at = NOW()
WHERE user_id = $1 AND id::text LIKE $2 AND status = 'paused'
RETURNING id, source, status`,
[userId, `${selector}%`]
);
return result.rows[0] || null;
}
export async function cancelTelegramBackgroundJob(userId: number, selector: string) {
const result = await query(
`UPDATE telegram_background_jobs
SET status = 'cancelled', scan_status = 'cancelled', download_status = 'cancelled', cancelled_at = NOW(), finished_at = NOW(), updated_at = NOW()
WHERE user_id = $1 AND id::text LIKE $2 AND status NOT IN ('completed', 'cancelled')
RETURNING id, source, status`,
[userId, `${selector}%`]
);
if (result.rows[0]) {
await query(`UPDATE telegram_download_items SET status = 'skipped', locked_at = NULL, updated_at = NOW() WHERE job_id = $1 AND status IN ('pending', 'downloading')`, [result.rows[0].id]);
}
return result.rows[0] || null;
}
export async function retryTelegramBackgroundJob(userId: number, selector: string) {
const result = await query(
`SELECT id FROM telegram_background_jobs WHERE user_id = $1 AND id::text LIKE $2 LIMIT 1`,
[userId, `${selector}%`]
);
const row = result.rows[0];
if (!row) return null;
const retry = await query(
`UPDATE telegram_download_items
SET status = 'pending', locked_at = NULL, last_error = NULL, error = NULL, updated_at = NOW()
WHERE job_id = $1 AND status = 'failed'
RETURNING id`,
[row.id]
);
await updateJob(row.id, { status: 'running', download_status: 'active', error: null, finished_at: null });
return { id: row.id, retried: retry.rowCount || 0 };
}
async function runSubscriptionScan(botClient: TelegramClient) {
const userClient = getTelegramUserClient();
if (!userClient || !isTelegramUserClientReady()) return;
@@ -558,15 +887,16 @@ async function runSubscriptionScan(botClient: TelegramClient) {
const ids = Array.from({ length: count }, (_, index) => lastMessageId + index + 1);
const jobId = await createJob(Number(row.user_id), row.chat_id?.toString(), 'subscription_sync', row.source, { fromId: lastMessageId + 1, toId: latestMessageId });
const candidateMessages = await expandMessagesWithMediaGroups(userClient, row.source, (await userClient.getMessages(row.source as any, { ids })).filter(Boolean) as Api.Message[]);
await persistDownloadItems(jobId, row.source, candidateMessages);
await persistDownloadMessages(jobId, row.source, candidateMessages, row.folder_override || null);
await updateJob(jobId, { status: 'running', started_at: new Date(), total_count: ids.length });
const targetChat = row.chat_id || row.user_id;
const requestMessage = ({ chatId: targetChat, id: latestMessageId } as unknown) as Api.Message;
const downloadResult = await downloadTelegramChannelRange(botClient, requestMessage, row.source, 0, ids.length, 'newer', ids, row.folder_override || null);
await updateDownloadItemsStatus(jobId, downloadResult.successfulMessageIds, 'success');
await updateDownloadItemsStatus(jobId, downloadResult.failedMessageIds, 'failed', '下载失败');
await updateDownloadItemsStatus(jobId, downloadResult.skippedMessageIds, 'skipped');
const subscriptionRefs = candidateMessages
.map(message => toChannelDownloadRef(row.source, message))
.filter((ref): ref is TelegramDownloadMessageRef => Boolean(ref));
await markDownloadRefsDownloading(jobId, subscriptionRefs);
const downloadResult = await downloadTelegramChannelRange(botClient, requestMessage, row.source, 0, ids.length, 'newer', ids, row.folder_override || null, subscriptionRefs, (ref, status, error) => markDownloadRefStatus(jobId, ref, status, error));
await updateJob(jobId, {
status: downloadResult.failed > 0 ? 'completed_with_errors' : 'completed',
enqueued_count: downloadResult.found,
@@ -586,6 +916,104 @@ async function runSubscriptionScan(botClient: TelegramClient) {
}
}
async function recoverTelegramJob(botClient: TelegramClient, job: any): Promise<void> {
const itemResult = await query(
`SELECT id, source, source_peer, origin, message_id, channel_post_id, file_name, mime_type, total_size, folder_override
FROM telegram_download_items
WHERE job_id = $1 AND status = 'pending'
ORDER BY created_at ASC`,
[job.id]
);
if (itemResult.rows.length === 0) return;
const refs: TelegramDownloadMessageRef[] = itemResult.rows
.filter(row => row.file_name && row.mime_type)
.map(row => ({
id: Number(row.message_id),
source: row.source_peer || row.source,
origin: row.origin === 'comment' ? 'comment' : 'channel',
channelPostId: row.channel_post_id || undefined,
fileInfo: { fileName: row.file_name, mimeType: row.mime_type },
totalSize: Number(row.total_size || 0),
}));
if (refs.length === 0) return;
const targetChat = job.chat_id || job.user_id;
const requestMessage = ({ chatId: targetChat, id: 0 } as unknown) as Api.Message;
console.log(`♻️ 恢复 Telegram 下载任务 ${String(job.id).slice(0, 8)},待处理 ${refs.length} 个文件`);
await updateJob(job.id, { status: 'running', started_at: job.started_at || new Date(), error: null });
try {
const result = await downloadTelegramChannelRange(
botClient,
requestMessage,
job.source,
0,
refs.length,
'older',
refs.map(ref => ref.id),
itemResult.rows[0]?.folder_override || null,
refs,
(ref, status, error) => markDownloadRefStatus(job.id, ref, status, error),
);
await updateJob(job.id, {
status: result.failed > 0 ? 'completed_with_errors' : 'completed',
enqueued_count: result.found,
skipped_count: result.skipped,
error: result.failed > 0 ? `${result.failed} 个文件下载失败` : null,
finished_at: new Date(),
});
await botClient.sendMessage(targetChat, {
message: `♻️ 已恢复并完成任务 ${String(job.id).slice(0, 8)}:成功 ${result.successful},跳过 ${result.skipped},失败 ${result.failed}`,
}).catch(() => undefined);
} catch (error) {
await updateJob(job.id, { status: 'failed', error: error instanceof Error ? error.message : String(error), finished_at: new Date() });
throw error;
}
}
export async function recoverInterruptedTelegramJobs(botClient: TelegramClient): Promise<void> {
if (recoveryRunning) return;
recoveryRunning = true;
try {
await query(
`UPDATE telegram_download_items
SET status = 'pending', locked_at = NULL, updated_at = NOW()
WHERE status = 'downloading'
AND (locked_at IS NULL OR locked_at < NOW() - INTERVAL '30 minutes')`
);
const jobs = await query(
`SELECT DISTINCT j.*
FROM telegram_background_jobs j
JOIN telegram_download_items i ON i.job_id = j.id
WHERE j.kind IN ('date_range', 'tag_download')
AND j.status IN ('pending', 'running', 'failed', 'completed_with_errors')
AND i.status = 'pending'
ORDER BY j.created_at ASC
LIMIT 5`
);
for (const job of jobs.rows) {
if (job.scan_status !== 'done' && (job.kind === 'date_range' || job.kind === 'tag_download') && job.params?.mode) {
const targetChat = job.chat_id || job.user_id;
const requestMessage = ({ chatId: targetChat, id: 0 } as unknown) as Api.Message;
await runSegmentedTelegramJob(botClient, requestMessage, job.id, job.source, job.params?.folderOverride || null, {}).catch(error => console.error('♻️ Telegram 分段任务恢复失败:', error));
} else {
await recoverTelegramJob(botClient, job).catch(error => console.error('♻️ Telegram 任务恢复失败:', error));
}
}
} finally {
recoveryRunning = false;
}
}
export function startTelegramJobRecoveryWorker(botClient: TelegramClient) {
if (recoveryStarted) return;
recoveryStarted = true;
setTimeout(() => recoverInterruptedTelegramJobs(botClient).catch(error => console.error('♻️ Telegram 任务恢复扫描失败:', error)), TG_JOB_RECOVERY_DELAY_MS);
setInterval(() => recoverInterruptedTelegramJobs(botClient).catch(error => console.error('♻️ Telegram 任务恢复扫描失败:', error)), SUBSCRIPTION_INTERVAL_MS);
}
export function startTelegramSubscriptionWorker(botClient: TelegramClient) {
if (subscriptionTimer) return;
subscriptionTimer = setInterval(() => {
+34 -1
View File
@@ -17,7 +17,7 @@ import {
import { authenticatedUsers, passwordInputState, isAuthenticatedAsync } from './telegramState.js';
import { forceStopDownloadTasks, getDownloadQueueStats, getTaskStatus, pauseDownloadTasks, resumeDownloadTasks, cancelDownloadTask, retryFailedDownloadTasks, getFileDownloadConcurrency, setFileDownloadConcurrency } from './telegramUpload.js';
import { storageManager } from './storage.js';
import { listTelegramBackgroundJobs } from './telegramChannelJobs.js';
import { cancelTelegramBackgroundJob, listTelegramBackgroundJobs, pauseTelegramBackgroundJob, resumeTelegramBackgroundJob, retryTelegramBackgroundJob } from './telegramChannelJobs.js';
import { getSetting, setSetting } from '../utils/settings.js';
import { DuplicateMode, getDuplicateMode } from '../utils/duplicatePolicy.js';
import { startPeriodicCleanup, stopPeriodicCleanup } from './orphanCleanup.js';
@@ -627,23 +627,56 @@ export async function handleStopTasks(message: Api.Message): Promise<void> {
export async function handlePauseTasks(message: Api.Message, args: string[] = []): Promise<void> {
const taskId = args[0];
const senderId = message.senderId?.toJSNumber();
if (taskId && senderId) {
const job = await pauseTelegramBackgroundJob(senderId, taskId);
if (job) {
await message.reply({ message: `⏸️ 已暂停频道任务 ${String(job.id).slice(0, 8)}\n来源:${job.source}` });
return;
}
}
const result = pauseDownloadTasks(taskId);
await message.reply({ message: `⏸️ 已暂停全局下载队列${taskId ? `\n任务卡: ${taskId}` : ''}\n\n进行中: ${result.active}\n等待中: ${result.pending}\n\n当前正在下载的文件会继续完成,新的等待任务暂不开始。` });
}
export async function handleResumeTasks(message: Api.Message, args: string[] = []): Promise<void> {
const taskId = args[0];
const senderId = message.senderId?.toJSNumber();
if (taskId && senderId) {
const job = await resumeTelegramBackgroundJob(senderId, taskId);
if (job) {
await message.reply({ message: `▶️ 已继续频道任务 ${String(job.id).slice(0, 8)}\n来源:${job.source}` });
return;
}
}
const result = resumeDownloadTasks(taskId);
await message.reply({ message: `▶️ 已继续全局下载队列${taskId ? `\n任务卡: ${taskId}` : ''}\n\n进行中: ${result.active}\n等待中: ${result.pending}` });
}
export async function handleCancelTask(message: Api.Message, args: string[]): Promise<void> {
const selector = args.join(' ').trim() || 'all';
const senderId = message.senderId?.toJSNumber();
if (selector !== 'all' && senderId) {
const job = await cancelTelegramBackgroundJob(senderId, selector);
if (job) {
await message.reply({ message: `🛑 已取消频道任务 ${String(job.id).slice(0, 8)}\n来源:${job.source}` });
return;
}
}
const result = cancelDownloadTask(selector);
await message.reply({ message: result.total > 0 ? `🛑 已取消匹配任务\n\n匹配: ${selector}\n处理中: ${result.active}\n等待中: ${result.pending}` : `📮 没有找到匹配的任务,未清空全局队列:${selector}` });
}
export async function handleRetryFailedTasks(message: Api.Message, args: string[]): Promise<void> {
const senderId = message.senderId?.toJSNumber();
const jobSelector = args.find(arg => /^[0-9a-f-]{4,}$/i.test(arg));
if (senderId && jobSelector) {
const jobRetry = await retryTelegramBackgroundJob(senderId, jobSelector);
if (jobRetry) {
await message.reply({ message: jobRetry.retried > 0 ? `🔄 已重新加入频道任务失败项 ${jobRetry.retried} 个\n任务: ${String(jobRetry.id).slice(0, 8)}` : '📮 该频道任务没有可重试失败项' });
return;
}
}
const taskId = args.find(arg => /^t[a-z0-9]+/i.test(arg));
const numericArg = args.find(arg => /^\d+$/.test(arg));
const limit = Math.max(1, Math.min(50, parseInt(numericArg || '10', 10) || 10));
+6
View File
@@ -1817,6 +1817,7 @@ export async function downloadTelegramChannelRange(
explicitIds?: number[],
folderOverride?: string | null,
explicitRefs?: TelegramDownloadMessageRef[],
onItemSettled?: (item: TelegramDownloadMessageRef, status: 'success' | 'failed' | 'skipped', error?: string) => Promise<void> | void,
): Promise<{ requested: number; found: number; skipped: number; failed: number; successful: number; successfulMessageIds: number[]; failedMessageIds: number[]; skippedMessageIds: number[]; firstId: number; lastId: number }> {
const userClient = getTelegramUserClient();
if (!userClient || !isTelegramUserClientReady()) {
@@ -1860,6 +1861,7 @@ export async function downloadTelegramChannelRange(
if (!fileInfo) {
skipped += 1;
skippedMessageIds.push(ref.id);
await onItemSettled?.(ref, 'skipped');
continue;
}
downloadableRefs.push({
@@ -2003,6 +2005,7 @@ export async function downloadTelegramChannelRange(
completed += 1;
failedMessageIds.push(item.id);
await refreshSegmentStatus(false, fileName);
await onItemSettled?.(item, 'failed', '消息不存在或无法重新读取');
return;
}
const uploadItem: FileUploadItem = {
@@ -2033,14 +2036,17 @@ export async function downloadTelegramChannelRange(
if (uploadItem.status === 'success') {
successful += 1;
successfulMessageIds.push(item.id);
await onItemSettled?.(item, 'success');
} else {
failed += 1;
failedMessageIds.push(item.id);
await onItemSettled?.(item, 'failed', uploadItem.error || '下载失败');
}
} catch (err) {
console.error(`🤖 频道分段下载任务异常: ${fileName}`, err);
failed += 1;
failedMessageIds.push(item.id);
await onItemSettled?.(item, 'failed', err instanceof Error ? err.message : String(err));
} finally {
completed += 1;
found += 1;