fix(zero): URL-mode photo sending + static AVAILABLE_POSES (no fs check)
Problem: engine container has no mount to /var/www/zeropost-uploads/, so
fs.existsSync() always returned false. This made image_url=null for every
Zero draft, and the TG runner fell back to sendMessage (text-only, no avatar).
Fix:
- zeroCharacter.js: drop fs.existsSync entirely; AVAILABLE_POSES is now a
static Set synced with what's actually on uploads-server (23 poses
including avatar/eureka/lock/gears/etc. — earlier I incorrectly thought
there were only 10). FALLBACK_POSE = 'coffee' (more on-brand than avatar).
- zeroNotes.js: image_url unconditionally '/uploads/zero-{pose}.webp';
pickPose now also looks at the generated content (not only theme).
- zeroNotesRunner.js: sendPhoto by URL (Telegram fetches the image itself).
No more form-data/createReadStream — full URL composed from
ZERO_PUBLIC_BASE_URL setting (default https://zeropost.ru).
- zeroAdmin.js: ZERO_PUBLIC_BASE_URL added to CONFIG_KEYS so it's editable
via /admin/zero settings panel.
Existing draft #1 backfilled via SQL UPDATE (pose=eureka → image_url set).
This commit is contained in:
@@ -228,6 +228,7 @@ const CONFIG_KEYS = [
|
|||||||
{ key: 'ZERO_NOTES_APPROVE_HOUR', default: '7', description: 'час авто-одобрения в МСК (0-23)' },
|
{ key: 'ZERO_NOTES_APPROVE_HOUR', default: '7', description: 'час авто-одобрения в МСК (0-23)' },
|
||||||
{ key: 'ZERO_NOTES_PUBLISH_HOUR', default: '13', description: 'час публикации в МСК (0-23) — определяет scheduled_at' },
|
{ key: 'ZERO_NOTES_PUBLISH_HOUR', default: '13', description: 'час публикации в МСК (0-23) — определяет scheduled_at' },
|
||||||
{ key: 'ZERO_SITE_URL_BASE', default: '', description: 'для inline-кнопки "Открыть на сайте"' },
|
{ 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) => {
|
router.get('/config', async (req, res) => {
|
||||||
|
|||||||
@@ -1,58 +1,36 @@
|
|||||||
// Маппинг постов на иллюстрации с персонажем Зеро.
|
// Маппинг постов на иллюстрации с персонажем Зеро.
|
||||||
// 15 поз хранятся как /var/www/zeropost-uploads/zero-{name}.webp
|
// Позы хранятся как /uploads/zero-{name}.webp и сервируются отдельным nginx
|
||||||
//
|
// (zeropost-uploads-server). Engine их по файловой системе не видит — общается
|
||||||
// Логика выбора:
|
// с ними только через публичный URL. Поэтому "доступность" позы определяется
|
||||||
// 1. Если в title/excerpt есть triggers — берём соответствующую эмоциональную/активную позу
|
// статическим списком AVAILABLE_POSES; добавил новую позу — добавь в список.
|
||||||
// 2. Иначе — берём позу по категории
|
|
||||||
// 3. Если в локации файла нет — fallback на 'avatar'
|
|
||||||
|
|
||||||
const fs = require('fs');
|
// Полный список поз, доступных в /uploads/zero-{name}.webp на проде.
|
||||||
const path = require('path');
|
// Источник: 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/content.
|
||||||
|
|
||||||
// Эмоциональные/активные позы — выбираются по ключевым словам в title/excerpt.
|
|
||||||
// Порядок важен: первое срабатывание побеждает.
|
|
||||||
const EMOTIONAL_TRIGGERS = [
|
const EMOTIONAL_TRIGGERS = [
|
||||||
// "Получилось / заработало / победа" → victory
|
|
||||||
{ pose: 'victory', words: ['получилось', 'заработало', 'победа', 'отличный результат', 'удалось', 'успех'] },
|
{ pose: 'victory', words: ['получилось', 'заработало', 'победа', 'отличный результат', 'удалось', 'успех'] },
|
||||||
|
|
||||||
// "Не работает / сломалось / провал" → facepalm
|
|
||||||
{ pose: 'facepalm', words: ['не работает', 'сломал', 'ошибк', 'провал', 'факап', 'fail', 'баг', 'неудач', 'облажал'] },
|
{ pose: 'facepalm', words: ['не работает', 'сломал', 'ошибк', 'провал', 'факап', 'fail', 'баг', 'неудач', 'облажал'] },
|
||||||
|
|
||||||
// "Нашёл / открыл / классный" → eureka
|
|
||||||
{ pose: 'eureka', words: ['нашёл', 'нашел', 'открыл', 'классн', 'крутая фича', 'интересн', 'wow', 'неожиданн'] },
|
{ pose: 'eureka', words: ['нашёл', 'нашел', 'открыл', 'классн', 'крутая фича', 'интересн', 'wow', 'неожиданн'] },
|
||||||
|
|
||||||
// "Запутался / непонятно / разбираемся" → confused
|
|
||||||
{ pose: 'confused', words: ['запутал', 'непонятно', 'разбира', 'разобрат', 'странн', 'не пойму', 'почему'] },
|
{ pose: 'confused', words: ['запутал', 'непонятно', 'разбира', 'разобрат', 'странн', 'не пойму', 'почему'] },
|
||||||
|
|
||||||
// "Устал / долго / ночь" → tired
|
|
||||||
{ pose: 'tired', words: ['устал', 'долго', 'часами', 'ночь', 'утром понял', 'выгорел'] },
|
{ pose: 'tired', words: ['устал', 'долго', 'часами', 'ночь', 'утром понял', 'выгорел'] },
|
||||||
|
|
||||||
// "Изучаю / разбор / гайд / шпаргалка" → reading или present
|
|
||||||
{ pose: 'reading', words: ['изуча', 'разбор', 'шпаргалк', 'гайд', 'мануал', 'документац'] },
|
{ pose: 'reading', words: ['изуча', 'разбор', 'шпаргалк', 'гайд', 'мануал', 'документац'] },
|
||||||
{ pose: 'present', words: ['как сделать', 'туториал', 'инструкц', 'объясн', 'показыва', 'учимся'] },
|
{ pose: 'present', words: ['как сделать', 'туториал', 'инструкц', 'объясн', 'показыва', 'учимся'] },
|
||||||
|
|
||||||
// "Расследую / разбираю / копаю" → magnifier
|
|
||||||
{ pose: 'magnifier', words: ['расследова', 'разбираю', 'копа', 'докопат', 'под капот', 'как устроен'] },
|
{ pose: 'magnifier', words: ['расследова', 'разбираю', 'копа', 'докопат', 'под капот', 'как устроен'] },
|
||||||
|
|
||||||
// "Аналитика / метрики / графики" → chart
|
|
||||||
{ pose: 'chart', words: ['метрик', 'аналитик', 'график', 'статистик', 'цифр', 'данные показ', 'результат за'] },
|
{ pose: 'chart', words: ['метрик', 'аналитик', 'график', 'статистик', 'цифр', 'данные показ', 'результат за'] },
|
||||||
// "Запуск / деплой" → rocket
|
|
||||||
{ pose: 'rocket', words: ['деплой', 'запустил', 'релиз', 'в продакш', 'залил', 'выкатил', 'запуск проект'] },
|
{ pose: 'rocket', words: ['деплой', 'запустил', 'релиз', 'в продакш', 'залил', 'выкатил', 'запуск проект'] },
|
||||||
// "Баг / отладка" → bug
|
|
||||||
{ pose: 'bug', words: ['баг', 'ошибк', 'дебаг', 'отлаживал', 'починил', 'не работало'] },
|
{ pose: 'bug', words: ['баг', 'ошибк', 'дебаг', 'отлаживал', 'починил', 'не работало'] },
|
||||||
// "Рекомендация / топ" → thumbsup
|
|
||||||
{ pose: 'thumbsup', words: ['рекомендую', 'советую', 'топ-', 'лучший', 'отличный инструмент', 'понравилось'] },
|
{ pose: 'thumbsup', words: ['рекомендую', 'советую', 'топ-', 'лучший', 'отличный инструмент', 'понравилось'] },
|
||||||
// "Плавание / спорт" → swimming
|
|
||||||
{ pose: 'swimming', words: ['плавани', 'бассейн', 'плыть', 'тренировк', 'спортивн'] },
|
{ pose: 'swimming', words: ['плавани', 'бассейн', 'плыть', 'тренировк', 'спортивн'] },
|
||||||
// "Думаю / вопрос" → thinking
|
|
||||||
{ pose: 'thinking', words: ['думаю', 'размышляю', 'не знаю точно', 'интересный вопрос', 'а что если'] },
|
{ pose: 'thinking', words: ['думаю', 'размышляю', 'не знаю точно', 'интересный вопрос', 'а что если'] },
|
||||||
// "Исследование" → telescope
|
{ pose: 'telescope', words: ['исследова', 'смотрю внимательно', 'нашёл интересн', 'открытие'] },
|
||||||
{ pose: 'telescope', words: ['исследова', 'изучаю', 'смотрю внимательно', 'нашёл интересн', 'открытие'] },
|
|
||||||
|
|
||||||
// "Подумать / поразмышлять / медитация" → meditate
|
|
||||||
{ pose: 'meditate', words: ['подумать', 'размышл', 'осмысл', 'мысли вслух', 'рефлекс'] },
|
{ pose: 'meditate', words: ['подумать', 'размышл', 'осмысл', 'мысли вслух', 'рефлекс'] },
|
||||||
|
{ pose: 'sleep', words: ['засыпа', 'спать', 'отдых', 'выходной', 'утро понедельник'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Категорийные позы — fallback если эмоциональных триггеров не нашлось
|
// Категорийные позы — fallback если эмоциональных триггеров не нашлось
|
||||||
@@ -63,62 +41,49 @@ const CATEGORY_POSES = {
|
|||||||
'ai-dev': 'coding',
|
'ai-dev': 'coding',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FALLBACK_POSE = 'avatar';
|
const FALLBACK_POSE = 'coffee'; // 'coffee' — наш дефолтный кофейный Зеро (лучше чем avatar в большинстве случаев)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Выбирает имя позы Зеро под пост.
|
* Выбирает имя позы Зеро под пост.
|
||||||
* @param {{ title?: string, excerpt?: string, category?: string }} ctx
|
* @param {{ title?: string, excerpt?: string, category?: string, content?: string }} ctx
|
||||||
* @returns {{ pose: string, path: string|null, exists: boolean }}
|
* @returns {{ pose: string, source: string }}
|
||||||
*/
|
*/
|
||||||
function pickPose({ title = '', excerpt = '', category = '' }) {
|
function pickPose({ title = '', excerpt = '', category = '', content = '' }) {
|
||||||
const haystack = `${title} ${excerpt}`.toLowerCase();
|
const haystack = `${title} ${excerpt} ${content}`.toLowerCase();
|
||||||
|
|
||||||
// 1. Эмоциональные триггеры
|
// 1. Эмоциональные триггеры
|
||||||
for (const t of EMOTIONAL_TRIGGERS) {
|
for (const t of EMOTIONAL_TRIGGERS) {
|
||||||
for (const w of t.words) {
|
for (const w of t.words) {
|
||||||
if (haystack.includes(w)) {
|
if (haystack.includes(w)) {
|
||||||
return resolve(t.pose, 'emotional');
|
return safe(t.pose, 'emotional');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. По категории
|
// 2. По категории
|
||||||
const catPose = CATEGORY_POSES[category];
|
const catPose = CATEGORY_POSES[category];
|
||||||
if (catPose) return resolve(catPose, 'category');
|
if (catPose) return safe(catPose, 'category');
|
||||||
|
|
||||||
// 3. Fallback
|
// 3. Fallback
|
||||||
return resolve(FALLBACK_POSE, 'fallback');
|
return safe(FALLBACK_POSE, 'fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(name, source) {
|
function safe(pose, source) {
|
||||||
const localPath = path.join(UPLOADS_DIR, `zero-${name}.webp`);
|
// Если позы нет в списке доступных — откатываемся на coffee
|
||||||
const exists = fs.existsSync(localPath);
|
if (!AVAILABLE_POSES.has(pose)) {
|
||||||
// Если позы нет — пробуем avatar
|
return { pose: FALLBACK_POSE, source: `${source}-fallback(${pose})` };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
return { pose: name, path: exists ? localPath : null, exists, source };
|
return { pose, source };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Список доступных поз (для UI).
|
* Список доступных поз (для UI).
|
||||||
*/
|
*/
|
||||||
function listAvailablePoses() {
|
function listAvailablePoses() {
|
||||||
const out = [];
|
return [...AVAILABLE_POSES].sort().map(name => ({
|
||||||
for (const name of [
|
name,
|
||||||
'avatar', 'coding', 'tools', 'lock', 'gears',
|
url: `/uploads/zero-${name}.webp`,
|
||||||
'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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { pickPose, listAvailablePoses, CATEGORY_POSES, EMOTIONAL_TRIGGERS };
|
module.exports = { pickPose, listAvailablePoses, AVAILABLE_POSES, CATEGORY_POSES, EMOTIONAL_TRIGGERS };
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ async function generateDraft(channelId, opts = {}) {
|
|||||||
excerpt: content.slice(0, 400),
|
excerpt: content.slice(0, 400),
|
||||||
category: 'ai-tools',
|
category: 'ai-tools',
|
||||||
});
|
});
|
||||||
const imageUrl = pose.exists ? `/uploads/zero-${pose.pose}.webp` : null;
|
const imageUrl = `/uploads/zero-${pose.pose}.webp`;
|
||||||
|
|
||||||
// 7. theme_hash для дедупа
|
// 7. theme_hash для дедупа
|
||||||
const themeHash = zPrompt.normalizeTheme(prompt.themeHint);
|
const themeHash = zPrompt.normalizeTheme(prompt.themeHint);
|
||||||
|
|||||||
@@ -16,29 +16,25 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const axios = require('axios');
|
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 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 MAX_ATTEMPTS = 3;
|
||||||
const TG_CAPTION_LIMIT = 1024;
|
const TG_CAPTION_LIMIT = 1024;
|
||||||
const TG_MESSAGE_LIMIT = 4096;
|
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;
|
if (!imageUrl) return null;
|
||||||
let pathname = imageUrl;
|
if (/^https?:\/\//i.test(imageUrl)) return imageUrl;
|
||||||
try { pathname = new URL(imageUrl).pathname; } catch { /* relative path */ }
|
const base = await settings.get('ZERO_PUBLIC_BASE_URL', 'https://zeropost.ru');
|
||||||
if (!pathname.startsWith('/uploads/')) return null;
|
return `${base.replace(/\/$/, '')}${imageUrl.startsWith('/') ? '' : '/'}${imageUrl}`;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,21 +83,16 @@ async function sendToTelegram(note, channel) {
|
|||||||
|
|
||||||
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
|
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);
|
const photoUrl = await buildPhotoUrl(note.image_url);
|
||||||
|
|
||||||
// sendPhoto если есть локальная поза и текст влезает в caption
|
// sendPhoto если есть URL картинки и текст влезает в caption (1024)
|
||||||
if (localPath && note.content.length <= TG_CAPTION_LIMIT) {
|
if (photoUrl && note.content.length <= TG_CAPTION_LIMIT) {
|
||||||
const form = new FormData();
|
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendPhoto`, {
|
||||||
form.append('chat_id', String(channel.tg_channel_id));
|
chat_id: channel.tg_channel_id,
|
||||||
form.append('caption', note.content);
|
photo: photoUrl,
|
||||||
if (reply_markup) form.append('reply_markup', JSON.stringify(reply_markup));
|
caption: note.content,
|
||||||
form.append('photo', fs.createReadStream(localPath));
|
reply_markup,
|
||||||
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendPhoto`, form, {
|
}, { timeout: 30_000 });
|
||||||
headers: form.getHeaders(),
|
|
||||||
timeout: 60_000,
|
|
||||||
maxContentLength: Infinity,
|
|
||||||
maxBodyLength: Infinity,
|
|
||||||
});
|
|
||||||
return res.data?.result?.message_id;
|
return res.data?.result?.message_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user