mirror of
https://github.com/Trollobot/Telemax.git
synced 2026-10-19 10:23:24 +00:00
1.3.0-beta.4: отправка в MAX ждёт, пока MAX обработает большой файл (до 15 мин), «📤 Отправить в MAX» в пульте для неотправленных, получатель сразу видит «📥 Пришёл файл» с прогрессом
This commit is contained in:
parent
34b812c236
commit
4bcf508e11
6 changed files with 114 additions and 21 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue