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:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
@@ -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);
|
||||
});
|
||||
Generated
+1463
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user