feat: Claude Telegram Bridge MCP server
A Model Context Protocol (MCP) server that enables Claude to communicate with users via Telegram. Provides two-way communication, notifications, question/answer flows, and message queuing. Features: - MCP server implementation with 5 tools - HTTP bridge for Telegram Bot API - Real-time notifications with priority levels - Question/answer blocking flow - Message queue for async communication - Background daemon support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
284
src/index.ts
Normal file
284
src/index.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { Telegraf } from 'telegraf';
|
||||
import express from 'express';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN!);
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3456');
|
||||
const HOST = process.env.HOST || 'localhost';
|
||||
const ENABLED = process.env.ENABLED !== 'false';
|
||||
|
||||
let chatId: string | null = process.env.TELEGRAM_CHAT_ID || null;
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Message queue for two-way communication
|
||||
interface QueuedMessage {
|
||||
from: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
const messageQueue: QueuedMessage[] = [];
|
||||
const pendingQuestions = new Map<string, { resolve: (answer: string) => void; timeout: NodeJS.Timeout }>();
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Save chat ID to .env file
|
||||
async function saveChatId(id: string) {
|
||||
try {
|
||||
const envContent = await fs.readFile(envPath, 'utf-8');
|
||||
const updated = envContent.replace(
|
||||
/TELEGRAM_CHAT_ID=.*/,
|
||||
`TELEGRAM_CHAT_ID=${id}`
|
||||
);
|
||||
await fs.writeFile(envPath, updated);
|
||||
console.log(`✅ Chat ID saved: ${id}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to save chat ID:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Bot commands
|
||||
bot.start(async (ctx) => {
|
||||
chatId = ctx.chat.id.toString();
|
||||
await saveChatId(chatId);
|
||||
await ctx.reply(
|
||||
'🤖 *Claude Telegram Bridge Active*\n\n' +
|
||||
'I will now forward notifications from Claude Code and other apps.\n\n' +
|
||||
'*Commands:*\n' +
|
||||
'/status - Check bridge status\n' +
|
||||
'/enable - Enable notifications\n' +
|
||||
'/disable - Disable notifications\n' +
|
||||
'/test - Send test notification',
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
});
|
||||
|
||||
bot.command('status', async (ctx) => {
|
||||
const status = ENABLED ? '✅ Enabled' : '⛔ Disabled';
|
||||
await ctx.reply(
|
||||
`*Bridge Status*\n\n` +
|
||||
`Status: ${status}\n` +
|
||||
`Chat ID: ${chatId}\n` +
|
||||
`HTTP Server: http://${HOST}:${PORT}`,
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
});
|
||||
|
||||
bot.command('help', async (ctx) => {
|
||||
await ctx.reply(
|
||||
'*Claude Telegram Bridge - Commands*\n\n' +
|
||||
'*Bot Commands:*\n' +
|
||||
'`/start` - Initialize and connect\n' +
|
||||
'`/help` - Show this help message\n' +
|
||||
'`/status` - Check bridge status\n' +
|
||||
'`/test` - Send test notification\n\n' +
|
||||
'*How it works:*\n' +
|
||||
'• Send me any message - I forward it to Claude\n' +
|
||||
'• Claude processes it and replies back\n' +
|
||||
'• When Claude asks a question, your next message answers it\n\n' +
|
||||
'*Features:*\n' +
|
||||
'✅ Two-way communication\n' +
|
||||
'✅ Question/Answer flow\n' +
|
||||
'✅ Progress notifications\n' +
|
||||
'✅ Error alerts\n\n' +
|
||||
'More info: See README in bridge folder',
|
||||
{ parse_mode: 'Markdown' }
|
||||
);
|
||||
});
|
||||
|
||||
bot.command('test', async (ctx) => {
|
||||
await ctx.reply('✅ Test notification received! Bridge is working.');
|
||||
});
|
||||
|
||||
// Listen for any text messages from user
|
||||
bot.on('text', async (ctx) => {
|
||||
const message = ctx.message.text;
|
||||
const from = ctx.from.username || ctx.from.first_name;
|
||||
|
||||
console.log(`\n📨 Message from ${from}: "${message}"\n`);
|
||||
|
||||
// Check if this is an answer to a pending question
|
||||
const questionId = Array.from(pendingQuestions.keys())[0];
|
||||
if (questionId && pendingQuestions.has(questionId)) {
|
||||
const { resolve, timeout } = pendingQuestions.get(questionId)!;
|
||||
clearTimeout(timeout);
|
||||
pendingQuestions.delete(questionId);
|
||||
resolve(message);
|
||||
await ctx.reply('✅ Answer received!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to message queue for processing
|
||||
messageQueue.push({
|
||||
from,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
});
|
||||
|
||||
// Acknowledge receipt - Claude will respond when available
|
||||
await ctx.reply('💬 Message received - responding...');
|
||||
|
||||
console.log('📥 Queued for Claude to process');
|
||||
});
|
||||
|
||||
// HTTP endpoint for sending notifications
|
||||
app.post('/notify', async (req, res) => {
|
||||
if (!ENABLED) {
|
||||
return res.status(503).json({ error: 'Bridge is disabled' });
|
||||
}
|
||||
|
||||
if (!chatId) {
|
||||
return res.status(400).json({
|
||||
error: 'No chat ID set. Please message the bot first with /start'
|
||||
});
|
||||
}
|
||||
|
||||
const { message, priority = 'info', parseMode = 'Markdown' } = req.body;
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).json({ error: 'Message is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const emojiMap: Record<string, string> = {
|
||||
info: 'ℹ️',
|
||||
success: '✅',
|
||||
warning: '⚠️',
|
||||
error: '❌',
|
||||
question: '❓'
|
||||
};
|
||||
const emoji = emojiMap[priority] || 'ℹ️';
|
||||
|
||||
await bot.telegram.sendMessage(
|
||||
chatId,
|
||||
`${emoji} ${message}`,
|
||||
{ parse_mode: parseMode as any }
|
||||
);
|
||||
|
||||
res.json({ success: true, chatId });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to send message:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get unread messages
|
||||
app.get('/messages', (req, res) => {
|
||||
const unread = messageQueue.filter(m => !m.read);
|
||||
res.json({ messages: unread, count: unread.length });
|
||||
});
|
||||
|
||||
// Mark messages as read
|
||||
app.post('/messages/read', (req, res) => {
|
||||
const { count } = req.body;
|
||||
const toMark = count || messageQueue.filter(m => !m.read).length;
|
||||
|
||||
let marked = 0;
|
||||
for (const msg of messageQueue) {
|
||||
if (!msg.read && marked < toMark) {
|
||||
msg.read = true;
|
||||
marked++;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ markedAsRead: marked });
|
||||
});
|
||||
|
||||
// Send reply to user message
|
||||
app.post('/reply', async (req, res) => {
|
||||
if (!chatId) {
|
||||
return res.status(400).json({ error: 'No chat ID set' });
|
||||
}
|
||||
|
||||
const { message } = req.body;
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).json({ error: 'Message is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
await bot.telegram.sendMessage(chatId, message, { parse_mode: 'Markdown' });
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Ask a question and wait for answer
|
||||
app.post('/ask', async (req, res) => {
|
||||
if (!chatId) {
|
||||
return res.status(400).json({ error: 'No chat ID set' });
|
||||
}
|
||||
|
||||
const { question, timeout = 300000 } = req.body; // 5 min default timeout
|
||||
|
||||
if (!question) {
|
||||
return res.status(400).json({ error: 'Question is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const questionId = Date.now().toString();
|
||||
|
||||
// Send question to Telegram
|
||||
await bot.telegram.sendMessage(chatId, `❓ ${question}`, { parse_mode: 'Markdown' });
|
||||
|
||||
// Wait for answer
|
||||
const answer = await new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingQuestions.delete(questionId);
|
||||
reject(new Error('Timeout waiting for answer'));
|
||||
}, timeout);
|
||||
|
||||
pendingQuestions.set(questionId, { resolve, timeout: timer });
|
||||
});
|
||||
|
||||
res.json({ answer });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'running',
|
||||
enabled: ENABLED,
|
||||
chatId: chatId ? 'set' : 'not set',
|
||||
unreadMessages: messageQueue.filter(m => !m.read).length,
|
||||
pendingQuestions: pendingQuestions.size
|
||||
});
|
||||
});
|
||||
|
||||
// Start bot
|
||||
bot.launch().then(() => {
|
||||
console.log('🤖 Telegram bot started');
|
||||
console.log('📱 Message your bot to get started');
|
||||
});
|
||||
|
||||
// Start HTTP server
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`🌐 HTTP server running on http://${HOST}:${PORT}`);
|
||||
console.log(`\n📋 Send notifications with:\n`);
|
||||
console.log(`curl -X POST http://${HOST}:${PORT}/notify \\`);
|
||||
console.log(` -H "Content-Type: application/json" \\`);
|
||||
console.log(` -d '{"message": "Hello from Claude!", "priority": "info"}'`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.once('SIGINT', () => {
|
||||
console.log('\n👋 Shutting down...');
|
||||
bot.stop('SIGINT');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.once('SIGTERM', () => {
|
||||
bot.stop('SIGTERM');
|
||||
process.exit(0);
|
||||
});
|
||||
265
src/mcp-server.ts
Normal file
265
src/mcp-server.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
Tool,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const BRIDGE_URL = process.env.TELEGRAM_BRIDGE_URL || 'http://localhost:3456';
|
||||
|
||||
// Define the Telegram bridge tools
|
||||
const TOOLS: Tool[] = [
|
||||
{
|
||||
name: 'telegram_notify',
|
||||
description: 'Send a notification to the user via Telegram. Use this to keep the user informed about progress, completion, warnings, or errors.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'The notification message to send. Supports Markdown formatting.',
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
enum: ['info', 'success', 'warning', 'error', 'question'],
|
||||
description: 'Priority level: info (ℹ️ general), success (✅ completed), warning (⚠️ alert), error (❌ failure), question (❓ needs input)',
|
||||
default: 'info',
|
||||
},
|
||||
},
|
||||
required: ['message'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'telegram_ask',
|
||||
description: 'Ask the user a question via Telegram and wait for their answer. This blocks until the user responds. Use for decisions that require user input.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: {
|
||||
type: 'string',
|
||||
description: 'The question to ask the user. Supports Markdown formatting.',
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in milliseconds (default: 300000 = 5 minutes)',
|
||||
default: 300000,
|
||||
},
|
||||
},
|
||||
required: ['question'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'telegram_get_messages',
|
||||
description: 'Retrieve unread messages from the user. Use this to check if the user has sent any messages.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'telegram_reply',
|
||||
description: 'Send a reply to a user message via Telegram. Use after getting messages to respond to the user.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {
|
||||
type: 'string',
|
||||
description: 'The reply message. Supports Markdown formatting.',
|
||||
},
|
||||
},
|
||||
required: ['message'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'telegram_check_health',
|
||||
description: 'Check the health and status of the Telegram bridge. Returns connection status, unread message count, and pending questions.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Create the MCP server
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'telegram-bridge',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Handle tool listing
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: TOOLS,
|
||||
};
|
||||
});
|
||||
|
||||
// Handle tool execution
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'telegram_notify': {
|
||||
const { message, priority = 'info' } = args as { message: string; priority?: string };
|
||||
|
||||
const response = await fetch(`${BRIDGE_URL}/notify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, priority }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: any = await response.json();
|
||||
throw new Error(error.error || 'Failed to send notification');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `✅ Notification sent successfully to Telegram (priority: ${priority})`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'telegram_ask': {
|
||||
const { question, timeout = 300000 } = args as { question: string; timeout?: number };
|
||||
|
||||
const response = await fetch(`${BRIDGE_URL}/ask`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ question, timeout }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: any = await response.json();
|
||||
throw new Error(error.error || 'Failed to ask question');
|
||||
}
|
||||
|
||||
const result: any = await response.json();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `User's answer: ${result.answer}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'telegram_get_messages': {
|
||||
const response = await fetch(`${BRIDGE_URL}/messages`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error: any = await response.json();
|
||||
throw new Error(error.error || 'Failed to get messages');
|
||||
}
|
||||
|
||||
const result: any = await response.json();
|
||||
const messages = result.messages.map((m: any) =>
|
||||
`[${m.timestamp}] ${m.from}: ${m.message}`
|
||||
).join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result.count > 0
|
||||
? `📬 ${result.count} unread message(s):\n\n${messages}`
|
||||
: '📭 No unread messages',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'telegram_reply': {
|
||||
const { message } = args as { message: string };
|
||||
|
||||
const response = await fetch(`${BRIDGE_URL}/reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: any = await response.json();
|
||||
throw new Error(error.error || 'Failed to send reply');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '✅ Reply sent successfully to Telegram',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'telegram_check_health': {
|
||||
const response = await fetch(`${BRIDGE_URL}/health`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Bridge is not responding');
|
||||
}
|
||||
|
||||
const health: any = await response.json();
|
||||
const statusText = [
|
||||
`🏥 Telegram Bridge Health Check`,
|
||||
``,
|
||||
`Status: ${health.status}`,
|
||||
`Enabled: ${health.enabled ? '✅' : '❌'}`,
|
||||
`Chat ID: ${health.chatId}`,
|
||||
`Unread Messages: ${health.unreadMessages}`,
|
||||
`Pending Questions: ${health.pendingQuestions}`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: statusText,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `❌ Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Start the server
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error('🚀 Telegram MCP server running on stdio');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user