Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled
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:
38
marketing-agent/services/gramjs-adapter/Dockerfile
Normal file
38
marketing-agent/services/gramjs-adapter/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install build dependencies and runtime dependencies for gramJS
|
||||
RUN apk add --no-cache python3 make g++ cairo-dev pango-dev libjpeg-turbo-dev
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --production
|
||||
|
||||
# Copy app source
|
||||
COPY . .
|
||||
|
||||
# Create sessions directory
|
||||
RUN mkdir -p sessions && chmod 755 sessions
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nodejs -u 1001
|
||||
|
||||
# Create logs directory with proper permissions
|
||||
RUN mkdir -p logs && chown -R nodejs:nodejs logs
|
||||
RUN chown -R nodejs:nodejs /app
|
||||
USER nodejs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3003
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
|
||||
CMD node healthcheck.js || exit 1
|
||||
|
||||
# Start the service
|
||||
CMD ["node", "src/app.js"]
|
||||
29
marketing-agent/services/gramjs-adapter/package.json
Normal file
29
marketing-agent/services/gramjs-adapter/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "gramjs-adapter-service",
|
||||
"version": "1.0.0",
|
||||
"description": "Telegram automation service using gramJS",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/hapi": "^21.3.2",
|
||||
"@hapi/joi": "^17.1.1",
|
||||
"axios": "^1.6.5",
|
||||
"dotenv": "^16.4.1",
|
||||
"telegram": "^2.19.8",
|
||||
"ioredis": "^5.3.2",
|
||||
"prom-client": "^15.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0",
|
||||
"input": "^1.0.1",
|
||||
"big-integer": "^1.6.52"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.3",
|
||||
"jest": "^29.7.0"
|
||||
}
|
||||
}
|
||||
1
marketing-agent/services/gramjs-adapter/src/app.js
Normal file
1
marketing-agent/services/gramjs-adapter/src/app.js
Normal file
@@ -0,0 +1 @@
|
||||
import './index.js';
|
||||
102
marketing-agent/services/gramjs-adapter/src/config/redis.js
Normal file
102
marketing-agent/services/gramjs-adapter/src/config/redis.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import Redis from 'ioredis';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export class RedisClient {
|
||||
constructor() {
|
||||
this.client = 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;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
this.client = new Redis(config);
|
||||
|
||||
this.client.on('connect', () => {
|
||||
logger.info('Redis connection established');
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
logger.error('Redis error:', err);
|
||||
});
|
||||
|
||||
// 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();
|
||||
logger.info('Redis connection closed');
|
||||
}
|
||||
}
|
||||
|
||||
// Session-specific methods
|
||||
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;
|
||||
}
|
||||
|
||||
async hdel(key, field) {
|
||||
return await this.client.hdel(key, field);
|
||||
}
|
||||
|
||||
// Generic 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);
|
||||
}
|
||||
}
|
||||
112
marketing-agent/services/gramjs-adapter/src/index.js
Normal file
112
marketing-agent/services/gramjs-adapter/src/index.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import Hapi from '@hapi/hapi';
|
||||
import dotenv from 'dotenv';
|
||||
import { logger } from './utils/logger.js';
|
||||
import { SessionManager } from './services/SessionManager.js';
|
||||
import { ConnectionPool } from './services/ConnectionPool.js';
|
||||
import { MessageService } from './services/MessageService.js';
|
||||
import { GroupService } from './services/GroupService.js';
|
||||
import { RedisClient } from './config/redis.js';
|
||||
import routes from './routes/index.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const init = async () => {
|
||||
// Initialize Redis
|
||||
await RedisClient.getInstance().connect();
|
||||
|
||||
// Initialize services
|
||||
const sessionManager = SessionManager.getInstance();
|
||||
const connectionPool = ConnectionPool.getInstance();
|
||||
const messageService = MessageService.getInstance();
|
||||
const groupService = GroupService.getInstance();
|
||||
|
||||
// Initialize connection pool
|
||||
await connectionPool.initialize();
|
||||
|
||||
// Create Hapi server
|
||||
const server = Hapi.server({
|
||||
port: process.env.PORT || 3003,
|
||||
host: '0.0.0.0',
|
||||
routes: {
|
||||
cors: {
|
||||
origin: ['*'],
|
||||
headers: ['Accept', 'Content-Type', 'Authorization'],
|
||||
credentials: true
|
||||
},
|
||||
payload: {
|
||||
maxBytes: 52428800 // 50MB for file uploads
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Register routes
|
||||
server.route(routes);
|
||||
|
||||
// Health check endpoint
|
||||
server.route({
|
||||
method: 'GET',
|
||||
path: '/health',
|
||||
options: {
|
||||
auth: false,
|
||||
handler: async (request, h) => {
|
||||
const redisHealth = await RedisClient.getInstance().checkHealth();
|
||||
const poolHealth = connectionPool.checkHealth();
|
||||
|
||||
const isHealthy = redisHealth && poolHealth;
|
||||
|
||||
return h.response({
|
||||
status: isHealthy ? 'healthy' : 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
redis: redisHealth ? 'up' : 'down',
|
||||
connectionPool: poolHealth ? 'up' : 'down',
|
||||
activeConnections: connectionPool.getActiveConnectionsCount()
|
||||
}
|
||||
}).code(isHealthy ? 200 : 503);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Metrics endpoint
|
||||
server.route({
|
||||
method: 'GET',
|
||||
path: '/metrics',
|
||||
options: {
|
||||
auth: false,
|
||||
handler: async (request, h) => {
|
||||
const metrics = connectionPool.getMetrics();
|
||||
return h.response(metrics).type('text/plain');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
await server.start();
|
||||
logger.info(`GramJS Adapter service started on ${server.info.uri}`);
|
||||
|
||||
// Load existing sessions
|
||||
await sessionManager.loadSessions();
|
||||
logger.info('Sessions loaded');
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('Shutting down gracefully...');
|
||||
await connectionPool.closeAll();
|
||||
await server.stop();
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import Joi from '@hapi/joi';
|
||||
import { ConnectionPool } from '../services/ConnectionPool.js';
|
||||
import { SessionManager } from '../services/SessionManager.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { authenticateAccount, createDemoAccount } from '../services/AuthService.js';
|
||||
|
||||
const connectionPool = ConnectionPool.getInstance();
|
||||
const sessionManager = SessionManager.getInstance();
|
||||
|
||||
export default [
|
||||
// Get all accounts
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/v1/accounts',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const sessions = await sessionManager.getActiveSessions();
|
||||
const connections = connectionPool.getAllConnectionsInfo();
|
||||
|
||||
const accounts = sessions.map(session => {
|
||||
const connInfo = connections.find(c => c.accountId === session.accountId);
|
||||
return {
|
||||
id: session.accountId,
|
||||
phone: session.phone || session.phoneNumber,
|
||||
username: session.username,
|
||||
status: connInfo ? 'active' : 'inactive',
|
||||
connected: connInfo?.connected || false,
|
||||
lastActive: connInfo?.lastUsed || session.lastUsed,
|
||||
messageCount: connInfo?.messageCount || 0,
|
||||
isDemo: session.isDemo || false
|
||||
};
|
||||
});
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: accounts
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get accounts:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: 'Failed to retrieve accounts'
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Connect new account
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/accounts/connect',
|
||||
options: {
|
||||
validate: {
|
||||
payload: Joi.object({
|
||||
phone: Joi.string().required(),
|
||||
demo: Joi.boolean().default(false)
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { phone, demo } = request.payload;
|
||||
|
||||
// Generate a unique account ID
|
||||
const accountId = `acc_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
if (demo) {
|
||||
// Create demo account for testing
|
||||
const result = await createDemoAccount(accountId, phone);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
phone,
|
||||
demo: true,
|
||||
authRequired: false,
|
||||
message: 'Demo account created successfully'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start real authentication process
|
||||
const authResult = await authenticateAccount(accountId, phone);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
phone,
|
||||
authRequired: true,
|
||||
authMethod: authResult.method,
|
||||
message: 'Please check your Telegram app for authentication code'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect account:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message || 'Failed to connect account'
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Verify authentication code
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/accounts/{accountId}/verify',
|
||||
options: {
|
||||
validate: {
|
||||
params: Joi.object({
|
||||
accountId: Joi.string().required()
|
||||
}),
|
||||
payload: Joi.object({
|
||||
code: Joi.string().required(),
|
||||
password: Joi.string().optional()
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { accountId } = request.params;
|
||||
const { code, password } = request.payload;
|
||||
|
||||
// Complete authentication
|
||||
const result = await authenticateAccount(accountId, null, { code, password });
|
||||
|
||||
if (result.success) {
|
||||
// Get connection info
|
||||
const connInfo = connectionPool.getConnectionInfo(accountId);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
connected: true,
|
||||
username: result.username,
|
||||
status: 'active',
|
||||
connectionInfo: connInfo
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return h.response({
|
||||
success: false,
|
||||
error: result.error || 'Verification failed',
|
||||
requiresPassword: result.requiresPassword
|
||||
}).code(400);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify account:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message || 'Failed to verify account'
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Disconnect account
|
||||
{
|
||||
method: 'DELETE',
|
||||
path: '/api/v1/accounts/{accountId}',
|
||||
options: {
|
||||
validate: {
|
||||
params: Joi.object({
|
||||
accountId: Joi.string().required()
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { accountId } = request.params;
|
||||
|
||||
// Close connection
|
||||
await connectionPool.closeConnection(accountId);
|
||||
|
||||
// Remove session
|
||||
await sessionManager.removeSession(accountId);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
message: 'Account disconnected successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to disconnect account:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: 'Failed to disconnect account'
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Get account status
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/v1/accounts/{accountId}/status',
|
||||
options: {
|
||||
validate: {
|
||||
params: Joi.object({
|
||||
accountId: Joi.string().required()
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { accountId } = request.params;
|
||||
|
||||
const connInfo = connectionPool.getConnectionInfo(accountId);
|
||||
const session = await sessionManager.getSession(accountId);
|
||||
|
||||
if (!session && !connInfo) {
|
||||
return h.response({
|
||||
success: false,
|
||||
error: 'Account not found'
|
||||
}).code(404);
|
||||
}
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
connected: connInfo?.connected || false,
|
||||
phone: session?.phone || session?.phoneNumber,
|
||||
username: session?.username,
|
||||
lastActive: connInfo?.lastUsed || session?.lastUsed,
|
||||
messageCount: connInfo?.messageCount || 0,
|
||||
uptime: connInfo?.uptime || 0,
|
||||
isDemo: session?.isDemo || false
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get account status:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: 'Failed to get account status'
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Reconnect account
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/accounts/{accountId}/reconnect',
|
||||
options: {
|
||||
validate: {
|
||||
params: Joi.object({
|
||||
accountId: Joi.string().required()
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { accountId } = request.params;
|
||||
|
||||
const client = await connectionPool.getConnection(accountId);
|
||||
const connInfo = connectionPool.getConnectionInfo(accountId);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
connected: true,
|
||||
connectionInfo: connInfo
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to reconnect account:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message || 'Failed to reconnect account'
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
228
marketing-agent/services/gramjs-adapter/src/routes/accounts.js
Normal file
228
marketing-agent/services/gramjs-adapter/src/routes/accounts.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import express from 'express';
|
||||
import { ConnectionPool } from '../services/ConnectionPool.js';
|
||||
import { SessionManager } from '../services/SessionManager.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { authenticateAccount } from '../services/AuthService.js';
|
||||
|
||||
const router = express.Router();
|
||||
const connectionPool = ConnectionPool.getInstance();
|
||||
const sessionManager = SessionManager.getInstance();
|
||||
|
||||
/**
|
||||
* @route GET /accounts
|
||||
* @desc Get all connected accounts
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const sessions = await sessionManager.getActiveSessions();
|
||||
const connections = connectionPool.getAllConnectionsInfo();
|
||||
|
||||
const accounts = sessions.map(session => {
|
||||
const connInfo = connections.find(c => c.accountId === session.accountId);
|
||||
return {
|
||||
id: session.accountId,
|
||||
phone: session.phone,
|
||||
username: session.username,
|
||||
status: connInfo ? 'active' : 'inactive',
|
||||
connected: connInfo?.connected || false,
|
||||
lastActive: connInfo?.lastUsed || session.lastUsed,
|
||||
messageCount: connInfo?.messageCount || 0
|
||||
};
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: accounts
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get accounts:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve accounts'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route POST /accounts/connect
|
||||
* @desc Connect a new Telegram account
|
||||
*/
|
||||
router.post('/connect', async (req, res) => {
|
||||
try {
|
||||
const { phone } = req.body;
|
||||
|
||||
if (!phone) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Phone number is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a unique account ID
|
||||
const accountId = `acc_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Start authentication process
|
||||
const authResult = await authenticateAccount(accountId, phone);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
phone,
|
||||
authRequired: true,
|
||||
authMethod: authResult.method,
|
||||
message: 'Please check your Telegram app for authentication code'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect account:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to connect account'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route POST /accounts/:accountId/verify
|
||||
* @desc Verify authentication code
|
||||
*/
|
||||
router.post('/:accountId/verify', async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { code, password } = req.body;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Verification code is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Complete authentication
|
||||
const result = await authenticateAccount(accountId, null, { code, password });
|
||||
|
||||
if (result.success) {
|
||||
// Get connection info
|
||||
const connInfo = connectionPool.getConnectionInfo(accountId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
connected: true,
|
||||
username: result.username,
|
||||
status: 'active',
|
||||
connectionInfo: connInfo
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error || 'Verification failed'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify account:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to verify account'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route DELETE /accounts/:accountId
|
||||
* @desc Disconnect an account
|
||||
*/
|
||||
router.delete('/:accountId', async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
// Close connection
|
||||
await connectionPool.closeConnection(accountId);
|
||||
|
||||
// Remove session
|
||||
await sessionManager.removeSession(accountId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Account disconnected successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to disconnect account:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to disconnect account'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route GET /accounts/:accountId/status
|
||||
* @desc Get account connection status
|
||||
*/
|
||||
router.get('/:accountId/status', async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
const connInfo = connectionPool.getConnectionInfo(accountId);
|
||||
const session = await sessionManager.getSession(accountId);
|
||||
|
||||
if (!session && !connInfo) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Account not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
connected: connInfo?.connected || false,
|
||||
phone: session?.phone,
|
||||
username: session?.username,
|
||||
lastActive: connInfo?.lastUsed || session?.lastUsed,
|
||||
messageCount: connInfo?.messageCount || 0,
|
||||
uptime: connInfo?.uptime || 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get account status:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get account status'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @route POST /accounts/:accountId/reconnect
|
||||
* @desc Reconnect a disconnected account
|
||||
*/
|
||||
router.post('/:accountId/reconnect', async (req, res) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
const client = await connectionPool.getConnection(accountId);
|
||||
const connInfo = connectionPool.getConnectionInfo(accountId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
connected: true,
|
||||
connectionInfo: connInfo
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to reconnect account:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to reconnect account'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
545
marketing-agent/services/gramjs-adapter/src/routes/index.js
Normal file
545
marketing-agent/services/gramjs-adapter/src/routes/index.js
Normal file
@@ -0,0 +1,545 @@
|
||||
import Joi from '@hapi/joi';
|
||||
import { SessionManager } from '../services/SessionManager.js';
|
||||
import { ConnectionPool } from '../services/ConnectionPool.js';
|
||||
import { MessageService } from '../services/MessageService.js';
|
||||
import { GroupService } from '../services/GroupService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import accountRoutes from './accountRoutes.js';
|
||||
|
||||
const sessionManager = SessionManager.getInstance();
|
||||
const connectionPool = ConnectionPool.getInstance();
|
||||
const messageService = MessageService.getInstance();
|
||||
const groupService = GroupService.getInstance();
|
||||
|
||||
export default [
|
||||
...accountRoutes,
|
||||
// Session Management Routes
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/sessions/create',
|
||||
options: {
|
||||
validate: {
|
||||
payload: Joi.object({
|
||||
accountId: Joi.string().required(),
|
||||
phoneNumber: Joi.string().required(),
|
||||
sessionString: Joi.string().optional()
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { accountId, phoneNumber, sessionString } = request.payload;
|
||||
|
||||
const session = await sessionManager.createSession(
|
||||
accountId,
|
||||
phoneNumber,
|
||||
sessionString
|
||||
);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
phoneNumber,
|
||||
status: 'created'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to create session:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/v1/sessions/{accountId}',
|
||||
options: {
|
||||
validate: {
|
||||
params: Joi.object({
|
||||
accountId: Joi.string().required()
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { accountId } = request.params;
|
||||
|
||||
const session = await sessionManager.getSession(accountId);
|
||||
|
||||
if (!session) {
|
||||
return h.response({
|
||||
success: false,
|
||||
error: 'Session not found'
|
||||
}).code(404);
|
||||
}
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
exists: true
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get session:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/v1/sessions',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const stats = await sessionManager.getSessionStats();
|
||||
const sessions = await sessionManager.getActiveSessions();
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: {
|
||||
stats,
|
||||
sessions: sessions.map(s => ({
|
||||
accountId: s.accountId,
|
||||
phoneNumber: s.phoneNumber,
|
||||
status: s.status,
|
||||
lastUsed: s.lastUsed
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get sessions:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Connection Management Routes
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/connections/connect',
|
||||
options: {
|
||||
validate: {
|
||||
payload: Joi.object({
|
||||
accountId: Joi.string().required()
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { accountId } = request.payload;
|
||||
|
||||
await connectionPool.getConnection(accountId);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: {
|
||||
accountId,
|
||||
connected: true
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/v1/connections',
|
||||
options: {
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const connections = connectionPool.getAllConnectionsInfo();
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: {
|
||||
total: connections.length,
|
||||
connections
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get connections:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Message Routes
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/messages/send',
|
||||
options: {
|
||||
validate: {
|
||||
payload: Joi.object({
|
||||
accountId: Joi.string().required(),
|
||||
chatId: Joi.alternatives().try(
|
||||
Joi.string(),
|
||||
Joi.number()
|
||||
).required(),
|
||||
message: Joi.string().required(),
|
||||
replyToMsgId: Joi.number().optional(),
|
||||
schedule: Joi.date().iso().optional(),
|
||||
parseMode: Joi.string().valid('md', 'html').default('md'),
|
||||
buttons: Joi.array().items(
|
||||
Joi.array().items(
|
||||
Joi.object({
|
||||
text: Joi.string().required(),
|
||||
url: Joi.string().uri().optional(),
|
||||
callback: Joi.string().optional()
|
||||
})
|
||||
)
|
||||
).optional(),
|
||||
silent: Joi.boolean().default(false)
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const result = await messageService.sendMessage(request.payload);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send message:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/messages/bulk-send',
|
||||
options: {
|
||||
validate: {
|
||||
payload: Joi.object({
|
||||
accountId: Joi.string().required(),
|
||||
recipients: Joi.array().items(
|
||||
Joi.alternatives().try(Joi.string(), Joi.number())
|
||||
).required(),
|
||||
message: Joi.string().required(),
|
||||
parseMode: Joi.string().valid('md', 'html').default('md'),
|
||||
buttons: Joi.array().optional(),
|
||||
batchSize: Joi.number().min(1).max(100).default(30),
|
||||
delayBetweenBatches: Joi.number().min(1000).default(5000),
|
||||
randomDelay: Joi.boolean().default(true)
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const result = await messageService.sendBulkMessages(request.payload);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send bulk messages:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/messages/forward',
|
||||
options: {
|
||||
validate: {
|
||||
payload: Joi.object({
|
||||
accountId: Joi.string().required(),
|
||||
fromChatId: Joi.alternatives().try(
|
||||
Joi.string(),
|
||||
Joi.number()
|
||||
).required(),
|
||||
toChatId: Joi.alternatives().try(
|
||||
Joi.string(),
|
||||
Joi.number()
|
||||
).required(),
|
||||
messageIds: Joi.array().items(Joi.number()).required(),
|
||||
silent: Joi.boolean().default(false)
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const result = await messageService.forwardMessage(request.payload);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to forward messages:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Group Management Routes
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/groups/create',
|
||||
options: {
|
||||
validate: {
|
||||
payload: Joi.object({
|
||||
accountId: Joi.string().required(),
|
||||
title: Joi.string().required(),
|
||||
users: Joi.array().items(
|
||||
Joi.alternatives().try(Joi.string(), Joi.number())
|
||||
).default([]),
|
||||
about: Joi.string().optional()
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const result = await groupService.createGroup(request.payload);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to create group:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/groups/join',
|
||||
options: {
|
||||
validate: {
|
||||
payload: Joi.object({
|
||||
accountId: Joi.string().required(),
|
||||
groupId: Joi.string().required()
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const result = await groupService.joinGroup(request.payload);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to join group:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/v1/groups/{chatId}',
|
||||
options: {
|
||||
validate: {
|
||||
params: Joi.object({
|
||||
chatId: Joi.alternatives().try(
|
||||
Joi.string(),
|
||||
Joi.number()
|
||||
).required()
|
||||
}),
|
||||
query: Joi.object({
|
||||
accountId: Joi.string().required()
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { chatId } = request.params;
|
||||
const { accountId } = request.query;
|
||||
|
||||
const result = await groupService.getGroupInfo({
|
||||
accountId,
|
||||
chatId
|
||||
});
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: result.info
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get group info:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/api/v1/groups/{chatId}/members',
|
||||
options: {
|
||||
validate: {
|
||||
params: Joi.object({
|
||||
chatId: Joi.alternatives().try(
|
||||
Joi.string(),
|
||||
Joi.number()
|
||||
).required()
|
||||
}),
|
||||
query: Joi.object({
|
||||
accountId: Joi.string().required(),
|
||||
filter: Joi.string().valid(
|
||||
'all', 'admins', 'bots', 'recent', 'kicked', 'restricted'
|
||||
).default('all'),
|
||||
limit: Joi.number().min(1).max(200).default(200),
|
||||
offset: Joi.number().min(0).default(0)
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { chatId } = request.params;
|
||||
const query = request.query;
|
||||
|
||||
const result = await groupService.getGroupMembers({
|
||||
chatId,
|
||||
...query
|
||||
});
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get group members:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/groups/{chatId}/invite',
|
||||
options: {
|
||||
validate: {
|
||||
params: Joi.object({
|
||||
chatId: Joi.alternatives().try(
|
||||
Joi.string(),
|
||||
Joi.number()
|
||||
).required()
|
||||
}),
|
||||
payload: Joi.object({
|
||||
accountId: Joi.string().required(),
|
||||
userIds: Joi.array().items(
|
||||
Joi.alternatives().try(Joi.string(), Joi.number())
|
||||
).required()
|
||||
})
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const { chatId } = request.params;
|
||||
const { accountId, userIds } = request.payload;
|
||||
|
||||
const result = await groupService.inviteUsers({
|
||||
accountId,
|
||||
chatId,
|
||||
userIds
|
||||
});
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to invite users:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// File Operations
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/api/v1/files/send',
|
||||
options: {
|
||||
validate: {
|
||||
payload: Joi.object({
|
||||
accountId: Joi.string().required(),
|
||||
chatId: Joi.alternatives().try(
|
||||
Joi.string(),
|
||||
Joi.number()
|
||||
).required(),
|
||||
file: Joi.any().required(), // Buffer or file path
|
||||
caption: Joi.string().optional(),
|
||||
parseMode: Joi.string().valid('md', 'html').default('md'),
|
||||
buttons: Joi.array().optional()
|
||||
})
|
||||
},
|
||||
payload: {
|
||||
output: 'stream',
|
||||
parse: true,
|
||||
multipart: true,
|
||||
maxBytes: 52428800 // 50MB
|
||||
},
|
||||
handler: async (request, h) => {
|
||||
try {
|
||||
const result = await messageService.sendFile(request.payload);
|
||||
|
||||
return h.response({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send file:', error);
|
||||
return h.response({
|
||||
success: false,
|
||||
error: error.message
|
||||
}).code(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,193 @@
|
||||
import { TelegramClient } from 'telegram';
|
||||
import { StringSession } from 'telegram/sessions/index.js';
|
||||
import { Api } from 'telegram/tl/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { SessionManager } from './SessionManager.js';
|
||||
import { ConnectionPool } from './ConnectionPool.js';
|
||||
import input from 'input';
|
||||
|
||||
const sessionManager = SessionManager.getInstance();
|
||||
const connectionPool = ConnectionPool.getInstance();
|
||||
|
||||
// Store pending authentications
|
||||
const pendingAuth = new Map();
|
||||
|
||||
export async function authenticateAccount(accountId, phone, verification = null) {
|
||||
try {
|
||||
// If this is a verification step
|
||||
if (verification) {
|
||||
const pending = pendingAuth.get(accountId);
|
||||
if (!pending) {
|
||||
throw new Error('No pending authentication found');
|
||||
}
|
||||
|
||||
const { client, phone: savedPhone, phoneCodeHash } = pending;
|
||||
|
||||
try {
|
||||
// Try to sign in with the code
|
||||
const result = await client.invoke(
|
||||
new Api.auth.SignIn({
|
||||
phoneNumber: savedPhone,
|
||||
phoneCodeHash: phoneCodeHash,
|
||||
phoneCode: verification.code
|
||||
})
|
||||
);
|
||||
|
||||
// Authentication successful
|
||||
await handleAuthSuccess(accountId, client, result);
|
||||
pendingAuth.delete(accountId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
username: result.user.username || result.user.firstName
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.message.includes('SESSION_PASSWORD_NEEDED')) {
|
||||
// 2FA is enabled
|
||||
if (!verification.password) {
|
||||
return {
|
||||
success: false,
|
||||
error: '2FA password required',
|
||||
requiresPassword: true
|
||||
};
|
||||
}
|
||||
|
||||
// Try with password
|
||||
const passwordResult = await client.invoke(
|
||||
new Api.auth.CheckPassword({
|
||||
password: verification.password
|
||||
})
|
||||
);
|
||||
|
||||
await handleAuthSuccess(accountId, client, passwordResult);
|
||||
pendingAuth.delete(accountId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
username: passwordResult.user.username || passwordResult.user.firstName
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Start new authentication
|
||||
const apiId = parseInt(process.env.TELEGRAM_API_ID);
|
||||
const apiHash = process.env.TELEGRAM_API_HASH;
|
||||
|
||||
if (!apiId || !apiHash) {
|
||||
throw new Error('Telegram API credentials not configured');
|
||||
}
|
||||
|
||||
const session = new StringSession('');
|
||||
const client = new TelegramClient(session, apiId, apiHash, {
|
||||
connectionRetries: 5,
|
||||
useWSS: true
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
|
||||
// Send code to phone
|
||||
const result = await client.invoke(
|
||||
new Api.auth.SendCode({
|
||||
phoneNumber: phone,
|
||||
apiId: apiId,
|
||||
apiHash: apiHash,
|
||||
settings: new Api.CodeSettings({
|
||||
allowFlashcall: false,
|
||||
currentNumber: false,
|
||||
allowAppHash: false
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
// Store pending authentication
|
||||
pendingAuth.set(accountId, {
|
||||
client,
|
||||
phone,
|
||||
phoneCodeHash: result.phoneCodeHash,
|
||||
timeout: setTimeout(() => {
|
||||
pendingAuth.delete(accountId);
|
||||
client.disconnect();
|
||||
}, 5 * 60 * 1000) // 5 minutes timeout
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
method: 'code',
|
||||
phoneCodeHash: result.phoneCodeHash
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Authentication error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthSuccess(accountId, client, authResult) {
|
||||
try {
|
||||
// Save session
|
||||
const sessionString = client.session.save();
|
||||
await sessionManager.saveSession(accountId, {
|
||||
session: sessionString,
|
||||
phone: authResult.user.phone,
|
||||
username: authResult.user.username,
|
||||
userId: authResult.user.id.toString(),
|
||||
firstName: authResult.user.firstName,
|
||||
lastName: authResult.user.lastName
|
||||
});
|
||||
|
||||
// Add to connection pool
|
||||
connectionPool.connections.set(accountId, {
|
||||
accountId,
|
||||
client,
|
||||
createdAt: Date.now(),
|
||||
lastUsed: Date.now(),
|
||||
messageCount: 0
|
||||
});
|
||||
|
||||
// Set up event handlers
|
||||
connectionPool.setupEventHandlers(client, accountId);
|
||||
|
||||
logger.info(`Account ${accountId} authenticated successfully`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to save authentication:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Demo mode for testing without real Telegram account
|
||||
export async function createDemoAccount(accountId, phone) {
|
||||
try {
|
||||
// Create a demo session
|
||||
await sessionManager.saveSession(accountId, {
|
||||
session: 'demo_session_' + accountId,
|
||||
phone: phone,
|
||||
username: 'demo_user_' + Math.random().toString(36).substr(2, 9),
|
||||
userId: 'demo_' + Date.now(),
|
||||
firstName: 'Demo',
|
||||
lastName: 'User',
|
||||
isDemo: true
|
||||
});
|
||||
|
||||
logger.info(`Demo account ${accountId} created`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
demo: true,
|
||||
username: 'demo_user'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to create demo account:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up pending authentications on shutdown
|
||||
export function cleanupPendingAuth() {
|
||||
for (const [accountId, pending] of pendingAuth) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.client.disconnect().catch(() => {});
|
||||
}
|
||||
pendingAuth.clear();
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { TelegramClient } from 'telegram';
|
||||
import { Api } from 'telegram/tl/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { SessionManager } from './SessionManager.js';
|
||||
import * as promClient from 'prom-client';
|
||||
import { sleep } from '../utils/helpers.js';
|
||||
|
||||
export class ConnectionPool {
|
||||
constructor() {
|
||||
this.connections = new Map();
|
||||
this.sessionManager = null;
|
||||
this.maxConnections = parseInt(process.env.MAX_CONNECTIONS) || 10;
|
||||
this.connectionQueue = [];
|
||||
|
||||
// Initialize metrics
|
||||
this.metrics = {
|
||||
activeConnections: new promClient.Gauge({
|
||||
name: 'gramjs_active_connections',
|
||||
help: 'Number of active Telegram connections'
|
||||
}),
|
||||
connectionAttempts: new promClient.Counter({
|
||||
name: 'gramjs_connection_attempts_total',
|
||||
help: 'Total connection attempts',
|
||||
labelNames: ['status']
|
||||
}),
|
||||
messagesSent: new promClient.Counter({
|
||||
name: 'gramjs_messages_sent_total',
|
||||
help: 'Total messages sent',
|
||||
labelNames: ['status']
|
||||
}),
|
||||
floodWaits: new promClient.Counter({
|
||||
name: 'gramjs_flood_waits_total',
|
||||
help: 'Total flood wait errors'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!ConnectionPool.instance) {
|
||||
ConnectionPool.instance = new ConnectionPool();
|
||||
}
|
||||
return ConnectionPool.instance;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.sessionManager = SessionManager.getInstance();
|
||||
logger.info('Connection pool initialized');
|
||||
}
|
||||
|
||||
async getConnection(accountId) {
|
||||
// Check if connection exists and is connected
|
||||
if (this.connections.has(accountId)) {
|
||||
const conn = this.connections.get(accountId);
|
||||
if (conn.client.connected) {
|
||||
conn.lastUsed = Date.now();
|
||||
return conn.client;
|
||||
} else {
|
||||
// Try to reconnect
|
||||
await this.reconnect(accountId);
|
||||
return this.connections.get(accountId)?.client;
|
||||
}
|
||||
}
|
||||
|
||||
// Check connection limit
|
||||
if (this.connections.size >= this.maxConnections) {
|
||||
// Find least recently used connection to close
|
||||
const lru = this.findLRUConnection();
|
||||
if (lru) {
|
||||
await this.closeConnection(lru.accountId);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new connection
|
||||
return await this.createConnection(accountId);
|
||||
}
|
||||
|
||||
async createConnection(accountId) {
|
||||
try {
|
||||
this.metrics.connectionAttempts.inc({ status: 'attempt' });
|
||||
|
||||
const session = await this.sessionManager.getSession(accountId);
|
||||
if (!session) {
|
||||
throw new Error(`No session found for account: ${accountId}`);
|
||||
}
|
||||
|
||||
const client = new TelegramClient(
|
||||
session,
|
||||
parseInt(process.env.TELEGRAM_API_ID),
|
||||
process.env.TELEGRAM_API_HASH,
|
||||
{
|
||||
connectionRetries: 5,
|
||||
useWSS: true,
|
||||
systemVersion: '1.0',
|
||||
deviceModel: 'Marketing Agent',
|
||||
appVersion: '1.0'
|
||||
}
|
||||
);
|
||||
|
||||
// Set up event handlers
|
||||
this.setupEventHandlers(client, accountId);
|
||||
|
||||
// Connect
|
||||
await client.connect();
|
||||
logger.info(`Connected account: ${accountId}`);
|
||||
|
||||
// Store connection
|
||||
this.connections.set(accountId, {
|
||||
accountId,
|
||||
client,
|
||||
createdAt: Date.now(),
|
||||
lastUsed: Date.now(),
|
||||
messageCount: 0
|
||||
});
|
||||
|
||||
this.metrics.activeConnections.set(this.connections.size);
|
||||
this.metrics.connectionAttempts.inc({ status: 'success' });
|
||||
|
||||
return client;
|
||||
} catch (error) {
|
||||
this.metrics.connectionAttempts.inc({ status: 'error' });
|
||||
logger.error(`Failed to create connection for ${accountId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setupEventHandlers(client, accountId) {
|
||||
// Handle updates
|
||||
client.addEventHandler((update) => {
|
||||
if (update instanceof Api.UpdateNewMessage) {
|
||||
logger.debug(`New message received on ${accountId}`);
|
||||
// Emit event for processing
|
||||
this.emit('message:received', {
|
||||
accountId,
|
||||
message: update.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
client.setLogLevel('error');
|
||||
|
||||
// Handle flood wait
|
||||
client.addEventHandler((update) => {
|
||||
if (update instanceof Error && update.message.includes('FLOOD_WAIT')) {
|
||||
const seconds = parseInt(update.message.match(/\d+/)[0]);
|
||||
logger.warn(`Flood wait ${seconds}s for account ${accountId}`);
|
||||
this.metrics.floodWaits.inc();
|
||||
this.emit('flood:wait', { accountId, seconds });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async reconnect(accountId) {
|
||||
try {
|
||||
const conn = this.connections.get(accountId);
|
||||
if (!conn) return;
|
||||
|
||||
logger.info(`Reconnecting account: ${accountId}`);
|
||||
await conn.client.disconnect();
|
||||
await sleep(1000);
|
||||
await conn.client.connect();
|
||||
|
||||
conn.lastUsed = Date.now();
|
||||
logger.info(`Reconnected account: ${accountId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to reconnect ${accountId}:`, error);
|
||||
this.connections.delete(accountId);
|
||||
this.metrics.activeConnections.set(this.connections.size);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async closeConnection(accountId) {
|
||||
const conn = this.connections.get(accountId);
|
||||
if (!conn) return;
|
||||
|
||||
try {
|
||||
await conn.client.disconnect();
|
||||
this.connections.delete(accountId);
|
||||
this.metrics.activeConnections.set(this.connections.size);
|
||||
logger.info(`Closed connection: ${accountId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error closing connection ${accountId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async closeAll() {
|
||||
logger.info('Closing all connections...');
|
||||
|
||||
const promises = [];
|
||||
for (const [accountId] of this.connections) {
|
||||
promises.push(this.closeConnection(accountId));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
logger.info('All connections closed');
|
||||
}
|
||||
|
||||
findLRUConnection() {
|
||||
let lru = null;
|
||||
let oldestTime = Date.now();
|
||||
|
||||
for (const [accountId, conn] of this.connections) {
|
||||
if (conn.lastUsed < oldestTime) {
|
||||
oldestTime = conn.lastUsed;
|
||||
lru = { accountId, conn };
|
||||
}
|
||||
}
|
||||
|
||||
return lru;
|
||||
}
|
||||
|
||||
getActiveConnectionsCount() {
|
||||
return this.connections.size;
|
||||
}
|
||||
|
||||
getConnectionInfo(accountId) {
|
||||
const conn = this.connections.get(accountId);
|
||||
if (!conn) return null;
|
||||
|
||||
return {
|
||||
accountId: conn.accountId,
|
||||
connected: conn.client.connected,
|
||||
createdAt: new Date(conn.createdAt).toISOString(),
|
||||
lastUsed: new Date(conn.lastUsed).toISOString(),
|
||||
messageCount: conn.messageCount,
|
||||
uptime: Date.now() - conn.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
getAllConnectionsInfo() {
|
||||
const info = [];
|
||||
for (const [accountId] of this.connections) {
|
||||
info.push(this.getConnectionInfo(accountId));
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
checkHealth() {
|
||||
const total = this.connections.size;
|
||||
let connected = 0;
|
||||
|
||||
for (const [, conn] of this.connections) {
|
||||
if (conn.client.connected) {
|
||||
connected++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
healthy: connected === total,
|
||||
total,
|
||||
connected,
|
||||
disconnected: total - connected
|
||||
};
|
||||
}
|
||||
|
||||
getMetrics() {
|
||||
return promClient.register.metrics();
|
||||
}
|
||||
|
||||
// Event emitter functionality
|
||||
emit(event, data) {
|
||||
// This would typically use an event emitter
|
||||
// For now, just log
|
||||
logger.debug(`Event emitted: ${event}`, data);
|
||||
}
|
||||
|
||||
async handleFloodWait(accountId, seconds) {
|
||||
logger.warn(`Handling flood wait for ${accountId}: ${seconds}s`);
|
||||
|
||||
// Mark account as temporarily unavailable
|
||||
const conn = this.connections.get(accountId);
|
||||
if (conn) {
|
||||
conn.floodWaitUntil = Date.now() + (seconds * 1000);
|
||||
}
|
||||
|
||||
// Wait before allowing more operations
|
||||
await sleep(seconds * 1000);
|
||||
|
||||
if (conn) {
|
||||
conn.floodWaitUntil = null;
|
||||
}
|
||||
}
|
||||
|
||||
isAccountAvailable(accountId) {
|
||||
const conn = this.connections.get(accountId);
|
||||
if (!conn) return false;
|
||||
|
||||
if (conn.floodWaitUntil && Date.now() < conn.floodWaitUntil) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return conn.client.connected;
|
||||
}
|
||||
|
||||
async getAvailableAccount() {
|
||||
// Find an available account for sending
|
||||
for (const [accountId, conn] of this.connections) {
|
||||
if (this.isAccountAvailable(accountId)) {
|
||||
return accountId;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to use an account that's not connected yet
|
||||
const sessions = await this.sessionManager.getActiveSessions();
|
||||
for (const session of sessions) {
|
||||
if (!this.connections.has(session.accountId)) {
|
||||
try {
|
||||
await this.getConnection(session.accountId);
|
||||
return session.accountId;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to connect account ${session.accountId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import { Api } from 'gramjs/tl/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { ConnectionPool } from './ConnectionPool.js';
|
||||
import { sleep, parseEntityId, parseUsername } from '../utils/helpers.js';
|
||||
|
||||
export class GroupService {
|
||||
constructor() {
|
||||
this.connectionPool = null;
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!GroupService.instance) {
|
||||
GroupService.instance = new GroupService();
|
||||
GroupService.instance.initialize();
|
||||
}
|
||||
return GroupService.instance;
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.connectionPool = ConnectionPool.getInstance();
|
||||
logger.info('Group service initialized');
|
||||
}
|
||||
|
||||
async createGroup(params) {
|
||||
const {
|
||||
accountId,
|
||||
title,
|
||||
users = [],
|
||||
about = ''
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const result = await client.invoke(
|
||||
new Api.messages.CreateChat({
|
||||
users: users,
|
||||
title: title
|
||||
})
|
||||
);
|
||||
|
||||
const chatId = result.chats[0].id;
|
||||
|
||||
// Set about if provided
|
||||
if (about) {
|
||||
await this.updateGroupAbout({
|
||||
accountId,
|
||||
chatId,
|
||||
about
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Group created: ${title} (${chatId})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chatId,
|
||||
title,
|
||||
participantsCount: users.length
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to create group:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createChannel(params) {
|
||||
const {
|
||||
accountId,
|
||||
title,
|
||||
about = '',
|
||||
megagroup = false,
|
||||
broadcast = true
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const result = await client.invoke(
|
||||
new Api.channels.CreateChannel({
|
||||
title,
|
||||
about,
|
||||
megagroup,
|
||||
broadcast,
|
||||
forImport: false
|
||||
})
|
||||
);
|
||||
|
||||
const channel = result.chats[0];
|
||||
|
||||
logger.info(`Channel created: ${title} (${channel.id})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channelId: channel.id,
|
||||
title: channel.title,
|
||||
username: channel.username,
|
||||
type: megagroup ? 'megagroup' : 'channel'
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to create channel:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async joinGroup(params) {
|
||||
const {
|
||||
accountId,
|
||||
groupId // Can be username, invite link, or chat ID
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
// Check if it's an invite link
|
||||
if (groupId.includes('joinchat') || groupId.includes('+')) {
|
||||
const hash = groupId.split('/').pop().replace('+', '');
|
||||
const result = await client.invoke(
|
||||
new Api.messages.ImportChatInvite({ hash })
|
||||
);
|
||||
|
||||
const chat = result.chats[0];
|
||||
logger.info(`Joined group via invite: ${chat.title}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chatId: chat.id,
|
||||
title: chat.title
|
||||
};
|
||||
} else {
|
||||
// Join by username or ID
|
||||
const result = await client.invoke(
|
||||
new Api.channels.JoinChannel({
|
||||
channel: groupId
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Joined group: ${groupId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updates: result.updates
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to join group:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async leaveGroup(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
await client.invoke(
|
||||
new Api.channels.LeaveChannel({
|
||||
channel: chatId
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Left group: ${chatId}`);
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to leave group:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupInfo(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const fullChat = await client.invoke(
|
||||
new Api.channels.GetFullChannel({
|
||||
channel: chatId
|
||||
})
|
||||
);
|
||||
|
||||
const chat = fullChat.chats[0];
|
||||
const fullInfo = fullChat.fullChat;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
info: {
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
username: chat.username,
|
||||
participantsCount: fullInfo.participantsCount,
|
||||
about: fullInfo.about,
|
||||
photo: chat.photo ? true : false,
|
||||
verified: chat.verified,
|
||||
restricted: chat.restricted,
|
||||
scam: chat.scam,
|
||||
hasLink: chat.hasLink,
|
||||
hasGeo: chat.hasGeo,
|
||||
slowmodeEnabled: fullInfo.slowmodeEnabled,
|
||||
slowmodeSeconds: fullInfo.slowmodeSeconds,
|
||||
migratedFromChatId: fullInfo.migratedFromChatId,
|
||||
linkedChatId: fullInfo.linkedChatId
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get group info:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupMembers(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
filter = 'all', // all, admins, bots, recent, kicked, restricted
|
||||
limit = 200,
|
||||
offset = 0
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
let filterType;
|
||||
switch (filter) {
|
||||
case 'admins':
|
||||
filterType = new Api.ChannelParticipantsAdmins();
|
||||
break;
|
||||
case 'bots':
|
||||
filterType = new Api.ChannelParticipantsBots();
|
||||
break;
|
||||
case 'recent':
|
||||
filterType = new Api.ChannelParticipantsRecent();
|
||||
break;
|
||||
case 'kicked':
|
||||
filterType = new Api.ChannelParticipantsKicked({ q: '' });
|
||||
break;
|
||||
case 'restricted':
|
||||
filterType = new Api.ChannelParticipantsBanned({ q: '' });
|
||||
break;
|
||||
default:
|
||||
filterType = new Api.ChannelParticipantsSearch({ q: '' });
|
||||
}
|
||||
|
||||
const result = await client.invoke(
|
||||
new Api.channels.GetParticipants({
|
||||
channel: chatId,
|
||||
filter: filterType,
|
||||
offset,
|
||||
limit,
|
||||
hash: 0
|
||||
})
|
||||
);
|
||||
|
||||
const members = result.participants.map(participant => ({
|
||||
userId: participant.userId,
|
||||
date: participant.date,
|
||||
isAdmin: participant.className === 'ChannelParticipantAdmin',
|
||||
isCreator: participant.className === 'ChannelParticipantCreator',
|
||||
isBanned: participant.className === 'ChannelParticipantBanned',
|
||||
adminRights: participant.adminRights,
|
||||
bannedRights: participant.bannedRights
|
||||
}));
|
||||
|
||||
// Get user details
|
||||
const users = result.users.reduce((acc, user) => {
|
||||
acc[user.id] = {
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
username: user.username,
|
||||
phone: user.phone,
|
||||
isBot: user.bot,
|
||||
isVerified: user.verified,
|
||||
isRestricted: user.restricted,
|
||||
isScam: user.scam
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
members: members.map(m => ({
|
||||
...m,
|
||||
user: users[m.userId]
|
||||
})),
|
||||
total: result.count,
|
||||
hasMore: offset + limit < result.count
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get group members:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async inviteUsers(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
userIds
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const result = await client.invoke(
|
||||
new Api.channels.InviteToChannel({
|
||||
channel: chatId,
|
||||
users: userIds
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Invited ${userIds.length} users to ${chatId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
invitedCount: userIds.length
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to invite users:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async kickUser(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
userId,
|
||||
banned = false
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
if (banned) {
|
||||
await client.invoke(
|
||||
new Api.channels.EditBanned({
|
||||
channel: chatId,
|
||||
participant: userId,
|
||||
bannedRights: new Api.ChatBannedRights({
|
||||
untilDate: 0, // Permanent ban
|
||||
viewMessages: true,
|
||||
sendMessages: true,
|
||||
sendMedia: true,
|
||||
sendStickers: true,
|
||||
sendGifs: true,
|
||||
sendGames: true,
|
||||
sendInline: true,
|
||||
embedLinks: true
|
||||
})
|
||||
})
|
||||
);
|
||||
} else {
|
||||
await client.invoke(
|
||||
new Api.channels.EditBanned({
|
||||
channel: chatId,
|
||||
participant: userId,
|
||||
bannedRights: new Api.ChatBannedRights({
|
||||
untilDate: 0,
|
||||
viewMessages: true
|
||||
})
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Kicked user ${userId} from ${chatId} (banned: ${banned})`);
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to kick user:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async promoteAdmin(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
userId,
|
||||
adminRights = {}
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const rights = new Api.ChatAdminRights({
|
||||
changeInfo: adminRights.changeInfo || false,
|
||||
postMessages: adminRights.postMessages || false,
|
||||
editMessages: adminRights.editMessages || false,
|
||||
deleteMessages: adminRights.deleteMessages || true,
|
||||
banUsers: adminRights.banUsers || false,
|
||||
inviteUsers: adminRights.inviteUsers || true,
|
||||
pinMessages: adminRights.pinMessages || false,
|
||||
addAdmins: adminRights.addAdmins || false,
|
||||
anonymous: adminRights.anonymous || false,
|
||||
manageCall: adminRights.manageCall || false,
|
||||
other: adminRights.other || false
|
||||
});
|
||||
|
||||
await client.invoke(
|
||||
new Api.channels.EditAdmin({
|
||||
channel: chatId,
|
||||
userId: userId,
|
||||
adminRights: rights,
|
||||
rank: adminRights.rank || 'Admin'
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Promoted user ${userId} to admin in ${chatId}`);
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to promote admin:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateGroupTitle(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
title
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
await client.invoke(
|
||||
new Api.channels.EditTitle({
|
||||
channel: chatId,
|
||||
title
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Updated group title: ${chatId} -> ${title}`);
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to update group title:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateGroupAbout(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
about
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
await client.invoke(
|
||||
new Api.messages.EditChatAbout({
|
||||
peer: chatId,
|
||||
about
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Updated group about: ${chatId}`);
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to update group about:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setGroupPhoto(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
photo
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const file = await client.uploadFile({
|
||||
file: photo,
|
||||
workers: 1
|
||||
});
|
||||
|
||||
await client.invoke(
|
||||
new Api.channels.EditPhoto({
|
||||
channel: chatId,
|
||||
photo: new Api.InputChatUploadedPhoto({
|
||||
file
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Updated group photo: ${chatId}`);
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to set group photo:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupStats(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const stats = await client.invoke(
|
||||
new Api.stats.GetBroadcastStats({
|
||||
channel: chatId
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stats: {
|
||||
period: stats.period,
|
||||
followers: stats.followers,
|
||||
viewsPerPost: stats.viewsPerPost,
|
||||
sharesPerPost: stats.sharesPerPost,
|
||||
enabledNotifications: stats.enabledNotifications,
|
||||
growthGraph: stats.growthGraph,
|
||||
followersGraph: stats.followersGraph,
|
||||
muteGraph: stats.muteGraph,
|
||||
viewsGraph: stats.viewsGraph,
|
||||
sharesGraph: stats.sharesGraph,
|
||||
messagesGraph: stats.messagesGraph,
|
||||
actionsGraph: stats.actionsGraph,
|
||||
topHoursGraph: stats.topHoursGraph
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get group stats:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { Api } from 'gramjs/tl/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { ConnectionPool } from './ConnectionPool.js';
|
||||
import { sleep, parseButtons, chunk } from '../utils/helpers.js';
|
||||
import bigInt from 'big-integer';
|
||||
|
||||
export class MessageService {
|
||||
constructor() {
|
||||
this.connectionPool = null;
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!MessageService.instance) {
|
||||
MessageService.instance = new MessageService();
|
||||
MessageService.instance.initialize();
|
||||
}
|
||||
return MessageService.instance;
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.connectionPool = ConnectionPool.getInstance();
|
||||
logger.info('Message service initialized');
|
||||
}
|
||||
|
||||
async sendMessage(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
message,
|
||||
replyToMsgId,
|
||||
schedule,
|
||||
parseMode = 'md',
|
||||
buttons,
|
||||
file,
|
||||
silent = false
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
// Prepare message options
|
||||
const options = {
|
||||
message,
|
||||
parseMode,
|
||||
silent
|
||||
};
|
||||
|
||||
// Add reply if specified
|
||||
if (replyToMsgId) {
|
||||
options.replyTo = replyToMsgId;
|
||||
}
|
||||
|
||||
// Add schedule if specified
|
||||
if (schedule) {
|
||||
options.schedule = new Date(schedule);
|
||||
}
|
||||
|
||||
// Add buttons if specified
|
||||
if (buttons && buttons.length > 0) {
|
||||
options.buttons = parseButtons(buttons);
|
||||
}
|
||||
|
||||
// Add file if specified
|
||||
if (file) {
|
||||
options.file = file;
|
||||
}
|
||||
|
||||
// Send message
|
||||
const result = await client.sendMessage(chatId, options);
|
||||
|
||||
logger.info(`Message sent: ${accountId} -> ${chatId}`);
|
||||
this.connectionPool.metrics.messagesSent.inc({ status: 'success' });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: result.id,
|
||||
date: result.date,
|
||||
chat: {
|
||||
id: result.peerId.userId || result.peerId.channelId || result.peerId.chatId,
|
||||
type: result.peerId.className
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to send message:', error);
|
||||
this.connectionPool.metrics.messagesSent.inc({ status: 'error' });
|
||||
|
||||
// Handle flood wait
|
||||
if (error.message && error.message.includes('FLOOD_WAIT')) {
|
||||
const seconds = parseInt(error.message.match(/\d+/)[0]);
|
||||
await this.connectionPool.handleFloodWait(accountId, seconds);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sendBulkMessages(params) {
|
||||
const {
|
||||
accountId,
|
||||
recipients,
|
||||
message,
|
||||
parseMode = 'md',
|
||||
buttons,
|
||||
batchSize = 30,
|
||||
delayBetweenBatches = 5000,
|
||||
randomDelay = true
|
||||
} = params;
|
||||
|
||||
const results = {
|
||||
successful: [],
|
||||
failed: [],
|
||||
total: recipients.length
|
||||
};
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
// Process recipients in batches
|
||||
const batches = chunk(recipients, batchSize);
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i];
|
||||
logger.info(`Processing batch ${i + 1}/${batches.length}`);
|
||||
|
||||
for (const recipient of batch) {
|
||||
try {
|
||||
const result = await this.sendMessage({
|
||||
accountId,
|
||||
chatId: recipient,
|
||||
message,
|
||||
parseMode,
|
||||
buttons
|
||||
});
|
||||
|
||||
results.successful.push({
|
||||
recipient,
|
||||
messageId: result.messageId
|
||||
});
|
||||
|
||||
// Random delay between messages
|
||||
if (randomDelay) {
|
||||
await sleep(Math.random() * 2000 + 1000); // 1-3 seconds
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to send to ${recipient}:`, error.message);
|
||||
results.failed.push({
|
||||
recipient,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delay between batches
|
||||
if (i < batches.length - 1) {
|
||||
logger.info(`Waiting ${delayBetweenBatches}ms before next batch...`);
|
||||
await sleep(delayBetweenBatches);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error('Bulk message sending failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async forwardMessage(params) {
|
||||
const {
|
||||
accountId,
|
||||
fromChatId,
|
||||
toChatId,
|
||||
messageIds,
|
||||
silent = false
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const result = await client.forwardMessages(toChatId, {
|
||||
messages: messageIds,
|
||||
fromPeer: fromChatId,
|
||||
silent
|
||||
});
|
||||
|
||||
logger.info(`Messages forwarded: ${messageIds.length} messages`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
forwardedCount: result.length,
|
||||
messages: result.map(msg => ({
|
||||
id: msg.id,
|
||||
date: msg.date
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to forward messages:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async editMessage(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
messageId,
|
||||
newMessage,
|
||||
parseMode = 'md',
|
||||
buttons
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const options = {
|
||||
message: newMessage,
|
||||
parseMode
|
||||
};
|
||||
|
||||
if (buttons) {
|
||||
options.buttons = parseButtons(buttons);
|
||||
}
|
||||
|
||||
const result = await client.editMessage(chatId, {
|
||||
message: messageId,
|
||||
...options
|
||||
});
|
||||
|
||||
logger.info(`Message edited: ${messageId} in ${chatId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: result.id,
|
||||
date: result.date
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to edit message:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMessage(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
messageIds,
|
||||
revoke = true
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const result = await client.deleteMessages(chatId, messageIds, {
|
||||
revoke
|
||||
});
|
||||
|
||||
logger.info(`Messages deleted: ${messageIds.length} messages`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: result.affectedHistory || messageIds.length
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete messages:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getMessages(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
limit = 100,
|
||||
offsetId = 0,
|
||||
minId = 0,
|
||||
maxId = 0,
|
||||
reverse = false
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const messages = await client.getMessages(chatId, {
|
||||
limit,
|
||||
offsetId,
|
||||
minId,
|
||||
maxId,
|
||||
reverse
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: messages.map(msg => ({
|
||||
id: msg.id,
|
||||
message: msg.message,
|
||||
date: msg.date,
|
||||
fromId: msg.fromId?.userId || msg.fromId?.channelId,
|
||||
replyToMsgId: msg.replyTo?.replyToMsgId,
|
||||
views: msg.views,
|
||||
forwards: msg.forwards,
|
||||
media: msg.media ? msg.media.className : null
|
||||
})),
|
||||
total: messages.total
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get messages:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sendFile(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
file,
|
||||
caption = '',
|
||||
parseMode = 'md',
|
||||
buttons,
|
||||
progressCallback
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const options = {
|
||||
caption,
|
||||
parseMode,
|
||||
progressCallback
|
||||
};
|
||||
|
||||
if (buttons) {
|
||||
options.buttons = parseButtons(buttons);
|
||||
}
|
||||
|
||||
const result = await client.sendFile(chatId, {
|
||||
file,
|
||||
...options
|
||||
});
|
||||
|
||||
logger.info(`File sent to ${chatId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: result.id,
|
||||
date: result.date,
|
||||
media: result.media.className
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to send file:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadMedia(params) {
|
||||
const {
|
||||
accountId,
|
||||
message,
|
||||
outputPath,
|
||||
progressCallback
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
const buffer = await client.downloadMedia(message, {
|
||||
progressCallback
|
||||
});
|
||||
|
||||
if (outputPath) {
|
||||
const fs = await import('fs/promises');
|
||||
await fs.writeFile(outputPath, buffer);
|
||||
}
|
||||
|
||||
logger.info('Media downloaded successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
size: buffer.length,
|
||||
outputPath
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to download media:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async markAsRead(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
maxId
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
await client.markAsRead(chatId, maxId);
|
||||
|
||||
logger.info(`Messages marked as read up to ${maxId} in ${chatId}`);
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to mark as read:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setTyping(params) {
|
||||
const {
|
||||
accountId,
|
||||
chatId,
|
||||
action = 'typing' // typing, cancel, upload_photo, upload_video, etc.
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const client = await this.connectionPool.getConnection(accountId);
|
||||
|
||||
await client.setTyping(chatId, action);
|
||||
|
||||
logger.info(`Typing action set: ${action} in ${chatId}`);
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to set typing:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { StringSession } from 'gramjs/sessions/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { RedisClient } from '../config/redis.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export class SessionManager {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
this.redis = null;
|
||||
this.sessionsDir = path.join(process.cwd(), 'sessions');
|
||||
}
|
||||
|
||||
static getInstance() {
|
||||
if (!SessionManager.instance) {
|
||||
SessionManager.instance = new SessionManager();
|
||||
SessionManager.instance.initialize();
|
||||
}
|
||||
return SessionManager.instance;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.redis = RedisClient.getInstance();
|
||||
|
||||
// Ensure sessions directory exists
|
||||
await fs.mkdir(this.sessionsDir, { recursive: true });
|
||||
|
||||
logger.info('Session manager initialized');
|
||||
}
|
||||
|
||||
async createSession(accountId, phoneNumber, sessionString = '') {
|
||||
const session = new StringSession(sessionString);
|
||||
|
||||
const sessionData = {
|
||||
accountId,
|
||||
phoneNumber,
|
||||
sessionString,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsed: new Date().toISOString(),
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
// Store in memory
|
||||
this.sessions.set(accountId, {
|
||||
session,
|
||||
data: sessionData
|
||||
});
|
||||
|
||||
// Store in Redis
|
||||
await this.redis.hset('sessions', accountId, sessionData);
|
||||
|
||||
// Save to file as backup
|
||||
await this.saveSessionToFile(accountId, sessionData);
|
||||
|
||||
logger.info(`Session created for account: ${accountId}`);
|
||||
return session;
|
||||
}
|
||||
|
||||
async getSession(accountId) {
|
||||
// Check memory first
|
||||
if (this.sessions.has(accountId)) {
|
||||
const sessionInfo = this.sessions.get(accountId);
|
||||
// Update last used
|
||||
sessionInfo.data.lastUsed = new Date().toISOString();
|
||||
await this.updateSessionLastUsed(accountId);
|
||||
return sessionInfo.session;
|
||||
}
|
||||
|
||||
// Try to load from Redis
|
||||
const sessionData = await this.redis.hget('sessions', accountId);
|
||||
if (sessionData) {
|
||||
const session = new StringSession(sessionData.sessionString);
|
||||
this.sessions.set(accountId, {
|
||||
session,
|
||||
data: sessionData
|
||||
});
|
||||
await this.updateSessionLastUsed(accountId);
|
||||
return session;
|
||||
}
|
||||
|
||||
// Try to load from file
|
||||
try {
|
||||
const filePath = path.join(this.sessionsDir, `${accountId}.session`);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const sessionData = JSON.parse(content);
|
||||
|
||||
const session = new StringSession(sessionData.sessionString);
|
||||
this.sessions.set(accountId, {
|
||||
session,
|
||||
data: sessionData
|
||||
});
|
||||
|
||||
// Restore to Redis
|
||||
await this.redis.hset('sessions', accountId, sessionData);
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
logger.warn(`Session not found for account: ${accountId}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async updateSession(accountId, sessionString) {
|
||||
const sessionInfo = this.sessions.get(accountId);
|
||||
if (!sessionInfo) {
|
||||
throw new Error(`Session not found: ${accountId}`);
|
||||
}
|
||||
|
||||
// Update session string
|
||||
sessionInfo.session.setDC(sessionString);
|
||||
sessionInfo.data.sessionString = sessionString;
|
||||
sessionInfo.data.lastUsed = new Date().toISOString();
|
||||
|
||||
// Update in Redis
|
||||
await this.redis.hset('sessions', accountId, sessionInfo.data);
|
||||
|
||||
// Update file
|
||||
await this.saveSessionToFile(accountId, sessionInfo.data);
|
||||
|
||||
logger.info(`Session updated for account: ${accountId}`);
|
||||
}
|
||||
|
||||
async removeSession(accountId) {
|
||||
// Remove from memory
|
||||
this.sessions.delete(accountId);
|
||||
|
||||
// Remove from Redis
|
||||
await this.redis.hdel('sessions', accountId);
|
||||
|
||||
// Remove file
|
||||
try {
|
||||
const filePath = path.join(this.sessionsDir, `${accountId}.session`);
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
// File might not exist
|
||||
}
|
||||
|
||||
logger.info(`Session removed for account: ${accountId}`);
|
||||
}
|
||||
|
||||
async getAllSessions() {
|
||||
const sessions = await this.redis.hgetall('sessions');
|
||||
return Object.values(sessions);
|
||||
}
|
||||
|
||||
async getActiveSessions() {
|
||||
const allSessions = await this.getAllSessions();
|
||||
return allSessions.filter(session => session.status === 'active');
|
||||
}
|
||||
|
||||
async markSessionInactive(accountId) {
|
||||
const sessionInfo = this.sessions.get(accountId);
|
||||
if (sessionInfo) {
|
||||
sessionInfo.data.status = 'inactive';
|
||||
await this.redis.hset('sessions', accountId, sessionInfo.data);
|
||||
await this.saveSessionToFile(accountId, sessionInfo.data);
|
||||
}
|
||||
}
|
||||
|
||||
async loadSessions() {
|
||||
try {
|
||||
// Load from Redis
|
||||
const sessions = await this.redis.hgetall('sessions');
|
||||
|
||||
for (const [accountId, sessionData] of Object.entries(sessions)) {
|
||||
if (sessionData.status === 'active') {
|
||||
const session = new StringSession(sessionData.sessionString);
|
||||
this.sessions.set(accountId, {
|
||||
session,
|
||||
data: sessionData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Loaded ${this.sessions.size} active sessions`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async saveSessionToFile(accountId, sessionData) {
|
||||
const filePath = path.join(this.sessionsDir, `${accountId}.session`);
|
||||
await fs.writeFile(filePath, JSON.stringify(sessionData, null, 2));
|
||||
}
|
||||
|
||||
async updateSessionLastUsed(accountId) {
|
||||
const sessionData = await this.redis.hget('sessions', accountId);
|
||||
if (sessionData) {
|
||||
sessionData.lastUsed = new Date().toISOString();
|
||||
await this.redis.hset('sessions', accountId, sessionData);
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionStats() {
|
||||
const allSessions = await this.getAllSessions();
|
||||
const activeSessions = allSessions.filter(s => s.status === 'active');
|
||||
const inactiveSessions = allSessions.filter(s => s.status === 'inactive');
|
||||
|
||||
return {
|
||||
total: allSessions.length,
|
||||
active: activeSessions.length,
|
||||
inactive: inactiveSessions.length,
|
||||
inMemory: this.sessions.size
|
||||
};
|
||||
}
|
||||
|
||||
async rotateSession(accountId) {
|
||||
// This would implement session rotation logic
|
||||
// For now, just log the intention
|
||||
logger.info(`Session rotation requested for account: ${accountId}`);
|
||||
|
||||
// In a real implementation:
|
||||
// 1. Create new session
|
||||
// 2. Transfer state
|
||||
// 3. Mark old session as rotated
|
||||
// 4. Update all references
|
||||
}
|
||||
|
||||
async cleanupOldSessions(daysOld = 30) {
|
||||
const allSessions = await this.getAllSessions();
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
|
||||
|
||||
let cleaned = 0;
|
||||
for (const session of allSessions) {
|
||||
const lastUsed = new Date(session.lastUsed);
|
||||
if (lastUsed < cutoffDate && session.status === 'inactive') {
|
||||
await this.removeSession(session.accountId);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Cleaned up ${cleaned} old sessions`);
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
196
marketing-agent/services/gramjs-adapter/src/utils/helpers.js
Normal file
196
marketing-agent/services/gramjs-adapter/src/utils/helpers.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Button } from 'gramjs/tl/custom/button.js';
|
||||
|
||||
export const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export const chunk = (array, size) => {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
};
|
||||
|
||||
export const parseButtons = (buttons) => {
|
||||
// Convert button configuration to gramJS button format
|
||||
const rows = [];
|
||||
|
||||
for (const row of buttons) {
|
||||
const buttonRow = [];
|
||||
|
||||
for (const button of row) {
|
||||
if (button.url) {
|
||||
buttonRow.push(Button.url(button.text, button.url));
|
||||
} else if (button.callback) {
|
||||
buttonRow.push(Button.inline(button.text, button.callback));
|
||||
} else {
|
||||
buttonRow.push(Button.text(button.text));
|
||||
}
|
||||
}
|
||||
|
||||
rows.push(buttonRow);
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const sanitizePhoneNumber = (phone) => {
|
||||
// Remove all non-digit characters
|
||||
const cleaned = phone.replace(/\D/g, '');
|
||||
|
||||
// Add + if not present
|
||||
if (!cleaned.startsWith('+')) {
|
||||
return '+' + cleaned;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
export const parseEntityId = (entity) => {
|
||||
if (typeof entity === 'string') {
|
||||
return entity;
|
||||
}
|
||||
|
||||
if (entity.userId) {
|
||||
return entity.userId;
|
||||
}
|
||||
|
||||
if (entity.channelId) {
|
||||
return entity.channelId;
|
||||
}
|
||||
|
||||
if (entity.chatId) {
|
||||
return entity.chatId;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isFloodError = (error) => {
|
||||
return error.message && error.message.includes('FLOOD_WAIT');
|
||||
};
|
||||
|
||||
export const extractFloodWaitTime = (error) => {
|
||||
const match = error.message.match(/FLOOD_WAIT_(\d+)/);
|
||||
return match ? parseInt(match[1]) : 60; // Default to 60 seconds
|
||||
};
|
||||
|
||||
export const formatBytes = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export const generateRandomString = (length = 16) => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const retryWithBackoff = async (fn, maxRetries = 3, baseDelay = 1000) => {
|
||||
let lastError;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (isFloodError(error)) {
|
||||
const waitTime = extractFloodWaitTime(error);
|
||||
await sleep(waitTime * 1000);
|
||||
} else {
|
||||
const delay = baseDelay * Math.pow(2, i);
|
||||
await sleep(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
export const validateChatId = (chatId) => {
|
||||
// Validate various Telegram chat ID formats
|
||||
if (typeof chatId === 'number') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof chatId === 'string') {
|
||||
// Username format
|
||||
if (chatId.match(/^@?[a-zA-Z0-9_]{5,32}$/)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Phone number format
|
||||
if (chatId.match(/^\+?[0-9]{7,15}$/)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Invite link format
|
||||
if (chatId.match(/^(https?:\/\/)?(t\.me|telegram\.me)\/joinchat\/[a-zA-Z0-9_-]+$/)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const parseInviteLink = (link) => {
|
||||
const match = link.match(/(?:https?:\/\/)?(?:t\.me|telegram\.me)\/joinchat\/([a-zA-Z0-9_-]+)/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
export const parseUsername = (username) => {
|
||||
// Remove @ if present
|
||||
if (username.startsWith('@')) {
|
||||
return username.substring(1);
|
||||
}
|
||||
return username;
|
||||
};
|
||||
|
||||
export const createProgressCallback = (onProgress) => {
|
||||
let lastReportedProgress = 0;
|
||||
|
||||
return (current, total) => {
|
||||
const progress = Math.floor((current / total) * 100);
|
||||
|
||||
// Only report if progress changed by at least 5%
|
||||
if (progress >= lastReportedProgress + 5) {
|
||||
lastReportedProgress = progress;
|
||||
onProgress(progress, current, total);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const rateLimiter = (maxCalls, timeWindow) => {
|
||||
const calls = [];
|
||||
|
||||
return async (fn) => {
|
||||
const now = Date.now();
|
||||
|
||||
// Remove old calls outside the time window
|
||||
while (calls.length > 0 && calls[0] < now - timeWindow) {
|
||||
calls.shift();
|
||||
}
|
||||
|
||||
// If we've hit the limit, wait
|
||||
if (calls.length >= maxCalls) {
|
||||
const oldestCall = calls[0];
|
||||
const waitTime = oldestCall + timeWindow - now;
|
||||
await sleep(waitTime);
|
||||
return rateLimiter(maxCalls, timeWindow)(fn);
|
||||
}
|
||||
|
||||
// Record this call and execute
|
||||
calls.push(now);
|
||||
return await fn();
|
||||
};
|
||||
};
|
||||
56
marketing-agent/services/gramjs-adapter/src/utils/logger.js
Normal file
56
marketing-agent/services/gramjs-adapter/src/utils/logger.js
Normal file
@@ -0,0 +1,56 @@
|
||||
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' })
|
||||
]
|
||||
});
|
||||
Reference in New Issue
Block a user