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
+99
View File
@@ -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 };