diff --git a/.changeset/seven-drinks-hammer.md b/.changeset/seven-drinks-hammer.md new file mode 100644 index 0000000..0d8343f --- /dev/null +++ b/.changeset/seven-drinks-hammer.md @@ -0,0 +1,5 @@ +--- +"@iqai/mcp-telegram": patch +--- + +- Adds more sampling listeners, more env config options diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..527c748 --- /dev/null +++ b/.env.sample @@ -0,0 +1,156 @@ +# ============================================================================== +# REQUIRED CONFIGURATION +# ============================================================================== + +# Your Telegram bot token from @BotFather +# Get this by messaging @BotFather on Telegram and creating a new bot +TELEGRAM_BOT_TOKEN=your_bot_token_here + +# ============================================================================== +# SAMPLING CONFIGURATION +# ============================================================================== + +# Master switch to enable/disable AI sampling entirely +# Set to false to use only core MCP tools (send/receive messages, etc.) +# Default: true +SAMPLING_ENABLED=true + +# ------------------------------------------------------------------------------ +# Response Triggers +# ------------------------------------------------------------------------------ + +# Only respond when bot is mentioned in groups (e.g., @yourbotname hello) +# Always responds in direct messages regardless of this setting +# Default: true +SAMPLING_MENTION_ONLY=true + +# Whether to respond to direct/private messages +# Default: true +SAMPLING_RESPOND_TO_DMS=true + +# ------------------------------------------------------------------------------ +# Access Control +# ------------------------------------------------------------------------------ + +# Comma-separated list of allowed chat IDs/usernames +# Empty means all chats are allowed +# Supports both numeric IDs (-1001234567890) and usernames (@mychannel) +# Example: SAMPLING_ALLOWED_CHATS=-1001234567890,@mychannel,@anotherchat +SAMPLING_ALLOWED_CHATS= + +# Comma-separated list of blocked chat IDs/usernames +# Example: SAMPLING_BLOCKED_CHATS=-1001111111111,@spamchannel +SAMPLING_BLOCKED_CHATS= + +# Comma-separated list of allowed user IDs (numeric only) +# Empty means all users are allowed +# Example: SAMPLING_ALLOWED_USERS=123456789,987654321 +SAMPLING_ALLOWED_USERS= + +# Comma-separated list of blocked user IDs (numeric only) +# Example: SAMPLING_BLOCKED_USERS=111111111,222222222 +SAMPLING_BLOCKED_USERS= + +# Comma-separated list of admin user IDs (numeric only) +# These users can use admin commands like /config +# Example: SAMPLING_ADMIN_USERS=123456789 +SAMPLING_ADMIN_USERS= + +# ------------------------------------------------------------------------------ +# Message Type Handlers +# ------------------------------------------------------------------------------ + +# Enable/disable processing of different message types +# Default: true for text, false for others (to avoid unwanted processing) +SAMPLING_ENABLE_TEXT=true +SAMPLING_ENABLE_PHOTO=false +SAMPLING_ENABLE_DOCUMENT=false +SAMPLING_ENABLE_VOICE=false +SAMPLING_ENABLE_VIDEO=false +SAMPLING_ENABLE_STICKER=false +SAMPLING_ENABLE_LOCATION=false +SAMPLING_ENABLE_CONTACT=false +SAMPLING_ENABLE_POLL=false + +# ------------------------------------------------------------------------------ +# Response Behavior +# ------------------------------------------------------------------------------ + +# Maximum tokens for AI responses +# Default: 1000 +SAMPLING_MAX_TOKENS=1000 + +# Show typing indicator while processing +# Default: true +SAMPLING_SHOW_TYPING=true + +# Silent mode - log messages but don't respond (useful for debugging) +# Default: false +SAMPLING_SILENT_MODE=false + +# ------------------------------------------------------------------------------ +# Rate Limiting +# ------------------------------------------------------------------------------ + +# Maximum requests per user per minute +# Default: 10 +SAMPLING_RATE_LIMIT_USER=10 + +# Maximum requests per chat per minute +# Default: 20 +SAMPLING_RATE_LIMIT_CHAT=20 + +# ------------------------------------------------------------------------------ +# Message Filtering +# ------------------------------------------------------------------------------ + +# Minimum message length to process +# Default: 1 +SAMPLING_MIN_MESSAGE_LENGTH=1 + +# Maximum message length to process +# Default: 1000 +SAMPLING_MAX_MESSAGE_LENGTH=1000 + +# Comma-separated list of keywords that must be present in messages +# Empty means no keyword filtering +# Example: SAMPLING_KEYWORD_TRIGGERS=help,support,question +SAMPLING_KEYWORD_TRIGGERS= + +# Ignore messages starting with / (bot commands) +# Default: true +SAMPLING_IGNORE_COMMANDS=true + +# ============================================================================== +# EXAMPLE CONFIGURATIONS +# ============================================================================== + +# Example 1: Tools-only mode (no AI sampling) +# SAMPLING_ENABLED=false + +# Example 2: High-security environment +# SAMPLING_ALLOWED_CHATS=-1001234567890,@trustedchannel +# SAMPLING_ADMIN_USERS=123456789 +# SAMPLING_BLOCKED_USERS=999999999 + +# Example 3: Support bot configuration +# SAMPLING_KEYWORD_TRIGGERS=help,support,issue,problem +# SAMPLING_RATE_LIMIT_USER=5 +# SAMPLING_ENABLE_PHOTO=true +# SAMPLING_ENABLE_DOCUMENT=true + +# Example 4: Development/testing +# SAMPLING_SILENT_MODE=true +# SAMPLING_RATE_LIMIT_USER=100 +# SAMPLING_RATE_LIMIT_CHAT=200 + +# Example 5: Media-focused bot +# SAMPLING_ENABLE_PHOTO=true +# SAMPLING_ENABLE_VIDEO=true +# SAMPLING_ENABLE_STICKER=true +# SAMPLING_ENABLE_VOICE=true + +# Example 6: Text-only with strict filtering +# SAMPLING_MIN_MESSAGE_LENGTH=10 +# SAMPLING_MAX_MESSAGE_LENGTH=500 +# SAMPLING_KEYWORD_TRIGGERS=question,help \ No newline at end of file diff --git a/.gitignore b/.gitignore index 498bb0d..bbda923 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ node_modules/ # OS-specific .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db +.env \ No newline at end of file diff --git a/README.md b/README.md index 4ec2095..cf89885 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,22 @@ An MCP (Model Context Protocol) server for interacting with Telegram bots and ch ## Features +### Core Tools + - **SEND_MESSAGE**: Send messages to channels or chats - **GET_CHANNEL_INFO**: Get information about channels/chats - **FORWARD_MESSAGE**: Forward messages between chats - **PIN_MESSAGE**: Pin messages in channels - **GET_CHANNEL_MEMBERS**: Get list of channel administrators -- **AI SAMPLING**: Automatic AI-powered responses to Telegram messages using FastMCP sampling + +### AI Sampling (Enhanced) + +- **šŸ¤– Intelligent Responses**: AI-powered responses using FastMCP sampling +- **šŸŽÆ Mention-Only Mode**: Smart filtering - responds when mentioned in groups +- **šŸ“± Multi-Message Types**: Supports text, photos, documents, voice, video, stickers, locations, contacts, polls +- **šŸ›”ļø Access Control**: Configurable user/chat allow/block lists +- **⚔ Rate Limiting**: Per-user and per-chat rate limiting +- **šŸŽ›ļø Advanced Configuration**: Highly customizable behavior and templates ## Setup @@ -85,44 +95,261 @@ Get channel administrators (limited by Telegram API). ## AI Sampling Feature -The server includes an AI sampling feature that automatically responds to Telegram messages using FastMCP's sampling capability. When a client connects to the MCP server, a Telegram bot is started that listens for incoming messages and generates AI-powered responses. +The server includes a comprehensive AI sampling feature that automatically responds to Telegram messages using FastMCP's sampling capability. The system is highly configurable and supports multiple message types with advanced filtering and access control. + +### Key Features + +- **šŸŽÆ Mention-Only Mode**: By default, only responds when mentioned in groups (always responds in DMs) +- **šŸ“± Multi-Message Support**: Handles text, photos, documents, voice, video, stickers, locations, contacts, and polls +- **šŸ›”ļø Access Control**: Allow/block specific users and chats +- **⚔ Rate Limiting**: Configurable per-user and per-chat rate limits +- **šŸŽ›ļø Flexible Configuration**: Extensive customization options +- **šŸ‘‘ Admin Commands**: Special commands for authorized users ### How It Works 1. **Client Connection**: When an MCP client connects to the server, the Telegram bot starts automatically -2. **Message Processing**: The bot listens for text messages in any chat where it's present -3. **AI Response**: Each message is sent to the AI using FastMCP's sampling feature -4. **Response Delivery**: The AI-generated response is sent back to the same chat +2. **Message Processing**: The bot listens for various message types based on configuration +3. **Smart Filtering**: Messages are validated against access control, rate limits, and content filters +4. **AI Response**: Qualifying messages are sent to the AI using FastMCP's sampling feature +5. **Response Delivery**: The AI-generated response is sent back to the same chat + +### Configuration + +The sampling feature is configured via environment variables with Zod validation and sensible defaults: + +#### Sampling Control + +```bash +SAMPLING_ENABLED=true # Enable/disable AI sampling entirely +``` + +#### Response Triggers + +```bash +SAMPLING_MENTION_ONLY=true # Only respond when mentioned in groups +SAMPLING_RESPOND_TO_DMS=true # Always respond in DMs +``` + +#### Access Control + +```bash +# Comma-separated lists of chat IDs (numeric or @usernames) and user IDs (numeric only) +SAMPLING_ALLOWED_CHATS="" # Empty = all allowed, or "-1001234567890,@vaultAgentLogs,@mychannel" +SAMPLING_BLOCKED_CHATS="" # Chat IDs/usernames to ignore +SAMPLING_ALLOWED_USERS="" # Empty = all allowed, or "123456,789012" +SAMPLING_BLOCKED_USERS="" # User IDs to ignore (numeric only) +SAMPLING_ADMIN_USERS="" # Users who can use admin commands (numeric only) +``` + +#### Message Type Support + +```bash +SAMPLING_ENABLE_TEXT=true # Text messages +SAMPLING_ENABLE_PHOTO=true # Photo messages with captions +SAMPLING_ENABLE_DOCUMENT=true # Document uploads +SAMPLING_ENABLE_VOICE=true # Voice messages +SAMPLING_ENABLE_VIDEO=true # Video messages +SAMPLING_ENABLE_STICKER=true # Sticker messages +SAMPLING_ENABLE_LOCATION=true # Location sharing +SAMPLING_ENABLE_CONTACT=true # Contact sharing +SAMPLING_ENABLE_POLL=true # Poll messages +``` + +#### Response Behavior + +```bash +SAMPLING_MAX_TOKENS=1000 # Max tokens per AI response +SAMPLING_SHOW_TYPING=true # Show typing indicator +SAMPLING_SILENT_MODE=false # Log but don't respond +``` + +#### Rate Limiting + +```bash +SAMPLING_RATE_LIMIT_USER=10 # Max requests per user per minute +SAMPLING_RATE_LIMIT_CHAT=20 # Max requests per chat per minute +``` + +#### Content Filtering + +```bash +SAMPLING_MIN_MESSAGE_LENGTH=1 # Minimum message length +SAMPLING_MAX_MESSAGE_LENGTH=1000 # Maximum message length +SAMPLING_KEYWORD_TRIGGERS="" # Only respond to messages with these keywords (comma-separated) +SAMPLING_IGNORE_COMMANDS=true # Ignore messages starting with / +``` ### Bot Commands - `/start`: Initialize the bot and get a welcome message -- `/help`: Get help information about the bot's capabilities +- `/help`: Get help information about available features +- `/config`: View current configuration (admin users only) -### Usage +### Environment Variables + +```bash +# Required: Your Telegram bot token +export TELEGRAM_BOT_TOKEN="your_bot_token_here" + +# Optional: Sampling configuration (these show the defaults) +export SAMPLING_ENABLED=true +export SAMPLING_MENTION_ONLY=true +export SAMPLING_RESPOND_TO_DMS=true +export SAMPLING_MAX_TOKENS=1000 +export SAMPLING_RATE_LIMIT_USER=10 +export SAMPLING_RATE_LIMIT_CHAT=20 + +# Example: Restrict to specific chats (mix of IDs and usernames) and users +export SAMPLING_ALLOWED_CHATS="-1001234567890,@mychannel" +export SAMPLING_ADMIN_USERS="123456789,987654321" + +# Example: Keyword-only mode +export SAMPLING_KEYWORD_TRIGGERS="help,support,question" + +# Example: Disable certain message types +export SAMPLING_ENABLE_VOICE=false +export SAMPLING_ENABLE_STICKER=false + +# Example: Disable sampling entirely (tools-only mode) +export SAMPLING_ENABLED=false +``` + +### Usage Examples + +#### Basic Setup 1. Add your bot to a Telegram chat or channel 2. Start the MCP server with a connected client -3. Send any message to the bot in Telegram -4. The bot will respond with an AI-generated message +3. **In groups**: Mention the bot (`@yourbotname hello`) +4. **In DMs**: Send any message directly +5. The bot will respond with an AI-generated message -### Example Interaction +#### Mention-Only Mode (Default) ``` -User: Hello, how are you? -Bot: Hello! I'm doing well, thank you for asking. I'm here to help you with any questions or tasks you might have. How can I assist you today? +User: @mybot Hello, how are you? +Bot: Hello! I'm doing well, thank you for asking. How can I assist you today? -User: What's the weather like? -Bot: I don't have access to real-time weather information, but I'd be happy to help you find weather resources or discuss weather-related topics. You might want to check a weather app or website for current conditions in your area. +User: @mybot What can you do? +Bot: I can help with various tasks, answer questions, and engage in conversations. I can also process different types of messages including photos, documents, and more! ``` -### Configuration +#### Direct Messages + +``` +User: Hello! +Bot: Hi there! I'm your AI assistant. What would you like to talk about? + +User: [Sends a photo with caption "What's in this image?"] +Bot: I can see you've shared a photo! While I can't analyze images directly, I can help you with questions about the photo or discuss related topics. +``` + +#### Advanced Configuration Examples + +##### Restrict to Specific Chats + +```bash +# Only respond in specific chats (supports both numeric IDs and @usernames) +export SAMPLING_ALLOWED_CHATS="-1001234567890,@vaultAgentLogs,@publicchannel" +``` + +##### Block Specific Users + +```bash +# Ignore messages from specific users +export SAMPLING_BLOCKED_USERS="123456789,987654321" +``` + +##### Keyword-Only Mode + +```bash +# Only respond to messages containing specific keywords +export SAMPLING_KEYWORD_TRIGGERS="help,question,support" +``` + +##### Admin Users -The AI sampling feature uses the same `TELEGRAM_BOT_TOKEN` environment variable as the other tools. The AI responses are generated with: +```bash +# Users who can use /config command +export SAMPLING_ADMIN_USERS="123456789" +``` -- **System Prompt**: "You are a helpful AI assistant responding to messages in a Telegram chat. Be concise and helpful." -- **Max Tokens**: 1000 -- **Context**: Includes the current server context for better responses +##### Silent Mode (Logging Only) + +```bash +# Log messages but don't respond +export SAMPLING_SILENT_MODE=true +``` + +##### Custom Rate Limiting + +```bash +# Higher rate limits for busy chats +export SAMPLING_RATE_LIMIT_USER=25 +export SAMPLING_RATE_LIMIT_CHAT=50 +``` + +##### Tools-Only Mode + +```bash +# Disable AI sampling entirely, use only core MCP tools +export SAMPLING_ENABLED=false +``` + +### Message Type Templates + +The system uses customizable templates for different message types: + +- **Text**: Standard text messages +- **Photo**: Image messages with caption analysis +- **Document**: File uploads with metadata +- **Voice**: Voice message duration tracking +- **Video**: Video messages with caption support +- **Sticker**: Sticker emoji and set information +- **Location**: GPS coordinates +- **Contact**: Contact information +- **Poll**: Poll questions and options + +### Customization + +To customize the sampling behavior: + +1. **Set Environment Variables**: Configure via `.env` file or export statements +2. **Restart Server**: Restart the MCP server to apply changes +3. **Test Settings**: Use `/config` command (admin only) to verify settings + +#### Example .env File + +```bash +# Required +TELEGRAM_BOT_TOKEN=your_bot_token_here + +# Basic sampling settings +SAMPLING_ENABLED=true +SAMPLING_MENTION_ONLY=true +SAMPLING_RESPOND_TO_DMS=true +SAMPLING_MAX_TOKENS=1500 + +# Access control +SAMPLING_ADMIN_USERS=123456789 +SAMPLING_ALLOWED_CHATS=-1001234567890,@vaultAgentLogs,@mychannel + +# Rate limiting +SAMPLING_RATE_LIMIT_USER=15 +SAMPLING_RATE_LIMIT_CHAT=30 + +# Message filtering +SAMPLING_KEYWORD_TRIGGERS=help,support +SAMPLING_MIN_MESSAGE_LENGTH=3 +``` + +### Performance Features + +- **Rate Limiting**: Prevents spam and overuse +- **Selective Processing**: Only processes enabled message types +- **Efficient Filtering**: Fast validation before AI processing +- **Graceful Degradation**: Continues working even if some features fail ## Usage Examples @@ -187,7 +414,31 @@ The tools will return an error message in the `result` field if an error occurs. ## Environment Variables -- `TELEGRAM_BOT_TOKEN`: Your Telegram bot token (required) +### Required + +- `TELEGRAM_BOT_TOKEN`: Your Telegram bot token from [@BotFather](https://t.me/botfather) + +### Optional + +All sampling configuration is done via environment variables with sensible defaults. See the Configuration section above for all available options. + +### Setup Example + +```bash +# Set your bot token +export TELEGRAM_BOT_TOKEN="1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" + +# Start the server +npm start +``` + +### Getting Your Bot Token + +1. Message [@BotFather](https://t.me/botfather) on Telegram +2. Use `/newbot` command +3. Follow the prompts to create your bot +4. Copy the bot token provided +5. Set it as an environment variable ## Bot Setup diff --git a/biome.json b/biome.json index 37afd11..835061f 100644 --- a/biome.json +++ b/biome.json @@ -13,6 +13,6 @@ "enabled": true }, "organizeImports": { - "enabled": true + "enabled": false } } diff --git a/package.json b/package.json index bcb319f..673514f 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,13 @@ }, "dependencies": { "dedent": "^1.6.0", + "dotenv": "^17.2.1", "fastmcp": "^3.9.0", "telegraf": "^4.16.3", "zod": "^3.25.7" }, "devDependencies": { - "@biomejs/biome": "*", + "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.29.4", "@types/node": "^22.15.19", "husky": "^9.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 641a13b..00055b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: dedent: specifier: ^1.6.0 version: 1.6.0 + dotenv: + specifier: ^17.2.1 + version: 17.2.1 fastmcp: specifier: ^3.9.0 version: 3.9.0 @@ -22,7 +25,7 @@ importers: version: 3.25.7 devDependencies: '@biomejs/biome': - specifier: '*' + specifier: ^1.9.4 version: 1.9.4 '@changesets/cli': specifier: ^2.29.4 @@ -368,6 +371,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + dotenv@17.2.1: + resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1648,6 +1655,8 @@ snapshots: dependencies: path-type: 4.0.0 + dotenv@17.2.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..fd67a02 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,243 @@ +import { config } from "dotenv"; +import { z } from "zod"; +import { MessageType, TemplateType } from "./sampling/types.js"; + +config(); + +// Helper functions for parsing environment variables +function parseCommaSeparatedChatIds(val: string): (number | string)[] { + if (!val) return []; + + return val + .split(",") + .map((id) => id.trim()) + .filter((id) => id.length > 0) + .map((id) => { + // Handle numeric IDs + const numId = Number.parseInt(id); + if (!Number.isNaN(numId)) return numId; + // Handle username strings (normalize to include @) + return id.startsWith("@") ? id : `@${id}`; + }); +} + +function parseCommaSeparatedUserIds(val: string): number[] { + if (!val) return []; + + return val + .split(",") + .map((id) => Number.parseInt(id.trim())) + .filter((id) => !Number.isNaN(id)); +} + +function parseCommaSeparatedStrings(val: string): string[] { + if (!val) return []; + + return val + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +const envSchema = z.object({ + // Required + TELEGRAM_BOT_TOKEN: z + .string() + .min(1, "TELEGRAM_BOT_TOKEN environment variable must be set"), + + // Sampling control + SAMPLING_ENABLED: z.coerce.boolean().default(true), + + // Response trigger settings + SAMPLING_MENTION_ONLY: z.coerce.boolean().default(true), + SAMPLING_RESPOND_TO_DMS: z.coerce.boolean().default(true), + + // Access control (comma-separated lists) + SAMPLING_ALLOWED_CHATS: z + .string() + .default("") + .transform(parseCommaSeparatedChatIds), + SAMPLING_BLOCKED_CHATS: z + .string() + .default("") + .transform(parseCommaSeparatedChatIds), + SAMPLING_ALLOWED_USERS: z + .string() + .default("") + .transform(parseCommaSeparatedUserIds), + SAMPLING_BLOCKED_USERS: z + .string() + .default("") + .transform(parseCommaSeparatedUserIds), + SAMPLING_ADMIN_USERS: z + .string() + .default("") + .transform(parseCommaSeparatedUserIds), + + // Message type handlers + SAMPLING_ENABLE_TEXT: z.coerce.boolean().default(true), + SAMPLING_ENABLE_PHOTO: z.coerce.boolean().default(false), + SAMPLING_ENABLE_DOCUMENT: z.coerce.boolean().default(false), + SAMPLING_ENABLE_VOICE: z.coerce.boolean().default(false), + SAMPLING_ENABLE_VIDEO: z.coerce.boolean().default(false), + SAMPLING_ENABLE_STICKER: z.coerce.boolean().default(false), + SAMPLING_ENABLE_LOCATION: z.coerce.boolean().default(false), + SAMPLING_ENABLE_CONTACT: z.coerce.boolean().default(false), + SAMPLING_ENABLE_POLL: z.coerce.boolean().default(false), + + // Response behavior + SAMPLING_MAX_TOKENS: z.coerce.number().default(1000), + SAMPLING_SHOW_TYPING: z.coerce.boolean().default(true), + SAMPLING_SILENT_MODE: z.coerce.boolean().default(false), + + // Rate limiting + SAMPLING_RATE_LIMIT_USER: z.coerce.number().default(10), + SAMPLING_RATE_LIMIT_CHAT: z.coerce.number().default(20), + + // Message filters + SAMPLING_MIN_MESSAGE_LENGTH: z.coerce.number().default(1), + SAMPLING_MAX_MESSAGE_LENGTH: z.coerce.number().default(1000), + SAMPLING_KEYWORD_TRIGGERS: z + .string() + .default("") + .transform(parseCommaSeparatedStrings), + SAMPLING_IGNORE_COMMANDS: z.coerce.boolean().default(true), +}); + +export const env = envSchema.parse(process.env); + +// Create sampling config object from env variables +export const samplingConfig = { + // Sampling control + enabled: env.SAMPLING_ENABLED, + + // Response trigger settings + mentionOnly: env.SAMPLING_MENTION_ONLY, + respondToPrivateMessages: env.SAMPLING_RESPOND_TO_DMS, + + // Access control + allowedChats: env.SAMPLING_ALLOWED_CHATS, + blockedChats: env.SAMPLING_BLOCKED_CHATS, + allowedUsers: env.SAMPLING_ALLOWED_USERS, + blockedUsers: env.SAMPLING_BLOCKED_USERS, + adminUsers: env.SAMPLING_ADMIN_USERS, + + // Message type handlers + enabledListeners: { + [MessageType.TEXT]: env.SAMPLING_ENABLE_TEXT, + [MessageType.PHOTO]: env.SAMPLING_ENABLE_PHOTO, + [MessageType.DOCUMENT]: env.SAMPLING_ENABLE_DOCUMENT, + [MessageType.VOICE]: env.SAMPLING_ENABLE_VOICE, + [MessageType.VIDEO]: env.SAMPLING_ENABLE_VIDEO, + [MessageType.STICKER]: env.SAMPLING_ENABLE_STICKER, + [MessageType.LOCATION]: env.SAMPLING_ENABLE_LOCATION, + [MessageType.CONTACT]: env.SAMPLING_ENABLE_CONTACT, + [MessageType.POLL]: env.SAMPLING_ENABLE_POLL, + }, + + // Response behavior + maxTokens: env.SAMPLING_MAX_TOKENS, + showTypingIndicator: env.SAMPLING_SHOW_TYPING, + silentMode: env.SAMPLING_SILENT_MODE, + + // Rate limiting + rateLimitPerUser: env.SAMPLING_RATE_LIMIT_USER, + rateLimitPerChat: env.SAMPLING_RATE_LIMIT_CHAT, + + // Message filters + minMessageLength: env.SAMPLING_MIN_MESSAGE_LENGTH, + maxMessageLength: env.SAMPLING_MAX_MESSAGE_LENGTH, + keywordTriggers: env.SAMPLING_KEYWORD_TRIGGERS, + ignoreCommands: env.SAMPLING_IGNORE_COMMANDS, + + // Response templates + templates: { + [TemplateType.TEXT]: `NEW TELEGRAM MESSAGE FROM: +user_id: {userId} +chat_id: {chatId} +isDM: {isDM} +message_id: {messageId} +message_type: ${MessageType.TEXT} +content: {content}`, + + [TemplateType.PHOTO]: `NEW PHOTO MESSAGE FROM: +user_id: {userId} +chat_id: {chatId} +isDM: {isDM} +message_id: {messageId} +message_type: ${MessageType.PHOTO} +caption: {caption} +photo_info: {photoInfo}`, + + [TemplateType.DOCUMENT]: `NEW DOCUMENT MESSAGE FROM: +user_id: {userId} +chat_id: {chatId} +isDM: {isDM} +message_id: {messageId} +message_type: ${MessageType.DOCUMENT} +filename: {fileName} +mime_type: {mimeType} +caption: {caption}`, + + [TemplateType.VOICE]: `NEW VOICE MESSAGE FROM: +user_id: {userId} +chat_id: {chatId} +isDM: {isDM} +message_id: {messageId} +message_type: ${MessageType.VOICE} +duration: {duration}s`, + + [TemplateType.VIDEO]: `NEW VIDEO MESSAGE FROM: +user_id: {userId} +chat_id: {chatId} +isDM: {isDM} +message_id: {messageId} +message_type: ${MessageType.VIDEO} +caption: {caption} +duration: {duration}s`, + + [TemplateType.STICKER]: `NEW STICKER MESSAGE FROM: +user_id: {userId} +chat_id: {chatId} +isDM: {isDM} +message_id: {messageId} +message_type: ${MessageType.STICKER} +emoji: {stickerEmoji} +set_name: {stickerSetName}`, + + [TemplateType.LOCATION]: `NEW LOCATION MESSAGE FROM: +user_id: {userId} +chat_id: {chatId} +isDM: {isDM} +message_id: {messageId} +message_type: ${MessageType.LOCATION} +latitude: {latitude} +longitude: {longitude}`, + + [TemplateType.CONTACT]: `NEW CONTACT MESSAGE FROM: +user_id: {userId} +chat_id: {chatId} +isDM: {isDM} +message_id: {messageId} +message_type: ${MessageType.CONTACT} +contact_name: {contactName} +phone_number: {phoneNumber}`, + + [TemplateType.POLL]: `NEW POLL MESSAGE FROM: +user_id: {userId} +chat_id: {chatId} +isDM: {isDM} +message_id: {messageId} +message_type: ${MessageType.POLL} +question: {pollQuestion} +options: {pollOptions}`, + + [TemplateType.FALLBACK]: `NEW MESSAGE FROM: +user_id: {userId} +chat_id: {chatId} +isDM: {isDM} +message_id: {messageId} +message_type: {messageType} +content: {content}`, + }, +} as const; diff --git a/src/index.ts b/src/index.ts index 6212d7f..d53eb3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,12 @@ #!/usr/bin/env node import { FastMCP, type FastMCPSession } from "fastmcp"; -import { SamplingHandler } from "./sampling.js"; import { forwardMessageTool } from "./tools/forward-message.js"; import { getChannelInfoTool } from "./tools/get-channel-info.js"; import { getChannelMembersTool } from "./tools/get-channel-members.js"; import { pinMessageTool } from "./tools/pin-message.js"; import { sendMessageTool } from "./tools/send-message.js"; +import { SamplingHandler } from "./sampling/handler.js"; +import { samplingConfig } from "./config.js"; // ============================================================================= // CONSTANTS @@ -43,18 +44,22 @@ function setupSessionEventHandlers(server: FastMCP): SamplingHandler | null { server.on("connect", (event) => { console.log("šŸ”Œ Client connected:", event.session); - if (!samplingHandler) { - initializeSamplingHandler(event.session) - .then((handler) => { - samplingHandler = handler; - console.log("āœ… Telegram sampling handler initialized"); - }) - .catch((error) => { - console.error("āŒ Failed to initialize sampling handler:", error); - }); + if (samplingConfig.enabled) { + if (!samplingHandler) { + initializeSamplingHandler(event.session) + .then((handler) => { + samplingHandler = handler; + console.log("āœ… Telegram sampling handler initialized"); + }) + .catch((error) => { + console.error("āŒ Failed to initialize sampling handler:", error); + }); + } else { + samplingHandler.updateSession(event.session); + console.log("šŸ”„ Session updated for existing sampling handler"); + } } else { - samplingHandler.updateSession(event.session); - console.log("šŸ”„ Session updated for existing sampling handler"); + console.log("ā„¹ļø Sampling is disabled via SAMPLING_ENABLED=false"); } }); @@ -82,13 +87,17 @@ function setupGracefulShutdown(samplingHandler: SamplingHandler | null): void { `\nšŸ›‘ Received ${signal}, shutting down Telegram MCP Server...`, ); - if (samplingHandler) { + if (samplingHandler && samplingConfig.enabled) { try { await samplingHandler.stop(); console.log("āœ… Telegram bot stopped gracefully"); } catch (error) { console.error("āŒ Error stopping Telegram bot:", error); } + } else if (samplingConfig.enabled) { + console.log("ā„¹ļø No Telegram bot to stop (not initialized yet)"); + } else { + console.log("ā„¹ļø No Telegram bot to stop (sampling was disabled)"); } process.exit(0); @@ -102,10 +111,16 @@ function logStartupInfo(): void { console.log("āœ… Telegram MCP Server started successfully over stdio"); console.log("šŸ“” Ready to accept MCP client connections"); console.log(`šŸ› ļø Available tools: ${AVAILABLE_TOOLS.join(", ")}`); - console.log( - "šŸ¤– Telegram bot will start when first client connects for AI sampling", - ); - console.log("šŸ’” Make sure TELEGRAM_BOT_TOKEN environment variable is set"); + + if (samplingConfig.enabled) { + console.log( + "šŸ¤– Telegram bot will start when first client connects for AI sampling", + ); + console.log("šŸ’” Make sure TELEGRAM_BOT_TOKEN environment variable is set"); + } else { + console.log("āš ļø AI sampling is disabled (SAMPLING_ENABLED=false)"); + console.log("šŸ’” Only core MCP tools will be available"); + } } // ============================================================================= diff --git a/src/lib/config.ts b/src/lib/config.ts deleted file mode 100644 index 71e357b..0000000 --- a/src/lib/config.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const config = { - weatherApi: { - baseUrl: "https://api.openweathermap.org/data/2.5", - apiKey: process.env.OPENWEATHER_API_KEY || "", - defaultUnits: "metric", // metric (Celsius) or imperial (Fahrenheit) - }, -}; diff --git a/src/lib/http.ts b/src/lib/http.ts deleted file mode 100644 index 1a09d0e..0000000 --- a/src/lib/http.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { z } from "zod"; - -export class HttpError extends Error { - constructor( - public readonly status: number, - message: string, - public readonly data?: unknown, - ) { - super(message); - this.name = "HttpError"; - } -} - -export async function fetchJson( - url: string, - options?: RequestInit, - schema?: z.ZodType, -): Promise { - const response = await fetch(url, options); - - if (!response.ok) { - throw new HttpError( - response.status, - `HTTP error ${response.status}: ${response.statusText}`, - await response.text().catch(() => undefined), - ); - } - - const data = await response.json(); - - if (schema) { - try { - return schema.parse(data); - } catch (error) { - throw new Error( - `Invalid response data: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - - return data as T; -} diff --git a/src/sampling.ts b/src/sampling.ts deleted file mode 100644 index 5615d8a..0000000 --- a/src/sampling.ts +++ /dev/null @@ -1,171 +0,0 @@ -import type { FastMCP, FastMCPSession, TextContent } from "fastmcp"; -import { Telegraf } from "telegraf"; -import type { Context } from "telegraf"; -import { message } from "telegraf/filters"; -import { TelegramService } from "./services/telegram-service.js"; - -interface SamplingRequest { - userId: number; - sessionId: string; - content: string; - chatId: number; - messageId: number; -} - -export class SamplingHandler { - private session: FastMCPSession; - private bot: Telegraf; - private telegramService: TelegramService; - - constructor(session: FastMCPSession) { - this.session = session; - this.telegramService = new TelegramService(); - - // Create bot instance for listening to messages - const botToken = process.env.TELEGRAM_BOT_TOKEN; - if (!botToken) { - throw new Error("TELEGRAM_BOT_TOKEN environment variable is required"); - } - this.bot = new Telegraf(botToken); - - this.setupTelegramHandlers(); - } - - private setupTelegramHandlers() { - // Handle text messages - this.bot.on(message("text"), async (ctx: Context) => { - try { - const msg = ctx.message; - if (!msg || !("text" in msg) || !ctx.chat) return; - const content = msg.text; - const chatId = ctx.chat.id; - const messageId = msg.message_id; - const sessionId = `telegram_${chatId}`; - const userId = msg.from?.id; - // Send typing indicator - await ctx.sendChatAction("typing"); - - // Create sampling request - const request: SamplingRequest = { - userId, - sessionId, - content, - chatId, - messageId, - }; - - // Handle the sampling request - await this.handleRequest(request); - } catch (error) { - console.error("Error handling Telegram message:", error); - await ctx.reply( - "Sorry, I encountered an error processing your message.", - ); - } - }); - - // Handle bot commands - this.bot.start((ctx) => { - ctx.reply( - "Hello! I'm your AI assistant. Send me a message and I'll respond using AI sampling.", - ); - }); - - this.bot.help((ctx) => { - ctx.reply( - "Send me any message and I'll generate a response using AI sampling.", - ); - }); - } - - async handleRequest(request: SamplingRequest) { - try { - // Check if we have an active session - if (!this.session) { - console.error("No active FastMCP session available"); - await this.telegramService.sendMessage( - request.chatId, - "Sorry, the AI service is not available right now.", - ); - return; - } - const template = ` - NEW TELEGRAM MESSAGE FROM: - user_id: ${request.userId} - chat_id: ${request.chatId} - isDM: ${request.chatId === request.userId} - message_id: ${request.messageId} - content: ${request.content} - `; - - // Create sampling request for FastMCP - const samplingResponse = await this.session.requestSampling({ - messages: [ - { - role: "user", - content: { - type: "text", - text: template, - }, - }, - ], - maxTokens: 1000, - }); - // Extract the response text - let responseText: string; - - if ( - samplingResponse.content && - samplingResponse.content.type === "text" - ) { - responseText = (samplingResponse.content as TextContent).text; - } else { - responseText = "I'm sorry, I couldn't generate a response."; - } - - // Send response back to Telegram using the service - await this.telegramService.sendMessage(request.chatId, responseText); - } catch (error) { - console.error("Error in sampling request:", error); - - // Send error message to user using the service - await this.telegramService.sendMessage( - request.chatId, - "Sorry, I encountered an error while processing your request. Please try again.", - ); - } - } - - // Start the Telegram bot - async start() { - try { - console.log("Starting Telegram bot for sampling..."); - await this.bot.launch(); - console.log("Telegram bot started successfully"); - - // Enable graceful stop - process.once("SIGINT", () => this.stop()); - process.once("SIGTERM", () => this.stop()); - } catch (error) { - console.error("Error starting Telegram bot:", error); - throw error; - } - } - - // Stop the Telegram bot - async stop() { - console.log("Stopping Telegram bot..."); - this.bot.stop(); - console.log("Telegram bot stopped"); - } - - // Update session when a new client connects - updateSession(session: FastMCPSession) { - this.session = session; - } - - // Get the telegram service instance for external use - getTelegramService(): TelegramService { - return this.telegramService; - } -} diff --git a/src/sampling/handler.ts b/src/sampling/handler.ts new file mode 100644 index 0000000..31a848b --- /dev/null +++ b/src/sampling/handler.ts @@ -0,0 +1,275 @@ +import type { FastMCPSession, TextContent } from "fastmcp"; +import { Telegraf } from "telegraf"; +import type { Context } from "telegraf"; +import { message } from "telegraf/filters"; +import { TelegramService } from "../services/telegram-service.js"; +import type { SamplingRequest, MessageTemplateData } from "./types.js"; +import { MessageType, TemplateType } from "./types.js"; +import { samplingConfig } from "../config.js"; +import { MessageValidator } from "./validators.js"; +import { RateLimiter, formatTemplate, getActiveListeners } from "./utils.js"; +import { + handleTextMessage, + handlePhotoMessage, + handleDocumentMessage, + handleVoiceMessage, + handleVideoMessage, + handleStickerMessage, + handleLocationMessage, + handleContactMessage, + handlePollMessage, +} from "./message-handlers.js"; + +export class SamplingHandler { + private session: FastMCPSession; + private bot: Telegraf; + private telegramService: TelegramService; + private validator: MessageValidator; + private rateLimiter: RateLimiter; + private botUsername: string | null = null; + + constructor(session: FastMCPSession) { + this.session = session; + this.telegramService = new TelegramService(); + this.validator = new MessageValidator(); + this.rateLimiter = new RateLimiter(); + + const botToken = process.env.TELEGRAM_BOT_TOKEN; + if (!botToken) { + throw new Error("TELEGRAM_BOT_TOKEN environment variable is required"); + } + this.bot = new Telegraf(botToken); + + this.setupTelegramHandlers(); + } + + private async getBotUsername(): Promise { + if (!this.botUsername) { + const botInfo = await this.bot.telegram.getMe(); + this.botUsername = botInfo.username || "bot"; + this.validator.setBotUsername(this.botUsername); + } + return this.botUsername; + } + + private async processMessage( + ctx: Context, + messageType: MessageType, + handlerFunction: (ctx: Context) => ReturnType, + ): Promise { + if (!this.validator.shouldProcessMessage(ctx, messageType)) return; + if (!this.rateLimiter.checkRateLimit(ctx.from?.id || 0, ctx.chat?.id || 0)) + return; + + const templateData = handlerFunction(ctx); + if (!templateData) return; + + if ( + messageType === "text" && + !this.validator.validateTextMessage(templateData.content) + ) { + return; + } + + await this.handleMessage(ctx, messageType, templateData); + } + + private setupTelegramHandlers() { + // Text messages + this.bot.on(message("text"), async (ctx: Context) => { + await this.processMessage(ctx, MessageType.TEXT, handleTextMessage); + }); + + // Photo messages + this.bot.on(message("photo"), async (ctx: Context) => { + await this.processMessage(ctx, MessageType.PHOTO, handlePhotoMessage); + }); + + // Document messages + this.bot.on(message("document"), async (ctx: Context) => { + await this.processMessage( + ctx, + MessageType.DOCUMENT, + handleDocumentMessage, + ); + }); + + // Voice messages + this.bot.on(message("voice"), async (ctx: Context) => { + await this.processMessage(ctx, MessageType.VOICE, handleVoiceMessage); + }); + + // Video messages + this.bot.on(message("video"), async (ctx: Context) => { + await this.processMessage(ctx, MessageType.VIDEO, handleVideoMessage); + }); + + // Sticker messages + this.bot.on(message("sticker"), async (ctx: Context) => { + await this.processMessage(ctx, MessageType.STICKER, handleStickerMessage); + }); + + // Location messages + this.bot.on(message("location"), async (ctx: Context) => { + await this.processMessage( + ctx, + MessageType.LOCATION, + handleLocationMessage, + ); + }); + + // Contact messages + this.bot.on(message("contact"), async (ctx: Context) => { + await this.processMessage(ctx, MessageType.CONTACT, handleContactMessage); + }); + + // Poll messages + this.bot.on(message("poll"), async (ctx: Context) => { + await this.processMessage(ctx, MessageType.POLL, handlePollMessage); + }); + } + + private async handleMessage( + ctx: Context, + messageType: MessageType, + templateData: MessageTemplateData, + ) { + try { + // Show typing indicator if enabled + if (samplingConfig.showTypingIndicator) { + await ctx.sendChatAction("typing"); + } + + // Create sampling request + const request: SamplingRequest = { + userId: Number(templateData.userId), + sessionId: `telegram_${templateData.chatId}`, + content: String(templateData.content), + chatId: Number(templateData.chatId), + messageId: Number(templateData.messageId), + messageType, + templateData, + }; + + // Handle the sampling request + await this.handleRequest(request); + } catch (error) { + console.error(`Error handling ${messageType} message:`, error); + if (!samplingConfig.silentMode) { + await ctx.reply( + "Sorry, I encountered an error processing your message.", + ); + } + } + } + + async handleRequest(request: SamplingRequest) { + try { + if (!this.session) { + console.error("No active FastMCP session available"); + if (!samplingConfig.silentMode) { + await this.telegramService.sendMessage( + request.chatId, + "Sorry, the AI service is not available right now.", + ); + } + return; + } + + // Get the appropriate template + const template = + samplingConfig.templates[ + request.messageType as unknown as TemplateType + ] || samplingConfig.templates[TemplateType.FALLBACK]; + + // Format the template with data + const formattedTemplate = formatTemplate(template, request.templateData); + + // Create sampling request for FastMCP + const samplingResponse = await this.session.requestSampling({ + messages: [ + { + role: "user", + content: { + type: "text", + text: formattedTemplate, + }, + }, + ], + maxTokens: samplingConfig.maxTokens, + }); + + // Extract the response text + let responseText: string; + if ( + samplingResponse.content && + samplingResponse.content.type === "text" + ) { + responseText = (samplingResponse.content as TextContent).text; + } else { + responseText = "I'm sorry, I couldn't generate a response."; + } + + // Send response back to Telegram (unless in silent mode) + if (!samplingConfig.silentMode) { + await this.telegramService.sendMessage(request.chatId, responseText); + } + + console.log( + `Processed ${request.messageType} message from user ${request.userId} in chat ${request.chatId}`, + ); + } catch (error) { + console.error("Error in sampling request:", error); + + if (!samplingConfig.silentMode) { + await this.telegramService.sendMessage( + request.chatId, + "Sorry, I encountered an error while processing your request. Please try again.", + ); + } + } + } + + async start() { + try { + console.log("Starting Telegram bot for sampling..."); + + // Get bot username for mention detection + await this.getBotUsername(); + + await this.bot.launch(); + console.log("Telegram bot started successfully"); + + const configMode = samplingConfig.mentionOnly + ? "Mention-only mode" + : "All messages"; + const listeners = getActiveListeners(); + console.log(`Configuration: ${configMode}, Listeners: ${listeners}`); + + // Enable graceful stop + process.once("SIGINT", () => this.stop()); + process.once("SIGTERM", () => this.stop()); + } catch (error) { + console.error("Error starting Telegram bot:", error); + throw error; + } + } + + async stop() { + console.log("Stopping Telegram bot..."); + this.bot.stop(); + console.log("Telegram bot stopped"); + } + + updateSession(session: FastMCPSession) { + this.session = session; + } + + getTelegramService(): TelegramService { + return this.telegramService; + } + + getRateLimiter(): RateLimiter { + return this.rateLimiter; + } +} diff --git a/src/sampling/message-handlers.ts b/src/sampling/message-handlers.ts new file mode 100644 index 0000000..cf3830f --- /dev/null +++ b/src/sampling/message-handlers.ts @@ -0,0 +1,165 @@ +import type { Context } from "telegraf"; +import type { MessageTemplateData } from "./types.js"; +import { MessageType } from "./types.js"; + +export function handleTextMessage(ctx: Context): MessageTemplateData | null { + const msg = ctx.message; + if (!msg || !("text" in msg) || !msg.from || !ctx.chat) return null; + + return { + content: msg.text, + userId: msg.from.id, + chatId: ctx.chat.id, + isDM: ctx.chat.id === msg.from.id, + messageId: msg.message_id, + messageType: MessageType.TEXT, + }; +} + +export function handlePhotoMessage(ctx: Context): MessageTemplateData | null { + const msg = ctx.message; + if (!msg || !("photo" in msg) || !msg.from || !ctx.chat) return null; + + const photo = msg.photo[msg.photo.length - 1]; // Get highest resolution + + return { + content: msg.caption || "[Photo without caption]", + caption: msg.caption || "", + photoInfo: `${photo.width}x${photo.height}, ${photo.file_size || 0} bytes`, + userId: msg.from.id, + chatId: ctx.chat.id, + isDM: ctx.chat.id === msg.from.id, + messageId: msg.message_id, + messageType: MessageType.PHOTO, + }; +} + +export function handleDocumentMessage( + ctx: Context, +): MessageTemplateData | null { + const msg = ctx.message; + if (!msg || !("document" in msg) || !msg.from || !ctx.chat) return null; + + const doc = msg.document; + + return { + content: msg.caption || "[Document without caption]", + caption: msg.caption || "", + fileName: doc.file_name || "unnamed", + mimeType: doc.mime_type || "unknown", + userId: msg.from.id, + chatId: ctx.chat.id, + isDM: ctx.chat.id === msg.from.id, + messageId: msg.message_id, + messageType: MessageType.DOCUMENT, + }; +} + +export function handleVoiceMessage(ctx: Context): MessageTemplateData | null { + const msg = ctx.message; + if (!msg || !("voice" in msg) || !msg.from || !ctx.chat) return null; + + const voice = msg.voice; + + return { + content: "[Voice message]", + duration: voice.duration, + userId: msg.from.id, + chatId: ctx.chat.id, + isDM: ctx.chat.id === msg.from.id, + messageId: msg.message_id, + messageType: MessageType.VOICE, + }; +} + +export function handleVideoMessage(ctx: Context): MessageTemplateData | null { + const msg = ctx.message; + if (!msg || !("video" in msg) || !msg.from || !ctx.chat) return null; + + const video = msg.video; + + return { + content: msg.caption || "[Video without caption]", + caption: msg.caption || "", + duration: video.duration, + userId: msg.from.id, + chatId: ctx.chat.id, + isDM: ctx.chat.id === msg.from.id, + messageId: msg.message_id, + messageType: MessageType.VIDEO, + }; +} + +export function handleStickerMessage(ctx: Context): MessageTemplateData | null { + const msg = ctx.message; + if (!msg || !("sticker" in msg) || !msg.from || !ctx.chat) return null; + + const sticker = msg.sticker; + + return { + content: `[Sticker: ${sticker.emoji || "no emoji"}]`, + stickerEmoji: sticker.emoji || "", + stickerSetName: sticker.set_name || "", + userId: msg.from.id, + chatId: ctx.chat.id, + isDM: ctx.chat.id === msg.from.id, + messageId: msg.message_id, + messageType: MessageType.STICKER, + }; +} + +export function handleLocationMessage( + ctx: Context, +): MessageTemplateData | null { + const msg = ctx.message; + if (!msg || !("location" in msg) || !msg.from || !ctx.chat) return null; + + const location = msg.location; + + return { + content: `[Location: ${location.latitude}, ${location.longitude}]`, + latitude: location.latitude, + longitude: location.longitude, + userId: msg.from.id, + chatId: ctx.chat.id, + isDM: ctx.chat.id === msg.from.id, + messageId: msg.message_id, + messageType: MessageType.LOCATION, + }; +} + +export function handleContactMessage(ctx: Context): MessageTemplateData | null { + const msg = ctx.message; + if (!msg || !("contact" in msg) || !msg.from || !ctx.chat) return null; + + const contact = msg.contact; + + return { + content: `[Contact: ${contact.first_name} ${contact.last_name || ""}]`, + contactName: `${contact.first_name} ${contact.last_name || ""}`.trim(), + phoneNumber: contact.phone_number || "", + userId: msg.from.id, + chatId: ctx.chat.id, + isDM: ctx.chat.id === msg.from.id, + messageId: msg.message_id, + messageType: MessageType.CONTACT, + }; +} + +export function handlePollMessage(ctx: Context): MessageTemplateData | null { + const msg = ctx.message; + if (!msg || !("poll" in msg) || !msg.from || !ctx.chat) return null; + + const poll = msg.poll; + + return { + content: `[Poll: ${poll.question}]`, + pollQuestion: poll.question, + pollOptions: poll.options.map((opt) => opt.text).join(", "), + userId: msg.from.id, + chatId: ctx.chat.id, + isDM: ctx.chat.id === msg.from.id, + messageId: msg.message_id, + messageType: MessageType.POLL, + }; +} diff --git a/src/sampling/types.ts b/src/sampling/types.ts new file mode 100644 index 0000000..6f01b53 --- /dev/null +++ b/src/sampling/types.ts @@ -0,0 +1,72 @@ +import type { Context } from "telegraf"; + +export enum MessageType { + TEXT = "text", + PHOTO = "photo", + DOCUMENT = "document", + VOICE = "voice", + VIDEO = "video", + STICKER = "sticker", + LOCATION = "location", + CONTACT = "contact", + POLL = "poll", +} + +export enum TemplateType { + TEXT = "text", + PHOTO = "photo", + DOCUMENT = "document", + VOICE = "voice", + VIDEO = "video", + STICKER = "sticker", + LOCATION = "location", + CONTACT = "contact", + POLL = "poll", + FALLBACK = "fallback", +} + +export interface SamplingRequest { + userId: number; + sessionId: string; + content: string; + chatId: number; + messageId: number; + messageType: MessageType; + templateData: MessageTemplateData; +} + +export interface RateLimitEntry { + count: number; + resetTime: number; +} + +export interface MessageEntity { + type: string; + offset: number; + length: number; +} + +export interface MessageTemplateData { + content: string; + userId: number; + chatId: number; + isDM: boolean; + messageId: number; + messageType?: string; + caption?: string; + photoInfo?: string; + fileName?: string; + mimeType?: string; + duration?: number; + stickerEmoji?: string; + stickerSetName?: string; + latitude?: number; + longitude?: number; + contactName?: string; + phoneNumber?: string; + pollQuestion?: string; + pollOptions?: string; + [key: string]: string | number | boolean | undefined; +} + +export type TelegramMessage = NonNullable; diff --git a/src/sampling/utils.ts b/src/sampling/utils.ts new file mode 100644 index 0000000..cc9e745 --- /dev/null +++ b/src/sampling/utils.ts @@ -0,0 +1,103 @@ +import type { RateLimitEntry, MessageTemplateData } from "./types.js"; +import { samplingConfig } from "../config.js"; + +export class RateLimiter { + private userRateLimit = new Map(); + private chatRateLimit = new Map(); + + checkRateLimit(userId: number, chatId: number): boolean { + const now = Date.now(); + const minute = 60 * 1000; + + // Check user rate limit + const userLimit = this.userRateLimit.get(userId); + if (userLimit) { + if (now < userLimit.resetTime) { + if (userLimit.count >= samplingConfig.rateLimitPerUser) { + return false; + } + userLimit.count++; + } else { + this.userRateLimit.set(userId, { count: 1, resetTime: now + minute }); + } + } else { + this.userRateLimit.set(userId, { count: 1, resetTime: now + minute }); + } + + // Check chat rate limit + const chatLimit = this.chatRateLimit.get(chatId); + if (chatLimit) { + if (now < chatLimit.resetTime) { + if (chatLimit.count >= samplingConfig.rateLimitPerChat) { + return false; + } + chatLimit.count++; + } else { + this.chatRateLimit.set(chatId, { count: 1, resetTime: now + minute }); + } + } else { + this.chatRateLimit.set(chatId, { count: 1, resetTime: now + minute }); + } + + return true; + } + + getRateLimitStatus( + userId: number, + chatId: number, + ): { + userLimit: RateLimitEntry | undefined; + chatLimit: RateLimitEntry | undefined; + } { + return { + userLimit: this.userRateLimit.get(userId), + chatLimit: this.chatRateLimit.get(chatId), + }; + } + + resetRateLimits(): void { + this.userRateLimit.clear(); + this.chatRateLimit.clear(); + } +} + +export function formatTemplate( + template: string, + data: MessageTemplateData, +): string { + return template.replace(/\{(\w+)\}/g, (match, key) => { + return data[key]?.toString() || match; + }); +} + +export function getEnabledFeatures(): string[] { + const features = [ + "šŸ¤– AI-powered responses to messages", + "šŸ“ Text message processing", + ]; + + if (samplingConfig.enabledListeners.photo) features.push("šŸ“ø Photo analysis"); + if (samplingConfig.enabledListeners.document) + features.push("šŸ“„ Document processing"); + if (samplingConfig.enabledListeners.voice) + features.push("šŸŽµ Voice message handling"); + if (samplingConfig.enabledListeners.video) + features.push("šŸŽ„ Video processing"); + if (samplingConfig.enabledListeners.sticker) + features.push("šŸ˜„ Sticker responses"); + if (samplingConfig.enabledListeners.location) + features.push("šŸ“ Location awareness"); + if (samplingConfig.enabledListeners.contact) + features.push("šŸ‘¤ Contact processing"); + if (samplingConfig.enabledListeners.poll) + features.push("šŸ“Š Poll interaction"); + + return features; +} + +export function getActiveListeners(): string { + return Object.entries(samplingConfig.enabledListeners) + .filter(([_, enabled]) => enabled) + .map(([type]) => type) + .join(", "); +} diff --git a/src/sampling/validators.ts b/src/sampling/validators.ts new file mode 100644 index 0000000..84d48e0 --- /dev/null +++ b/src/sampling/validators.ts @@ -0,0 +1,150 @@ +import type { Context } from "telegraf"; +import type { MessageEntity, TelegramMessage, MessageType } from "./types.js"; +import { samplingConfig } from "../config.js"; + +export class MessageValidator { + private botUsername: string | null = null; + + setBotUsername(username: string): void { + this.botUsername = username; + } + + isMentioned(text: string, entities: MessageEntity[] = []): boolean { + if (!this.botUsername) return false; + + // Check for @username mentions + if (text.includes(`@${this.botUsername}`)) return true; + + // Check for mentions in entities + return entities.some( + (entity) => + entity.type === "mention" && + text.substring(entity.offset, entity.offset + entity.length) === + `@${this.botUsername}`, + ); + } + + shouldProcessMessage(ctx: Context, messageType: MessageType): boolean { + const msg = ctx.message; + if (!msg || !ctx.chat) return false; + + const chatId = ctx.chat.id; + const userId = msg.from?.id; + if (!userId) return false; + + // Check if message type is enabled + if ( + !samplingConfig.enabledListeners[ + messageType as keyof typeof samplingConfig.enabledListeners + ] + ) { + return false; + } + + // Check blocked users/chats + if ( + samplingConfig.blockedUsers.includes(userId) || + this.isChatBlocked(ctx.chat) + ) { + return false; + } + + // Check allowed users/chats (if specified) + if ( + samplingConfig.allowedUsers.length > 0 && + !samplingConfig.allowedUsers.includes(userId) + ) { + return false; + } + if ( + samplingConfig.allowedChats.length > 0 && + !this.isChatAllowed(ctx.chat) + ) { + return false; + } + + // Check if it's a DM + const isDM = chatId === userId; + + // For groups: check mention requirement + if (!isDM && samplingConfig.mentionOnly) { + if ("text" in msg && msg.text) { + return this.isMentioned(msg.text, msg.entities); + } + if ("caption" in msg && msg.caption) { + return this.isMentioned(msg.caption, msg.caption_entities); + } + return false; + } + + // For DMs: respect respondToPrivateMessages setting + if (isDM && !samplingConfig.respondToPrivateMessages) { + return false; + } + + return true; + } + + validateTextMessage(text: string): boolean { + // Check message length + if ( + text.length < samplingConfig.minMessageLength || + text.length > samplingConfig.maxMessageLength + ) { + return false; + } + + // Check if it's a command and we should ignore commands + if (samplingConfig.ignoreCommands && text.startsWith("/")) { + return false; + } + + // Check keyword triggers (if specified) + if (samplingConfig.keywordTriggers.length > 0) { + const hasKeyword = samplingConfig.keywordTriggers.some((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()), + ); + if (!hasKeyword) return false; + } + + return true; + } + + hasRequiredFields(msg: TelegramMessage, requiredFields: string[]): boolean { + return requiredFields.every((field) => { + if (field === "from") return msg.from != null; + return field in msg; + }); + } + + private isChatAllowed(chat: NonNullable): boolean { + return this.isChatInList(chat, samplingConfig.allowedChats); + } + + private isChatBlocked(chat: NonNullable): boolean { + return this.isChatInList(chat, samplingConfig.blockedChats); + } + + private isChatInList( + chat: NonNullable, + chatList: (number | string)[], + ): boolean { + const chatId = chat.id; + const chatUsername = "username" in chat ? chat.username : undefined; + + return chatList.some((entry) => { + // Check numeric ID match + if (typeof entry === "number") { + return entry === chatId; + } + + // Check username match (handle both @username and username formats) + if (typeof entry === "string" && chatUsername) { + const normalizedEntry = entry.startsWith("@") ? entry.slice(1) : entry; + return normalizedEntry === chatUsername; + } + + return false; + }); + } +}