diff --git a/src/routes/zeroAdmin.js b/src/routes/zeroAdmin.js index 303a264..2bf4e5e 100644 --- a/src/routes/zeroAdmin.js +++ b/src/routes/zeroAdmin.js @@ -228,6 +228,7 @@ const CONFIG_KEYS = [ { key: 'ZERO_NOTES_APPROVE_HOUR', default: '7', description: 'час авто-одобрения в МСК (0-23)' }, { key: 'ZERO_NOTES_PUBLISH_HOUR', default: '13', description: 'час публикации в МСК (0-23) — определяет scheduled_at' }, { key: 'ZERO_SITE_URL_BASE', default: '', description: 'для inline-кнопки "Открыть на сайте"' }, + { key: 'ZERO_PUBLIC_BASE_URL', default: 'https://zeropost.ru', description: 'база URL для картинок (Telegram скачает по этому URL)' }, ]; router.get('/config', async (req, res) => { diff --git a/src/services/zeroCharacter.js b/src/services/zeroCharacter.js index dffb6bb..9b3073f 100644 --- a/src/services/zeroCharacter.js +++ b/src/services/zeroCharacter.js @@ -1,58 +1,36 @@ // Маппинг постов на иллюстрации с персонажем Зеро. -// 15 поз хранятся как /var/www/zeropost-uploads/zero-{name}.webp -// -// Логика выбора: -// 1. Если в title/excerpt есть triggers — берём соответствующую эмоциональную/активную позу -// 2. Иначе — берём позу по категории -// 3. Если в локации файла нет — fallback на 'avatar' +// Позы хранятся как /uploads/zero-{name}.webp и сервируются отдельным nginx +// (zeropost-uploads-server). Engine их по файловой системе не видит — общается +// с ними только через публичный URL. Поэтому "доступность" позы определяется +// статическим списком AVAILABLE_POSES; добавил новую позу — добавь в список. -const fs = require('fs'); -const path = require('path'); +// Полный список поз, доступных в /uploads/zero-{name}.webp на проде. +// Источник: ls на uploads-server. Если добавляешь — синкни с этим списком. +const AVAILABLE_POSES = new Set([ + 'avatar', 'bug', 'chart', 'coding', 'coffee', 'confused', 'eureka', 'facepalm', + 'gears', 'lock', 'magnifier', 'meditate', 'present', 'reading', 'rocket', + 'sleep', 'swimming', 'telescope', 'thinking', 'thumbsup', 'tired', 'tools', 'victory', +]); -const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads'; - -// Эмоциональные/активные позы — выбираются по ключевым словам в title/excerpt. -// Порядок важен: первое срабатывание побеждает. +// Эмоциональные/активные позы — выбираются по ключевым словам в title/excerpt/content. const EMOTIONAL_TRIGGERS = [ - // "Получилось / заработало / победа" → victory { pose: 'victory', words: ['получилось', 'заработало', 'победа', 'отличный результат', 'удалось', 'успех'] }, - - // "Не работает / сломалось / провал" → facepalm { pose: 'facepalm', words: ['не работает', 'сломал', 'ошибк', 'провал', 'факап', 'fail', 'баг', 'неудач', 'облажал'] }, - - // "Нашёл / открыл / классный" → eureka { pose: 'eureka', words: ['нашёл', 'нашел', 'открыл', 'классн', 'крутая фича', 'интересн', 'wow', 'неожиданн'] }, - - // "Запутался / непонятно / разбираемся" → confused { pose: 'confused', words: ['запутал', 'непонятно', 'разбира', 'разобрат', 'странн', 'не пойму', 'почему'] }, - - // "Устал / долго / ночь" → tired { pose: 'tired', words: ['устал', 'долго', 'часами', 'ночь', 'утром понял', 'выгорел'] }, - - // "Изучаю / разбор / гайд / шпаргалка" → reading или present { pose: 'reading', words: ['изуча', 'разбор', 'шпаргалк', 'гайд', 'мануал', 'документац'] }, { pose: 'present', words: ['как сделать', 'туториал', 'инструкц', 'объясн', 'показыва', 'учимся'] }, - - // "Расследую / разбираю / копаю" → magnifier { pose: 'magnifier', words: ['расследова', 'разбираю', 'копа', 'докопат', 'под капот', 'как устроен'] }, - - // "Аналитика / метрики / графики" → chart { pose: 'chart', words: ['метрик', 'аналитик', 'график', 'статистик', 'цифр', 'данные показ', 'результат за'] }, - // "Запуск / деплой" → rocket { pose: 'rocket', words: ['деплой', 'запустил', 'релиз', 'в продакш', 'залил', 'выкатил', 'запуск проект'] }, - // "Баг / отладка" → bug { pose: 'bug', words: ['баг', 'ошибк', 'дебаг', 'отлаживал', 'починил', 'не работало'] }, - // "Рекомендация / топ" → thumbsup { pose: 'thumbsup', words: ['рекомендую', 'советую', 'топ-', 'лучший', 'отличный инструмент', 'понравилось'] }, - // "Плавание / спорт" → swimming { pose: 'swimming', words: ['плавани', 'бассейн', 'плыть', 'тренировк', 'спортивн'] }, - // "Думаю / вопрос" → thinking { pose: 'thinking', words: ['думаю', 'размышляю', 'не знаю точно', 'интересный вопрос', 'а что если'] }, - // "Исследование" → telescope - { pose: 'telescope', words: ['исследова', 'изучаю', 'смотрю внимательно', 'нашёл интересн', 'открытие'] }, - - // "Подумать / поразмышлять / медитация" → meditate + { pose: 'telescope', words: ['исследова', 'смотрю внимательно', 'нашёл интересн', 'открытие'] }, { pose: 'meditate', words: ['подумать', 'размышл', 'осмысл', 'мысли вслух', 'рефлекс'] }, + { pose: 'sleep', words: ['засыпа', 'спать', 'отдых', 'выходной', 'утро понедельник'] }, ]; // Категорийные позы — fallback если эмоциональных триггеров не нашлось @@ -63,62 +41,49 @@ const CATEGORY_POSES = { 'ai-dev': 'coding', }; -const FALLBACK_POSE = 'avatar'; +const FALLBACK_POSE = 'coffee'; // 'coffee' — наш дефолтный кофейный Зеро (лучше чем avatar в большинстве случаев) /** * Выбирает имя позы Зеро под пост. - * @param {{ title?: string, excerpt?: string, category?: string }} ctx - * @returns {{ pose: string, path: string|null, exists: boolean }} + * @param {{ title?: string, excerpt?: string, category?: string, content?: string }} ctx + * @returns {{ pose: string, source: string }} */ -function pickPose({ title = '', excerpt = '', category = '' }) { - const haystack = `${title} ${excerpt}`.toLowerCase(); +function pickPose({ title = '', excerpt = '', category = '', content = '' }) { + const haystack = `${title} ${excerpt} ${content}`.toLowerCase(); // 1. Эмоциональные триггеры for (const t of EMOTIONAL_TRIGGERS) { for (const w of t.words) { if (haystack.includes(w)) { - return resolve(t.pose, 'emotional'); + return safe(t.pose, 'emotional'); } } } // 2. По категории const catPose = CATEGORY_POSES[category]; - if (catPose) return resolve(catPose, 'category'); + if (catPose) return safe(catPose, 'category'); // 3. Fallback - return resolve(FALLBACK_POSE, 'fallback'); + return safe(FALLBACK_POSE, 'fallback'); } -function resolve(name, source) { - const localPath = path.join(UPLOADS_DIR, `zero-${name}.webp`); - const exists = fs.existsSync(localPath); - // Если позы нет — пробуем avatar - if (!exists && name !== FALLBACK_POSE) { - const fbPath = path.join(UPLOADS_DIR, `zero-${FALLBACK_POSE}.webp`); - if (fs.existsSync(fbPath)) { - return { pose: FALLBACK_POSE, path: fbPath, exists: true, source: `${source}-fallback` }; - } - return { pose: name, path: null, exists: false, source }; +function safe(pose, source) { + // Если позы нет в списке доступных — откатываемся на coffee + if (!AVAILABLE_POSES.has(pose)) { + return { pose: FALLBACK_POSE, source: `${source}-fallback(${pose})` }; } - return { pose: name, path: exists ? localPath : null, exists, source }; + return { pose, source }; } /** * Список доступных поз (для UI). */ function listAvailablePoses() { - const out = []; - for (const name of [ - 'avatar', 'coding', 'tools', 'lock', 'gears', - 'eureka', 'confused', 'facepalm', 'victory', 'tired', - 'reading', 'magnifier', 'chart', 'meditate', 'present', - 'swimming', 'thinking', 'coffee', 'telescope', 'rocket', 'bug', 'sleep', 'thumbsup', - ]) { - const p = path.join(UPLOADS_DIR, `zero-${name}.webp`); - out.push({ name, exists: fs.existsSync(p), path: p, url: `/uploads/zero-${name}.webp` }); - } - return out; + return [...AVAILABLE_POSES].sort().map(name => ({ + name, + url: `/uploads/zero-${name}.webp`, + })); } -module.exports = { pickPose, listAvailablePoses, CATEGORY_POSES, EMOTIONAL_TRIGGERS }; +module.exports = { pickPose, listAvailablePoses, AVAILABLE_POSES, CATEGORY_POSES, EMOTIONAL_TRIGGERS }; diff --git a/src/services/zeroNotes.js b/src/services/zeroNotes.js index 356498b..33e225d 100644 --- a/src/services/zeroNotes.js +++ b/src/services/zeroNotes.js @@ -176,7 +176,7 @@ async function generateDraft(channelId, opts = {}) { excerpt: content.slice(0, 400), category: 'ai-tools', }); - const imageUrl = pose.exists ? `/uploads/zero-${pose.pose}.webp` : null; + const imageUrl = `/uploads/zero-${pose.pose}.webp`; // 7. theme_hash для дедупа const themeHash = zPrompt.normalizeTheme(prompt.themeHint); diff --git a/src/services/zeroNotesRunner.js b/src/services/zeroNotesRunner.js index 0fd35b2..83b8744 100644 --- a/src/services/zeroNotesRunner.js +++ b/src/services/zeroNotesRunner.js @@ -16,29 +16,25 @@ */ 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 UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads'; +// Engine-контейнер не имеет монтированных uploads — отправляем фото в Telegram +// по публичному URL (Telegram сам скачивает). Базу URL берём из app_settings, +// дефолт https://zeropost.ru. Файлы лежат на отдельном nginx и публично сервятся. const MAX_ATTEMPTS = 3; const TG_CAPTION_LIMIT = 1024; const TG_MESSAGE_LIMIT = 4096; /** - * Безопасная локальная резолюция картинки позы. Возвращает абсолютный путь или null. + * Превращает relative image_url (/uploads/zero-X.webp) в полный публичный URL + * который Telegram сможет скачать. Если image_url уже абсолютный — возвращает как есть. */ -function resolvePosePath(imageUrl) { +async function buildPhotoUrl(imageUrl) { if (!imageUrl) return null; - let pathname = imageUrl; - try { pathname = new URL(imageUrl).pathname; } catch { /* relative path */ } - 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; + if (/^https?:\/\//i.test(imageUrl)) return imageUrl; + const base = await settings.get('ZERO_PUBLIC_BASE_URL', 'https://zeropost.ru'); + return `${base.replace(/\/$/, '')}${imageUrl.startsWith('/') ? '' : '/'}${imageUrl}`; } /** @@ -87,21 +83,16 @@ async function sendToTelegram(note, channel) { 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); + const photoUrl = await buildPhotoUrl(note.image_url); - // sendPhoto если есть локальная поза и текст влезает в caption - 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, - }); + // sendPhoto если есть URL картинки и текст влезает в caption (1024) + if (photoUrl && note.content.length <= TG_CAPTION_LIMIT) { + const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendPhoto`, { + chat_id: channel.tg_channel_id, + photo: photoUrl, + caption: note.content, + reply_markup, + }, { timeout: 30_000 }); return res.data?.result?.message_id; }