1.3.0-beta.4: отправка в MAX ждёт, пока MAX обработает большой файл (до 15 мин), «📤 Отправить в MAX» в пульте для неотправленных, получатель сразу видит «📥 Пришёл файл» с прогрессом

This commit is contained in:
Folist 2026-10-09 22:16:31 +03:00
parent 34b812c236
commit 4bcf508e11
6 changed files with 114 additions and 21 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "telemax",
"version": "1.3.0-beta.3",
"version": "1.3.0-beta.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "telemax",
"version": "1.3.0-beta.3",
"version": "1.3.0-beta.4",
"license": "AGPL-3.0-only",
"dependencies": {
"dotenv": "^17.2.3",

View file

@ -2,7 +2,7 @@
"name": "telemax",
"private": true,
"license": "AGPL-3.0-only",
"version": "1.3.0-beta.3",
"version": "1.3.0-beta.4",
"type": "module",
"scripts": {
"dev": "tsx watch src/server/app.ts",

View file

@ -42,8 +42,14 @@ export interface BigFiles {
offerUpload(p: { maxChatId: string; topicId: number; telegramMessageId: number; caption?: string; name?: string; size?: number }): Promise<void>;
/** MAX → Telegram: a FILE over the bot upload limit — save it and post a download link. */
relayFromMax(att: MaxAttachment, ctx: DownloadContext, opts: SendOpts): Promise<SentMessage>;
/** The panel's «📤 Отправить в MAX»: a stored file that didn't make it, sent again from disk. */
resendToMax(id: string): Promise<{ ok: boolean; text: string }>;
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** MAX's answer to MSG_SEND while a big upload is still being digested. */
const STILL_PROCESSING = /в процессе обработки|not[ ._]?ready|processing/i;
export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax }: BigFilesDeps): BigFiles {
const send = (text: string, opts: SendOpts, markup?: ReturnType<typeof Markup.inlineKeyboard>) =>
bot.telegram.sendMessage(targetGroupId, text, { ...opts, ...(markup ? { reply_markup: markup.reply_markup } : {}) });
@ -61,6 +67,31 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
};
const reserveText = formatBytes(fileShare.opts.reserveBytes);
/**
* Uploads a file from disk and sends it. A big file is still «в процессе обработки» for a while
* after its upload (a 2.7 GB ISO: well over the 30 s guessed in uploadFileFromDiskToMax — live
* 2026-10-09), so the send is retried for up to 15 minutes while MAX says exactly that.
*/
async function uploadAndSend(maxChatId: string, filePath: string, size: number, name: string, caption: string | undefined, onWaiting?: () => void) {
const attach = await uploadFileFromDiskToMax(max, filePath, size, name);
const deadline = Date.now() + 15 * 60_000;
let pause = 5000;
let told = false;
for (;;) {
try {
return await max.sendMessage(maxChatId, caption ?? null, [attach]);
} catch (err) {
if (!STILL_PROCESSING.test((err as Error).message ?? '') || Date.now() > deadline) throw err;
if (!told) {
told = true;
onWaiting?.();
}
await sleep(pause);
pause = Math.min(Math.round(pause * 1.5), 30_000);
}
}
}
// --- Telegram → MAX -------------------------------------------------------------------------
const CANCEL = 'tlmx_panel:fcancel:';
@ -144,8 +175,9 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
}
await edit(ticket.promptMessageId, ticket.topicId, `⏳ Файл ${label} получен — отправляю в MAX…`);
try {
const attach = await uploadFileFromDiskToMax(max, filePath, size, name);
const sent = await max.sendMessage(ticket.maxChatId, ticket.caption ?? null, [attach]);
const sent = await uploadAndSend(ticket.maxChatId, filePath, size, name, ticket.caption, () => {
void edit(ticket.promptMessageId, ticket.topicId, `⏳ Файл ${label} загружен в MAX — MAX его обрабатывает, это может занять несколько минут…`);
});
onSentToMax(ticket, sent);
await fileShare.dropTicket(ticket.token);
await edit(ticket.promptMessageId, ticket.topicId, `✅ Файл ${label} отправлен в MAX.`);
@ -157,7 +189,7 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
ticket.promptMessageId,
ticket.topicId,
`⚠️ Файл ${label} получен, но в MAX не ушёл: ${(err as Error).message}.
Он сохранён до ${formatExpiry(file.expiresAt)} — ссылку на него можно взять в пульте → 📁 Файлы и отправить вручную.`,
Он сохранён до ${formatExpiry(file.expiresAt)} — отправить его ещё раз можно из пульта → 📁 Файлы, загружать заново не нужно.`,
);
}
});
@ -178,6 +210,18 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
}
const url = await getMaxFileUrl(att, ctx);
if (!url) return send(describeAttachment(att), opts);
// The recipient sees at once that something is coming — saving gigabytes takes minutes.
const notice = `📥 Пришёл файл ${label} — ${WHY_TG_UPLOAD}, поэтому мост сохраняет его на ваш сервер и пришлёт сюда ссылку.`;
const msg = await send(`${notice}
⏳ Сохраняю…`, opts);
let lastShown = Date.now();
const progress = (written: number) => {
if (Date.now() - lastShown < 20_000) return;
lastShown = Date.now();
const pct = size > 0 ? ` (${Math.min(99, Math.floor((written / size) * 100))}%)` : '';
void edit(msg.message_id, opts.message_thread_id, `${notice}
⏳ Сохранено ${formatBytes(written)} из ${formatBytes(size)}${pct}…`);
};
let file;
try {
const res = await maxFetchBig(url);
@ -185,15 +229,21 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
if (isTransientHttpStatus(res.status)) throw new TransientDownloadError(`MAX CDN answered ${res.status}`);
throw new Error(`MAX отдал ${res.status}`);
}
file = await fileShare.storeStream(nodeReadable(res.body), name, size);
file = await fileShare.storeStream(nodeReadable(res.body), name, size, progress);
logger.info(`MAX -> TG: ${name} (${file.size} bytes) saved for a download link`);
} catch (err) {
const transient = err instanceof TransientDownloadError || isTransientNetworkError(err);
if (transient && ctx.throwOnTransient) throw err instanceof TransientDownloadError ? err : new TransientDownloadError(`big file download: ${(err as Error).message}`, err);
if (transient && ctx.throwOnTransient) {
// The catch-up retries this message later and posts a fresh notice — don't leave this one hanging.
await bot.telegram.deleteMessage(targetGroupId, msg.message_id).catch(() => {});
throw err instanceof TransientDownloadError ? err : new TransientDownloadError(`big file download: ${(err as Error).message}`, err);
}
logger.error(`Saving the big MAX file ${name} failed`, err);
return send(`📎 Файл ${label} — ${WHY_TG_UPLOAD}, а сохранить его для скачивания не удалось (${(err as Error).message}). Откройте его в MAX.`, opts);
await edit(msg.message_id, opts.message_thread_id, `📎 Файл ${label} — ${WHY_TG_UPLOAD}, а сохранить его для скачивания не удалось (${(err as Error).message}). Откройте его в MAX.`);
return msg;
}
const msg = await send(`📎 Файл ${label} — ${WHY_TG_UPLOAD}. Готовлю ссылку для скачивания…`, opts);
await edit(msg.message_id, opts.message_thread_id, `${notice}
✅ Сохранён — готовлю ссылку…`);
// Not awaited: the service may take a minute or two to come up, and this runs inside the
// chat's queue — the next message must not wait for it.
void (async () => {
@ -212,5 +262,21 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
return msg;
}
return { offerUpload, relayFromMax };
async function resendToMax(id: string): Promise<{ ok: boolean; text: string }> {
const file = fileShare.get(id);
if (!file?.target) return { ok: false, text: 'Этот файл не для MAX или его уже нет.' };
const label = `«${file.name}» (${formatBytes(file.size)})`;
try {
const sent = await uploadAndSend(file.target.maxChatId, fileShare.pathOf(file), file.size, file.name, file.target.caption);
onSentToMax({ ...file.target, token: '', name: file.name, expectedSize: file.size, createdAt: 0, expiresAt: 0 }, sent);
await fileShare.remove(file.id);
logger.info(`TG -> MAX: stored ${file.name} sent again to chat ${file.target.maxChatId}`);
return { ok: true, text: `✅ Файл ${label} отправлен в MAX и удалён с сервера.` };
} catch (err) {
logger.error(`Sending the stored ${file.name} to MAX failed`, err);
return { ok: false, text: `⚠️ Файл ${label} в MAX не ушёл: ${(err as Error).message}. Он остаётся на сервере — можно попробовать ещё раз.` };
}
}
return { offerUpload, relayFromMax, resendToMax };
}

View file

@ -40,6 +40,8 @@ export interface StoredFile {
token: string;
createdAt: number;
expiresAt: number;
/** tg2max: where it was going — lets the panel send it to MAX again (a failed send keeps the file). */
target?: { maxChatId: string; topicId: number; telegramMessageId: number; caption?: string };
}
export interface UploadTicket {
@ -185,6 +187,11 @@ export class FileShare {
return [...this.index.files].sort((a, b) => b.createdAt - a.createdAt);
}
/** Where a stored file lies on disk. */
pathOf(file: StoredFile): string {
return path.join(this.dir, 'items', file.id, file.name);
}
get(id: string): StoredFile | undefined {
return this.index.files.find((f) => f.id === id);
}
@ -249,7 +256,12 @@ export class FileShare {
await mkdir(path.join(this.dir, 'items', id), { recursive: true });
const target = path.join(this.dir, 'items', id, safeFileName(name));
await rename(filePath, target);
return this.addFile(id, safeFileName(name), size, 'tg2max');
return this.addFile(id, safeFileName(name), size, 'tg2max', {
maxChatId: ticket.maxChatId,
topicId: ticket.topicId,
telegramMessageId: ticket.telegramMessageId,
caption: ticket.caption,
});
});
await this.dropTicket(ticket.token);
return file;
@ -266,7 +278,7 @@ export class FileShare {
* Streams `body` into storage and opens a download link for it. Throws when the stream fails or
* runs past `size` + the free space (the partial file is removed either way).
*/
async storeStream(body: Readable, name: string, size: number): Promise<StoredFile> {
async storeStream(body: Readable, name: string, size: number, onProgress?: (written: number) => void): Promise<StoredFile> {
const id = newToken();
const safe = safeFileName(name);
const dir = path.join(this.dir, 'items', id);
@ -277,6 +289,7 @@ export class FileShare {
const guard = new Transform({
transform(chunk: Buffer, _enc, cb) {
written += chunk.length;
onProgress?.(written);
if (written > limit) cb(new Error(`file grew past its declared ${size} bytes`));
else cb(null, chunk);
},
@ -291,9 +304,9 @@ export class FileShare {
return this.locked(() => this.addFile(id, safe, written, 'max2tg'));
}
private async addFile(id: string, name: string, size: number, direction: StoredFile['direction']): Promise<StoredFile> {
private async addFile(id: string, name: string, size: number, direction: StoredFile['direction'], target?: StoredFile['target']): Promise<StoredFile> {
const now = Date.now();
const file: StoredFile = { id, name, size, direction, token: newToken(), createdAt: now, expiresAt: now + this.opts.ttlMs };
const file: StoredFile = { id, name, size, direction, token: newToken(), createdAt: now, expiresAt: now + this.opts.ttlMs, ...(target ? { target } : {}) };
await this.writeToken(file.token, { kind: 'download', name, size, path: `items/${id}/${name}`, expiresAt: file.expiresAt });
this.index.files.push(file);
await this.save();

View file

@ -42,6 +42,8 @@ export interface ControlPanelDeps {
pause: PauseControl;
/** Big files kept for download links — the «📁 Файлы» section. */
files?: FileShare;
/** «📤 Отправить в MAX» for a stored file that didn't make it (bigFiles.ts). */
resendToMax?: (id: string) => Promise<{ ok: boolean; text: string }>;
}
export interface PauseControl {
@ -179,13 +181,14 @@ function filesView(files: FileShare): View {
};
}
function fileCardView(f: StoredFile, files: FileShare, link?: string): View {
function fileCardView(f: StoredFile, files: FileShare, link?: string, canResend = false): View {
const where = f.direction === 'max2tg' ? 'пришёл из MAX' : 'загружен для MAX';
const linkLine = link ? `\n\n🔗 ${link}\nСсылку можно переслать — по ней откроется страница со скачиванием.` : '';
return {
text: `📄 ${f.name}\n${formatBytes(f.size)} · ${where}\nХранится до ${formatExpiry(f.expiresAt)}, потом удалится.${linkLine}`,
markup: Markup.inlineKeyboard([
...(link ? [[Markup.button.url('⬇️ Открыть ссылку', link)]] : []),
...(canResend && f.direction === 'tg2max' && f.target ? [[Markup.button.callback('📤 Отправить в MAX', `tlmx_panel:fsend:${f.id}`)]] : []),
[Markup.button.callback(`🔗 Новая ссылка (+${ttlText(files.opts.ttlMs)})`, `tlmx_panel:frenew:${f.id}`)],
[Markup.button.callback('🗑 Удалить', `tlmx_panel:fdel:${f.id}`)],
[Markup.button.callback('◀️ К файлам', 'tlmx_panel:files')],
@ -251,7 +254,8 @@ function pauseView(): View {
}
export function wireControlPanel(deps: ControlPanelDeps): void {
const { bot, targetGroupId, max, getActivePhone, triggerFullResync, leaves, startDialog, resolveContactName, getStatus, pause, files } = deps;
const { bot, targetGroupId, max, getActivePhone, triggerFullResync, leaves, startDialog, resolveContactName, getStatus, pause, files, resendToMax } = deps;
const card = (f: StoredFile, link?: string): View => fileCardView(f, files!, link, Boolean(resendToMax));
const root = (): View => rootView(getActivePhone(), pause.until());
const system = (): View => systemView(pause.until() != null);
@ -340,7 +344,7 @@ export function wireControlPanel(deps: ControlPanelDeps): void {
if (files) await edit(ctx, filesView(files));
return;
}
await edit(ctx, fileCardView(f, files));
await edit(ctx, card(f));
});
bot.action(/^tlmx_panel:frenew:(.+)$/, async (ctx) => {
const id = ctx.match[1] ?? '';
@ -354,11 +358,20 @@ export function wireControlPanel(deps: ControlPanelDeps): void {
await ctx.editMessageText(`⏳ Готовлю ссылку на «${f.name}»… Если служба файлов не запущена, это займёт до пары минут.`).catch(() => {});
const svc = await files.ensureService();
if (!svc.ok) {
const card = fileCardView(f, files);
await ctx.editMessageText(`${card.text}\n\n⚠️ Ссылку подготовить не удалось: ${svc.reason}.`, { reply_markup: card.markup }).catch(() => {});
const c = card(f);
await ctx.editMessageText(`${c.text}\n\n⚠️ Ссылку подготовить не удалось: ${svc.reason}.`, { reply_markup: c.markup }).catch(() => {});
return;
}
await edit(ctx, fileCardView(f, files, `${svc.url}/f/${f.token}`));
await edit(ctx, card(f, `${svc.url}/f/${f.token}`));
});
bot.action(/^tlmx_panel:fsend:(.+)$/, async (ctx) => {
const f = files?.get(ctx.match[1] ?? '');
await ctx.answerCbQuery(f ? 'Отправляю в MAX…' : 'Файла уже нет').catch(() => {});
if (!files || !f || !resendToMax) return;
await ctx.editMessageText(`⏳ Отправляю «${f.name}» в MAX… Большой файл MAX обрабатывает несколько минут — сообщение обновится само.`).catch(() => {});
const r = await resendToMax(f.id);
const back = Markup.inlineKeyboard([[Markup.button.callback('◀️ К файлам', 'tlmx_panel:files')]]).reply_markup;
await ctx.editMessageText(r.text, { reply_markup: back }).catch(() => {});
});
bot.action(/^tlmx_panel:fdel:(.+)$/, async (ctx) => {
const removed = files ? await files.remove(ctx.match[1] ?? '') : false;

View file

@ -2812,6 +2812,7 @@ export function wireBridge({
startDialog,
pause,
files: fileShare,
resendToMax: (id) => bigFiles.resendToMax(id),
// Warm-cache name lookup (buildRoster already fetched the profiles); undefined on a miss lets
// the panel fall back to CONTACT_INFO.
resolveContactName: (uid) => {