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 { 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 }) {
|
||||||
|
|||||||
@@ -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
|
* иначе '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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user