Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled

Full-stack web application for Telegram management
- Frontend: Vue 3 + Vben Admin
- Backend: NestJS
- Features: User management, group broadcast, statistics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
你的用户名
2025-11-04 15:37:50 +08:00
commit 237c7802e5
3674 changed files with 525172 additions and 0 deletions

View File

@@ -0,0 +1 @@
import './index.js';

View File

@@ -0,0 +1,86 @@
import { Sequelize } from 'sequelize';
import { logger } from '../utils/logger.js';
export class Database {
constructor() {
this.sequelize = null;
}
static getInstance() {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
async connect() {
try {
this.sequelize = new Sequelize({
host: process.env.POSTGRES_HOST || 'localhost',
port: process.env.POSTGRES_PORT || 5432,
database: process.env.POSTGRES_DATABASE || 'marketing_agent',
username: process.env.POSTGRES_USER || 'marketing_user',
password: process.env.POSTGRES_PASSWORD || 'marketing_pass',
dialect: 'postgres',
logging: process.env.NODE_ENV === 'development' ? logger.debug : false,
pool: {
max: parseInt(process.env.DB_POOL_SIZE) || 10,
min: 0,
acquire: 30000,
idle: 10000
}
});
await this.sequelize.authenticate();
logger.info('Database connection established successfully');
// Sync models
await this.syncModels();
return this.sequelize;
} catch (error) {
logger.error('Unable to connect to database:', error);
throw error;
}
}
async syncModels() {
try {
// Import models
const { Task } = await import('../models/Task.js');
const { Campaign } = await import('../models/Campaign.js');
const { Workflow } = await import('../models/Workflow.js');
const { AuditLog } = await import('../models/AuditLog.js');
// Sync database
if (process.env.NODE_ENV === 'development') {
await this.sequelize.sync({ alter: true });
logger.info('Database models synchronized');
}
} catch (error) {
logger.error('Error syncing models:', error);
throw error;
}
}
async checkHealth() {
try {
await this.sequelize.authenticate();
return true;
} catch (error) {
logger.error('Database health check failed:', error);
return false;
}
}
async disconnect() {
if (this.sequelize) {
await this.sequelize.close();
logger.info('Database connection closed');
}
}
getSequelize() {
return this.sequelize;
}
}

View File

@@ -0,0 +1,192 @@
import Redis from 'ioredis';
import { logger } from '../utils/logger.js';
export class RedisClient {
constructor() {
this.client = null;
this.subscriber = null;
this.publisher = null;
}
static getInstance() {
if (!RedisClient.instance) {
RedisClient.instance = new RedisClient();
}
return RedisClient.instance;
}
async connect() {
const config = {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB) || 0,
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
reconnectOnError: (err) => {
const targetError = 'READONLY';
if (err.message.includes(targetError)) {
return true;
}
return false;
}
};
try {
// Main client for general operations
this.client = new Redis(config);
// Separate clients for pub/sub
this.subscriber = new Redis(config);
this.publisher = new Redis(config);
// Set up event handlers
this.client.on('connect', () => {
logger.info('Redis connection established');
});
this.client.on('error', (err) => {
logger.error('Redis error:', err);
});
this.client.on('close', () => {
logger.warn('Redis connection closed');
});
// Wait for connection
await this.client.ping();
return this.client;
} catch (error) {
logger.error('Failed to connect to Redis:', error);
throw error;
}
}
async checkHealth() {
try {
const result = await this.client.ping();
return result === 'PONG';
} catch (error) {
logger.error('Redis health check failed:', error);
return false;
}
}
async disconnect() {
if (this.client) {
await this.client.quit();
}
if (this.subscriber) {
await this.subscriber.quit();
}
if (this.publisher) {
await this.publisher.quit();
}
logger.info('Redis connections closed');
}
getClient() {
return this.client;
}
getSubscriber() {
return this.subscriber;
}
getPublisher() {
return this.publisher;
}
// Utility methods
async setWithExpiry(key, value, ttl) {
return await this.client.setex(key, ttl, JSON.stringify(value));
}
async get(key) {
const value = await this.client.get(key);
return value ? JSON.parse(value) : null;
}
async del(key) {
return await this.client.del(key);
}
async exists(key) {
return await this.client.exists(key);
}
async incr(key) {
return await this.client.incr(key);
}
async decr(key) {
return await this.client.decr(key);
}
async expire(key, seconds) {
return await this.client.expire(key, seconds);
}
// List operations
async lpush(key, ...values) {
return await this.client.lpush(key, ...values.map(v => JSON.stringify(v)));
}
async rpush(key, ...values) {
return await this.client.rpush(key, ...values.map(v => JSON.stringify(v)));
}
async lpop(key) {
const value = await this.client.lpop(key);
return value ? JSON.parse(value) : null;
}
async rpop(key) {
const value = await this.client.rpop(key);
return value ? JSON.parse(value) : null;
}
async lrange(key, start, stop) {
const values = await this.client.lrange(key, start, stop);
return values.map(v => JSON.parse(v));
}
// Hash operations
async hset(key, field, value) {
return await this.client.hset(key, field, JSON.stringify(value));
}
async hget(key, field) {
const value = await this.client.hget(key, field);
return value ? JSON.parse(value) : null;
}
async hgetall(key) {
const data = await this.client.hgetall(key);
const result = {};
for (const [field, value] of Object.entries(data)) {
result[field] = JSON.parse(value);
}
return result;
}
// Set operations
async sadd(key, ...members) {
return await this.client.sadd(key, ...members);
}
async srem(key, ...members) {
return await this.client.srem(key, ...members);
}
async smembers(key) {
return await this.client.smembers(key);
}
async sismember(key, member) {
return await this.client.sismember(key, member);
}
}

View File

@@ -0,0 +1,140 @@
import Hapi from '@hapi/hapi';
import dotenv from 'dotenv';
import { logger } from './utils/logger.js';
import { Database } from './config/database.js';
import { RedisClient } from './config/redis.js';
import { TaskQueueManager } from './services/TaskQueueManager.js';
import { StateMachineEngine } from './services/StateMachineEngine.js';
import { MetricsService } from './services/MetricsService.js';
import routes from './routes/index.js';
// Load environment variables
dotenv.config();
const init = async () => {
// Initialize database
await Database.getInstance().connect();
// Initialize Redis
await RedisClient.getInstance().connect();
// Initialize services
const taskQueueManager = TaskQueueManager.getInstance();
const stateMachineEngine = StateMachineEngine.getInstance();
const metricsService = MetricsService.getInstance();
// Create Hapi server
const server = Hapi.server({
port: process.env.PORT || 3001,
host: '0.0.0.0',
routes: {
cors: {
origin: ['*'],
headers: ['Accept', 'Content-Type', 'Authorization'],
credentials: true
}
}
});
// Register plugins
await server.register([
{
plugin: await import('@hapi/jwt'),
options: {}
}
]);
// Configure JWT authentication
server.auth.strategy('jwt', 'jwt', {
keys: process.env.JWT_SECRET,
verify: {
aud: 'urn:audience:orchestrator',
iss: 'urn:issuer:marketing-agent',
sub: false,
nbf: true,
exp: true,
maxAgeSec: 14400, // 4 hours
timeSkewSec: 15
},
validate: (artifacts, request, h) => {
return {
isValid: true,
credentials: { user: artifacts.decoded.payload.user }
};
}
});
server.auth.default('jwt');
// Register routes
server.route(routes);
// Health check endpoint
server.route({
method: 'GET',
path: '/health',
options: {
auth: false,
handler: async (request, h) => {
const dbHealth = await Database.getInstance().checkHealth();
const redisHealth = await RedisClient.getInstance().checkHealth();
const queueHealth = await taskQueueManager.checkHealth();
const isHealthy = dbHealth && redisHealth && queueHealth;
return h.response({
status: isHealthy ? 'healthy' : 'unhealthy',
timestamp: new Date().toISOString(),
services: {
database: dbHealth ? 'up' : 'down',
redis: redisHealth ? 'up' : 'down',
queue: queueHealth ? 'up' : 'down'
}
}).code(isHealthy ? 200 : 503);
}
}
});
// Metrics endpoint
server.route({
method: 'GET',
path: '/metrics',
options: {
auth: false,
handler: async (request, h) => {
const metrics = await metricsService.getMetrics();
return h.response(metrics).type('text/plain');
}
}
});
// Start server
await server.start();
logger.info(`Orchestrator service started on ${server.info.uri}`);
// Start background workers
await taskQueueManager.startWorkers();
logger.info('Task queue workers started');
// Graceful shutdown
process.on('SIGINT', async () => {
logger.info('Shutting down gracefully...');
await taskQueueManager.stopWorkers();
await server.stop();
await Database.getInstance().disconnect();
await RedisClient.getInstance().disconnect();
process.exit(0);
});
};
// Handle uncaught errors
process.on('unhandledRejection', (err) => {
logger.error('Unhandled rejection:', err);
process.exit(1);
});
// Start the service
init().catch((err) => {
logger.error('Failed to start service:', err);
process.exit(1);
});

View File

@@ -0,0 +1,173 @@
import { DataTypes } from 'sequelize';
import { Database } from '../config/database.js';
const sequelize = Database.getInstance().getSequelize();
export const Campaign = sequelize.define('Campaign', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
// Multi-tenant support
tenantId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'tenants',
key: 'id'
}
},
name: {
type: DataTypes.STRING,
allowNull: false,
validate: {
notEmpty: true
}
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
type: {
type: DataTypes.ENUM('message', 'invitation', 'data_collection', 'engagement', 'custom'),
allowNull: false
},
status: {
type: DataTypes.ENUM('draft', 'scheduled', 'active', 'paused', 'completed', 'cancelled'),
defaultValue: 'draft',
allowNull: false
},
goals: {
type: DataTypes.JSONB,
defaultValue: {},
comment: 'Campaign goals and KPIs'
},
targetAudience: {
type: DataTypes.JSONB,
defaultValue: {},
comment: 'Target audience criteria'
},
strategy: {
type: DataTypes.JSONB,
defaultValue: {},
comment: 'AI-generated campaign strategy'
},
budget: {
type: DataTypes.DECIMAL(10, 2),
allowNull: true
},
startDate: {
type: DataTypes.DATE,
allowNull: true
},
endDate: {
type: DataTypes.DATE,
allowNull: true
},
createdBy: {
type: DataTypes.STRING,
allowNull: false
},
metadata: {
type: DataTypes.JSONB,
defaultValue: {}
},
statistics: {
type: DataTypes.JSONB,
defaultValue: {
totalTasks: 0,
completedTasks: 0,
failedTasks: 0,
messagesSent: 0,
conversionsAchieved: 0,
totalCost: 0
}
}
}, {
tableName: 'campaigns',
timestamps: true,
indexes: [
{
fields: ['tenantId']
},
{
fields: ['tenantId', 'name']
},
{
fields: ['tenantId', 'type']
},
{
fields: ['tenantId', 'status']
},
{
fields: ['tenantId', 'startDate']
},
{
fields: ['tenantId', 'endDate']
},
{
fields: ['tenantId', 'createdBy']
}
]
});
// Instance methods
Campaign.prototype.isActive = function() {
const now = new Date();
return this.status === 'active' &&
(!this.startDate || this.startDate <= now) &&
(!this.endDate || this.endDate >= now);
};
Campaign.prototype.updateStatistics = async function(stats) {
const currentStats = this.statistics || {};
const updatedStats = {
...currentStats,
...stats,
lastUpdated: new Date()
};
this.statistics = updatedStats;
await this.save();
return updatedStats;
};
// Class methods
Campaign.getActiveCampaigns = async function() {
const now = new Date();
return await Campaign.findAll({
where: {
status: 'active',
[Op.or]: [
{ startDate: null },
{ startDate: { [Op.lte]: now } }
],
[Op.and]: [
{ endDate: null },
{ endDate: { [Op.gte]: now } }
]
}
});
};
Campaign.getCampaignsByStatus = async function(status, options = {}) {
return await Campaign.findAndCountAll({
where: { status },
...options
});
};
// Define associations
Campaign.associate = (models) => {
Campaign.hasMany(models.Task, {
foreignKey: 'campaignId',
as: 'tasks'
});
Campaign.hasMany(models.Workflow, {
foreignKey: 'campaignId',
as: 'workflows'
});
};

View File

@@ -0,0 +1,221 @@
import { DataTypes } from 'sequelize';
import { Database } from '../config/database.js';
const sequelize = Database.getInstance().getSequelize();
export const Task = sequelize.define('Task', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
// Multi-tenant support
tenantId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: 'tenants',
key: 'id'
}
},
type: {
type: DataTypes.STRING,
allowNull: false,
comment: 'Task type (e.g., send_message, create_campaign, analyze_data)'
},
priority: {
type: DataTypes.ENUM('high', 'normal', 'low'),
defaultValue: 'normal',
allowNull: false
},
status: {
type: DataTypes.ENUM('pending', 'processing', 'completed', 'failed', 'cancelled'),
defaultValue: 'pending',
allowNull: false
},
data: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'JSON stringified task data'
},
result: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'JSON stringified task result'
},
error: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Error message if task failed'
},
attempts: {
type: DataTypes.INTEGER,
defaultValue: 0
},
maxAttempts: {
type: DataTypes.INTEGER,
defaultValue: 3
},
campaignId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'Campaigns',
key: 'id'
}
},
workflowId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'Workflows',
key: 'id'
}
},
parentTaskId: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'Tasks',
key: 'id'
}
},
createdBy: {
type: DataTypes.STRING,
allowNull: false
},
scheduledAt: {
type: DataTypes.DATE,
allowNull: true
},
startedAt: {
type: DataTypes.DATE,
allowNull: true
},
completedAt: {
type: DataTypes.DATE,
allowNull: true
},
failedAt: {
type: DataTypes.DATE,
allowNull: true
},
metadata: {
type: DataTypes.JSONB,
defaultValue: {},
comment: 'Additional metadata for the task'
}
}, {
tableName: 'tasks',
timestamps: true,
indexes: [
{
fields: ['tenantId']
},
{
fields: ['tenantId', 'type']
},
{
fields: ['tenantId', 'status']
},
{
fields: ['tenantId', 'priority']
},
{
fields: ['tenantId', 'campaignId']
},
{
fields: ['tenantId', 'workflowId']
},
{
fields: ['tenantId', 'createdAt']
},
{
fields: ['tenantId', 'scheduledAt']
}
]
});
// Instance methods
Task.prototype.toJSON = function() {
const values = Object.assign({}, this.get());
// Parse JSON fields
if (values.data) {
try {
values.data = JSON.parse(values.data);
} catch (e) {}
}
if (values.result) {
try {
values.result = JSON.parse(values.result);
} catch (e) {}
}
return values;
};
// Class methods
Task.getPendingTasks = async function(limit = 100) {
return await Task.findAll({
where: {
status: 'pending',
scheduledAt: {
[Op.or]: [
null,
{ [Op.lte]: new Date() }
]
}
},
order: [
['priority', 'DESC'],
['createdAt', 'ASC']
],
limit
});
};
Task.getTasksByStatus = async function(status, options = {}) {
return await Task.findAndCountAll({
where: { status },
...options
});
};
Task.getTasksByCampaign = async function(campaignId, options = {}) {
return await Task.findAndCountAll({
where: { campaignId },
...options
});
};
Task.getTasksByWorkflow = async function(workflowId, options = {}) {
return await Task.findAndCountAll({
where: { workflowId },
...options
});
};
// Define associations
Task.associate = (models) => {
Task.belongsTo(models.Campaign, {
foreignKey: 'campaignId',
as: 'campaign'
});
Task.belongsTo(models.Workflow, {
foreignKey: 'workflowId',
as: 'workflow'
});
Task.belongsTo(Task, {
foreignKey: 'parentTaskId',
as: 'parentTask'
});
Task.hasMany(Task, {
foreignKey: 'parentTaskId',
as: 'subTasks'
});
};

View File

@@ -0,0 +1,330 @@
import { Campaign } from '../models/Campaign.js';
import { CampaignExecutor } from '../services/CampaignExecutor.js';
import { logger } from '../utils/logger.js';
import Boom from '@hapi/boom';
const campaignExecutor = CampaignExecutor.getInstance();
const routes = [
{
method: 'GET',
path: '/campaigns',
options: {
handler: async (request, h) => {
try {
const { status, type, page = 1, limit = 10 } = request.query;
const where = {};
if (status) where.status = status;
if (type) where.type = type;
const offset = (page - 1) * limit;
const { count, rows: campaigns } = await Campaign.findAndCountAll({
where,
limit: parseInt(limit),
offset: parseInt(offset),
order: [['createdAt', 'DESC']]
});
return {
success: true,
data: {
campaigns,
total: count,
page: parseInt(page),
totalPages: Math.ceil(count / limit)
}
};
} catch (error) {
logger.error('Failed to fetch campaigns:', error);
throw Boom.internal('Failed to fetch campaigns');
}
}
}
},
{
method: 'GET',
path: '/campaigns/{id}',
options: {
handler: async (request, h) => {
try {
const campaign = await Campaign.findByPk(request.params.id);
if (!campaign) {
throw Boom.notFound('Campaign not found');
}
return {
success: true,
data: campaign
};
} catch (error) {
logger.error('Failed to fetch campaign:', error);
if (error.isBoom) throw error;
throw Boom.internal('Failed to fetch campaign');
}
}
}
},
{
method: 'POST',
path: '/campaigns',
options: {
handler: async (request, h) => {
try {
const {
name,
description,
type,
goals,
targetAudience,
message,
startDate,
endDate,
budget
} = request.payload;
const campaign = await Campaign.create({
name,
description,
type,
goals,
targetAudience,
metadata: { message },
startDate,
endDate,
budget,
createdBy: request.auth.credentials?.user?.id || 'system',
status: 'draft'
});
return h.response({
success: true,
data: campaign
}).code(201);
} catch (error) {
logger.error('Failed to create campaign:', error);
throw Boom.internal('Failed to create campaign');
}
}
}
},
{
method: 'PUT',
path: '/campaigns/{id}',
options: {
handler: async (request, h) => {
try {
const campaign = await Campaign.findByPk(request.params.id);
if (!campaign) {
throw Boom.notFound('Campaign not found');
}
// Don't allow updating active campaigns
if (campaign.status === 'active') {
throw Boom.badRequest('Cannot update active campaign');
}
const allowedUpdates = [
'name', 'description', 'goals', 'targetAudience',
'startDate', 'endDate', 'budget', 'metadata'
];
allowedUpdates.forEach(field => {
if (request.payload[field] !== undefined) {
campaign[field] = request.payload[field];
}
});
await campaign.save();
return {
success: true,
data: campaign
};
} catch (error) {
logger.error('Failed to update campaign:', error);
if (error.isBoom) throw error;
throw Boom.internal('Failed to update campaign');
}
}
}
},
{
method: 'DELETE',
path: '/campaigns/{id}',
options: {
handler: async (request, h) => {
try {
const campaign = await Campaign.findByPk(request.params.id);
if (!campaign) {
throw Boom.notFound('Campaign not found');
}
// Don't allow deleting active campaigns
if (campaign.status === 'active') {
throw Boom.badRequest('Cannot delete active campaign');
}
await campaign.destroy();
return {
success: true,
message: 'Campaign deleted successfully'
};
} catch (error) {
logger.error('Failed to delete campaign:', error);
if (error.isBoom) throw error;
throw Boom.internal('Failed to delete campaign');
}
}
}
},
{
method: 'POST',
path: '/campaigns/{id}/execute',
options: {
handler: async (request, h) => {
try {
const result = await campaignExecutor.executeCampaign(request.params.id);
return {
success: true,
data: result
};
} catch (error) {
logger.error('Failed to execute campaign:', error);
throw Boom.internal(error.message || 'Failed to execute campaign');
}
}
}
},
{
method: 'POST',
path: '/campaigns/{id}/pause',
options: {
handler: async (request, h) => {
try {
const result = await campaignExecutor.pauseCampaign(request.params.id);
return {
success: true,
data: result
};
} catch (error) {
logger.error('Failed to pause campaign:', error);
throw Boom.internal(error.message || 'Failed to pause campaign');
}
}
}
},
{
method: 'POST',
path: '/campaigns/{id}/resume',
options: {
handler: async (request, h) => {
try {
const result = await campaignExecutor.resumeCampaign(request.params.id);
return {
success: true,
data: result
};
} catch (error) {
logger.error('Failed to resume campaign:', error);
throw Boom.internal(error.message || 'Failed to resume campaign');
}
}
}
},
{
method: 'GET',
path: '/campaigns/{id}/progress',
options: {
handler: async (request, h) => {
try {
const progress = await campaignExecutor.getCampaignProgress(request.params.id);
return {
success: true,
data: progress
};
} catch (error) {
logger.error('Failed to get campaign progress:', error);
throw Boom.internal(error.message || 'Failed to get campaign progress');
}
}
}
},
{
method: 'GET',
path: '/campaigns/{id}/statistics',
options: {
handler: async (request, h) => {
try {
const campaign = await Campaign.findByPk(request.params.id);
if (!campaign) {
throw Boom.notFound('Campaign not found');
}
return {
success: true,
data: campaign.statistics || {}
};
} catch (error) {
logger.error('Failed to get campaign statistics:', error);
if (error.isBoom) throw error;
throw Boom.internal('Failed to get campaign statistics');
}
}
}
},
{
method: 'POST',
path: '/campaigns/{id}/clone',
options: {
handler: async (request, h) => {
try {
const sourceCampaign = await Campaign.findByPk(request.params.id);
if (!sourceCampaign) {
throw Boom.notFound('Campaign not found');
}
const clonedData = sourceCampaign.toJSON();
delete clonedData.id;
delete clonedData.createdAt;
delete clonedData.updatedAt;
clonedData.name = `${clonedData.name} (Copy)`;
clonedData.status = 'draft';
clonedData.statistics = {
totalTasks: 0,
completedTasks: 0,
failedTasks: 0,
messagesSent: 0,
conversionsAchieved: 0,
totalCost: 0
};
const clonedCampaign = await Campaign.create(clonedData);
return h.response({
success: true,
data: clonedCampaign
}).code(201);
} catch (error) {
logger.error('Failed to clone campaign:', error);
if (error.isBoom) throw error;
throw Boom.internal('Failed to clone campaign');
}
}
}
}
];
export default routes;

View File

@@ -0,0 +1,11 @@
import campaignRoutes from './campaigns.js';
import messageRoutes from './messages.js';
import workflowRoutes from './workflows.js';
const routes = [
...campaignRoutes,
...messageRoutes,
...workflowRoutes
];
export default routes;

View File

@@ -0,0 +1,481 @@
import { logger } from '../utils/logger.js';
import axios from 'axios';
import Boom from '@hapi/boom';
import { TemplateClient } from '../../../../shared/utils/templateClient.js';
// Initialize template client
const templateClient = new TemplateClient({
baseURL: process.env.TEMPLATE_SERVICE_URL || 'http://localhost:3010'
});
// In-memory storage for templates (should be moved to database)
const messageTemplates = new Map();
// Predefined templates
const defaultTemplates = [
{
id: 'welcome-1',
name: 'Welcome Message',
content: 'Welcome to {{company}}! 👋\n\nWe\'re excited to have you here. {{personalized_message}}',
category: 'onboarding',
variables: ['company', 'personalized_message'],
usage: 0
},
{
id: 'promo-1',
name: 'Special Offer',
content: '🎉 Special Offer Alert!\n\n{{offer_title}}\n\n✨ {{discount}}% OFF\n📅 Valid until: {{end_date}}\n\n👉 {{cta_text}}',
category: 'promotional',
variables: ['offer_title', 'discount', 'end_date', 'cta_text'],
usage: 0
},
{
id: 'reminder-1',
name: 'Event Reminder',
content: '⏰ Reminder: {{event_name}}\n\n📅 Date: {{event_date}}\n📍 Location: {{location}}\n\nDon\'t forget to {{action}}!',
category: 'reminder',
variables: ['event_name', 'event_date', 'location', 'action'],
usage: 0
},
{
id: 'survey-1',
name: 'Feedback Request',
content: 'Hi {{name}}! 👋\n\nWe\'d love to hear your thoughts about {{product_service}}.\n\nCould you spare 2 minutes for a quick survey?\n\n🔗 {{survey_link}}\n\nThank you! 🙏',
category: 'engagement',
variables: ['name', 'product_service', 'survey_link'],
usage: 0
}
];
// Initialize default templates
defaultTemplates.forEach(template => {
messageTemplates.set(template.id, template);
});
const routes = [
{
method: 'GET',
path: '/messages/templates',
options: {
handler: async (request, h) => {
try {
const { category } = request.query;
let templates = Array.from(messageTemplates.values());
if (category) {
templates = templates.filter(t => t.category === category);
}
return {
success: true,
data: templates
};
} catch (error) {
logger.error('Failed to fetch templates:', error);
throw Boom.internal('Failed to fetch templates');
}
}
}
},
{
method: 'GET',
path: '/messages/templates/{id}',
options: {
handler: async (request, h) => {
try {
const template = messageTemplates.get(request.params.id);
if (!template) {
throw Boom.notFound('Template not found');
}
return {
success: true,
data: template
};
} catch (error) {
logger.error('Failed to fetch template:', error);
if (error.isBoom) throw error;
throw Boom.internal('Failed to fetch template');
}
}
}
},
{
method: 'POST',
path: '/messages/templates',
options: {
handler: async (request, h) => {
try {
const { name, content, category, variables = [] } = request.payload;
if (!name || !content || !category) {
throw Boom.badRequest('Missing required fields: name, content, category');
}
const template = {
id: `custom-${Date.now()}`,
name,
content,
category,
variables,
usage: 0,
createdAt: new Date().toISOString()
};
messageTemplates.set(template.id, template);
return h.response({
success: true,
data: template
}).code(201);
} catch (error) {
logger.error('Failed to create template:', error);
if (error.isBoom) throw error;
throw Boom.internal('Failed to create template');
}
}
}
},
{
method: 'PUT',
path: '/messages/templates/{id}',
options: {
handler: async (request, h) => {
try {
const template = messageTemplates.get(request.params.id);
if (!template) {
throw Boom.notFound('Template not found');
}
const { name, content, category, variables } = request.payload;
if (name) template.name = name;
if (content) template.content = content;
if (category) template.category = category;
if (variables) template.variables = variables;
template.updatedAt = new Date().toISOString();
return {
success: true,
data: template
};
} catch (error) {
logger.error('Failed to update template:', error);
if (error.isBoom) throw error;
throw Boom.internal('Failed to update template');
}
}
}
},
{
method: 'DELETE',
path: '/messages/templates/{id}',
options: {
handler: async (request, h) => {
try {
if (!messageTemplates.has(request.params.id)) {
throw Boom.notFound('Template not found');
}
messageTemplates.delete(request.params.id);
return {
success: true,
message: 'Template deleted successfully'
};
} catch (error) {
logger.error('Failed to delete template:', error);
if (error.isBoom) throw error;
throw Boom.internal('Failed to delete template');
}
}
}
},
{
method: 'POST',
path: '/messages/templates/{id}/preview',
options: {
handler: async (request, h) => {
try {
const template = messageTemplates.get(request.params.id);
if (!template) {
throw Boom.notFound('Template not found');
}
const { variables } = request.payload;
let content = template.content;
// Replace variables
if (variables) {
Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
content = content.replace(regex, value);
});
}
return {
success: true,
data: {
original: template.content,
preview: content,
variables: template.variables
}
};
} catch (error) {
logger.error('Failed to preview template:', error);
if (error.isBoom) throw error;
throw Boom.internal('Failed to preview template');
}
}
}
},
{
method: 'GET',
path: '/messages/history',
options: {
handler: async (request, h) => {
try {
// TODO: Implement actual message history from database
const mockHistory = [
{
id: 'msg-1',
campaignId: 'c1',
campaignName: 'Summer Sale 2025',
recipient: '+1234567890',
content: 'Special offer: 20% off all items!',
status: 'delivered',
sentAt: new Date(Date.now() - 3600000).toISOString(),
deliveredAt: new Date(Date.now() - 3590000).toISOString()
},
{
id: 'msg-2',
campaignId: 'c2',
campaignName: 'Welcome Series',
recipient: '+0987654321',
content: 'Welcome to our service!',
status: 'delivered',
sentAt: new Date(Date.now() - 7200000).toISOString(),
deliveredAt: new Date(Date.now() - 7190000).toISOString()
}
];
return {
success: true,
data: mockHistory
};
} catch (error) {
logger.error('Failed to fetch message history:', error);
throw Boom.internal('Failed to fetch message history');
}
}
}
},
{
method: 'POST',
path: '/messages/send',
options: {
handler: async (request, h) => {
try {
const { accountId, recipient, content, parseMode = 'md' } = request.payload;
if (!accountId || !recipient || !content) {
throw Boom.badRequest('Missing required fields: accountId, recipient, content');
}
// Send via gramjs-adapter
const gramjsUrl = process.env.GRAMJS_ADAPTER_URL || 'http://gramjs-adapter:3003';
const response = await axios.post(`${gramjsUrl}/messages/send`, {
accountId,
chatId: recipient,
message: content,
parseMode
});
return response.data;
} catch (error) {
logger.error('Failed to send message:', error);
if (error.response?.status === 400) {
throw Boom.badRequest(error.response.data.error || 'Invalid request');
}
throw Boom.internal(error.response?.data?.error || 'Failed to send message');
}
}
}
},
{
method: 'POST',
path: '/messages/send-with-template',
options: {
handler: async (request, h) => {
try {
const {
accountId,
recipient,
templateId,
templateName,
language = 'en',
data = {},
parseMode = 'md'
} = request.payload;
if (!accountId || !recipient || (!templateId && !templateName)) {
throw Boom.badRequest('Missing required fields: accountId, recipient, and either templateId or templateName');
}
// Render template
let renderResult;
if (templateId) {
renderResult = await templateClient.renderTemplate(templateId, data);
} else {
renderResult = await templateClient.renderTemplateByName(templateName, language, data);
}
if (!renderResult.success) {
throw Boom.badRequest(renderResult.error || 'Failed to render template');
}
// Send via gramjs-adapter
const gramjsUrl = process.env.GRAMJS_ADAPTER_URL || 'http://gramjs-adapter:3003';
const response = await axios.post(`${gramjsUrl}/messages/send`, {
accountId,
chatId: recipient,
message: renderResult.content,
parseMode: renderResult.format === 'html' ? 'html' : parseMode
});
return {
...response.data,
template: {
id: templateId,
name: templateName,
format: renderResult.format
}
};
} catch (error) {
logger.error('Failed to send message with template:', error);
if (error.isBoom) throw error;
if (error.response?.status === 400) {
throw Boom.badRequest(error.response.data.error || 'Invalid request');
}
throw Boom.internal(error.response?.data?.error || 'Failed to send message with template');
}
}
}
},
{
method: 'POST',
path: '/messages/bulk',
options: {
handler: async (request, h) => {
try {
const { accountId, recipients, content, parseMode = 'md' } = request.payload;
if (!accountId || !recipients || !content) {
throw Boom.badRequest('Missing required fields: accountId, recipients, content');
}
// Send via gramjs-adapter
const gramjsUrl = process.env.GRAMJS_ADAPTER_URL || 'http://gramjs-adapter:3003';
const response = await axios.post(`${gramjsUrl}/messages/bulk`, {
accountId,
recipients,
message: content,
parseMode
});
return response.data;
} catch (error) {
logger.error('Failed to send bulk messages:', error);
throw Boom.internal(error.response?.data?.error || 'Failed to send bulk messages');
}
}
}
},
{
method: 'POST',
path: '/messages/bulk-with-template',
options: {
handler: async (request, h) => {
try {
const {
accountId,
recipients,
templateId,
templateName,
language = 'en',
data = {},
personalizedData = {},
parseMode = 'md'
} = request.payload;
if (!accountId || !recipients || (!templateId && !templateName)) {
throw Boom.badRequest('Missing required fields: accountId, recipients, and either templateId or templateName');
}
// Prepare batch render requests
const renderRequests = recipients.map(recipient => {
const recipientData = {
...data,
...(personalizedData[recipient] || {})
};
return {
templateId,
templateName,
language,
data: recipientData,
recipient
};
});
// Batch render templates
const batchResult = await templateClient.batchRenderTemplates(renderRequests);
if (!batchResult.success) {
throw Boom.badRequest(batchResult.error || 'Failed to render templates');
}
// Prepare messages for bulk send
const messages = batchResult.results.map(result => ({
recipient: result.recipient,
content: result.content,
format: result.format
}));
// Send via gramjs-adapter
const gramjsUrl = process.env.GRAMJS_ADAPTER_URL || 'http://gramjs-adapter:3003';
const response = await axios.post(`${gramjsUrl}/messages/bulk-custom`, {
accountId,
messages: messages.map(msg => ({
chatId: msg.recipient,
message: msg.content,
parseMode: msg.format === 'html' ? 'html' : parseMode
}))
});
return {
...response.data,
template: {
id: templateId,
name: templateName,
recipientCount: recipients.length
}
};
} catch (error) {
logger.error('Failed to send bulk messages with template:', error);
if (error.isBoom) throw error;
if (error.response?.status === 400) {
throw Boom.badRequest(error.response.data.error || 'Invalid request');
}
throw Boom.internal(error.response?.data?.error || 'Failed to send bulk messages with template');
}
}
}
}
];
export default routes;

View File

@@ -0,0 +1,25 @@
import { logger } from '../utils/logger.js';
import Boom from '@hapi/boom';
const routes = [
{
method: 'GET',
path: '/workflows',
options: {
handler: async (request, h) => {
try {
// TODO: Implement workflow management
return {
success: true,
data: []
};
} catch (error) {
logger.error('Failed to fetch workflows:', error);
throw Boom.internal('Failed to fetch workflows');
}
}
}
}
];
export default routes;

View File

@@ -0,0 +1,375 @@
import { Campaign } from '../models/Campaign.js';
import { Task } from '../models/Task.js';
import { logger } from '../utils/logger.js';
import axios from 'axios';
import { Queue } from 'bullmq';
import { RedisClient } from '../config/redis.js';
export class CampaignExecutor {
constructor() {
const redisClient = RedisClient.getInstance();
this.messageQueue = new Queue('message-queue', {
connection: {
host: process.env.REDIS_HOST || 'redis',
port: parseInt(process.env.REDIS_PORT) || 6379
}
});
this.gramjsApiUrl = process.env.GRAMJS_ADAPTER_URL || 'http://gramjs-adapter:3003';
this.setupQueueProcessors();
}
static getInstance() {
if (!CampaignExecutor.instance) {
CampaignExecutor.instance = new CampaignExecutor();
}
return CampaignExecutor.instance;
}
setupQueueProcessors() {
// Process messages from the queue
this.messageQueue.process('send-message', 5, async (job) => {
const { accountId, recipient, message, campaignId, taskId } = job.data;
try {
logger.info(`Processing message job ${job.id} for campaign ${campaignId}`);
// Send message via gramjs-adapter
const response = await axios.post(`${this.gramjsApiUrl}/messages/send`, {
accountId,
chatId: recipient,
message: message.content,
parseMode: message.parseMode || 'md',
buttons: message.buttons
});
if (response.data.success) {
await this.updateTaskStatus(taskId, 'completed', {
messageId: response.data.data.messageId,
sentAt: new Date()
});
await this.incrementCampaignStats(campaignId, {
messagesSent: 1
});
return response.data;
} else {
throw new Error(response.data.error || 'Failed to send message');
}
} catch (error) {
logger.error(`Failed to send message for job ${job.id}:`, error);
// Handle rate limiting
if (error.response?.status === 429 || error.message?.includes('FLOOD_WAIT')) {
const retryAfter = error.response?.headers['retry-after'] || 60;
throw new Error(`Rate limited. Retry after ${retryAfter} seconds`);
}
await this.updateTaskStatus(taskId, 'failed', {
error: error.message,
failedAt: new Date()
});
await this.incrementCampaignStats(campaignId, {
failedTasks: 1
});
throw error;
}
});
// Handle bulk message campaigns
this.messageQueue.process('bulk-send', 2, async (job) => {
const { campaignId, accountId, recipients, message } = job.data;
try {
logger.info(`Processing bulk send for campaign ${campaignId} with ${recipients.length} recipients`);
// Create individual message jobs
const jobs = recipients.map(recipient => ({
name: 'send-message',
data: {
accountId,
recipient,
message,
campaignId,
taskId: `${campaignId}-${recipient}`
},
opts: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000
},
removeOnComplete: true,
removeOnFail: false
}
}));
await this.messageQueue.addBulk(jobs);
return { queued: jobs.length };
} catch (error) {
logger.error(`Failed to process bulk send:`, error);
throw error;
}
});
}
async executeCampaign(campaignId) {
try {
const campaign = await Campaign.findByPk(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
if (campaign.status !== 'scheduled' && campaign.status !== 'active') {
throw new Error(`Campaign is not ready for execution. Current status: ${campaign.status}`);
}
// Update campaign status to active
campaign.status = 'active';
await campaign.save();
logger.info(`Starting execution of campaign ${campaignId}: ${campaign.name}`);
// Get campaign tasks or create based on target audience
const tasks = await this.generateCampaignTasks(campaign);
// Queue tasks for execution
for (const task of tasks) {
await this.queueTask(task, campaign);
}
return {
success: true,
campaignId,
tasksQueued: tasks.length
};
} catch (error) {
logger.error(`Failed to execute campaign ${campaignId}:`, error);
throw error;
}
}
async generateCampaignTasks(campaign) {
const tasks = [];
// Get target recipients based on campaign criteria
const recipients = await this.getTargetRecipients(campaign.targetAudience);
// Get available Telegram accounts
const accounts = await this.getAvailableAccounts();
if (accounts.length === 0) {
throw new Error('No available Telegram accounts for sending messages');
}
// Distribute recipients across accounts
const recipientsPerAccount = Math.ceil(recipients.length / accounts.length);
for (let i = 0; i < accounts.length; i++) {
const accountRecipients = recipients.slice(
i * recipientsPerAccount,
(i + 1) * recipientsPerAccount
);
if (accountRecipients.length > 0) {
const task = await Task.create({
campaignId: campaign.id,
type: 'bulk_message',
status: 'pending',
data: {
accountId: accounts[i].id,
recipients: accountRecipients,
message: campaign.metadata.message
},
scheduledAt: new Date()
});
tasks.push(task);
}
}
return tasks;
}
async queueTask(task, campaign) {
const jobData = {
campaignId: campaign.id,
...task.data
};
const job = await this.messageQueue.add(
task.type === 'bulk_message' ? 'bulk-send' : 'send-message',
jobData,
{
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000
},
removeOnComplete: true,
removeOnFail: false
}
);
// Update task with job ID
task.metadata = { ...task.metadata, jobId: job.id };
await task.save();
logger.info(`Queued task ${task.id} with job ${job.id}`);
}
async getTargetRecipients(targetAudience) {
// TODO: Implement recipient selection based on target audience criteria
// For now, return mock data
return [
'+1234567890',
'+0987654321',
// Add more test recipients
];
}
async getAvailableAccounts() {
try {
const response = await axios.get(`${this.gramjsApiUrl}/accounts`);
if (response.data.success) {
return response.data.data.filter(account =>
account.connected && account.status === 'active'
);
}
return [];
} catch (error) {
logger.error('Failed to get available accounts:', error);
return [];
}
}
async updateTaskStatus(taskId, status, metadata = {}) {
try {
const task = await Task.findByPk(taskId);
if (task) {
task.status = status;
task.metadata = { ...task.metadata, ...metadata };
await task.save();
}
} catch (error) {
logger.error(`Failed to update task ${taskId} status:`, error);
}
}
async incrementCampaignStats(campaignId, stats) {
try {
const campaign = await Campaign.findByPk(campaignId);
if (campaign) {
const currentStats = campaign.statistics || {};
const updatedStats = {};
for (const [key, value] of Object.entries(stats)) {
updatedStats[key] = (currentStats[key] || 0) + value;
}
await campaign.updateStatistics(updatedStats);
}
} catch (error) {
logger.error(`Failed to update campaign ${campaignId} stats:`, error);
}
}
async pauseCampaign(campaignId) {
try {
const campaign = await Campaign.findByPk(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
campaign.status = 'paused';
await campaign.save();
// Pause all pending jobs for this campaign
const jobs = await this.messageQueue.getJobs(['waiting', 'delayed']);
for (const job of jobs) {
if (job.data.campaignId === campaignId) {
await job.pause();
}
}
logger.info(`Campaign ${campaignId} paused`);
return { success: true };
} catch (error) {
logger.error(`Failed to pause campaign ${campaignId}:`, error);
throw error;
}
}
async resumeCampaign(campaignId) {
try {
const campaign = await Campaign.findByPk(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
campaign.status = 'active';
await campaign.save();
// Resume all paused jobs for this campaign
const jobs = await this.messageQueue.getJobs(['paused']);
for (const job of jobs) {
if (job.data.campaignId === campaignId) {
await job.resume();
}
}
logger.info(`Campaign ${campaignId} resumed`);
return { success: true };
} catch (error) {
logger.error(`Failed to resume campaign ${campaignId}:`, error);
throw error;
}
}
async getCampaignProgress(campaignId) {
try {
const campaign = await Campaign.findByPk(campaignId);
if (!campaign) {
throw new Error('Campaign not found');
}
const stats = campaign.statistics || {};
const totalTasks = stats.totalTasks || 0;
const completedTasks = stats.completedTasks || 0;
const failedTasks = stats.failedTasks || 0;
const progress = totalTasks > 0 ? ((completedTasks + failedTasks) / totalTasks) * 100 : 0;
// Get queue stats
const waiting = await this.messageQueue.getWaitingCount();
const active = await this.messageQueue.getActiveCount();
const completed = await this.messageQueue.getCompletedCount();
const failed = await this.messageQueue.getFailedCount();
return {
campaign: {
id: campaign.id,
name: campaign.name,
status: campaign.status,
progress: Math.round(progress)
},
statistics: stats,
queue: {
waiting,
active,
completed,
failed
}
};
} catch (error) {
logger.error(`Failed to get campaign progress:`, error);
throw error;
}
}
}

View File

@@ -0,0 +1,138 @@
import { logger } from '../utils/logger.js';
export class MetricsService {
constructor() {
this.metrics = {
campaigns: {
total: 0,
active: 0,
completed: 0,
failed: 0
},
messages: {
sent: 0,
delivered: 0,
failed: 0,
queued: 0
},
performance: {
avgProcessingTime: 0,
queueLength: 0,
errorRate: 0
}
};
}
static getInstance() {
if (!MetricsService.instance) {
MetricsService.instance = new MetricsService();
}
return MetricsService.instance;
}
async getMetrics() {
try {
// Format metrics in Prometheus format
const lines = [];
// Campaign metrics
lines.push('# HELP campaigns_total Total number of campaigns');
lines.push('# TYPE campaigns_total counter');
lines.push(`campaigns_total ${this.metrics.campaigns.total}`);
lines.push('# HELP campaigns_active Number of active campaigns');
lines.push('# TYPE campaigns_active gauge');
lines.push(`campaigns_active ${this.metrics.campaigns.active}`);
// Message metrics
lines.push('# HELP messages_sent_total Total number of messages sent');
lines.push('# TYPE messages_sent_total counter');
lines.push(`messages_sent_total ${this.metrics.messages.sent}`);
lines.push('# HELP messages_delivered_total Total number of messages delivered');
lines.push('# TYPE messages_delivered_total counter');
lines.push(`messages_delivered_total ${this.metrics.messages.delivered}`);
lines.push('# HELP messages_failed_total Total number of failed messages');
lines.push('# TYPE messages_failed_total counter');
lines.push(`messages_failed_total ${this.metrics.messages.failed}`);
lines.push('# HELP messages_queued Current number of queued messages');
lines.push('# TYPE messages_queued gauge');
lines.push(`messages_queued ${this.metrics.messages.queued}`);
// Performance metrics
lines.push('# HELP processing_time_avg Average message processing time in ms');
lines.push('# TYPE processing_time_avg gauge');
lines.push(`processing_time_avg ${this.metrics.performance.avgProcessingTime}`);
lines.push('# HELP error_rate Current error rate percentage');
lines.push('# TYPE error_rate gauge');
lines.push(`error_rate ${this.metrics.performance.errorRate}`);
return lines.join('\n');
} catch (error) {
logger.error('Failed to get metrics:', error);
throw error;
}
}
incrementCampaignCount(status) {
this.metrics.campaigns.total++;
if (status === 'active') {
this.metrics.campaigns.active++;
} else if (status === 'completed') {
this.metrics.campaigns.completed++;
} else if (status === 'failed') {
this.metrics.campaigns.failed++;
}
}
updateCampaignStatus(oldStatus, newStatus) {
if (oldStatus === 'active' && newStatus !== 'active') {
this.metrics.campaigns.active--;
} else if (oldStatus !== 'active' && newStatus === 'active') {
this.metrics.campaigns.active++;
}
if (newStatus === 'completed') {
this.metrics.campaigns.completed++;
} else if (newStatus === 'failed') {
this.metrics.campaigns.failed++;
}
}
incrementMessageCount(type) {
switch (type) {
case 'sent':
this.metrics.messages.sent++;
break;
case 'delivered':
this.metrics.messages.delivered++;
break;
case 'failed':
this.metrics.messages.failed++;
break;
}
}
updateQueueLength(length) {
this.metrics.messages.queued = length;
}
updateProcessingTime(time) {
// Simple moving average
const currentAvg = this.metrics.performance.avgProcessingTime;
const count = this.metrics.messages.sent + this.metrics.messages.failed;
this.metrics.performance.avgProcessingTime =
(currentAvg * (count - 1) + time) / count;
}
updateErrorRate() {
const total = this.metrics.messages.sent + this.metrics.messages.failed;
if (total > 0) {
this.metrics.performance.errorRate =
(this.metrics.messages.failed / total) * 100;
}
}
}

View File

@@ -0,0 +1,450 @@
import { createMachine, interpret } from 'xstate';
import { logger } from '../utils/logger.js';
import { EventEmitter } from 'events';
export class StateMachineEngine extends EventEmitter {
constructor() {
super();
this.machines = new Map();
this.services = new Map();
}
static getInstance() {
if (!StateMachineEngine.instance) {
StateMachineEngine.instance = new StateMachineEngine();
}
return StateMachineEngine.instance;
}
// Define campaign workflow state machine
createCampaignMachine() {
return createMachine({
id: 'campaign',
initial: 'draft',
context: {
campaignId: null,
attempts: 0,
errors: []
},
states: {
draft: {
on: {
SUBMIT: {
target: 'validating',
actions: ['logTransition']
},
SAVE: {
target: 'draft',
actions: ['saveDraft']
}
}
},
validating: {
invoke: {
src: 'validateCampaign',
onDone: {
target: 'planning',
actions: ['updateContext']
},
onError: {
target: 'draft',
actions: ['handleError']
}
}
},
planning: {
invoke: {
src: 'generateStrategy',
onDone: {
target: 'reviewing',
actions: ['updateStrategy']
},
onError: {
target: 'draft',
actions: ['handleError']
}
}
},
reviewing: {
on: {
APPROVE: {
target: 'scheduled',
actions: ['approveCampaign']
},
REJECT: {
target: 'draft',
actions: ['rejectCampaign']
},
REQUEST_CHANGES: {
target: 'draft',
actions: ['requestChanges']
}
}
},
scheduled: {
on: {
START: {
target: 'active',
cond: 'canStart'
},
CANCEL: {
target: 'cancelled'
}
},
after: {
CHECK_START_TIME: {
target: 'active',
cond: 'isStartTime'
}
}
},
active: {
on: {
PAUSE: {
target: 'paused'
},
COMPLETE: {
target: 'completed'
},
FAIL: {
target: 'failed'
}
},
invoke: {
src: 'executeCampaign',
onDone: {
target: 'completed'
},
onError: {
target: 'failed',
actions: ['handleError']
}
}
},
paused: {
on: {
RESUME: {
target: 'active'
},
CANCEL: {
target: 'cancelled'
}
}
},
completed: {
type: 'final',
entry: ['notifyCompletion']
},
failed: {
on: {
RETRY: {
target: 'active',
cond: 'canRetry'
},
CANCEL: {
target: 'cancelled'
}
}
},
cancelled: {
type: 'final',
entry: ['notifyCancellation']
}
}
}, {
actions: {
logTransition: (context, event) => {
logger.info(`Campaign ${context.campaignId}: ${event.type}`);
},
saveDraft: (context, event) => {
this.emit('campaign:draft:saved', { campaignId: context.campaignId });
},
updateContext: (context, event) => {
Object.assign(context, event.data);
},
handleError: (context, event) => {
context.errors.push(event.data);
context.attempts += 1;
logger.error(`Campaign ${context.campaignId} error:`, event.data);
},
updateStrategy: (context, event) => {
context.strategy = event.data;
this.emit('campaign:strategy:generated', {
campaignId: context.campaignId,
strategy: event.data
});
},
approveCampaign: (context) => {
this.emit('campaign:approved', { campaignId: context.campaignId });
},
rejectCampaign: (context) => {
this.emit('campaign:rejected', { campaignId: context.campaignId });
},
requestChanges: (context, event) => {
this.emit('campaign:changes:requested', {
campaignId: context.campaignId,
changes: event.changes
});
},
notifyCompletion: (context) => {
this.emit('campaign:completed', { campaignId: context.campaignId });
},
notifyCancellation: (context) => {
this.emit('campaign:cancelled', { campaignId: context.campaignId });
}
},
guards: {
canStart: (context) => {
// Check if campaign can start
return true;
},
isStartTime: (context) => {
// Check if scheduled start time has been reached
return new Date() >= new Date(context.startDate);
},
canRetry: (context) => {
return context.attempts < 3;
}
},
services: {
validateCampaign: async (context) => {
// Validate campaign data
return { valid: true };
},
generateStrategy: async (context) => {
// Call Claude Agent to generate strategy
return {
strategy: 'AI-generated strategy',
tactics: []
};
},
executeCampaign: async (context) => {
// Execute campaign tasks
return { success: true };
}
}
});
}
// Define task execution state machine
createTaskMachine() {
return createMachine({
id: 'task',
initial: 'pending',
context: {
taskId: null,
attempts: 0,
result: null,
error: null
},
states: {
pending: {
on: {
START: 'validating'
}
},
validating: {
invoke: {
src: 'validateTask',
onDone: {
target: 'queued',
actions: ['updateContext']
},
onError: {
target: 'failed',
actions: ['handleError']
}
}
},
queued: {
on: {
PROCESS: 'processing',
CANCEL: 'cancelled'
}
},
processing: {
invoke: {
src: 'processTask',
onDone: {
target: 'verifying',
actions: ['updateResult']
},
onError: [
{
target: 'retrying',
cond: 'canRetry',
actions: ['incrementAttempts']
},
{
target: 'failed',
actions: ['handleError']
}
]
}
},
retrying: {
after: {
RETRY_DELAY: 'processing'
}
},
verifying: {
invoke: {
src: 'verifyResult',
onDone: 'completed',
onError: 'failed'
}
},
completed: {
type: 'final',
entry: ['notifyCompletion']
},
failed: {
on: {
RETRY: {
target: 'processing',
cond: 'canRetry'
}
},
entry: ['notifyFailure']
},
cancelled: {
type: 'final',
entry: ['notifyCancellation']
}
}
}, {
actions: {
updateContext: (context, event) => {
Object.assign(context, event.data);
},
updateResult: (context, event) => {
context.result = event.data;
},
handleError: (context, event) => {
context.error = event.data;
logger.error(`Task ${context.taskId} error:`, event.data);
},
incrementAttempts: (context) => {
context.attempts += 1;
},
notifyCompletion: (context) => {
this.emit('task:completed', {
taskId: context.taskId,
result: context.result
});
},
notifyFailure: (context) => {
this.emit('task:failed', {
taskId: context.taskId,
error: context.error
});
},
notifyCancellation: (context) => {
this.emit('task:cancelled', { taskId: context.taskId });
}
},
guards: {
canRetry: (context) => {
return context.attempts < 3;
}
},
delays: {
RETRY_DELAY: (context) => {
// Exponential backoff
return Math.pow(2, context.attempts) * 1000;
}
},
services: {
validateTask: async (context) => {
// Validate task requirements
return { valid: true };
},
processTask: async (context) => {
// Process the actual task
return { success: true };
},
verifyResult: async (context) => {
// Verify task result
return { verified: true };
}
}
});
}
// Register a state machine
registerMachine(name, machine) {
this.machines.set(name, machine);
logger.info(`State machine registered: ${name}`);
}
// Create and start a state machine service
startMachine(name, context = {}) {
const machine = this.machines.get(name);
if (!machine) {
throw new Error(`Machine not found: ${name}`);
}
const service = interpret(machine.withContext({
...machine.context,
...context
}))
.onTransition((state) => {
logger.debug(`${name} transition:`, state.value);
this.emit('machine:transition', {
machine: name,
state: state.value,
context: state.context
});
})
.start();
const serviceId = `${name}-${Date.now()}`;
this.services.set(serviceId, service);
return serviceId;
}
// Send event to a running service
sendEvent(serviceId, event) {
const service = this.services.get(serviceId);
if (!service) {
throw new Error(`Service not found: ${serviceId}`);
}
service.send(event);
}
// Get current state of a service
getState(serviceId) {
const service = this.services.get(serviceId);
if (!service) {
return null;
}
return {
value: service.state.value,
context: service.state.context
};
}
// Stop a running service
stopMachine(serviceId) {
const service = this.services.get(serviceId);
if (!service) {
return false;
}
service.stop();
this.services.delete(serviceId);
logger.info(`State machine stopped: ${serviceId}`);
return true;
}
// Initialize default machines
initialize() {
this.registerMachine('campaign', this.createCampaignMachine());
this.registerMachine('task', this.createTaskMachine());
logger.info('State machine engine initialized');
}
}

View File

@@ -0,0 +1,375 @@
import { Queue, Worker } from 'bullmq';
import { logger } from '../utils/logger.js';
import { RedisClient } from '../config/redis.js';
import { Task } from '../models/Task.js';
import { EventEmitter } from 'events';
export class TaskQueueManager extends EventEmitter {
constructor() {
super();
this.queues = new Map();
this.workers = new Map();
this.connection = null;
}
static getInstance() {
if (!TaskQueueManager.instance) {
TaskQueueManager.instance = new TaskQueueManager();
}
return TaskQueueManager.instance;
}
async initialize() {
this.connection = RedisClient.getInstance().getClient();
// Initialize default queues
await this.createQueue('high-priority', { priority: 3 });
await this.createQueue('normal-priority', { priority: 2 });
await this.createQueue('low-priority', { priority: 1 });
await this.createQueue('scheduled', { priority: 2 });
logger.info('Task queue manager initialized');
}
async createQueue(name, options = {}) {
if (this.queues.has(name)) {
return this.queues.get(name);
}
const queue = new Queue(name, {
connection: this.connection,
defaultJobOptions: {
attempts: options.attempts || 3,
backoff: {
type: 'exponential',
delay: 2000
},
removeOnComplete: options.removeOnComplete || 100,
removeOnFail: options.removeOnFail || 50
}
});
this.queues.set(name, queue);
logger.info(`Queue created: ${name}`);
return queue;
}
async createWorker(queueName, processor, options = {}) {
const worker = new Worker(queueName, processor, {
connection: this.connection,
concurrency: options.concurrency || 5,
...options
});
// Set up event handlers
worker.on('completed', (job) => {
logger.info(`Job completed: ${job.id} in queue ${queueName}`);
this.emit('job:completed', { queue: queueName, job });
});
worker.on('failed', (job, err) => {
logger.error(`Job failed: ${job.id} in queue ${queueName}`, err);
this.emit('job:failed', { queue: queueName, job, error: err });
});
worker.on('active', (job) => {
logger.debug(`Job active: ${job.id} in queue ${queueName}`);
this.emit('job:active', { queue: queueName, job });
});
worker.on('stalled', (job) => {
logger.warn(`Job stalled: ${job.id} in queue ${queueName}`);
this.emit('job:stalled', { queue: queueName, job });
});
this.workers.set(queueName, worker);
logger.info(`Worker created for queue: ${queueName}`);
return worker;
}
async addTask(taskData) {
const { type, priority = 'normal', data, options = {} } = taskData;
// Determine queue based on priority
const queueName = `${priority}-priority`;
const queue = this.queues.get(queueName);
if (!queue) {
throw new Error(`Queue not found: ${queueName}`);
}
// Save task to database
const task = await Task.create({
type,
priority,
status: 'pending',
data: JSON.stringify(data),
createdBy: options.userId || 'system'
});
// Add job to queue
const job = await queue.add(type, {
taskId: task.id,
...data
}, {
...options,
jobId: task.id.toString()
});
logger.info(`Task added to queue: ${task.id} (${type})`);
return {
taskId: task.id,
jobId: job.id,
queue: queueName
};
}
async scheduleTask(taskData, delay) {
const result = await this.addTask({
...taskData,
options: {
...taskData.options,
delay
}
});
logger.info(`Task scheduled: ${result.taskId} (delay: ${delay}ms)`);
return result;
}
async getTaskStatus(taskId) {
// Check all queues
for (const [queueName, queue] of this.queues) {
const job = await queue.getJob(taskId.toString());
if (job) {
return {
id: job.id,
queue: queueName,
status: await job.getState(),
progress: job.progress,
data: job.data,
result: job.returnvalue,
failedReason: job.failedReason,
attemptsMade: job.attemptsMade,
timestamp: job.timestamp,
processedOn: job.processedOn,
finishedOn: job.finishedOn
};
}
}
return null;
}
async cancelTask(taskId) {
for (const [queueName, queue] of this.queues) {
const job = await queue.getJob(taskId.toString());
if (job) {
await job.remove();
// Update task status in database
await Task.update(
{ status: 'cancelled' },
{ where: { id: taskId } }
);
logger.info(`Task cancelled: ${taskId}`);
return true;
}
}
return false;
}
async retryTask(taskId) {
for (const [queueName, queue] of this.queues) {
const job = await queue.getJob(taskId.toString());
if (job && ['failed', 'completed'].includes(await job.getState())) {
await job.retry();
logger.info(`Task retried: ${taskId}`);
return true;
}
}
return false;
}
async getQueueStats(queueName) {
const queue = this.queues.get(queueName);
if (!queue) {
throw new Error(`Queue not found: ${queueName}`);
}
const [
waiting,
active,
completed,
failed,
delayed,
paused
] = await Promise.all([
queue.getWaitingCount(),
queue.getActiveCount(),
queue.getCompletedCount(),
queue.getFailedCount(),
queue.getDelayedCount(),
queue.getPausedCount()
]);
return {
name: queueName,
counts: {
waiting,
active,
completed,
failed,
delayed,
paused
},
isPaused: await queue.isPaused()
};
}
async getAllQueueStats() {
const stats = {};
for (const queueName of this.queues.keys()) {
stats[queueName] = await this.getQueueStats(queueName);
}
return stats;
}
async pauseQueue(queueName) {
const queue = this.queues.get(queueName);
if (!queue) {
throw new Error(`Queue not found: ${queueName}`);
}
await queue.pause();
logger.info(`Queue paused: ${queueName}`);
}
async resumeQueue(queueName) {
const queue = this.queues.get(queueName);
if (!queue) {
throw new Error(`Queue not found: ${queueName}`);
}
await queue.resume();
logger.info(`Queue resumed: ${queueName}`);
}
async drainQueue(queueName) {
const queue = this.queues.get(queueName);
if (!queue) {
throw new Error(`Queue not found: ${queueName}`);
}
await queue.drain();
logger.info(`Queue drained: ${queueName}`);
}
async cleanQueue(queueName, grace = 0, limit = 100, status) {
const queue = this.queues.get(queueName);
if (!queue) {
throw new Error(`Queue not found: ${queueName}`);
}
const jobs = await queue.clean(grace, limit, status);
logger.info(`Queue cleaned: ${queueName} (removed ${jobs.length} jobs)`);
return jobs.length;
}
async startWorkers() {
// High priority worker
await this.createWorker('high-priority', async (job) => {
return await this.processTask(job);
}, { concurrency: 10 });
// Normal priority worker
await this.createWorker('normal-priority', async (job) => {
return await this.processTask(job);
}, { concurrency: 5 });
// Low priority worker
await this.createWorker('low-priority', async (job) => {
return await this.processTask(job);
}, { concurrency: 2 });
logger.info('All workers started');
}
async processTask(job) {
const { taskId } = job.data;
try {
// Update task status
await Task.update(
{ status: 'processing', startedAt: new Date() },
{ where: { id: taskId } }
);
// Emit event for other services to handle
this.emit('task:process', {
taskId,
type: job.name,
data: job.data
});
// Wait for task completion (this would be handled by specific processors)
await job.updateProgress(50);
// Simulate processing
await new Promise(resolve => setTimeout(resolve, 1000));
await job.updateProgress(100);
// Update task status
await Task.update(
{
status: 'completed',
completedAt: new Date(),
result: JSON.stringify({ success: true })
},
{ where: { id: taskId } }
);
return { success: true, taskId };
} catch (error) {
// Update task status
await Task.update(
{
status: 'failed',
failedAt: new Date(),
error: error.message
},
{ where: { id: taskId } }
);
throw error;
}
}
async stopWorkers() {
for (const [name, worker] of this.workers) {
await worker.close();
logger.info(`Worker stopped: ${name}`);
}
}
async checkHealth() {
try {
const stats = await this.getAllQueueStats();
return Object.keys(stats).length > 0;
} catch (error) {
logger.error('Queue health check failed:', error);
return false;
}
}
}

View File

@@ -0,0 +1,63 @@
import winston from 'winston';
const { combine, timestamp, printf, colorize, errors } = winston.format;
// Custom log format
const logFormat = printf(({ level, message, timestamp, stack, ...metadata }) => {
let msg = `${timestamp} [${level}] ${message}`;
if (stack) {
msg += `\n${stack}`;
}
if (Object.keys(metadata).length > 0) {
msg += ` ${JSON.stringify(metadata)}`;
}
return msg;
});
// Create logger instance
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: combine(
errors({ stack: true }),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
logFormat
),
transports: [
// Console transport
new winston.transports.Console({
format: combine(
colorize(),
logFormat
)
}),
// File transport for errors
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
maxsize: 10485760, // 10MB
maxFiles: 5
}),
// File transport for all logs
new winston.transports.File({
filename: 'logs/combined.log',
maxsize: 10485760, // 10MB
maxFiles: 10
})
],
exceptionHandlers: [
new winston.transports.File({ filename: 'logs/exceptions.log' })
],
rejectionHandlers: [
new winston.transports.File({ filename: 'logs/rejections.log' })
]
});
// Create a stream object for Morgan HTTP logging
export const stream = {
write: (message) => {
logger.info(message.trim());
}
};