diff --git a/src/services/zeroNotesRunner.js b/src/services/zeroNotesRunner.js index 83b8744..10ef6cf 100644 --- a/src/services/zeroNotesRunner.js +++ b/src/services/zeroNotesRunner.js @@ -16,26 +16,39 @@ */ const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); +const FormData = require('form-data'); const { query } = require('../config/db'); const settings = require('./settings'); -// Engine-контейнер не имеет монтированных uploads — отправляем фото в Telegram -// по публичному URL (Telegram сам скачивает). Базу URL берём из app_settings, -// дефолт https://zeropost.ru. Файлы лежат на отдельном nginx и публично сервятся. +// Engine-контейнер монтирует uploads volume на /var/www/zeropost-uploads +// (см. docker-compose), поэтому отправляем фото в Telegram через multipart +// 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 TG_CAPTION_LIMIT = 1024; 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), @@ -83,16 +96,22 @@ async function sendToTelegram(note, channel) { const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org'); const reply_markup = await buildReplyMarkup(note.id); - const photoUrl = await buildPhotoUrl(note.image_url); + const localPath = resolvePosePath(note.image_url); - // sendPhoto если есть URL картинки и текст влезает в caption (1024) - if (photoUrl && note.content.length <= TG_CAPTION_LIMIT) { - const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendPhoto`, { - chat_id: channel.tg_channel_id, - photo: photoUrl, - caption: note.content, - reply_markup, - }, { timeout: 30_000 }); + // sendPhoto через multipart (файл загружается в TG напрямую) — если есть локальный pose + // и текст влезает в caption (1024 hard-limit Telegram для sendPhoto). + if (localPath && note.content.length <= TG_CAPTION_LIMIT) { + const form = new FormData(); + form.append('chat_id', String(channel.tg_channel_id)); + form.append('caption', note.content); + if (reply_markup) form.append('reply_markup', JSON.stringify(reply_markup)); + 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; }