telegram-claude-mcp 1.6.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +234 -0
- package/README.md +249 -58
- package/bin/daemon-ctl.js +207 -0
- package/bin/daemon.js +20 -0
- package/bin/proxy.js +22 -0
- package/bin/setup.js +90 -63
- package/hooks-v2/notify-hook.sh +32 -0
- package/hooks-v2/permission-hook.sh +43 -0
- package/hooks-v2/stop-hook.sh +45 -0
- package/package.json +16 -5
- package/src/daemon/index.ts +415 -0
- package/src/daemon/session-manager.ts +173 -0
- package/src/daemon/telegram-multi.ts +611 -0
- package/src/proxy/index.ts +429 -0
- package/src/shared/protocol.ts +146 -0
- package/src/telegram.ts +85 -71
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Telegram Claude Daemon
|
|
5
|
+
*
|
|
6
|
+
* Singleton daemon that handles all Telegram communication for multiple
|
|
7
|
+
* Claude Code sessions. Proxies connect via Unix socket.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as net from 'net';
|
|
11
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'http';
|
|
12
|
+
import { existsSync, unlinkSync, writeFileSync, mkdirSync } from 'fs';
|
|
13
|
+
import { dirname } from 'path';
|
|
14
|
+
import { SessionManager } from './session-manager.js';
|
|
15
|
+
import { MultiTelegramManager, type HookEvent } from './telegram-multi.js';
|
|
16
|
+
import {
|
|
17
|
+
DAEMON_SOCKET_PATH,
|
|
18
|
+
DAEMON_PID_FILE,
|
|
19
|
+
DAEMON_HTTP_PORT,
|
|
20
|
+
serializeMessage,
|
|
21
|
+
parseMessages,
|
|
22
|
+
type ProxyMessage,
|
|
23
|
+
type DaemonMessage,
|
|
24
|
+
type DaemonConnectedMessage,
|
|
25
|
+
type DaemonToolResultMessage,
|
|
26
|
+
type DaemonErrorMessage,
|
|
27
|
+
type DaemonPongMessage,
|
|
28
|
+
} from '../shared/protocol.js';
|
|
29
|
+
|
|
30
|
+
const VERSION = '2.0.0';
|
|
31
|
+
|
|
32
|
+
interface DaemonConfig {
|
|
33
|
+
telegramBotToken: string;
|
|
34
|
+
telegramChatId: number;
|
|
35
|
+
responseTimeoutMs: number;
|
|
36
|
+
permissionTimeoutMs: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loadConfig(): DaemonConfig {
|
|
40
|
+
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
41
|
+
const chatId = process.env.TELEGRAM_CHAT_ID;
|
|
42
|
+
|
|
43
|
+
if (!botToken) {
|
|
44
|
+
console.error('Error: TELEGRAM_BOT_TOKEN is required');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!chatId) {
|
|
49
|
+
console.error('Error: TELEGRAM_CHAT_ID is required');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
telegramBotToken: botToken,
|
|
55
|
+
telegramChatId: parseInt(chatId, 10),
|
|
56
|
+
responseTimeoutMs: parseInt(process.env.CHAT_RESPONSE_TIMEOUT_MS || '600000', 10),
|
|
57
|
+
permissionTimeoutMs: parseInt(process.env.PERMISSION_TIMEOUT_MS || '600000', 10),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function main() {
|
|
62
|
+
const config = loadConfig();
|
|
63
|
+
|
|
64
|
+
console.error('');
|
|
65
|
+
console.error('Telegram Claude Daemon v' + VERSION);
|
|
66
|
+
console.error('================================');
|
|
67
|
+
|
|
68
|
+
// Clean up stale socket
|
|
69
|
+
if (existsSync(DAEMON_SOCKET_PATH)) {
|
|
70
|
+
console.error('[Daemon] Removing stale socket');
|
|
71
|
+
unlinkSync(DAEMON_SOCKET_PATH);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Ensure socket directory exists
|
|
75
|
+
const socketDir = dirname(DAEMON_SOCKET_PATH);
|
|
76
|
+
if (!existsSync(socketDir)) {
|
|
77
|
+
mkdirSync(socketDir, { recursive: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Write PID file
|
|
81
|
+
writeFileSync(DAEMON_PID_FILE, process.pid.toString());
|
|
82
|
+
|
|
83
|
+
// Create session manager
|
|
84
|
+
const sessionManager = new SessionManager();
|
|
85
|
+
|
|
86
|
+
// Create multi-session Telegram manager
|
|
87
|
+
const telegram = new MultiTelegramManager(
|
|
88
|
+
{
|
|
89
|
+
botToken: config.telegramBotToken,
|
|
90
|
+
chatId: config.telegramChatId,
|
|
91
|
+
responseTimeoutMs: config.responseTimeoutMs,
|
|
92
|
+
permissionTimeoutMs: config.permissionTimeoutMs,
|
|
93
|
+
},
|
|
94
|
+
sessionManager
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
telegram.start();
|
|
98
|
+
|
|
99
|
+
// Track socket buffers for each connection
|
|
100
|
+
const socketBuffers = new Map<net.Socket, string>();
|
|
101
|
+
|
|
102
|
+
// Create Unix socket server for proxy connections
|
|
103
|
+
const ipcServer = net.createServer((socket) => {
|
|
104
|
+
console.error('[Daemon] New proxy connection');
|
|
105
|
+
socketBuffers.set(socket, '');
|
|
106
|
+
|
|
107
|
+
socket.on('data', async (data) => {
|
|
108
|
+
let buffer = socketBuffers.get(socket) || '';
|
|
109
|
+
buffer += data.toString();
|
|
110
|
+
|
|
111
|
+
const { messages, remainder } = parseMessages<ProxyMessage>(buffer);
|
|
112
|
+
socketBuffers.set(socket, remainder);
|
|
113
|
+
|
|
114
|
+
for (const msg of messages) {
|
|
115
|
+
await handleProxyMessage(socket, msg, sessionManager, telegram);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
socket.on('close', () => {
|
|
120
|
+
const session = sessionManager.findBySocket(socket);
|
|
121
|
+
if (session) {
|
|
122
|
+
console.error(`[Daemon] Proxy disconnected: ${session.sessionId}`);
|
|
123
|
+
sessionManager.unregister(session.sessionId);
|
|
124
|
+
}
|
|
125
|
+
socketBuffers.delete(socket);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
socket.on('error', (err) => {
|
|
129
|
+
console.error('[Daemon] Socket error:', err.message);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
ipcServer.listen(DAEMON_SOCKET_PATH, () => {
|
|
134
|
+
console.error(`[Daemon] IPC server listening on ${DAEMON_SOCKET_PATH}`);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Create HTTP server for hooks
|
|
138
|
+
const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
139
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
140
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
141
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
142
|
+
|
|
143
|
+
if (req.method === 'OPTIONS') {
|
|
144
|
+
res.writeHead(200);
|
|
145
|
+
res.end();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (req.method !== 'POST') {
|
|
150
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
151
|
+
res.end(JSON.stringify({ error: 'Method not allowed' }));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let body = '';
|
|
156
|
+
for await (const chunk of req) {
|
|
157
|
+
body += chunk;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const data = JSON.parse(body);
|
|
162
|
+
const url = req.url || '/';
|
|
163
|
+
|
|
164
|
+
// Get session name from header or data
|
|
165
|
+
const sessionName = req.headers['x-session-name'] as string || data.session_name || 'default';
|
|
166
|
+
|
|
167
|
+
if (url === '/permission' || url === '/hooks/permission') {
|
|
168
|
+
const { tool_name, tool_input } = data;
|
|
169
|
+
|
|
170
|
+
if (!tool_name) {
|
|
171
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
172
|
+
res.end(JSON.stringify({ error: 'Missing tool_name' }));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
console.error(`[HTTP] Permission request: ${tool_name} (session: ${sessionName})`);
|
|
177
|
+
|
|
178
|
+
const decision = await telegram.handlePermissionRequest(
|
|
179
|
+
sessionName,
|
|
180
|
+
tool_name,
|
|
181
|
+
tool_input || {}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
185
|
+
res.end(JSON.stringify({
|
|
186
|
+
hookSpecificOutput: {
|
|
187
|
+
hookEventName: 'PermissionRequest',
|
|
188
|
+
decision: {
|
|
189
|
+
behavior: decision.behavior,
|
|
190
|
+
...(decision.message && { message: decision.message }),
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
}));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (url === '/notify' || url === '/hooks/notify') {
|
|
198
|
+
const event: HookEvent = {
|
|
199
|
+
type: data.type || 'notification',
|
|
200
|
+
message: data.message,
|
|
201
|
+
session_id: data.session_id,
|
|
202
|
+
tool_name: data.tool_name,
|
|
203
|
+
tool_input: data.tool_input,
|
|
204
|
+
timestamp: data.timestamp || new Date().toISOString(),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
console.error(`[HTTP] Notification: ${event.type} (session: ${sessionName})`);
|
|
208
|
+
await telegram.sendHookNotification(sessionName, event);
|
|
209
|
+
|
|
210
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
211
|
+
res.end(JSON.stringify({ success: true }));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (url === '/stop' || url === '/hooks/stop') {
|
|
216
|
+
const { transcript_path } = data;
|
|
217
|
+
|
|
218
|
+
console.error(`[HTTP] Interactive stop (session: ${sessionName})`);
|
|
219
|
+
const result = await telegram.handleInteractiveStop(sessionName, transcript_path);
|
|
220
|
+
|
|
221
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
222
|
+
res.end(JSON.stringify(result));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Status endpoint
|
|
227
|
+
if (url === '/status') {
|
|
228
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
229
|
+
res.end(JSON.stringify({
|
|
230
|
+
version: VERSION,
|
|
231
|
+
activeSessions: sessionManager.count(),
|
|
232
|
+
sessions: sessionManager.getAll().map(s => ({
|
|
233
|
+
sessionId: s.sessionId,
|
|
234
|
+
sessionName: s.sessionName,
|
|
235
|
+
connectedAt: s.connectedAt,
|
|
236
|
+
lastActivity: s.lastActivity,
|
|
237
|
+
})),
|
|
238
|
+
}));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
243
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
244
|
+
} catch (error) {
|
|
245
|
+
console.error('[HTTP] Error:', error);
|
|
246
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
247
|
+
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
httpServer.listen(DAEMON_HTTP_PORT, () => {
|
|
252
|
+
console.error(`[Daemon] HTTP hook server on port ${DAEMON_HTTP_PORT}`);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
console.error('');
|
|
256
|
+
console.error('Daemon ready!');
|
|
257
|
+
console.error(` IPC: ${DAEMON_SOCKET_PATH}`);
|
|
258
|
+
console.error(` HTTP: http://localhost:${DAEMON_HTTP_PORT}`);
|
|
259
|
+
console.error(` PID: ${process.pid}`);
|
|
260
|
+
console.error('');
|
|
261
|
+
|
|
262
|
+
// Graceful shutdown
|
|
263
|
+
const shutdown = async () => {
|
|
264
|
+
console.error('\n[Daemon] Shutting down...');
|
|
265
|
+
|
|
266
|
+
// Close all proxy connections
|
|
267
|
+
for (const session of sessionManager.getAll()) {
|
|
268
|
+
session.socket.end();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
ipcServer.close();
|
|
272
|
+
httpServer.close();
|
|
273
|
+
telegram.stop();
|
|
274
|
+
|
|
275
|
+
// Clean up files
|
|
276
|
+
try {
|
|
277
|
+
if (existsSync(DAEMON_SOCKET_PATH)) unlinkSync(DAEMON_SOCKET_PATH);
|
|
278
|
+
if (existsSync(DAEMON_PID_FILE)) unlinkSync(DAEMON_PID_FILE);
|
|
279
|
+
} catch {}
|
|
280
|
+
|
|
281
|
+
process.exit(0);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
process.on('SIGINT', shutdown);
|
|
285
|
+
process.on('SIGTERM', shutdown);
|
|
286
|
+
|
|
287
|
+
// Periodic cleanup of dead sessions
|
|
288
|
+
setInterval(() => {
|
|
289
|
+
sessionManager.cleanup();
|
|
290
|
+
}, 30000);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Handle a message from a proxy
|
|
295
|
+
*/
|
|
296
|
+
async function handleProxyMessage(
|
|
297
|
+
socket: net.Socket,
|
|
298
|
+
msg: ProxyMessage,
|
|
299
|
+
sessionManager: SessionManager,
|
|
300
|
+
telegram: MultiTelegramManager
|
|
301
|
+
): Promise<void> {
|
|
302
|
+
console.error(`[Daemon] Received: ${msg.type} from ${msg.sessionId}`);
|
|
303
|
+
|
|
304
|
+
switch (msg.type) {
|
|
305
|
+
case 'connect': {
|
|
306
|
+
sessionManager.register(msg.sessionId, msg.sessionName, socket, msg.projectPath);
|
|
307
|
+
|
|
308
|
+
const response: DaemonConnectedMessage = {
|
|
309
|
+
type: 'connected',
|
|
310
|
+
sessionId: msg.sessionId,
|
|
311
|
+
daemonVersion: VERSION,
|
|
312
|
+
};
|
|
313
|
+
socket.write(serializeMessage(response));
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
case 'disconnect': {
|
|
318
|
+
sessionManager.unregister(msg.sessionId);
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case 'ping': {
|
|
323
|
+
const response: DaemonPongMessage = {
|
|
324
|
+
type: 'pong',
|
|
325
|
+
sessionId: msg.sessionId,
|
|
326
|
+
activeSessions: sessionManager.count(),
|
|
327
|
+
};
|
|
328
|
+
socket.write(serializeMessage(response));
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
case 'tool_call': {
|
|
333
|
+
const { requestId, tool, arguments: args } = msg;
|
|
334
|
+
sessionManager.touch(msg.sessionId);
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
let result: { content: Array<{ type: string; text: string }>; isError?: boolean };
|
|
338
|
+
|
|
339
|
+
switch (tool) {
|
|
340
|
+
case 'send_message': {
|
|
341
|
+
const { message } = args as { message: string };
|
|
342
|
+
const res = await telegram.sendMessageAndWait(msg.sessionId, message);
|
|
343
|
+
result = {
|
|
344
|
+
content: [
|
|
345
|
+
{
|
|
346
|
+
type: 'text',
|
|
347
|
+
text: `Message sent to Telegram.\n\nChat ID: ${res.chatId}\n\nUser's response:\n${res.response}\n\nUse continue_chat for follow-ups or end_chat to close.`,
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
};
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
case 'continue_chat': {
|
|
355
|
+
const { message } = args as { chat_id: string; message: string };
|
|
356
|
+
const response = await telegram.continueChat(msg.sessionId, message);
|
|
357
|
+
result = {
|
|
358
|
+
content: [{ type: 'text', text: `User's response:\n${response}` }],
|
|
359
|
+
};
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
case 'notify_user': {
|
|
364
|
+
const { message } = args as { chat_id: string; message: string };
|
|
365
|
+
await telegram.notify(msg.sessionId, message);
|
|
366
|
+
result = {
|
|
367
|
+
content: [{ type: 'text', text: `Notification sent: "${message}"` }],
|
|
368
|
+
};
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
case 'end_chat': {
|
|
373
|
+
const { message } = args as { chat_id: string; message?: string };
|
|
374
|
+
await telegram.endChat(msg.sessionId, message);
|
|
375
|
+
result = {
|
|
376
|
+
content: [{ type: 'text', text: 'Chat session ended.' }],
|
|
377
|
+
};
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
default:
|
|
382
|
+
throw new Error(`Unknown tool: ${tool}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const response: DaemonToolResultMessage = {
|
|
386
|
+
type: 'tool_result',
|
|
387
|
+
sessionId: msg.sessionId,
|
|
388
|
+
requestId,
|
|
389
|
+
success: true,
|
|
390
|
+
result,
|
|
391
|
+
};
|
|
392
|
+
socket.write(serializeMessage(response));
|
|
393
|
+
} catch (error) {
|
|
394
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
395
|
+
const response: DaemonToolResultMessage = {
|
|
396
|
+
type: 'tool_result',
|
|
397
|
+
sessionId: msg.sessionId,
|
|
398
|
+
requestId,
|
|
399
|
+
success: false,
|
|
400
|
+
result: {
|
|
401
|
+
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
|
|
402
|
+
isError: true,
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
socket.write(serializeMessage(response));
|
|
406
|
+
}
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
main().catch((error) => {
|
|
413
|
+
console.error('Fatal error:', error);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Manager for the Telegram Claude Daemon
|
|
3
|
+
*
|
|
4
|
+
* Tracks all connected Claude Code sessions and their state.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Socket } from 'net';
|
|
8
|
+
import type { SessionInfo } from '../shared/protocol.js';
|
|
9
|
+
|
|
10
|
+
export interface ConnectedSession extends SessionInfo {
|
|
11
|
+
socket: Socket;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class SessionManager {
|
|
15
|
+
private sessions: Map<string, ConnectedSession> = new Map();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register a new session
|
|
19
|
+
*/
|
|
20
|
+
register(
|
|
21
|
+
sessionId: string,
|
|
22
|
+
sessionName: string,
|
|
23
|
+
socket: Socket,
|
|
24
|
+
projectPath?: string
|
|
25
|
+
): ConnectedSession {
|
|
26
|
+
const session: ConnectedSession = {
|
|
27
|
+
sessionId,
|
|
28
|
+
sessionName,
|
|
29
|
+
projectPath,
|
|
30
|
+
socket,
|
|
31
|
+
connectedAt: new Date(),
|
|
32
|
+
lastActivity: new Date(),
|
|
33
|
+
activeChats: new Set(),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
this.sessions.set(sessionId, session);
|
|
37
|
+
console.error(`[SessionManager] Registered session: ${sessionId} (${sessionName})`);
|
|
38
|
+
|
|
39
|
+
return session;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Unregister a session
|
|
44
|
+
*/
|
|
45
|
+
unregister(sessionId: string): boolean {
|
|
46
|
+
const session = this.sessions.get(sessionId);
|
|
47
|
+
if (session) {
|
|
48
|
+
this.sessions.delete(sessionId);
|
|
49
|
+
console.error(`[SessionManager] Unregistered session: ${sessionId}`);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get session by ID
|
|
57
|
+
*/
|
|
58
|
+
get(sessionId: string): ConnectedSession | undefined {
|
|
59
|
+
return this.sessions.get(sessionId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get session by name (returns first match)
|
|
64
|
+
*/
|
|
65
|
+
getByName(sessionName: string): ConnectedSession | undefined {
|
|
66
|
+
for (const session of this.sessions.values()) {
|
|
67
|
+
if (session.sessionName === sessionName) {
|
|
68
|
+
return session;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get all sessions
|
|
76
|
+
*/
|
|
77
|
+
getAll(): ConnectedSession[] {
|
|
78
|
+
return Array.from(this.sessions.values());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get count of active sessions
|
|
83
|
+
*/
|
|
84
|
+
count(): number {
|
|
85
|
+
return this.sessions.size;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Update last activity timestamp
|
|
90
|
+
*/
|
|
91
|
+
touch(sessionId: string): void {
|
|
92
|
+
const session = this.sessions.get(sessionId);
|
|
93
|
+
if (session) {
|
|
94
|
+
session.lastActivity = new Date();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Add active chat to session
|
|
100
|
+
*/
|
|
101
|
+
addChat(sessionId: string, chatId: string): void {
|
|
102
|
+
const session = this.sessions.get(sessionId);
|
|
103
|
+
if (session) {
|
|
104
|
+
session.activeChats.add(chatId);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Remove active chat from session
|
|
110
|
+
*/
|
|
111
|
+
removeChat(sessionId: string, chatId: string): void {
|
|
112
|
+
const session = this.sessions.get(sessionId);
|
|
113
|
+
if (session) {
|
|
114
|
+
session.activeChats.delete(chatId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Find session by socket
|
|
120
|
+
*/
|
|
121
|
+
findBySocket(socket: Socket): ConnectedSession | undefined {
|
|
122
|
+
for (const session of this.sessions.values()) {
|
|
123
|
+
if (session.socket === socket) {
|
|
124
|
+
return session;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get most recently active session
|
|
132
|
+
*/
|
|
133
|
+
getMostRecentActive(): ConnectedSession | undefined {
|
|
134
|
+
let mostRecent: ConnectedSession | undefined;
|
|
135
|
+
let latestTime = 0;
|
|
136
|
+
|
|
137
|
+
for (const session of this.sessions.values()) {
|
|
138
|
+
const time = session.lastActivity.getTime();
|
|
139
|
+
if (time > latestTime) {
|
|
140
|
+
latestTime = time;
|
|
141
|
+
mostRecent = session;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return mostRecent;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Find session that should receive a hook event
|
|
150
|
+
* Priority: explicit session name > most recently active
|
|
151
|
+
*/
|
|
152
|
+
findForHook(sessionName?: string): ConnectedSession | undefined {
|
|
153
|
+
if (sessionName) {
|
|
154
|
+
return this.getByName(sessionName);
|
|
155
|
+
}
|
|
156
|
+
return this.getMostRecentActive();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Clean up disconnected sessions
|
|
161
|
+
*/
|
|
162
|
+
cleanup(): number {
|
|
163
|
+
let cleaned = 0;
|
|
164
|
+
for (const [sessionId, session] of this.sessions.entries()) {
|
|
165
|
+
if (session.socket.destroyed) {
|
|
166
|
+
this.sessions.delete(sessionId);
|
|
167
|
+
cleaned++;
|
|
168
|
+
console.error(`[SessionManager] Cleaned up dead session: ${sessionId}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return cleaned;
|
|
172
|
+
}
|
|
173
|
+
}
|