forked from admin/zeropost-engine
5e075ac81d
MAX API URL updated to platform-api2.max.ru (mandatory before 2026-07-19). Added Russian Trusted Root + Sub CA 2024 bundle as repo file — to be loaded via NODE_EXTRA_CA_CERTS=/app/russian_trusted_bundle.pem (set in Coolify env).
460 lines
20 KiB
JavaScript
460 lines
20 KiB
JavaScript
// Раннер scheduled_posts (системные публикации статей в каналы).
|
||
// Дёргается cron'ом раз в минуту.
|
||
//
|
||
// Логика:
|
||
// - article + channel.auto_publish_template → текст-тизер
|
||
// - в TG: inline-кнопка «Читать на сайте →» (без URL в тексте)
|
||
// - в VK/MAX: URL автоматически подмешивается в конец текста (кнопок нет)
|
||
// - cover статьи прикрепляется если auto_publish_with_cover=true
|
||
|
||
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 zeroChar = require('./zeroCharacter');
|
||
|
||
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
|
||
|
||
/**
|
||
* Если photoUrl указывает на наш собственный /uploads — открываем файл
|
||
* с диска и шлём как multipart. Это надёжнее, чем заставлять Telegram-прокси
|
||
* самостоятельно тянуть файл с zeropost.ru (бывают таймауты / sandbox-ограничения CF Worker'а).
|
||
*/
|
||
function resolveLocalPhoto(photoUrl) {
|
||
if (!photoUrl) return null;
|
||
// Формы: /uploads/x.webp, https://zeropost.ru/uploads/x.webp
|
||
let pathname = photoUrl;
|
||
try {
|
||
const u = new URL(photoUrl);
|
||
pathname = u.pathname;
|
||
} catch {}
|
||
if (!pathname.startsWith('/uploads/')) return null;
|
||
const filename = pathname.replace(/^\/uploads\//, '');
|
||
// Защита от path traversal
|
||
if (filename.includes('..') || filename.includes('/')) return null;
|
||
const local = path.join(UPLOADS_DIR, filename);
|
||
if (!fs.existsSync(local)) return null;
|
||
return local;
|
||
}
|
||
|
||
const DEFAULT_TEMPLATE = '{categoryEmoji} *{categoryLabel}*\n\n*{title}*\n\n{excerpt}';
|
||
const DEFAULT_BUTTON_TEXT = '📖 Читать на сайте →';
|
||
|
||
// Маппинг slug → emoji + русское название для плейсхолдеров
|
||
const CATEGORY_META = {
|
||
'ai-tools': { emoji: '🤖', label: 'AI Tools' },
|
||
'cybersec': { emoji: '🔒', label: 'Cybersec' },
|
||
'automation': { emoji: '⚡', label: 'Automation' },
|
||
'ai-dev': { emoji: '💻', label: 'AI Dev' },
|
||
};
|
||
|
||
function articleUrl(article) {
|
||
return `https://zeropost.ru/blog/${article.slug}`;
|
||
}
|
||
|
||
function renderTemplate(template, article) {
|
||
const tpl = (template && template.trim()) || DEFAULT_TEMPLATE;
|
||
const url = articleUrl(article);
|
||
const meta = CATEGORY_META[article.category] || { emoji: '📝', label: article.category || '' };
|
||
return tpl
|
||
.replaceAll('{title}', article.title || '')
|
||
.replaceAll('{excerpt}', article.excerpt || '')
|
||
.replaceAll('{url}', url)
|
||
.replaceAll('{category}', article.category || '')
|
||
.replaceAll('{categoryEmoji}', meta.emoji)
|
||
.replaceAll('{categoryLabel}', meta.label);
|
||
}
|
||
|
||
/**
|
||
* Telegram. Если есть article — добавляем inline-кнопку «Читать на сайте».
|
||
* Если 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-кнопка — только если есть статья и кнопка не отключена
|
||
const buttonText = channel.auto_publish_button_text === null
|
||
? null
|
||
: (channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT);
|
||
let reply_markup = undefined;
|
||
if (article && buttonText) {
|
||
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;
|
||
}
|
||
|
||
async function publishToVK({ channel, text, photoUrl, article }) {
|
||
if (!channel.vk_group_id || !channel.vk_access_token) {
|
||
throw new Error('VK не настроен');
|
||
}
|
||
|
||
const groupId = String(channel.vk_group_id).replace(/^-/, '');
|
||
const ownerId = '-' + groupId;
|
||
const token = channel.vk_access_token;
|
||
const v = '5.199';
|
||
|
||
// Добавляем ссылку на статью в конец текста
|
||
let finalText = text;
|
||
if (article) {
|
||
const url = articleUrl(article);
|
||
if (!finalText.includes(url)) {
|
||
const buttonText = channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT;
|
||
finalText = `${finalText}\n\n${buttonText}\n${url}`;
|
||
}
|
||
}
|
||
|
||
// 2-step upload картинки
|
||
let attachments = '';
|
||
if (photoUrl) {
|
||
try {
|
||
// Шаг 1: получаем upload URL
|
||
const uploadServerRes = await axios.get('https://api.vk.com/method/photos.getWallUploadServer', {
|
||
params: { group_id: groupId, access_token: token, v },
|
||
timeout: 10_000,
|
||
});
|
||
if (uploadServerRes.data?.error) throw new Error(`VK getWallUploadServer: ${uploadServerRes.data.error.error_msg}`);
|
||
const uploadUrl = uploadServerRes.data.response.upload_url;
|
||
|
||
// Шаг 2: загружаем файл
|
||
const localPath = resolveLocalPhoto(photoUrl);
|
||
let fileBuffer, fileName;
|
||
if (localPath) {
|
||
fileBuffer = fs.readFileSync(localPath);
|
||
fileName = path.basename(localPath);
|
||
} else {
|
||
// Внешний URL — скачиваем
|
||
const dl = await axios.get(photoUrl, { responseType: 'arraybuffer', timeout: 30_000 });
|
||
fileBuffer = Buffer.from(dl.data);
|
||
fileName = 'photo.jpg';
|
||
}
|
||
|
||
const form = new FormData();
|
||
form.append('photo', fileBuffer, { filename: fileName, contentType: 'image/jpeg' });
|
||
const uploadRes = await axios.post(uploadUrl, form, {
|
||
headers: form.getHeaders(),
|
||
timeout: 60_000,
|
||
});
|
||
const { server, photo: photoData, hash } = uploadRes.data;
|
||
if (!photoData) throw new Error('VK upload: пустой ответ');
|
||
|
||
// Шаг 3: сохраняем фото
|
||
const saveRes = await axios.get('https://api.vk.com/method/photos.saveWallPhoto', {
|
||
params: { group_id: groupId, server, photo: photoData, hash, access_token: token, v },
|
||
timeout: 10_000,
|
||
});
|
||
if (saveRes.data?.error) throw new Error(`VK saveWallPhoto: ${saveRes.data.error.error_msg}`);
|
||
const saved = saveRes.data.response?.[0];
|
||
if (!saved) throw new Error('VK saveWallPhoto: нет данных');
|
||
|
||
attachments = `photo${saved.owner_id}_${saved.id}`;
|
||
} catch (photoErr) {
|
||
console.warn(`[VK] photo upload failed, posting without photo: ${photoErr.message}`);
|
||
// Публикуем без картинки если загрузка упала
|
||
}
|
||
}
|
||
|
||
// Шаг 4: публикуем пост
|
||
const params = new URLSearchParams({
|
||
owner_id: ownerId,
|
||
from_group: '1',
|
||
message: finalText,
|
||
access_token: token,
|
||
v,
|
||
});
|
||
if (attachments) params.set('attachments', attachments);
|
||
|
||
const res = await axios.post('https://api.vk.com/method/wall.post', params, { timeout: 15_000 });
|
||
if (res.data?.error) throw new Error(`VK wall.post: ${res.data.error.error_msg}`);
|
||
return res.data?.response?.post_id;
|
||
}
|
||
|
||
async function publishToMax({ channel, text, photoUrl, article }) {
|
||
if (!channel.max_channel_id || !channel.max_access_token) {
|
||
throw new Error('MAX не настроен');
|
||
}
|
||
|
||
const BASE = 'https://platform-api2.max.ru';
|
||
const token = channel.max_access_token;
|
||
const chatId = channel.max_channel_id;
|
||
const headers = { Authorization: token, 'Content-Type': 'application/json' };
|
||
|
||
// Добавляем ссылку на статью в конец текста
|
||
let finalText = text;
|
||
if (article) {
|
||
const url = articleUrl(article);
|
||
if (!finalText.includes(url)) {
|
||
const buttonText = channel.auto_publish_button_text || DEFAULT_BUTTON_TEXT;
|
||
finalText = `${finalText}\n\n${buttonText}\n${url}`;
|
||
}
|
||
}
|
||
|
||
const body = { text: finalText, attachments: [] };
|
||
|
||
// Загрузка фото через 2-step upload
|
||
if (photoUrl) {
|
||
try {
|
||
// Шаг 1: получаем presigned URL для загрузки
|
||
const uploadUrlRes = await axios.post(`${BASE}/uploads?type=image`, null, {
|
||
headers: { Authorization: token },
|
||
timeout: 10_000,
|
||
});
|
||
const uploadUrl = uploadUrlRes.data?.url;
|
||
if (!uploadUrl) throw new Error('MAX: нет upload URL');
|
||
|
||
// Шаг 2: загружаем файл (multipart)
|
||
const localPath = resolveLocalPhoto(photoUrl);
|
||
let fileBuffer, fileName;
|
||
if (localPath) {
|
||
fileBuffer = fs.readFileSync(localPath);
|
||
fileName = path.basename(localPath);
|
||
} else {
|
||
const dl = await axios.get(photoUrl, { responseType: 'arraybuffer', timeout: 30_000 });
|
||
fileBuffer = Buffer.from(dl.data);
|
||
fileName = 'photo.jpg';
|
||
}
|
||
|
||
const form = new FormData();
|
||
form.append('file', fileBuffer, { filename: fileName, contentType: 'image/jpeg' });
|
||
const uploadRes = await axios.post(uploadUrl, form, {
|
||
headers: form.getHeaders(),
|
||
timeout: 60_000,
|
||
});
|
||
|
||
const token_img = uploadRes.data?.token;
|
||
if (token_img) {
|
||
body.attachments.push({ type: 'image', payload: { token: token_img } });
|
||
}
|
||
} catch (photoErr) {
|
||
console.warn(`[MAX] photo upload failed, posting without photo: ${photoErr.message}`);
|
||
}
|
||
}
|
||
|
||
if (!body.attachments.length) delete body.attachments;
|
||
|
||
const res = await axios.post(`${BASE}/messages?chat_id=${chatId}`, body, {
|
||
headers,
|
||
timeout: 15_000,
|
||
});
|
||
if (res.data?.error) throw new Error(`MAX: ${res.data.error.message || res.data.error}`);
|
||
return res.data?.message?.body?.mid;
|
||
}
|
||
|
||
async function publishOne(scheduledPost) {
|
||
const { rows: chRows } = await query(`SELECT * FROM channels WHERE id=$1`, [scheduledPost.channel_id]);
|
||
if (!chRows.length) throw new Error('Channel not found');
|
||
const channel = chRows[0];
|
||
|
||
let text = scheduledPost.custom_text;
|
||
let photoUrl = null;
|
||
let article = null;
|
||
|
||
if (scheduledPost.article_id) {
|
||
const { rows: arts } = await query(`SELECT * FROM articles WHERE id=$1`, [scheduledPost.article_id]);
|
||
if (!arts.length) throw new Error('Article not found');
|
||
article = arts[0];
|
||
if (!text) text = renderTemplate(channel.auto_publish_template, article);
|
||
|
||
// Выбор картинки:
|
||
// image_source='zero' — иллюстрация Зеро по позе
|
||
// image_source='cover' — обложка статьи (старое поведение)
|
||
// image_source='none' — без картинки
|
||
const imgSource = channel.auto_publish_image_source || 'cover';
|
||
|
||
// 'alternating' — чётные article_id = обложка статьи, нечётные = Зеро.
|
||
// Это даёт визуальное разнообразие без ручного управления.
|
||
const useZero = imgSource === 'zero'
|
||
|| (imgSource === 'alternating' && article.id % 2 === 1);
|
||
const useCover = imgSource === 'cover'
|
||
|| (imgSource === 'alternating' && article.id % 2 === 0);
|
||
|
||
if (useZero && channel.auto_publish_with_cover !== false) {
|
||
const picked = zeroChar.pickPose({
|
||
title: article.title,
|
||
excerpt: article.excerpt,
|
||
category: article.category,
|
||
});
|
||
if (picked.exists) {
|
||
photoUrl = `/uploads/zero-${picked.pose}.webp`;
|
||
console.log(`[scheduled-runner] Zero pose=${picked.pose} (${picked.source}) article=${article.id}`);
|
||
} else if (article.cover_url) {
|
||
// Fallback на обложку если поза ещё не сгенерирована
|
||
photoUrl = article.cover_url.startsWith('http')
|
||
? article.cover_url
|
||
: `https://zeropost.ru${article.cover_url}`;
|
||
console.log(`[scheduled-runner] Zero fallback to cover (pose not ready) article=${article.id}`);
|
||
}
|
||
} else if (useCover && channel.auto_publish_with_cover) {
|
||
if (article.cover_url) {
|
||
// Проверяем что обложка реальная, а не SVG-заглушка (< 30KB)
|
||
const localCoverPath = path.join(UPLOADS_DIR, article.cover_url.replace(/^\/uploads\//, ''));
|
||
let coverIsReal = true;
|
||
try {
|
||
const stat = fs.statSync(localCoverPath);
|
||
if (stat.size < 30 * 1024) {
|
||
coverIsReal = false;
|
||
console.log(`[scheduled-runner] SVG stub (${Math.round(stat.size/1024)}KB), regen article=${article.id}`);
|
||
try {
|
||
const covers = require('./covers');
|
||
await covers.generateCover({ articleId: article.id, title: article.title, tags: article.tags || [], channelId: channel.id });
|
||
const { rows: refreshed } = await query('SELECT cover_url FROM articles WHERE id=$1', [article.id]);
|
||
if (refreshed[0]?.cover_url) article.cover_url = refreshed[0].cover_url;
|
||
coverIsReal = true;
|
||
console.log(`[scheduled-runner] regen OK article=${article.id}`);
|
||
} catch(regenErr) {
|
||
console.warn(`[scheduled-runner] regen failed, using Zero: ${regenErr.message.slice(0,80)}`);
|
||
}
|
||
}
|
||
} catch(_) { /* внешний URL или файл не найден — считаем реальным */ }
|
||
|
||
if (coverIsReal) {
|
||
photoUrl = article.cover_url.startsWith('http')
|
||
? article.cover_url
|
||
: `https://zeropost.ru${article.cover_url}`;
|
||
console.log(`[scheduled-runner] cover=${article.cover_url.split('/').pop()} article=${article.id}`);
|
||
} else {
|
||
const attempts = scheduledPost.cover_regen_attempts || 0;
|
||
const MAX_REGEN_ATTEMPTS = 3; // 3 × 15 мин = 45 мин максимум ждём
|
||
if (attempts < MAX_REGEN_ATTEMPTS) {
|
||
// Откладываем пост на 15 минут, не публикуем
|
||
const retryAt = new Date(Date.now() + 15 * 60_000);
|
||
await query(
|
||
`UPDATE scheduled_posts SET scheduled_at=$1, cover_regen_attempts=$2 WHERE id=$3`,
|
||
[retryAt, attempts + 1, scheduledPost.id]
|
||
);
|
||
console.log(`[scheduled-runner] SVG stub — delayed 15min (attempt ${attempts+1}/${MAX_REGEN_ATTEMPTS}) post=${scheduledPost.id} article=${article.id}`);
|
||
return; // не публикуем сейчас
|
||
}
|
||
// Исчерпали попытки — публикуем с Зеро чтобы пост не завис навсегда
|
||
console.log(`[scheduled-runner] SVG stub max attempts reached, using Zero article=${article.id}`);
|
||
const picked = zeroChar.pickPose({ title: article.title, excerpt: article.excerpt, category: article.category });
|
||
if (picked.exists) {
|
||
photoUrl = `/uploads/zero-${picked.pose}.webp`;
|
||
console.log(`[scheduled-runner] Zero fallback pose=${picked.pose} article=${article.id}`);
|
||
}
|
||
}
|
||
} else {
|
||
// Обложки нет (ещё генерируется) — fallback на Зеро
|
||
const picked = zeroChar.pickPose({
|
||
title: article.title,
|
||
excerpt: article.excerpt,
|
||
category: article.category,
|
||
});
|
||
if (picked.exists) {
|
||
photoUrl = `/uploads/zero-${picked.pose}.webp`;
|
||
console.log(`[scheduled-runner] cover fallback → Zero pose=${picked.pose} article=${article.id}`);
|
||
}
|
||
}
|
||
}
|
||
// imgSource === 'none' → photoUrl остаётся null
|
||
}
|
||
|
||
if (!text && scheduledPost.post_type !== 'poll') throw new Error('Empty text and no article');
|
||
|
||
let messageId;
|
||
|
||
// Опрос — отдельная ветка
|
||
if (scheduledPost.post_type === 'poll') {
|
||
if (channel.platform !== 'telegram') throw new Error('Опросы только в Telegram');
|
||
const { sendPoll } = require('../routes/polls');
|
||
const meta = scheduledPost.meta || {};
|
||
messageId = await sendPoll({
|
||
channel,
|
||
question: meta.question || scheduledPost.text,
|
||
options: meta.options || [],
|
||
is_anonymous: meta.is_anonymous ?? true,
|
||
allows_multiple_answers: meta.allows_multiple_answers ?? false,
|
||
type: meta.type || 'regular',
|
||
correct_option_id: meta.correct_option_id,
|
||
explanation: meta.explanation,
|
||
});
|
||
} else if (channel.platform === 'telegram' || !channel.platform) {
|
||
messageId = await publishToTelegram({ channel, text, photoUrl, article });
|
||
} else if (channel.platform === 'vk') {
|
||
messageId = await publishToVK({ channel, text, photoUrl, article });
|
||
} else if (channel.platform === 'max') {
|
||
messageId = await publishToMax({ channel, text, photoUrl, article });
|
||
} else {
|
||
throw new Error(`Платформа ${channel.platform} не поддерживается`);
|
||
}
|
||
|
||
// Логируем в posts
|
||
await query(
|
||
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
|
||
VALUES ($1,$2,'published',NOW(),$3)`,
|
||
[channel.id, text, channel.platform === 'telegram' ? (messageId || null) : null]
|
||
);
|
||
|
||
return { messageId, channel, article };
|
||
}
|
||
|
||
async function runScheduled() {
|
||
const { rows } = await query(
|
||
`SELECT * FROM scheduled_posts
|
||
WHERE status='pending' AND scheduled_at <= NOW()
|
||
ORDER BY scheduled_at ASC LIMIT 20`
|
||
);
|
||
const results = [];
|
||
for (const sp of rows) {
|
||
try {
|
||
const { messageId } = await publishOne(sp);
|
||
await query(
|
||
`UPDATE scheduled_posts SET status='sent', published_at=NOW(), error=NULL WHERE id=$1`,
|
||
[sp.id]
|
||
);
|
||
results.push({ id: sp.id, ok: true, message_id: messageId });
|
||
console.log(`[scheduled-runner] sent id=${sp.id} channel=${sp.channel_id} article=${sp.article_id}`);
|
||
} catch (err) {
|
||
const msg = err.response?.data?.description || err.response?.data?.error?.error_msg || err.message;
|
||
await query(
|
||
`UPDATE scheduled_posts SET status='failed', error=$1 WHERE id=$2`,
|
||
[String(msg).slice(0, 1000), sp.id]
|
||
);
|
||
results.push({ id: sp.id, ok: false, error: msg });
|
||
console.error(`[scheduled-runner] failed id=${sp.id}: ${msg}`);
|
||
}
|
||
}
|
||
return { processed: rows.length, results };
|
||
}
|
||
|
||
module.exports = { runScheduled, publishOne, renderTemplate, DEFAULT_TEMPLATE, DEFAULT_BUTTON_TEXT };
|