a370b8f7d8
- Персонаж Зеро: 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
147 lines
5.3 KiB
JavaScript
147 lines
5.3 KiB
JavaScript
const { query } = require('../config/db');
|
|
const axios = require('axios');
|
|
const settings = require('../services/settings');
|
|
|
|
/**
|
|
* Сохранить пост в базу (как черновик или сразу запланированный).
|
|
*/
|
|
async function savePost({ userId, channelId, content, imageUrl = null, imageCredit = null, topic = null, status = 'draft', scheduledAt = null }) {
|
|
const { rows } = await query(
|
|
`INSERT INTO user_posts (user_id, channel_id, content, image_url, image_credit, topic, status, scheduled_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
|
[userId, channelId, content, imageUrl, imageCredit, topic, status, scheduledAt]
|
|
);
|
|
return rows[0];
|
|
}
|
|
|
|
async function listUserPosts({ userId, channelId = null, status = null, limit = 50 }) {
|
|
let sql = `SELECT * FROM user_posts WHERE user_id=$1`;
|
|
const params = [userId];
|
|
if (channelId) { sql += ` AND channel_id=$${params.length + 1}`; params.push(channelId); }
|
|
if (status) { sql += ` AND status=$${params.length + 1}`; params.push(status); }
|
|
sql += ` ORDER BY created_at DESC LIMIT $${params.length + 1}`;
|
|
params.push(limit);
|
|
const { rows } = await query(sql, params);
|
|
return rows;
|
|
}
|
|
|
|
async function getPost(userId, postId) {
|
|
const { rows } = await query(`SELECT * FROM user_posts WHERE id=$1 AND user_id=$2`, [postId, userId]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
async function updatePost(userId, postId, data) {
|
|
const allowed = ['content','image_url','image_credit','status','scheduled_at','topic'];
|
|
const fields = []; const vals = []; let i = 1;
|
|
for (const key of allowed) {
|
|
if (data[key] !== undefined) { fields.push(`${key}=$${i++}`); vals.push(data[key]); }
|
|
}
|
|
if (!fields.length) return null;
|
|
fields.push(`updated_at=NOW()`);
|
|
vals.push(postId, userId);
|
|
const { rows } = await query(
|
|
`UPDATE user_posts SET ${fields.join(',')} WHERE id=$${i++} AND user_id=$${i} RETURNING *`,
|
|
vals
|
|
);
|
|
return rows[0];
|
|
}
|
|
|
|
async function deletePost(userId, postId) {
|
|
const { rowCount } = await query(`DELETE FROM user_posts WHERE id=$1 AND user_id=$2`, [postId, userId]);
|
|
return rowCount > 0;
|
|
}
|
|
|
|
/**
|
|
* Опубликовать пост в Telegram.
|
|
*/
|
|
async function publishToTelegram(post, channel) {
|
|
if (!channel.bot_token || !channel.tg_channel_id) {
|
|
throw new Error('Telegram не настроен (нужны bot_token и tg_channel_id)');
|
|
}
|
|
// Если есть картинка — отправляем как фото с подписью, иначе текст
|
|
if (post.image_url) {
|
|
const photoUrl = post.image_url.startsWith('http')
|
|
? post.image_url
|
|
: `https://app.zeropost.ru${post.image_url}`;
|
|
const res = await axios.post(
|
|
`${await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org')}/bot${channel.bot_token}/sendPhoto`,
|
|
{
|
|
chat_id: channel.tg_channel_id,
|
|
photo: photoUrl,
|
|
caption: post.content.slice(0, 1024),
|
|
parse_mode: 'Markdown',
|
|
},
|
|
{ timeout: 30000 }
|
|
);
|
|
return { ok: true, message_id: res.data?.result?.message_id };
|
|
} else {
|
|
const res = await axios.post(
|
|
`${await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org')}/bot${channel.bot_token}/sendMessage`,
|
|
{
|
|
chat_id: channel.tg_channel_id,
|
|
text: post.content,
|
|
parse_mode: 'Markdown',
|
|
disable_web_page_preview: false,
|
|
},
|
|
{ timeout: 15000 }
|
|
);
|
|
return { ok: true, message_id: res.data?.result?.message_id };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Опубликовать пост (выбирает платформу по channel.platform).
|
|
*/
|
|
async function publishPost(userId, postId) {
|
|
const post = await getPost(userId, postId);
|
|
if (!post) throw new Error('Post not found');
|
|
|
|
const { rows: chRows } = await query(`SELECT * FROM channels WHERE id=$1`, [post.channel_id]);
|
|
if (!chRows.length) throw new Error('Channel not found');
|
|
const channel = chRows[0];
|
|
|
|
let result;
|
|
try {
|
|
if (channel.platform === 'telegram' || !channel.platform) {
|
|
result = await publishToTelegram(post, channel);
|
|
} else {
|
|
throw new Error(`Платформа ${channel.platform} пока не поддерживается`);
|
|
}
|
|
await query(
|
|
`UPDATE user_posts SET status='published', published_at=NOW(), tg_message_id=$1, error=NULL WHERE id=$2`,
|
|
[result.message_id || null, postId]
|
|
);
|
|
return { ok: true, message_id: result.message_id };
|
|
} catch (err) {
|
|
const msg = err.response?.data?.description || err.message;
|
|
await query(
|
|
`UPDATE user_posts SET status='failed', error=$1 WHERE id=$2`,
|
|
[msg, postId]
|
|
);
|
|
throw new Error(msg);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Запустить публикацию запланированных постов (вызывается cron-ом).
|
|
*/
|
|
async function runScheduledPublications() {
|
|
const { rows } = await query(
|
|
`SELECT * FROM user_posts
|
|
WHERE status='scheduled' AND scheduled_at <= NOW()
|
|
ORDER BY scheduled_at LIMIT 50`
|
|
);
|
|
const results = [];
|
|
for (const post of rows) {
|
|
try {
|
|
const res = await publishPost(post.user_id, post.id);
|
|
results.push({ id: post.id, ok: true, message_id: res.message_id });
|
|
} catch (err) {
|
|
results.push({ id: post.id, ok: false, error: err.message });
|
|
}
|
|
}
|
|
return { processed: rows.length, results };
|
|
}
|
|
|
|
module.exports = { savePost, listUserPosts, getPost, updatePost, deletePost, publishPost, runScheduledPublications };
|