From 385f91de4db43cf1d108597210f92ce72263549c Mon Sep 17 00:00:00 2001 From: Garfield Date: Tue, 5 May 2026 16:54:07 -0400 Subject: [PATCH] feat: Telegram Bot API integration - New client: src/clients/telegram.ts - Tools: telegram_get_me, telegram_send_message, telegram_send_photo, telegram_get_updates, telegram_get_chat - REST endpoints: GET /api/telegram/me, POST /api/telegram/message, POST /api/telegram/photo, GET /api/telegram/updates, GET /api/telegram/chat - Multi-account env var pattern: TELEGRAM_{ACCOUNT}_BOT_TOKEN - Uses Telegram Bot API (https://api.telegram.org) Total tools: 24 (email 6, obsidian 5, whatsapp 3, linkedin 4, telegram 5) --- .env.example | 6 ++ src/clients/telegram.ts | 129 ++++++++++++++++++++++++++++++++++++ src/index.ts | 56 ++++++++++++++++ src/manifest.ts | 140 ++++++++++++++++++++++++++++++++++++++++ src/tools.ts | 108 +++++++++++++++++++++++++++++++ 5 files changed, 439 insertions(+) create mode 100644 src/clients/telegram.ts diff --git a/.env.example b/.env.example index 32c8963..c4cb5a7 100644 --- a/.env.example +++ b/.env.example @@ -54,3 +54,9 @@ LINKEDIN_DEFAULT_ACCESS_TOKEN=your-linkedin-access-token LINKEDIN_DEFAULT_CLIENT_ID=your-linkedin-client-id LINKEDIN_DEFAULT_CLIENT_SECRET=your-linkedin-client-secret # For additional accounts, duplicate with LINKEDIN_{ACCOUNT}_* + +# ── Telegram Bot API ───────────────────────────────────────────────────────── +# Create a bot via @BotFather on Telegram and copy the token +# For default account: +TELEGRAM_DEFAULT_BOT_TOKEN=your-telegram-bot-token +# For additional accounts, duplicate with TELEGRAM_{ACCOUNT}_* diff --git a/src/clients/telegram.ts b/src/clients/telegram.ts new file mode 100644 index 0000000..62a138c --- /dev/null +++ b/src/clients/telegram.ts @@ -0,0 +1,129 @@ +const TELEGRAM_API_BASE = 'https://api.telegram.org'; + +function getToken(account: string): string { + const envKey = `TELEGRAM_${account.toUpperCase()}_BOT_TOKEN`; + return process.env[envKey] ?? ''; +} + +async function telegramRequest( + token: string, + method: string, + params?: Record +) { + const url = `${TELEGRAM_API_BASE}/bot${token}/${method}`; + const res = await fetch(url, { + method: params ? 'POST' : 'GET', + headers: params ? { 'Content-Type': 'application/json' } : undefined, + body: params ? JSON.stringify(params) : undefined, + signal: AbortSignal.timeout(15000), + }); + + const data = await res.json(); + if (!data.ok) { + throw new Error(`Telegram API error: ${data.description ?? 'Unknown error'}`); + } + return data.result; +} + +export async function getMe(args: { account?: string }): Promise<{ + id: number; + first_name: string; + username: string; + is_bot: boolean; +}> { + const token = getToken(args.account ?? 'default'); + if (!token) { + throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN'); + } + return telegramRequest(token, 'getMe'); +} + +export async function sendMessage(args: { + chat_id: string | number; + text: string; + parse_mode?: 'HTML' | 'Markdown' | 'MarkdownV2'; + account?: string; +}): Promise<{ message_id: number; chat_id: string | number }> { + const token = getToken(args.account ?? 'default'); + if (!token) { + throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN'); + } + + const result = await telegramRequest(token, 'sendMessage', { + chat_id: args.chat_id, + text: args.text, + parse_mode: args.parse_mode, + }); + + return { + message_id: result.message_id, + chat_id: result.chat.id, + }; +} + +export async function sendPhoto(args: { + chat_id: string | number; + photo: string; + caption?: string; + account?: string; +}): Promise<{ message_id: number; chat_id: string | number }> { + const token = getToken(args.account ?? 'default'); + if (!token) { + throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN'); + } + + const result = await telegramRequest(token, 'sendPhoto', { + chat_id: args.chat_id, + photo: args.photo, + caption: args.caption, + }); + + return { + message_id: result.message_id, + chat_id: result.chat.id, + }; +} + +export async function getUpdates(args: { + limit?: number; + account?: string; +}): Promise> { + const token = getToken(args.account ?? 'default'); + if (!token) { + throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN'); + } + + return telegramRequest(token, 'getUpdates', { + limit: args.limit ?? 10, + }); +} + +export async function getChat(args: { + chat_id: string | number; + account?: string; +}): Promise<{ + id: number; + type: string; + title?: string; + username?: string; + description?: string; + member_count?: number; +}> { + const token = getToken(args.account ?? 'default'); + if (!token) { + throw new Error('Missing Telegram credentials. Set TELEGRAM_{ACCOUNT}_BOT_TOKEN'); + } + + return telegramRequest(token, 'getChat', { + chat_id: args.chat_id, + }); +} diff --git a/src/index.ts b/src/index.ts index e0811d8..809dcb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -623,6 +623,62 @@ app.post('/api/linkedin/message', requireAuth, async (req, res) => { } }); +// ── Telegram REST endpoints ───────────────────────────────────── +app.get('/api/telegram/me', requireAuth, async (req, res) => { + const account = req.query.account as string | undefined; + try { + const result = await handleToolCall('telegram_get_me', { account }); + res.json(parseToolResult(result)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +app.post('/api/telegram/message', requireAuth, async (req, res) => { + const { chat_id, text, parse_mode, account } = req.body as Record; + if (!chat_id || !text) { res.status(400).json({ error: 'chat_id and text are required' }); return; } + try { + const result = await handleToolCall('telegram_send_message', { chat_id, text, parse_mode, account }); + res.json(parseToolResult(result)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +app.post('/api/telegram/photo', requireAuth, async (req, res) => { + const { chat_id, photo, caption, account } = req.body as Record; + if (!chat_id || !photo) { res.status(400).json({ error: 'chat_id and photo are required' }); return; } + try { + const result = await handleToolCall('telegram_send_photo', { chat_id, photo, caption, account }); + res.json(parseToolResult(result)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +app.get('/api/telegram/updates', requireAuth, async (req, res) => { + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; + const account = req.query.account as string | undefined; + try { + const result = await handleToolCall('telegram_get_updates', { limit, account }); + res.json(parseToolResult(result)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + +app.get('/api/telegram/chat', requireAuth, async (req, res) => { + const chat_id = req.query.chat_id as string | undefined; + const account = req.query.account as string | undefined; + if (!chat_id) { res.status(400).json({ error: 'chat_id is required' }); return; } + try { + const result = await handleToolCall('telegram_get_chat', { chat_id, account }); + res.json(parseToolResult(result)); + } catch (err) { + res.status(500).json({ error: (err as Error).message }); + } +}); + app.post('/api/pilot-request', async (req, res) => { const origin = req.get('origin'); if (origin && !SQUAREMCP_ALLOWED_ORIGINS.has(origin)) { diff --git a/src/manifest.ts b/src/manifest.ts index 0f8fea2..baac04a 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -567,6 +567,142 @@ export function getManifest(serverUrl: string, authEnabled: boolean) { examples: [{ recipient_id: 'urn:li:person:abc123', message: 'Hi, thanks for connecting!', account: 'default' }], }, + // ── Telegram tools ────────────────────────────────────────────────────── + { + name: 'telegram_get_me', + category: 'telegram', + description: 'Get information about the connected Telegram bot', + when_to_use: + 'User asks about the Telegram bot, wants to verify it is connected, or needs the bot username.', + input_schema: { + type: 'object', + properties: { + account: { type: 'string', description: 'Which Telegram account to use (default: "default")' }, + }, + }, + returns: { + type: 'object', + properties: { + id: { type: 'number', description: 'Bot user ID' }, + first_name: { type: 'string' }, + username: { type: 'string' }, + is_bot: { type: 'boolean' }, + }, + }, + examples: [{ account: 'default' }], + }, + { + name: 'telegram_send_message', + category: 'telegram', + description: 'Send a text message via Telegram bot', + when_to_use: + 'User wants to send a Telegram message, DM someone, or notify a group or channel.', + input_schema: { + type: 'object', + required: ['chat_id', 'text'], + properties: { + chat_id: { type: 'string', description: 'Chat ID, username (@username), or channel ID' }, + text: { type: 'string', description: 'Message text' }, + parse_mode: { type: 'string', enum: ['HTML', 'Markdown', 'MarkdownV2'], description: 'Message formatting mode' }, + account: { type: 'string', description: 'Which Telegram account to use (default: "default")' }, + }, + }, + returns: { + type: 'object', + properties: { + message_id: { type: 'number' }, + chat_id: { type: 'string', description: 'Chat ID where the message was sent' }, + }, + }, + examples: [{ chat_id: '@mychannel', text: 'Hello from Hermes!', account: 'default' }], + }, + { + name: 'telegram_send_photo', + category: 'telegram', + description: 'Send a photo via Telegram bot', + when_to_use: + 'User wants to share an image through Telegram.', + input_schema: { + type: 'object', + required: ['chat_id', 'photo'], + properties: { + chat_id: { type: 'string', description: 'Chat ID, username (@username), or channel ID' }, + photo: { type: 'string', description: 'Photo URL or Telegram file_id' }, + caption: { type: 'string', description: 'Optional caption text' }, + account: { type: 'string', description: 'Which Telegram account to use (default: "default")' }, + }, + }, + returns: { + type: 'object', + properties: { + message_id: { type: 'number' }, + chat_id: { type: 'string' }, + }, + }, + examples: [{ chat_id: '@mychannel', photo: 'https://example.com/image.jpg', caption: 'Check this out', account: 'default' }], + }, + { + name: 'telegram_get_updates', + category: 'telegram', + description: 'Get recent incoming messages for the Telegram bot', + when_to_use: + 'User asks to check Telegram messages, read DMs, or see recent bot activity.', + input_schema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max updates to return (default: 10)' }, + account: { type: 'string', description: 'Which Telegram account to use (default: "default")' }, + }, + }, + returns: { + type: 'array', + items: { + type: 'object', + properties: { + update_id: { type: 'number' }, + message: { + type: 'object', + properties: { + message_id: { type: 'number' }, + from: { type: 'object', properties: { id: { type: 'number' }, first_name: { type: 'string' }, username: { type: 'string' } } }, + chat: { type: 'object', properties: { id: { type: 'number' }, type: { type: 'string' }, title: { type: 'string' } } }, + date: { type: 'number' }, + text: { type: 'string' }, + }, + }, + }, + }, + }, + examples: [{ limit: 5, account: 'default' }], + }, + { + name: 'telegram_get_chat', + category: 'telegram', + description: 'Get information about a Telegram chat or channel', + when_to_use: + 'User wants to verify a Telegram chat exists or get its details before sending a message.', + input_schema: { + type: 'object', + required: ['chat_id'], + properties: { + chat_id: { type: 'string', description: 'Chat ID, username (@username), or channel ID' }, + account: { type: 'string', description: 'Which Telegram account to use (default: "default")' }, + }, + }, + returns: { + type: 'object', + properties: { + id: { type: 'number' }, + type: { type: 'string' }, + title: { type: 'string' }, + username: { type: 'string' }, + description: { type: 'string' }, + member_count: { type: 'number' }, + }, + }, + examples: [{ chat_id: '@mychannel', account: 'default' }], + }, + // ── Obsidian tools ────────────────────────────────────────────────────── { name: 'obsidian_search_notes', @@ -734,6 +870,10 @@ export function getManifest(serverUrl: string, authEnabled: boolean) { description: 'LinkedIn profile and posting via LinkedIn API', icon: '🔗', }, + telegram: { + description: 'Telegram messaging via Bot API', + icon: '✈️', + }, }, }; } diff --git a/src/tools.ts b/src/tools.ts index 4dd5cd9..7a4fa78 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -4,6 +4,7 @@ import { sendEmail, createDraft } from './smtp.js'; import { searchNotes, getNote, appendToNote, updateNote, getSyncStatus } from './clients/obsidian.js'; import { sendMessage, sendTemplate, getMessageStatus, listTemplates } from './clients/whatsapp.js'; import { getProfile as getLinkedInProfile, createPost as createLinkedInPost, searchConnections, sendMessage as sendLinkedInMessage } from './clients/linkedin.js'; +import { getMe as getTelegramMe, sendMessage as sendTelegramMessage, sendPhoto as sendTelegramPhoto, getUpdates as getTelegramUpdates, getChat as getTelegramChat } from './clients/telegram.js'; const ACCOUNT_PARAM = { account: { @@ -284,6 +285,74 @@ export const tools: Tool[] = [ required: ['recipient_id', 'message'], }, }, + + // ── Telegram tools ───────────────────────────────────────────── + { + name: 'telegram_get_me', + description: + 'Get information about the Telegram bot. Use to verify the bot is connected and get its username.', + inputSchema: { + type: 'object', + properties: { + account: { type: 'string', description: 'Which Telegram account to use (default: "default")' }, + }, + }, + }, + { + name: 'telegram_send_message', + description: + 'Send a text message via Telegram bot. Use when the user asks to send a Telegram message, DM someone, or notify a group/channel.', + inputSchema: { + type: 'object', + required: ['chat_id', 'text'], + properties: { + chat_id: { type: 'string', description: 'Chat ID, username (@username), or channel ID' }, + text: { type: 'string', description: 'Message text to send' }, + parse_mode: { type: 'string', enum: ['HTML', 'Markdown', 'MarkdownV2'], description: 'Formatting mode for the message' }, + account: { type: 'string', description: 'Which Telegram account to use (default: "default")' }, + }, + }, + }, + { + name: 'telegram_send_photo', + description: + 'Send a photo via Telegram bot. Use when the user wants to share an image through Telegram.', + inputSchema: { + type: 'object', + required: ['chat_id', 'photo'], + properties: { + chat_id: { type: 'string', description: 'Chat ID, username (@username), or channel ID' }, + photo: { type: 'string', description: 'Photo URL or file_id to send' }, + caption: { type: 'string', description: 'Optional caption text' }, + account: { type: 'string', description: 'Which Telegram account to use (default: "default")' }, + }, + }, + }, + { + name: 'telegram_get_updates', + description: + 'Get recent incoming messages and updates for the Telegram bot. Use when the user asks to check messages or read Telegram DMs.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max number of updates to return (default: 10)' }, + account: { type: 'string', description: 'Which Telegram account to use (default: "default")' }, + }, + }, + }, + { + name: 'telegram_get_chat', + description: + 'Get information about a Telegram chat or channel. Use to verify a chat exists and get its details.', + inputSchema: { + type: 'object', + required: ['chat_id'], + properties: { + chat_id: { type: 'string', description: 'Chat ID, username (@username), or channel ID' }, + account: { type: 'string', description: 'Which Telegram account to use (default: "default")' }, + }, + }, + }, ]; function acct(args: Record): Account { @@ -417,6 +486,45 @@ export async function handleToolCall( }); break; + // ── Telegram ─────────────────────────────────────────────── + case 'telegram_get_me': + result = await getTelegramMe({ + account: args.account as string | undefined, + }); + break; + + case 'telegram_send_message': + result = await sendTelegramMessage({ + chat_id: args.chat_id as string | number, + text: args.text as string, + parse_mode: (args.parse_mode as 'HTML' | 'Markdown' | 'MarkdownV2') ?? undefined, + account: args.account as string | undefined, + }); + break; + + case 'telegram_send_photo': + result = await sendTelegramPhoto({ + chat_id: args.chat_id as string | number, + photo: args.photo as string, + caption: args.caption as string | undefined, + account: args.account as string | undefined, + }); + break; + + case 'telegram_get_updates': + result = await getTelegramUpdates({ + limit: (args.limit as number) ?? 10, + account: args.account as string | undefined, + }); + break; + + case 'telegram_get_chat': + result = await getTelegramChat({ + chat_id: args.chat_id as string | number, + account: args.account as string | undefined, + }); + break; + // Legacy Yahoo-prefixed names — keep working for any cached Claude sessions case 'yahoo_get_profile': result = await getProfile('yahoo');