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

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

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

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

View File

@@ -0,0 +1,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"]

View 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"
}
}

View File

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

View 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);
}
}

View 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);
});

View File

@@ -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);
}
}
}
}
];

View 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;

View 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);
}
}
}
}
];

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View 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();
};
};

View 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' })
]
});