fix(zero): back to multipart photo upload (engine now has uploads volume)

The URL-mode I added earlier (Telegram fetches photo from our public URL)
hit 'wrong type of the web page content' from Telegram servers — likely
because traefik / Cloudflare serves something non-image to bot User-Agent,
or the worker proxy mangles things on the way back. Either way it stopped
working.

Today we added the persistent /var/www/zeropost-uploads volume to the engine
container, so the file is now directly accessible inside engine. Switching
back to multipart upload via fs.createReadStream — same pattern as
scheduledPostsRunner uses for article covers (and which works fine).

ZERO_PUBLIC_BASE_URL setting is no longer needed but harmless if left.
This commit is contained in:
Aleksei Pavlov
2026-06-20 10:26:13 +03:00
parent bdff84e579
commit 325ebe7759
+41 -22
View File
@@ -16,26 +16,39 @@
*/ */
const axios = require('axios'); const axios = require('axios');
const fs = require('fs');
const path = require('path');
const FormData = require('form-data');
const { query } = require('../config/db'); const { query } = require('../config/db');
const settings = require('./settings'); const settings = require('./settings');
// Engine-контейнер не имеет монтированных uploads — отправляем фото в Telegram // Engine-контейнер монтирует uploads volume на /var/www/zeropost-uploads
// по публичному URL (Telegram сам скачивает). Базу URL берём из app_settings, // (см. docker-compose), поэтому отправляем фото в Telegram через multipart
// дефолт https://zeropost.ru. Файлы лежат на отдельном nginx и публично сервятся. // upload (file stream) — это надёжнее URL-режима, особенно когда Telegram
// почему-то не может скачать наш публичный URL ("wrong type of web page content").
// Тот же подход используется в scheduledPostsRunner для обложек статей.
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
/**
* Открывает локальный путь для /uploads/* image_url. Возвращает абсолютный путь
* или null если файла нет / image_url абсолютный URL / некорректный путь.
*/
function resolvePosePath(imageUrl) {
if (!imageUrl) return null;
if (/^https?:\/\//i.test(imageUrl)) return null; // абсолютный URL — multipart не годится
let pathname = imageUrl;
try { pathname = new URL(imageUrl, 'http://x').pathname; } catch { /* relative */ }
if (!pathname.startsWith('/uploads/')) return null;
const filename = pathname.replace(/^\/uploads\//, '');
if (filename.includes('..') || filename.includes('/')) return null;
const local = path.join(UPLOADS_DIR, filename);
return fs.existsSync(local) ? local : null;
}
const MAX_ATTEMPTS = 3; const MAX_ATTEMPTS = 3;
const TG_CAPTION_LIMIT = 1024; const TG_CAPTION_LIMIT = 1024;
const TG_MESSAGE_LIMIT = 4096; const TG_MESSAGE_LIMIT = 4096;
/**
* Превращает relative image_url (/uploads/zero-X.webp) в полный публичный URL
* который Telegram сможет скачать. Если image_url уже абсолютный — возвращает как есть.
*/
async function buildPhotoUrl(imageUrl) {
if (!imageUrl) return null;
if (/^https?:\/\//i.test(imageUrl)) return imageUrl;
const base = await settings.get('ZERO_PUBLIC_BASE_URL', 'https://zeropost.ru');
return `${base.replace(/\/$/, '')}${imageUrl.startsWith('/') ? '' : '/'}${imageUrl}`;
}
/** /**
* Берёт ОДНУ approved-заметку готовую к публикации (scheduled_at <= NOW), * Берёт ОДНУ approved-заметку готовую к публикации (scheduled_at <= NOW),
@@ -83,16 +96,22 @@ async function sendToTelegram(note, channel) {
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org'); const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
const reply_markup = await buildReplyMarkup(note.id); const reply_markup = await buildReplyMarkup(note.id);
const photoUrl = await buildPhotoUrl(note.image_url); const localPath = resolvePosePath(note.image_url);
// sendPhoto если есть URL картинки и текст влезает в caption (1024) // sendPhoto через multipart (файл загружается в TG напрямую) — если есть локальный pose
if (photoUrl && note.content.length <= TG_CAPTION_LIMIT) { // и текст влезает в caption (1024 hard-limit Telegram для sendPhoto).
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendPhoto`, { if (localPath && note.content.length <= TG_CAPTION_LIMIT) {
chat_id: channel.tg_channel_id, const form = new FormData();
photo: photoUrl, form.append('chat_id', String(channel.tg_channel_id));
caption: note.content, form.append('caption', note.content);
reply_markup, if (reply_markup) form.append('reply_markup', JSON.stringify(reply_markup));
}, { timeout: 30_000 }); form.append('photo', fs.createReadStream(localPath));
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendPhoto`, form, {
headers: form.getHeaders(),
timeout: 60_000,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
return res.data?.result?.message_id; return res.data?.result?.message_id;
} }