forked from admin/zeropost-engine
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:
@@ -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);
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user