// Раннер 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 };