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:
@@ -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,
|
||||
// Единый модуль отправки (multipart для локальных файлов, URL для внешних, fallback sendMessage)
|
||||
return tgSend({
|
||||
botToken: channel.bot_token,
|
||||
chatId: channel.tg_channel_id,
|
||||
text,
|
||||
photoUrl,
|
||||
replyMarkup: reply_markup,
|
||||
parseMode: 'Markdown',
|
||||
});
|
||||
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 }) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
// Заметки Зеро пишутся обычным текстом (без 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,
|
||||
});
|
||||
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) {
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user