diff --git a/src/services/scheduledPostsRunner.js b/src/services/scheduledPostsRunner.js index 1b9019e..dd0e13b 100644 --- a/src/services/scheduledPostsRunner.js +++ b/src/services/scheduledPostsRunner.js @@ -14,6 +14,7 @@ const FormData = require('form-data'); const { query } = require('../config/db'); const settings = require('./settings'); const zeroChar = require('./zeroCharacter'); +const { tgSend } = require('./tgSend'); const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads'; @@ -72,53 +73,21 @@ function renderTemplate(template, article) { * Если caption длиннее 1024 — режется (TG hard-limit для sendPhoto). Для длинных постов лучше посылать без cover (sendMessage до 4096). */ async function publishToTelegram({ channel, text, photoUrl, article }) { - const base = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org'); - - // Inline-кнопка — только если есть статья и кнопка не отключена + // Inline-кнопка «Читать на сайте» — только если есть статья и кнопка не отключена const buttonText = channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT; let reply_markup = undefined; if (article && buttonText) { - reply_markup = { - inline_keyboard: [[{ text: buttonText, url: articleUrl(article) }]], - }; + reply_markup = { inline_keyboard: [[{ text: buttonText, url: articleUrl(article) }]] }; } - - if (photoUrl) { - const localPath = resolveLocalPhoto(photoUrl); - if (localPath) { - // Шлём файл напрямую через multipart — TG не пойдёт сам ходить за URL - const form = new FormData(); - form.append('chat_id', String(channel.tg_channel_id)); - form.append('caption', text.slice(0, 1024)); - form.append('parse_mode', 'Markdown'); - if (reply_markup) form.append('reply_markup', JSON.stringify(reply_markup)); - form.append('photo', fs.createReadStream(localPath)); - const res = await axios.post(`${base}/bot${channel.bot_token}/sendPhoto`, form, { - headers: form.getHeaders(), - timeout: 60000, - maxContentLength: Infinity, - maxBodyLength: Infinity, - }); - return res.data?.result?.message_id; - } - // Внешний URL — оставляем старое поведение - const res = await axios.post(`${base}/bot${channel.bot_token}/sendPhoto`, { - chat_id: channel.tg_channel_id, - photo: photoUrl, - caption: text.slice(0, 1024), - parse_mode: 'Markdown', - reply_markup, - }, { timeout: 30000 }); - return res.data?.result?.message_id; - } - const res = await axios.post(`${base}/bot${channel.bot_token}/sendMessage`, { - chat_id: channel.tg_channel_id, - text: text.slice(0, 4096), - parse_mode: 'Markdown', - disable_web_page_preview: !article, // если есть кнопка — превью сайта не нужно - reply_markup, - }, { timeout: 15000 }); - return res.data?.result?.message_id; + // Единый модуль отправки (multipart для локальных файлов, URL для внешних, fallback sendMessage) + return tgSend({ + botToken: channel.bot_token, + chatId: channel.tg_channel_id, + text, + photoUrl, + replyMarkup: reply_markup, + parseMode: 'Markdown', + }); } async function publishToVK({ channel, text, photoUrl, article }) { diff --git a/src/services/tgSend.js b/src/services/tgSend.js new file mode 100644 index 0000000..efa4e64 --- /dev/null +++ b/src/services/tgSend.js @@ -0,0 +1,134 @@ +/** + * tgSend.js — единый модуль отправки сообщений в Telegram. + * + * Зачем: раньше scheduledPostsRunner (статьи) и zeroNotesRunner (заметки Зеро) + * имели каждый свою копию логики sendPhoto/sendMessage. Они разъезжались — + * у статей фото работало, у заметок ломалось. Теперь оба зовут этот модуль. + * + * Главный принцип отправки фото: + * Если фото — это наш локальный файл (/uploads/...), шлём его как multipart + * (file stream). Telegram НЕ ходит за URL сам — это надёжнее (нет таймаутов + * CF-Worker'а, нет "wrong type of web page content"). Внешний URL (не наш) — + * отправляем как ссылку (Telegram скачает). + * + * Единственный источник правды по: + * - резолву локального пути картинки (resolveLocalPhoto) + * - лимитам Telegram (caption 1024 / message 4096) + * - обработке ошибок (extractTgError) + */ +const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); +const FormData = require('form-data'); +const settings = require('./settings'); + +const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads'; +const TG_CAPTION_LIMIT = 1024; // hard-limit Telegram для caption у sendPhoto +const TG_MESSAGE_LIMIT = 4096; // hard-limit Telegram для text у sendMessage + +/** + * Резолвит локальный путь для /uploads/* фото. Возвращает абсолютный путь к + * существующему файлу, либо null (внешний URL / файла нет / path traversal). + */ +function resolveLocalPhoto(photoUrl) { + if (!photoUrl) return null; + let pathname = photoUrl; + try { + // new URL кинет если относительный путь — тогда оставляем как есть + pathname = new URL(photoUrl).pathname; + } catch { + pathname = photoUrl; + } + if (!pathname.startsWith('/uploads/')) return null; + const filename = pathname.replace(/^\/uploads\//, ''); + if (filename.includes('..') || filename.includes('/')) return null; // path traversal guard + const local = path.join(UPLOADS_DIR, filename); + if (!fs.existsSync(local)) return null; + return local; +} + +/** Достаёт человекочитаемую ошибку из ответа Telegram/axios. */ +function extractTgError(err) { + return err.response?.data?.description + || err.response?.data?.error?.error_msg + || err.message + || 'unknown telegram error'; +} + +/** + * Универсальная отправка в Telegram. + * + * @param {object} o + * @param {string} o.botToken — токен бота канала + * @param {string} o.chatId — tg_channel_id + * @param {string} o.text — текст (станет caption если есть фото, иначе message) + * @param {string} [o.photoUrl] — /uploads/... или внешний URL (необязательно) + * @param {object} [o.replyMarkup] — inline_keyboard и т.п. (необязательно) + * @param {string} [o.parseMode] — 'Markdown' | 'HTML' | undefined (без разметки) + * @returns {Promise} message_id + * + * Поведение: + * - фото есть + текст влезает в caption (1024) → sendPhoto + * · локальный файл → multipart (file stream) + * · внешний URL → photo=url (TG скачает) + * - иначе → sendMessage (текст до 4096, режется при превышении) + */ +async function tgSend({ botToken, chatId, text, photoUrl, replyMarkup, parseMode }) { + if (!botToken || !chatId) { + throw new Error('botToken или chatId не заданы'); + } + const base = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org'); + const body = String(text || ''); + + const canUsePhoto = photoUrl && body.length <= TG_CAPTION_LIMIT; + + if (canUsePhoto) { + const localPath = resolveLocalPhoto(photoUrl); + + if (localPath) { + // Локальный файл → multipart + const form = new FormData(); + form.append('chat_id', String(chatId)); + form.append('caption', body); + if (parseMode) form.append('parse_mode', parseMode); + if (replyMarkup) form.append('reply_markup', JSON.stringify(replyMarkup)); + form.append('photo', fs.createReadStream(localPath)); + const res = await axios.post(`${base}/bot${botToken}/sendPhoto`, form, { + headers: form.getHeaders(), + timeout: 60_000, + maxContentLength: Infinity, + maxBodyLength: Infinity, + }); + return res.data?.result?.message_id; + } + + // Внешний URL → пусть Telegram сам скачает + const res = await axios.post(`${base}/bot${botToken}/sendPhoto`, { + chat_id: chatId, + photo: photoUrl, + caption: body, + parse_mode: parseMode, + reply_markup: replyMarkup, + }, { timeout: 30_000 }); + return res.data?.result?.message_id; + } + + // Без фото (или текст слишком длинный для caption) → sendMessage + const res = await axios.post(`${base}/bot${botToken}/sendMessage`, { + chat_id: chatId, + text: body.slice(0, TG_MESSAGE_LIMIT), + parse_mode: parseMode, + disable_web_page_preview: !replyMarkup, // если есть кнопка — превью сайта не нужно + reply_markup: replyMarkup, + }, { timeout: 15_000 }); + return res.data?.result?.message_id; +} + +module.exports = { + tgSend, + resolveLocalPhoto, + extractTgError, + TG_CAPTION_LIMIT, + TG_MESSAGE_LIMIT, + UPLOADS_DIR, +}; diff --git a/src/services/zeroNotesRunner.js b/src/services/zeroNotesRunner.js index 10ef6cf..bcc3ec1 100644 --- a/src/services/zeroNotesRunner.js +++ b/src/services/zeroNotesRunner.js @@ -15,38 +15,11 @@ * иначе 'failed' c сохранением error */ -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'); +const { tgSend, extractTgError } = require('./tgSend'); -// 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; @@ -90,39 +63,17 @@ async function buildReplyMarkup(noteId) { } async function sendToTelegram(note, channel) { - if (!channel.bot_token || !channel.tg_channel_id) { - throw new Error('bot_token или tg_channel_id у канала не заданы'); - } - - const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org'); const reply_markup = await buildReplyMarkup(note.id); - const localPath = resolvePosePath(note.image_url); - - // 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; - } - - // Иначе — sendMessage (текст до 4096) - const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendMessage`, { - chat_id: channel.tg_channel_id, - text: note.content.slice(0, TG_MESSAGE_LIMIT), - disable_web_page_preview: !reply_markup, - reply_markup, - }, { timeout: 15_000 }); - return res.data?.result?.message_id; + // Заметки Зеро пишутся обычным текстом (без Markdown-разметки) — parseMode не задаём, + // чтобы случайные * и _ в тексте не ломали парсинг (была ошибка "can't parse entities"). + return tgSend({ + botToken: channel.bot_token, + chatId: channel.tg_channel_id, + text: note.content, + photoUrl: note.image_url, + replyMarkup: reply_markup, + parseMode: undefined, + }); } async function markPublished(noteId, messageId) { @@ -170,7 +121,7 @@ async function publishOne() { console.log(`[zeroNotes/runner] published #${note.id} → tg_msg=${messageId} channel=${channel.id}`); return { processed: true, noteId: note.id, messageId }; } catch (err) { - const errMsg = err.response?.data?.description || err.message || 'unknown error'; + const errMsg = extractTgError(err); const r = await markFailedOrRetry(note, errMsg); console.error(`[zeroNotes/runner] FAIL #${note.id} attempt=${note.attempts} → ${r.newStatus}: ${errMsg}`); return { processed: true, noteId: note.id, error: errMsg, status: r.newStatus };