forked from admin/zeropost-engine
feat: Зеро-персонаж, auto-publish, auto-series, channel-stats, fallback covers
- Персонаж Зеро: 23 позы (zeroCharacter.js), скрипты генерации - Auto-publish статей в TG: multipart upload, кнопки, режим alternating Zero/cover - Fallback цепочка обложек: aiprimetech gpt-5.5 → Pollinations → local SVG (6 палитр) - Auto-series: Claude haiku определяет серию для каждой статьи автоматически - Channel stats: подписчики, история, delta 24h/7d - Photo-search: Yandex API, профили доменов, Redis лимиты - Scheduled posts runner: backfill, preview, queue, cancel - promptBuilder: author_persona Зеро, голос от первого лица - Fixes: dollar-placeholder bugs в PATCH channels/autogen, listArticles фильтры - AI model: gpt-5.5 для image generation
This commit is contained in:
+104
-102
@@ -1,6 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const channelsSvc = require('../services/channels');
|
||||
const settings = require('../services/settings');
|
||||
const autoPublish = require('../services/articleAutoPublish');
|
||||
const { query } = require('../config/db');
|
||||
|
||||
const getUserId = (req) => {
|
||||
@@ -50,7 +52,6 @@ router.post('/admin', async (req, res) => {
|
||||
vk_group_id||null, vk_access_token||null, max_channel_id||null, max_access_token||null,
|
||||
niche||null, audience||null, goal]
|
||||
);
|
||||
// Создаём style и schedule по умолчанию
|
||||
await query(`INSERT INTO channel_style (channel_id) VALUES ($1) ON CONFLICT DO NOTHING`, [rows[0].id]);
|
||||
await query(`INSERT INTO channel_schedule (channel_id) VALUES ($1) ON CONFLICT DO NOTHING`, [rows[0].id]);
|
||||
res.json(rows[0]);
|
||||
@@ -60,13 +61,18 @@ router.post('/admin', async (req, res) => {
|
||||
// PATCH /api/channels/admin/:id — обновить системный канал
|
||||
router.patch('/admin/:id', async (req, res) => {
|
||||
try {
|
||||
const allowed = ['name','platform','tg_channel_id','tg_username','bot_token',
|
||||
const allowed = [
|
||||
'name','platform','tg_channel_id','tg_username','bot_token',
|
||||
'vk_group_id','vk_access_token','max_channel_id','max_access_token',
|
||||
'niche','audience','goal','is_active'];
|
||||
'niche','audience','goal','is_active',
|
||||
// autopublish
|
||||
'auto_publish_enabled','auto_publish_categories','auto_publish_delay_min',
|
||||
'auto_publish_template','auto_publish_with_cover','auto_publish_button_text','auto_publish_image_source',
|
||||
];
|
||||
const fields = []; const vals = []; let i = 1;
|
||||
for (const key of allowed) {
|
||||
if (req.body[key] !== undefined) {
|
||||
fields.push(`${key}=${i++}`);
|
||||
fields.push(`${key}=$${i++}`);
|
||||
vals.push(req.body[key]);
|
||||
}
|
||||
}
|
||||
@@ -74,7 +80,7 @@ router.patch('/admin/:id', async (req, res) => {
|
||||
fields.push(`updated_at=NOW()`);
|
||||
vals.push(req.params.id);
|
||||
const { rows } = await query(
|
||||
`UPDATE channels SET ${fields.join(',')} WHERE id=${i} AND is_system=true RETURNING *`,
|
||||
`UPDATE channels SET ${fields.join(',')} WHERE id=$${i} AND is_system=true RETURNING *`,
|
||||
vals
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
@@ -90,45 +96,40 @@ router.delete('/admin/:id', async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// POST /api/channels/admin/:id/publish — опубликовать статью в канал
|
||||
// POST /api/channels/admin/:id/publish — опубликовать статью в канал ПРЯМО СЕЙЧАС
|
||||
// Использует channel.auto_publish_template (если есть) и channel.auto_publish_with_cover.
|
||||
router.post('/admin/:id/publish', async (req, res) => {
|
||||
try {
|
||||
const { article_id, custom_text } = req.body;
|
||||
const { article_id, custom_text, with_cover } = req.body;
|
||||
const { rows } = await query(`SELECT * FROM channels WHERE id=$1 AND is_system=true`, [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Channel not found' });
|
||||
const channel = rows[0];
|
||||
|
||||
let text = custom_text;
|
||||
// Если текст не передан — берём статью и генерируем пост
|
||||
if (!text && article_id) {
|
||||
const { rows: arts } = await query(`SELECT * FROM articles WHERE id=$1`, [article_id]);
|
||||
if (!arts.length) return res.status(404).json({ error: 'Article not found' });
|
||||
const art = arts[0];
|
||||
// Простой текст поста из заголовка и excerpt
|
||||
text = `*${art.title}*\n\n${art.excerpt || ''}\n\nhttps://zeropost.ru/blog/${art.slug}`;
|
||||
}
|
||||
if (!text) return res.status(400).json({ error: 'text or article_id required' });
|
||||
// Создаём временный scheduled_post на NOW и сразу запускаем runner на него.
|
||||
const { rows: spRows } = await query(
|
||||
`INSERT INTO scheduled_posts (channel_id, article_id, custom_text, scheduled_at, status)
|
||||
VALUES ($1,$2,$3,NOW(),'pending') RETURNING *`,
|
||||
[channel.id, article_id || null, custom_text || null]
|
||||
);
|
||||
const sp = spRows[0];
|
||||
|
||||
const result = { ok: true, platform: channel.platform, text };
|
||||
|
||||
// Telegram
|
||||
if (channel.platform === 'telegram' && channel.bot_token && channel.tg_channel_id) {
|
||||
const axios = require('axios');
|
||||
const tgRes = await axios.post(
|
||||
`https://api.telegram.org/bot${channel.bot_token}/sendMessage`,
|
||||
{ chat_id: channel.tg_channel_id, text, parse_mode: 'Markdown', disable_web_page_preview: false },
|
||||
{ timeout: 15000 }
|
||||
);
|
||||
result.tg_message_id = tgRes.data?.result?.message_id;
|
||||
// Сохраняем пост
|
||||
// Точечный запуск
|
||||
const runner = require('../services/scheduledPostsRunner');
|
||||
try {
|
||||
const { messageId } = await runner.publishOne(sp);
|
||||
await query(
|
||||
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
|
||||
VALUES ($1,$2,'published',NOW(),$3)`,
|
||||
[channel.id, text, result.tg_message_id || null]
|
||||
`UPDATE scheduled_posts SET status='sent', published_at=NOW(), error=NULL WHERE id=$1`,
|
||||
[sp.id]
|
||||
);
|
||||
return res.json({ ok: true, platform: channel.platform, tg_message_id: messageId || null, scheduled_post_id: sp.id });
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.description || err.response?.data?.error?.error_msg || err.message;
|
||||
await query(
|
||||
`UPDATE scheduled_posts SET status='failed', error=$1 WHERE id=$2`,
|
||||
[String(msg).slice(0, 1000), sp.id]
|
||||
);
|
||||
return res.status(500).json({ error: msg });
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.description || err.message;
|
||||
res.status(500).json({ error: msg });
|
||||
@@ -146,70 +147,6 @@ router.get('/admin/:id/posts', async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channels = await channelsSvc.listChannels(userId);
|
||||
res.json(channels);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/channels/:id — один канал со всеми настройками
|
||||
router.get('/:id', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channel = await channelsSvc.getFullChannel(req.params.id, userId);
|
||||
if (!channel) return res.status(404).json({ error: 'Channel not found' });
|
||||
res.json(channel);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/channels — создать канал
|
||||
router.post('/', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channel = await channelsSvc.createChannel(userId, req.body);
|
||||
res.json(channel);
|
||||
} catch (err) {
|
||||
console.error('[Route] POST /channels', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/channels/:id — обновить
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channel = await channelsSvc.updateChannel(req.params.id, userId, req.body);
|
||||
res.json(channel);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/channels/:id
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
await channelsSvc.deleteChannel(req.params.id, userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
// ── Publish slots ─────────────────────────────────────────────────────────────
|
||||
|
||||
// GET /api/channels/admin/:id/slots
|
||||
@@ -251,11 +188,11 @@ router.delete('/admin/:id/slots/:slotId', async (req, res) => {
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// GET /api/channels/admin/:id/scheduled — запланированные посты
|
||||
// GET /api/channels/admin/:id/scheduled — запланированные посты канала (pending+failed+sent последние)
|
||||
router.get('/admin/:id/scheduled', async (req, res) => {
|
||||
try {
|
||||
const { rows } = await query(
|
||||
`SELECT sp.*, a.title as article_title, a.slug as article_slug
|
||||
`SELECT sp.*, a.title as article_title, a.slug as article_slug, a.category as article_category
|
||||
FROM scheduled_posts sp
|
||||
LEFT JOIN articles a ON a.id = sp.article_id
|
||||
WHERE sp.channel_id=$1
|
||||
@@ -267,15 +204,80 @@ router.get('/admin/:id/scheduled', async (req, res) => {
|
||||
});
|
||||
|
||||
// POST /api/channels/admin/:id/schedule — поставить пост в очередь
|
||||
// scheduled_at: если не передан — берём ближайший слот канала (через autoPublish.pickScheduleTime).
|
||||
router.post('/admin/:id/schedule', async (req, res) => {
|
||||
try {
|
||||
const { article_id, custom_text, scheduled_at } = req.body;
|
||||
if (!scheduled_at) return res.status(400).json({ error: 'scheduled_at required' });
|
||||
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=$1 AND is_system=true`, [req.params.id]);
|
||||
if (!chs.length) return res.status(404).json({ error: 'Channel not found' });
|
||||
|
||||
const when = scheduled_at ? new Date(scheduled_at) : await autoPublish.pickScheduleTime(chs[0]);
|
||||
const { rows } = await query(
|
||||
`INSERT INTO scheduled_posts (channel_id, article_id, custom_text, scheduled_at)
|
||||
VALUES ($1,$2,$3,$4) RETURNING *`,
|
||||
[req.params.id, article_id || null, custom_text || null, scheduled_at]
|
||||
[req.params.id, article_id || null, custom_text || null, when]
|
||||
);
|
||||
res.json(rows[0]);
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ── User routes (НЕ системные, для tool) ──────────────────────────────────────
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channels = await channelsSvc.listChannels(userId);
|
||||
res.json(channels);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channel = await channelsSvc.getFullChannel(req.params.id, userId);
|
||||
if (!channel) return res.status(404).json({ error: 'Channel not found' });
|
||||
res.json(channel);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channel = await channelsSvc.createChannel(userId, req.body);
|
||||
res.json(channel);
|
||||
} catch (err) {
|
||||
console.error('[Route] POST /channels', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
const channel = await channelsSvc.updateChannel(req.params.id, userId, req.body);
|
||||
res.json(channel);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const userId = getUserId(req);
|
||||
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
|
||||
try {
|
||||
await channelsSvc.deleteChannel(req.params.id, userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user