telegram-claude-mcp 1.0.0
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/bin/run.js +17 -0
- package/package.json +43 -0
- package/src/index.ts +205 -0
- package/src/providers/index.ts +73 -0
- package/src/providers/llm-openrouter.ts +129 -0
- package/src/telegram.ts +342 -0
package/bin/run.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const srcPath = join(__dirname, '..', 'src', 'index.ts');
|
|
8
|
+
const tsxPath = join(__dirname, '..', 'node_modules', '.bin', 'tsx');
|
|
9
|
+
|
|
10
|
+
const child = spawn(tsxPath, [srcPath], {
|
|
11
|
+
stdio: 'inherit',
|
|
12
|
+
env: process.env,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
child.on('exit', (code) => {
|
|
16
|
+
process.exit(code ?? 0);
|
|
17
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "telegram-claude-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server that lets Claude message you on Telegram",
|
|
5
|
+
"author": "Geravant",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/Geravant/telegram-claude"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"claude",
|
|
13
|
+
"telegram",
|
|
14
|
+
"mcp",
|
|
15
|
+
"claude-code",
|
|
16
|
+
"bot",
|
|
17
|
+
"messaging"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "src/index.ts",
|
|
21
|
+
"bin": {
|
|
22
|
+
"telegram-claude-mcp": "./bin/run.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"bin"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"start": "node --import tsx src/index.ts",
|
|
30
|
+
"dev": "node --watch --import tsx src/index.ts"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
34
|
+
"node-telegram-bot-api": "^0.66.0",
|
|
35
|
+
"openai": "^4.77.3",
|
|
36
|
+
"tsx": "^4.21.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22.10.5",
|
|
40
|
+
"@types/node-telegram-bot-api": "^0.64.7",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Telegram Claude MCP Server
|
|
5
|
+
*
|
|
6
|
+
* A stdio-based MCP server that lets Claude message you on Telegram.
|
|
7
|
+
* Supports multiple Claude Code sessions with message tagging.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
11
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
13
|
+
import { TelegramManager } from './telegram.js';
|
|
14
|
+
import { loadAppConfig, validateAppConfig } from './providers/index.js';
|
|
15
|
+
|
|
16
|
+
async function main() {
|
|
17
|
+
// Load configuration
|
|
18
|
+
const config = loadAppConfig();
|
|
19
|
+
|
|
20
|
+
// Validate configuration
|
|
21
|
+
const errors = validateAppConfig(config);
|
|
22
|
+
if (errors.length > 0) {
|
|
23
|
+
console.error('Configuration errors:');
|
|
24
|
+
errors.forEach((e) => console.error(` - ${e}`));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Create Telegram manager
|
|
29
|
+
const telegram = new TelegramManager({
|
|
30
|
+
botToken: config.telegramBotToken,
|
|
31
|
+
chatId: config.telegramChatId,
|
|
32
|
+
sessionName: config.sessionName,
|
|
33
|
+
responseTimeoutMs: config.responseTimeoutMs,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
telegram.start();
|
|
37
|
+
|
|
38
|
+
// Track active chat sessions
|
|
39
|
+
let activeSessionId: string | null = null;
|
|
40
|
+
|
|
41
|
+
// Create stdio MCP server
|
|
42
|
+
const mcpServer = new Server(
|
|
43
|
+
{ name: 'telegram-claude', version: '1.0.0' },
|
|
44
|
+
{ capabilities: { tools: {} } }
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// List available tools
|
|
48
|
+
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
49
|
+
return {
|
|
50
|
+
tools: [
|
|
51
|
+
{
|
|
52
|
+
name: 'send_message',
|
|
53
|
+
description:
|
|
54
|
+
'Send a message to the user via Telegram and wait for their response. Use when you need user input, want to report completed work, or need discussion.',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
message: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
description: 'What you want to say to the user. Be clear and concise.',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
required: ['message'],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'continue_chat',
|
|
68
|
+
description: 'Continue an active chat with a follow-up message and wait for response.',
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
chat_id: { type: 'string', description: 'The chat ID from send_message' },
|
|
73
|
+
message: { type: 'string', description: 'Your follow-up message' },
|
|
74
|
+
},
|
|
75
|
+
required: ['chat_id', 'message'],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'notify_user',
|
|
80
|
+
description:
|
|
81
|
+
'Send a notification to the user without waiting for a response. Use for status updates or acknowledgments.',
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
chat_id: { type: 'string', description: 'The chat ID from send_message' },
|
|
86
|
+
message: { type: 'string', description: 'The notification message' },
|
|
87
|
+
},
|
|
88
|
+
required: ['chat_id', 'message'],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'end_chat',
|
|
93
|
+
description: 'End an active chat session with an optional closing message.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
chat_id: { type: 'string', description: 'The chat ID from send_message' },
|
|
98
|
+
message: {
|
|
99
|
+
type: 'string',
|
|
100
|
+
description: 'Optional closing message',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
required: ['chat_id'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Handle tool calls
|
|
111
|
+
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
112
|
+
try {
|
|
113
|
+
if (request.params.name === 'send_message') {
|
|
114
|
+
const { message } = request.params.arguments as { message: string };
|
|
115
|
+
const result = await telegram.sendMessageAndWait(message);
|
|
116
|
+
activeSessionId = result.chatId;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
content: [
|
|
120
|
+
{
|
|
121
|
+
type: 'text',
|
|
122
|
+
text: `Message sent to Telegram.\n\nChat ID: ${result.chatId}\n\nUser's response:\n${result.response}\n\nUse continue_chat for follow-ups or end_chat to close the session.`,
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (request.params.name === 'continue_chat') {
|
|
129
|
+
const { chat_id, message } = request.params.arguments as {
|
|
130
|
+
chat_id: string;
|
|
131
|
+
message: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Validate chat_id matches active session
|
|
135
|
+
if (activeSessionId && chat_id !== activeSessionId) {
|
|
136
|
+
console.error(`Warning: chat_id mismatch. Expected ${activeSessionId}, got ${chat_id}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const response = await telegram.continueChat(message);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: 'text', text: `User's response:\n${response}` }],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (request.params.name === 'notify_user') {
|
|
147
|
+
const { message } = request.params.arguments as {
|
|
148
|
+
chat_id: string;
|
|
149
|
+
message: string;
|
|
150
|
+
};
|
|
151
|
+
await telegram.notify(message);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
content: [{ type: 'text', text: `Notification sent: "${message}"` }],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (request.params.name === 'end_chat') {
|
|
159
|
+
const { chat_id, message } = request.params.arguments as {
|
|
160
|
+
chat_id: string;
|
|
161
|
+
message?: string;
|
|
162
|
+
};
|
|
163
|
+
await telegram.endChat(message);
|
|
164
|
+
activeSessionId = null;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
content: [{ type: 'text', text: 'Chat session ended.' }],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
throw new Error(`Unknown tool: ${request.params.name}`);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: 'text', text: `Error: ${errorMessage}` }],
|
|
176
|
+
isError: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Connect MCP server via stdio
|
|
182
|
+
const transport = new StdioServerTransport();
|
|
183
|
+
await mcpServer.connect(transport);
|
|
184
|
+
|
|
185
|
+
console.error('');
|
|
186
|
+
console.error('Telegram Claude MCP server ready');
|
|
187
|
+
console.error(`Session: ${config.sessionName}`);
|
|
188
|
+
console.error(`Chat ID: ${config.telegramChatId}`);
|
|
189
|
+
console.error('');
|
|
190
|
+
|
|
191
|
+
// Graceful shutdown
|
|
192
|
+
const shutdown = async () => {
|
|
193
|
+
console.error('\nShutting down...');
|
|
194
|
+
telegram.stop();
|
|
195
|
+
process.exit(0);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
process.on('SIGINT', shutdown);
|
|
199
|
+
process.on('SIGTERM', shutdown);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
main().catch((error) => {
|
|
203
|
+
console.error('Fatal error:', error);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider Factory
|
|
3
|
+
*
|
|
4
|
+
* Creates and configures providers based on environment variables.
|
|
5
|
+
* Supports Telegram for messaging and OpenRouter for LLM.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { OpenRouterProvider } from './llm-openrouter.js';
|
|
9
|
+
|
|
10
|
+
export { OpenRouterProvider } from './llm-openrouter.js';
|
|
11
|
+
export type { OpenRouterConfig, ChatMessage, CompletionResult } from './llm-openrouter.js';
|
|
12
|
+
|
|
13
|
+
export interface AppConfig {
|
|
14
|
+
// Telegram settings
|
|
15
|
+
telegramBotToken: string;
|
|
16
|
+
telegramChatId: number;
|
|
17
|
+
|
|
18
|
+
// Session settings
|
|
19
|
+
sessionName: string;
|
|
20
|
+
sessionPort: number;
|
|
21
|
+
|
|
22
|
+
// OpenRouter settings (optional)
|
|
23
|
+
openrouterApiKey?: string;
|
|
24
|
+
openrouterModel?: string;
|
|
25
|
+
|
|
26
|
+
// Chat settings
|
|
27
|
+
responseTimeoutMs: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadAppConfig(): AppConfig {
|
|
31
|
+
const chatId = process.env.TELEGRAM_CHAT_ID;
|
|
32
|
+
const sessionPort = process.env.SESSION_PORT || '3333';
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || '',
|
|
36
|
+
telegramChatId: chatId ? parseInt(chatId, 10) : 0,
|
|
37
|
+
sessionName: process.env.SESSION_NAME || 'default',
|
|
38
|
+
sessionPort: parseInt(sessionPort, 10),
|
|
39
|
+
openrouterApiKey: process.env.OPENROUTER_API_KEY,
|
|
40
|
+
openrouterModel: process.env.OPENROUTER_MODEL || 'openai/gpt-oss-120b',
|
|
41
|
+
responseTimeoutMs: parseInt(process.env.CHAT_RESPONSE_TIMEOUT_MS || '180000', 10),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validateAppConfig(config: AppConfig): string[] {
|
|
46
|
+
const errors: string[] = [];
|
|
47
|
+
|
|
48
|
+
if (!config.telegramBotToken) {
|
|
49
|
+
errors.push('Missing TELEGRAM_BOT_TOKEN (get from @BotFather)');
|
|
50
|
+
}
|
|
51
|
+
if (!config.telegramChatId) {
|
|
52
|
+
errors.push('Missing TELEGRAM_CHAT_ID (your Telegram user/chat ID)');
|
|
53
|
+
}
|
|
54
|
+
if (!config.sessionName) {
|
|
55
|
+
errors.push('Missing SESSION_NAME (unique identifier for this session)');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return errors;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createOpenRouterProvider(config: AppConfig): OpenRouterProvider | null {
|
|
62
|
+
if (!config.openrouterApiKey) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const provider = new OpenRouterProvider();
|
|
67
|
+
provider.initialize({
|
|
68
|
+
apiKey: config.openrouterApiKey,
|
|
69
|
+
model: config.openrouterModel,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return provider;
|
|
73
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouter LLM Provider
|
|
3
|
+
*
|
|
4
|
+
* Uses the OpenAI-compatible API provided by OpenRouter to access
|
|
5
|
+
* various LLM models including Claude, GPT-4, Gemini, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import OpenAI from 'openai';
|
|
9
|
+
|
|
10
|
+
export interface OpenRouterConfig {
|
|
11
|
+
apiKey: string;
|
|
12
|
+
model?: string;
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ChatMessage {
|
|
17
|
+
role: 'system' | 'user' | 'assistant';
|
|
18
|
+
content: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CompletionResult {
|
|
22
|
+
content: string;
|
|
23
|
+
model: string;
|
|
24
|
+
usage?: {
|
|
25
|
+
promptTokens: number;
|
|
26
|
+
completionTokens: number;
|
|
27
|
+
totalTokens: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class OpenRouterProvider {
|
|
32
|
+
readonly name = 'openrouter';
|
|
33
|
+
private client: OpenAI | null = null;
|
|
34
|
+
private model: string = 'openai/gpt-oss-120b';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Initialize the OpenRouter client
|
|
38
|
+
*/
|
|
39
|
+
initialize(config: OpenRouterConfig): void {
|
|
40
|
+
this.client = new OpenAI({
|
|
41
|
+
apiKey: config.apiKey,
|
|
42
|
+
baseURL: config.baseUrl || 'https://openrouter.ai/api/v1',
|
|
43
|
+
defaultHeaders: {
|
|
44
|
+
'HTTP-Referer': 'https://github.com/anthropics/claude-code',
|
|
45
|
+
'X-Title': 'Claude Code Telegram Bridge',
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.model = config.model || 'anthropic/claude-3.5-sonnet';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if the provider is initialized
|
|
54
|
+
*/
|
|
55
|
+
isInitialized(): boolean {
|
|
56
|
+
return this.client !== null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generate a chat completion
|
|
61
|
+
*/
|
|
62
|
+
async complete(messages: ChatMessage[]): Promise<CompletionResult> {
|
|
63
|
+
if (!this.client) {
|
|
64
|
+
throw new Error('OpenRouter provider not initialized');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const response = await this.client.chat.completions.create({
|
|
68
|
+
model: this.model,
|
|
69
|
+
messages: messages.map((m) => ({
|
|
70
|
+
role: m.role,
|
|
71
|
+
content: m.content,
|
|
72
|
+
})),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const choice = response.choices[0];
|
|
76
|
+
if (!choice || !choice.message.content) {
|
|
77
|
+
throw new Error('No response from OpenRouter');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
content: choice.message.content,
|
|
82
|
+
model: response.model,
|
|
83
|
+
usage: response.usage
|
|
84
|
+
? {
|
|
85
|
+
promptTokens: response.usage.prompt_tokens,
|
|
86
|
+
completionTokens: response.usage.completion_tokens,
|
|
87
|
+
totalTokens: response.usage.total_tokens,
|
|
88
|
+
}
|
|
89
|
+
: undefined,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Simple text completion with optional system prompt
|
|
95
|
+
*/
|
|
96
|
+
async simpleComplete(prompt: string, systemPrompt?: string): Promise<string> {
|
|
97
|
+
const messages: ChatMessage[] = [];
|
|
98
|
+
|
|
99
|
+
if (systemPrompt) {
|
|
100
|
+
messages.push({ role: 'system', content: systemPrompt });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
messages.push({ role: 'user', content: prompt });
|
|
104
|
+
|
|
105
|
+
const result = await this.complete(messages);
|
|
106
|
+
return result.content;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Summarize text
|
|
111
|
+
*/
|
|
112
|
+
async summarize(text: string, maxLength?: number): Promise<string> {
|
|
113
|
+
const systemPrompt = maxLength
|
|
114
|
+
? `Summarize the following text in ${maxLength} characters or less. Be concise.`
|
|
115
|
+
: 'Summarize the following text concisely.';
|
|
116
|
+
|
|
117
|
+
return this.simpleComplete(text, systemPrompt);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Singleton instance for optional use
|
|
122
|
+
let instance: OpenRouterProvider | null = null;
|
|
123
|
+
|
|
124
|
+
export function getOpenRouterProvider(): OpenRouterProvider {
|
|
125
|
+
if (!instance) {
|
|
126
|
+
instance = new OpenRouterProvider();
|
|
127
|
+
}
|
|
128
|
+
return instance;
|
|
129
|
+
}
|
package/src/telegram.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Bot Manager
|
|
3
|
+
*
|
|
4
|
+
* Handles Telegram messaging for Claude Code with multi-session support.
|
|
5
|
+
* Multiple Claude Code instances can share one Telegram chat, with messages
|
|
6
|
+
* tagged by session name and replies routed to the correct session.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import TelegramBot from 'node-telegram-bot-api';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
|
|
13
|
+
// Session state file location
|
|
14
|
+
const SESSION_DIR = '/tmp/telegram-claude-sessions';
|
|
15
|
+
|
|
16
|
+
interface SessionState {
|
|
17
|
+
sessionName: string;
|
|
18
|
+
chatId: number;
|
|
19
|
+
messageIds: number[]; // Telegram message IDs sent by this session
|
|
20
|
+
waitingForResponse: boolean;
|
|
21
|
+
lastActivity: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PendingResponse {
|
|
25
|
+
resolve: (response: string) => void;
|
|
26
|
+
reject: (error: Error) => void;
|
|
27
|
+
messageId: number;
|
|
28
|
+
timestamp: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TelegramConfig {
|
|
32
|
+
botToken: string;
|
|
33
|
+
chatId: number;
|
|
34
|
+
sessionName: string;
|
|
35
|
+
responseTimeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class TelegramManager {
|
|
39
|
+
private bot: TelegramBot;
|
|
40
|
+
private config: TelegramConfig;
|
|
41
|
+
private pendingResponses: Map<string, PendingResponse> = new Map();
|
|
42
|
+
private sessionStateFile: string;
|
|
43
|
+
private isRunning = false;
|
|
44
|
+
|
|
45
|
+
constructor(config: TelegramConfig) {
|
|
46
|
+
this.config = {
|
|
47
|
+
responseTimeoutMs: 180000, // 3 minutes default
|
|
48
|
+
...config,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
this.bot = new TelegramBot(config.botToken, { polling: true });
|
|
52
|
+
this.sessionStateFile = path.join(SESSION_DIR, `${config.sessionName}.json`);
|
|
53
|
+
|
|
54
|
+
// Ensure session directory exists
|
|
55
|
+
if (!fs.existsSync(SESSION_DIR)) {
|
|
56
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
this.setupMessageHandler();
|
|
60
|
+
this.updateSessionState({ waitingForResponse: false, messageIds: [] });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Start the bot (called after initialization)
|
|
65
|
+
*/
|
|
66
|
+
start(): void {
|
|
67
|
+
this.isRunning = true;
|
|
68
|
+
console.error(`[${this.config.sessionName}] Telegram bot started`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stop the bot
|
|
73
|
+
*/
|
|
74
|
+
stop(): void {
|
|
75
|
+
this.isRunning = false;
|
|
76
|
+
this.bot.stopPolling();
|
|
77
|
+
this.cleanupSessionState();
|
|
78
|
+
console.error(`[${this.config.sessionName}] Telegram bot stopped`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Send a message and wait for user response
|
|
83
|
+
*/
|
|
84
|
+
async sendMessageAndWait(message: string): Promise<{ chatId: string; response: string }> {
|
|
85
|
+
const taggedMessage = `[${this.config.sessionName}] ${message}`;
|
|
86
|
+
|
|
87
|
+
const sent = await this.bot.sendMessage(this.config.chatId, taggedMessage);
|
|
88
|
+
const messageId = sent.message_id;
|
|
89
|
+
|
|
90
|
+
// Update session state
|
|
91
|
+
this.updateSessionState({
|
|
92
|
+
waitingForResponse: true,
|
|
93
|
+
messageIds: [...this.getSessionState().messageIds, messageId],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Wait for response
|
|
97
|
+
const response = await this.waitForResponse(messageId);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
chatId: `${this.config.sessionName}:${this.config.chatId}`,
|
|
101
|
+
response,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Continue a chat - send follow-up and wait for response
|
|
107
|
+
*/
|
|
108
|
+
async continueChat(message: string): Promise<string> {
|
|
109
|
+
const taggedMessage = `[${this.config.sessionName}] ${message}`;
|
|
110
|
+
|
|
111
|
+
const sent = await this.bot.sendMessage(this.config.chatId, taggedMessage);
|
|
112
|
+
const messageId = sent.message_id;
|
|
113
|
+
|
|
114
|
+
// Update session state
|
|
115
|
+
this.updateSessionState({
|
|
116
|
+
waitingForResponse: true,
|
|
117
|
+
messageIds: [...this.getSessionState().messageIds, messageId],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return this.waitForResponse(messageId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Send a notification without waiting for response
|
|
125
|
+
*/
|
|
126
|
+
async notify(message: string): Promise<void> {
|
|
127
|
+
const taggedMessage = `[${this.config.sessionName}] ${message}`;
|
|
128
|
+
const sent = await this.bot.sendMessage(this.config.chatId, taggedMessage);
|
|
129
|
+
|
|
130
|
+
// Track the message but don't wait
|
|
131
|
+
this.updateSessionState({
|
|
132
|
+
messageIds: [...this.getSessionState().messageIds, sent.message_id],
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* End the chat session
|
|
138
|
+
*/
|
|
139
|
+
async endChat(message?: string): Promise<void> {
|
|
140
|
+
if (message) {
|
|
141
|
+
const taggedMessage = `[${this.config.sessionName}] ${message}`;
|
|
142
|
+
await this.bot.sendMessage(this.config.chatId, taggedMessage);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Clear pending responses
|
|
146
|
+
for (const [key, pending] of this.pendingResponses) {
|
|
147
|
+
pending.reject(new Error('Chat ended'));
|
|
148
|
+
this.pendingResponses.delete(key);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.updateSessionState({ waitingForResponse: false, messageIds: [] });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Set up message handler for incoming messages
|
|
156
|
+
*/
|
|
157
|
+
private setupMessageHandler(): void {
|
|
158
|
+
this.bot.on('message', (msg) => {
|
|
159
|
+
// Ignore messages not from our target chat
|
|
160
|
+
if (msg.chat.id !== this.config.chatId) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Ignore our own messages
|
|
165
|
+
if (msg.from?.is_bot) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const text = msg.text || '';
|
|
170
|
+
|
|
171
|
+
// Check if this is a reply to one of our messages
|
|
172
|
+
if (msg.reply_to_message) {
|
|
173
|
+
const replyToId = msg.reply_to_message.message_id;
|
|
174
|
+
if (this.isOurMessage(replyToId)) {
|
|
175
|
+
this.resolveResponse(text);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check if message is prefixed with our session name
|
|
181
|
+
const sessionPrefix = `@${this.config.sessionName}`;
|
|
182
|
+
if (text.toLowerCase().startsWith(sessionPrefix.toLowerCase())) {
|
|
183
|
+
const response = text.slice(sessionPrefix.length).trim();
|
|
184
|
+
this.resolveResponse(response);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check if we're the most recent waiting session
|
|
189
|
+
if (this.isWaitingAndMostRecent()) {
|
|
190
|
+
this.resolveResponse(text);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
this.bot.on('polling_error', (error) => {
|
|
195
|
+
console.error(`[${this.config.sessionName}] Telegram polling error:`, error.message);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Wait for a response with timeout
|
|
201
|
+
*/
|
|
202
|
+
private waitForResponse(messageId: number): Promise<string> {
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
const key = `${messageId}`;
|
|
205
|
+
|
|
206
|
+
// Set timeout
|
|
207
|
+
const timeout = setTimeout(() => {
|
|
208
|
+
this.pendingResponses.delete(key);
|
|
209
|
+
this.updateSessionState({ waitingForResponse: false });
|
|
210
|
+
reject(new Error(`Response timeout after ${this.config.responseTimeoutMs}ms`));
|
|
211
|
+
}, this.config.responseTimeoutMs);
|
|
212
|
+
|
|
213
|
+
this.pendingResponses.set(key, {
|
|
214
|
+
resolve: (response: string) => {
|
|
215
|
+
clearTimeout(timeout);
|
|
216
|
+
this.pendingResponses.delete(key);
|
|
217
|
+
this.updateSessionState({ waitingForResponse: false });
|
|
218
|
+
resolve(response);
|
|
219
|
+
},
|
|
220
|
+
reject: (error: Error) => {
|
|
221
|
+
clearTimeout(timeout);
|
|
222
|
+
this.pendingResponses.delete(key);
|
|
223
|
+
reject(error);
|
|
224
|
+
},
|
|
225
|
+
messageId,
|
|
226
|
+
timestamp: Date.now(),
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resolve a pending response
|
|
233
|
+
*/
|
|
234
|
+
private resolveResponse(text: string): void {
|
|
235
|
+
// Get the most recent pending response
|
|
236
|
+
let mostRecent: PendingResponse | null = null;
|
|
237
|
+
let mostRecentKey: string | null = null;
|
|
238
|
+
|
|
239
|
+
for (const [key, pending] of this.pendingResponses) {
|
|
240
|
+
if (!mostRecent || pending.timestamp > mostRecent.timestamp) {
|
|
241
|
+
mostRecent = pending;
|
|
242
|
+
mostRecentKey = key;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (mostRecent && mostRecentKey) {
|
|
247
|
+
mostRecent.resolve(text);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Check if a message ID belongs to this session
|
|
253
|
+
*/
|
|
254
|
+
private isOurMessage(messageId: number): boolean {
|
|
255
|
+
const state = this.getSessionState();
|
|
256
|
+
return state.messageIds.includes(messageId);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Check if this session is waiting and is the most recently active waiting session
|
|
261
|
+
*/
|
|
262
|
+
private isWaitingAndMostRecent(): boolean {
|
|
263
|
+
const ourState = this.getSessionState();
|
|
264
|
+
if (!ourState.waitingForResponse) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Check all other sessions
|
|
269
|
+
const sessionFiles = fs.readdirSync(SESSION_DIR).filter((f) => f.endsWith('.json'));
|
|
270
|
+
|
|
271
|
+
for (const file of sessionFiles) {
|
|
272
|
+
if (file === `${this.config.sessionName}.json`) continue;
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const otherState = JSON.parse(
|
|
276
|
+
fs.readFileSync(path.join(SESSION_DIR, file), 'utf-8')
|
|
277
|
+
) as SessionState;
|
|
278
|
+
|
|
279
|
+
// If another session is waiting and more recent, we're not the target
|
|
280
|
+
if (otherState.waitingForResponse && otherState.lastActivity > ourState.lastActivity) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
// Ignore invalid files
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get current session state from file
|
|
293
|
+
*/
|
|
294
|
+
private getSessionState(): SessionState {
|
|
295
|
+
try {
|
|
296
|
+
if (fs.existsSync(this.sessionStateFile)) {
|
|
297
|
+
return JSON.parse(fs.readFileSync(this.sessionStateFile, 'utf-8'));
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
// Return default state on error
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
sessionName: this.config.sessionName,
|
|
305
|
+
chatId: this.config.chatId,
|
|
306
|
+
messageIds: [],
|
|
307
|
+
waitingForResponse: false,
|
|
308
|
+
lastActivity: Date.now(),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Update session state file
|
|
314
|
+
*/
|
|
315
|
+
private updateSessionState(updates: Partial<SessionState>): void {
|
|
316
|
+
const current = this.getSessionState();
|
|
317
|
+
const updated: SessionState = {
|
|
318
|
+
...current,
|
|
319
|
+
...updates,
|
|
320
|
+
lastActivity: Date.now(),
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
fs.writeFileSync(this.sessionStateFile, JSON.stringify(updated, null, 2));
|
|
325
|
+
} catch (error) {
|
|
326
|
+
console.error(`[${this.config.sessionName}] Failed to update session state:`, error);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Clean up session state file on shutdown
|
|
332
|
+
*/
|
|
333
|
+
private cleanupSessionState(): void {
|
|
334
|
+
try {
|
|
335
|
+
if (fs.existsSync(this.sessionStateFile)) {
|
|
336
|
+
fs.unlinkSync(this.sessionStateFile);
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
// Ignore cleanup errors
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|