feat(zero): Zero notes — AI persona for @zeropostru daily posts

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
This commit is contained in:
Aleksei Pavlov
2026-06-19 10:52:22 +03:00
committed by Alexey Pavlov
parent 5b5f703078
commit 29788a8f9d
8 changed files with 1245 additions and 0 deletions
+63
View File
@@ -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;
+209
View File
@@ -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;