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:
committed by
Alexey Pavlov
parent
5b5f703078
commit
29788a8f9d
@@ -75,6 +75,9 @@ app.post('/api/billing/webhook',
|
|||||||
const inboxRoutes = require('./src/routes/inbox');
|
const inboxRoutes = require('./src/routes/inbox');
|
||||||
app.use('/api', inboxRoutes); // включает /api/tg-webhook/:channelId
|
app.use('/api', inboxRoutes); // включает /api/tg-webhook/:channelId
|
||||||
|
|
||||||
|
// Заметки Зеро — публичная часть (для сайта zeropost.ru/zero)
|
||||||
|
app.use('/api/zero', require('./src/routes/zero'));
|
||||||
|
|
||||||
// Simple internal auth middleware
|
// Simple internal auth middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const secret = req.headers['x-internal-secret'];
|
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', inboxRoutes);
|
||||||
app.use('/api', require('./src/routes/drafts'));
|
app.use('/api', require('./src/routes/drafts'));
|
||||||
|
|
||||||
|
// Заметки Зеро — админская часть (за internal-secret middleware)
|
||||||
|
app.use('/api/admin/zero', require('./src/routes/zeroAdmin'));
|
||||||
|
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
|
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
|
||||||
});
|
});
|
||||||
@@ -162,6 +168,9 @@ const start = async () => {
|
|||||||
// Первый запуск через 5 мин после старта
|
// Первый запуск через 5 мин после старта
|
||||||
setTimeout(() => draftSvc.generateDailyDrafts().catch(() => {}), 5 * 60 * 1000);
|
setTimeout(() => draftSvc.generateDailyDrafts().catch(() => {}), 5 * 60 * 1000);
|
||||||
|
|
||||||
|
// Заметки Зеро — генерация в 13:00 МСК + авто-одобрение в 07:00 МСК
|
||||||
|
require('./src/workers/zeroNotesScheduler').start();
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
app.listen(config.port, () => {
|
||||||
console.log(`[Engine] Running on port ${config.port}`);
|
console.log(`[Engine] Running on port ${config.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 — тематические серии статей
|
// series — тематические серии статей
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS series (
|
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_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_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_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');
|
console.log('[DB] Migrations applied');
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user