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
+9
View File
@@ -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}`);
});
+34
View File
@@ -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');
+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;
+326
View File
@@ -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,
};
+184
View File
@@ -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 };
+321
View File
@@ -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,
};
+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 };