feat: initial zeropost-engine structure

- AI service with Anthropic claude-sonnet-4-6
- Bull queue for async generation jobs
- Routes: /api/generate, /api/channels, /api/posts
- PostgreSQL schema: users, channels, posts, generation_jobs
- Supports: post, article, topics generation types
This commit is contained in:
Alexey Pavlov
2026-05-30 21:29:00 +03:00
parent 1b9767f269
commit 612053b93d
12 changed files with 1907 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
PORT=3030
ANTHROPIC_API_KEY=your_key_here
DB_HOST=localhost
DB_PORT=5432
DB_NAME=zeropost
DB_USER=postgres
DB_PASS=postgres
REDIS_HOST=localhost
REDIS_PORT=6379
INTERNAL_SECRET=change_me_in_prod
+3
View File
@@ -0,0 +1,3 @@
node_modules/
.env
*.log
+43
View File
@@ -0,0 +1,43 @@
const express = require('express');
const config = require('./src/config');
const { migrate } = require('./src/config/db');
// Routes
const generateRoutes = require('./src/routes/generate');
const channelsRoutes = require('./src/routes/channels');
const postsRoutes = require('./src/routes/posts');
// Start queue worker
require('./src/workers/generation');
const app = express();
app.use(express.json());
// Simple internal auth middleware
app.use((req, res, next) => {
const secret = req.headers['x-internal-secret'];
if (secret !== config.internalSecret) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
});
app.use('/api/generate', generateRoutes);
app.use('/api/channels', channelsRoutes);
app.use('/api/posts', postsRoutes);
app.get('/health', (req, res) => {
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
});
const start = async () => {
await migrate();
app.listen(config.port, () => {
console.log(`[Engine] Running on port ${config.port}`);
});
};
start().catch(err => {
console.error('[Engine] Failed to start:', err);
process.exit(1);
});
+1463
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "zeropost-engine",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "https://git.zeroday.su/admin/zeropost-engine.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.16.1",
"bull": "^4.16.5",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"ioredis": "^5.11.0",
"node-cron": "^4.2.1",
"pg": "^8.21.0"
}
}
+67
View File
@@ -0,0 +1,67 @@
const { Pool } = require('pg');
const config = require('../config');
const pool = new Pool(config.db);
pool.on('error', (err) => {
console.error('[DB] Unexpected error on idle client', err);
});
const query = (text, params) => pool.query(text, params);
const migrate = async () => {
await query(`
CREATE TABLE IF NOT EXISTS generation_jobs (
id SERIAL PRIMARY KEY,
type VARCHAR(50) NOT NULL, -- 'article', 'post', 'caption'
channel_id INTEGER,
topic TEXT,
prompt TEXT,
result TEXT,
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, done, failed
error TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS channels (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
name VARCHAR(255) NOT NULL,
tg_channel_id VARCHAR(255),
bot_token TEXT,
topic TEXT,
tone VARCHAR(100) DEFAULT 'neutral',
language VARCHAR(10) DEFAULT 'ru',
post_schedule JSONB DEFAULT '{}',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL,
plan VARCHAR(20) DEFAULT 'free', -- free, pro, enterprise
api_key VARCHAR(64) UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS posts (
id SERIAL PRIMARY KEY,
channel_id INTEGER REFERENCES channels(id),
job_id INTEGER REFERENCES generation_jobs(id),
content TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'draft', -- draft, scheduled, published, failed
scheduled_at TIMESTAMPTZ,
published_at TIMESTAMPTZ,
tg_message_id BIGINT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
`);
console.log('[DB] Migrations applied');
};
module.exports = { query, migrate };
+18
View File
@@ -0,0 +1,18 @@
require('dotenv').config();
module.exports = {
port: process.env.PORT || 3030,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
db: {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME || 'zeropost',
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASS || 'postgres',
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
},
internalSecret: process.env.INTERNAL_SECRET || 'dev-secret-change-in-prod',
};
+50
View File
@@ -0,0 +1,50 @@
const express = require('express');
const router = express.Router();
const { query } = require('../config/db');
// GET /api/channels - list channels for user
router.get('/', async (req, res) => {
const userId = req.headers['x-user-id'];
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const { rows } = await query(`SELECT * FROM channels WHERE user_id=$1 ORDER BY created_at DESC`, [userId]);
res.json(rows);
});
// POST /api/channels - create channel
router.post('/', async (req, res) => {
const userId = req.headers['x-user-id'];
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const { name, tgChannelId, botToken, topic, tone, language } = req.body;
if (!name) return res.status(400).json({ error: 'name is required' });
const { rows } = await query(
`INSERT INTO channels (user_id, name, tg_channel_id, bot_token, topic, tone, language) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[userId, name, tgChannelId || null, botToken || null, topic || '', tone || 'neutral', language || 'ru']
);
res.json(rows[0]);
});
// PATCH /api/channels/:id - update channel
router.patch('/:id', async (req, res) => {
const userId = req.headers['x-user-id'];
const { name, topic, tone, language, botToken, tgChannelId, isActive, postSchedule } = req.body;
const { rows } = await query(
`UPDATE channels SET
name=COALESCE($1,name), topic=COALESCE($2,topic), tone=COALESCE($3,tone),
language=COALESCE($4,language), bot_token=COALESCE($5,bot_token),
tg_channel_id=COALESCE($6,tg_channel_id), is_active=COALESCE($7,is_active),
post_schedule=COALESCE($8,post_schedule)
WHERE id=$9 AND user_id=$10 RETURNING *`,
[name, topic, tone, language, botToken, tgChannelId, isActive, postSchedule ? JSON.stringify(postSchedule) : null, req.params.id, userId]
);
if (!rows.length) return res.status(404).json({ error: 'Channel not found' });
res.json(rows[0]);
});
// DELETE /api/channels/:id
router.delete('/:id', async (req, res) => {
const userId = req.headers['x-user-id'];
await query(`DELETE FROM channels WHERE id=$1 AND user_id=$2`, [req.params.id, userId]);
res.json({ ok: true });
});
module.exports = router;
+40
View File
@@ -0,0 +1,40 @@
const express = require('express');
const router = express.Router();
const { query } = require('../config/db');
const generationQueue = require('../workers/generation');
// POST /api/generate - create generation job
router.post('/', async (req, res) => {
try {
const { type, topic, tone = 'neutral', language = 'ru', channelContext = '', keywords = [], channelId } = req.body;
if (!type || !topic) return res.status(400).json({ error: 'type and topic are required' });
if (!['post', 'article', 'topics'].includes(type)) return res.status(400).json({ error: 'Invalid type' });
const { rows } = await query(
`INSERT INTO generation_jobs (type, channel_id, topic, status) VALUES ($1,$2,$3,'pending') RETURNING id`,
[type, channelId || null, topic]
);
const jobId = rows[0].id;
await generationQueue.add({ jobId, type, topic, tone, language, channelContext, keywords });
res.json({ jobId, status: 'pending' });
} catch (err) {
console.error('[Route] POST /generate', err);
res.status(500).json({ error: err.message });
}
});
// GET /api/generate/:id - get job status
router.get('/:id', async (req, res) => {
try {
const { rows } = await query(`SELECT * FROM generation_jobs WHERE id=$1`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Job not found' });
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+51
View File
@@ -0,0 +1,51 @@
const express = require('express');
const router = express.Router();
const { query } = require('../config/db');
const axios = require('axios');
// POST /api/posts/publish - publish a post to Telegram immediately
router.post('/publish', async (req, res) => {
try {
const { channelId, content, userId } = req.body;
if (!channelId || !content) return res.status(400).json({ error: 'channelId and content required' });
const { rows } = await query(
`SELECT * FROM channels WHERE id=$1 AND user_id=$2`,
[channelId, userId]
);
if (!rows.length) return res.status(404).json({ error: 'Channel not found' });
const ch = rows[0];
if (!ch.bot_token || !ch.tg_channel_id) return res.status(400).json({ error: 'Channel has no bot_token or tg_channel_id' });
const tgRes = await axios.post(`https://api.telegram.org/bot${ch.bot_token}/sendMessage`, {
chat_id: ch.tg_channel_id,
text: content,
parse_mode: 'HTML',
});
const msgId = tgRes.data?.result?.message_id;
const { rows: postRows } = await query(
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id) VALUES ($1,$2,'published',NOW(),$3) RETURNING *`,
[channelId, content, msgId]
);
res.json(postRows[0]);
} catch (err) {
console.error('[Route] POST /posts/publish', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/posts?channelId=X - list posts
router.get('/', async (req, res) => {
const { channelId } = req.query;
const { rows } = await query(
`SELECT p.*, g.type as job_type FROM posts p LEFT JOIN generation_jobs g ON p.job_id=g.id WHERE p.channel_id=$1 ORDER BY p.created_at DESC LIMIT 50`,
[channelId]
);
res.json(rows);
});
module.exports = router;
+80
View File
@@ -0,0 +1,80 @@
const axios = require('axios');
const config = require('../config');
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
const MODEL = 'claude-sonnet-4-6';
/**
* Generate text via Anthropic API
* @param {string} systemPrompt
* @param {string} userPrompt
* @param {object} options - { maxTokens, temperature }
*/
const generate = async (systemPrompt, userPrompt, options = {}) => {
const { maxTokens = 2000 } = options;
const res = await axios.post(ANTHROPIC_URL, {
model: MODEL,
max_tokens: maxTokens,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
}, {
headers: {
'x-api-key': config.anthropicApiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
});
const text = res.data.content?.[0]?.text;
if (!text) throw new Error('Empty response from Anthropic');
return text;
};
/**
* Generate a Telegram post
*/
const generatePost = async ({ topic, tone = 'neutral', language = 'ru', channelContext = '' }) => {
const system = `Ты — профессиональный автор Telegram-каналов. Пишешь посты на ${language === 'ru' ? 'русском' : 'английском'} языке.
Тон: ${tone}.
${channelContext ? `Контекст канала: ${channelContext}` : ''}
Правила:
- Пост 150-400 слов
- Используй эмодзи уместно
- Не используй markdown заголовки (##, **), только текст и эмодзи
- В конце добавь 2-4 релевантных хэштега`;
const user = `Напиши пост на тему: "${topic}"`;
return generate(system, user, { maxTokens: 1000 });
};
/**
* Generate a blog article
*/
const generateArticle = async ({ topic, language = 'ru', keywords = [] }) => {
const system = `Ты — эксперт по искусственному интеллекту, пишешь SEO-оптимизированные статьи на ${language === 'ru' ? 'русском' : 'английском'} языке.
Правила:
- Статья 800-1500 слов
- Структура: заголовок H1, введение, 3-5 разделов H2, заключение
- Используй ключевые слова органично
- Простой и понятный язык`;
const user = `Напиши статью на тему: "${topic}"${keywords.length ? `\nКлючевые слова: ${keywords.join(', ')}` : ''}`;
return generate(system, user, { maxTokens: 3000 });
};
/**
* Generate topic ideas for a channel
*/
const generateTopics = async ({ channelContext, count = 10, language = 'ru' }) => {
const system = `Генерируй идеи для постов в Telegram-канале. Отвечай только JSON массивом строк, без пояснений.`;
const user = `Придумай ${count} идей для постов. Контекст канала: "${channelContext}". Язык: ${language}. Формат: ["тема1","тема2",...]`;
const raw = await generate(system, user, { maxTokens: 800 });
try {
return JSON.parse(raw.replace(/```json|```/g, '').trim());
} catch {
return raw.split('\n').filter(Boolean).slice(0, count);
}
};
module.exports = { generate, generatePost, generateArticle, generateTopics };
+57
View File
@@ -0,0 +1,57 @@
const Queue = require('bull');
const config = require('../config');
const ai = require('../services/ai');
const { query } = require('../config/db');
const generationQueue = new Queue('generation', {
redis: config.redis,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: 100,
removeOnFail: 200,
},
});
generationQueue.process(async (job) => {
const { jobId, type, topic, tone, language, channelContext, keywords } = job.data;
await query(`UPDATE generation_jobs SET status='processing', updated_at=NOW() WHERE id=$1`, [jobId]);
try {
let result;
if (type === 'post') {
result = await ai.generatePost({ topic, tone, language, channelContext });
} else if (type === 'article') {
result = await ai.generateArticle({ topic, language, keywords });
} else if (type === 'topics') {
const topics = await ai.generateTopics({ channelContext: topic, language });
result = JSON.stringify(topics);
} else {
throw new Error(`Unknown job type: ${type}`);
}
await query(
`UPDATE generation_jobs SET status='done', result=$1, updated_at=NOW() WHERE id=$2`,
[result, jobId]
);
console.log(`[Worker] Job ${jobId} (${type}) done`);
return { jobId, result };
} catch (err) {
await query(
`UPDATE generation_jobs SET status='failed', error=$1, updated_at=NOW() WHERE id=$2`,
[err.message, jobId]
);
throw err;
}
});
generationQueue.on('failed', (job, err) => {
console.error(`[Worker] Job ${job.data.jobId} failed:`, err.message);
});
console.log('[Worker] Generation queue started');
module.exports = generationQueue;