refactor(tg): единый модуль tgSend для всех публикаций в Telegram

Проблема: scheduledPostsRunner (статьи) и zeroNotesRunner (заметки Зеро)
держали каждый свою копию sendPhoto/sendMessage. Логика разъезжалась —
у статей фото работало через multipart, у заметок ломалось (URL-режим →
'wrong type of web page content'). Чинили в одном месте — в другом
оставалось сломано.

Решение: src/services/tgSend.js — единый источник правды:
  - resolveLocalPhoto: /uploads/* → локальный файл (multipart), внешний → URL
  - tgSend({botToken, chatId, text, photoUrl, replyMarkup, parseMode}):
      фото+caption<=1024 → sendPhoto (multipart для локальных, URL для внешних)
      иначе → sendMessage (текст<=4096)
  - extractTgError: единый разбор ошибок Telegram

zeroNotesRunner и scheduledPostsRunner.publishToTelegram теперь оба зовут
tgSend. Кнопка-на-сайт у статей и reply_markup у заметок сохранены.
parseMode: статьи — Markdown, заметки Зеро — без разметки (текст от первого
лица с произвольными символами не должен падать на parse entities).

VK/MAX публикация не затронута (там свой resolveLocalPhoto/FormData).
This commit is contained in:
Aleksei Pavlov
2026-06-20 10:43:50 +03:00
parent 325ebe7759
commit a09ee4a5fb
3 changed files with 158 additions and 104 deletions
+11 -42
View File
@@ -14,6 +14,7 @@ const FormData = require('form-data');
const { query } = require('../config/db'); const { query } = require('../config/db');
const settings = require('./settings'); const settings = require('./settings');
const zeroChar = require('./zeroCharacter'); const zeroChar = require('./zeroCharacter');
const { tgSend } = require('./tgSend');
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads'; 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). * Если caption длиннее 1024 — режется (TG hard-limit для sendPhoto). Для длинных постов лучше посылать без cover (sendMessage до 4096).
*/ */
async function publishToTelegram({ channel, text, photoUrl, article }) { 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; const buttonText = channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT;
let reply_markup = undefined; let reply_markup = undefined;
if (article && buttonText) { if (article && buttonText) {
reply_markup = { reply_markup = { inline_keyboard: [[{ text: buttonText, url: articleUrl(article) }]] };
inline_keyboard: [[{ text: buttonText, url: articleUrl(article) }]],
};
} }
// Единый модуль отправки (multipart для локальных файлов, URL для внешних, fallback sendMessage)
if (photoUrl) { return tgSend({
const localPath = resolveLocalPhoto(photoUrl); botToken: channel.bot_token,
if (localPath) { chatId: channel.tg_channel_id,
// Шлём файл напрямую через multipart — TG не пойдёт сам ходить за URL text,
const form = new FormData(); photoUrl,
form.append('chat_id', String(channel.tg_channel_id)); replyMarkup: reply_markup,
form.append('caption', text.slice(0, 1024)); parseMode: 'Markdown',
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;
} }
async function publishToVK({ channel, text, photoUrl, article }) { async function publishToVK({ channel, text, photoUrl, article }) {
+134
View File
@@ -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<number>} 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,
};
+11 -60
View File
@@ -15,38 +15,11 @@
* иначе 'failed' c сохранением error * иначе '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 { query } = require('../config/db');
const settings = require('./settings'); 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 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) { 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 reply_markup = await buildReplyMarkup(note.id);
const localPath = resolvePosePath(note.image_url); // Заметки Зеро пишутся обычным текстом (без Markdown-разметки) — parseMode не задаём,
// чтобы случайные * и _ в тексте не ломали парсинг (была ошибка "can't parse entities").
// sendPhoto через multipart (файл загружается в TG напрямую) — если есть локальный pose return tgSend({
// и текст влезает в caption (1024 hard-limit Telegram для sendPhoto). botToken: channel.bot_token,
if (localPath && note.content.length <= TG_CAPTION_LIMIT) { chatId: channel.tg_channel_id,
const form = new FormData(); text: note.content,
form.append('chat_id', String(channel.tg_channel_id)); photoUrl: note.image_url,
form.append('caption', note.content); replyMarkup: reply_markup,
if (reply_markup) form.append('reply_markup', JSON.stringify(reply_markup)); parseMode: undefined,
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;
} }
async function markPublished(noteId, messageId) { 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}`); console.log(`[zeroNotes/runner] published #${note.id} → tg_msg=${messageId} channel=${channel.id}`);
return { processed: true, noteId: note.id, messageId }; return { processed: true, noteId: note.id, messageId };
} catch (err) { } catch (err) {
const errMsg = err.response?.data?.description || err.message || 'unknown error'; const errMsg = extractTgError(err);
const r = await markFailedOrRetry(note, errMsg); const r = await markFailedOrRetry(note, errMsg);
console.error(`[zeroNotes/runner] FAIL #${note.id} attempt=${note.attempts}${r.newStatus}: ${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 }; return { processed: true, noteId: note.id, error: errMsg, status: r.newStatus };