From 29788a8f9d5c64762b3ad9309930cf98dd3ce5ad Mon Sep 17 00:00:00 2001 From: Aleksei Pavlov Date: Fri, 19 Jun 2026 10:52:22 +0300 Subject: [PATCH] =?UTF-8?q?feat(zero):=20Zero=20notes=20=E2=80=94=20AI=20p?= =?UTF-8?q?ersona=20for=20@zeropostru=20daily=20posts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds character-driven AI-generated notes pipeline parallel to articles: - migration: zero_notes table (channel-scoped, status flow draft→approved→published) - services/zeroPrompt.js — 12 theme buckets, few-shots, anti-repeat by bucket, sha256 theme_hash for dedup - services/zeroNotes.js — generateDraft/approve/skip/edit/autoApproveOldDrafts - services/zeroNotesRunner.js — TG publication with multipart pose images, FOR UPDATE SKIP LOCKED claim, retry up to 3 attempts - workers/zeroNotesScheduler.js — 13:00 MSK generate, 07:00 MSK auto-approve, publish runner every 60s - routes/zero.js (public, no secret) — character bio + published notes for site - routes/zeroAdmin.js — full CRUD + manual generate button + regenerate Settings (app_settings): ZERO_NOTES_CHANNEL_IDS — csv int, channels to post for (required to enable) ZERO_NOTES_MODEL — defaults to AI_MODEL_POST ZERO_SITE_URL_BASE — optional, adds 'Open on site' inline button --- index.js | 9 + src/config/db.js | 34 ++++ src/routes/zero.js | 63 ++++++ src/routes/zeroAdmin.js | 209 +++++++++++++++++++ src/services/zeroNotes.js | 326 ++++++++++++++++++++++++++++++ src/services/zeroNotesRunner.js | 184 +++++++++++++++++ src/services/zeroPrompt.js | 321 +++++++++++++++++++++++++++++ src/workers/zeroNotesScheduler.js | 99 +++++++++ 8 files changed, 1245 insertions(+) create mode 100644 src/routes/zero.js create mode 100644 src/routes/zeroAdmin.js create mode 100644 src/services/zeroNotes.js create mode 100644 src/services/zeroNotesRunner.js create mode 100644 src/services/zeroPrompt.js create mode 100644 src/workers/zeroNotesScheduler.js diff --git a/index.js b/index.js index 79cc21d..1203eab 100644 --- a/index.js +++ b/index.js @@ -75,6 +75,9 @@ app.post('/api/billing/webhook', const inboxRoutes = require('./src/routes/inbox'); app.use('/api', inboxRoutes); // включает /api/tg-webhook/:channelId +// Заметки Зеро — публичная часть (для сайта zeropost.ru/zero) +app.use('/api/zero', require('./src/routes/zero')); + // Simple internal auth middleware app.use((req, res, next) => { const secret = req.headers['x-internal-secret']; @@ -126,6 +129,9 @@ app.use('/api/channels', require('./src/routes/polls')); app.use('/api', inboxRoutes); app.use('/api', require('./src/routes/drafts')); +// Заметки Зеро — админская часть (за internal-secret middleware) +app.use('/api/admin/zero', require('./src/routes/zeroAdmin')); + app.get('/health', (req, res) => { res.json({ ok: true, service: 'zeropost-engine', time: new Date() }); }); @@ -162,6 +168,9 @@ const start = async () => { // Первый запуск через 5 мин после старта setTimeout(() => draftSvc.generateDailyDrafts().catch(() => {}), 5 * 60 * 1000); + // Заметки Зеро — генерация в 13:00 МСК + авто-одобрение в 07:00 МСК + require('./src/workers/zeroNotesScheduler').start(); + app.listen(config.port, () => { console.log(`[Engine] Running on port ${config.port}`); }); diff --git a/src/config/db.js b/src/config/db.js index 8843af1..2194bc2 100644 --- a/src/config/db.js +++ b/src/config/db.js @@ -157,6 +157,36 @@ const migrate = async () => { ); `); + // zero_notes — короткие заметки от AI-персонажа "Зеро" + // Программист с юмором, любит кофе. Постит 1 раз в день в 13:00 МСК. + // Поток: 13:00 генерится → вечером ручная проверка → 07:00 auto-approve → 13:00 публикация + await query(` + CREATE TABLE IF NOT EXISTS zero_notes ( + id SERIAL PRIMARY KEY, + channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE, + content TEXT NOT NULL, -- 50-150 слов от первого лица + theme VARCHAR(500), -- о чём заметка (для логов и дедупа) + theme_bucket VARCHAR(50), -- ведро темы: bugs/tools/ai/coffee/musing/story/... + theme_hash VARCHAR(64), -- нормализованный хэш темы (sha256 первых 8 значимых слов) + pose VARCHAR(50), -- имя позы Зеро (zero-{pose}.webp) + image_url TEXT, -- /uploads/zero-{pose}.webp или внешний URL + status VARCHAR(20) DEFAULT 'draft', -- draft/approved/scheduled/published/failed/skipped + scheduled_at TIMESTAMPTZ, -- когда уйдёт в канал (13:00 МСК следующего дня) + approved_at TIMESTAMPTZ, + approved_by VARCHAR(100), -- 'auto' (07:00 cron) или email редактора + published_at TIMESTAMPTZ, + channel_message_id BIGINT, -- id сообщения в TG после публикации + tokens_in INTEGER, + tokens_out INTEGER, + model VARCHAR(100), -- какой моделью сгенерили + attempts INTEGER DEFAULT 0, -- сколько раз пытались опубликовать + error TEXT, + generation_meta JSONB DEFAULT '{}'::jsonb, -- доп. контекст: использованные триггеры позы, recentThemes hash и т.п. + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + // series — тематические серии статей await query(` CREATE TABLE IF NOT EXISTS series ( @@ -185,6 +215,10 @@ const migrate = async () => { CREATE INDEX IF NOT EXISTS idx_articles_status_pub ON articles(status, published_at DESC); CREATE INDEX IF NOT EXISTS idx_notes_pub ON editor_notes(is_published, created_at DESC); CREATE INDEX IF NOT EXISTS idx_series_slug ON series(slug); + CREATE INDEX IF NOT EXISTS idx_zero_notes_status_sched ON zero_notes(status, scheduled_at); + CREATE INDEX IF NOT EXISTS idx_zero_notes_channel ON zero_notes(channel_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_zero_notes_theme_hash ON zero_notes(theme_hash); + CREATE INDEX IF NOT EXISTS idx_zero_notes_published ON zero_notes(published_at DESC) WHERE status='published'; `); console.log('[DB] Migrations applied'); diff --git a/src/routes/zero.js b/src/routes/zero.js new file mode 100644 index 0000000..781d2bd --- /dev/null +++ b/src/routes/zero.js @@ -0,0 +1,63 @@ +/** + * routes/zero.js — публичные роуты для сайта zeropost.ru/zero + * Монтируется на /api/zero + * + * Без аутентификации — отдаём только published. + */ + +const express = require('express'); +const router = express.Router(); +const zeroNotes = require('../services/zeroNotes'); +const zPrompt = require('../services/zeroPrompt'); + +// GET /api/zero/notes?limit=20&offset=0&channel_id=1 +router.get('/notes', async (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit) || 20, 50); + const offset = parseInt(req.query.offset) || 0; + const channelId = req.query.channel_id ? parseInt(req.query.channel_id) : null; + const items = await zeroNotes.listPublished({ channelId, limit, offset }); + res.json({ ok: true, items, limit, offset }); + } catch (err) { + console.error('[GET /api/zero/notes]', err.message); + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/zero/notes/:id — одна published заметка +router.get('/notes/:id', async (req, res) => { + try { + const note = await zeroNotes.getById(parseInt(req.params.id)); + if (!note || note.status !== 'published') { + return res.status(404).json({ error: 'not found' }); + } + // Отдаём только публичные поля + res.json({ + ok: true, + note: { + id: note.id, + channel_id: note.channel_id, + content: note.content, + theme: note.theme, + theme_bucket: note.theme_bucket, + pose: note.pose, + image_url: note.image_url, + published_at: note.published_at, + channel_message_id: note.channel_message_id, + }, + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/zero/character — bio и описание для блока "Кто такой Зеро" +router.get('/character', async (_req, res) => { + res.json({ + ok: true, + character: zPrompt.CHARACTER, + buckets: zPrompt.THEME_BUCKETS.map(b => ({ key: b.key, label: b.label })), + }); +}); + +module.exports = router; diff --git a/src/routes/zeroAdmin.js b/src/routes/zeroAdmin.js new file mode 100644 index 0000000..25288a5 --- /dev/null +++ b/src/routes/zeroAdmin.js @@ -0,0 +1,209 @@ +/** + * routes/zeroAdmin.js — админские роуты для управления заметками Зеро. + * Монтируется на /api/admin/zero + * + * Auth: x-user-id header → users.is_admin = true (та же конвенция что в routes/admin.js) + */ + +const express = require('express'); +const router = express.Router(); +const { query } = require('../config/db'); +const zeroNotes = require('../services/zeroNotes'); +const zPrompt = require('../services/zeroPrompt'); + +function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; } + +async function requireAdmin(req, res) { + const adminId = uid(req); + if (!adminId) { res.status(401).json({ error: 'x-user-id required' }); return null; } + const { rows: [u] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]); + if (!u?.is_admin) { res.status(403).json({ error: 'Forbidden' }); return null; } + return adminId; +} + +// ─────────────────────────────────────────────────────────────────────────── +// СПИСКИ +// ─────────────────────────────────────────────────────────────────────────── + +// GET /api/admin/zero/notes?status=draft&channel_id=1&limit=50 +router.get('/notes', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + const status = req.query.status || null; + const channelId = req.query.channel_id ? parseInt(req.query.channel_id) : null; + const limit = Math.min(parseInt(req.query.limit) || 50, 200); + + const params = [limit]; + const where = []; + if (status) { params.push(status); where.push(`status = $${params.length}`); } + if (channelId) { params.push(channelId); where.push(`channel_id = $${params.length}`); } + const sqlWhere = where.length ? `WHERE ${where.join(' AND ')}` : ''; + + const { rows } = await query( + `SELECT id, channel_id, content, theme, theme_bucket, theme_hash, + pose, image_url, status, scheduled_at, approved_at, approved_by, + published_at, channel_message_id, model, tokens_in, tokens_out, + attempts, error, created_at, updated_at + FROM zero_notes + ${sqlWhere} + ORDER BY + CASE status + WHEN 'draft' THEN 1 + WHEN 'approved' THEN 2 + WHEN 'scheduled' THEN 3 + WHEN 'published' THEN 4 + WHEN 'failed' THEN 5 + WHEN 'skipped' THEN 6 + ELSE 7 + END, + scheduled_at ASC NULLS LAST, + created_at DESC + LIMIT $1`, + params + ); + res.json({ ok: true, items: rows, count: rows.length }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/admin/zero/notes/:id — детали одной заметки (любой статус) +router.get('/notes/:id', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + const n = await zeroNotes.getById(parseInt(req.params.id)); + if (!n) return res.status(404).json({ error: 'not found' }); + res.json({ ok: true, note: n }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/admin/zero/buckets — список ведёр (для UI: dropdown "Сгенерить с ведром X") +router.get('/buckets', async (req, res) => { + if (!await requireAdmin(req, res)) return; + res.json({ + ok: true, + buckets: zPrompt.THEME_BUCKETS.map(b => ({ key: b.key, label: b.label, examples: b.examples })), + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// ГЕНЕРАЦИЯ (КНОПКА) +// ─────────────────────────────────────────────────────────────────────────── + +// POST /api/admin/zero/generate +// body: { channel_id: int (обязательно), force_bucket?: string, allow_today_dup?: bool } +router.post('/generate', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + const channelId = parseInt(req.body?.channel_id); + const forceBucket = req.body?.force_bucket || null; + const allowDup = !!req.body?.allow_today_dup; + + if (!Number.isFinite(channelId)) { + return res.status(400).json({ error: 'channel_id required' }); + } + + // Если уже есть draft сегодня — по умолчанию запрещаем (защита от случайных кликов), + // но админ может передать allow_today_dup=true чтобы всё-таки сгенерить + if (!allowDup && await zeroNotes.hasDraftToday(channelId)) { + return res.status(409).json({ + error: 'draft уже создан сегодня — передай allow_today_dup=true, чтобы пересоздать', + }); + } + + const draft = await zeroNotes.generateDraft(channelId, { forceBucket, allowDup }); + if (!draft) { + return res.status(409).json({ error: 'не удалось создать draft (возможно дубль)' }); + } + res.json({ ok: true, note: draft }); + } catch (err) { + console.error('[POST /api/admin/zero/generate]', err.message); + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/admin/zero/notes/:id/regenerate +// body: { force_bucket?: string } — стирает старый draft и создаёт новый +router.post('/notes/:id/regenerate', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + const id = parseInt(req.params.id); + const old = await zeroNotes.getById(id); + if (!old) return res.status(404).json({ error: 'not found' }); + if (!['draft', 'failed'].includes(old.status)) { + return res.status(400).json({ error: `нельзя перегенерить заметку в статусе ${old.status}` }); + } + // Удаляем старый и генерим новый + await query('DELETE FROM zero_notes WHERE id=$1', [id]); + const fresh = await zeroNotes.generateDraft(old.channel_id, { + forceBucket: req.body?.force_bucket || null, + }); + res.json({ ok: true, note: fresh, replaced: id }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// ─────────────────────────────────────────────────────────────────────────── +// WORKFLOW +// ─────────────────────────────────────────────────────────────────────────── + +// PATCH /api/admin/zero/notes/:id — редактирование +router.patch('/notes/:id', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + const id = parseInt(req.params.id); + const updated = await zeroNotes.editContent(id, { + content: req.body?.content, + theme: req.body?.theme, + pose: req.body?.pose, + imageUrl: req.body?.image_url, + scheduledAt: req.body?.scheduled_at ? new Date(req.body.scheduled_at) : undefined, + }); + if (!updated) return res.status(404).json({ error: 'not found' }); + res.json({ ok: true, note: updated }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/admin/zero/notes/:id/approve — ручное одобрение +router.post('/notes/:id/approve', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + const adminId = uid(req); + const { rows: [u] } = await query('SELECT email FROM users WHERE id=$1', [adminId]); + const updated = await zeroNotes.approveManual(parseInt(req.params.id), u?.email || `admin#${adminId}`); + if (!updated) return res.status(404).json({ error: 'not found or wrong status' }); + res.json({ ok: true, note: updated }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/admin/zero/notes/:id/skip — пропустить (не публиковать) +router.post('/notes/:id/skip', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + const updated = await zeroNotes.skipNote(parseInt(req.params.id), req.body?.reason || 'skipped by admin'); + if (!updated) return res.status(404).json({ error: 'not found or wrong status' }); + res.json({ ok: true, note: updated }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/admin/zero/auto-approve — ручной триггер auto-approve (для тестов) +router.post('/auto-approve', async (req, res) => { + if (!await requireAdmin(req, res)) return; + try { + const rows = await zeroNotes.autoApproveOldDrafts(); + res.json({ ok: true, approved: rows.length, ids: rows.map(r => r.id) }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; diff --git a/src/services/zeroNotes.js b/src/services/zeroNotes.js new file mode 100644 index 0000000..9af421c --- /dev/null +++ b/src/services/zeroNotes.js @@ -0,0 +1,326 @@ +/** + * zeroNotes.js — сервис заметок от персонажа Зеро. + * + * Цикл одной заметки: + * 1) 13:00 МСК — generateDraft(channelId) → status='draft', scheduled_at=ЗАВТРА 13:00 + * 2) Вечером — редактор вручную: approveManual / editContent / skip / regenerate + * 3) 07:00 МСК — autoApproveOldDrafts(): остальные draft → 'approved' (approved_by='auto') + * 4) 13:00 следующего дня — runner забирает 'approved' и публикует + * + * Здесь только бизнес-логика. Расписание — в src/workers/zeroNotesScheduler.js. + * Публикация в TG/на сайт — в src/services/zeroNotesRunner.js (отдельный шаг). + */ + +const { query } = require('../config/db'); +const settings = require('./settings'); +const ai = require('./ai'); +const zeroChar = require('./zeroCharacter'); +const zPrompt = require('./zeroPrompt'); + +// ─────────────────────────────────────────────────────────────────────────── +// УТИЛИТЫ ВРЕМЕНИ +// ─────────────────────────────────────────────────────────────────────────── + +const MSK_OFFSET_MIN = 3 * 60; // UTC+3 + +/** Текущее время в МСК как { hour, minute, dateYMD } */ +function nowMsk() { + const now = new Date(); + const msk = new Date(now.getTime() + MSK_OFFSET_MIN * 60_000); + return { + date: msk, + hour: msk.getUTCHours(), + minute: msk.getUTCMinutes(), + ymd: msk.toISOString().slice(0, 10), + }; +} + +/** + * Следующий публикационный слот в МСК (13:00 по умолчанию). + * Если сейчас < сегодняшнего 13:00 МСК — возвращаем сегодня в 13:00 МСК, + * иначе — завтра в 13:00 МСК. Возвращаем как UTC Date для записи в TIMESTAMPTZ. + */ +function nextPublishSlot({ publishHourMsk = 13, publishMinMsk = 0 } = {}) { + const { date: nowM, hour, minute } = nowMsk(); + const isFuture = hour < publishHourMsk || (hour === publishHourMsk && minute < publishMinMsk); + const baseUtc = new Date(nowM.getTime() - MSK_OFFSET_MIN * 60_000); + baseUtc.setUTCHours(publishHourMsk - 3, publishMinMsk, 0, 0); // UTC-эквивалент МСК-часа + if (!isFuture) baseUtc.setUTCDate(baseUtc.getUTCDate() + 1); + return baseUtc; +} + +// ─────────────────────────────────────────────────────────────────────────── +// НАСТРОЙКИ +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Список channel_id для которых работают заметки Зеро. + * Источник: app_settings.ZERO_NOTES_CHANNEL_IDS = "1,2,5" (csv int) или ENV. + * Если пусто — заметки выключены. + */ +async function getActiveChannelIds() { + const raw = await settings.get('ZERO_NOTES_CHANNEL_IDS', ''); + if (!raw) return []; + return String(raw).split(',').map(s => parseInt(s.trim(), 10)).filter(Number.isFinite); +} + +async function getModel() { + return await settings.get('ZERO_NOTES_MODEL', process.env.AI_MODEL_POST || 'claude-haiku-4-5-20251001'); +} + +// ─────────────────────────────────────────────────────────────────────────── +// ВЫБОРКИ +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Последние N заметок (любого статуса кроме failed/skipped) — для anti-repeat в промпте. + */ +async function getRecentForPrompt(channelId, limit = 30) { + const { rows } = await query( + `SELECT id, theme, theme_bucket, theme_hash + FROM zero_notes + WHERE channel_id = $1 + AND status NOT IN ('failed', 'skipped') + ORDER BY created_at DESC + LIMIT $2`, + [channelId, limit] + ); + return rows; +} + +async function getById(noteId) { + const { rows } = await query('SELECT * FROM zero_notes WHERE id=$1', [noteId]); + return rows[0] || null; +} + +async function listByStatus(channelId, status, { limit = 50 } = {}) { + const { rows } = await query( + `SELECT * FROM zero_notes + WHERE channel_id=$1 AND status=$2 + ORDER BY scheduled_at ASC NULLS LAST, created_at DESC + LIMIT $3`, + [channelId, status, limit] + ); + return rows; +} + +/** + * Был ли уже сгенерирован draft за сегодня (по МСК) для канала. + * Защита от двойного запуска scheduler-tick'а в одну минуту. + */ +async function hasDraftToday(channelId) { + const { rows } = await query( + `SELECT 1 FROM zero_notes + WHERE channel_id = $1 + AND (created_at AT TIME ZONE 'Europe/Moscow')::date = (NOW() AT TIME ZONE 'Europe/Moscow')::date + LIMIT 1`, + [channelId] + ); + return rows.length > 0; +} + +// ─────────────────────────────────────────────────────────────────────────── +// ГЕНЕРАЦИЯ +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Сгенерировать черновик заметки для канала. + * Возвращает запись из zero_notes (status='draft'). + * + * @param {number} channelId + * @param {object} [opts] + * @param {string} [opts.forceBucket] — принудительное ведро темы (для админки/ручного теста) + * @param {boolean} [opts.dryRun] — не сохранять в БД (вернуть только текст и план) + */ +async function generateDraft(channelId, opts = {}) { + const { forceBucket = null, dryRun = false, allowDup = false } = opts; + + // 1. Канал должен существовать + const { rows: [channel] } = await query('SELECT id, name FROM channels WHERE id=$1', [channelId]); + if (!channel) throw new Error(`channel ${channelId} not found`); + + // 2. Антидубль по дню (можно пробить через allowDup — для админской кнопки "regenerate") + if (!dryRun && !allowDup && await hasDraftToday(channelId)) { + console.log(`[zeroNotes] channel=${channelId} skip: draft уже создан сегодня`); + return null; + } + + // 3. Промпт + const recent = await getRecentForPrompt(channelId, 30); + const prompt = zPrompt.buildPrompt({ recentNotes: recent, forceBucket }); + + // 4. Вызов AI + const model = await getModel(); + const t0 = Date.now(); + const { text, usage } = await ai.chat( + model, + prompt.system, + prompt.user, + { maxTokens: 600, temperature: 0.85 } + ); + const dtMs = Date.now() - t0; + + // 5. Чистим артефакты (markdown-обёртки если вдруг) + const content = text + .replace(/^\s*```(?:markdown|md|text)?\s*/i, '') + .replace(/\s*```\s*$/i, '') + .trim(); + + // 6. Подбираем позу Зеро по тексту + const pose = zeroChar.pickPose({ + title: prompt.themeHint, + excerpt: content.slice(0, 400), + category: 'ai-tools', + }); + const imageUrl = pose.exists ? `/uploads/zero-${pose.pose}.webp` : null; + + // 7. theme_hash для дедупа + const themeHash = zPrompt.normalizeTheme(prompt.themeHint); + + // 8. scheduled_at = ближайшее 13:00 МСК (если генерим в 13:00 сегодня → ставим на завтра) + const scheduledAt = nextPublishSlot({ publishHourMsk: 13 }); + + if (dryRun) { + return { + dryRun: true, + channel_id: channelId, + content, + theme: prompt.themeHint, + theme_bucket: prompt.bucket, + theme_hash: themeHash, + pose: pose.pose, + image_url: imageUrl, + scheduled_at: scheduledAt, + model, + tokens_in: usage.prompt_tokens, + tokens_out: usage.completion_tokens, + duration_ms: dtMs, + }; + } + + // 9. Сохраняем в БД + const { rows: [saved] } = await query( + `INSERT INTO zero_notes + (channel_id, content, theme, theme_bucket, theme_hash, + pose, image_url, status, scheduled_at, + tokens_in, tokens_out, model, generation_meta) + VALUES ($1,$2,$3,$4,$5,$6,$7,'draft',$8,$9,$10,$11,$12) + RETURNING *`, + [ + channelId, content, prompt.themeHint, prompt.bucket, themeHash, + pose.pose, imageUrl, scheduledAt, + usage.prompt_tokens || null, usage.completion_tokens || null, model, + JSON.stringify({ pose_source: pose.source, duration_ms: dtMs, recent_buckets: recent.map(r => r.theme_bucket) }), + ] + ); + + console.log(`[zeroNotes] channel=${channelId} draft #${saved.id} bucket=${prompt.bucket} pose=${pose.pose} ${dtMs}ms`); + return saved; +} + +// ─────────────────────────────────────────────────────────────────────────── +// ОДОБРЕНИЕ / РЕДАКТИРОВАНИЕ +// ─────────────────────────────────────────────────────────────────────────── + +async function approveManual(noteId, by = 'editor') { + const { rows: [n] } = await query( + `UPDATE zero_notes + SET status='approved', approved_at=NOW(), approved_by=$2, updated_at=NOW() + WHERE id=$1 AND status IN ('draft','approved') + RETURNING *`, + [noteId, by] + ); + return n || null; +} + +async function skipNote(noteId, reason = '') { + const { rows: [n] } = await query( + `UPDATE zero_notes + SET status='skipped', error=$2, updated_at=NOW() + WHERE id=$1 AND status IN ('draft','approved') + RETURNING *`, + [noteId, reason || null] + ); + return n || null; +} + +async function editContent(noteId, { content, theme, pose, imageUrl, scheduledAt }) { + const sets = ['updated_at=NOW()']; + const vals = []; + let i = 1; + if (content !== undefined) { sets.push(`content=$${i++}`); vals.push(content); } + if (theme !== undefined) { sets.push(`theme=$${i++}, theme_hash=$${i++}`); vals.push(theme, zPrompt.normalizeTheme(theme)); } + if (pose !== undefined) { sets.push(`pose=$${i++}, image_url=$${i++}`); vals.push(pose, `/uploads/zero-${pose}.webp`); } + if (imageUrl !== undefined) { sets.push(`image_url=$${i++}`); vals.push(imageUrl); } + if (scheduledAt !== undefined) { sets.push(`scheduled_at=$${i++}`); vals.push(scheduledAt); } + vals.push(noteId); + const { rows: [n] } = await query( + `UPDATE zero_notes SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`, + vals + ); + return n || null; +} + +/** + * Авто-одобрение: все draft со scheduled_at <= NOW()+8h → approved (approved_by='auto'). + * Запускается в 07:00 МСК — переводит все вчерашние draft в готовые к публикации в 13:00. + */ +async function autoApproveOldDrafts() { + const { rows } = await query( + `UPDATE zero_notes + SET status='approved', approved_at=NOW(), approved_by='auto', updated_at=NOW() + WHERE status='draft' + AND scheduled_at IS NOT NULL + AND scheduled_at <= NOW() + INTERVAL '8 hours' + RETURNING id, channel_id, theme_bucket` + ); + if (rows.length) { + console.log(`[zeroNotes] auto-approved ${rows.length} drafts: ${rows.map(r => r.id).join(', ')}`); + } + return rows; +} + +// ─────────────────────────────────────────────────────────────────────────── +// ПУБЛИЧНОЕ API ДЛЯ САЙТА +// ─────────────────────────────────────────────────────────────────────────── + +async function listPublished({ channelId = null, limit = 20, offset = 0 } = {}) { + const params = [limit, offset]; + let where = `status='published'`; + if (channelId) { + params.push(channelId); + where += ` AND channel_id=$${params.length}`; + } + const { rows } = await query( + `SELECT id, channel_id, content, theme, theme_bucket, pose, image_url, + published_at, channel_message_id + FROM zero_notes + WHERE ${where} + ORDER BY published_at DESC + LIMIT $1 OFFSET $2`, + params + ); + return rows; +} + +module.exports = { + // генерация + generateDraft, + // workflow + approveManual, + skipNote, + editContent, + autoApproveOldDrafts, + // выборки + getById, + listByStatus, + listPublished, + getRecentForPrompt, + hasDraftToday, + // настройки + getActiveChannelIds, + getModel, + // утилиты времени + nowMsk, + nextPublishSlot, +}; diff --git a/src/services/zeroNotesRunner.js b/src/services/zeroNotesRunner.js new file mode 100644 index 0000000..0fd35b2 --- /dev/null +++ b/src/services/zeroNotesRunner.js @@ -0,0 +1,184 @@ +/** + * zeroNotesRunner.js — публикация approved-заметок Зеро в Telegram. + * + * Запускается scheduler'ом раз в минуту: publishReady(). + * + * Логика: + * 1) Атомарно: status='approved' AND scheduled_at <= NOW() → status='sending', attempts++ + * (FOR UPDATE SKIP LOCKED — защита от двойного раннера) + * 2) Шлём в TG: + * - если есть локальный pose-файл — sendPhoto с multipart + * - иначе sendMessage (только текст) + * - опционально inline-кнопка "Открыть на сайте" если ZERO_SITE_URL_BASE задан + * 3) Успех → status='published', published_at=NOW(), channel_message_id + * Ошибка → если attempts < MAX_ATTEMPTS вернуть в 'approved' для ретрая, + * иначе 'failed' c сохранением error + */ + +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'; +const MAX_ATTEMPTS = 3; +const TG_CAPTION_LIMIT = 1024; +const TG_MESSAGE_LIMIT = 4096; + +/** + * Безопасная локальная резолюция картинки позы. Возвращает абсолютный путь или null. + */ +function resolvePosePath(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; +} + +/** + * Берёт ОДНУ approved-заметку готовую к публикации (scheduled_at <= NOW), + * атомарно переводит её в 'sending', возвращает строку или null. + */ +async function claimNextReady() { + const { rows } = await query(` + UPDATE zero_notes + SET status='sending', attempts=attempts+1, updated_at=NOW() + WHERE id = ( + SELECT id FROM zero_notes + WHERE status='approved' AND scheduled_at <= NOW() + ORDER BY scheduled_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING * + `); + return rows[0] || null; +} + +async function getChannel(channelId) { + const { rows: [c] } = await query('SELECT * FROM channels WHERE id=$1', [channelId]); + return c || null; +} + +/** + * Кнопка "Открыть на сайте" — опционально, если ZERO_SITE_URL_BASE задан. + * Пример: ZERO_SITE_URL_BASE=https://zeropost.ru/zero → https://zeropost.ru/zero/123 + */ +async function buildReplyMarkup(noteId) { + const base = await settings.get('ZERO_SITE_URL_BASE', ''); + if (!base) return undefined; + return { + inline_keyboard: [[ + { text: '💬 Открыть на сайте', url: `${base.replace(/\/$/, '')}/${noteId}` }, + ]], + }; +} + +async function sendToTelegram(note, channel) { + if (!channel.bot_token || !channel.tg_channel_id) { + throw new Error('bot_token или tg_channel_id у канала не заданы'); + } + + 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); + + // 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, + }); + return res.data?.result?.message_id; + } + + // Иначе — sendMessage (текст до 4096) + const res = await axios.post(`${tgBase}/bot${channel.bot_token}/sendMessage`, { + chat_id: channel.tg_channel_id, + text: note.content.slice(0, TG_MESSAGE_LIMIT), + disable_web_page_preview: !reply_markup, + reply_markup, + }, { timeout: 15_000 }); + return res.data?.result?.message_id; +} + +async function markPublished(noteId, messageId) { + await query( + `UPDATE zero_notes + SET status='published', published_at=NOW(), channel_message_id=$2, + error=NULL, updated_at=NOW() + WHERE id=$1`, + [noteId, messageId || null] + ); +} + +async function markFailedOrRetry(note, errorMsg) { + const failPermanent = note.attempts >= MAX_ATTEMPTS; + const newStatus = failPermanent ? 'failed' : 'approved'; + await query( + `UPDATE zero_notes + SET status=$2, error=$3, updated_at=NOW() + WHERE id=$1`, + [note.id, newStatus, errorMsg.slice(0, 1000)] + ); + return { failPermanent, newStatus }; +} + +/** + * Основная функция — обрабатывает одну заметку готовую к публикации. + * Возвращает { processed: bool, noteId?, messageId?, error? } + */ +async function publishOne() { + const note = await claimNextReady(); + if (!note) return { processed: false }; + + let channel; + try { + channel = await getChannel(note.channel_id); + if (!channel) throw new Error(`channel ${note.channel_id} not found`); + } catch (err) { + await markFailedOrRetry(note, err.message); + return { processed: true, noteId: note.id, error: err.message }; + } + + try { + const messageId = await sendToTelegram(note, channel); + await markPublished(note.id, messageId); + console.log(`[zeroNotes/runner] published #${note.id} → tg_msg=${messageId} channel=${channel.id}`); + return { processed: true, noteId: note.id, messageId }; + } catch (err) { + const errMsg = err.response?.data?.description || err.message || 'unknown error'; + const r = await markFailedOrRetry(note, errMsg); + console.error(`[zeroNotes/runner] FAIL #${note.id} attempt=${note.attempts} → ${r.newStatus}: ${errMsg}`); + return { processed: true, noteId: note.id, error: errMsg, status: r.newStatus }; + } +} + +/** + * Запустить раннер: публикует до `limit` заметок за один тик. + * Возвращает количество обработанных. + */ +async function publishReady({ limit = 5 } = {}) { + let count = 0; + for (let i = 0; i < limit; i++) { + const r = await publishOne(); + if (!r.processed) break; + count++; + } + return count; +} + +module.exports = { publishReady, publishOne, claimNextReady }; diff --git a/src/services/zeroPrompt.js b/src/services/zeroPrompt.js new file mode 100644 index 0000000..a0595f5 --- /dev/null +++ b/src/services/zeroPrompt.js @@ -0,0 +1,321 @@ +/** + * zeroPrompt.js — генерация промпта для AI-персонажа Зеро. + * + * Зеро — программист с юмором, любит кофе, постит короткие заметки 50-150 слов + * от первого лица. 1 заметка в день в 13:00 МСК в @zeropostru. + * + * Экспорт: + * - buildPrompt({ recentNotes, forceBucket? }) → { system, user, bucket, themeHint } + * - normalizeTheme(theme) → theme_hash (для дедупа) + * - THEME_BUCKETS, CHARACTER — для тестов и UI + */ + +const crypto = require('crypto'); + +// ─────────────────────────────────────────────────────────────────────────── +// ПЕРСОНАЖ +// ─────────────────────────────────────────────────────────────────────────── + +const CHARACTER = { + name: 'Зеро', + bio: [ + 'Программист с многолетним опытом, любит копаться под капотом.', + 'Постоянно носится с кофе — без него мысли не текут.', + 'Работает с AI-API, Docker, Linux, self-hosted всем подряд, иногда ESP32 и Orange Pi.', + 'Любит маленькие умные решения, не любит overengineering.', + 'Юмор — лёгкий, дружелюбный, без сарказма и понта.', + ], + voice: [ + 'Пишет от первого лица: "я", "у меня", "вчера сидел и…"', + 'Обращается к читателю на "ты" — как к коллеге за соседним столом.', + 'Может вставить лёгкое наблюдение или короткий вывод, но без пафоса и без "вот 5 советов".', + 'Допустимы редкие технические термины, но без понтов и без "магических" фраз.', + 'Если ошибается — спокойно об этом говорит, без самобичевания.', + ], + forbidden: [ + 'НЕ начинай заметку со слова "Сегодня" или "Привет, друзья" — звучит как корпоративный SMM.', + 'НЕ используй буллиты, заголовки, нумерованные списки. Это пост, а не статья.', + 'НЕ ставь хэштеги — канал не SMM-помойка.', + 'НЕ обещай "разобрать в следующем посте" и не делай тизеров.', + 'НЕ пиши "Дорогие читатели", "Уважаемые подписчики" и подобный пафос.', + 'НЕ используй кликбейт типа "Шок! Никто не знает, что..."', + 'НЕ ставь больше одного эмодзи на заметку (и лучше вообще без них).', + 'НЕ упоминай конкретные торговые марки в негативном ключе — нейтрально только.', + 'НЕ задавай встречных вопросов и не уточняй задачу — сразу пиши заметку.', + 'НЕ начинай ответ со слов "Понял", "Конечно", "Хорошо", "Сейчас напишу" — это служебные фразы, их быть не должно.', + ], +}; + +// ─────────────────────────────────────────────────────────────────────────── +// ВЁДРА ТЕМ — для разнообразия. Anti-repeat исключает последние N. +// ─────────────────────────────────────────────────────────────────────────── + +const THEME_BUCKETS = [ + { + key: 'ai_industry', + label: 'AI-индустрия', + examples: [ + 'свежий релиз модели и что в нём цепляет на практике', + 'неожиданный поворот в гонке провайдеров (OpenAI / Anthropic / Mistral / Qwen)', + 'почему вчерашний хайп вокруг "AGI к концу года" так быстро сдулся', + 'почему open-source модели догоняют закрытые быстрее, чем казалось', + ], + }, + { + key: 'tools', + label: 'Инструменты', + examples: [ + 'мини-обзор CLI-утилиты, которую недавно нашёл и теперь юзаю каждый день', + 'почему перешёл с одного редактора на другой и о чём жалею', + 'неожиданная фича в знакомом инструменте, мимо которой все ходят', + 'связка двух простых тулзов, которая внезапно решила большую проблему', + ], + }, + { + key: 'bug_story', + label: 'Забавный баг', + examples: [ + 'баг, который правил три часа, а оказался опечаткой в одном символе', + 'история про "у меня работает" между двумя одинаковыми машинами', + 'крон, который убивал всё подряд по таймеру, и как я его ловил', + 'таймзонный баг, всплывший только в проде в полночь', + ], + }, + { + key: 'dev_musing', + label: 'Размышление о разработке', + examples: [ + 'почему "написать с нуля" соблазнительно, но почти всегда плохая идея', + 'почему документация устаревает быстрее, чем код', + 'про разницу между "работает" и "понятно как чинить когда сломается"', + 'когда абстракция помогает, а когда — закапывает', + ], + }, + { + key: 'workflow', + label: 'Воркфлоу', + examples: [ + 'как я организую черновики и почему перестал держать всё в голове', + 'минимальный набор команд в shell, без которого больно', + 'короткий цикл "правка → проверка" — почему он важнее любых фреймворков', + 'как делю задачу на куски, чтобы не залипать на старте', + ], + }, + { + key: 'coffee_thoughts', + label: 'Кофейные мысли', + examples: [ + 'мысль, которая пришла на третьей чашке — про природу багов', + 'почему утренний кофе и первая дебаг-сессия — лучшее сочетание', + 'когда чашка кофе помогла больше, чем час чтения документации', + 'почему я перестал писать код без чашки рядом', + ], + }, + { + key: 'anti_pattern', + label: 'Анти-паттерн', + examples: [ + 'почему "сейчас быстро добавим" обычно превращается в технический долг', + 'про закомментированный код, который никто никогда не удалит', + 'про конфиги, которые проще хардкодить, чем потом разгребать', + 'когда логирование "на всякий случай" превращается в DDoS на диск', + ], + }, + { + key: 'iot_hw', + label: 'Железо и IoT', + examples: [ + 'как ESP32 учит покорности — отладка на железе vs на ноутбуке', + 'про разницу между чтением даташита и реальным поведением чипа', + 'забавный момент с relay-модулем и индуктивной нагрузкой', + 'почему "просто прошить" редко бывает "просто"', + ], + }, + { + key: 'self_host', + label: 'Self-hosted', + examples: [ + 'почему свой сервер — это и свобода, и круглосуточная ответственность', + 'про момент когда осознал, что Docker — не панацея', + 'про nginx-конфиг, в котором я каждый раз заново разбираюсь', + 'про бэкапы, которые не делал, пока не пришлось их искать', + ], + }, + { + key: 'observation', + label: 'Наблюдение', + examples: [ + 'про то, что весь "продвинутый" AI всё равно ломается на банальных edge-кейсах', + 'что общего у git rebase и приготовления яичницы', + 'почему 80% времени уходит на 20% задачи', + 'как меняется восприятие старого кода через полгода', + ], + }, + { + key: 'story', + label: 'Короткая история', + examples: [ + '"вчера сидел до утра, разбирался с одной строкой..."', + 'как друг-фронтендер открыл для себя SSH и теперь не молчит', + 'про деплой в пятницу вечером (несмотря на все мемы)', + 'как один странный комментарий в коде сэкономил мне час', + ], + }, + { + key: 'quick_tip', + label: 'Короткий совет', + examples: [ + 'один shell-приём, который экономит мне минуту в день', + 'почему стоит начинать день с просмотра вчерашних логов', + 'короткий чек-лист перед "git push --force"', + 'один параметр в curl, про который часто забывают', + ], + }, +]; + +const BUCKET_BY_KEY = Object.fromEntries(THEME_BUCKETS.map(b => [b.key, b])); + +// ─────────────────────────────────────────────────────────────────────────── +// FEW-SHOTS — эталоны стиля. Подаём 2 примера, чтобы AI поймал интонацию. +// ─────────────────────────────────────────────────────────────────────────── + +const FEW_SHOTS = [ + { + bucket: 'bug_story', + text: `Три часа ловил баг, из-за которого крон-задача убивала docker-контейнер каждую минуту. Думал — память течёт, думал — health-check злой, думал — может я сам что-то спросонья сделал. + +Оказалось, я когда-то давно положил в /etc/cron.d скрипт-watchdog, который должен был "поднимать" сервис если упал. Только условие "упал" он определял по статусу старой версии compose-файла. Сервис давно переехал, а watchdog продолжал честно его "поднимать" — пересоздавая контейнер заново. + +Мораль скучная: всегда смотри, что у тебя крутится в /etc/cron.d. Особенно то, что ты сам туда положил полгода назад с пометкой "временно".`, + }, + { + bucket: 'coffee_thoughts', + text: `Заметил странную закономерность: самые рабочие идеи приходят не за клавиатурой, а пока завариваю кофе. Между "нажал кнопку" и "налил в чашку" мозг как будто перестаёт держать задачу за рукав — и ровно в этот момент она сама собирается в голове. + +Психологи это, наверное, как-то умно называют. Я для себя называю проще: думать руками не всегда полезно. Иногда полезно просто перестать. + +Сейчас, кстати, как раз завариваю вторую. Если ещё через десять минут не пойму, как разрулить один баг — пойду варить третью.`, + }, +]; + +// ─────────────────────────────────────────────────────────────────────────── +// УТИЛИТЫ +// ─────────────────────────────────────────────────────────────────────────── + +const STOP_WORDS = new Set([ + 'и','в','на','с','по','для','от','до','из','к','а','но','что','как','это', + 'про','же','бы','ли','не','то','я','ты','он','она','мы','вы','они','быть', + 'мой','твой','наш','свой','этот','тот','очень','уже','ещё','еще','если', +]); + +/** + * Нормализует тему в стабильный хэш для дедупа. + * Шаги: lower → выкинуть пунктуацию → split → выкинуть стоп-слова → sort → join первых 8. + */ +function normalizeTheme(theme) { + if (!theme) return ''; + const words = String(theme) + .toLowerCase() + .replace(/ё/g, 'е') + .replace(/[^\p{L}\p{N}\s]+/gu, ' ') + .split(/\s+/) + .filter(w => w.length > 2 && !STOP_WORDS.has(w)); + const top = words.sort().slice(0, 8); + return crypto.createHash('sha256').update(top.join(' ')).digest('hex').slice(0, 16); +} + +/** + * Выбирает ведро для генерации. + * Anti-repeat: исключаем последние N использованных ведёр. + */ +function pickBucket({ recentBuckets = [], avoidLast = 5, forceBucket = null } = {}) { + if (forceBucket && BUCKET_BY_KEY[forceBucket]) return BUCKET_BY_KEY[forceBucket]; + const recentSet = new Set(recentBuckets.slice(-avoidLast)); + const pool = THEME_BUCKETS.filter(b => !recentSet.has(b.key)); + const candidates = pool.length > 0 ? pool : THEME_BUCKETS; + return candidates[Math.floor(Math.random() * candidates.length)]; +} + +// ─────────────────────────────────────────────────────────────────────────── +// СБОРКА ПРОМПТА +// ─────────────────────────────────────────────────────────────────────────── + +/** + * @param {object} opts + * @param {Array<{theme:string, theme_hash:string, theme_bucket:string, content?:string}>} opts.recentNotes — последние заметки для дедупа + * @param {string} [opts.forceBucket] — принудительное ведро (для теста или ручной генерации) + * @returns {{system:string, user:string, bucket:string, themeHint:string, fewShots:Array}} + */ +function buildPrompt({ recentNotes = [], forceBucket = null } = {}) { + const recentBuckets = recentNotes.map(n => n.theme_bucket).filter(Boolean); + const bucket = pickBucket({ recentBuckets, forceBucket }); + + // Подбираем подсказку темы — случайный example из ведра + const themeHint = bucket.examples[Math.floor(Math.random() * bucket.examples.length)]; + + // Список последних тем для антидубля (подаём в промпт) + const avoidList = recentNotes + .slice(0, 30) + .map(n => n.theme) + .filter(Boolean) + .map((t, i) => `${i + 1}. ${t}`) + .join('\n'); + + const system = [ + `Ты — ${CHARACTER.name}. Голос Telegram-канала @zeropostru.`, + '', + 'Кто ты:', + ...CHARACTER.bio.map(s => `— ${s}`), + '', + 'Как пишешь:', + ...CHARACTER.voice.map(s => `— ${s}`), + '', + 'Что НЕ делаешь:', + ...CHARACTER.forbidden.map(s => `— ${s}`), + '', + 'Формат ответа:', + '— Только текст заметки. Никаких заголовков, markdown, JSON, комментариев.', + '— Объём: 50–150 слов (это пост в TG, не эссе).', + '— 1–3 коротких абзаца. Между абзацами — пустая строка.', + '— Первая фраза не должна быть шаблонной ("Сегодня..." / "Привет..." / "Сейчас расскажу...").', + '— Первый символ ответа — уже начало самой заметки. Никаких преамбул, мета-комментариев, уточнений.', + ].join('\n'); + + // Few-shots в виде блока + const shotsBlock = FEW_SHOTS + .map((s, i) => `### Пример ${i + 1} (ведро: ${s.bucket})\n${s.text}`) + .join('\n\n---\n\n'); + + const user = [ + `Ведро темы: **${bucket.label}** (${bucket.key}).`, + `Подсказка темы: «${themeHint}». Можешь немного отойти, но в рамках ведра.`, + '', + 'Вот примеры твоего стиля — держи похожую интонацию, длину, структуру:', + '', + shotsBlock, + '', + avoidList + ? `Эти темы УЖЕ выходили в канале за последнее время — НЕ повторяй ни по сути, ни по углу подачи:\n${avoidList}\n` + : '', + 'Выбери конкретную микро-тему внутри ведра сам — у тебя достаточно подсказок выше. Уточнений не жди.', + 'Сразу пиши саму заметку. Никаких "Понял", "Сейчас расскажу", никаких пометок про задачу. Первая строка ответа = первая строка поста.', + ].filter(Boolean).join('\n'); + + return { + system, + user, + bucket: bucket.key, + themeHint, + fewShots: FEW_SHOTS.map(s => s.bucket), + }; +} + +module.exports = { + buildPrompt, + normalizeTheme, + pickBucket, + THEME_BUCKETS, + BUCKET_BY_KEY, + CHARACTER, + FEW_SHOTS, +}; diff --git a/src/workers/zeroNotesScheduler.js b/src/workers/zeroNotesScheduler.js new file mode 100644 index 0000000..12fa1f2 --- /dev/null +++ b/src/workers/zeroNotesScheduler.js @@ -0,0 +1,99 @@ +/** + * zeroNotesScheduler.js — расписание для заметок Зеро. + * + * Запускается из index.js: require('./src/workers/zeroNotesScheduler').start(); + * + * Тик каждые 60 сек, в МСК: + * - 13:00 → generateDraft для каждого активного канала (планирует на завтра 13:00) + * - 07:00 → autoApproveOldDrafts (переводит вчерашние draft в approved) + * - публикация в TG — отдельный раннер (следующий шаг) + * + * Защита от двойного запуска: in-memory флаг по YMD-минуте. + */ + +const zeroNotes = require('../services/zeroNotes'); +const zeroRunner = require('../services/zeroNotesRunner'); + +const TICK_MS = 60_000; + +// Отметка последнего успешного тика по slot'у: { generate: '2026-06-19T13:00', approve: '2026-06-19T07:00' } +const lastRun = {}; + +const GENERATE_HOUR_MSK = 13; +const APPROVE_HOUR_MSK = 7; + +function slotKey(ymd, hour) { + return `${ymd}T${String(hour).padStart(2, '0')}:00`; +} + +async function runGeneration(ymd) { + const key = slotKey(ymd, GENERATE_HOUR_MSK); + if (lastRun.generate === key) return; + lastRun.generate = key; + + const channelIds = await zeroNotes.getActiveChannelIds(); + if (!channelIds.length) { + console.log('[zeroNotes/scheduler] 13:00 МСК — нет активных каналов (ZERO_NOTES_CHANNEL_IDS пусто)'); + return; + } + console.log(`[zeroNotes/scheduler] 13:00 МСК — генерация для каналов: ${channelIds.join(', ')}`); + for (const channelId of channelIds) { + try { + const saved = await zeroNotes.generateDraft(channelId); + if (saved) { + console.log(`[zeroNotes/scheduler] channel=${channelId} → draft #${saved.id}, scheduled=${saved.scheduled_at?.toISOString?.() || saved.scheduled_at}`); + } + } catch (err) { + console.error(`[zeroNotes/scheduler] channel=${channelId} generation FAILED: ${err.message}`); + } + } +} + +async function runAutoApprove(ymd) { + const key = slotKey(ymd, APPROVE_HOUR_MSK); + if (lastRun.approve === key) return; + lastRun.approve = key; + + console.log(`[zeroNotes/scheduler] 07:00 МСК — авто-одобрение драфтов`); + try { + await zeroNotes.autoApproveOldDrafts(); + } catch (err) { + console.error(`[zeroNotes/scheduler] auto-approve FAILED: ${err.message}`); + } +} + +async function tick() { + const { hour, ymd } = zeroNotes.nowMsk(); + try { + if (hour === GENERATE_HOUR_MSK) await runGeneration(ymd); + if (hour === APPROVE_HOUR_MSK) await runAutoApprove(ymd); + // публикация approved-заметок в TG (каждую минуту) + const published = await zeroRunner.publishReady({ limit: 3 }); + if (published > 0) console.log(`[zeroNotes/scheduler] published ${published} note(s)`); + } catch (err) { + console.error(`[zeroNotes/scheduler] tick error: ${err.message}`); + } +} + +let intervalRef = null; + +function start() { + if (intervalRef) { + console.log('[zeroNotes/scheduler] already started'); + return; + } + intervalRef = setInterval(tick, TICK_MS); + // первый тик через 30 сек после старта (даём engine стабильно подняться) + setTimeout(tick, 30_000); + console.log(`[zeroNotes/scheduler] started, tick every ${TICK_MS / 1000}s, ` + + `generate=${GENERATE_HOUR_MSK}:00 MSK, auto-approve=${APPROVE_HOUR_MSK}:00 MSK`); +} + +function stop() { + if (intervalRef) { + clearInterval(intervalRef); + intervalRef = null; + } +} + +module.exports = { start, stop, tick, runGeneration, runAutoApprove };