1.3.0-beta.9: «📤 Загрузить файл» в пульте — свой файлообменник (файл на сервере, ссылкой делиться где угодно, 📦 в списке); удаление и отмена пишутся в лог

This commit is contained in:
Folist 2026-10-10 00:11:37 +03:00
parent 0088e99807
commit 0d027bb493
8 changed files with 122 additions and 24 deletions

View file

@ -127,9 +127,13 @@ async function showLink(res, t) {
const free = Math.max(0, (await freeBytes(t.root)) - (body.reserveBytes ?? 0));
const maxFile = typeof body.maxFileBytes === 'number' ? body.maxFileBytes : Infinity;
const maxBytes = Math.min(free, maxFile);
const wanted = body.name
? `Ждём «${esc(body.name)}» (${esc(formatBytes(body.expectedSize ?? 0))}) — мост отправит его в MAX.`
: 'Выберите файл — мост отправит его в MAX.';
const store = body.purpose === 'store';
const wanted = store
? 'Выберите файл — он сохранится на сервере, а ссылку для скачивания бот пришлёт в Telegram.'
: body.name
? `Ждём «${esc(body.name)}» (${esc(formatBytes(body.expectedSize ?? 0))}) — мост отправит его в MAX.`
: 'Выберите файл — мост отправит его в MAX.';
const doneText = store ? 'Готово! Файл на сервере — ссылку на него бот пришлёт в Telegram. Страницу можно закрыть.' : 'Готово! Мост отправит файл в MAX — страницу можно закрыть.';
const limits = [
Number.isFinite(maxFile) ? `MAX принимает файлы до ${esc(formatBytes(maxFile))}.` : '',
`Свободно на сервере: ${esc(formatBytes(free))}.`,
@ -155,7 +159,7 @@ f.onchange=()=>{const x=f.files[0];s.textContent='';go.disabled=!x;
go.onclick=()=>{const x=f.files[0];if(!x)return;go.disabled=true;f.disabled=true;stop.hidden=false;p.style.display='block';bar.style.width='0';
r=new XMLHttpRequest();r.open('PUT',location.pathname.replace(/\\/$/,'')+'/upload');r.setRequestHeader('X-File-Name',encodeURIComponent(x.name));
r.upload.onprogress=e=>{if(e.lengthComputable){bar.style.width=(e.loaded/e.total*100).toFixed(1)+'%';s.textContent=fmt(e.loaded)+' из '+fmt(e.total)}};
r.onload=()=>{if(r.status===200){bar.style.width='100%';s.textContent='Готово! Мост отправит файл в MAX — страницу можно закрыть.';stop.hidden=true;f.disabled=true}else{s.textContent='Не получилось: '+(r.responseText||r.status);idle()}};
r.onload=()=>{if(r.status===200){bar.style.width='100%';s.textContent=${JSON.stringify(doneText)};stop.hidden=true;f.disabled=true}else{s.textContent='Не получилось: '+(r.responseText||r.status);idle()}};
r.onerror=()=>{s.textContent='Связь прервалась — попробуйте ещё раз.';idle()};
r.onabort=()=>{s.textContent='Загрузка отменена. Можно выбрать файл заново.';p.style.display='none';idle()};
r.send(x)};

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "telemax",
"version": "1.3.0-beta.8",
"version": "1.3.0-beta.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "telemax",
"version": "1.3.0-beta.8",
"version": "1.3.0-beta.9",
"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.8",
"version": "1.3.0-beta.9",
"type": "module",
"scripts": {
"dev": "tsx watch src/server/app.ts",

View file

@ -41,7 +41,8 @@ export function formatExpiry(ms: number): string {
}
type SentMessage = { message_id: number };
type SendOpts = { message_thread_id: number; reply_parameters?: { message_id: number; allow_sending_without_reply: boolean } };
/** No message_thread_id = the group's General topic (where the panel lives). */
type SendOpts = { message_thread_id?: number; reply_parameters?: { message_id: number; allow_sending_without_reply: boolean } };
export interface BigFilesDeps {
bot: Telegraf;
@ -61,6 +62,8 @@ 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 «📤 Загрузить файл»: an upload link into the server's own store, posted in General. */
offerStore(): Promise<void>;
/** The panel's «📤 Отправить в MAX»: a stored file that didn't make it, sent again from disk. */
resendToMax(id: string): Promise<{ ok: boolean; text: string }>;
}
@ -82,7 +85,7 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
logger.error('Editing a big-file notice failed — posting a new one', err);
}
}
await send(text, { message_thread_id: topicId }, markup).catch((e) => logger.error('Posting a big-file notice failed', e));
await send(text, topicId ? { message_thread_id: topicId } : {}, markup).catch((e) => logger.error('Posting a big-file notice failed', e));
};
const reserveText = formatBytes(fileShare.opts.reserveBytes);
@ -180,13 +183,70 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
bot.action(new RegExp(`^${CANCEL}(.+)$`), async (ctx) => {
const ticket = fileShare.getTicket(ctx.match[1] ?? '');
await ctx.answerCbQuery(ticket ? 'Загрузка отменена' : 'Ссылка уже не действует').catch(() => {});
if (ticket) logger.info(`Upload link for «${ticket.name || 'any file'}» cancelled`);
if (ticket) await fileShare.dropTicket(ticket.token);
const text = (ctx.callbackQuery.message as { text?: string } | undefined)?.text;
await ctx.editMessageText(ticket || !text ? '✖️ Загрузка отменена — ссылка больше не работает.' : text).catch(() => {});
});
async function offerStore(): Promise<void> {
if (!fileShare.enabled) {
await send('⚠️ Пересылка больших файлов отключена в настройках (FILES=off).', {});
return;
}
const room = await fileShare.roomFor(1);
if (!room.fits) {
await send(`⚠️ Загрузить файл не выйдет: на сервере свободно ${formatBytes(room.freeBytes)}, а ${reserveText} нужно оставить для обновлений.`, {});
return;
}
const prompt = await send('⏳ Готовлю ссылку для загрузки файла на сервер…', {});
const ticket = await fileShare.createUploadTicket({
purpose: 'store',
maxChatId: '',
topicId: 0,
telegramMessageId: prompt.message_id,
name: '',
expectedSize: 0,
promptMessageId: prompt.message_id,
});
logger.info('Panel: offering an upload link into the server store');
const svc = await fileShare.ensureService();
if (!svc.ok) {
report('service', svc.reason);
await fileShare.dropTicket(ticket.token);
await edit(prompt.message_id, 0, `⚠️ Ссылку для загрузки подготовить не удалось — ${svc.reason}.`);
return;
}
await edit(
prompt.message_id,
0,
`📤 Загрузите файл по ссылке — он сохранится на вашем сервере, а ссылку для скачивания я пришлю сюда же. Ею можно делиться где угодно.
Ссылка на загрузку действует до ${formatExpiry(ticket.expiresAt)}.`,
uploadKeyboard(`${svc.url}/f/${ticket.token}`, ticket.token),
);
}
fileShare.onUpload(async (ticket, filePath, size, name) => {
const label = `«${name}» (${formatBytes(size)})`;
if (ticket.purpose === 'store') {
const file = await fileShare.adoptUpload(ticket, filePath, size, name);
logger.info(`Panel store: ${name} (${size} bytes) uploaded`);
const svc = await fileShare.ensureService(30_000);
const until = formatExpiry(file.expiresAt);
if (!svc.ok) {
await edit(ticket.promptMessageId, 0, `✅ Файл ${label} на сервере до ${until}. Ссылку можно взять в пульте → 📁 Файлы.`);
return;
}
await edit(
ticket.promptMessageId,
0,
`✅ Файл ${label} на сервере до ${until}.
🔗 ${svc.url}/f/${file.token}
Ссылкой можно делиться — по ней откроется страница со скачиванием. Продлить или удалить — в пульте → 📁 Файлы.`,
Markup.inlineKeyboard([Markup.button.url('⬇️ Скачать', `${svc.url}/f/${file.token}`)]),
);
return;
}
if (size > MAX_FILE_LIMIT) {
// The page and the service refuse this already — belt and braces.
await fileShare.dropTicket(ticket.token);
@ -240,7 +300,7 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
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}
void edit(msg.message_id, opts.message_thread_id ?? 0, `${notice}
⏳ Сохранено ${formatBytes(written)} из ${formatBytes(size)}${pct}…`);
};
let file;
@ -261,10 +321,10 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
}
logger.error(`Saving the big MAX file ${name} failed`, err);
report('save from MAX', err);
await edit(msg.message_id, opts.message_thread_id, `📎 Файл ${label} — ${WHY_TG_UPLOAD}, а сохранить его для скачивания не удалось: ${humanError(err)}. Откройте его в MAX.`);
await edit(msg.message_id, opts.message_thread_id ?? 0, `📎 Файл ${label} — ${WHY_TG_UPLOAD}, а сохранить его для скачивания не удалось: ${humanError(err)}. Откройте его в MAX.`);
return msg;
}
await edit(msg.message_id, opts.message_thread_id, `${notice}
await edit(msg.message_id, opts.message_thread_id ?? 0, `${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.
@ -272,12 +332,12 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
const svc = await fileShare.ensureService();
if (!svc.ok) {
report('service', svc.reason);
await edit(msg.message_id, opts.message_thread_id, `📎 Файл ${label} — ${WHY_TG_UPLOAD}. Ссылку подготовить не удалось: ${svc.reason}.\nФайл сохранён до ${formatExpiry(file.expiresAt)} — новую ссылку можно получить в пульте → 📁 Файлы.`);
await edit(msg.message_id, opts.message_thread_id ?? 0, `📎 Файл ${label} — ${WHY_TG_UPLOAD}. Ссылку подготовить не удалось: ${svc.reason}.\nФайл сохранён до ${formatExpiry(file.expiresAt)} — новую ссылку можно получить в пульте → 📁 Файлы.`);
return;
}
await edit(
msg.message_id,
opts.message_thread_id,
opts.message_thread_id ?? 0,
`📎 Файл ${label} — ${WHY_TG_UPLOAD}, поэтому он лежит на вашем сервере. Скачайте по ссылке до ${formatExpiry(file.expiresAt)} — потом файл удалится.`,
Markup.inlineKeyboard([Markup.button.url('⬇️ Скачать', `${svc.url}/f/${file.token}`)]),
);
@ -302,5 +362,5 @@ export function createBigFiles({ bot, max, targetGroupId, fileShare, onSentToMax
}
}
return { offerUpload, relayFromMax, resendToMax };
return { offerUpload, offerStore, relayFromMax, resendToMax };
}

View file

@ -35,8 +35,8 @@ export interface StoredFile {
id: string;
name: string;
size: number;
/** max2tg: saved from MAX for the topic; tg2max: uploaded for MAX (over MAX's limit). */
direction: 'max2tg' | 'tg2max';
/** max2tg: saved from MAX for the topic; tg2max: uploaded for MAX but not sent; stored: put on the server through the panel to share by link. */
direction: 'max2tg' | 'tg2max' | 'stored';
token: string;
createdAt: number;
expiresAt: number;
@ -45,6 +45,8 @@ export interface StoredFile {
}
export interface UploadTicket {
/** 'store': the panel's «📤 Загрузить файл» — kept on the server for a link, not sent anywhere. */
purpose?: 'max' | 'store';
token: string;
maxChatId: string;
topicId: number;
@ -226,11 +228,13 @@ export class FileShare {
const ticket: UploadTicket = { ...t, token: newToken(), createdAt: now, expiresAt: now + this.opts.ttlMs };
await this.writeToken(ticket.token, {
kind: 'upload',
purpose: ticket.purpose ?? 'max',
name: ticket.name,
expectedSize: ticket.expectedSize,
expiresAt: ticket.expiresAt,
reserveBytes: this.opts.reserveBytes,
maxFileBytes: MAX_FILE_LIMIT,
// The MAX limit only matters for a file going to MAX; the panel's store is bounded by the disk.
...(ticket.purpose === 'store' ? {} : { maxFileBytes: MAX_FILE_LIMIT }),
});
this.index.tickets.push(ticket);
await this.save();
@ -263,6 +267,7 @@ 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);
if (ticket.purpose === 'store') return this.addFile(id, safeFileName(name), size, 'stored');
return this.addFile(id, safeFileName(name), size, 'tg2max', {
maxChatId: ticket.maxChatId,
topicId: ticket.topicId,

View file

@ -42,6 +42,8 @@ export interface ControlPanelDeps {
pause: PauseControl;
/** Big files kept for download links — the «📁 Файлы» section. */
files?: FileShare;
/** «📤 Загрузить файл»: an upload link into the server store, posted in General (bigFiles.ts). */
offerStore?: () => Promise<void>;
/** «📤 Отправить в MAX» for a stored file that didn't make it (bigFiles.ts). */
resendToMax?: (id: string) => Promise<{ ok: boolean; text: string }>;
}
@ -169,22 +171,25 @@ function filesView(files: FileShare): View {
const footer =
'Здесь хранятся файлы, которые мост держит на сервере для скачивания по ссылке:\n' +
'• файлы из MAX больше 50 МБ — Telegram не принимает от ботов такие файлы напрямую;\n' +
'• загруженные для отправки в MAX, но не ушедшие из-за ошибки — их можно отправить ещё раз.\n\n' +
'• загруженные для отправки в MAX, но не ушедшие из-за ошибки — их можно отправить ещё раз;\n' +
'• загруженные вами через «📤 Загрузить файл» — чтобы делиться ссылкой.\n\n' +
'Файлы, которые уже ушли в MAX, на сервере не остаются.\n' +
`Файлы со ссылкой хранятся ${ttl} от последней выданной ссылки, потом удаляются.`;
const extra = [...savingLines, pendingLine].filter(Boolean).join('\n');
if (list.length === 0) {
return {
text: `📁 Файлов нет.${extra ? `\n\n${extra}` : ''}\n\n${footer}`,
markup: Markup.inlineKeyboard([[Markup.button.callback('🔄 Обновить', 'tlmx_panel:files')], back]).reply_markup,
markup: Markup.inlineKeyboard([[Markup.button.callback('📤 Загрузить файл', 'tlmx_panel:fupload')], [Markup.button.callback('🔄 Обновить', 'tlmx_panel:files')], back]).reply_markup,
};
}
const total = list.reduce((s, f) => s + f.size, 0);
const lines = list.map((f, i) => `${i + 1}. ${f.direction === 'max2tg' ? '⬇️' : '⬆️'} ${f.name} — ${formatBytes(f.size)}, до ${formatExpiry(f.expiresAt)}`);
const icon = (f: StoredFile) => (f.direction === 'max2tg' ? '⬇️' : f.direction === 'stored' ? '📦' : '⬆️');
const lines = list.map((f, i) => `${i + 1}. ${icon(f)} ${f.name} — ${formatBytes(f.size)}, до ${formatExpiry(f.expiresAt)}`);
return {
text: `📁 Файлы на сервере: ${list.length}, всего ${formatBytes(total)}.\n⬇️ — из MAX, ⬆️ — не ушли в MAX.\n\n${lines.join('\n')}${extra ? `\n\n${extra}` : ''}\n\n${footer}`,
text: `📁 Файлы на сервере: ${list.length}, всего ${formatBytes(total)}.\n⬇️ — из MAX, ⬆️ — не ушли в MAX, 📦 — загружены вами.\n\n${lines.join('\n')}${extra ? `\n\n${extra}` : ''}\n\n${footer}`,
markup: Markup.inlineKeyboard([
...list.slice(0, 20).map((f, i) => [Markup.button.callback(`${i + 1}. ${truncateUtf16(f.name, 48)}`, `tlmx_panel:file:${f.id}`)]),
[Markup.button.callback('📤 Загрузить файл', 'tlmx_panel:fupload')],
[Markup.button.callback('🔄 Обновить', 'tlmx_panel:files')],
back,
]).reply_markup,
@ -192,7 +197,7 @@ function filesView(files: FileShare): View {
}
function fileCardView(f: StoredFile, files: FileShare, link?: string, canResend = false): View {
const where = f.direction === 'max2tg' ? 'пришёл из MAX' : 'загружен для MAX';
const where = f.direction === 'max2tg' ? 'пришёл из MAX' : f.direction === 'stored' ? 'загружен вами для ссылки' : 'загружен для MAX, но не ушёл';
const linkLine = link ? `\n\n🔗 ${link}\nСсылку можно переслать — по ней откроется страница со скачиванием.` : '';
return {
text: `📄 ${f.name}\n${formatBytes(f.size)} · ${where}\nХранится до ${formatExpiry(f.expiresAt)}, потом удалится.${linkLine}`,
@ -264,7 +269,7 @@ function pauseView(): View {
}
export function wireControlPanel(deps: ControlPanelDeps): void {
const { bot, targetGroupId, max, getActivePhone, triggerFullResync, leaves, startDialog, resolveContactName, getStatus, pause, files, resendToMax } = deps;
const { bot, targetGroupId, max, getActivePhone, triggerFullResync, leaves, startDialog, resolveContactName, getStatus, pause, files, resendToMax, offerStore } = 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);
@ -389,8 +394,14 @@ export function wireControlPanel(deps: ControlPanelDeps): void {
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:fupload', async (ctx) => {
await ctx.answerCbQuery('Готовлю ссылку для загрузки…').catch(() => {});
if (offerStore) void offerStore().catch((err) => logger.error('Panel store upload failed', err));
});
bot.action(/^tlmx_panel:fdel:(.+)$/, async (ctx) => {
const name = files?.get(ctx.match[1] ?? '')?.name;
const removed = files ? await files.remove(ctx.match[1] ?? '') : false;
if (removed) logger.info(`Panel: file «${name}» deleted`);
await ctx.answerCbQuery(removed ? 'Файл удалён' : 'Файла уже нет').catch(() => {});
if (files) await edit(ctx, filesView(files));
});

View file

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

View file

@ -205,3 +205,20 @@ describe('big-file errors', () => {
expect(s).toContain('ENOENT');
});
});
describe('the panel store («📤 Загрузить файл»)', () => {
it('keeps the upload as a 📦 file with a link, with no MAX limit and nowhere to send it', async () => {
const share = new FileShare(dataDir, opts);
await share.init();
const ticket = await share.createUploadTicket({ purpose: 'store', maxChatId: '', topicId: 0, telegramMessageId: 1, name: '', expectedSize: 0 });
const tokenBody = JSON.parse(await readFile(path.join(share.dir, 'tokens', `${ticket.token}.json`), 'utf8'));
expect(tokenBody).toMatchObject({ kind: 'upload', purpose: 'store' });
expect(tokenBody.maxFileBytes).toBeUndefined();
const dir = path.join(share.dir, 'incoming', ticket.token);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, 'photos.zip'), 'zz');
const f = await share.adoptUpload(ticket, path.join(dir, 'photos.zip'), 2, 'photos.zip');
expect(f.direction).toBe('stored');
expect(f.target).toBeUndefined();
});
});