feat: Зеро-персонаж, auto-publish, auto-series, channel-stats, fallback covers

- Персонаж Зеро: 23 позы (zeroCharacter.js), скрипты генерации
- Auto-publish статей в TG: multipart upload, кнопки, режим alternating Zero/cover
- Fallback цепочка обложек: aiprimetech gpt-5.5 → Pollinations → local SVG (6 палитр)
- Auto-series: Claude haiku определяет серию для каждой статьи автоматически
- Channel stats: подписчики, история, delta 24h/7d
- Photo-search: Yandex API, профили доменов, Redis лимиты
- Scheduled posts runner: backfill, preview, queue, cancel
- promptBuilder: author_persona Зеро, голос от первого лица
- Fixes: dollar-placeholder bugs в PATCH channels/autogen, listArticles фильтры
- AI model: gpt-5.5 для image generation
This commit is contained in:
Nik (Claude)
2026-06-07 14:03:56 +03:00
parent 8968eed3e0
commit a370b8f7d8
33 changed files with 2695 additions and 147 deletions
+140
View File
@@ -0,0 +1,140 @@
// Генерация 8 новых поз Зеро — расширенный набор.
// Запуск: cd /var/www/zeropost-engine && node scripts/generate-zero-poses-v2.js
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const ROOT = '/var/www/zeropost-engine';
process.chdir(ROOT);
require('dotenv').config({ path: path.join(ROOT, '.env') });
const config = require(path.join(ROOT, 'src/config'));
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
let sharp = null;
try { sharp = require('sharp'); } catch {}
const CHARACTER_BASE = `
Character: a small, friendly mascot named Zero.
Body: a soft rounded square shape (like a plump pixel or chubby tetris block).
Color: bright emerald green (#10b981) with slight gradient to teal, soft and matte.
Face: two simple round black dot eyes, a small mouth expressing the current emotion.
Style: clean modern vector illustration, flat design with soft shading, no outlines.
Background: warm off-white (#fafaf9) with subtle geometric shapes in light teal/emerald.
Composition: 1:1 square format with comfortable padding, character is the main focus.
Strictly: no text, no letters, no logos, no humans, no realistic robots, no circuits.
`.trim();
const NEW_POSES = [
{
name: 'swimming',
desc: 'Zero is swimming or floating happily in stylized blue water waves. Small stick arms doing a swim stroke, surrounded by abstract blue and teal wave shapes. Big happy smile, eyes bright. Playful aquatic mood.',
},
{
name: 'thinking',
desc: 'Zero sits quietly, looking thoughtfully into the distance. One small stick arm raised slightly touching chin area in a thinking gesture. Eyes are slightly narrowed and upward-looking. Calm, contemplative expression. A small thought-bubble or single floating dot above.',
},
{
name: 'coffee',
desc: 'Zero holds an oversized steaming coffee mug (the mug is almost as big as Zero itself) with both stick arms. Cozy content smile, eyes half-closed in morning satisfaction. Steam rising in decorative curls. Warm amber and cream color accents from the mug.',
},
{
name: 'telescope',
desc: 'Zero peers through a small stylized telescope (geometric, teal colored). One eye closed, one looking through the eyepiece. Expression of curiosity and discovery. Stars or geometric star shapes floating in background.',
},
{
name: 'rocket',
desc: 'Zero sits on top of or rides a small stylized rocket (geometric, emerald and white colored). Both stick arms raised in excitement. Big grin, eyes wide with excitement. Abstract motion lines and geometric stars around. Launch/deploy energy.',
},
{
name: 'bug',
desc: 'Zero stands next to a large stylized bug (geometric beetle shape, in a contrasting color like amber or red). Zero has a detective expression — slightly furrowed brow, focused eyes, one stick arm pointing at the bug. Bug-hunting / debugging moment.',
},
{
name: 'sleep',
desc: 'Zero is sleeping peacefully. Eyes closed (shown as two curved lines). Small content smile. Floating ZZZ letters nearby (styled as geometric shapes, not text). A small pillow or star shape underneath. Soft, calm, night-time mood with dark blue accents.',
},
{
name: 'thumbsup',
desc: 'Zero gives an enthusiastic thumbs up with one raised stick arm (the thumb is stylized as a geometric shape). Big confident smile, eyes bright and happy. Possibly a small star or sparkle nearby. Approval, recommendation, "great choice" energy.',
},
];
const ATTEMPTS = 8;
const LOG = '/tmp/zero-poses-v2.log';
function log(msg) {
const line = `[${new Date().toISOString().slice(11,19)}] ${msg}`;
fs.appendFileSync(LOG, line + '\n');
console.log(line);
}
async function generateOne(prompt, attempt = 1) {
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.5';
const wrapped = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${prompt}`;
try {
const res = await axios.post(
`${config.ai.baseUrl}/responses`,
{ model, input: wrapped, tools: [{ type: 'image_generation' }], tool_choice: { type: 'image_generation' } },
{ headers: { Authorization: `Bearer ${config.ai.imageApiKey}` }, timeout: 300_000 }
);
const output = res.data?.output || [];
const imgCall = output.find(o => o.type === 'image_generation_call');
if (!imgCall || !imgCall.result) {
if (attempt < ATTEMPTS) {
log(` retry ${attempt+1}/${ATTEMPTS} (no image)`);
return generateOne(prompt, attempt + 1);
}
throw new Error('No image after ' + ATTEMPTS + ' attempts');
}
return Buffer.from(imgCall.result, 'base64');
} catch (err) {
if (attempt < ATTEMPTS) {
const msg = err.response?.data?.error?.message || err.message;
log(` retry ${attempt+1}/${ATTEMPTS} (${msg.slice(0,50)})`);
return generateOne(prompt, attempt + 1);
}
throw err;
}
}
async function saveBytes(bytes, name) {
const outPath = path.join(UPLOADS_DIR, `zero-${name}.webp`);
if (sharp) {
await sharp(bytes).resize(1024, 1024, { fit: 'cover' }).webp({ quality: 88 }).toFile(outPath);
} else {
fs.writeFileSync(outPath, bytes);
}
return outPath;
}
(async () => {
fs.writeFileSync(LOG, '');
log(`=== generating ${NEW_POSES.length} new Zero poses ===`);
const done = [], failed = [];
for (const p of NEW_POSES) {
const outPath = path.join(UPLOADS_DIR, `zero-${p.name}.webp`);
if (fs.existsSync(outPath)) {
log(`[${p.name}] already exists, skip`);
done.push(p.name);
continue;
}
log(`[${p.name}] starting...`);
const t = Date.now();
try {
const bytes = await generateOne(`${CHARACTER_BASE}\n\nPose: ${p.desc}`);
const out = await saveBytes(bytes, p.name);
const elapsed = ((Date.now() - t) / 1000).toFixed(0);
log(`[${p.name}] ✅ saved (${elapsed}s) → ${out}`);
done.push(p.name);
} catch (err) {
log(`[${p.name}] ❌ FAILED: ${err.message.slice(0, 100)}`);
failed.push(p.name);
}
}
log(`=== DONE: ${done.length} ok, ${failed.length} failed ===`);
if (failed.length) log('Failed: ' + failed.join(', '));
process.exit(0);
})();
+151
View File
@@ -0,0 +1,151 @@
// Генерирует полный набор поз персонажа Зеро.
// Картинки сохраняются в /var/www/zeropost-uploads/zero-{name}.webp (без timestamp — фиксированные имена)
// чтобы engine мог потом выбирать по имени.
//
// Запуск: cd /var/www/zeropost-engine && node scripts/generate-zero-poses.js
// Прогресс пишется в /tmp/zero-poses.log
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const ROOT = '/var/www/zeropost-engine';
process.chdir(ROOT);
require('dotenv').config({ path: path.join(ROOT, '.env') });
const config = require(path.join(ROOT, 'src/config'));
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
let sharp = null;
try { sharp = require('sharp'); } catch {}
// Базовое описание персонажа — повторяется в каждом промпте.
const CHARACTER_BASE = `
Character: a small, friendly mascot named Zero.
Body: a soft rounded square shape (like a plump pixel or chubby tetris block).
Color: bright emerald green (#10b981) with slight gradient to teal, soft and matte.
Face: two simple round black dot eyes, a small mouth expressing the current emotion.
Personality: friendly, curious, enthusiastic, a bit nerdy.
Style: clean modern vector illustration, flat design with soft shading, no outlines, consistent with previous Zero illustrations.
Background: warm off-white (#fafaf9) with subtle geometric shapes in light teal/emerald, like Notion/Linear/Anthropic editorial illustrations.
Composition: 1:1 square format with comfortable padding, character is the main focus.
Strictly: no text, no letters, no logos, no humans, no realistic robots, no glowing nodes, no circuits.
`.trim();
const POSES = [
// Категорийные
{ name: 'tools', desc: 'Zero stands next to a small open toolbox filled with stylized abstract tools (wrench, paintbrush, screwdriver shapes — all geometric). Curious smile, leaning slightly forward. Represents the AI Tools category.' },
{ name: 'lock', desc: 'Zero stands next to a large stylized padlock (closed, in soft red/coral accent color). Serious focused expression, alert eyes. Small shield shape floats nearby. Represents the cybersecurity category.' },
{ name: 'gears', desc: 'Zero next to two large interlocking gears that turn together (gears in soft amber/cream color). Content smile, slight tilt of body, gears form a small mechanical system. Represents the automation category.' },
// Эмоциональные
{ name: 'eureka', desc: 'Zero has just discovered something exciting. One small stick arm raised in the air with a soft glowing dot/lightbulb shape above the head. Eyes wide and sparkly, big enthusiastic smile. Stylized geometric sparkles around.' },
{ name: 'confused', desc: 'Zero scratches the top of its head with one small stick arm. Eyes slightly squinted, mouth a small flat wavy line, tiny question marks floating to the side. Stuck on a problem.' },
{ name: 'facepalm', desc: 'Zero covers part of its face with one small stick arm. Eyes closed or downcast, mouth a flat resigned line. Slightly slumped posture. The moment when something goes wrong.' },
{ name: 'victory', desc: 'Zero jumps with both small stick arms raised triumphantly. Eyes shining bright, huge happy grin. Small geometric celebration confetti or rays around. It worked!' },
{ name: 'tired', desc: 'Zero sits on the floor holding a steaming coffee cup with both stick arms. Slightly droopy eyes, small content but tired smile. Maybe small zZz shape floating above. End of a long debugging session.' },
// Активности
{ name: 'reading', desc: 'Zero sits cross-legged reading an open book (geometric book shape in cream color). Eyes focused on the pages, peaceful happy expression, small reading-glasses shape floating optional. Quiet study moment.' },
{ name: 'magnifier', desc: 'Zero holds up a large magnifying glass with one stick arm, examining something carefully. One eye looks through the magnifier (enlarged through the lens, dot-eye gets bigger). Investigative curious expression.' },
{ name: 'chart', desc: 'Zero stands next to a small bar chart or upward line graph (geometric, emerald/teal bars). One stick arm raised pointing at the rising line. Pleased confident smile. Data-driven moment.' },
{ name: 'meditate', desc: 'Zero sits cross-legged in a meditation pose, eyes peacefully closed (small curved lines instead of dots), small content smile. Subtle aura/circle around it in soft teal. Reflective moment.' },
{ name: 'present', desc: 'Zero stands next to a small whiteboard/easel with simple abstract diagram (lines, dots, geometric shapes — no text or letters). One stick arm pointing at the board with a small pointer stick. Teaching pose, friendly explanatory expression.' },
];
const ATTEMPTS = 5;
const LOG_PATH = '/tmp/zero-poses.log';
function log(msg) {
const line = `[${new Date().toISOString()}] ${msg}\n`;
fs.appendFileSync(LOG_PATH, line);
console.log(msg);
}
async function generateOne(prompt, attempt = 1) {
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
const wrapped = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${prompt}`;
try {
const res = await axios.post(
`${config.ai.baseUrl}/responses`,
{
model, input: wrapped,
tools: [{ type: 'image_generation' }],
tool_choice: { type: 'image_generation' },
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 300_000,
}
);
const output = res.data?.output || [];
const imgCall = output.find(o => o.type === 'image_generation_call');
if (!imgCall || !imgCall.result) {
if (attempt < ATTEMPTS) {
log(` retry ${attempt + 1}/${ATTEMPTS} (no image_generation_call)`);
return generateOne(prompt, attempt + 1);
}
throw new Error(`No image after ${ATTEMPTS} attempts`);
}
return Buffer.from(imgCall.result, 'base64');
} catch (err) {
if (attempt < ATTEMPTS) {
const msg = err.response?.data?.error?.message || err.message;
log(` retry ${attempt + 1}/${ATTEMPTS} (${msg.slice(0, 60)})`);
return generateOne(prompt, attempt + 1);
}
throw err;
}
}
async function processAndSave(bytes, name) {
const outPath = path.join(UPLOADS_DIR, `zero-${name}.webp`);
if (sharp) {
await sharp(bytes)
.resize(1024, 1024, { fit: 'cover' })
.webp({ quality: 88 })
.toFile(outPath);
} else {
fs.writeFileSync(outPath, bytes);
}
return outPath;
}
(async () => {
fs.writeFileSync(LOG_PATH, '');
log(`[zero-poses] starting generation of ${POSES.length} poses`);
const startTime = Date.now();
const results = { done: [], failed: [] };
for (const p of POSES) {
// Skip if already exists
const expected = path.join(UPLOADS_DIR, `zero-${p.name}.webp`);
if (fs.existsSync(expected)) {
log(`[${p.name}] already exists, skipping`);
results.done.push({ name: p.name, path: expected, skipped: true });
continue;
}
const tStart = Date.now();
try {
log(`[${p.name}] starting...`);
const prompt = `${CHARACTER_BASE}\n\nPose: ${p.desc}`;
const bytes = await generateOne(prompt);
const out = await processAndSave(bytes, p.name);
const elapsed = ((Date.now() - tStart) / 1000).toFixed(0);
log(`[${p.name}] ✓ saved → ${out} (${elapsed}s)`);
results.done.push({ name: p.name, path: out });
} catch (err) {
const msg = err.response?.data?.error?.message || err.message;
log(`[${p.name}] ✗ FAILED: ${msg}`);
results.failed.push({ name: p.name, error: msg });
}
}
const totalMin = ((Date.now() - startTime) / 60000).toFixed(1);
log(`[zero-poses] DONE in ${totalMin}min. Done: ${results.done.length}/${POSES.length}, failed: ${results.failed.length}`);
fs.writeFileSync('/tmp/zero-poses-result.json', JSON.stringify(results, null, 2));
process.exit(0);
})().catch(e => {
log(`[zero-poses] FATAL: ${e.message}`);
process.exit(1);
});
+37
View File
@@ -0,0 +1,37 @@
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
process.chdir('/var/www/zeropost-engine');
require('dotenv').config({ path: '/var/www/zeropost-engine/.env' });
const settings = require('/var/www/zeropost-engine/src/services/settings');
const { query } = require('/var/www/zeropost-engine/src/config/db');
(async () => {
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=1`);
const channel = chs[0];
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
const files = [
'/var/www/zeropost-uploads/zero-avatar-1780332236389.webp',
'/var/www/zeropost-uploads/zero-laptop-1780332301106.webp',
];
const form = new FormData();
form.append('chat_id', String(channel.tg_channel_id));
const media = files.map((_, i) => ({
type: 'photo',
media: `attach://photo${i}`,
caption: i === 0 ? `🧪 Тест Зеро — 2 ракурса:\n1. аватар (анфас)\n2. за ноутбуком\n\n(третий не пошёл, шлюз поломался)\n\nКак тебе персонаж? Стоит дорабатывать или искать другой образ?` : undefined,
}));
form.append('media', JSON.stringify(media));
files.forEach((f, i) => form.append(`photo${i}`, fs.createReadStream(f)));
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendMediaGroup`, form, {
headers: form.getHeaders(),
timeout: 60000, maxContentLength: Infinity, maxBodyLength: Infinity,
});
const ids = (res.data?.result || []).map(m => m.message_id);
console.log('sent ids:', ids.join(','));
})().catch(e => console.error('FAIL:', e.response?.data || e.message));
+143
View File
@@ -0,0 +1,143 @@
// Одноразовый скрипт: генерирует обложку для welcome-поста и отправляет в TG.
// Запуск: node scripts/send-welcome-post.js
//
// Шлём файл через multipart напрямую — иначе TG прокси (CF Worker) не вытягивает
// файлы с zeropost.ru и валится с "Bad Request: failed to get HTTP URL content".
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
const ROOT = '/var/www/zeropost-engine';
process.chdir(ROOT);
require('dotenv').config({ path: path.join(ROOT, '.env') });
const config = require(path.join(ROOT, 'src/config'));
const settings = require(path.join(ROOT, 'src/services/settings'));
const { query } = require(path.join(ROOT, 'src/config/db'));
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
let sharp = null;
try { sharp = require('sharp'); } catch {}
const WELCOME_PROMPT = `Abstract editorial cover illustration for a technology blog about AI, cybersecurity, automation and development.
Style: flat geometric shapes, smooth flowing curves, layered planes, vector-clean lines. Modern editorial illustration.
Color palette: emerald green (#10b981, #34d399) as primary brand color, soft teal accents, warm off-white background (#fafaf9), subtle dark navy accents. Sophisticated and clean.
Mood: clean, modern, calm, intellectual, welcoming — Stripe Press / Linear / Anthropic brand aesthetic.
Composition: balanced asymmetry with four implied zones representing four content pillars, flowing diagonal forms, generous negative space, sense of intellectual depth and curiosity. Wide 16:9 format.
Strictly: no text, no letters, no logos, no human faces, no robots, no brains, no glowing nodes, no circuit boards, no clocks, no screens.`;
async function generateCover() {
console.log('[welcome] generating cover via /v1/responses + image_generation...');
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
const wrappedInput = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${WELCOME_PROMPT}`;
const res = await axios.post(
`${config.ai.baseUrl}/responses`,
{
model,
input: wrappedInput,
tools: [{ type: 'image_generation' }],
tool_choice: { type: 'image_generation' },
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 300_000,
}
);
const output = res.data?.output || [];
const imgCall = output.find(o => o.type === 'image_generation_call');
if (!imgCall || !imgCall.result) {
throw new Error('No image returned from /responses');
}
const bytes = Buffer.from(imgCall.result, 'base64');
console.log(`[welcome] got ${(bytes.length / 1024).toFixed(0)}KB image`);
const ts = Date.now();
const ext = imgCall.output_format || 'png';
const origName = `welcome-${ts}.${ext}`;
const origPath = path.join(UPLOADS_DIR, origName);
fs.writeFileSync(origPath, bytes);
let finalLocal = origPath;
if (sharp) {
try {
const webpName = `welcome-${ts}.webp`;
const webpPath = path.join(UPLOADS_DIR, webpName);
await sharp(bytes)
.resize(1600, null, { withoutEnlargement: true })
.webp({ quality: 86 })
.toFile(webpPath);
const size = fs.statSync(webpPath).size;
console.log(`[welcome] optimized → ${webpName} (${(size / 1024).toFixed(0)}KB)`);
finalLocal = webpPath;
} catch (e) {
console.warn(`[welcome] sharp skipped: ${e.message}`);
}
}
return { localPath: finalLocal };
}
const WELCOME_TEXT = `👋 Это *ZeroPost* — блог про ИИ, кибербезопасность, автоматизацию и разработку.
Эксперимент: контент пишет ИИ, а человек только держит курс. Получается технологический блог без воды и хайпа — разборы инструментов, рабочие промпты, реальные кейсы.
В канале — анонсы новых материалов с сайта. Каждая заметка отрабатывает один практический вопрос: «как сделать X в инструменте Y и не пожалеть».
🌐 Все материалы: [zeropost.ru](https://zeropost.ru)
📌 Закрепите этот пост, чтобы не потерять`;
async function sendToTelegram(localPath) {
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=1`);
if (!chs.length) throw new Error('Channel 1 not found');
const channel = chs[0];
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
const reply_markup = {
inline_keyboard: [[{ text: '🌐 Открыть сайт', url: 'https://zeropost.ru' }]],
};
console.log(`[welcome] uploading ${localPath} to TG via multipart...`);
const form = new FormData();
form.append('chat_id', String(channel.tg_channel_id));
form.append('caption', WELCOME_TEXT.slice(0, 1024));
form.append('parse_mode', 'Markdown');
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: 60000,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
const messageId = res.data?.result?.message_id;
console.log(`[welcome] sent, message_id=${messageId}`);
await query(
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
VALUES ($1,$2,'published',NOW(),$3)`,
[channel.id, WELCOME_TEXT, messageId]
);
return messageId;
}
(async () => {
try {
const { localPath } = await generateCover();
console.log(`[welcome] cover ready at ${localPath}`);
const messageId = await sendToTelegram(localPath);
console.log(`[welcome] DONE. TG message_id=${messageId}. Длина caption=${WELCOME_TEXT.length}`);
process.exit(0);
} catch (err) {
console.error('[welcome] FAILED:', err.response?.data || err.message);
process.exit(1);
}
})();
+63
View File
@@ -0,0 +1,63 @@
// Welcome-пост от имени Зеро.
// Использует готовый zero-avatar.webp.
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
process.chdir('/var/www/zeropost-engine');
require('dotenv').config({ path: '/var/www/zeropost-engine/.env' });
const settings = require('/var/www/zeropost-engine/src/services/settings');
const { query } = require('/var/www/zeropost-engine/src/config/db');
const TEXT = `Привет! Я Зеро 👋
Веду этот канал — пишу про ИИ, кибербезопасность, автоматизацию и разработку. Каждый день — короткая заметка про что-то, что я попробовал.
Что внутри:
🤖 ИИ-инструменты, которые реально работают
💻 Разработка с ИИ-помощниками
⚡ Автоматизация без боли
🔒 Безопасность для обычных людей
Без хайпа, без «революционных открытий», без картинок с роботами. Только то, что заходит в работе.
🌐 Полная версия каждой заметки — на сайте
📌 Закрепи этот пост, чтобы не потерять`;
(async () => {
try {
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=1`);
const channel = chs[0];
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
const localPath = '/var/www/zeropost-uploads/zero-avatar.webp';
if (!fs.existsSync(localPath)) throw new Error('zero-avatar.webp not found');
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');
form.append('reply_markup', JSON.stringify({
inline_keyboard: [[{ text: '🌐 Открыть сайт', url: 'https://zeropost.ru' }]],
}));
form.append('photo', fs.createReadStream(localPath));
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendPhoto`, form, {
headers: form.getHeaders(),
timeout: 60000, maxContentLength: Infinity, maxBodyLength: Infinity,
});
const messageId = res.data?.result?.message_id;
console.log(`sent message_id=${messageId}, длина caption=${TEXT.length}`);
await query(
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
VALUES ($1,$2,'published',NOW(),$3)`,
[channel.id, TEXT, messageId]
);
} catch (err) {
console.error('FAIL:', err.response?.data || err.message);
process.exit(1);
}
})();
+28
View File
@@ -0,0 +1,28 @@
const path = require('path');
process.chdir('/var/www/zeropost-engine');
require('dotenv').config({ path: '/var/www/zeropost-engine/.env' });
const axios = require('axios');
const config = require('/var/www/zeropost-engine/src/config');
(async () => {
const prompt = `A small friendly emerald green geometric mascot character, soft rounded square shape, two simple black dot eyes, small smile, flat vector illustration style, warm off-white background. No text.`;
const wrapped = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${prompt}`;
try {
const res = await axios.post(
`${config.ai.baseUrl}/responses`,
{
model: process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2',
input: wrapped,
tools: [{ type: 'image_generation' }],
tool_choice: { type: 'image_generation' },
},
{ headers: { Authorization: `Bearer ${config.ai.imageApiKey}` }, timeout: 300_000 }
);
console.log('output types:', (res.data?.output || []).map(o => o.type));
const imgCall = (res.data?.output || []).find(o => o.type === 'image_generation_call');
if (imgCall) console.log('status:', imgCall.status, 'has result:', !!imgCall.result);
else console.log('full:', JSON.stringify(res.data).slice(0, 500));
} catch (e) {
console.error('FAIL:', e.response?.data || e.message);
}
})();
+147
View File
@@ -0,0 +1,147 @@
// Тест: генерируем 3 ракурса Зеро. Каждая картинка — до 5 попыток (GPT-5.2 капризничает с tool_choice).
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const FormData = require('form-data');
const ROOT = '/var/www/zeropost-engine';
process.chdir(ROOT);
require('dotenv').config({ path: path.join(ROOT, '.env') });
const config = require(path.join(ROOT, 'src/config'));
const settings = require(path.join(ROOT, 'src/services/settings'));
const { query } = require(path.join(ROOT, 'src/config/db'));
const UPLOADS_DIR = process.env.UPLOADS_DIR || '/var/www/zeropost-uploads';
let sharp = null;
try { sharp = require('sharp'); } catch {}
// Базовое описание персонажа — повторяется в каждом промпте чтобы сохранять консистентность.
const CHARACTER_BASE = `
Character: a small, friendly mascot named Zero.
Body: a soft rounded square shape (like a plump pixel or a chubby tetris block), about the size of a small companion creature.
Color: bright emerald green (#10b981) with a slight gradient to teal, soft and matte.
Face: two simple round black dot eyes, a small understated mouth that expresses the current emotion.
Personality: friendly, curious, enthusiastic, a bit nerdy.
Style: clean modern vector illustration, flat design with soft shading, no outlines.
Background: warm off-white (#fafaf9) with subtle geometric shapes in light teal/emerald, like Notion/Linear/Anthropic editorial illustrations.
Composition: 1:1 square format with comfortable padding, character is the main focus.
Strictly: no text, no letters, no logos, no humans, no realistic robots, no glowing nodes, no circuits.
`.trim();
const POSES = [
{
name: 'avatar',
desc: `Front-facing portrait pose. Zero is looking directly at the viewer with curious wide-open eyes and a small confident smile. Sits centered in the frame. Used as channel avatar.`,
},
{
name: 'laptop',
desc: `Zero sits at a tiny laptop (the laptop is also stylized geometric, in cream/emerald colors). Eyes focused on the screen, small thoughtful expression. Cozy setup with a small plant nearby. The mascot is enthusiastic about coding.`,
},
{
name: 'eureka',
desc: `Zero has just discovered something exciting. One small stick arm raised in the air with a soft glowing dot above the head representing an idea. Eyes wide and sparkly, big enthusiastic smile. Confetti or stylized geometric sparkles around.`,
},
];
async function generateOne(prompt, attempt = 1) {
const MAX_ATTEMPTS = 5;
const model = process.env.AI_MODEL_IMAGE_VIA_RESPONSES || 'gpt-5.2';
const wrapped = `Use the image_generation tool to create the following illustration. Do not write any text response, only call the tool.\n\n${prompt}`;
try {
const res = await axios.post(
`${config.ai.baseUrl}/responses`,
{
model, input: wrapped,
tools: [{ type: 'image_generation' }],
tool_choice: { type: 'image_generation' },
},
{
headers: { Authorization: `Bearer ${config.ai.imageApiKey}` },
timeout: 300_000,
}
);
const output = res.data?.output || [];
const imgCall = output.find(o => o.type === 'image_generation_call');
if (!imgCall || !imgCall.result) {
if (attempt < MAX_ATTEMPTS) {
console.log(`[zero] retry attempt=${attempt + 1} (no image_generation_call)`);
return generateOne(prompt, attempt + 1);
}
throw new Error(`No image after ${MAX_ATTEMPTS} attempts`);
}
return Buffer.from(imgCall.result, 'base64');
} catch (err) {
if (attempt < MAX_ATTEMPTS) {
const msg = err.response?.data?.error?.message || err.message;
console.log(`[zero] retry attempt=${attempt + 1} (${msg.slice(0, 60)})`);
return generateOne(prompt, attempt + 1);
}
throw err;
}
}
async function processBytes(bytes, name) {
const ts = Date.now();
const webpName = `zero-${name}-${ts}.webp`;
const webpPath = path.join(UPLOADS_DIR, webpName);
if (sharp) {
await sharp(bytes)
.resize(1024, 1024, { fit: 'cover' })
.webp({ quality: 88 })
.toFile(webpPath);
} else {
fs.writeFileSync(webpPath, bytes);
}
return webpPath;
}
(async () => {
try {
console.log('[zero] generating 3 poses sequentially (with retries)...');
const results = [];
for (const p of POSES) {
console.log(`[zero] starting ${p.name}...`);
const prompt = `${CHARACTER_BASE}\n\nPose: ${p.desc}`;
const bytes = await generateOne(prompt);
const localPath = await processBytes(bytes, p.name);
console.log(`[zero] ${p.name}${localPath}`);
results.push({ name: p.name, path: localPath });
}
// Шлём все 3 в TG как media group с подписью
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=1`);
const channel = chs[0];
const tgBase = await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org');
const form = new FormData();
form.append('chat_id', String(channel.tg_channel_id));
const media = results.map((r, i) => ({
type: 'photo',
media: `attach://photo${i}`,
caption: i === 0
? `🧪 Тест Зеро — три позы:\n1. аватар 2. за ноутом 3. эврика`
: undefined,
}));
form.append('media', JSON.stringify(media));
results.forEach((r, i) => {
form.append(`photo${i}`, fs.createReadStream(r.path));
});
const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendMediaGroup`, form, {
headers: form.getHeaders(),
timeout: 60000,
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
const msgIds = (res.data?.result || []).map(m => m.message_id);
console.log(`[zero] sent media group, message_ids=${msgIds.join(',')}`);
console.log('[zero] DONE');
process.exit(0);
} catch (err) {
console.error('[zero] FAILED:', err.response?.data || err.message);
process.exit(1);
}
})();