diff --git a/.gitignore b/.gitignore index 733c724..060b2c8 100644 --- a/.gitignore +++ b/.gitignore @@ -201,4 +201,35 @@ test_issues_report.md test_upload.txt test_voice.ogg sticker.webp -two.png \ No newline at end of file +two.png +# === appended by migration === +node_modules/ +.next/ +.turbo/ +.nuxt/ +dist/ +build/ +out/ +target/ +.cache/ +.parcel-cache/ +.svelte-kit/ +.vercel/ +.angular/ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +venv/ +.venv/ +env/ +myenv/ +.tox/ +*.log +*.log.* +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*.swo diff --git a/__init__.py b/__init__.py deleted file mode 100644 index d49f2ac..0000000 --- a/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Telegram MCP Package diff --git a/main.py b/main.py index 454ab2b..030fb93 100644 --- a/main.py +++ b/main.py @@ -1,4152 +1,10 @@ -import os -import sys -import json -import time -import asyncio -import sqlite3 -import logging -import mimetypes -from datetime import datetime, timedelta -from enum import Enum -from typing import List, Dict, Optional, Union, Any +"""Backward-compatible entry point for telegram-mcp. -# Third-party libraries -import nest_asyncio -from dotenv import load_dotenv -from mcp.server.fastmcp import FastMCP -from mcp.types import ToolAnnotations -from pythonjsonlogger import jsonlogger -from telethon import TelegramClient, functions, utils -from telethon.sessions import StringSession -from telethon.tl.types import ( - User, - Chat, - Channel, - ChatAdminRights, - ChatBannedRights, - ChannelParticipantsKicked, - ChannelParticipantsAdmins, - InputChatPhoto, - InputChatUploadedPhoto, - InputChatPhotoEmpty, - InputPeerUser, - InputPeerChat, - InputPeerChannel, - DialogFilter, - DialogFilterDefault, - TextWithEntities, -) -import re -from functools import wraps -import telethon.errors.rpcerrorlist - - -class ValidationError(Exception): - """Custom exception for validation errors.""" - - pass - - -def json_serializer(obj): - """Helper function to convert non-serializable objects for JSON serialization.""" - if isinstance(obj, datetime): - return obj.isoformat() - if isinstance(obj, bytes): - return obj.decode("utf-8", errors="replace") - # Add other non-serializable types as needed - raise TypeError(f"Object of type {type(obj)} is not JSON serializable") - - -load_dotenv() - -TELEGRAM_API_ID = int(os.getenv("TELEGRAM_API_ID")) -TELEGRAM_API_HASH = os.getenv("TELEGRAM_API_HASH") -TELEGRAM_SESSION_NAME = os.getenv("TELEGRAM_SESSION_NAME") - -# Check if a string session exists in environment, otherwise use file-based session -SESSION_STRING = os.getenv("TELEGRAM_SESSION_STRING") - -mcp = FastMCP("telegram") - -if SESSION_STRING: - # Use the string session if available - client = TelegramClient(StringSession(SESSION_STRING), TELEGRAM_API_ID, TELEGRAM_API_HASH) -else: - # Use file-based session - client = TelegramClient(TELEGRAM_SESSION_NAME, TELEGRAM_API_ID, TELEGRAM_API_HASH) - -# Setup robust logging with both file and console output -logger = logging.getLogger("telegram_mcp") -logger.setLevel(logging.ERROR) # Set to ERROR for production, INFO for debugging - -# Create console handler -console_handler = logging.StreamHandler() -console_handler.setLevel(logging.ERROR) # Set to ERROR for production, INFO for debugging - -# Create file handler with absolute path -script_dir = os.path.dirname(os.path.abspath(__file__)) -log_file_path = os.path.join(script_dir, "mcp_errors.log") - -try: - file_handler = logging.FileHandler(log_file_path, mode="a") # Append mode - file_handler.setLevel(logging.ERROR) - - # Create formatters - # Console formatter remains in the old format - console_formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s") - console_handler.setFormatter(console_formatter) - - # File formatter is now JSON - json_formatter = jsonlogger.JsonFormatter( - "%(asctime)s %(name)s %(levelname)s %(message)s", - datefmt="%Y-%m-%dT%H:%M:%S%z", - ) - file_handler.setFormatter(json_formatter) - - # Add handlers to logger - logger.addHandler(console_handler) - logger.addHandler(file_handler) - logger.info(f"Logging initialized to {log_file_path}") -except Exception as log_error: - print(f"WARNING: Error setting up log file: {log_error}") - # Fallback to console-only logging - logger.addHandler(console_handler) - logger.error(f"Failed to set up log file handler: {log_error}") - - -# Error code prefix mapping for better error tracing -class ErrorCategory(str, Enum): - CHAT = "CHAT" - MSG = "MSG" - CONTACT = "CONTACT" - GROUP = "GROUP" - MEDIA = "MEDIA" - PROFILE = "PROFILE" - AUTH = "AUTH" - ADMIN = "ADMIN" - FOLDER = "FOLDER" - - -def log_and_format_error( - function_name: str, - error: Exception, - prefix: Optional[Union[ErrorCategory, str]] = None, - user_message: str = None, - **kwargs, -) -> str: - """ - Centralized error handling function. - - Logs an error and returns a formatted, user-friendly message. - - Args: - function_name: Name of the function where the error occurred. - error: The exception that was raised. - prefix: Error code prefix (e.g., ErrorCategory.CHAT, "VALIDATION-001"). - If None, it will be derived from the function_name. - user_message: A custom user-facing message to return. If None, a generic one is created. - **kwargs: Additional context parameters to include in the log. - - Returns: - A user-friendly error message with an error code. - """ - # Generate a consistent error code - if isinstance(prefix, str) and prefix == "VALIDATION-001": - # Special case for validation errors - error_code = prefix - else: - if prefix is None: - # Try to derive prefix from function name - for category in ErrorCategory: - if category.name.lower() in function_name.lower(): - prefix = category - break - - prefix_str = prefix.value if isinstance(prefix, ErrorCategory) else (prefix or "GEN") - error_code = f"{prefix_str}-ERR-{abs(hash(function_name)) % 1000:03d}" - - # Format the additional context parameters - context = ", ".join(f"{k}={v}" for k, v in kwargs.items()) - - # Log the full technical error - logger.error(f"Error in {function_name} ({context}) - Code: {error_code}", exc_info=True) - - # Return a user-friendly message - if user_message: - return user_message - - return f"An error occurred (code: {error_code}). Check mcp_errors.log for details." - - -def validate_id(*param_names_to_validate): - """ - Decorator to validate chat_id and user_id parameters, including lists of IDs. - It checks for valid integer ranges, string representations of integers, - and username formats. - """ - - def decorator(func): - @wraps(func) - async def wrapper(*args, **kwargs): - for param_name in param_names_to_validate: - if param_name not in kwargs or kwargs[param_name] is None: - continue - - param_value = kwargs[param_name] - - def validate_single_id(value, p_name): - # Handle integer IDs - if isinstance(value, int): - if not (-(2**63) <= value <= 2**63 - 1): - return ( - None, - f"Invalid {p_name}: {value}. ID is out of the valid integer range.", - ) - return value, None - - # Handle string IDs - if isinstance(value, str): - try: - int_value = int(value) - if not (-(2**63) <= int_value <= 2**63 - 1): - return ( - None, - f"Invalid {p_name}: {value}. ID is out of the valid integer range.", - ) - return int_value, None - except ValueError: - if re.match(r"^@?[a-zA-Z0-9_]{5,}$", value): - return value, None - else: - return ( - None, - f"Invalid {p_name}: '{value}'. Must be a valid integer ID, or a username string.", - ) - - # Handle other invalid types - return ( - None, - f"Invalid {p_name}: {value}. Type must be an integer or a string.", - ) - - if isinstance(param_value, list): - validated_list = [] - for item in param_value: - validated_item, error_msg = validate_single_id(item, param_name) - if error_msg: - return log_and_format_error( - func.__name__, - ValidationError(error_msg), - prefix="VALIDATION-001", - user_message=error_msg, - **{param_name: param_value}, - ) - validated_list.append(validated_item) - kwargs[param_name] = validated_list - else: - validated_value, error_msg = validate_single_id(param_value, param_name) - if error_msg: - return log_and_format_error( - func.__name__, - ValidationError(error_msg), - prefix="VALIDATION-001", - user_message=error_msg, - **{param_name: param_value}, - ) - kwargs[param_name] = validated_value - - return await func(*args, **kwargs) - - return wrapper - - return decorator - - -def format_entity(entity) -> Dict[str, Any]: - """Helper function to format entity information consistently.""" - result = {"id": entity.id} - - if hasattr(entity, "title"): - result["name"] = entity.title - result["type"] = "group" if isinstance(entity, Chat) else "channel" - elif hasattr(entity, "first_name"): - name_parts = [] - if entity.first_name: - name_parts.append(entity.first_name) - if hasattr(entity, "last_name") and entity.last_name: - name_parts.append(entity.last_name) - result["name"] = " ".join(name_parts) - result["type"] = "user" - if hasattr(entity, "username") and entity.username: - result["username"] = entity.username - if hasattr(entity, "phone") and entity.phone: - result["phone"] = entity.phone - - return result - - -def format_message(message) -> Dict[str, Any]: - """Helper function to format message information consistently.""" - result = { - "id": message.id, - "date": message.date.isoformat(), - "text": message.message or "", - } - - if message.from_id: - result["from_id"] = utils.get_peer_id(message.from_id) - - if message.media: - result["has_media"] = True - result["media_type"] = type(message.media).__name__ - - return result - - -def get_sender_name(message) -> str: - """Helper function to get sender name from a message.""" - if not message.sender: - return "Unknown" - - # Check for group/channel title first - if hasattr(message.sender, "title") and message.sender.title: - return message.sender.title - elif hasattr(message.sender, "first_name"): - # User sender - first_name = getattr(message.sender, "first_name", "") or "" - last_name = getattr(message.sender, "last_name", "") or "" - full_name = f"{first_name} {last_name}".strip() - return full_name if full_name else "Unknown" - else: - return "Unknown" - - -def get_engagement_info(message) -> str: - """Helper function to get engagement metrics (views, forwards, reactions) from a message.""" - engagement_parts = [] - views = getattr(message, "views", None) - if views is not None: - engagement_parts.append(f"views:{views}") - forwards = getattr(message, "forwards", None) - if forwards is not None: - engagement_parts.append(f"forwards:{forwards}") - reactions = getattr(message, "reactions", None) - if reactions is not None: - results = getattr(reactions, "results", None) - total_reactions = sum(getattr(r, "count", 0) or 0 for r in results) if results else 0 - engagement_parts.append(f"reactions:{total_reactions}") - return f" | {', '.join(engagement_parts)}" if engagement_parts else "" - - -@mcp.tool(annotations=ToolAnnotations(title="Get Chats", openWorldHint=True, readOnlyHint=True)) -async def get_chats(page: int = 1, page_size: int = 20) -> str: - """ - Get a paginated list of chats. - Args: - page: Page number (1-indexed). - page_size: Number of chats per page. - """ - try: - dialogs = await client.get_dialogs() - start = (page - 1) * page_size - end = start + page_size - if start >= len(dialogs): - return "Page out of range." - chats = dialogs[start:end] - lines = [] - for dialog in chats: - entity = dialog.entity - chat_id = entity.id - title = getattr(entity, "title", None) or getattr(entity, "first_name", "Unknown") - lines.append(f"Chat ID: {chat_id}, Title: {title}") - return "\n".join(lines) - except Exception as e: - return log_and_format_error("get_chats", e) - - -@mcp.tool(annotations=ToolAnnotations(title="Get Messages", openWorldHint=True, readOnlyHint=True)) -@validate_id("chat_id") -async def get_messages(chat_id: Union[int, str], page: int = 1, page_size: int = 20) -> str: - """ - Get paginated messages from a specific chat. - Args: - chat_id: The ID or username of the chat. - page: Page number (1-indexed). - page_size: Number of messages per page. - """ - try: - entity = await client.get_entity(chat_id) - offset = (page - 1) * page_size - messages = await client.get_messages(entity, limit=page_size, add_offset=offset) - if not messages: - return "No messages found for this page." - lines = [] - for msg in messages: - sender_name = get_sender_name(msg) - reply_info = "" - if msg.reply_to and msg.reply_to.reply_to_msg_id: - reply_info = f" | reply to {msg.reply_to.reply_to_msg_id}" - - engagement_info = get_engagement_info(msg) - - lines.append( - f"ID: {msg.id} | {sender_name} | Date: {msg.date}{reply_info}{engagement_info} | Message: {msg.message}" - ) - return "\n".join(lines) - except Exception as e: - return log_and_format_error( - "get_messages", e, chat_id=chat_id, page=page, page_size=page_size - ) - - -@mcp.tool( - annotations=ToolAnnotations(title="Send Message", openWorldHint=True, destructiveHint=True) -) -@validate_id("chat_id") -async def send_message(chat_id: Union[int, str], message: str) -> str: - """ - Send a message to a specific chat. - Args: - chat_id: The ID or username of the chat. - message: The message content to send. - """ - try: - entity = await client.get_entity(chat_id) - await client.send_message(entity, message) - return "Message sent successfully." - except Exception as e: - return log_and_format_error("send_message", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Subscribe Public Channel", - openWorldHint=True, - destructiveHint=True, - idempotentHint=True, - ) -) -@validate_id("channel") -async def subscribe_public_channel(channel: Union[int, str]) -> str: - """ - Subscribe (join) to a public channel or supergroup by username or ID. - """ - try: - entity = await client.get_entity(channel) - await client(functions.channels.JoinChannelRequest(channel=entity)) - title = getattr(entity, "title", getattr(entity, "username", "Unknown channel")) - return f"Subscribed to {title}." - except telethon.errors.rpcerrorlist.UserAlreadyParticipantError: - title = getattr(entity, "title", getattr(entity, "username", "this channel")) - return f"Already subscribed to {title}." - except telethon.errors.rpcerrorlist.ChannelPrivateError: - return "Cannot subscribe: this channel is private or requires an invite link." - except Exception as e: - return log_and_format_error("subscribe_public_channel", e, channel=channel) - - -@mcp.tool( - annotations=ToolAnnotations(title="List Inline Buttons", openWorldHint=True, readOnlyHint=True) -) -@validate_id("chat_id") -async def list_inline_buttons( - chat_id: Union[int, str], message_id: Optional[Union[int, str]] = None, limit: int = 20 -) -> str: - """ - Inspect inline buttons on a recent message to discover their indices/text/URLs. - """ - try: - if isinstance(message_id, str): - if message_id.isdigit(): - message_id = int(message_id) - else: - return "message_id must be an integer." - - entity = await client.get_entity(chat_id) - target_message = None - - if message_id is not None: - target_message = await client.get_messages(entity, ids=message_id) - if isinstance(target_message, list): - target_message = target_message[0] if target_message else None - else: - recent_messages = await client.get_messages(entity, limit=limit) - target_message = next( - (msg for msg in recent_messages if getattr(msg, "buttons", None)), None - ) - - if not target_message: - return "No message with inline buttons found." - - buttons_attr = getattr(target_message, "buttons", None) - if not buttons_attr: - return f"Message {target_message.id} does not contain inline buttons." - - buttons = [btn for row in buttons_attr for btn in row] - if not buttons: - return f"Message {target_message.id} does not contain inline buttons." - - lines = [ - f"Buttons for message {target_message.id} (date {target_message.date}):", - ] - for idx, btn in enumerate(buttons): - raw_button = getattr(btn, "button", None) - text = getattr(btn, "text", "") or "" - url = getattr(raw_button, "url", None) if raw_button else None - has_callback = bool(getattr(btn, "data", None)) - parts = [f"[{idx}] text='{text}'"] - parts.append("callback=yes" if has_callback else "callback=no") - if url: - parts.append(f"url={url}") - lines.append(", ".join(parts)) - - return "\n".join(lines) - except Exception as e: - return log_and_format_error( - "list_inline_buttons", - e, - chat_id=chat_id, - message_id=message_id, - limit=limit, - ) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Press Inline Button", openWorldHint=True, destructiveHint=True - ) -) -@validate_id("chat_id") -async def press_inline_button( - chat_id: Union[int, str], - message_id: Optional[Union[int, str]] = None, - button_text: Optional[str] = None, - button_index: Optional[int] = None, -) -> str: - """ - Press an inline button (callback) in a chat message. - - Args: - chat_id: Chat or bot where the inline keyboard exists. - message_id: Specific message ID to inspect. If omitted, searches recent messages for one containing buttons. - button_text: Exact text of the button to press (case-insensitive). - button_index: Zero-based index among all buttons if you prefer positional access. - """ - try: - if button_text is None and button_index is None: - return "Provide button_text or button_index to choose a button." - - # Normalize message_id if provided as a string - if isinstance(message_id, str): - if message_id.isdigit(): - message_id = int(message_id) - else: - return "message_id must be an integer." - - if isinstance(button_index, str): - if button_index.isdigit(): - button_index = int(button_index) - else: - return "button_index must be an integer." - - entity = await client.get_entity(chat_id) - - target_message = None - if message_id is not None: - target_message = await client.get_messages(entity, ids=message_id) - if isinstance(target_message, list): - target_message = target_message[0] if target_message else None - else: - recent_messages = await client.get_messages(entity, limit=20) - target_message = next( - (msg for msg in recent_messages if getattr(msg, "buttons", None)), None - ) - - if not target_message: - return "No message with inline buttons found. Specify message_id to target a specific message." - - buttons_attr = getattr(target_message, "buttons", None) - if not buttons_attr: - return f"Message {target_message.id} does not contain inline buttons." - - buttons = [btn for row in buttons_attr for btn in row] - if not buttons: - return f"Message {target_message.id} does not contain inline buttons." - - target_button = None - if button_text: - normalized = button_text.strip().lower() - target_button = next( - ( - btn - for btn in buttons - if (getattr(btn, "text", "") or "").strip().lower() == normalized - ), - None, - ) - - if target_button is None and button_index is not None: - if button_index < 0 or button_index >= len(buttons): - return f"button_index out of range. Valid indices: 0-{len(buttons) - 1}." - target_button = buttons[button_index] - - if not target_button: - available = ", ".join( - f"[{idx}] {getattr(btn, 'text', '') or ''}" - for idx, btn in enumerate(buttons) - ) - return f"Button not found. Available buttons: {available}" - - if not getattr(target_button, "data", None): - raw_button = getattr(target_button, "button", None) - url = getattr(raw_button, "url", None) if raw_button else None - if url: - return f"Selected button opens a URL instead of sending a callback: {url}" - return "Selected button does not provide callback data to press." - - callback_result = await client( - functions.messages.GetBotCallbackAnswerRequest( - peer=entity, msg_id=target_message.id, data=target_button.data - ) - ) - - response_parts = [] - if getattr(callback_result, "message", None): - response_parts.append(callback_result.message) - if getattr(callback_result, "alert", None): - response_parts.append("Telegram displayed an alert to the user.") - if not response_parts: - response_parts.append("Button pressed successfully.") - - return " ".join(response_parts) - except Exception as e: - return log_and_format_error( - "press_inline_button", - e, - chat_id=chat_id, - message_id=message_id, - button_text=button_text, - button_index=button_index, - ) - - -@mcp.tool( - annotations=ToolAnnotations(title="List Contacts", openWorldHint=True, readOnlyHint=True) -) -async def list_contacts() -> str: - """ - List all contacts in your Telegram account. - """ - try: - result = await client(functions.contacts.GetContactsRequest(hash=0)) - users = result.users - if not users: - return "No contacts found." - lines = [] - for user in users: - name = f"{getattr(user, 'first_name', '')} {getattr(user, 'last_name', '')}".strip() - username = getattr(user, "username", "") - phone = getattr(user, "phone", "") - contact_info = f"ID: {user.id}, Name: {name}" - if username: - contact_info += f", Username: @{username}" - if phone: - contact_info += f", Phone: {phone}" - lines.append(contact_info) - return "\n".join(lines) - except Exception as e: - return log_and_format_error("list_contacts", e) - - -@mcp.tool( - annotations=ToolAnnotations(title="Search Contacts", openWorldHint=True, readOnlyHint=True) -) -async def search_contacts(query: str) -> str: - """ - Search for contacts by name, username, or phone number using Telethon's SearchRequest. - Args: - query: The search term to look for in contact names, usernames, or phone numbers. - """ - try: - result = await client(functions.contacts.SearchRequest(q=query, limit=50)) - users = result.users - if not users: - return f"No contacts found matching '{query}'." - lines = [] - for user in users: - name = f"{getattr(user, 'first_name', '')} {getattr(user, 'last_name', '')}".strip() - username = getattr(user, "username", "") - phone = getattr(user, "phone", "") - contact_info = f"ID: {user.id}, Name: {name}" - if username: - contact_info += f", Username: @{username}" - if phone: - contact_info += f", Phone: {phone}" - lines.append(contact_info) - return "\n".join(lines) - except Exception as e: - return log_and_format_error("search_contacts", e, query=query) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get Contact Ids", openWorldHint=True, readOnlyHint=True) -) -async def get_contact_ids() -> str: - """ - Get all contact IDs in your Telegram account. - """ - try: - result = await client(functions.contacts.GetContactIDsRequest(hash=0)) - if not result: - return "No contact IDs found." - return "Contact IDs: " + ", ".join(str(cid) for cid in result) - except Exception as e: - return log_and_format_error("get_contact_ids", e) - - -@mcp.tool( - annotations=ToolAnnotations(title="List Messages", openWorldHint=True, readOnlyHint=True) -) -@validate_id("chat_id") -async def list_messages( - chat_id: Union[int, str], - limit: int = 20, - search_query: str = None, - from_date: str = None, - to_date: str = None, -) -> str: - """ - Retrieve messages with optional filters. - - Args: - chat_id: The ID or username of the chat to get messages from. - limit: Maximum number of messages to retrieve. - search_query: Filter messages containing this text. - from_date: Filter messages starting from this date (format: YYYY-MM-DD). - to_date: Filter messages until this date (format: YYYY-MM-DD). - """ - try: - entity = await client.get_entity(chat_id) - - # Parse date filters if provided - from_date_obj = None - to_date_obj = None - - if from_date: - try: - from_date_obj = datetime.strptime(from_date, "%Y-%m-%d") - # Make it timezone aware by adding UTC timezone info - # Use datetime.timezone.utc for Python 3.9+ or import timezone directly for 3.13+ - try: - # For Python 3.9+ - from_date_obj = from_date_obj.replace(tzinfo=datetime.timezone.utc) - except AttributeError: - # For Python 3.13+ - from datetime import timezone - - from_date_obj = from_date_obj.replace(tzinfo=timezone.utc) - except ValueError: - return f"Invalid from_date format. Use YYYY-MM-DD." - - if to_date: - try: - to_date_obj = datetime.strptime(to_date, "%Y-%m-%d") - # Set to end of day and make timezone aware - to_date_obj = to_date_obj + timedelta(days=1, microseconds=-1) - # Add timezone info - try: - to_date_obj = to_date_obj.replace(tzinfo=datetime.timezone.utc) - except AttributeError: - from datetime import timezone - - to_date_obj = to_date_obj.replace(tzinfo=timezone.utc) - except ValueError: - return f"Invalid to_date format. Use YYYY-MM-DD." - - # Prepare filter parameters - params = {} - if search_query: - # IMPORTANT: Do not combine offset_date with search. - # Use server-side search alone, then enforce date bounds client-side. - params["search"] = search_query - messages = [] - async for msg in client.iter_messages(entity, **params): # newest -> oldest - if to_date_obj and msg.date > to_date_obj: - continue - if from_date_obj and msg.date < from_date_obj: - break - messages.append(msg) - if len(messages) >= limit: - break - - else: - # Use server-side iteration when only date bounds are present - # (no search) to avoid over-fetching. - if from_date_obj or to_date_obj: - messages = [] - if from_date_obj: - # Walk forward from start date (oldest -> newest) - async for msg in client.iter_messages( - entity, offset_date=from_date_obj, reverse=True - ): - if to_date_obj and msg.date > to_date_obj: - break - if msg.date < from_date_obj: - continue - messages.append(msg) - if len(messages) >= limit: - break - else: - # Only upper bound: walk backward from end bound - async for msg in client.iter_messages( - # offset_date is exclusive; +1ยตs makes to_date inclusive - entity, - offset_date=to_date_obj + timedelta(microseconds=1), - ): - messages.append(msg) - if len(messages) >= limit: - break - else: - messages = await client.get_messages(entity, limit=limit, **params) - - if not messages: - return "No messages found matching the criteria." - - lines = [] - for msg in messages: - sender_name = get_sender_name(msg) - message_text = msg.message or "[Media/No text]" - reply_info = "" - if msg.reply_to and msg.reply_to.reply_to_msg_id: - reply_info = f" | reply to {msg.reply_to.reply_to_msg_id}" - - engagement_info = get_engagement_info(msg) - - lines.append( - f"ID: {msg.id} | {sender_name} | Date: {msg.date}{reply_info}{engagement_info} | Message: {message_text}" - ) - - return "\n".join(lines) - except Exception as e: - return log_and_format_error("list_messages", e, chat_id=chat_id) - - -@mcp.tool(annotations=ToolAnnotations(title="List Topics", openWorldHint=True, readOnlyHint=True)) -async def list_topics( - chat_id: int, - limit: int = 200, - offset_topic: int = 0, - search_query: str = None, -) -> str: - """ - Retrieve forum topics from a supergroup with the forum feature enabled. - - Note for LLM: You can send a message to a selected topic via reply_to_message tool - by using Topic ID as the message_id parameter. - - Args: - chat_id: The ID of the forum-enabled chat (supergroup). - limit: Maximum number of topics to retrieve. - offset_topic: Topic ID offset for pagination. - search_query: Optional query to filter topics by title. - """ - try: - entity = await client.get_entity(chat_id) - - if not isinstance(entity, Channel) or not getattr(entity, "megagroup", False): - return "The specified chat is not a supergroup." - - if not getattr(entity, "forum", False): - return "The specified supergroup does not have forum topics enabled." - - result = await client( - functions.channels.GetForumTopicsRequest( - channel=entity, - offset_date=0, - offset_id=0, - offset_topic=offset_topic, - limit=limit, - q=search_query or None, - ) - ) - - topics = getattr(result, "topics", None) or [] - if not topics: - return "No topics found for this chat." - - messages_map = {} - if getattr(result, "messages", None): - messages_map = {message.id: message for message in result.messages} - - lines = [] - for topic in topics: - line_parts = [f"Topic ID: {topic.id}"] - - title = getattr(topic, "title", None) or "(no title)" - line_parts.append(f"Title: {title}") - - total_messages = getattr(topic, "total_messages", None) - if total_messages is not None: - line_parts.append(f"Messages: {total_messages}") - - unread_count = getattr(topic, "unread_count", None) - if unread_count: - line_parts.append(f"Unread: {unread_count}") - - if getattr(topic, "closed", False): - line_parts.append("Closed: Yes") - - if getattr(topic, "hidden", False): - line_parts.append("Hidden: Yes") - - top_message_id = getattr(topic, "top_message", None) - top_message = messages_map.get(top_message_id) - if top_message and getattr(top_message, "date", None): - line_parts.append(f"Last Activity: {top_message.date.isoformat()}") - - lines.append(" | ".join(line_parts)) - - return "\n".join(lines) - except Exception as e: - return log_and_format_error( - "list_topics", - e, - chat_id=chat_id, - limit=limit, - offset_topic=offset_topic, - search_query=search_query, - ) - - -@mcp.tool(annotations=ToolAnnotations(title="List Chats", openWorldHint=True, readOnlyHint=True)) -async def list_chats(chat_type: str = None, limit: int = 20) -> str: - """ - List available chats with metadata. - - Args: - chat_type: Filter by chat type ('user', 'group', 'channel', or None for all) - limit: Maximum number of chats to retrieve. - """ - try: - dialogs = await client.get_dialogs(limit=limit) - - results = [] - for dialog in dialogs: - entity = dialog.entity - - # Filter by type if requested - current_type = None - if isinstance(entity, User): - current_type = "user" - elif isinstance(entity, Chat): - current_type = "group" - elif isinstance(entity, Channel): - if getattr(entity, "broadcast", False): - current_type = "channel" - else: - current_type = "group" # Supergroup - - if chat_type and current_type != chat_type.lower(): - continue - - # Format chat info - chat_info = f"Chat ID: {entity.id}" - - if hasattr(entity, "title"): - chat_info += f", Title: {entity.title}" - elif hasattr(entity, "first_name"): - name = f"{entity.first_name}" - if hasattr(entity, "last_name") and entity.last_name: - name += f" {entity.last_name}" - chat_info += f", Name: {name}" - - chat_info += f", Type: {current_type}" - - if hasattr(entity, "username") and entity.username: - chat_info += f", Username: @{entity.username}" - - # Add unread count if available - unread_count = getattr(dialog, "unread_count", 0) or 0 - # Also check unread_mark (manual "mark as unread" flag) - inner_dialog = getattr(dialog, "dialog", None) - unread_mark = ( - bool(getattr(inner_dialog, "unread_mark", False)) if inner_dialog else False - ) - - if unread_count > 0: - chat_info += f", Unread: {unread_count}" - elif unread_mark: - chat_info += ", Unread: marked" - else: - chat_info += ", No unread messages" - - results.append(chat_info) - - if not results: - return f"No chats found matching the criteria." - - return "\n".join(results) - except Exception as e: - return log_and_format_error("list_chats", e, chat_type=chat_type, limit=limit) - - -@mcp.tool(annotations=ToolAnnotations(title="Get Chat", openWorldHint=True, readOnlyHint=True)) -@validate_id("chat_id") -async def get_chat(chat_id: Union[int, str]) -> str: - """ - Get detailed information about a specific chat. - - Args: - chat_id: The ID or username of the chat. - """ - try: - entity = await client.get_entity(chat_id) - - result = [] - result.append(f"ID: {entity.id}") - - is_channel = isinstance(entity, Channel) - is_chat = isinstance(entity, Chat) - is_user = isinstance(entity, User) - - if hasattr(entity, "title"): - result.append(f"Title: {entity.title}") - chat_type = ( - "Channel" if is_channel and getattr(entity, "broadcast", False) else "Group" - ) - if is_channel and getattr(entity, "megagroup", False): - chat_type = "Supergroup" - elif is_chat: - chat_type = "Group (Basic)" - result.append(f"Type: {chat_type}") - if hasattr(entity, "username") and entity.username: - result.append(f"Username: @{entity.username}") - - # Fetch participants count reliably - try: - participants_count = (await client.get_participants(entity, limit=0)).total - result.append(f"Participants: {participants_count}") - except Exception as pe: - result.append(f"Participants: Error fetching ({pe})") - - elif is_user: - name = f"{entity.first_name}" - if entity.last_name: - name += f" {entity.last_name}" - result.append(f"Name: {name}") - result.append(f"Type: User") - if entity.username: - result.append(f"Username: @{entity.username}") - if entity.phone: - result.append(f"Phone: {entity.phone}") - result.append(f"Bot: {'Yes' if entity.bot else 'No'}") - result.append(f"Verified: {'Yes' if entity.verified else 'No'}") - - # Get last activity if it's a dialog - try: - # Using get_dialogs might be slow if there are many dialogs - # Alternative: Get entity again via get_dialogs if needed for unread count - dialog = await client.get_dialogs(limit=1, offset_id=0, offset_peer=entity) - if dialog: - dialog = dialog[0] - result.append(f"Unread Messages: {dialog.unread_count}") - if dialog.message: - last_msg = dialog.message - sender_name = "Unknown" - if last_msg.sender: - sender_name = getattr(last_msg.sender, "first_name", "") or getattr( - last_msg.sender, "title", "Unknown" - ) - if hasattr(last_msg.sender, "last_name") and last_msg.sender.last_name: - sender_name += f" {last_msg.sender.last_name}" - sender_name = sender_name.strip() or "Unknown" - result.append(f"Last Message: From {sender_name} at {last_msg.date}") - result.append(f"Message: {last_msg.message or '[Media/No text]'}") - except Exception as diag_ex: - logger.warning(f"Could not get dialog info for {chat_id}: {diag_ex}") - pass - - return "\n".join(result) - except Exception as e: - return log_and_format_error("get_chat", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Get Direct Chat By Contact", openWorldHint=True, readOnlyHint=True - ) -) -async def get_direct_chat_by_contact(contact_query: str) -> str: - """ - Find a direct chat with a specific contact by name, username, or phone. - - Args: - contact_query: Name, username, or phone number to search for. - """ - try: - # Fetch all contacts using the correct Telethon method - result = await client(functions.contacts.GetContactsRequest(hash=0)) - contacts = result.users - found_contacts = [] - for contact in contacts: - if not contact: - continue - name = ( - f"{getattr(contact, 'first_name', '')} {getattr(contact, 'last_name', '')}".strip() - ) - username = getattr(contact, "username", "") - phone = getattr(contact, "phone", "") - if ( - contact_query.lower() in name.lower() - or (username and contact_query.lower() in username.lower()) - or (phone and contact_query in phone) - ): - found_contacts.append(contact) - if not found_contacts: - return f"No contacts found matching '{contact_query}'." - # If we found contacts, look for direct chats with them - results = [] - dialogs = await client.get_dialogs() - for contact in found_contacts: - contact_name = ( - f"{getattr(contact, 'first_name', '')} {getattr(contact, 'last_name', '')}".strip() - ) - for dialog in dialogs: - if isinstance(dialog.entity, User) and dialog.entity.id == contact.id: - chat_info = f"Chat ID: {dialog.entity.id}, Contact: {contact_name}" - if getattr(contact, "username", ""): - chat_info += f", Username: @{contact.username}" - if dialog.unread_count: - chat_info += f", Unread: {dialog.unread_count}" - results.append(chat_info) - break - if not results: - found_names = ", ".join( - [f"{c.first_name} {c.last_name}".strip() for c in found_contacts] - ) - return f"Found contacts: {found_names}, but no direct chats were found with them." - return "\n".join(results) - except Exception as e: - return log_and_format_error("get_direct_chat_by_contact", e, contact_query=contact_query) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get Contact Chats", openWorldHint=True, readOnlyHint=True) -) -@validate_id("contact_id") -async def get_contact_chats(contact_id: Union[int, str]) -> str: - """ - List all chats involving a specific contact. - - Args: - contact_id: The ID or username of the contact. - """ - try: - # Get contact info - contact = await client.get_entity(contact_id) - if not isinstance(contact, User): - return f"ID {contact_id} is not a user/contact." - - contact_name = ( - f"{getattr(contact, 'first_name', '')} {getattr(contact, 'last_name', '')}".strip() - ) - - # Find direct chat - direct_chat = None - dialogs = await client.get_dialogs() - - results = [] - - # Look for direct chat - for dialog in dialogs: - if isinstance(dialog.entity, User) and dialog.entity.id == contact_id: - chat_info = f"Direct Chat ID: {dialog.entity.id}, Type: Private" - if dialog.unread_count: - chat_info += f", Unread: {dialog.unread_count}" - results.append(chat_info) - break - - # Look for common groups/channels - common_chats = [] - try: - common = await client.get_common_chats(contact) - for chat in common: - chat_type = "Channel" if getattr(chat, "broadcast", False) else "Group" - chat_info = f"Chat ID: {chat.id}, Title: {chat.title}, Type: {chat_type}" - results.append(chat_info) - except: - results.append("Could not retrieve common groups.") - - if not results: - return f"No chats found with {contact_name} (ID: {contact_id})." - - return f"Chats with {contact_name} (ID: {contact_id}):\n" + "\n".join(results) - except Exception as e: - return log_and_format_error("get_contact_chats", e, contact_id=contact_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Get Last Interaction", openWorldHint=True, readOnlyHint=True - ) -) -@validate_id("contact_id") -async def get_last_interaction(contact_id: Union[int, str]) -> str: - """ - Get the most recent message with a contact. - - Args: - contact_id: The ID or username of the contact. - """ - try: - # Get contact info - contact = await client.get_entity(contact_id) - if not isinstance(contact, User): - return f"ID {contact_id} is not a user/contact." - - contact_name = ( - f"{getattr(contact, 'first_name', '')} {getattr(contact, 'last_name', '')}".strip() - ) - - # Get the last few messages - messages = await client.get_messages(contact, limit=5) - - if not messages: - return f"No messages found with {contact_name} (ID: {contact_id})." - - results = [f"Last interactions with {contact_name} (ID: {contact_id}):"] - - for msg in messages: - sender = "You" if msg.out else contact_name - message_text = msg.message or "[Media/No text]" - results.append(f"Date: {msg.date}, From: {sender}, Message: {message_text}") - - return "\n".join(results) - except Exception as e: - return log_and_format_error("get_last_interaction", e, contact_id=contact_id) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get Message Context", openWorldHint=True, readOnlyHint=True) -) -@validate_id("chat_id") -async def get_message_context( - chat_id: Union[int, str], message_id: int, context_size: int = 3 -) -> str: - """ - Retrieve context around a specific message. - - Args: - chat_id: The ID or username of the chat. - message_id: The ID of the central message. - context_size: Number of messages before and after to include. - """ - try: - chat = await client.get_entity(chat_id) - # Get messages around the specified message - messages_before = await client.get_messages(chat, limit=context_size, max_id=message_id) - central_message = await client.get_messages(chat, ids=message_id) - # Fix: get_messages(ids=...) returns a single Message, not a list - if central_message is not None and not isinstance(central_message, list): - central_message = [central_message] - elif central_message is None: - central_message = [] - messages_after = await client.get_messages( - chat, limit=context_size, min_id=message_id, reverse=True - ) - if not central_message: - return f"Message with ID {message_id} not found in chat {chat_id}." - # Combine messages in chronological order - all_messages = list(messages_before) + list(central_message) + list(messages_after) - all_messages.sort(key=lambda m: m.id) - results = [f"Context for message {message_id} in chat {chat_id}:"] - for msg in all_messages: - sender_name = get_sender_name(msg) - highlight = " [THIS MESSAGE]" if msg.id == message_id else "" - - # Check if this message is a reply and get the replied message - reply_content = "" - if msg.reply_to and msg.reply_to.reply_to_msg_id: - try: - replied_msg = await client.get_messages(chat, ids=msg.reply_to.reply_to_msg_id) - if replied_msg: - replied_sender = "Unknown" - if replied_msg.sender: - replied_sender = getattr( - replied_msg.sender, "first_name", "" - ) or getattr(replied_msg.sender, "title", "Unknown") - reply_content = f" | reply to {msg.reply_to.reply_to_msg_id}\n โ†’ Replied message: [{replied_sender}] {replied_msg.message or '[Media/No text]'}" - except Exception: - reply_content = ( - f" | reply to {msg.reply_to.reply_to_msg_id} (original message not found)" - ) - - results.append( - f"ID: {msg.id} | {sender_name} | {msg.date}{highlight}{reply_content}\n{msg.message or '[Media/No text]'}\n" - ) - return "\n".join(results) - except Exception as e: - return log_and_format_error( - "get_message_context", - e, - chat_id=chat_id, - message_id=message_id, - context_size=context_size, - ) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Add Contact", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -async def add_contact(phone: str, first_name: str, last_name: str = "") -> str: - """ - Add a new contact to your Telegram account. - Args: - phone: The phone number of the contact (with country code). - first_name: The contact's first name. - last_name: The contact's last name (optional). - """ - try: - # Try to import the required types first - from telethon.tl.types import InputPhoneContact - - result = await client( - functions.contacts.ImportContactsRequest( - contacts=[ - InputPhoneContact( - client_id=0, - phone=phone, - first_name=first_name, - last_name=last_name, - ) - ] - ) - ) - if result.imported: - return f"Contact {first_name} {last_name} added successfully." - else: - return f"Contact not added. Response: {str(result)}" - except (ImportError, AttributeError) as type_err: - # Try alternative approach using raw API - try: - result = await client( - functions.contacts.ImportContactsRequest( - contacts=[ - { - "client_id": 0, - "phone": phone, - "first_name": first_name, - "last_name": last_name, - } - ] - ) - ) - if hasattr(result, "imported") and result.imported: - return f"Contact {first_name} {last_name} added successfully (alt method)." - else: - return f"Contact not added. Alternative method response: {str(result)}" - except Exception as alt_e: - logger.exception(f"add_contact (alt method) failed (phone={phone})") - return log_and_format_error("add_contact", alt_e, phone=phone) - except Exception as e: - logger.exception(f"add_contact failed (phone={phone})") - return log_and_format_error("add_contact", e, phone=phone) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Delete Contact", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("user_id") -async def delete_contact(user_id: Union[int, str]) -> str: - """ - Delete a contact by user ID. - Args: - user_id: The Telegram user ID or username of the contact to delete. - """ - try: - user = await client.get_entity(user_id) - await client(functions.contacts.DeleteContactsRequest(id=[user])) - return f"Contact with user ID {user_id} deleted." - except Exception as e: - return log_and_format_error("delete_contact", e, user_id=user_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Block User", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("user_id") -async def block_user(user_id: Union[int, str]) -> str: - """ - Block a user by user ID. - Args: - user_id: The Telegram user ID or username to block. - """ - try: - user = await client.get_entity(user_id) - await client(functions.contacts.BlockRequest(id=user)) - return f"User {user_id} blocked." - except Exception as e: - return log_and_format_error("block_user", e, user_id=user_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Unblock User", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("user_id") -async def unblock_user(user_id: Union[int, str]) -> str: - """ - Unblock a user by user ID. - Args: - user_id: The Telegram user ID or username to unblock. - """ - try: - user = await client.get_entity(user_id) - await client(functions.contacts.UnblockRequest(id=user)) - return f"User {user_id} unblocked." - except Exception as e: - return log_and_format_error("unblock_user", e, user_id=user_id) - - -@mcp.tool(annotations=ToolAnnotations(title="Get Me", openWorldHint=True, readOnlyHint=True)) -async def get_me() -> str: - """ - Get your own user information. - """ - try: - me = await client.get_me() - return json.dumps(format_entity(me), indent=2) - except Exception as e: - return log_and_format_error("get_me", e) - - -@mcp.tool( - annotations=ToolAnnotations(title="Create Group", openWorldHint=True, destructiveHint=True) -) -@validate_id("user_ids") -async def create_group(title: str, user_ids: List[Union[int, str]]) -> str: - """ - Create a new group or supergroup and add users. - - Args: - title: Title for the new group - user_ids: List of user IDs or usernames to add to the group - """ - try: - # Convert user IDs to entities - users = [] - for user_id in user_ids: - try: - user = await client.get_entity(user_id) - users.append(user) - except Exception as e: - logger.error(f"Failed to get entity for user ID {user_id}: {e}") - return f"Error: Could not find user with ID {user_id}" - - if not users: - return "Error: No valid users provided" - - # Create the group with the users - try: - # Create a new chat with selected users - result = await client(functions.messages.CreateChatRequest(users=users, title=title)) - - # Check what type of response we got - if hasattr(result, "chats") and result.chats: - created_chat = result.chats[0] - return f"Group created with ID: {created_chat.id}" - elif hasattr(result, "chat") and result.chat: - return f"Group created with ID: {result.chat.id}" - elif hasattr(result, "chat_id"): - return f"Group created with ID: {result.chat_id}" - else: - # If we can't determine the chat ID directly from the result - # Try to find it in recent dialogs - await asyncio.sleep(1) # Give Telegram a moment to register the new group - dialogs = await client.get_dialogs(limit=5) # Get recent dialogs - for dialog in dialogs: - if dialog.title == title: - return f"Group created with ID: {dialog.id}" - - # If we still can't find it, at least return success - return f"Group created successfully. Please check your recent chats for '{title}'." - - except Exception as create_err: - if "PEER_FLOOD" in str(create_err): - return "Error: Cannot create group due to Telegram limits. Try again later." - else: - raise # Let the outer exception handler catch it - except Exception as e: - logger.exception(f"create_group failed (title={title}, user_ids={user_ids})") - return log_and_format_error("create_group", e, title=title, user_ids=user_ids) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Invite To Group", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("group_id", "user_ids") -async def invite_to_group(group_id: Union[int, str], user_ids: List[Union[int, str]]) -> str: - """ - Invite users to a group or channel. - - Args: - group_id: The ID or username of the group/channel. - user_ids: List of user IDs or usernames to invite. - """ - try: - entity = await client.get_entity(group_id) - users_to_add = [] - - for user_id in user_ids: - try: - user = await client.get_entity(user_id) - users_to_add.append(user) - except ValueError as e: - return f"Error: User with ID {user_id} could not be found. {e}" - - try: - result = await client( - functions.channels.InviteToChannelRequest(channel=entity, users=users_to_add) - ) - - invited_count = 0 - if hasattr(result, "users") and result.users: - invited_count = len(result.users) - elif hasattr(result, "count"): - invited_count = result.count - - return f"Successfully invited {invited_count} users to {entity.title}" - except telethon.errors.rpcerrorlist.UserNotMutualContactError: - return "Error: Cannot invite users who are not mutual contacts. Please ensure the users are in your contacts and have added you back." - except telethon.errors.rpcerrorlist.UserPrivacyRestrictedError: - return ( - "Error: One or more users have privacy settings that prevent you from adding them." - ) - except Exception as e: - return log_and_format_error("invite_to_group", e, group_id=group_id, user_ids=user_ids) - - except Exception as e: - logger.error( - f"telegram_mcp invite_to_group failed (group_id={group_id}, user_ids={user_ids})", - exc_info=True, - ) - return log_and_format_error("invite_to_group", e, group_id=group_id, user_ids=user_ids) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Leave Chat", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def leave_chat(chat_id: Union[int, str]) -> str: - """ - Leave a group or channel by chat ID. - - Args: - chat_id: The chat ID or username to leave. - """ - try: - entity = await client.get_entity(chat_id) - - # Check the entity type carefully - if isinstance(entity, Channel): - # Handle both channels and supergroups (which are also channels in Telegram) - try: - await client(functions.channels.LeaveChannelRequest(channel=entity)) - chat_name = getattr(entity, "title", str(chat_id)) - return f"Left channel/supergroup {chat_name} (ID: {chat_id})." - except Exception as chan_err: - return log_and_format_error("leave_chat", chan_err, chat_id=chat_id) - - elif isinstance(entity, Chat): - # Traditional basic groups (not supergroups) - try: - # First try with InputPeerUser - me = await client.get_me(input_peer=True) - await client( - functions.messages.DeleteChatUserRequest( - chat_id=entity.id, - user_id=me, # Use the entity ID directly - ) - ) - chat_name = getattr(entity, "title", str(chat_id)) - return f"Left basic group {chat_name} (ID: {chat_id})." - except Exception as chat_err: - # If the above fails, try the second approach - logger.warning( - f"First leave attempt failed: {chat_err}, trying alternative method" - ) - - try: - # Alternative approach - sometimes this works better - me_full = await client.get_me() - await client( - functions.messages.DeleteChatUserRequest( - chat_id=entity.id, user_id=me_full.id - ) - ) - chat_name = getattr(entity, "title", str(chat_id)) - return f"Left basic group {chat_name} (ID: {chat_id})." - except Exception as alt_err: - return log_and_format_error("leave_chat", alt_err, chat_id=chat_id) - else: - # Cannot leave a user chat this way - entity_type = type(entity).__name__ - return log_and_format_error( - "leave_chat", - Exception( - f"Cannot leave chat ID {chat_id} of type {entity_type}. This function is for groups and channels only." - ), - chat_id=chat_id, - ) - - except Exception as e: - logger.exception(f"leave_chat failed (chat_id={chat_id})") - - # Provide helpful hint for common errors - error_str = str(e).lower() - if "invalid" in error_str and "chat" in error_str: - return log_and_format_error( - "leave_chat", - Exception( - f"Error leaving chat: This appears to be a channel/supergroup. Please check the chat ID and try again." - ), - chat_id=chat_id, - ) - - return log_and_format_error("leave_chat", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get Participants", openWorldHint=True, readOnlyHint=True) -) -@validate_id("chat_id") -async def get_participants(chat_id: Union[int, str]) -> str: - """ - List all participants in a group or channel. - Args: - chat_id: The group or channel ID or username. - """ - try: - participants = await client.get_participants(chat_id) - lines = [ - f"ID: {p.id}, Name: {getattr(p, 'first_name', '')} {getattr(p, 'last_name', '')}" - for p in participants - ] - return "\n".join(lines) - except Exception as e: - return log_and_format_error("get_participants", e, chat_id=chat_id) - - -@mcp.tool(annotations=ToolAnnotations(title="Send File", openWorldHint=True, destructiveHint=True)) -@validate_id("chat_id") -async def send_file(chat_id: Union[int, str], file_path: str, caption: str = None) -> str: - """ - Send a file to a chat. - Args: - chat_id: The chat ID or username. - file_path: Absolute path to the file to send (must exist and be readable). - caption: Optional caption for the file. - """ - try: - if not os.path.isfile(file_path): - return f"File not found: {file_path}" - if not os.access(file_path, os.R_OK): - return f"File is not readable: {file_path}" - entity = await client.get_entity(chat_id) - await client.send_file(entity, file_path, caption=caption) - return f"File sent to chat {chat_id}." - except Exception as e: - return log_and_format_error( - "send_file", e, chat_id=chat_id, file_path=file_path, caption=caption - ) - - -@mcp.tool( - annotations=ToolAnnotations(title="Download Media", openWorldHint=True, readOnlyHint=True) -) -@validate_id("chat_id") -async def download_media(chat_id: Union[int, str], message_id: int, file_path: str) -> str: - """ - Download media from a message in a chat. - Args: - chat_id: The chat ID or username. - message_id: The message ID containing the media. - file_path: Absolute path to save the downloaded file (must be writable). - """ - try: - entity = await client.get_entity(chat_id) - msg = await client.get_messages(entity, ids=message_id) - if not msg or not msg.media: - return "No media found in the specified message." - # Check if directory is writable - dir_path = os.path.dirname(file_path) or "." - if not os.access(dir_path, os.W_OK): - return f"Directory not writable: {dir_path}" - await client.download_media(msg, file=file_path) - if not os.path.isfile(file_path): - return f"Download failed: file not created at {file_path}" - return f"Media downloaded to {file_path}." - except Exception as e: - return log_and_format_error( - "download_media", - e, - chat_id=chat_id, - message_id=message_id, - file_path=file_path, - ) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Update Profile", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -async def update_profile(first_name: str = None, last_name: str = None, about: str = None) -> str: - """ - Update your profile information (name, bio). - """ - try: - await client( - functions.account.UpdateProfileRequest( - first_name=first_name, last_name=last_name, about=about - ) - ) - return "Profile updated." - except Exception as e: - return log_and_format_error( - "update_profile", e, first_name=first_name, last_name=last_name, about=about - ) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Set Profile Photo", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -async def set_profile_photo(file_path: str) -> str: - """ - Set a new profile photo. - """ - try: - await client( - functions.photos.UploadProfilePhotoRequest(file=await client.upload_file(file_path)) - ) - return "Profile photo updated." - except Exception as e: - return log_and_format_error("set_profile_photo", e, file_path=file_path) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Delete Profile Photo", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -async def delete_profile_photo() -> str: - """ - Delete your current profile photo. - """ - try: - photos = await client( - functions.photos.GetUserPhotosRequest(user_id="me", offset=0, max_id=0, limit=1) - ) - if not photos.photos: - return "No profile photo to delete." - await client(functions.photos.DeletePhotosRequest(id=[photos.photos[0].id])) - return "Profile photo deleted." - except Exception as e: - return log_and_format_error("delete_profile_photo", e) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Get Privacy Settings", openWorldHint=True, readOnlyHint=True - ) -) -async def get_privacy_settings() -> str: - """ - Get your privacy settings for last seen status. - """ - try: - # Import needed types directly - from telethon.tl.types import InputPrivacyKeyStatusTimestamp - - try: - settings = await client( - functions.account.GetPrivacyRequest(key=InputPrivacyKeyStatusTimestamp()) - ) - return str(settings) - except TypeError as e: - if "TLObject was expected" in str(e): - return "Error: Privacy settings API call failed due to type mismatch. This is likely a version compatibility issue with Telethon." - else: - raise - except Exception as e: - logger.exception("get_privacy_settings failed") - return log_and_format_error("get_privacy_settings", e) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Set Privacy Settings", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("allow_users", "disallow_users") -async def set_privacy_settings( - key: str, - allow_users: Optional[List[Union[int, str]]] = None, - disallow_users: Optional[List[Union[int, str]]] = None, -) -> str: - """ - Set privacy settings (e.g., last seen, phone, etc.). - - Args: - key: The privacy setting to modify ('status' for last seen, 'phone', 'profile_photo', etc.) - allow_users: List of user IDs or usernames to allow - disallow_users: List of user IDs or usernames to disallow - """ - try: - # Import needed types - from telethon.tl.types import ( - InputPrivacyKeyStatusTimestamp, - InputPrivacyKeyPhoneNumber, - InputPrivacyKeyProfilePhoto, - InputPrivacyValueAllowUsers, - InputPrivacyValueDisallowUsers, - InputPrivacyValueAllowAll, - InputPrivacyValueDisallowAll, - ) - - # Map the simplified keys to their corresponding input types - key_mapping = { - "status": InputPrivacyKeyStatusTimestamp, - "phone": InputPrivacyKeyPhoneNumber, - "profile_photo": InputPrivacyKeyProfilePhoto, - } - - # Get the appropriate key class - if key not in key_mapping: - return f"Error: Unsupported privacy key '{key}'. Supported keys: {', '.join(key_mapping.keys())}" - - privacy_key = key_mapping[key]() - - # Prepare the rules - rules = [] - - # Process allow rules - if allow_users is None or len(allow_users) == 0: - # If no specific users to allow, allow everyone by default - rules.append(InputPrivacyValueAllowAll()) - else: - # Convert user IDs to InputUser entities - try: - allow_entities = [] - for user_id in allow_users: - try: - user = await client.get_entity(user_id) - allow_entities.append(user) - except Exception as user_err: - logger.warning(f"Could not get entity for user ID {user_id}: {user_err}") - - if allow_entities: - rules.append(InputPrivacyValueAllowUsers(users=allow_entities)) - except Exception as allow_err: - logger.error(f"Error processing allowed users: {allow_err}") - return log_and_format_error("set_privacy_settings", allow_err, key=key) - - # Process disallow rules - if disallow_users and len(disallow_users) > 0: - try: - disallow_entities = [] - for user_id in disallow_users: - try: - user = await client.get_entity(user_id) - disallow_entities.append(user) - except Exception as user_err: - logger.warning(f"Could not get entity for user ID {user_id}: {user_err}") - - if disallow_entities: - rules.append(InputPrivacyValueDisallowUsers(users=disallow_entities)) - except Exception as disallow_err: - logger.error(f"Error processing disallowed users: {disallow_err}") - return log_and_format_error("set_privacy_settings", disallow_err, key=key) - - # Apply the privacy settings - try: - result = await client( - functions.account.SetPrivacyRequest(key=privacy_key, rules=rules) - ) - return f"Privacy settings for {key} updated successfully." - except TypeError as type_err: - if "TLObject was expected" in str(type_err): - return "Error: Privacy settings API call failed due to type mismatch. This is likely a version compatibility issue with Telethon." - else: - raise - except Exception as e: - logger.exception(f"set_privacy_settings failed (key={key})") - return log_and_format_error("set_privacy_settings", e, key=key) - - -@mcp.tool( - annotations=ToolAnnotations(title="Import Contacts", openWorldHint=True, destructiveHint=True) -) -async def import_contacts(contacts: list) -> str: - """ - Import a list of contacts. Each contact should be a dict with phone, first_name, last_name. - """ - try: - input_contacts = [ - functions.contacts.InputPhoneContact( - client_id=i, - phone=c["phone"], - first_name=c["first_name"], - last_name=c.get("last_name", ""), - ) - for i, c in enumerate(contacts) - ] - result = await client(functions.contacts.ImportContactsRequest(contacts=input_contacts)) - return f"Imported {len(result.imported)} contacts." - except Exception as e: - return log_and_format_error("import_contacts", e, contacts=contacts) - - -@mcp.tool( - annotations=ToolAnnotations(title="Export Contacts", openWorldHint=True, readOnlyHint=True) -) -async def export_contacts() -> str: - """ - Export all contacts as a JSON string. - """ - try: - result = await client(functions.contacts.GetContactsRequest(hash=0)) - users = result.users - return json.dumps([format_entity(u) for u in users], indent=2) - except Exception as e: - return log_and_format_error("export_contacts", e) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get Blocked Users", openWorldHint=True, readOnlyHint=True) -) -async def get_blocked_users() -> str: - """ - Get a list of blocked users. - """ - try: - result = await client(functions.contacts.GetBlockedRequest(offset=0, limit=100)) - return json.dumps([format_entity(u) for u in result.users], indent=2) - except Exception as e: - return log_and_format_error("get_blocked_users", e) - - -@mcp.tool( - annotations=ToolAnnotations(title="Create Channel", openWorldHint=True, destructiveHint=True) -) -async def create_channel(title: str, about: str = "", megagroup: bool = False) -> str: - """ - Create a new channel or supergroup. - """ - try: - result = await client( - functions.channels.CreateChannelRequest(title=title, about=about, megagroup=megagroup) - ) - return f"Channel '{title}' created with ID: {result.chats[0].id}" - except Exception as e: - return log_and_format_error( - "create_channel", e, title=title, about=about, megagroup=megagroup - ) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Edit Chat Title", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def edit_chat_title(chat_id: Union[int, str], title: str) -> str: - """ - Edit the title of a chat, group, or channel. - """ - try: - entity = await client.get_entity(chat_id) - if isinstance(entity, Channel): - await client(functions.channels.EditTitleRequest(channel=entity, title=title)) - elif isinstance(entity, Chat): - await client(functions.messages.EditChatTitleRequest(chat_id=chat_id, title=title)) - else: - return f"Cannot edit title for this entity type ({type(entity)})." - return f"Chat {chat_id} title updated to '{title}'." - except Exception as e: - logger.exception(f"edit_chat_title failed (chat_id={chat_id}, title='{title}')") - return log_and_format_error("edit_chat_title", e, chat_id=chat_id, title=title) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Edit Chat Photo", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def edit_chat_photo(chat_id: Union[int, str], file_path: str) -> str: - """ - Edit the photo of a chat, group, or channel. Requires a file path to an image. - """ - try: - if not os.path.isfile(file_path): - return f"Photo file not found: {file_path}" - if not os.access(file_path, os.R_OK): - return f"Photo file not readable: {file_path}" - - entity = await client.get_entity(chat_id) - uploaded_file = await client.upload_file(file_path) - - if isinstance(entity, Channel): - # For channels/supergroups, use EditPhotoRequest with InputChatUploadedPhoto - input_photo = InputChatUploadedPhoto(file=uploaded_file) - await client(functions.channels.EditPhotoRequest(channel=entity, photo=input_photo)) - elif isinstance(entity, Chat): - # For basic groups, use EditChatPhotoRequest with InputChatUploadedPhoto - input_photo = InputChatUploadedPhoto(file=uploaded_file) - await client( - functions.messages.EditChatPhotoRequest(chat_id=chat_id, photo=input_photo) - ) - else: - return f"Cannot edit photo for this entity type ({type(entity)})." - - return f"Chat {chat_id} photo updated." - except Exception as e: - logger.exception(f"edit_chat_photo failed (chat_id={chat_id}, file_path='{file_path}')") - return log_and_format_error("edit_chat_photo", e, chat_id=chat_id, file_path=file_path) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Delete Chat Photo", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def delete_chat_photo(chat_id: Union[int, str]) -> str: - """ - Delete the photo of a chat, group, or channel. - """ - try: - entity = await client.get_entity(chat_id) - if isinstance(entity, Channel): - # Use InputChatPhotoEmpty for channels/supergroups - await client( - functions.channels.EditPhotoRequest(channel=entity, photo=InputChatPhotoEmpty()) - ) - elif isinstance(entity, Chat): - # Use None (or InputChatPhotoEmpty) for basic groups - await client( - functions.messages.EditChatPhotoRequest( - chat_id=chat_id, photo=InputChatPhotoEmpty() - ) - ) - else: - return f"Cannot delete photo for this entity type ({type(entity)})." - - return f"Chat {chat_id} photo deleted." - except Exception as e: - logger.exception(f"delete_chat_photo failed (chat_id={chat_id})") - return log_and_format_error("delete_chat_photo", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Promote Admin", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("group_id", "user_id") -async def promote_admin( - group_id: Union[int, str], user_id: Union[int, str], rights: dict = None -) -> str: - """ - Promote a user to admin in a group/channel. - - Args: - group_id: ID or username of the group/channel - user_id: User ID or username to promote - rights: Admin rights to give (optional) - """ - try: - chat = await client.get_entity(group_id) - user = await client.get_entity(user_id) - - # Set default admin rights if not provided - if not rights: - rights = { - "change_info": True, - "post_messages": True, - "edit_messages": True, - "delete_messages": True, - "ban_users": True, - "invite_users": True, - "pin_messages": True, - "add_admins": False, - "anonymous": False, - "manage_call": True, - "other": True, - } - - admin_rights = ChatAdminRights( - change_info=rights.get("change_info", True), - post_messages=rights.get("post_messages", True), - edit_messages=rights.get("edit_messages", True), - delete_messages=rights.get("delete_messages", True), - ban_users=rights.get("ban_users", True), - invite_users=rights.get("invite_users", True), - pin_messages=rights.get("pin_messages", True), - add_admins=rights.get("add_admins", False), - anonymous=rights.get("anonymous", False), - manage_call=rights.get("manage_call", True), - other=rights.get("other", True), - ) - - try: - result = await client( - functions.channels.EditAdminRequest( - channel=chat, user_id=user, admin_rights=admin_rights, rank="Admin" - ) - ) - return f"Successfully promoted user {user_id} to admin in {chat.title}" - except telethon.errors.rpcerrorlist.UserNotMutualContactError: - return "Error: Cannot promote users who are not mutual contacts. Please ensure the user is in your contacts and has added you back." - except Exception as e: - return log_and_format_error("promote_admin", e, group_id=group_id, user_id=user_id) - - except Exception as e: - logger.error( - f"telegram_mcp promote_admin failed (group_id={group_id}, user_id={user_id})", - exc_info=True, - ) - return log_and_format_error("promote_admin", e, group_id=group_id, user_id=user_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Demote Admin", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("group_id", "user_id") -async def demote_admin(group_id: Union[int, str], user_id: Union[int, str]) -> str: - """ - Demote a user from admin in a group/channel. - - Args: - group_id: ID or username of the group/channel - user_id: User ID or username to demote - """ - try: - chat = await client.get_entity(group_id) - user = await client.get_entity(user_id) - - # Create empty admin rights (regular user) - admin_rights = ChatAdminRights( - change_info=False, - post_messages=False, - edit_messages=False, - delete_messages=False, - ban_users=False, - invite_users=False, - pin_messages=False, - add_admins=False, - anonymous=False, - manage_call=False, - other=False, - ) - - try: - result = await client( - functions.channels.EditAdminRequest( - channel=chat, user_id=user, admin_rights=admin_rights, rank="" - ) - ) - return f"Successfully demoted user {user_id} from admin in {chat.title}" - except telethon.errors.rpcerrorlist.UserNotMutualContactError: - return "Error: Cannot modify admin status of users who are not mutual contacts. Please ensure the user is in your contacts and has added you back." - except Exception as e: - return log_and_format_error("demote_admin", e, group_id=group_id, user_id=user_id) - - except Exception as e: - logger.error( - f"telegram_mcp demote_admin failed (group_id={group_id}, user_id={user_id})", - exc_info=True, - ) - return log_and_format_error("demote_admin", e, group_id=group_id, user_id=user_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Ban User", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id", "user_id") -async def ban_user(chat_id: Union[int, str], user_id: Union[int, str]) -> str: - """ - Ban a user from a group or channel. - - Args: - chat_id: ID or username of the group/channel - user_id: User ID or username to ban - """ - try: - chat = await client.get_entity(chat_id) - user = await client.get_entity(user_id) - - # Create banned rights (all restrictions enabled) - banned_rights = ChatBannedRights( - until_date=None, # Ban forever - view_messages=True, - send_messages=True, - send_media=True, - send_stickers=True, - send_gifs=True, - send_games=True, - send_inline=True, - embed_links=True, - send_polls=True, - change_info=True, - invite_users=True, - pin_messages=True, - ) - - try: - await client( - functions.channels.EditBannedRequest( - channel=chat, participant=user, banned_rights=banned_rights - ) - ) - return f"User {user_id} banned from chat {chat.title} (ID: {chat_id})." - except telethon.errors.rpcerrorlist.UserNotMutualContactError: - return "Error: Cannot ban users who are not mutual contacts. Please ensure the user is in your contacts and has added you back." - except Exception as e: - return log_and_format_error("ban_user", e, chat_id=chat_id, user_id=user_id) - except Exception as e: - logger.exception(f"ban_user failed (chat_id={chat_id}, user_id={user_id})") - return log_and_format_error("ban_user", e, chat_id=chat_id, user_id=user_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Unban User", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id", "user_id") -async def unban_user(chat_id: Union[int, str], user_id: Union[int, str]) -> str: - """ - Unban a user from a group or channel. - - Args: - chat_id: ID or username of the group/channel - user_id: User ID or username to unban - """ - try: - chat = await client.get_entity(chat_id) - user = await client.get_entity(user_id) - - # Create unbanned rights (no restrictions) - unbanned_rights = ChatBannedRights( - until_date=None, - view_messages=False, - send_messages=False, - send_media=False, - send_stickers=False, - send_gifs=False, - send_games=False, - send_inline=False, - embed_links=False, - send_polls=False, - change_info=False, - invite_users=False, - pin_messages=False, - ) - - try: - await client( - functions.channels.EditBannedRequest( - channel=chat, participant=user, banned_rights=unbanned_rights - ) - ) - return f"User {user_id} unbanned from chat {chat.title} (ID: {chat_id})." - except telethon.errors.rpcerrorlist.UserNotMutualContactError: - return "Error: Cannot modify status of users who are not mutual contacts. Please ensure the user is in your contacts and has added you back." - except Exception as e: - return log_and_format_error("unban_user", e, chat_id=chat_id, user_id=user_id) - except Exception as e: - logger.exception(f"unban_user failed (chat_id={chat_id}, user_id={user_id})") - return log_and_format_error("unban_user", e, chat_id=chat_id, user_id=user_id) - - -@mcp.tool(annotations=ToolAnnotations(title="Get Admins", openWorldHint=True, readOnlyHint=True)) -@validate_id("chat_id") -async def get_admins(chat_id: Union[int, str]) -> str: - """ - Get all admins in a group or channel. - """ - try: - # Fix: Use the correct filter type ChannelParticipantsAdmins - participants = await client.get_participants(chat_id, filter=ChannelParticipantsAdmins()) - lines = [ - f"ID: {p.id}, Name: {getattr(p, 'first_name', '')} {getattr(p, 'last_name', '')}".strip() - for p in participants - ] - return "\n".join(lines) if lines else "No admins found." - except Exception as e: - logger.exception(f"get_admins failed (chat_id={chat_id})") - return log_and_format_error("get_admins", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get Banned Users", openWorldHint=True, readOnlyHint=True) -) -@validate_id("chat_id") -async def get_banned_users(chat_id: Union[int, str]) -> str: - """ - Get all banned users in a group or channel. - """ - try: - # Fix: Use the correct filter type ChannelParticipantsKicked - participants = await client.get_participants( - chat_id, filter=ChannelParticipantsKicked(q="") - ) - lines = [ - f"ID: {p.id}, Name: {getattr(p, 'first_name', '')} {getattr(p, 'last_name', '')}".strip() - for p in participants - ] - return "\n".join(lines) if lines else "No banned users found." - except Exception as e: - logger.exception(f"get_banned_users failed (chat_id={chat_id})") - return log_and_format_error("get_banned_users", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get Invite Link", openWorldHint=True, readOnlyHint=True) -) -@validate_id("chat_id") -async def get_invite_link(chat_id: Union[int, str]) -> str: - """ - Get the invite link for a group or channel. - """ - try: - entity = await client.get_entity(chat_id) - - # Try using ExportChatInviteRequest first - try: - from telethon.tl import functions - - result = await client(functions.messages.ExportChatInviteRequest(peer=entity)) - return result.link - except AttributeError: - # If the function doesn't exist in the current Telethon version - logger.warning("ExportChatInviteRequest not available, using alternative method") - except Exception as e1: - # If that fails, log and try alternative approach - logger.warning(f"ExportChatInviteRequest failed: {e1}") - - # Alternative approach using client.export_chat_invite_link - try: - invite_link = await client.export_chat_invite_link(entity) - return invite_link - except Exception as e2: - logger.warning(f"export_chat_invite_link failed: {e2}") - - # Last resort: Try directly fetching chat info - try: - if isinstance(entity, (Chat, Channel)): - full_chat = await client(functions.messages.GetFullChatRequest(chat_id=entity.id)) - if hasattr(full_chat, "full_chat") and hasattr(full_chat.full_chat, "invite_link"): - return full_chat.full_chat.invite_link or "No invite link available." - except Exception as e3: - logger.warning(f"GetFullChatRequest failed: {e3}") - - return "Could not retrieve invite link for this chat." - except Exception as e: - logger.exception(f"get_invite_link failed (chat_id={chat_id})") - return log_and_format_error("get_invite_link", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Join Chat By Link", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -async def join_chat_by_link(link: str) -> str: - """ - Join a chat by invite link. - """ - try: - # Extract the hash from the invite link - if "/" in link: - hash_part = link.split("/")[-1] - if hash_part.startswith("+"): - hash_part = hash_part[1:] # Remove the '+' if present - else: - hash_part = link - - # Try checking the invite before joining - try: - # Try to check invite info first (will often fail if not a member) - invite_info = await client(functions.messages.CheckChatInviteRequest(hash=hash_part)) - if hasattr(invite_info, "chat") and invite_info.chat: - # If we got chat info, we're already a member - chat_title = getattr(invite_info.chat, "title", "Unknown Chat") - return f"You are already a member of this chat: {chat_title}" - except Exception: - # This often fails if not a member - just continue - pass - - # Join the chat using the hash - result = await client(functions.messages.ImportChatInviteRequest(hash=hash_part)) - if result and hasattr(result, "chats") and result.chats: - chat_title = getattr(result.chats[0], "title", "Unknown Chat") - return f"Successfully joined chat: {chat_title}" - return f"Joined chat via invite hash." - except Exception as e: - err_str = str(e).lower() - if "expired" in err_str: - return "The invite hash has expired and is no longer valid." - elif "invalid" in err_str: - return "The invite hash is invalid or malformed." - elif "already" in err_str and "participant" in err_str: - return "You are already a member of this chat." - logger.exception(f"join_chat_by_link failed (link={link})") - return f"Error joining chat: {e}" - - -@mcp.tool( - annotations=ToolAnnotations(title="Export Chat Invite", openWorldHint=True, readOnlyHint=True) -) -@validate_id("chat_id") -async def export_chat_invite(chat_id: Union[int, str]) -> str: - """ - Export a chat invite link. - """ - try: - entity = await client.get_entity(chat_id) - - # Try using ExportChatInviteRequest first - try: - from telethon.tl import functions - - result = await client(functions.messages.ExportChatInviteRequest(peer=entity)) - return result.link - except AttributeError: - # If the function doesn't exist in the current Telethon version - logger.warning("ExportChatInviteRequest not available, using alternative method") - except Exception as e1: - # If that fails, log and try alternative approach - logger.warning(f"ExportChatInviteRequest failed: {e1}") - - # Alternative approach using client.export_chat_invite_link - try: - invite_link = await client.export_chat_invite_link(entity) - return invite_link - except Exception as e2: - logger.warning(f"export_chat_invite_link failed: {e2}") - return log_and_format_error("export_chat_invite", e2, chat_id=chat_id) - - except Exception as e: - logger.exception(f"export_chat_invite failed (chat_id={chat_id})") - return log_and_format_error("export_chat_invite", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Import Chat Invite", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -async def import_chat_invite(hash: str) -> str: - """ - Import a chat invite by hash. - """ - try: - # Remove any prefixes like '+' if present - if hash.startswith("+"): - hash = hash[1:] - - # Try checking the invite before joining - try: - from telethon.errors import ( - InviteHashExpiredError, - InviteHashInvalidError, - UserAlreadyParticipantError, - ChatAdminRequiredError, - UsersTooMuchError, - ) - - # Try to check invite info first (will often fail if not a member) - invite_info = await client(functions.messages.CheckChatInviteRequest(hash=hash)) - if hasattr(invite_info, "chat") and invite_info.chat: - # If we got chat info, we're already a member - chat_title = getattr(invite_info.chat, "title", "Unknown Chat") - return f"You are already a member of this chat: {chat_title}" - except Exception as check_err: - # This often fails if not a member - just continue - pass - - # Join the chat using the hash - try: - result = await client(functions.messages.ImportChatInviteRequest(hash=hash)) - if result and hasattr(result, "chats") and result.chats: - chat_title = getattr(result.chats[0], "title", "Unknown Chat") - return f"Successfully joined chat: {chat_title}" - return f"Joined chat via invite hash." - except Exception as join_err: - err_str = str(join_err).lower() - if "expired" in err_str: - return "The invite hash has expired and is no longer valid." - elif "invalid" in err_str: - return "The invite hash is invalid or malformed." - elif "already" in err_str and "participant" in err_str: - return "You are already a member of this chat." - elif "admin" in err_str: - return "Cannot join this chat - requires admin approval." - elif "too much" in err_str or "too many" in err_str: - return "Cannot join this chat - it has reached maximum number of participants." - else: - raise # Re-raise to be caught by the outer exception handler - - except Exception as e: - logger.exception(f"import_chat_invite failed (hash={hash})") - return log_and_format_error("import_chat_invite", e, hash=hash) - - -@mcp.tool( - annotations=ToolAnnotations(title="Send Voice", openWorldHint=True, destructiveHint=True) -) -@validate_id("chat_id") -async def send_voice(chat_id: Union[int, str], file_path: str) -> str: - """ - Send a voice message to a chat. File must be an OGG/OPUS voice note. - - Args: - chat_id: The chat ID or username. - file_path: Absolute path to the OGG/OPUS file. - """ - try: - if not os.path.isfile(file_path): - return f"File not found: {file_path}" - if not os.access(file_path, os.R_OK): - return f"File is not readable: {file_path}" - - mime, _ = mimetypes.guess_type(file_path) - if not ( - mime - and ( - mime == "audio/ogg" - or file_path.lower().endswith(".ogg") - or file_path.lower().endswith(".opus") - ) - ): - return "Voice file must be .ogg or .opus format." - - entity = await client.get_entity(chat_id) - await client.send_file(entity, file_path, voice_note=True) - return f"Voice message sent to chat {chat_id}." - except Exception as e: - return log_and_format_error("send_voice", e, chat_id=chat_id, file_path=file_path) - - -@mcp.tool( - annotations=ToolAnnotations(title="Forward Message", openWorldHint=True, destructiveHint=True) -) -@validate_id("from_chat_id", "to_chat_id") -async def forward_message( - from_chat_id: Union[int, str], message_id: int, to_chat_id: Union[int, str] -) -> str: - """ - Forward a message from one chat to another. - """ - try: - from_entity = await client.get_entity(from_chat_id) - to_entity = await client.get_entity(to_chat_id) - await client.forward_messages(to_entity, message_id, from_entity) - return f"Message {message_id} forwarded from {from_chat_id} to {to_chat_id}." - except Exception as e: - return log_and_format_error( - "forward_message", - e, - from_chat_id=from_chat_id, - message_id=message_id, - to_chat_id=to_chat_id, - ) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Edit Message", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def edit_message(chat_id: Union[int, str], message_id: int, new_text: str) -> str: - """ - Edit a message you sent. - """ - try: - entity = await client.get_entity(chat_id) - await client.edit_message(entity, message_id, new_text) - return f"Message {message_id} edited." - except Exception as e: - return log_and_format_error( - "edit_message", e, chat_id=chat_id, message_id=message_id, new_text=new_text - ) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Delete Message", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def delete_message(chat_id: Union[int, str], message_id: int) -> str: - """ - Delete a message by ID. - """ - try: - entity = await client.get_entity(chat_id) - await client.delete_messages(entity, message_id) - return f"Message {message_id} deleted." - except Exception as e: - return log_and_format_error("delete_message", e, chat_id=chat_id, message_id=message_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Pin Message", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def pin_message(chat_id: Union[int, str], message_id: int) -> str: - """ - Pin a message in a chat. - """ - try: - entity = await client.get_entity(chat_id) - await client.pin_message(entity, message_id) - return f"Message {message_id} pinned in chat {chat_id}." - except Exception as e: - return log_and_format_error("pin_message", e, chat_id=chat_id, message_id=message_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Unpin Message", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def unpin_message(chat_id: Union[int, str], message_id: int) -> str: - """ - Unpin a message in a chat. - """ - try: - entity = await client.get_entity(chat_id) - await client.unpin_message(entity, message_id) - return f"Message {message_id} unpinned in chat {chat_id}." - except Exception as e: - return log_and_format_error("unpin_message", e, chat_id=chat_id, message_id=message_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Mark As Read", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def mark_as_read(chat_id: Union[int, str]) -> str: - """ - Mark all messages as read in a chat. - """ - try: - entity = await client.get_entity(chat_id) - await client.send_read_acknowledge(entity) - return f"Marked all messages as read in chat {chat_id}." - except Exception as e: - return log_and_format_error("mark_as_read", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations(title="Reply To Message", openWorldHint=True, destructiveHint=True) -) -@validate_id("chat_id") -async def reply_to_message(chat_id: Union[int, str], message_id: int, text: str) -> str: - """ - Reply to a specific message in a chat. - """ - try: - entity = await client.get_entity(chat_id) - await client.send_message(entity, text, reply_to=message_id) - return f"Replied to message {message_id} in chat {chat_id}." - except Exception as e: - return log_and_format_error( - "reply_to_message", e, chat_id=chat_id, message_id=message_id, text=text - ) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get Media Info", openWorldHint=True, readOnlyHint=True) -) -@validate_id("chat_id") -async def get_media_info(chat_id: Union[int, str], message_id: int) -> str: - """ - Get info about media in a message. - - Args: - chat_id: The chat ID or username. - message_id: The message ID. - """ - try: - entity = await client.get_entity(chat_id) - msg = await client.get_messages(entity, ids=message_id) - - if not msg or not msg.media: - return "No media found in the specified message." - - return str(msg.media) - except Exception as e: - return log_and_format_error("get_media_info", e, chat_id=chat_id, message_id=message_id) - - -@mcp.tool( - annotations=ToolAnnotations(title="Search Public Chats", openWorldHint=True, readOnlyHint=True) -) -async def search_public_chats(query: str) -> str: - """ - Search for public chats, channels, or bots by username or title. - """ - try: - result = await client(functions.contacts.SearchRequest(q=query, limit=20)) - return json.dumps([format_entity(u) for u in result.users], indent=2) - except Exception as e: - return log_and_format_error("search_public_chats", e, query=query) - - -@mcp.tool( - annotations=ToolAnnotations(title="Search Messages", openWorldHint=True, readOnlyHint=True) -) -@validate_id("chat_id") -async def search_messages(chat_id: Union[int, str], query: str, limit: int = 20) -> str: - """ - Search for messages in a chat by text. - """ - try: - entity = await client.get_entity(chat_id) - messages = await client.get_messages(entity, limit=limit, search=query) - - lines = [] - for msg in messages: - sender_name = get_sender_name(msg) - reply_info = "" - if msg.reply_to and msg.reply_to.reply_to_msg_id: - reply_info = f" | reply to {msg.reply_to.reply_to_msg_id}" - lines.append( - f"ID: {msg.id} | {sender_name} | Date: {msg.date}{reply_info} | Message: {msg.message}" - ) - return "\n".join(lines) - except Exception as e: - return log_and_format_error( - "search_messages", e, chat_id=chat_id, query=query, limit=limit - ) - - -@mcp.tool( - annotations=ToolAnnotations(title="Resolve Username", openWorldHint=True, readOnlyHint=True) -) -async def resolve_username(username: str) -> str: - """ - Resolve a username to a user or chat ID. - """ - try: - result = await client(functions.contacts.ResolveUsernameRequest(username=username)) - return str(result) - except Exception as e: - return log_and_format_error("resolve_username", e, username=username) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Mute Chat", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def mute_chat(chat_id: Union[int, str]) -> str: - """ - Mute notifications for a chat. - """ - try: - from telethon.tl.types import InputPeerNotifySettings - - peer = await client.get_entity(chat_id) - await client( - functions.account.UpdateNotifySettingsRequest( - peer=peer, settings=InputPeerNotifySettings(mute_until=2**31 - 1) - ) - ) - return f"Chat {chat_id} muted." - except (ImportError, AttributeError) as type_err: - try: - # Alternative approach directly using raw API - peer = await client.get_input_entity(chat_id) - await client( - functions.account.UpdateNotifySettingsRequest( - peer=peer, - settings={ - "mute_until": 2**31 - 1, # Far future - "show_previews": False, - "silent": True, - }, - ) - ) - return f"Chat {chat_id} muted (using alternative method)." - except Exception as alt_e: - logger.exception(f"mute_chat (alt method) failed (chat_id={chat_id})") - return log_and_format_error("mute_chat", alt_e, chat_id=chat_id) - except Exception as e: - logger.exception(f"mute_chat failed (chat_id={chat_id})") - return log_and_format_error("mute_chat", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Unmute Chat", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def unmute_chat(chat_id: Union[int, str]) -> str: - """ - Unmute notifications for a chat. - """ - try: - from telethon.tl.types import InputPeerNotifySettings - - peer = await client.get_entity(chat_id) - await client( - functions.account.UpdateNotifySettingsRequest( - peer=peer, settings=InputPeerNotifySettings(mute_until=0) - ) - ) - return f"Chat {chat_id} unmuted." - except (ImportError, AttributeError) as type_err: - try: - # Alternative approach directly using raw API - peer = await client.get_input_entity(chat_id) - await client( - functions.account.UpdateNotifySettingsRequest( - peer=peer, - settings={ - "mute_until": 0, # Unmute (current time) - "show_previews": True, - "silent": False, - }, - ) - ) - return f"Chat {chat_id} unmuted (using alternative method)." - except Exception as alt_e: - logger.exception(f"unmute_chat (alt method) failed (chat_id={chat_id})") - return log_and_format_error("unmute_chat", alt_e, chat_id=chat_id) - except Exception as e: - logger.exception(f"unmute_chat failed (chat_id={chat_id})") - return log_and_format_error("unmute_chat", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Archive Chat", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def archive_chat(chat_id: Union[int, str]) -> str: - """ - Archive a chat. - """ - try: - await client( - functions.messages.ToggleDialogPinRequest( - peer=await client.get_entity(chat_id), pinned=True - ) - ) - return f"Chat {chat_id} archived." - except Exception as e: - return log_and_format_error("archive_chat", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Unarchive Chat", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def unarchive_chat(chat_id: Union[int, str]) -> str: - """ - Unarchive a chat. - """ - try: - await client( - functions.messages.ToggleDialogPinRequest( - peer=await client.get_entity(chat_id), pinned=False - ) - ) - return f"Chat {chat_id} unarchived." - except Exception as e: - return log_and_format_error("unarchive_chat", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get Sticker Sets", openWorldHint=True, readOnlyHint=True) -) -async def get_sticker_sets() -> str: - """ - Get all sticker sets. - """ - try: - result = await client(functions.messages.GetAllStickersRequest(hash=0)) - return json.dumps([s.title for s in result.sets], indent=2) - except Exception as e: - return log_and_format_error("get_sticker_sets", e) - - -@mcp.tool( - annotations=ToolAnnotations(title="Send Sticker", openWorldHint=True, destructiveHint=True) -) -@validate_id("chat_id") -async def send_sticker(chat_id: Union[int, str], file_path: str) -> str: - """ - Send a sticker to a chat. File must be a valid .webp sticker file. - - Args: - chat_id: The chat ID or username. - file_path: Absolute path to the .webp sticker file. - """ - try: - if not os.path.isfile(file_path): - return f"Sticker file not found: {file_path}" - if not os.access(file_path, os.R_OK): - return f"Sticker file is not readable: {file_path}" - if not file_path.lower().endswith(".webp"): - return "Sticker file must be a .webp file." - - entity = await client.get_entity(chat_id) - await client.send_file(entity, file_path, force_document=False) - return f"Sticker sent to chat {chat_id}." - except Exception as e: - return log_and_format_error("send_sticker", e, chat_id=chat_id, file_path=file_path) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get Gif Search", openWorldHint=True, readOnlyHint=True) -) -async def get_gif_search(query: str, limit: int = 10) -> str: - """ - Search for GIFs by query. Returns a list of Telegram document IDs (not file paths). - - Args: - query: Search term for GIFs. - limit: Max number of GIFs to return. - """ - try: - # Try approach 1: SearchGifsRequest - try: - result = await client( - functions.messages.SearchGifsRequest(q=query, offset_id=0, limit=limit) - ) - if not result.gifs: - return "[]" - return json.dumps( - [g.document.id for g in result.gifs], indent=2, default=json_serializer - ) - except (AttributeError, ImportError): - # Fallback approach: Use SearchRequest with GIF filter - try: - from telethon.tl.types import InputMessagesFilterGif - - result = await client( - functions.messages.SearchRequest( - peer="gif", - q=query, - filter=InputMessagesFilterGif(), - min_date=None, - max_date=None, - offset_id=0, - add_offset=0, - limit=limit, - max_id=0, - min_id=0, - hash=0, - ) - ) - if not result or not hasattr(result, "messages") or not result.messages: - return "[]" - # Extract document IDs from any messages with media - gif_ids = [] - for msg in result.messages: - if hasattr(msg, "media") and msg.media and hasattr(msg.media, "document"): - gif_ids.append(msg.media.document.id) - return json.dumps(gif_ids, default=json_serializer) - except Exception as inner_e: - # Last resort: Try to fetch from a public bot - return f"Could not search GIFs using available methods: {inner_e}" - except Exception as e: - logger.exception(f"get_gif_search failed (query={query}, limit={limit})") - return log_and_format_error("get_gif_search", e, query=query, limit=limit) - - -@mcp.tool(annotations=ToolAnnotations(title="Send Gif", openWorldHint=True, destructiveHint=True)) -@validate_id("chat_id") -async def send_gif(chat_id: Union[int, str], gif_id: int) -> str: - """ - Send a GIF to a chat by Telegram GIF document ID (not a file path). - - Args: - chat_id: The chat ID or username. - gif_id: Telegram document ID for the GIF (from get_gif_search). - """ - try: - if not isinstance(gif_id, int): - return "gif_id must be a Telegram document ID (integer), not a file path. Use get_gif_search to find IDs." - entity = await client.get_entity(chat_id) - await client.send_file(entity, gif_id) - return f"GIF sent to chat {chat_id}." - except Exception as e: - return log_and_format_error("send_gif", e, chat_id=chat_id, gif_id=gif_id) - - -@mcp.tool(annotations=ToolAnnotations(title="Get Bot Info", openWorldHint=True, readOnlyHint=True)) -async def get_bot_info(bot_username: str) -> str: - """ - Get information about a bot by username. - """ - try: - entity = await client.get_entity(bot_username) - if not entity: - return f"Bot with username {bot_username} not found." - - result = await client(functions.users.GetFullUserRequest(id=entity)) - - # Create a more structured, serializable response - if hasattr(result, "to_dict"): - # Use custom serializer to handle non-serializable types - return json.dumps(result.to_dict(), indent=2, default=json_serializer) - else: - # Fallback if to_dict is not available - info = { - "bot_info": { - "id": entity.id, - "username": entity.username, - "first_name": entity.first_name, - "last_name": getattr(entity, "last_name", ""), - "is_bot": getattr(entity, "bot", False), - "verified": getattr(entity, "verified", False), - } - } - if hasattr(result, "full_user") and hasattr(result.full_user, "about"): - info["bot_info"]["about"] = result.full_user.about - return json.dumps(info, indent=2) - except Exception as e: - logger.exception(f"get_bot_info failed (bot_username={bot_username})") - return log_and_format_error("get_bot_info", e, bot_username=bot_username) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Set Bot Commands", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -async def set_bot_commands(bot_username: str, commands: list) -> str: - """ - Set bot commands for a bot you own. - Note: This function can only be used if the Telegram client is a bot account. - Regular user accounts cannot set bot commands. - - Args: - bot_username: The username of the bot to set commands for. - commands: List of command dictionaries with 'command' and 'description' keys. - """ - try: - # First check if the current client is a bot - me = await client.get_me() - if not getattr(me, "bot", False): - return "Error: This function can only be used by bot accounts. Your current Telegram account is a regular user account, not a bot." - - # Import required types - from telethon.tl.types import BotCommand, BotCommandScopeDefault - from telethon.tl.functions.bots import SetBotCommandsRequest - - # Create BotCommand objects from the command dictionaries - bot_commands = [ - BotCommand(command=c["command"], description=c["description"]) for c in commands - ] - - # Get the bot entity - bot = await client.get_entity(bot_username) - - # Set the commands with proper scope - await client( - SetBotCommandsRequest( - scope=BotCommandScopeDefault(), - lang_code="en", # Default language code - commands=bot_commands, - ) - ) - - return f"Bot commands set for {bot_username}." - except ImportError as ie: - logger.exception(f"set_bot_commands failed - ImportError: {ie}") - return log_and_format_error("set_bot_commands", ie) - except Exception as e: - logger.exception(f"set_bot_commands failed (bot_username={bot_username})") - return log_and_format_error("set_bot_commands", e, bot_username=bot_username) - - -@mcp.tool(annotations=ToolAnnotations(title="Get History", openWorldHint=True, readOnlyHint=True)) -@validate_id("chat_id") -async def get_history(chat_id: Union[int, str], limit: int = 100) -> str: - """ - Get full chat history (up to limit). - """ - try: - entity = await client.get_entity(chat_id) - messages = await client.get_messages(entity, limit=limit) - - lines = [] - for msg in messages: - sender_name = get_sender_name(msg) - reply_info = "" - if msg.reply_to and msg.reply_to.reply_to_msg_id: - reply_info = f" | reply to {msg.reply_to.reply_to_msg_id}" - lines.append( - f"ID: {msg.id} | {sender_name} | Date: {msg.date}{reply_info} | Message: {msg.message}" - ) - return "\n".join(lines) - except Exception as e: - return log_and_format_error("get_history", e, chat_id=chat_id, limit=limit) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get User Photos", openWorldHint=True, readOnlyHint=True) -) -@validate_id("user_id") -async def get_user_photos(user_id: Union[int, str], limit: int = 10) -> str: - """ - Get profile photos of a user. - """ - try: - user = await client.get_entity(user_id) - photos = await client( - functions.photos.GetUserPhotosRequest(user_id=user, offset=0, max_id=0, limit=limit) - ) - return json.dumps([p.id for p in photos.photos], indent=2) - except Exception as e: - return log_and_format_error("get_user_photos", e, user_id=user_id, limit=limit) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get User Status", openWorldHint=True, readOnlyHint=True) -) -@validate_id("user_id") -async def get_user_status(user_id: Union[int, str]) -> str: - """ - Get the online status of a user. - """ - try: - user = await client.get_entity(user_id) - return str(user.status) - except Exception as e: - return log_and_format_error("get_user_status", e, user_id=user_id) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get Recent Actions", openWorldHint=True, readOnlyHint=True) -) -@validate_id("chat_id") -async def get_recent_actions(chat_id: Union[int, str]) -> str: - """ - Get recent admin actions (admin log) in a group or channel. - """ - try: - result = await client( - functions.channels.GetAdminLogRequest( - channel=chat_id, - q="", - events_filter=None, - admins=[], - max_id=0, - min_id=0, - limit=20, - ) - ) - - if not result or not result.events: - return "No recent admin actions found." - - # Use the custom serializer to handle datetime objects - return json.dumps([e.to_dict() for e in result.events], indent=2, default=json_serializer) - except Exception as e: - logger.exception(f"get_recent_actions failed (chat_id={chat_id})") - return log_and_format_error("get_recent_actions", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations(title="Get Pinned Messages", openWorldHint=True, readOnlyHint=True) -) -@validate_id("chat_id") -async def get_pinned_messages(chat_id: Union[int, str]) -> str: - """ - Get all pinned messages in a chat. - """ - try: - entity = await client.get_entity(chat_id) - - # Use correct filter based on Telethon version - try: - # Try newer Telethon approach - from telethon.tl.types import InputMessagesFilterPinned - - messages = await client.get_messages(entity, filter=InputMessagesFilterPinned()) - except (ImportError, AttributeError): - # Fallback - try without filter and manually filter pinned - all_messages = await client.get_messages(entity, limit=50) - messages = [m for m in all_messages if getattr(m, "pinned", False)] - - if not messages: - return "No pinned messages found in this chat." - - lines = [] - for msg in messages: - sender_name = get_sender_name(msg) - reply_info = "" - if msg.reply_to and msg.reply_to.reply_to_msg_id: - reply_info = f" | reply to {msg.reply_to.reply_to_msg_id}" - lines.append( - f"ID: {msg.id} | {sender_name} | Date: {msg.date}{reply_info} | Message: {msg.message or '[Media/No text]'}" - ) - - return "\n".join(lines) - except Exception as e: - logger.exception(f"get_pinned_messages failed (chat_id={chat_id})") - return log_and_format_error("get_pinned_messages", e, chat_id=chat_id) - - -@mcp.tool( - annotations=ToolAnnotations(title="Create Poll", openWorldHint=True, destructiveHint=True) -) -async def create_poll( - chat_id: int, - question: str, - options: list, - multiple_choice: bool = False, - quiz_mode: bool = False, - public_votes: bool = True, - close_date: str = None, -) -> str: - """ - Create a poll in a chat using Telegram's native poll feature. - - Args: - chat_id: The ID of the chat to send the poll to - question: The poll question - options: List of answer options (2-10 options) - multiple_choice: Whether users can select multiple answers - quiz_mode: Whether this is a quiz (has correct answer) - public_votes: Whether votes are public - close_date: Optional close date in ISO format (YYYY-MM-DD HH:MM:SS) - """ - try: - entity = await client.get_entity(chat_id) - - # Validate options - if len(options) < 2: - return "Error: Poll must have at least 2 options." - if len(options) > 10: - return "Error: Poll can have at most 10 options." - - # Parse close date if provided - close_date_obj = None - if close_date: - try: - close_date_obj = datetime.fromisoformat(close_date.replace("Z", "+00:00")) - except ValueError: - return f"Invalid close_date format. Use YYYY-MM-DD HH:MM:SS format." - - # Create the poll using InputMediaPoll with SendMediaRequest - from telethon.tl.types import InputMediaPoll, Poll, PollAnswer, TextWithEntities - import random - - poll = Poll( - id=random.randint(0, 2**63 - 1), - question=TextWithEntities(text=question, entities=[]), - answers=[ - PollAnswer(text=TextWithEntities(text=option, entities=[]), option=bytes([i])) - for i, option in enumerate(options) - ], - multiple_choice=multiple_choice, - quiz=quiz_mode, - public_voters=public_votes, - close_date=close_date_obj, - ) - - result = await client( - functions.messages.SendMediaRequest( - peer=entity, - media=InputMediaPoll(poll=poll), - message="", - random_id=random.randint(0, 2**63 - 1), - ) - ) - - return f"Poll created successfully in chat {chat_id}." - except Exception as e: - logger.exception(f"create_poll failed (chat_id={chat_id}, question='{question}')") - return log_and_format_error( - "create_poll", e, chat_id=chat_id, question=question, options=options - ) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Send Reaction", openWorldHint=True, destructiveHint=False, idempotentHint=True - ) -) -@validate_id("chat_id") -async def send_reaction( - chat_id: Union[int, str], - message_id: int, - emoji: str, - big: bool = False, -) -> str: - """ - Send a reaction to a message. - - Args: - chat_id: The chat ID or username - message_id: The message ID to react to - emoji: The emoji to react with (e.g., "๐Ÿ‘", "โค๏ธ", "๐Ÿ”ฅ", "๐Ÿ˜‚", "๐Ÿ˜ฎ", "๐Ÿ˜ข", "๐ŸŽ‰", "๐Ÿ’ฉ", "๐Ÿ‘Ž") - big: Whether to show a big animation for the reaction (default: False) - """ - try: - from telethon.tl.types import ReactionEmoji - - peer = await client.get_input_entity(chat_id) - await client( - functions.messages.SendReactionRequest( - peer=peer, - msg_id=message_id, - big=big, - reaction=[ReactionEmoji(emoticon=emoji)], - ) - ) - return f"Reaction '{emoji}' sent to message {message_id} in chat {chat_id}." - except Exception as e: - logger.exception( - f"send_reaction failed (chat_id={chat_id}, message_id={message_id}, emoji={emoji})" - ) - return log_and_format_error( - "send_reaction", e, chat_id=chat_id, message_id=message_id, emoji=emoji - ) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Remove Reaction", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def remove_reaction( - chat_id: Union[int, str], - message_id: int, -) -> str: - """ - Remove your reaction from a message. - - Args: - chat_id: The chat ID or username - message_id: The message ID to remove reaction from - """ - try: - peer = await client.get_input_entity(chat_id) - await client( - functions.messages.SendReactionRequest( - peer=peer, - msg_id=message_id, - reaction=[], # Empty list removes reaction - ) - ) - return f"Reaction removed from message {message_id} in chat {chat_id}." - except Exception as e: - logger.exception(f"remove_reaction failed (chat_id={chat_id}, message_id={message_id})") - return log_and_format_error("remove_reaction", e, chat_id=chat_id, message_id=message_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Get Message Reactions", openWorldHint=True, readOnlyHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def get_message_reactions( - chat_id: Union[int, str], - message_id: int, - limit: int = 50, -) -> str: - """ - Get the list of reactions on a message. - - Args: - chat_id: The chat ID or username - message_id: The message ID to get reactions from - limit: Maximum number of users to return per reaction (default: 50) - """ - try: - from telethon.tl.types import ReactionEmoji, ReactionCustomEmoji - - peer = await client.get_input_entity(chat_id) - - result = await client( - functions.messages.GetMessageReactionsListRequest( - peer=peer, - id=message_id, - limit=limit, - ) - ) - - if not result.reactions: - return f"No reactions on message {message_id} in chat {chat_id}." - - reactions_data = [] - for reaction in result.reactions: - user_id = reaction.peer_id.user_id if hasattr(reaction.peer_id, "user_id") else None - emoji = None - if isinstance(reaction.reaction, ReactionEmoji): - emoji = reaction.reaction.emoticon - elif isinstance(reaction.reaction, ReactionCustomEmoji): - emoji = f"custom:{reaction.reaction.document_id}" - - reactions_data.append( - { - "user_id": user_id, - "emoji": emoji, - "date": reaction.date.isoformat() if reaction.date else None, - } - ) - - return json.dumps( - { - "message_id": message_id, - "chat_id": str(chat_id), - "reactions": reactions_data, - "count": len(reactions_data), - }, - indent=2, - default=json_serializer, - ) - except Exception as e: - logger.exception( - f"get_message_reactions failed (chat_id={chat_id}, message_id={message_id})" - ) - return log_and_format_error( - "get_message_reactions", e, chat_id=chat_id, message_id=message_id - ) - - -# ============================================================================ -# DRAFT MANAGEMENT TOOLS -# ============================================================================ - - -@mcp.tool( - annotations=ToolAnnotations( - title="Save Draft", openWorldHint=True, destructiveHint=False, idempotentHint=True - ) -) -@validate_id("chat_id") -async def save_draft( - chat_id: Union[int, str], - message: str, - reply_to_msg_id: Optional[int] = None, - no_webpage: bool = False, -) -> str: - """ - Save a draft message to a chat or channel. The draft will appear in the Telegram - app's input field when you open that chat, allowing you to review and send it manually. - - Args: - chat_id: The chat ID or username/channel to save the draft to - message: The draft message text - reply_to_msg_id: Optional message ID to reply to - no_webpage: If True, disable link preview in the draft - """ - try: - peer = await client.get_input_entity(chat_id) - - # Build reply_to parameter if provided - reply_to = None - if reply_to_msg_id: - from telethon.tl.types import InputReplyToMessage - - reply_to = InputReplyToMessage(reply_to_msg_id=reply_to_msg_id) - - await client( - functions.messages.SaveDraftRequest( - peer=peer, - message=message, - no_webpage=no_webpage, - reply_to=reply_to, - ) - ) - - return f"Draft saved to chat {chat_id}. Open the chat in Telegram to see and send it." - except Exception as e: - logger.exception(f"save_draft failed (chat_id={chat_id})") - return log_and_format_error("save_draft", e, chat_id=chat_id) - - -@mcp.tool(annotations=ToolAnnotations(title="Get Drafts", openWorldHint=True, readOnlyHint=True)) -async def get_drafts() -> str: - """ - Get all draft messages across all chats. - Returns a list of drafts with their chat info and message content. - """ - try: - result = await client(functions.messages.GetAllDraftsRequest()) - - # The result contains updates with draft info - drafts_info = [] - - # GetAllDraftsRequest returns Updates object with updates array - if hasattr(result, "updates"): - for update in result.updates: - if hasattr(update, "draft") and update.draft: - draft = update.draft - peer_id = None - - # Extract peer ID based on type - if hasattr(update, "peer"): - peer = update.peer - if hasattr(peer, "user_id"): - peer_id = peer.user_id - elif hasattr(peer, "chat_id"): - peer_id = -peer.chat_id - elif hasattr(peer, "channel_id"): - peer_id = -1000000000000 - peer.channel_id - - draft_data = { - "peer_id": peer_id, - "message": getattr(draft, "message", ""), - "date": ( - draft.date.isoformat() - if hasattr(draft, "date") and draft.date - else None - ), - "no_webpage": getattr(draft, "no_webpage", False), - "reply_to_msg_id": ( - draft.reply_to.reply_to_msg_id - if hasattr(draft, "reply_to") and draft.reply_to - else None - ), - } - drafts_info.append(draft_data) - - if not drafts_info: - return "No drafts found." - - return json.dumps( - {"drafts": drafts_info, "count": len(drafts_info)}, indent=2, default=json_serializer - ) - except Exception as e: - logger.exception("get_drafts failed") - return log_and_format_error("get_drafts", e) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Clear Draft", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def clear_draft(chat_id: Union[int, str]) -> str: - """ - Clear/delete a draft from a specific chat. - - Args: - chat_id: The chat ID or username to clear the draft from - """ - try: - peer = await client.get_input_entity(chat_id) - - # Saving an empty message clears the draft - await client( - functions.messages.SaveDraftRequest( - peer=peer, - message="", - ) - ) - - return f"Draft cleared from chat {chat_id}." - except Exception as e: - logger.exception(f"clear_draft failed (chat_id={chat_id})") - return log_and_format_error("clear_draft", e, chat_id=chat_id) - - -# ============================================================================ -# FOLDER MANAGEMENT TOOLS -# ============================================================================ - - -@mcp.tool(annotations=ToolAnnotations(title="List Folders", openWorldHint=True, readOnlyHint=True)) -async def list_folders() -> str: - """ - Get all dialog folders (filters) with their IDs, names, and emoji. - Returns a list of folders that can be used with other folder tools. - """ - try: - result = await client(functions.messages.GetDialogFiltersRequest()) - - folders = [] - for f in result.filters: - # Skip system default folder - if isinstance(f, DialogFilterDefault): - continue - - if isinstance(f, DialogFilter): - # Handle title which can be str or TextWithEntities - title = f.title - if isinstance(title, TextWithEntities): - title = title.text - folder_data = { - "id": f.id, - "title": title, - "emoticon": getattr(f, "emoticon", None), - "contacts": getattr(f, "contacts", False), - "non_contacts": getattr(f, "non_contacts", False), - "groups": getattr(f, "groups", False), - "broadcasts": getattr(f, "broadcasts", False), - "bots": getattr(f, "bots", False), - "exclude_muted": getattr(f, "exclude_muted", False), - "exclude_read": getattr(f, "exclude_read", False), - "exclude_archived": getattr(f, "exclude_archived", False), - "included_peers_count": len(getattr(f, "include_peers", [])), - "excluded_peers_count": len(getattr(f, "exclude_peers", [])), - "pinned_peers_count": len(getattr(f, "pinned_peers", [])), - } - folders.append(folder_data) - - if not folders: - return "No folders found. Create one with create_folder tool." - - return json.dumps( - {"folders": folders, "count": len(folders)}, indent=2, default=json_serializer - ) - except Exception as e: - logger.exception("list_folders failed") - return log_and_format_error("list_folders", e, ErrorCategory.FOLDER) - - -@mcp.tool(annotations=ToolAnnotations(title="Get Folder", openWorldHint=True, readOnlyHint=True)) -async def get_folder(folder_id: int) -> str: - """ - Get detailed information about a specific folder including all included chats. - - Args: - folder_id: The folder ID (get from list_folders) - """ - try: - result = await client(functions.messages.GetDialogFiltersRequest()) - - target_folder = None - for f in result.filters: - if isinstance(f, DialogFilter) and f.id == folder_id: - target_folder = f - break - - if not target_folder: - return ( - f"Folder with ID {folder_id} not found. Use list_folders to see available folders." - ) - - # Resolve included peers to readable names - included_chats = [] - for peer in getattr(target_folder, "include_peers", []): - try: - entity = await client.get_entity(peer) - chat_info = { - "id": entity.id, - "name": getattr(entity, "title", None) - or getattr(entity, "first_name", "Unknown"), - "type": type(entity).__name__, - } - if hasattr(entity, "username") and entity.username: - chat_info["username"] = entity.username - included_chats.append(chat_info) - except Exception: - included_chats.append({"id": str(peer), "name": "Unknown", "type": "Unknown"}) - - # Resolve excluded peers - excluded_chats = [] - for peer in getattr(target_folder, "exclude_peers", []): - try: - entity = await client.get_entity(peer) - chat_info = { - "id": entity.id, - "name": getattr(entity, "title", None) - or getattr(entity, "first_name", "Unknown"), - "type": type(entity).__name__, - } - excluded_chats.append(chat_info) - except Exception: - excluded_chats.append({"id": str(peer), "name": "Unknown", "type": "Unknown"}) - - # Resolve pinned peers - pinned_chats = [] - for peer in getattr(target_folder, "pinned_peers", []): - try: - entity = await client.get_entity(peer) - chat_info = { - "id": entity.id, - "name": getattr(entity, "title", None) - or getattr(entity, "first_name", "Unknown"), - "type": type(entity).__name__, - } - pinned_chats.append(chat_info) - except Exception: - pinned_chats.append({"id": str(peer), "name": "Unknown", "type": "Unknown"}) - - # Handle title which can be str or TextWithEntities - title = target_folder.title - if isinstance(title, TextWithEntities): - title = title.text - - folder_data = { - "id": target_folder.id, - "title": title, - "emoticon": getattr(target_folder, "emoticon", None), - "filters": { - "contacts": getattr(target_folder, "contacts", False), - "non_contacts": getattr(target_folder, "non_contacts", False), - "groups": getattr(target_folder, "groups", False), - "broadcasts": getattr(target_folder, "broadcasts", False), - "bots": getattr(target_folder, "bots", False), - "exclude_muted": getattr(target_folder, "exclude_muted", False), - "exclude_read": getattr(target_folder, "exclude_read", False), - "exclude_archived": getattr(target_folder, "exclude_archived", False), - }, - "included_chats": included_chats, - "excluded_chats": excluded_chats, - "pinned_chats": pinned_chats, - } - - return json.dumps(folder_data, indent=2, default=json_serializer) - except Exception as e: - logger.exception(f"get_folder failed (folder_id={folder_id})") - return log_and_format_error("get_folder", e, ErrorCategory.FOLDER, folder_id=folder_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Create Folder", openWorldHint=True, destructiveHint=True, idempotentHint=False - ) -) -async def create_folder( - title: str, - emoticon: Optional[str] = None, - chat_ids: Optional[List[Union[int, str]]] = None, - contacts: bool = False, - non_contacts: bool = False, - groups: bool = False, - broadcasts: bool = False, - bots: bool = False, - exclude_muted: bool = False, - exclude_read: bool = False, - exclude_archived: bool = True, -) -> str: - """ - Create a new dialog folder. - - Args: - title: Folder name (required) - emoticon: Folder emoji (optional, e.g., "๐Ÿ“", "๐Ÿ ", "๐Ÿ’ผ") - chat_ids: List of chat IDs or usernames to include (optional) - contacts: Include all contacts - non_contacts: Include all non-contacts - groups: Include all groups - broadcasts: Include all channels - bots: Include all bots - exclude_muted: Exclude muted chats - exclude_read: Exclude read chats - exclude_archived: Exclude archived chats (default True) - """ - try: - # Get existing folders to check count and find next ID - result = await client(functions.messages.GetDialogFiltersRequest()) - - existing_ids = set() - folder_count = 0 - for f in result.filters: - if isinstance(f, DialogFilter): - existing_ids.add(f.id) - folder_count += 1 - - # Telegram limit: max 10 custom folders - if folder_count >= 10: - return "Cannot create folder: Telegram limit is 10 folders. Delete one first." - - # Find next available ID (IDs 0 and 1 are reserved for system) - new_id = 2 - while new_id in existing_ids: - new_id += 1 - - # Resolve chat_ids to input peers - include_peers = [] - if chat_ids: - for chat_id in chat_ids: - try: - peer = await client.get_input_entity(chat_id) - include_peers.append(peer) - except Exception as e: - return f"Failed to resolve chat '{chat_id}': {str(e)}" - - # Create the folder (title must be TextWithEntities) - title_obj = TextWithEntities(text=title, entities=[]) - new_filter = DialogFilter( - id=new_id, - title=title_obj, - emoticon=emoticon, - pinned_peers=[], - include_peers=include_peers, - exclude_peers=[], - contacts=contacts, - non_contacts=non_contacts, - groups=groups, - broadcasts=broadcasts, - bots=bots, - exclude_muted=exclude_muted, - exclude_read=exclude_read, - exclude_archived=exclude_archived, - ) - - await client(functions.messages.UpdateDialogFilterRequest(id=new_id, filter=new_filter)) - - return json.dumps( - { - "success": True, - "folder_id": new_id, - "title": title, - "emoticon": emoticon, - "included_chats_count": len(include_peers), - }, - indent=2, - ) - except Exception as e: - logger.exception(f"create_folder failed (title={title})") - return log_and_format_error("create_folder", e, ErrorCategory.FOLDER, title=title) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Add Chat to Folder", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -@validate_id("chat_id") -async def add_chat_to_folder( - folder_id: int, chat_id: Union[int, str], pinned: bool = False -) -> str: - """ - Add a chat to an existing folder. - - Args: - folder_id: The folder ID (get from list_folders) - chat_id: Chat ID or username to add - pinned: Pin the chat in this folder (default False) - """ - try: - # Get the folder - result = await client(functions.messages.GetDialogFiltersRequest()) - - target_folder = None - for f in result.filters: - if isinstance(f, DialogFilter) and f.id == folder_id: - target_folder = f - break - - if not target_folder: - return ( - f"Folder with ID {folder_id} not found. Use list_folders to see available folders." - ) - - # Resolve chat to input peer - try: - peer = await client.get_input_entity(chat_id) - except Exception as e: - return f"Failed to resolve chat '{chat_id}': {str(e)}" - - # Check if already included (idempotent) - include_peers = list(getattr(target_folder, "include_peers", [])) - pinned_peers = list(getattr(target_folder, "pinned_peers", [])) - - # Get peer ID for comparison - peer_id = utils.get_peer_id(peer) - already_included = any(utils.get_peer_id(p) == peer_id for p in include_peers) - already_pinned = any(utils.get_peer_id(p) == peer_id for p in pinned_peers) - - if already_included and (not pinned or already_pinned): - return f"Chat {chat_id} is already in folder {folder_id}." - - # Add to appropriate list - if not already_included: - include_peers.append(peer) - if pinned and not already_pinned: - pinned_peers.append(peer) - - # Update the folder (keep all original attributes) - updated_filter = DialogFilter( - id=target_folder.id, - title=target_folder.title, - emoticon=getattr(target_folder, "emoticon", None), - pinned_peers=pinned_peers, - include_peers=include_peers, - exclude_peers=list(getattr(target_folder, "exclude_peers", [])), - contacts=getattr(target_folder, "contacts", False), - non_contacts=getattr(target_folder, "non_contacts", False), - groups=getattr(target_folder, "groups", False), - broadcasts=getattr(target_folder, "broadcasts", False), - bots=getattr(target_folder, "bots", False), - exclude_muted=getattr(target_folder, "exclude_muted", False), - exclude_read=getattr(target_folder, "exclude_read", False), - exclude_archived=getattr(target_folder, "exclude_archived", False), - title_noanimate=getattr(target_folder, "title_noanimate", None), - color=getattr(target_folder, "color", None), - ) - - await client( - functions.messages.UpdateDialogFilterRequest(id=folder_id, filter=updated_filter) - ) - - return ( - f"Chat {chat_id} added to folder {folder_id}" + (" (pinned)" if pinned else "") + "." - ) - except Exception as e: - logger.exception(f"add_chat_to_folder failed (folder_id={folder_id}, chat_id={chat_id})") - return log_and_format_error( - "add_chat_to_folder", e, ErrorCategory.FOLDER, folder_id=folder_id, chat_id=chat_id - ) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Remove Chat from Folder", - openWorldHint=True, - destructiveHint=True, - idempotentHint=True, - ) -) -@validate_id("chat_id") -async def remove_chat_from_folder(folder_id: int, chat_id: Union[int, str]) -> str: - """ - Remove a chat from a folder. - - Args: - folder_id: The folder ID (get from list_folders) - chat_id: Chat ID or username to remove - """ - try: - # Get the folder - result = await client(functions.messages.GetDialogFiltersRequest()) - - target_folder = None - for f in result.filters: - if isinstance(f, DialogFilter) and f.id == folder_id: - target_folder = f - break - - if not target_folder: - return ( - f"Folder with ID {folder_id} not found. Use list_folders to see available folders." - ) - - # Resolve chat to get peer ID - try: - peer = await client.get_input_entity(chat_id) - peer_id = utils.get_peer_id(peer) - except Exception as e: - return f"Failed to resolve chat '{chat_id}': {str(e)}" - - # Filter out the peer from both include and pinned lists - include_peers = [ - p - for p in getattr(target_folder, "include_peers", []) - if utils.get_peer_id(p) != peer_id - ] - pinned_peers = [ - p - for p in getattr(target_folder, "pinned_peers", []) - if utils.get_peer_id(p) != peer_id - ] - - original_include_count = len(getattr(target_folder, "include_peers", [])) - original_pinned_count = len(getattr(target_folder, "pinned_peers", [])) - - # Check if anything was removed (idempotent) - if ( - len(include_peers) == original_include_count - and len(pinned_peers) == original_pinned_count - ): - return f"Chat {chat_id} was not in folder {folder_id}." - - # Update the folder (keep all original attributes) - updated_filter = DialogFilter( - id=target_folder.id, - title=target_folder.title, - emoticon=getattr(target_folder, "emoticon", None), - pinned_peers=pinned_peers, - include_peers=include_peers, - exclude_peers=list(getattr(target_folder, "exclude_peers", [])), - contacts=getattr(target_folder, "contacts", False), - non_contacts=getattr(target_folder, "non_contacts", False), - groups=getattr(target_folder, "groups", False), - broadcasts=getattr(target_folder, "broadcasts", False), - bots=getattr(target_folder, "bots", False), - exclude_muted=getattr(target_folder, "exclude_muted", False), - exclude_read=getattr(target_folder, "exclude_read", False), - exclude_archived=getattr(target_folder, "exclude_archived", False), - title_noanimate=getattr(target_folder, "title_noanimate", None), - color=getattr(target_folder, "color", None), - ) - - await client( - functions.messages.UpdateDialogFilterRequest(id=folder_id, filter=updated_filter) - ) - - return f"Chat {chat_id} removed from folder {folder_id}." - except Exception as e: - logger.exception( - f"remove_chat_from_folder failed (folder_id={folder_id}, chat_id={chat_id})" - ) - return log_and_format_error( - "remove_chat_from_folder", - e, - ErrorCategory.FOLDER, - folder_id=folder_id, - chat_id=chat_id, - ) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Delete Folder", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -async def delete_folder(folder_id: int) -> str: - """ - Delete a folder. Chats in the folder are preserved, only the folder is removed. - - Args: - folder_id: The folder ID to delete (get from list_folders) - """ - try: - # System folders (id < 2) cannot be deleted - if folder_id < 2: - return f"Cannot delete system folder (ID {folder_id}). Only custom folders can be deleted." - - # Check if folder exists - result = await client(functions.messages.GetDialogFiltersRequest()) - - folder_exists = False - folder_title = None - for f in result.filters: - if isinstance(f, DialogFilter) and f.id == folder_id: - folder_exists = True - # Handle title which can be str or TextWithEntities - title = f.title - if isinstance(title, TextWithEntities): - title = title.text - folder_title = title - break - - if not folder_exists: - return f"Folder with ID {folder_id} not found (may already be deleted)." - - # Delete by passing None as filter - await client(functions.messages.UpdateDialogFilterRequest(id=folder_id, filter=None)) - - return f"Folder '{folder_title}' (ID {folder_id}) deleted. Chats are preserved." - except Exception as e: - logger.exception(f"delete_folder failed (folder_id={folder_id})") - return log_and_format_error("delete_folder", e, ErrorCategory.FOLDER, folder_id=folder_id) - - -@mcp.tool( - annotations=ToolAnnotations( - title="Reorder Folders", openWorldHint=True, destructiveHint=True, idempotentHint=True - ) -) -async def reorder_folders(folder_ids: List[int]) -> str: - """ - Change the order of folders in the folder list. - - Args: - folder_ids: List of folder IDs in the desired order - """ - try: - # Get existing folders to validate - result = await client(functions.messages.GetDialogFiltersRequest()) - - existing_ids = set() - for f in result.filters: - if isinstance(f, DialogFilter): - existing_ids.add(f.id) - - # Validate all provided IDs exist - for fid in folder_ids: - if fid not in existing_ids: - return f"Folder ID {fid} not found. Use list_folders to see available folders." - - # Validate all existing folders are included - if set(folder_ids) != existing_ids: - missing = existing_ids - set(folder_ids) - return f"All folder IDs must be included. Missing: {missing}" - - # Reorder - await client(functions.messages.UpdateDialogFiltersOrderRequest(order=folder_ids)) - - return f"Folders reordered: {folder_ids}" - except Exception as e: - logger.exception(f"reorder_folders failed (folder_ids={folder_ids})") - return log_and_format_error( - "reorder_folders", e, ErrorCategory.FOLDER, folder_ids=folder_ids - ) - - -async def _main() -> None: - try: - # Start the Telethon client non-interactively - print("Starting Telegram client...") - await client.start() - - print("Telegram client started. Running MCP server...") - # Use the asynchronous entrypoint instead of mcp.run() - await mcp.run_stdio_async() - except Exception as e: - print(f"Error starting client: {e}", file=sys.stderr) - if isinstance(e, sqlite3.OperationalError) and "database is locked" in str(e): - print( - "Database lock detected. Please ensure no other instances are running.", - file=sys.stderr, - ) - sys.exit(1) - - -def main() -> None: - nest_asyncio.apply() - asyncio.run(_main()) +This module re-exports main from the telegram_mcp package for backward compatibility. +New code should import directly from telegram_mcp. +""" +from telegram_mcp import main if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml index 4fcf097..2ef5e31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,11 +36,14 @@ dependencies = [ "Homepage" = "https://github.com/chigwell/telegram-mcp" "Bug Tracker" = "https://github.com/chigwell/telegram-mcp/issues" -[tool.setuptools] -py-modules = ["main", "session_string_generator"] +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +"*" = ["py.typed"] [project.scripts] -telegram-mcp = "main:main" +telegram-mcp = "telegram_mcp:main" telegram-mcp-generate-session = "session_string_generator:main" [tool.black] @@ -64,4 +67,6 @@ exclude = [ dev = [ "black>=25.9.0", "flake8>=7.3.0", + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", ] diff --git a/scripts/test_readonly.py b/scripts/test_readonly.py new file mode 100644 index 0000000..8126f39 --- /dev/null +++ b/scripts/test_readonly.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Test script for read-only Telegram MCP tools. + +Run this script to test all read-only tools against your Telegram account. +Requires valid credentials in .env file. +""" + +import asyncio +import json +import sys +from pathlib import Path + +# Add src to path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from dotenv import load_dotenv +load_dotenv() + +# Track test results +results = {"passed": [], "failed": [], "skipped": []} + + +async def test_tool(name: str, coro, skip_condition=None): + """Run a single tool test.""" + if skip_condition: + results["skipped"].append((name, skip_condition)) + print(f" SKIP: {name} - {skip_condition}") + return None + + try: + result = await coro + if "Error" in str(result) and "not found" not in str(result).lower(): + results["failed"].append((name, str(result)[:100])) + print(f" FAIL: {name}") + print(f" {str(result)[:100]}") + else: + results["passed"].append(name) + # Truncate long results + display = str(result)[:150].replace("\n", " ") + print(f" PASS: {name}") + print(f" {display}...") + return result + except Exception as e: + results["failed"].append((name, str(e)[:100])) + print(f" FAIL: {name}") + print(f" Exception: {str(e)[:100]}") + return None + + +async def run_tests(): + """Run all read-only tool tests.""" + print("=" * 60) + print("Telegram MCP Read-Only Tools Test") + print("=" * 60) + + # Import client and connect + from telegram_mcp.app import client + + print("\n[1] Connecting to Telegram...") + try: + await client.connect() + me = await client.get_me() + print(f" Connected as: {me.first_name} (ID: {me.id})") + except Exception as e: + print(f" Failed to connect: {e}") + return + + # === User Tools === + print("\n[2] Testing User Tools...") + from telegram_mcp.tools.users import ( + get_me, get_privacy_settings, get_user_photos, get_user_status + ) + + await test_tool("get_me", get_me()) + await test_tool("get_privacy_settings", get_privacy_settings()) + await test_tool("get_user_photos", get_user_photos(me.id)) + await test_tool("get_user_status", get_user_status(me.id)) + + # === Chat Tools === + print("\n[3] Testing Chat Tools...") + from telegram_mcp.tools.chats import ( + get_chats, list_chats, get_chat, list_topics, search_public_chats + ) + + await test_tool("get_chats", get_chats(page=1, page_size=3)) + chats_result = await test_tool("list_chats", list_chats(limit=3)) + + # Get a chat ID directly from client for further tests + chat_id = None + try: + dialogs = await client.get_dialogs(limit=1) + if dialogs: + chat_id = dialogs[0].entity.id + # Adjust for channels/groups + if hasattr(dialogs[0].entity, 'broadcast') or hasattr(dialogs[0].entity, 'megagroup'): + chat_id = -1000000000000 - dialogs[0].entity.id + elif hasattr(dialogs[0].entity, 'title'): + chat_id = -dialogs[0].entity.id + print(f" Using chat_id: {chat_id}") + except Exception as e: + print(f" Could not get chat_id: {e}") + + if chat_id: + await test_tool("get_chat", get_chat(chat_id)) + await test_tool("list_topics", list_topics(chat_id)) + + await test_tool("search_public_chats", search_public_chats("python")) + + # === Contact Tools === + print("\n[4] Testing Contact Tools...") + from telegram_mcp.tools.contacts import ( + list_contacts, search_contacts, get_contact_ids, + export_contacts, get_blocked_users, resolve_username + ) + + await test_tool("list_contacts", list_contacts()) + await test_tool("search_contacts", search_contacts("test")) + await test_tool("get_contact_ids", get_contact_ids()) + await test_tool("export_contacts", export_contacts()) + await test_tool("get_blocked_users", get_blocked_users()) + await test_tool("resolve_username", resolve_username("telegram")) + + # === Folder Tools === + print("\n[5] Testing Folder Tools...") + from telegram_mcp.tools.folders import list_folders, get_folder + + folders_result = await test_tool("list_folders", list_folders()) + + # Get a folder ID for further tests + folder_id = None + if folders_result and "Error" not in str(folders_result): + try: + folders_data = json.loads(folders_result) + if folders_data.get("folders"): + folder_id = folders_data["folders"][0].get("id") + except: + pass + + if folder_id: + await test_tool("get_folder", get_folder(folder_id)) + + # === Message Tools === + print("\n[6] Testing Message Tools...") + from telegram_mcp.tools.messages import ( + get_messages, list_messages, get_pinned_messages, search_messages + ) + + if chat_id: + await test_tool("get_messages", get_messages(chat_id, page=1, page_size=3)) + await test_tool("list_messages", list_messages(chat_id, limit=3)) + await test_tool("get_pinned_messages", get_pinned_messages(chat_id)) + await test_tool("search_messages", search_messages(chat_id, "test")) + else: + print(" SKIP: Message tools (no chat_id available)") + + # === Media Tools === + print("\n[7] Testing Media Tools...") + from telegram_mcp.tools.media import ( + get_sticker_sets, get_gif_search, get_bot_info + ) + + await test_tool("get_sticker_sets", get_sticker_sets()) + await test_tool("get_gif_search", get_gif_search("hello", limit=3)) + await test_tool("get_bot_info", get_bot_info("BotFather")) + + # === Misc Tools === + print("\n[8] Testing Misc Tools...") + from telegram_mcp.tools.misc import get_drafts + + await test_tool("get_drafts", get_drafts()) + + # === Admin Tools === + print("\n[9] Testing Admin Tools...") + from telegram_mcp.tools.admin import get_admins, get_banned_users + + if chat_id: + await test_tool("get_admins", get_admins(chat_id)) + await test_tool("get_banned_users", get_banned_users(chat_id)) + else: + print(" SKIP: Admin tools (no chat_id available)") + + # Print summary + print("\n" + "=" * 60) + print("Test Summary") + print("=" * 60) + print(f" Passed: {len(results['passed'])}") + print(f" Failed: {len(results['failed'])}") + print(f" Skipped: {len(results['skipped'])}") + + if results["failed"]: + print("\nFailed tests:") + for name, error in results["failed"]: + print(f" - {name}: {error}") + + print("\n" + "=" * 60) + + # Disconnect + await client.disconnect() + + +if __name__ == "__main__": + asyncio.run(run_tests()) diff --git a/src/telegram_mcp/__init__.py b/src/telegram_mcp/__init__.py new file mode 100644 index 0000000..ed97917 --- /dev/null +++ b/src/telegram_mcp/__init__.py @@ -0,0 +1,7 @@ +"""Telegram MCP Package.""" + +__version__ = "2.0.1" + +from .app import main + +__all__ = ["main", "__version__"] diff --git a/src/telegram_mcp/__main__.py b/src/telegram_mcp/__main__.py new file mode 100644 index 0000000..750c5f4 --- /dev/null +++ b/src/telegram_mcp/__main__.py @@ -0,0 +1,6 @@ +"""Support for running as python -m telegram_mcp.""" + +from .app import main + +if __name__ == "__main__": + main() diff --git a/src/telegram_mcp/app.py b/src/telegram_mcp/app.py new file mode 100644 index 0000000..5c5083b --- /dev/null +++ b/src/telegram_mcp/app.py @@ -0,0 +1,53 @@ +"""Application entry point for telegram-mcp.""" + +import asyncio +import sqlite3 +import sys + +import nest_asyncio +from mcp.server.fastmcp import FastMCP +from telethon import TelegramClient +from telethon.sessions import StringSession + +from .config import ( + TELEGRAM_API_ID, + TELEGRAM_API_HASH, + TELEGRAM_SESSION_NAME, + SESSION_STRING, +) + +# Create MCP server instance +mcp = FastMCP("telegram") + +# Create Telegram client instance +if SESSION_STRING: + client = TelegramClient(StringSession(SESSION_STRING), TELEGRAM_API_ID, TELEGRAM_API_HASH) +else: + client = TelegramClient(TELEGRAM_SESSION_NAME, TELEGRAM_API_ID, TELEGRAM_API_HASH) + + +async def _main() -> None: + try: + # Start the Telethon client non-interactively + print("Starting Telegram client...") + await client.start() + + print("Telegram client started. Running MCP server...") + # Use the asynchronous entrypoint instead of mcp.run() + await mcp.run_stdio_async() + except Exception as e: + print(f"Error starting client: {e}", file=sys.stderr) + if isinstance(e, sqlite3.OperationalError) and "database is locked" in str(e): + print( + "Database lock detected. Please ensure no other instances are running.", + file=sys.stderr, + ) + sys.exit(1) + + +def main() -> None: + # Import tools to register them with mcp + from . import tools # noqa: F401 + + nest_asyncio.apply() + asyncio.run(_main()) diff --git a/src/telegram_mcp/config.py b/src/telegram_mcp/config.py new file mode 100644 index 0000000..cdd189b --- /dev/null +++ b/src/telegram_mcp/config.py @@ -0,0 +1,12 @@ +"""Configuration module for telegram-mcp.""" + +import os + +from dotenv import load_dotenv + +load_dotenv() + +TELEGRAM_API_ID = int(os.getenv("TELEGRAM_API_ID")) +TELEGRAM_API_HASH = os.getenv("TELEGRAM_API_HASH") +TELEGRAM_SESSION_NAME = os.getenv("TELEGRAM_SESSION_NAME") +SESSION_STRING = os.getenv("TELEGRAM_SESSION_STRING") diff --git a/src/telegram_mcp/exceptions.py b/src/telegram_mcp/exceptions.py new file mode 100644 index 0000000..80e72a7 --- /dev/null +++ b/src/telegram_mcp/exceptions.py @@ -0,0 +1,75 @@ +"""Exceptions and error handling for telegram-mcp.""" + +from enum import Enum +from typing import Optional, Union + +from .logging_config import logger + + +class ValidationError(Exception): + """Custom exception for validation errors.""" + + pass + + +class ErrorCategory(str, Enum): + CHAT = "CHAT" + MSG = "MSG" + CONTACT = "CONTACT" + GROUP = "GROUP" + MEDIA = "MEDIA" + PROFILE = "PROFILE" + AUTH = "AUTH" + ADMIN = "ADMIN" + FOLDER = "FOLDER" + + +def log_and_format_error( + function_name: str, + error: Exception, + prefix: Optional[Union[ErrorCategory, str]] = None, + user_message: str = None, + **kwargs, +) -> str: + """ + Centralized error handling function. + + Logs an error and returns a formatted, user-friendly message. + + Args: + function_name: Name of the function where the error occurred. + error: The exception that was raised. + prefix: Error code prefix (e.g., ErrorCategory.CHAT, "VALIDATION-001"). + If None, it will be derived from the function_name. + user_message: A custom user-facing message to return. If None, a generic one is created. + **kwargs: Additional context parameters to include in the log. + + Returns: + A user-friendly error message with an error code. + """ + # Generate a consistent error code + if isinstance(prefix, str) and prefix == "VALIDATION-001": + # Special case for validation errors + error_code = prefix + else: + if prefix is None: + # Try to derive prefix from function name + for category in ErrorCategory: + if category.name.lower() in function_name.lower(): + prefix = category + break + + prefix_str = prefix.value if isinstance(prefix, ErrorCategory) else (prefix or "GEN") + error_code = f"{prefix_str}-ERR-{abs(hash(function_name)) % 1000:03d}" + + # Format the additional context parameters + context = ", ".join(f"{k}={v}" for k, v in kwargs.items()) + + # Log the full technical error + logger.error(f"Error in {function_name} ({context}) - Code: {error_code}", exc_info=True) + + # Return a user-friendly message + if user_message: + return user_message + + return f"An error occurred (code: {error_code}). Check mcp_errors.log for details." diff --git a/src/telegram_mcp/formatters.py b/src/telegram_mcp/formatters.py new file mode 100644 index 0000000..f7faf22 --- /dev/null +++ b/src/telegram_mcp/formatters.py @@ -0,0 +1,93 @@ +"""Formatting utilities for telegram-mcp.""" + +from datetime import datetime +from typing import Any, Dict + +from telethon.tl.types import Chat, Channel + + +def json_serializer(obj): + """Helper function to convert non-serializable objects for JSON serialization.""" + if isinstance(obj, datetime): + return obj.isoformat() + if isinstance(obj, bytes): + return obj.decode("utf-8", errors="replace") + raise TypeError(f"Object of type {type(obj)} is not JSON serializable") + + +def format_entity(entity) -> Dict[str, Any]: + """Helper function to format entity information consistently.""" + result = {"id": entity.id} + + if hasattr(entity, "title"): + result["name"] = entity.title + result["type"] = "group" if isinstance(entity, Chat) else "channel" + elif hasattr(entity, "first_name"): + name_parts = [] + if entity.first_name: + name_parts.append(entity.first_name) + if hasattr(entity, "last_name") and entity.last_name: + name_parts.append(entity.last_name) + result["name"] = " ".join(name_parts) + result["type"] = "user" + if hasattr(entity, "username") and entity.username: + result["username"] = entity.username + if hasattr(entity, "phone") and entity.phone: + result["phone"] = entity.phone + + return result + + +def format_message(message) -> Dict[str, Any]: + """Helper function to format message information consistently.""" + from telethon import utils + + result = { + "id": message.id, + "date": message.date.isoformat(), + "text": message.message or "", + } + + if message.from_id: + result["from_id"] = utils.get_peer_id(message.from_id) + + if message.media: + result["has_media"] = True + result["media_type"] = type(message.media).__name__ + + return result + + +def get_sender_name(message) -> str: + """Helper function to get sender name from a message.""" + if not message.sender: + return "Unknown" + + # Check for group/channel title first + if hasattr(message.sender, "title") and message.sender.title: + return message.sender.title + elif hasattr(message.sender, "first_name"): + # User sender + first_name = getattr(message.sender, "first_name", "") or "" + last_name = getattr(message.sender, "last_name", "") or "" + full_name = f"{first_name} {last_name}".strip() + return full_name if full_name else "Unknown" + else: + return "Unknown" + + +def get_engagement_info(message) -> str: + """Helper function to get engagement metrics (views, forwards, reactions) from a message.""" + engagement_parts = [] + views = getattr(message, "views", None) + if views is not None: + engagement_parts.append(f"views:{views}") + forwards = getattr(message, "forwards", None) + if forwards is not None: + engagement_parts.append(f"forwards:{forwards}") + reactions = getattr(message, "reactions", None) + if reactions is not None: + results = getattr(reactions, "results", None) + total_reactions = sum(getattr(r, "count", 0) or 0 for r in results) if results else 0 + engagement_parts.append(f"reactions:{total_reactions}") + return f" | {', '.join(engagement_parts)}" if engagement_parts else "" diff --git a/src/telegram_mcp/logging_config.py b/src/telegram_mcp/logging_config.py new file mode 100644 index 0000000..8d295a1 --- /dev/null +++ b/src/telegram_mcp/logging_config.py @@ -0,0 +1,50 @@ +"""Logging configuration for telegram-mcp.""" + +import logging +import os + +from pythonjsonlogger import jsonlogger + + +def setup_logger() -> logging.Logger: + """Setup and return the configured logger.""" + logger = logging.getLogger("telegram_mcp") + logger.setLevel(logging.ERROR) + + # Create console handler + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.ERROR) + + # Create file handler with absolute path + script_dir = os.path.dirname(os.path.abspath(__file__)) + log_file_path = os.path.join(script_dir, "mcp_errors.log") + + try: + file_handler = logging.FileHandler(log_file_path, mode="a") + file_handler.setLevel(logging.ERROR) + + # Console formatter remains in the old format + console_formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s") + console_handler.setFormatter(console_formatter) + + # File formatter is now JSON + json_formatter = jsonlogger.JsonFormatter( + "%(asctime)s %(name)s %(levelname)s %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S%z", + ) + file_handler.setFormatter(json_formatter) + + # Add handlers to logger + logger.addHandler(console_handler) + logger.addHandler(file_handler) + logger.info(f"Logging initialized to {log_file_path}") + except Exception as log_error: + print(f"WARNING: Error setting up log file: {log_error}") + # Fallback to console-only logging + logger.addHandler(console_handler) + logger.error(f"Failed to set up log file handler: {log_error}") + + return logger + + +logger = setup_logger() diff --git a/src/telegram_mcp/tools/__init__.py b/src/telegram_mcp/tools/__init__.py new file mode 100644 index 0000000..d9f88ae --- /dev/null +++ b/src/telegram_mcp/tools/__init__.py @@ -0,0 +1,24 @@ +"""Tools package for telegram-mcp. + +This module imports all tool modules to register them with the MCP server. +""" + +from . import chats +from . import messages +from . import contacts +from . import users +from . import admin +from . import media +from . import folders +from . import misc + +__all__ = [ + "chats", + "messages", + "contacts", + "users", + "admin", + "media", + "folders", + "misc", +] diff --git a/src/telegram_mcp/tools/admin.py b/src/telegram_mcp/tools/admin.py new file mode 100644 index 0000000..f9f165a --- /dev/null +++ b/src/telegram_mcp/tools/admin.py @@ -0,0 +1,384 @@ +"""Admin management tools for telegram-mcp.""" + +import json +from typing import Union + +from telethon import functions +from telethon.tl.types import ( + ChatAdminRights, + ChatBannedRights, + ChannelParticipantsKicked, + ChannelParticipantsAdmins, + InputChatUploadedPhoto, + InputChatPhotoEmpty, + Chat, + Channel, +) +import telethon.errors.rpcerrorlist +import os + +from ..app import mcp, client +from ..exceptions import log_and_format_error +from ..validators import validate_id +from ..formatters import json_serializer +from ..logging_config import logger +from mcp.types import ToolAnnotations + + +@mcp.tool( + annotations=ToolAnnotations( + title="Promote Admin", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("group_id", "user_id") +async def promote_admin( + group_id: Union[int, str], user_id: Union[int, str], rights: dict = None +) -> str: + """ + Promote a user to admin in a group/channel. + + Args: + group_id: ID or username of the group/channel + user_id: User ID or username to promote + rights: Admin rights to give (optional) + """ + try: + chat = await client.get_entity(group_id) + user = await client.get_entity(user_id) + + if not rights: + rights = { + "change_info": True, + "post_messages": True, + "edit_messages": True, + "delete_messages": True, + "ban_users": True, + "invite_users": True, + "pin_messages": True, + "add_admins": False, + "anonymous": False, + "manage_call": True, + "other": True, + } + + admin_rights = ChatAdminRights( + change_info=rights.get("change_info", True), + post_messages=rights.get("post_messages", True), + edit_messages=rights.get("edit_messages", True), + delete_messages=rights.get("delete_messages", True), + ban_users=rights.get("ban_users", True), + invite_users=rights.get("invite_users", True), + pin_messages=rights.get("pin_messages", True), + add_admins=rights.get("add_admins", False), + anonymous=rights.get("anonymous", False), + manage_call=rights.get("manage_call", True), + other=rights.get("other", True), + ) + + try: + result = await client( + functions.channels.EditAdminRequest( + channel=chat, user_id=user, admin_rights=admin_rights, rank="Admin" + ) + ) + return f"Successfully promoted user {user_id} to admin in {chat.title}" + except telethon.errors.rpcerrorlist.UserNotMutualContactError: + return "Error: Cannot promote users who are not mutual contacts. Please ensure the user is in your contacts and has added you back." + except Exception as e: + return log_and_format_error("promote_admin", e, group_id=group_id, user_id=user_id) + + except Exception as e: + logger.error( + f"telegram_mcp promote_admin failed (group_id={group_id}, user_id={user_id})", + exc_info=True, + ) + return log_and_format_error("promote_admin", e, group_id=group_id, user_id=user_id) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Demote Admin", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("group_id", "user_id") +async def demote_admin(group_id: Union[int, str], user_id: Union[int, str]) -> str: + """ + Demote a user from admin in a group/channel. + + Args: + group_id: ID or username of the group/channel + user_id: User ID or username to demote + """ + try: + chat = await client.get_entity(group_id) + user = await client.get_entity(user_id) + + admin_rights = ChatAdminRights( + change_info=False, + post_messages=False, + edit_messages=False, + delete_messages=False, + ban_users=False, + invite_users=False, + pin_messages=False, + add_admins=False, + anonymous=False, + manage_call=False, + other=False, + ) + + try: + result = await client( + functions.channels.EditAdminRequest( + channel=chat, user_id=user, admin_rights=admin_rights, rank="" + ) + ) + return f"Successfully demoted user {user_id} from admin in {chat.title}" + except telethon.errors.rpcerrorlist.UserNotMutualContactError: + return "Error: Cannot modify admin status of users who are not mutual contacts. Please ensure the user is in your contacts and has added you back." + except Exception as e: + return log_and_format_error("demote_admin", e, group_id=group_id, user_id=user_id) + + except Exception as e: + logger.error( + f"telegram_mcp demote_admin failed (group_id={group_id}, user_id={user_id})", + exc_info=True, + ) + return log_and_format_error("demote_admin", e, group_id=group_id, user_id=user_id) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Ban User", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("chat_id", "user_id") +async def ban_user(chat_id: Union[int, str], user_id: Union[int, str]) -> str: + """ + Ban a user from a group or channel. + + Args: + chat_id: ID or username of the group/channel + user_id: User ID or username to ban + """ + try: + chat = await client.get_entity(chat_id) + user = await client.get_entity(user_id) + + banned_rights = ChatBannedRights( + until_date=None, + view_messages=True, + send_messages=True, + send_media=True, + send_stickers=True, + send_gifs=True, + send_games=True, + send_inline=True, + embed_links=True, + send_polls=True, + change_info=True, + invite_users=True, + pin_messages=True, + ) + + try: + await client( + functions.channels.EditBannedRequest( + channel=chat, participant=user, banned_rights=banned_rights + ) + ) + return f"User {user_id} banned from chat {chat.title} (ID: {chat_id})." + except telethon.errors.rpcerrorlist.UserNotMutualContactError: + return "Error: Cannot ban users who are not mutual contacts. Please ensure the user is in your contacts and has added you back." + except Exception as e: + return log_and_format_error("ban_user", e, chat_id=chat_id, user_id=user_id) + except Exception as e: + logger.exception(f"ban_user failed (chat_id={chat_id}, user_id={user_id})") + return log_and_format_error("ban_user", e, chat_id=chat_id, user_id=user_id) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Unban User", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("chat_id", "user_id") +async def unban_user(chat_id: Union[int, str], user_id: Union[int, str]) -> str: + """ + Unban a user from a group or channel. + + Args: + chat_id: ID or username of the group/channel + user_id: User ID or username to unban + """ + try: + chat = await client.get_entity(chat_id) + user = await client.get_entity(user_id) + + unbanned_rights = ChatBannedRights( + until_date=None, + view_messages=False, + send_messages=False, + send_media=False, + send_stickers=False, + send_gifs=False, + send_games=False, + send_inline=False, + embed_links=False, + send_polls=False, + change_info=False, + invite_users=False, + pin_messages=False, + ) + + try: + await client( + functions.channels.EditBannedRequest( + channel=chat, participant=user, banned_rights=unbanned_rights + ) + ) + return f"User {user_id} unbanned from chat {chat.title} (ID: {chat_id})." + except telethon.errors.rpcerrorlist.UserNotMutualContactError: + return "Error: Cannot modify status of users who are not mutual contacts. Please ensure the user is in your contacts and has added you back." + except Exception as e: + return log_and_format_error("unban_user", e, chat_id=chat_id, user_id=user_id) + except Exception as e: + logger.exception(f"unban_user failed (chat_id={chat_id}, user_id={user_id})") + return log_and_format_error("unban_user", e, chat_id=chat_id, user_id=user_id) + + +@mcp.tool(annotations=ToolAnnotations(title="Get Admins", openWorldHint=True, readOnlyHint=True)) +@validate_id("chat_id") +async def get_admins(chat_id: Union[int, str]) -> str: + """ + Get all admins in a group or channel. + """ + try: + participants = await client.get_participants(chat_id, filter=ChannelParticipantsAdmins()) + lines = [ + f"ID: {p.id}, Name: {getattr(p, 'first_name', '')} {getattr(p, 'last_name', '')}".strip() + for p in participants + ] + return "\n".join(lines) if lines else "No admins found." + except Exception as e: + logger.exception(f"get_admins failed (chat_id={chat_id})") + return log_and_format_error("get_admins", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Banned Users", openWorldHint=True, readOnlyHint=True) +) +@validate_id("chat_id") +async def get_banned_users(chat_id: Union[int, str]) -> str: + """ + Get all banned users in a group or channel. + """ + try: + participants = await client.get_participants( + chat_id, filter=ChannelParticipantsKicked(q="") + ) + lines = [ + f"ID: {p.id}, Name: {getattr(p, 'first_name', '')} {getattr(p, 'last_name', '')}".strip() + for p in participants + ] + return "\n".join(lines) if lines else "No banned users found." + except Exception as e: + logger.exception(f"get_banned_users failed (chat_id={chat_id})") + return log_and_format_error("get_banned_users", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Recent Actions", openWorldHint=True, readOnlyHint=True) +) +@validate_id("chat_id") +async def get_recent_actions(chat_id: Union[int, str]) -> str: + """ + Get recent admin actions (admin log) in a group or channel. + """ + try: + result = await client( + functions.channels.GetAdminLogRequest( + channel=chat_id, + q="", + events_filter=None, + admins=[], + max_id=0, + min_id=0, + limit=20, + ) + ) + + if not result or not result.events: + return "No recent admin actions found." + + return json.dumps([e.to_dict() for e in result.events], indent=2, default=json_serializer) + except Exception as e: + logger.exception(f"get_recent_actions failed (chat_id={chat_id})") + return log_and_format_error("get_recent_actions", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Edit Chat Photo", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("chat_id") +async def edit_chat_photo(chat_id: Union[int, str], file_path: str) -> str: + """ + Edit the photo of a chat, group, or channel. Requires a file path to an image. + """ + try: + if not os.path.isfile(file_path): + return f"Photo file not found: {file_path}" + if not os.access(file_path, os.R_OK): + return f"Photo file not readable: {file_path}" + + entity = await client.get_entity(chat_id) + uploaded_file = await client.upload_file(file_path) + + if isinstance(entity, Channel): + input_photo = InputChatUploadedPhoto(file=uploaded_file) + await client(functions.channels.EditPhotoRequest(channel=entity, photo=input_photo)) + elif isinstance(entity, Chat): + input_photo = InputChatUploadedPhoto(file=uploaded_file) + await client( + functions.messages.EditChatPhotoRequest(chat_id=chat_id, photo=input_photo) + ) + else: + return f"Cannot edit photo for this entity type ({type(entity)})." + + return f"Chat {chat_id} photo updated." + except Exception as e: + logger.exception(f"edit_chat_photo failed (chat_id={chat_id}, file_path='{file_path}')") + return log_and_format_error("edit_chat_photo", e, chat_id=chat_id, file_path=file_path) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Delete Chat Photo", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("chat_id") +async def delete_chat_photo(chat_id: Union[int, str]) -> str: + """ + Delete the photo of a chat, group, or channel. + """ + try: + entity = await client.get_entity(chat_id) + if isinstance(entity, Channel): + await client( + functions.channels.EditPhotoRequest(channel=entity, photo=InputChatPhotoEmpty()) + ) + elif isinstance(entity, Chat): + await client( + functions.messages.EditChatPhotoRequest( + chat_id=chat_id, photo=InputChatPhotoEmpty() + ) + ) + else: + return f"Cannot delete photo for this entity type ({type(entity)})." + + return f"Chat {chat_id} photo deleted." + except Exception as e: + logger.exception(f"delete_chat_photo failed (chat_id={chat_id})") + return log_and_format_error("delete_chat_photo", e, chat_id=chat_id) diff --git a/src/telegram_mcp/tools/chats.py b/src/telegram_mcp/tools/chats.py new file mode 100644 index 0000000..42f2036 --- /dev/null +++ b/src/telegram_mcp/tools/chats.py @@ -0,0 +1,863 @@ +"""Chat management tools for telegram-mcp.""" + +import json +from typing import Union, List, Optional +from datetime import datetime, timedelta + +from telethon import functions +from telethon.tl.types import User, Chat, Channel +import telethon.errors.rpcerrorlist + +from ..app import mcp, client +from ..exceptions import log_and_format_error +from ..validators import validate_id +from ..formatters import format_entity, get_sender_name, get_engagement_info +from ..logging_config import logger +from mcp.types import ToolAnnotations + + +@mcp.tool(annotations=ToolAnnotations(title="Get Chats", openWorldHint=True, readOnlyHint=True)) +async def get_chats(page: int = 1, page_size: int = 20) -> str: + """ + Get a paginated list of chats. + Args: + page: Page number (1-indexed). + page_size: Number of chats per page. + """ + try: + dialogs = await client.get_dialogs() + start = (page - 1) * page_size + end = start + page_size + if start >= len(dialogs): + return "Page out of range." + chats = dialogs[start:end] + lines = [] + for dialog in chats: + entity = dialog.entity + chat_id = entity.id + title = getattr(entity, "title", None) or getattr(entity, "first_name", "Unknown") + lines.append(f"Chat ID: {chat_id}, Title: {title}") + return "\n".join(lines) + except Exception as e: + return log_and_format_error("get_chats", e) + + +@mcp.tool(annotations=ToolAnnotations(title="List Chats", openWorldHint=True, readOnlyHint=True)) +async def list_chats(chat_type: str = None, limit: int = 20) -> str: + """ + List available chats with metadata. + + Args: + chat_type: Filter by chat type ('user', 'group', 'channel', or None for all) + limit: Maximum number of chats to retrieve. + """ + try: + dialogs = await client.get_dialogs(limit=limit) + + results = [] + for dialog in dialogs: + entity = dialog.entity + + # Filter by type if requested + current_type = None + if isinstance(entity, User): + current_type = "user" + elif isinstance(entity, Chat): + current_type = "group" + elif isinstance(entity, Channel): + if getattr(entity, "broadcast", False): + current_type = "channel" + else: + current_type = "group" # Supergroup + + if chat_type and current_type != chat_type.lower(): + continue + + # Format chat info + chat_info = f"Chat ID: {entity.id}" + + if hasattr(entity, "title"): + chat_info += f", Title: {entity.title}" + elif hasattr(entity, "first_name"): + name = f"{entity.first_name}" + if hasattr(entity, "last_name") and entity.last_name: + name += f" {entity.last_name}" + chat_info += f", Name: {name}" + + chat_info += f", Type: {current_type}" + + if hasattr(entity, "username") and entity.username: + chat_info += f", Username: @{entity.username}" + + # Add unread count if available + unread_count = getattr(dialog, "unread_count", 0) or 0 + # Also check unread_mark (manual "mark as unread" flag) + inner_dialog = getattr(dialog, "dialog", None) + unread_mark = ( + bool(getattr(inner_dialog, "unread_mark", False)) if inner_dialog else False + ) + + if unread_count > 0: + chat_info += f", Unread: {unread_count}" + elif unread_mark: + chat_info += ", Unread: marked" + else: + chat_info += ", No unread messages" + + results.append(chat_info) + + if not results: + return f"No chats found matching the criteria." + + return "\n".join(results) + except Exception as e: + return log_and_format_error("list_chats", e, chat_type=chat_type, limit=limit) + + +@mcp.tool(annotations=ToolAnnotations(title="Get Chat", openWorldHint=True, readOnlyHint=True)) +@validate_id("chat_id") +async def get_chat(chat_id: Union[int, str]) -> str: + """ + Get detailed information about a specific chat. + + Args: + chat_id: The ID or username of the chat. + """ + try: + entity = await client.get_entity(chat_id) + + result = [] + result.append(f"ID: {entity.id}") + + is_channel = isinstance(entity, Channel) + is_chat = isinstance(entity, Chat) + is_user = isinstance(entity, User) + + if hasattr(entity, "title"): + result.append(f"Title: {entity.title}") + chat_type = ( + "Channel" if is_channel and getattr(entity, "broadcast", False) else "Group" + ) + if is_channel and getattr(entity, "megagroup", False): + chat_type = "Supergroup" + elif is_chat: + chat_type = "Group (Basic)" + result.append(f"Type: {chat_type}") + if hasattr(entity, "username") and entity.username: + result.append(f"Username: @{entity.username}") + + # Fetch participants count reliably + try: + participants_count = (await client.get_participants(entity, limit=0)).total + result.append(f"Participants: {participants_count}") + except Exception as pe: + result.append(f"Participants: Error fetching ({pe})") + + elif is_user: + name = f"{entity.first_name}" + if entity.last_name: + name += f" {entity.last_name}" + result.append(f"Name: {name}") + result.append(f"Type: User") + if entity.username: + result.append(f"Username: @{entity.username}") + if entity.phone: + result.append(f"Phone: {entity.phone}") + result.append(f"Bot: {'Yes' if entity.bot else 'No'}") + result.append(f"Verified: {'Yes' if entity.verified else 'No'}") + + # Get last activity if it's a dialog + try: + dialog = await client.get_dialogs(limit=1, offset_id=0, offset_peer=entity) + if dialog: + dialog = dialog[0] + result.append(f"Unread Messages: {dialog.unread_count}") + if dialog.message: + last_msg = dialog.message + sender_name = "Unknown" + if last_msg.sender: + sender_name = getattr(last_msg.sender, "first_name", "") or getattr( + last_msg.sender, "title", "Unknown" + ) + if hasattr(last_msg.sender, "last_name") and last_msg.sender.last_name: + sender_name += f" {last_msg.sender.last_name}" + sender_name = sender_name.strip() or "Unknown" + result.append(f"Last Message: From {sender_name} at {last_msg.date}") + result.append(f"Message: {last_msg.message or '[Media/No text]'}") + except Exception as diag_ex: + logger.warning(f"Could not get dialog info for {chat_id}: {diag_ex}") + pass + + return "\n".join(result) + except Exception as e: + return log_and_format_error("get_chat", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Subscribe Public Channel", + openWorldHint=True, + destructiveHint=True, + idempotentHint=True, + ) +) +@validate_id("channel") +async def subscribe_public_channel(channel: Union[int, str]) -> str: + """ + Subscribe (join) to a public channel or supergroup by username or ID. + """ + try: + entity = await client.get_entity(channel) + await client(functions.channels.JoinChannelRequest(channel=entity)) + title = getattr(entity, "title", getattr(entity, "username", "Unknown channel")) + return f"Subscribed to {title}." + except telethon.errors.rpcerrorlist.UserAlreadyParticipantError: + title = getattr(entity, "title", getattr(entity, "username", "this channel")) + return f"Already subscribed to {title}." + except telethon.errors.rpcerrorlist.ChannelPrivateError: + return "Cannot subscribe: this channel is private or requires an invite link." + except Exception as e: + return log_and_format_error("subscribe_public_channel", e, channel=channel) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Leave Chat", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("chat_id") +async def leave_chat(chat_id: Union[int, str]) -> str: + """ + Leave a group or channel by chat ID. + + Args: + chat_id: The chat ID or username to leave. + """ + try: + entity = await client.get_entity(chat_id) + + # Check the entity type carefully + if isinstance(entity, Channel): + # Handle both channels and supergroups (which are also channels in Telegram) + try: + await client(functions.channels.LeaveChannelRequest(channel=entity)) + chat_name = getattr(entity, "title", str(chat_id)) + return f"Left channel/supergroup {chat_name} (ID: {chat_id})." + except Exception as chan_err: + return log_and_format_error("leave_chat", chan_err, chat_id=chat_id) + + elif isinstance(entity, Chat): + # Traditional basic groups (not supergroups) + try: + # First try with InputPeerUser + me = await client.get_me(input_peer=True) + await client( + functions.messages.DeleteChatUserRequest( + chat_id=entity.id, + user_id=me, + ) + ) + chat_name = getattr(entity, "title", str(chat_id)) + return f"Left basic group {chat_name} (ID: {chat_id})." + except Exception as chat_err: + # If the above fails, try the second approach + logger.warning( + f"First leave attempt failed: {chat_err}, trying alternative method" + ) + + try: + # Alternative approach - sometimes this works better + me_full = await client.get_me() + await client( + functions.messages.DeleteChatUserRequest( + chat_id=entity.id, user_id=me_full.id + ) + ) + chat_name = getattr(entity, "title", str(chat_id)) + return f"Left basic group {chat_name} (ID: {chat_id})." + except Exception as alt_err: + return log_and_format_error("leave_chat", alt_err, chat_id=chat_id) + else: + # Cannot leave a user chat this way + entity_type = type(entity).__name__ + return log_and_format_error( + "leave_chat", + Exception( + f"Cannot leave chat ID {chat_id} of type {entity_type}. This function is for groups and channels only." + ), + chat_id=chat_id, + ) + + except Exception as e: + logger.exception(f"leave_chat failed (chat_id={chat_id})") + + # Provide helpful hint for common errors + error_str = str(e).lower() + if "invalid" in error_str and "chat" in error_str: + return log_and_format_error( + "leave_chat", + Exception( + f"Error leaving chat: This appears to be a channel/supergroup. Please check the chat ID and try again." + ), + chat_id=chat_id, + ) + + return log_and_format_error("leave_chat", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Participants", openWorldHint=True, readOnlyHint=True) +) +@validate_id("chat_id") +async def get_participants(chat_id: Union[int, str]) -> str: + """ + List all participants in a group or channel. + Args: + chat_id: The group or channel ID or username. + """ + try: + participants = await client.get_participants(chat_id) + lines = [ + f"ID: {p.id}, Name: {getattr(p, 'first_name', '')} {getattr(p, 'last_name', '')}" + for p in participants + ] + return "\n".join(lines) + except Exception as e: + return log_and_format_error("get_participants", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Create Group", openWorldHint=True, destructiveHint=True) +) +@validate_id("user_ids") +async def create_group(title: str, user_ids: List[Union[int, str]]) -> str: + """ + Create a new group or supergroup and add users. + + Args: + title: Title for the new group + user_ids: List of user IDs or usernames to add to the group + """ + import asyncio + try: + # Convert user IDs to entities + users = [] + for user_id in user_ids: + try: + user = await client.get_entity(user_id) + users.append(user) + except Exception as e: + logger.error(f"Failed to get entity for user ID {user_id}: {e}") + return f"Error: Could not find user with ID {user_id}" + + if not users: + return "Error: No valid users provided" + + # Create the group with the users + try: + # Create a new chat with selected users + result = await client(functions.messages.CreateChatRequest(users=users, title=title)) + + # Check what type of response we got + if hasattr(result, "chats") and result.chats: + created_chat = result.chats[0] + return f"Group created with ID: {created_chat.id}" + elif hasattr(result, "chat") and result.chat: + return f"Group created with ID: {result.chat.id}" + elif hasattr(result, "chat_id"): + return f"Group created with ID: {result.chat_id}" + else: + # If we can't determine the chat ID directly from the result + # Try to find it in recent dialogs + await asyncio.sleep(1) # Give Telegram a moment to register the new group + dialogs = await client.get_dialogs(limit=5) # Get recent dialogs + for dialog in dialogs: + if dialog.title == title: + return f"Group created with ID: {dialog.id}" + + # If we still can't find it, at least return success + return f"Group created successfully. Please check your recent chats for '{title}'." + + except Exception as create_err: + if "PEER_FLOOD" in str(create_err): + return "Error: Cannot create group due to Telegram limits. Try again later." + else: + raise # Let the outer exception handler catch it + except Exception as e: + logger.exception(f"create_group failed (title={title}, user_ids={user_ids})") + return log_and_format_error("create_group", e, title=title, user_ids=user_ids) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Invite To Group", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("group_id", "user_ids") +async def invite_to_group(group_id: Union[int, str], user_ids: List[Union[int, str]]) -> str: + """ + Invite users to a group or channel. + + Args: + group_id: The ID or username of the group/channel. + user_ids: List of user IDs or usernames to invite. + """ + try: + entity = await client.get_entity(group_id) + users_to_add = [] + + for user_id in user_ids: + try: + user = await client.get_entity(user_id) + users_to_add.append(user) + except ValueError as e: + return f"Error: User with ID {user_id} could not be found. {e}" + + try: + result = await client( + functions.channels.InviteToChannelRequest(channel=entity, users=users_to_add) + ) + + invited_count = 0 + if hasattr(result, "users") and result.users: + invited_count = len(result.users) + elif hasattr(result, "count"): + invited_count = result.count + + return f"Successfully invited {invited_count} users to {entity.title}" + except telethon.errors.rpcerrorlist.UserNotMutualContactError: + return "Error: Cannot invite users who are not mutual contacts. Please ensure the users are in your contacts and have added you back." + except telethon.errors.rpcerrorlist.UserPrivacyRestrictedError: + return ( + "Error: One or more users have privacy settings that prevent you from adding them." + ) + except Exception as e: + return log_and_format_error("invite_to_group", e, group_id=group_id, user_ids=user_ids) + + except Exception as e: + logger.error( + f"telegram_mcp invite_to_group failed (group_id={group_id}, user_ids={user_ids})", + exc_info=True, + ) + return log_and_format_error("invite_to_group", e, group_id=group_id, user_ids=user_ids) + + +@mcp.tool( + annotations=ToolAnnotations(title="Create Channel", openWorldHint=True, destructiveHint=True) +) +async def create_channel(title: str, about: str = "", megagroup: bool = False) -> str: + """ + Create a new channel or supergroup. + """ + try: + result = await client( + functions.channels.CreateChannelRequest(title=title, about=about, megagroup=megagroup) + ) + return f"Channel '{title}' created with ID: {result.chats[0].id}" + except Exception as e: + return log_and_format_error( + "create_channel", e, title=title, about=about, megagroup=megagroup + ) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Edit Chat Title", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("chat_id") +async def edit_chat_title(chat_id: Union[int, str], title: str) -> str: + """ + Edit the title of a chat, group, or channel. + """ + try: + entity = await client.get_entity(chat_id) + if isinstance(entity, Channel): + await client(functions.channels.EditTitleRequest(channel=entity, title=title)) + elif isinstance(entity, Chat): + await client(functions.messages.EditChatTitleRequest(chat_id=chat_id, title=title)) + else: + return f"Cannot edit title for this entity type ({type(entity)})." + return f"Chat {chat_id} title updated to '{title}'." + except Exception as e: + logger.exception(f"edit_chat_title failed (chat_id={chat_id}, title='{title}')") + return log_and_format_error("edit_chat_title", e, chat_id=chat_id, title=title) + + +@mcp.tool(annotations=ToolAnnotations(title="List Topics", openWorldHint=True, readOnlyHint=True)) +async def list_topics( + chat_id: int, + limit: int = 200, + offset_topic: int = 0, + search_query: str = None, +) -> str: + """ + Retrieve forum topics from a supergroup with the forum feature enabled. + + Note for LLM: You can send a message to a selected topic via reply_to_message tool + by using Topic ID as the message_id parameter. + + Args: + chat_id: The ID of the forum-enabled chat (supergroup). + limit: Maximum number of topics to retrieve. + offset_topic: Topic ID offset for pagination. + search_query: Optional query to filter topics by title. + """ + try: + entity = await client.get_entity(chat_id) + + if not isinstance(entity, Channel) or not getattr(entity, "megagroup", False): + return "The specified chat is not a supergroup." + + if not getattr(entity, "forum", False): + return "The specified supergroup does not have forum topics enabled." + + result = await client( + functions.channels.GetForumTopicsRequest( + channel=entity, + offset_date=0, + offset_id=0, + offset_topic=offset_topic, + limit=limit, + q=search_query or None, + ) + ) + + topics = getattr(result, "topics", None) or [] + if not topics: + return "No topics found for this chat." + + messages_map = {} + if getattr(result, "messages", None): + messages_map = {message.id: message for message in result.messages} + + lines = [] + for topic in topics: + line_parts = [f"Topic ID: {topic.id}"] + + title = getattr(topic, "title", None) or "(no title)" + line_parts.append(f"Title: {title}") + + total_messages = getattr(topic, "total_messages", None) + if total_messages is not None: + line_parts.append(f"Messages: {total_messages}") + + unread_count = getattr(topic, "unread_count", None) + if unread_count: + line_parts.append(f"Unread: {unread_count}") + + if getattr(topic, "closed", False): + line_parts.append("Closed: Yes") + + if getattr(topic, "hidden", False): + line_parts.append("Hidden: Yes") + + top_message_id = getattr(topic, "top_message", None) + top_message = messages_map.get(top_message_id) + if top_message and getattr(top_message, "date", None): + line_parts.append(f"Last Activity: {top_message.date.isoformat()}") + + lines.append(" | ".join(line_parts)) + + return "\n".join(lines) + except Exception as e: + return log_and_format_error( + "list_topics", + e, + chat_id=chat_id, + limit=limit, + offset_topic=offset_topic, + search_query=search_query, + ) + + +@mcp.tool( + annotations=ToolAnnotations(title="Search Public Chats", openWorldHint=True, readOnlyHint=True) +) +async def search_public_chats(query: str) -> str: + """ + Search for public chats, channels, or bots by username or title. + """ + try: + result = await client(functions.contacts.SearchRequest(q=query, limit=20)) + return json.dumps([format_entity(u) for u in result.users], indent=2) + except Exception as e: + return log_and_format_error("search_public_chats", e, query=query) + + +@mcp.tool( + annotations=ToolAnnotations(title="Mute Chat", openWorldHint=True, destructiveHint=True, idempotentHint=True) +) +@validate_id("chat_id") +async def mute_chat(chat_id: Union[int, str]) -> str: + """ + Mute notifications for a chat. + """ + try: + from telethon.tl.types import InputPeerNotifySettings + + peer = await client.get_entity(chat_id) + await client( + functions.account.UpdateNotifySettingsRequest( + peer=peer, settings=InputPeerNotifySettings(mute_until=2**31 - 1) + ) + ) + return f"Chat {chat_id} muted." + except (ImportError, AttributeError) as type_err: + try: + peer = await client.get_input_entity(chat_id) + await client( + functions.account.UpdateNotifySettingsRequest( + peer=peer, + settings={ + "mute_until": 2**31 - 1, + "show_previews": False, + "silent": True, + }, + ) + ) + return f"Chat {chat_id} muted (using alternative method)." + except Exception as alt_e: + logger.exception(f"mute_chat (alt method) failed (chat_id={chat_id})") + return log_and_format_error("mute_chat", alt_e, chat_id=chat_id) + except Exception as e: + logger.exception(f"mute_chat failed (chat_id={chat_id})") + return log_and_format_error("mute_chat", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Unmute Chat", openWorldHint=True, destructiveHint=True, idempotentHint=True) +) +@validate_id("chat_id") +async def unmute_chat(chat_id: Union[int, str]) -> str: + """ + Unmute notifications for a chat. + """ + try: + from telethon.tl.types import InputPeerNotifySettings + + peer = await client.get_entity(chat_id) + await client( + functions.account.UpdateNotifySettingsRequest( + peer=peer, settings=InputPeerNotifySettings(mute_until=0) + ) + ) + return f"Chat {chat_id} unmuted." + except (ImportError, AttributeError) as type_err: + try: + peer = await client.get_input_entity(chat_id) + await client( + functions.account.UpdateNotifySettingsRequest( + peer=peer, + settings={ + "mute_until": 0, + "show_previews": True, + "silent": False, + }, + ) + ) + return f"Chat {chat_id} unmuted (using alternative method)." + except Exception as alt_e: + logger.exception(f"unmute_chat (alt method) failed (chat_id={chat_id})") + return log_and_format_error("unmute_chat", alt_e, chat_id=chat_id) + except Exception as e: + logger.exception(f"unmute_chat failed (chat_id={chat_id})") + return log_and_format_error("unmute_chat", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Archive Chat", openWorldHint=True, destructiveHint=True, idempotentHint=True) +) +@validate_id("chat_id") +async def archive_chat(chat_id: Union[int, str]) -> str: + """ + Archive a chat. + """ + try: + await client( + functions.messages.ToggleDialogPinRequest( + peer=await client.get_entity(chat_id), pinned=True + ) + ) + return f"Chat {chat_id} archived." + except Exception as e: + return log_and_format_error("archive_chat", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Unarchive Chat", openWorldHint=True, destructiveHint=True, idempotentHint=True) +) +@validate_id("chat_id") +async def unarchive_chat(chat_id: Union[int, str]) -> str: + """ + Unarchive a chat. + """ + try: + await client( + functions.messages.ToggleDialogPinRequest( + peer=await client.get_entity(chat_id), pinned=False + ) + ) + return f"Chat {chat_id} unarchived." + except Exception as e: + return log_and_format_error("unarchive_chat", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Invite Link", openWorldHint=True, readOnlyHint=True) +) +@validate_id("chat_id") +async def get_invite_link(chat_id: Union[int, str]) -> str: + """ + Get the invite link for a group or channel. + """ + try: + entity = await client.get_entity(chat_id) + + try: + result = await client(functions.messages.ExportChatInviteRequest(peer=entity)) + return result.link + except AttributeError: + logger.warning("ExportChatInviteRequest not available, using alternative method") + except Exception as e1: + logger.warning(f"ExportChatInviteRequest failed: {e1}") + + try: + invite_link = await client.export_chat_invite_link(entity) + return invite_link + except Exception as e2: + logger.warning(f"export_chat_invite_link failed: {e2}") + + try: + if isinstance(entity, (Chat, Channel)): + full_chat = await client(functions.messages.GetFullChatRequest(chat_id=entity.id)) + if hasattr(full_chat, "full_chat") and hasattr(full_chat.full_chat, "invite_link"): + return full_chat.full_chat.invite_link or "No invite link available." + except Exception as e3: + logger.warning(f"GetFullChatRequest failed: {e3}") + + return "Could not retrieve invite link for this chat." + except Exception as e: + logger.exception(f"get_invite_link failed (chat_id={chat_id})") + return log_and_format_error("get_invite_link", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Join Chat By Link", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +async def join_chat_by_link(link: str) -> str: + """ + Join a chat by invite link. + """ + try: + # Extract the hash from the invite link + if "/" in link: + hash_part = link.split("/")[-1] + if hash_part.startswith("+"): + hash_part = hash_part[1:] + else: + hash_part = link + + try: + invite_info = await client(functions.messages.CheckChatInviteRequest(hash=hash_part)) + if hasattr(invite_info, "chat") and invite_info.chat: + chat_title = getattr(invite_info.chat, "title", "Unknown Chat") + return f"You are already a member of this chat: {chat_title}" + except Exception: + pass + + result = await client(functions.messages.ImportChatInviteRequest(hash=hash_part)) + if result and hasattr(result, "chats") and result.chats: + chat_title = getattr(result.chats[0], "title", "Unknown Chat") + return f"Successfully joined chat: {chat_title}" + return f"Joined chat via invite hash." + except Exception as e: + err_str = str(e).lower() + if "expired" in err_str: + return "The invite hash has expired and is no longer valid." + elif "invalid" in err_str: + return "The invite hash is invalid or malformed." + elif "already" in err_str and "participant" in err_str: + return "You are already a member of this chat." + logger.exception(f"join_chat_by_link failed (link={link})") + return f"Error joining chat: {e}" + + +@mcp.tool( + annotations=ToolAnnotations(title="Export Chat Invite", openWorldHint=True, readOnlyHint=True) +) +@validate_id("chat_id") +async def export_chat_invite(chat_id: Union[int, str]) -> str: + """ + Export a chat invite link. + """ + try: + entity = await client.get_entity(chat_id) + + try: + result = await client(functions.messages.ExportChatInviteRequest(peer=entity)) + return result.link + except AttributeError: + logger.warning("ExportChatInviteRequest not available, using alternative method") + except Exception as e1: + logger.warning(f"ExportChatInviteRequest failed: {e1}") + + try: + invite_link = await client.export_chat_invite_link(entity) + return invite_link + except Exception as e2: + logger.warning(f"export_chat_invite_link failed: {e2}") + return log_and_format_error("export_chat_invite", e2, chat_id=chat_id) + + except Exception as e: + logger.exception(f"export_chat_invite failed (chat_id={chat_id})") + return log_and_format_error("export_chat_invite", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Import Chat Invite", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +async def import_chat_invite(hash: str) -> str: + """ + Import a chat invite by hash. + """ + try: + if hash.startswith("+"): + hash = hash[1:] + + try: + invite_info = await client(functions.messages.CheckChatInviteRequest(hash=hash)) + if hasattr(invite_info, "chat") and invite_info.chat: + chat_title = getattr(invite_info.chat, "title", "Unknown Chat") + return f"You are already a member of this chat: {chat_title}" + except Exception as check_err: + pass + + try: + result = await client(functions.messages.ImportChatInviteRequest(hash=hash)) + if result and hasattr(result, "chats") and result.chats: + chat_title = getattr(result.chats[0], "title", "Unknown Chat") + return f"Successfully joined chat: {chat_title}" + return f"Joined chat via invite hash." + except Exception as join_err: + err_str = str(join_err).lower() + if "expired" in err_str: + return "The invite hash has expired and is no longer valid." + elif "invalid" in err_str: + return "The invite hash is invalid or malformed." + elif "already" in err_str and "participant" in err_str: + return "You are already a member of this chat." + elif "admin" in err_str: + return "Cannot join this chat - requires admin approval." + elif "too much" in err_str or "too many" in err_str: + return "Cannot join this chat - it has reached maximum number of participants." + else: + raise + + except Exception as e: + logger.exception(f"import_chat_invite failed (hash={hash})") + return log_and_format_error("import_chat_invite", e, hash=hash) diff --git a/src/telegram_mcp/tools/contacts.py b/src/telegram_mcp/tools/contacts.py new file mode 100644 index 0000000..caba0e3 --- /dev/null +++ b/src/telegram_mcp/tools/contacts.py @@ -0,0 +1,414 @@ +"""Contact management tools for telegram-mcp.""" + +import json +from typing import Union, List, Optional + +from telethon import functions +from telethon.tl.types import User + +from ..app import mcp, client +from ..exceptions import log_and_format_error +from ..validators import validate_id +from ..formatters import format_entity +from ..logging_config import logger +from mcp.types import ToolAnnotations + + +@mcp.tool( + annotations=ToolAnnotations(title="List Contacts", openWorldHint=True, readOnlyHint=True) +) +async def list_contacts() -> str: + """ + List all contacts in your Telegram account. + """ + try: + result = await client(functions.contacts.GetContactsRequest(hash=0)) + users = result.users + if not users: + return "No contacts found." + lines = [] + for user in users: + name = f"{getattr(user, 'first_name', '')} {getattr(user, 'last_name', '')}".strip() + username = getattr(user, "username", "") + phone = getattr(user, "phone", "") + contact_info = f"ID: {user.id}, Name: {name}" + if username: + contact_info += f", Username: @{username}" + if phone: + contact_info += f", Phone: {phone}" + lines.append(contact_info) + return "\n".join(lines) + except Exception as e: + return log_and_format_error("list_contacts", e) + + +@mcp.tool( + annotations=ToolAnnotations(title="Search Contacts", openWorldHint=True, readOnlyHint=True) +) +async def search_contacts(query: str) -> str: + """ + Search for contacts by name, username, or phone number using Telethon's SearchRequest. + Args: + query: The search term to look for in contact names, usernames, or phone numbers. + """ + try: + result = await client(functions.contacts.SearchRequest(q=query, limit=50)) + users = result.users + if not users: + return f"No contacts found matching '{query}'." + lines = [] + for user in users: + name = f"{getattr(user, 'first_name', '')} {getattr(user, 'last_name', '')}".strip() + username = getattr(user, "username", "") + phone = getattr(user, "phone", "") + contact_info = f"ID: {user.id}, Name: {name}" + if username: + contact_info += f", Username: @{username}" + if phone: + contact_info += f", Phone: {phone}" + lines.append(contact_info) + return "\n".join(lines) + except Exception as e: + return log_and_format_error("search_contacts", e, query=query) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Contact Ids", openWorldHint=True, readOnlyHint=True) +) +async def get_contact_ids() -> str: + """ + Get all contact IDs in your Telegram account. + """ + try: + result = await client(functions.contacts.GetContactIDsRequest(hash=0)) + if not result: + return "No contact IDs found." + return "Contact IDs: " + ", ".join(str(cid) for cid in result) + except Exception as e: + return log_and_format_error("get_contact_ids", e) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Get Direct Chat By Contact", openWorldHint=True, readOnlyHint=True + ) +) +async def get_direct_chat_by_contact(contact_query: str) -> str: + """ + Find a direct chat with a specific contact by name, username, or phone. + + Args: + contact_query: Name, username, or phone number to search for. + """ + try: + result = await client(functions.contacts.GetContactsRequest(hash=0)) + contacts = result.users + found_contacts = [] + for contact in contacts: + if not contact: + continue + name = ( + f"{getattr(contact, 'first_name', '')} {getattr(contact, 'last_name', '')}".strip() + ) + username = getattr(contact, "username", "") + phone = getattr(contact, "phone", "") + if ( + contact_query.lower() in name.lower() + or (username and contact_query.lower() in username.lower()) + or (phone and contact_query in phone) + ): + found_contacts.append(contact) + if not found_contacts: + return f"No contacts found matching '{contact_query}'." + results = [] + dialogs = await client.get_dialogs() + for contact in found_contacts: + contact_name = ( + f"{getattr(contact, 'first_name', '')} {getattr(contact, 'last_name', '')}".strip() + ) + for dialog in dialogs: + if isinstance(dialog.entity, User) and dialog.entity.id == contact.id: + chat_info = f"Chat ID: {dialog.entity.id}, Contact: {contact_name}" + if getattr(contact, "username", ""): + chat_info += f", Username: @{contact.username}" + if dialog.unread_count: + chat_info += f", Unread: {dialog.unread_count}" + results.append(chat_info) + break + if not results: + found_names = ", ".join( + [f"{c.first_name} {c.last_name}".strip() for c in found_contacts] + ) + return f"Found contacts: {found_names}, but no direct chats were found with them." + return "\n".join(results) + except Exception as e: + return log_and_format_error("get_direct_chat_by_contact", e, contact_query=contact_query) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Contact Chats", openWorldHint=True, readOnlyHint=True) +) +@validate_id("contact_id") +async def get_contact_chats(contact_id: Union[int, str]) -> str: + """ + List all chats involving a specific contact. + + Args: + contact_id: The ID or username of the contact. + """ + try: + contact = await client.get_entity(contact_id) + if not isinstance(contact, User): + return f"ID {contact_id} is not a user/contact." + + contact_name = ( + f"{getattr(contact, 'first_name', '')} {getattr(contact, 'last_name', '')}".strip() + ) + + dialogs = await client.get_dialogs() + results = [] + + for dialog in dialogs: + if isinstance(dialog.entity, User) and dialog.entity.id == contact_id: + chat_info = f"Direct Chat ID: {dialog.entity.id}, Type: Private" + if dialog.unread_count: + chat_info += f", Unread: {dialog.unread_count}" + results.append(chat_info) + break + + try: + common = await client.get_common_chats(contact) + for chat in common: + chat_type = "Channel" if getattr(chat, "broadcast", False) else "Group" + chat_info = f"Chat ID: {chat.id}, Title: {chat.title}, Type: {chat_type}" + results.append(chat_info) + except: + results.append("Could not retrieve common groups.") + + if not results: + return f"No chats found with {contact_name} (ID: {contact_id})." + + return f"Chats with {contact_name} (ID: {contact_id}):\n" + "\n".join(results) + except Exception as e: + return log_and_format_error("get_contact_chats", e, contact_id=contact_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Last Interaction", openWorldHint=True, readOnlyHint=True) +) +@validate_id("contact_id") +async def get_last_interaction(contact_id: Union[int, str]) -> str: + """ + Get the most recent message with a contact. + + Args: + contact_id: The ID or username of the contact. + """ + try: + contact = await client.get_entity(contact_id) + if not isinstance(contact, User): + return f"ID {contact_id} is not a user/contact." + + contact_name = ( + f"{getattr(contact, 'first_name', '')} {getattr(contact, 'last_name', '')}".strip() + ) + + messages = await client.get_messages(contact, limit=5) + + if not messages: + return f"No messages found with {contact_name} (ID: {contact_id})." + + results = [f"Last interactions with {contact_name} (ID: {contact_id}):"] + + for msg in messages: + sender = "You" if msg.out else contact_name + message_text = msg.message or "[Media/No text]" + results.append(f"Date: {msg.date}, From: {sender}, Message: {message_text}") + + return "\n".join(results) + except Exception as e: + return log_and_format_error("get_last_interaction", e, contact_id=contact_id) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Add Contact", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +async def add_contact(phone: str, first_name: str, last_name: str = "") -> str: + """ + Add a new contact to your Telegram account. + Args: + phone: The phone number of the contact (with country code). + first_name: The contact's first name. + last_name: The contact's last name (optional). + """ + try: + from telethon.tl.types import InputPhoneContact + + result = await client( + functions.contacts.ImportContactsRequest( + contacts=[ + InputPhoneContact( + client_id=0, + phone=phone, + first_name=first_name, + last_name=last_name, + ) + ] + ) + ) + if result.imported: + return f"Contact {first_name} {last_name} added successfully." + else: + return f"Contact not added. Response: {str(result)}" + except (ImportError, AttributeError) as type_err: + try: + result = await client( + functions.contacts.ImportContactsRequest( + contacts=[ + { + "client_id": 0, + "phone": phone, + "first_name": first_name, + "last_name": last_name, + } + ] + ) + ) + if hasattr(result, "imported") and result.imported: + return f"Contact {first_name} {last_name} added successfully (alt method)." + else: + return f"Contact not added. Alternative method response: {str(result)}" + except Exception as alt_e: + logger.exception(f"add_contact (alt method) failed (phone={phone})") + return log_and_format_error("add_contact", alt_e, phone=phone) + except Exception as e: + logger.exception(f"add_contact failed (phone={phone})") + return log_and_format_error("add_contact", e, phone=phone) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Delete Contact", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("user_id") +async def delete_contact(user_id: Union[int, str]) -> str: + """ + Delete a contact by user ID. + Args: + user_id: The Telegram user ID or username of the contact to delete. + """ + try: + user = await client.get_entity(user_id) + await client(functions.contacts.DeleteContactsRequest(id=[user])) + return f"Contact with user ID {user_id} deleted." + except Exception as e: + return log_and_format_error("delete_contact", e, user_id=user_id) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Block User", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("user_id") +async def block_user(user_id: Union[int, str]) -> str: + """ + Block a user by user ID. + Args: + user_id: The Telegram user ID or username to block. + """ + try: + user = await client.get_entity(user_id) + await client(functions.contacts.BlockRequest(id=user)) + return f"User {user_id} blocked." + except Exception as e: + return log_and_format_error("block_user", e, user_id=user_id) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Unblock User", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("user_id") +async def unblock_user(user_id: Union[int, str]) -> str: + """ + Unblock a user by user ID. + Args: + user_id: The Telegram user ID or username to unblock. + """ + try: + user = await client.get_entity(user_id) + await client(functions.contacts.UnblockRequest(id=user)) + return f"User {user_id} unblocked." + except Exception as e: + return log_and_format_error("unblock_user", e, user_id=user_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Import Contacts", openWorldHint=True, destructiveHint=True) +) +async def import_contacts(contacts: list) -> str: + """ + Import a list of contacts. Each contact should be a dict with phone, first_name, last_name. + """ + try: + input_contacts = [ + functions.contacts.InputPhoneContact( + client_id=i, + phone=c["phone"], + first_name=c["first_name"], + last_name=c.get("last_name", ""), + ) + for i, c in enumerate(contacts) + ] + result = await client(functions.contacts.ImportContactsRequest(contacts=input_contacts)) + return f"Imported {len(result.imported)} contacts." + except Exception as e: + return log_and_format_error("import_contacts", e, contacts=contacts) + + +@mcp.tool( + annotations=ToolAnnotations(title="Export Contacts", openWorldHint=True, readOnlyHint=True) +) +async def export_contacts() -> str: + """ + Export all contacts as a JSON string. + """ + try: + result = await client(functions.contacts.GetContactsRequest(hash=0)) + users = result.users + return json.dumps([format_entity(u) for u in users], indent=2) + except Exception as e: + return log_and_format_error("export_contacts", e) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Blocked Users", openWorldHint=True, readOnlyHint=True) +) +async def get_blocked_users() -> str: + """ + Get a list of blocked users. + """ + try: + result = await client(functions.contacts.GetBlockedRequest(offset=0, limit=100)) + return json.dumps([format_entity(u) for u in result.users], indent=2) + except Exception as e: + return log_and_format_error("get_blocked_users", e) + + +@mcp.tool( + annotations=ToolAnnotations(title="Resolve Username", openWorldHint=True, readOnlyHint=True) +) +async def resolve_username(username: str) -> str: + """ + Resolve a username to a user or chat ID. + """ + try: + result = await client(functions.contacts.ResolveUsernameRequest(username=username)) + return str(result) + except Exception as e: + return log_and_format_error("resolve_username", e, username=username) diff --git a/src/telegram_mcp/tools/folders.py b/src/telegram_mcp/tools/folders.py new file mode 100644 index 0000000..7c6374b --- /dev/null +++ b/src/telegram_mcp/tools/folders.py @@ -0,0 +1,507 @@ +"""Folder management tools for telegram-mcp.""" + +import json +from typing import Union, List, Optional + +from telethon import functions, utils +from telethon.tl.types import DialogFilter, DialogFilterDefault, TextWithEntities + +from ..app import mcp, client +from ..exceptions import log_and_format_error, ErrorCategory +from ..validators import validate_id +from ..formatters import json_serializer +from ..logging_config import logger +from mcp.types import ToolAnnotations + + +@mcp.tool(annotations=ToolAnnotations(title="List Folders", openWorldHint=True, readOnlyHint=True)) +async def list_folders() -> str: + """ + Get all dialog folders (filters) with their IDs, names, and emoji. + Returns a list of folders that can be used with other folder tools. + """ + try: + result = await client(functions.messages.GetDialogFiltersRequest()) + + folders = [] + for f in result.filters: + if isinstance(f, DialogFilterDefault): + continue + + if isinstance(f, DialogFilter): + title = f.title + if isinstance(title, TextWithEntities): + title = title.text + folder_data = { + "id": f.id, + "title": title, + "emoticon": getattr(f, "emoticon", None), + "contacts": getattr(f, "contacts", False), + "non_contacts": getattr(f, "non_contacts", False), + "groups": getattr(f, "groups", False), + "broadcasts": getattr(f, "broadcasts", False), + "bots": getattr(f, "bots", False), + "exclude_muted": getattr(f, "exclude_muted", False), + "exclude_read": getattr(f, "exclude_read", False), + "exclude_archived": getattr(f, "exclude_archived", False), + "included_peers_count": len(getattr(f, "include_peers", [])), + "excluded_peers_count": len(getattr(f, "exclude_peers", [])), + "pinned_peers_count": len(getattr(f, "pinned_peers", [])), + } + folders.append(folder_data) + + if not folders: + return "No folders found. Create one with create_folder tool." + + return json.dumps( + {"folders": folders, "count": len(folders)}, indent=2, default=json_serializer + ) + except Exception as e: + logger.exception("list_folders failed") + return log_and_format_error("list_folders", e, ErrorCategory.FOLDER) + + +@mcp.tool(annotations=ToolAnnotations(title="Get Folder", openWorldHint=True, readOnlyHint=True)) +async def get_folder(folder_id: int) -> str: + """ + Get detailed information about a specific folder including all included chats. + + Args: + folder_id: The folder ID (get from list_folders) + """ + try: + result = await client(functions.messages.GetDialogFiltersRequest()) + + target_folder = None + for f in result.filters: + if isinstance(f, DialogFilter) and f.id == folder_id: + target_folder = f + break + + if not target_folder: + return ( + f"Folder with ID {folder_id} not found. Use list_folders to see available folders." + ) + + included_chats = [] + for peer in getattr(target_folder, "include_peers", []): + try: + entity = await client.get_entity(peer) + chat_info = { + "id": entity.id, + "name": getattr(entity, "title", None) + or getattr(entity, "first_name", "Unknown"), + "type": type(entity).__name__, + } + if hasattr(entity, "username") and entity.username: + chat_info["username"] = entity.username + included_chats.append(chat_info) + except Exception: + included_chats.append({"id": str(peer), "name": "Unknown", "type": "Unknown"}) + + excluded_chats = [] + for peer in getattr(target_folder, "exclude_peers", []): + try: + entity = await client.get_entity(peer) + chat_info = { + "id": entity.id, + "name": getattr(entity, "title", None) + or getattr(entity, "first_name", "Unknown"), + "type": type(entity).__name__, + } + excluded_chats.append(chat_info) + except Exception: + excluded_chats.append({"id": str(peer), "name": "Unknown", "type": "Unknown"}) + + pinned_chats = [] + for peer in getattr(target_folder, "pinned_peers", []): + try: + entity = await client.get_entity(peer) + chat_info = { + "id": entity.id, + "name": getattr(entity, "title", None) + or getattr(entity, "first_name", "Unknown"), + "type": type(entity).__name__, + } + pinned_chats.append(chat_info) + except Exception: + pinned_chats.append({"id": str(peer), "name": "Unknown", "type": "Unknown"}) + + title = target_folder.title + if isinstance(title, TextWithEntities): + title = title.text + + folder_data = { + "id": target_folder.id, + "title": title, + "emoticon": getattr(target_folder, "emoticon", None), + "filters": { + "contacts": getattr(target_folder, "contacts", False), + "non_contacts": getattr(target_folder, "non_contacts", False), + "groups": getattr(target_folder, "groups", False), + "broadcasts": getattr(target_folder, "broadcasts", False), + "bots": getattr(target_folder, "bots", False), + "exclude_muted": getattr(target_folder, "exclude_muted", False), + "exclude_read": getattr(target_folder, "exclude_read", False), + "exclude_archived": getattr(target_folder, "exclude_archived", False), + }, + "included_chats": included_chats, + "excluded_chats": excluded_chats, + "pinned_chats": pinned_chats, + } + + return json.dumps(folder_data, indent=2, default=json_serializer) + except Exception as e: + logger.exception(f"get_folder failed (folder_id={folder_id})") + return log_and_format_error("get_folder", e, ErrorCategory.FOLDER, folder_id=folder_id) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Create Folder", openWorldHint=True, destructiveHint=True, idempotentHint=False + ) +) +async def create_folder( + title: str, + emoticon: Optional[str] = None, + chat_ids: Optional[List[Union[int, str]]] = None, + contacts: bool = False, + non_contacts: bool = False, + groups: bool = False, + broadcasts: bool = False, + bots: bool = False, + exclude_muted: bool = False, + exclude_read: bool = False, + exclude_archived: bool = True, +) -> str: + """ + Create a new dialog folder. + + Args: + title: Folder name (required) + emoticon: Folder emoji (optional, e.g., "๐Ÿ“", "๐Ÿ ", "๐Ÿ’ผ") + chat_ids: List of chat IDs or usernames to include (optional) + contacts: Include all contacts + non_contacts: Include all non-contacts + groups: Include all groups + broadcasts: Include all channels + bots: Include all bots + exclude_muted: Exclude muted chats + exclude_read: Exclude read chats + exclude_archived: Exclude archived chats (default True) + """ + try: + result = await client(functions.messages.GetDialogFiltersRequest()) + + existing_ids = set() + folder_count = 0 + for f in result.filters: + if isinstance(f, DialogFilter): + existing_ids.add(f.id) + folder_count += 1 + + if folder_count >= 10: + return "Cannot create folder: Telegram limit is 10 folders. Delete one first." + + new_id = 2 + while new_id in existing_ids: + new_id += 1 + + include_peers = [] + if chat_ids: + for chat_id in chat_ids: + try: + peer = await client.get_input_entity(chat_id) + include_peers.append(peer) + except Exception as e: + return f"Failed to resolve chat '{chat_id}': {str(e)}" + + title_obj = TextWithEntities(text=title, entities=[]) + new_filter = DialogFilter( + id=new_id, + title=title_obj, + emoticon=emoticon, + pinned_peers=[], + include_peers=include_peers, + exclude_peers=[], + contacts=contacts, + non_contacts=non_contacts, + groups=groups, + broadcasts=broadcasts, + bots=bots, + exclude_muted=exclude_muted, + exclude_read=exclude_read, + exclude_archived=exclude_archived, + ) + + await client(functions.messages.UpdateDialogFilterRequest(id=new_id, filter=new_filter)) + + return json.dumps( + { + "success": True, + "folder_id": new_id, + "title": title, + "emoticon": emoticon, + "included_chats_count": len(include_peers), + }, + indent=2, + ) + except Exception as e: + logger.exception(f"create_folder failed (title={title})") + return log_and_format_error("create_folder", e, ErrorCategory.FOLDER, title=title) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Add Chat to Folder", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("chat_id") +async def add_chat_to_folder( + folder_id: int, chat_id: Union[int, str], pinned: bool = False +) -> str: + """ + Add a chat to an existing folder. + + Args: + folder_id: The folder ID (get from list_folders) + chat_id: Chat ID or username to add + pinned: Pin the chat in this folder (default False) + """ + try: + result = await client(functions.messages.GetDialogFiltersRequest()) + + target_folder = None + for f in result.filters: + if isinstance(f, DialogFilter) and f.id == folder_id: + target_folder = f + break + + if not target_folder: + return ( + f"Folder with ID {folder_id} not found. Use list_folders to see available folders." + ) + + try: + peer = await client.get_input_entity(chat_id) + except Exception as e: + return f"Failed to resolve chat '{chat_id}': {str(e)}" + + include_peers = list(getattr(target_folder, "include_peers", [])) + pinned_peers = list(getattr(target_folder, "pinned_peers", [])) + + peer_id = utils.get_peer_id(peer) + already_included = any(utils.get_peer_id(p) == peer_id for p in include_peers) + already_pinned = any(utils.get_peer_id(p) == peer_id for p in pinned_peers) + + if already_included and (not pinned or already_pinned): + return f"Chat {chat_id} is already in folder {folder_id}." + + if not already_included: + include_peers.append(peer) + if pinned and not already_pinned: + pinned_peers.append(peer) + + updated_filter = DialogFilter( + id=target_folder.id, + title=target_folder.title, + emoticon=getattr(target_folder, "emoticon", None), + pinned_peers=pinned_peers, + include_peers=include_peers, + exclude_peers=list(getattr(target_folder, "exclude_peers", [])), + contacts=getattr(target_folder, "contacts", False), + non_contacts=getattr(target_folder, "non_contacts", False), + groups=getattr(target_folder, "groups", False), + broadcasts=getattr(target_folder, "broadcasts", False), + bots=getattr(target_folder, "bots", False), + exclude_muted=getattr(target_folder, "exclude_muted", False), + exclude_read=getattr(target_folder, "exclude_read", False), + exclude_archived=getattr(target_folder, "exclude_archived", False), + title_noanimate=getattr(target_folder, "title_noanimate", None), + color=getattr(target_folder, "color", None), + ) + + await client( + functions.messages.UpdateDialogFilterRequest(id=folder_id, filter=updated_filter) + ) + + return ( + f"Chat {chat_id} added to folder {folder_id}" + (" (pinned)" if pinned else "") + "." + ) + except Exception as e: + logger.exception(f"add_chat_to_folder failed (folder_id={folder_id}, chat_id={chat_id})") + return log_and_format_error( + "add_chat_to_folder", e, ErrorCategory.FOLDER, folder_id=folder_id, chat_id=chat_id + ) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Remove Chat from Folder", + openWorldHint=True, + destructiveHint=True, + idempotentHint=True, + ) +) +@validate_id("chat_id") +async def remove_chat_from_folder(folder_id: int, chat_id: Union[int, str]) -> str: + """ + Remove a chat from a folder. + + Args: + folder_id: The folder ID (get from list_folders) + chat_id: Chat ID or username to remove + """ + try: + result = await client(functions.messages.GetDialogFiltersRequest()) + + target_folder = None + for f in result.filters: + if isinstance(f, DialogFilter) and f.id == folder_id: + target_folder = f + break + + if not target_folder: + return ( + f"Folder with ID {folder_id} not found. Use list_folders to see available folders." + ) + + try: + peer = await client.get_input_entity(chat_id) + peer_id = utils.get_peer_id(peer) + except Exception as e: + return f"Failed to resolve chat '{chat_id}': {str(e)}" + + include_peers = [ + p + for p in getattr(target_folder, "include_peers", []) + if utils.get_peer_id(p) != peer_id + ] + pinned_peers = [ + p + for p in getattr(target_folder, "pinned_peers", []) + if utils.get_peer_id(p) != peer_id + ] + + original_include_count = len(getattr(target_folder, "include_peers", [])) + original_pinned_count = len(getattr(target_folder, "pinned_peers", [])) + + if ( + len(include_peers) == original_include_count + and len(pinned_peers) == original_pinned_count + ): + return f"Chat {chat_id} was not in folder {folder_id}." + + updated_filter = DialogFilter( + id=target_folder.id, + title=target_folder.title, + emoticon=getattr(target_folder, "emoticon", None), + pinned_peers=pinned_peers, + include_peers=include_peers, + exclude_peers=list(getattr(target_folder, "exclude_peers", [])), + contacts=getattr(target_folder, "contacts", False), + non_contacts=getattr(target_folder, "non_contacts", False), + groups=getattr(target_folder, "groups", False), + broadcasts=getattr(target_folder, "broadcasts", False), + bots=getattr(target_folder, "bots", False), + exclude_muted=getattr(target_folder, "exclude_muted", False), + exclude_read=getattr(target_folder, "exclude_read", False), + exclude_archived=getattr(target_folder, "exclude_archived", False), + title_noanimate=getattr(target_folder, "title_noanimate", None), + color=getattr(target_folder, "color", None), + ) + + await client( + functions.messages.UpdateDialogFilterRequest(id=folder_id, filter=updated_filter) + ) + + return f"Chat {chat_id} removed from folder {folder_id}." + except Exception as e: + logger.exception( + f"remove_chat_from_folder failed (folder_id={folder_id}, chat_id={chat_id})" + ) + return log_and_format_error( + "remove_chat_from_folder", + e, + ErrorCategory.FOLDER, + folder_id=folder_id, + chat_id=chat_id, + ) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Delete Folder", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +async def delete_folder(folder_id: int) -> str: + """ + Delete a folder. Chats in the folder are preserved, only the folder is removed. + + Args: + folder_id: The folder ID to delete (get from list_folders) + """ + try: + if folder_id < 2: + return f"Cannot delete system folder (ID {folder_id}). Only custom folders can be deleted." + + result = await client(functions.messages.GetDialogFiltersRequest()) + + folder_exists = False + folder_title = None + for f in result.filters: + if isinstance(f, DialogFilter) and f.id == folder_id: + folder_exists = True + title = f.title + if isinstance(title, TextWithEntities): + title = title.text + folder_title = title + break + + if not folder_exists: + return f"Folder with ID {folder_id} not found (may already be deleted)." + + await client(functions.messages.UpdateDialogFilterRequest(id=folder_id, filter=None)) + + return f"Folder '{folder_title}' (ID {folder_id}) deleted. Chats are preserved." + except Exception as e: + logger.exception(f"delete_folder failed (folder_id={folder_id})") + return log_and_format_error("delete_folder", e, ErrorCategory.FOLDER, folder_id=folder_id) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Reorder Folders", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +async def reorder_folders(folder_ids: List[int]) -> str: + """ + Change the order of folders in the folder list. + + Args: + folder_ids: List of folder IDs in the desired order + """ + try: + result = await client(functions.messages.GetDialogFiltersRequest()) + + existing_ids = set() + for f in result.filters: + if isinstance(f, DialogFilter): + existing_ids.add(f.id) + + for fid in folder_ids: + if fid not in existing_ids: + return f"Folder ID {fid} not found. Use list_folders to see available folders." + + if set(folder_ids) != existing_ids: + missing = existing_ids - set(folder_ids) + return f"All folder IDs must be included. Missing: {missing}" + + await client(functions.messages.UpdateDialogFiltersOrderRequest(order=folder_ids)) + + return f"Folders reordered: {folder_ids}" + except Exception as e: + logger.exception(f"reorder_folders failed (folder_ids={folder_ids})") + return log_and_format_error( + "reorder_folders", e, ErrorCategory.FOLDER, folder_ids=folder_ids + ) diff --git a/src/telegram_mcp/tools/media.py b/src/telegram_mcp/tools/media.py new file mode 100644 index 0000000..85f0eb1 --- /dev/null +++ b/src/telegram_mcp/tools/media.py @@ -0,0 +1,454 @@ +"""Media handling tools for telegram-mcp.""" + +import json +import os +import mimetypes +import tempfile +import urllib.request +from typing import Union, List +from urllib.parse import urlparse + +from telethon import functions, types + +from ..app import mcp, client +from ..exceptions import log_and_format_error +from ..validators import validate_id +from ..formatters import json_serializer +from ..logging_config import logger +from mcp.types import ToolAnnotations + + +def _validate_image_url(url: str) -> tuple[bool, str]: + """Validate a single image URL. + + Args: + url: URL string to validate. + + Returns: + Tuple of (is_valid, error_message). If valid, error_message is empty. + """ + if not isinstance(url, str): + return False, f"URL must be a string, got {type(url).__name__}" + if not url or not url.strip(): + return False, "URL cannot be empty" + + url = url.strip() + try: + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return False, f"URL must use http or https protocol, got '{parsed.scheme}'" + if not parsed.netloc: + return False, "Invalid URL format: missing domain" + except Exception as e: + return False, f"Invalid URL format: {e}" + + return True, "" + + +@mcp.tool(annotations=ToolAnnotations(title="Send File", openWorldHint=True, destructiveHint=True)) +@validate_id("chat_id") +async def send_file(chat_id: Union[int, str], file_path: str, caption: str = None) -> str: + """ + Send a file to a chat. + Args: + chat_id: The chat ID or username. + file_path: Absolute path to the file to send (must exist and be readable). + caption: Optional caption for the file. + """ + try: + if not os.path.isfile(file_path): + return f"File not found: {file_path}" + if not os.access(file_path, os.R_OK): + return f"File is not readable: {file_path}" + entity = await client.get_entity(chat_id) + await client.send_file(entity, file_path, caption=caption) + return f"File sent to chat {chat_id}." + except Exception as e: + return log_and_format_error( + "send_file", e, chat_id=chat_id, file_path=file_path, caption=caption + ) + + +@mcp.tool( + annotations=ToolAnnotations(title="Download Media", openWorldHint=True, readOnlyHint=True) +) +@validate_id("chat_id") +async def download_media(chat_id: Union[int, str], message_id: int, file_path: str) -> str: + """ + Download media from a message in a chat. + Args: + chat_id: The chat ID or username. + message_id: The message ID containing the media. + file_path: Absolute path to save the downloaded file (must be writable). + """ + try: + entity = await client.get_entity(chat_id) + msg = await client.get_messages(entity, ids=message_id) + if not msg or not msg.media: + return "No media found in the specified message." + dir_path = os.path.dirname(file_path) or "." + if not os.access(dir_path, os.W_OK): + return f"Directory not writable: {dir_path}" + await client.download_media(msg, file=file_path) + if not os.path.isfile(file_path): + return f"Download failed: file not created at {file_path}" + return f"Media downloaded to {file_path}." + except Exception as e: + return log_and_format_error( + "download_media", + e, + chat_id=chat_id, + message_id=message_id, + file_path=file_path, + ) + + +@mcp.tool( + annotations=ToolAnnotations(title="Send Voice", openWorldHint=True, destructiveHint=True) +) +@validate_id("chat_id") +async def send_voice(chat_id: Union[int, str], file_path: str) -> str: + """ + Send a voice message to a chat. File must be an OGG/OPUS voice note. + + Args: + chat_id: The chat ID or username. + file_path: Absolute path to the OGG/OPUS file. + """ + try: + if not os.path.isfile(file_path): + return f"File not found: {file_path}" + if not os.access(file_path, os.R_OK): + return f"File is not readable: {file_path}" + + mime, _ = mimetypes.guess_type(file_path) + if not ( + mime + and ( + mime == "audio/ogg" + or file_path.lower().endswith(".ogg") + or file_path.lower().endswith(".opus") + ) + ): + return "Voice file must be .ogg or .opus format." + + entity = await client.get_entity(chat_id) + await client.send_file(entity, file_path, voice_note=True) + return f"Voice message sent to chat {chat_id}." + except Exception as e: + return log_and_format_error("send_voice", e, chat_id=chat_id, file_path=file_path) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Media Info", openWorldHint=True, readOnlyHint=True) +) +@validate_id("chat_id") +async def get_media_info(chat_id: Union[int, str], message_id: int) -> str: + """ + Get info about media in a message. + + Args: + chat_id: The chat ID or username. + message_id: The message ID. + """ + try: + entity = await client.get_entity(chat_id) + msg = await client.get_messages(entity, ids=message_id) + + if not msg or not msg.media: + return "No media found in the specified message." + + return str(msg.media) + except Exception as e: + return log_and_format_error("get_media_info", e, chat_id=chat_id, message_id=message_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Sticker Sets", openWorldHint=True, readOnlyHint=True) +) +async def get_sticker_sets() -> str: + """ + Get all sticker sets. + """ + try: + result = await client(functions.messages.GetAllStickersRequest(hash=0)) + return json.dumps([s.title for s in result.sets], indent=2) + except Exception as e: + return log_and_format_error("get_sticker_sets", e) + + +@mcp.tool( + annotations=ToolAnnotations(title="Send Sticker", openWorldHint=True, destructiveHint=True) +) +@validate_id("chat_id") +async def send_sticker(chat_id: Union[int, str], file_path: str) -> str: + """ + Send a sticker to a chat. File must be a valid .webp sticker file. + + Args: + chat_id: The chat ID or username. + file_path: Absolute path to the .webp sticker file. + """ + try: + if not os.path.isfile(file_path): + return f"Sticker file not found: {file_path}" + if not os.access(file_path, os.R_OK): + return f"Sticker file is not readable: {file_path}" + if not file_path.lower().endswith(".webp"): + return "Sticker file must be a .webp file." + + entity = await client.get_entity(chat_id) + await client.send_file(entity, file_path, force_document=False) + return f"Sticker sent to chat {chat_id}." + except Exception as e: + return log_and_format_error("send_sticker", e, chat_id=chat_id, file_path=file_path) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Gif Search", openWorldHint=True, readOnlyHint=True) +) +async def get_gif_search(query: str, limit: int = 10) -> str: + """ + Search for GIFs by query. Returns a list of Telegram document IDs (not file paths). + + Args: + query: Search term for GIFs. + limit: Max number of GIFs to return. + """ + try: + try: + result = await client( + functions.messages.SearchGifsRequest(q=query, offset_id=0, limit=limit) + ) + if not result.gifs: + return "[]" + return json.dumps( + [g.document.id for g in result.gifs], indent=2, default=json_serializer + ) + except (AttributeError, ImportError): + try: + from telethon.tl.types import InputMessagesFilterGif + + result = await client( + functions.messages.SearchRequest( + peer="gif", + q=query, + filter=InputMessagesFilterGif(), + min_date=None, + max_date=None, + offset_id=0, + add_offset=0, + limit=limit, + max_id=0, + min_id=0, + hash=0, + ) + ) + if not result or not hasattr(result, "messages") or not result.messages: + return "[]" + gif_ids = [] + for msg in result.messages: + if hasattr(msg, "media") and msg.media and hasattr(msg.media, "document"): + gif_ids.append(msg.media.document.id) + return json.dumps(gif_ids, default=json_serializer) + except Exception as inner_e: + return f"Could not search GIFs using available methods: {inner_e}" + except Exception as e: + logger.exception(f"get_gif_search failed (query={query}, limit={limit})") + return log_and_format_error("get_gif_search", e, query=query, limit=limit) + + +@mcp.tool(annotations=ToolAnnotations(title="Send Gif", openWorldHint=True, destructiveHint=True)) +@validate_id("chat_id") +async def send_gif(chat_id: Union[int, str], gif_id: int) -> str: + """ + Send a GIF to a chat by Telegram GIF document ID (not a file path). + + Args: + chat_id: The chat ID or username. + gif_id: Telegram document ID for the GIF (from get_gif_search). + """ + try: + if not isinstance(gif_id, int): + return "gif_id must be a Telegram document ID (integer), not a file path. Use get_gif_search to find IDs." + entity = await client.get_entity(chat_id) + await client.send_file(entity, gif_id) + return f"GIF sent to chat {chat_id}." + except Exception as e: + return log_and_format_error("send_gif", e, chat_id=chat_id, gif_id=gif_id) + + +@mcp.tool(annotations=ToolAnnotations(title="Get Bot Info", openWorldHint=True, readOnlyHint=True)) +async def get_bot_info(bot_username: str) -> str: + """ + Get information about a bot by username. + """ + try: + entity = await client.get_entity(bot_username) + if not entity: + return f"Bot with username {bot_username} not found." + + result = await client(functions.users.GetFullUserRequest(id=entity)) + + if hasattr(result, "to_dict"): + return json.dumps(result.to_dict(), indent=2, default=json_serializer) + else: + info = { + "bot_info": { + "id": entity.id, + "username": entity.username, + "first_name": entity.first_name, + "last_name": getattr(entity, "last_name", ""), + "is_bot": getattr(entity, "bot", False), + "verified": getattr(entity, "verified", False), + } + } + if hasattr(result, "full_user") and hasattr(result.full_user, "about"): + info["bot_info"]["about"] = result.full_user.about + return json.dumps(info, indent=2) + except Exception as e: + logger.exception(f"get_bot_info failed (bot_username={bot_username})") + return log_and_format_error("get_bot_info", e, bot_username=bot_username) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Set Bot Commands", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +async def set_bot_commands(bot_username: str, commands: list) -> str: + """ + Set bot commands for a bot you own. + Note: This function can only be used if the Telegram client is a bot account. + Regular user accounts cannot set bot commands. + + Args: + bot_username: The username of the bot to set commands for. + commands: List of command dictionaries with 'command' and 'description' keys. + """ + try: + me = await client.get_me() + if not getattr(me, "bot", False): + return "Error: This function can only be used by bot accounts. Your current Telegram account is a regular user account, not a bot." + + from telethon.tl.types import BotCommand, BotCommandScopeDefault + from telethon.tl.functions.bots import SetBotCommandsRequest + + bot_commands = [ + BotCommand(command=c["command"], description=c["description"]) for c in commands + ] + + bot = await client.get_entity(bot_username) + + await client( + SetBotCommandsRequest( + scope=BotCommandScopeDefault(), + lang_code="en", + commands=bot_commands, + ) + ) + + return f"Bot commands set for {bot_username}." + except ImportError as ie: + logger.exception(f"set_bot_commands failed - ImportError: {ie}") + return log_and_format_error("set_bot_commands", ie) + except Exception as e: + logger.exception(f"set_bot_commands failed (bot_username={bot_username})") + return log_and_format_error("set_bot_commands", e, bot_username=bot_username) + + +def _download_url_to_temp(url: str, index: int) -> str: + """Download a URL to a temporary file. + + Args: + url: URL to download. + index: Index for naming the temp file. + + Returns: + Path to the temporary file. + + Raises: + Exception: If download fails. + """ + temp_dir = tempfile.gettempdir() + temp_path = os.path.join(temp_dir, f"telegram_mcp_photo_{index}_{os.getpid()}.jpg") + + opener = urllib.request.build_opener() + opener.addheaders = [("User-Agent", "Mozilla/5.0")] + urllib.request.install_opener(opener) + urllib.request.urlretrieve(url.strip(), temp_path) + + return temp_path + + +@mcp.tool( + annotations=ToolAnnotations( + title="Send Photo From URL", openWorldHint=True, destructiveHint=True + ) +) +@validate_id("chat_id") +async def send_photo_from_url( + chat_id: Union[int, str], + url: Union[str, List[str]], + caption: str = None, +) -> str: + """ + Send photo(s) from URL(s) to a chat. Supports single image or media group (album). + + Args: + chat_id: The chat ID or username. + url: Single URL or list of URLs (max 10 for media group/album). + caption: Optional caption for the photo(s). + """ + temp_files = [] + try: + # Normalize to list + urls = [url] if isinstance(url, str) else list(url) + + # Validate count + if len(urls) == 0: + return "Error: At least one URL is required." + if len(urls) > 10: + return "Error: Media group cannot exceed 10 images." + + # Validate each URL + for i, u in enumerate(urls): + is_valid, error = _validate_image_url(u) + if not is_valid: + return f"Error: Invalid URL at index {i}: {error}" + + entity = await client.get_entity(chat_id) + + # Send single image using InputMediaPhotoExternal + if len(urls) == 1: + media = types.InputMediaPhotoExternal(url=urls[0].strip()) + await client( + functions.messages.SendMediaRequest( + peer=entity, + media=media, + message=caption or "", + ) + ) + return f"Photo sent to chat {chat_id}." + + # For multiple images, download first then send as album + # (Telegram API doesn't support external URLs for albums directly) + for i, u in enumerate(urls): + temp_path = _download_url_to_temp(u, i) + temp_files.append(temp_path) + + await client.send_file(entity, temp_files, caption=caption, force_document=False) + return f"Media group ({len(urls)} photos) sent to chat {chat_id}." + + except Exception as e: + return log_and_format_error( + "send_photo_from_url", e, chat_id=chat_id, url=url, caption=caption + ) + finally: + # Clean up temp files + for f in temp_files: + try: + if os.path.exists(f): + os.remove(f) + except OSError: + pass diff --git a/src/telegram_mcp/tools/messages.py b/src/telegram_mcp/tools/messages.py new file mode 100644 index 0000000..1fd06e4 --- /dev/null +++ b/src/telegram_mcp/tools/messages.py @@ -0,0 +1,773 @@ +"""Message management tools for telegram-mcp.""" + +import json +from typing import Union, Optional +from datetime import datetime, timedelta + +from telethon import functions +from telethon.tl.types import User, Chat, Channel + +from ..app import mcp, client +from ..exceptions import log_and_format_error +from ..validators import validate_id +from ..formatters import get_sender_name, get_engagement_info, json_serializer +from ..logging_config import logger +from mcp.types import ToolAnnotations + + +@mcp.tool(annotations=ToolAnnotations(title="Get Messages", openWorldHint=True, readOnlyHint=True)) +@validate_id("chat_id") +async def get_messages(chat_id: Union[int, str], page: int = 1, page_size: int = 20) -> str: + """ + Get paginated messages from a specific chat. + Args: + chat_id: The ID or username of the chat. + page: Page number (1-indexed). + page_size: Number of messages per page. + """ + try: + entity = await client.get_entity(chat_id) + offset = (page - 1) * page_size + messages = await client.get_messages(entity, limit=page_size, add_offset=offset) + if not messages: + return "No messages found for this page." + lines = [] + for msg in messages: + sender_name = get_sender_name(msg) + reply_info = "" + if msg.reply_to and msg.reply_to.reply_to_msg_id: + reply_info = f" | reply to {msg.reply_to.reply_to_msg_id}" + + engagement_info = get_engagement_info(msg) + + lines.append( + f"ID: {msg.id} | {sender_name} | Date: {msg.date}{reply_info}{engagement_info} | Message: {msg.message}" + ) + return "\n".join(lines) + except Exception as e: + return log_and_format_error( + "get_messages", e, chat_id=chat_id, page=page, page_size=page_size + ) + + +@mcp.tool( + annotations=ToolAnnotations(title="Send Message", openWorldHint=True, destructiveHint=True) +) +@validate_id("chat_id") +async def send_message(chat_id: Union[int, str], message: str) -> str: + """ + Send a message to a specific chat. + Args: + chat_id: The ID or username of the chat. + message: The message content to send. + """ + try: + entity = await client.get_entity(chat_id) + await client.send_message(entity, message) + return "Message sent successfully." + except Exception as e: + return log_and_format_error("send_message", e, chat_id=chat_id) + + +@mcp.tool(annotations=ToolAnnotations(title="List Messages", openWorldHint=True, readOnlyHint=True)) +@validate_id("chat_id") +async def list_messages( + chat_id: Union[int, str], + limit: int = 20, + search_query: str = None, + from_date: str = None, + to_date: str = None, +) -> str: + """ + Retrieve messages with optional filters. + + Args: + chat_id: The ID or username of the chat to get messages from. + limit: Maximum number of messages to retrieve. + search_query: Filter messages containing this text. + from_date: Filter messages starting from this date (format: YYYY-MM-DD). + to_date: Filter messages until this date (format: YYYY-MM-DD). + """ + try: + entity = await client.get_entity(chat_id) + + from_date_obj = None + to_date_obj = None + + if from_date: + try: + from_date_obj = datetime.strptime(from_date, "%Y-%m-%d") + try: + from_date_obj = from_date_obj.replace(tzinfo=datetime.timezone.utc) + except AttributeError: + from datetime import timezone + from_date_obj = from_date_obj.replace(tzinfo=timezone.utc) + except ValueError: + return f"Invalid from_date format. Use YYYY-MM-DD." + + if to_date: + try: + to_date_obj = datetime.strptime(to_date, "%Y-%m-%d") + to_date_obj = to_date_obj + timedelta(days=1, microseconds=-1) + try: + to_date_obj = to_date_obj.replace(tzinfo=datetime.timezone.utc) + except AttributeError: + from datetime import timezone + to_date_obj = to_date_obj.replace(tzinfo=timezone.utc) + except ValueError: + return f"Invalid to_date format. Use YYYY-MM-DD." + + params = {} + if search_query: + params["search"] = search_query + messages = [] + async for msg in client.iter_messages(entity, **params): + if to_date_obj and msg.date > to_date_obj: + continue + if from_date_obj and msg.date < from_date_obj: + break + messages.append(msg) + if len(messages) >= limit: + break + else: + if from_date_obj or to_date_obj: + messages = [] + if from_date_obj: + async for msg in client.iter_messages( + entity, offset_date=from_date_obj, reverse=True + ): + if to_date_obj and msg.date > to_date_obj: + break + if msg.date < from_date_obj: + continue + messages.append(msg) + if len(messages) >= limit: + break + else: + async for msg in client.iter_messages( + entity, + offset_date=to_date_obj + timedelta(microseconds=1), + ): + messages.append(msg) + if len(messages) >= limit: + break + else: + messages = await client.get_messages(entity, limit=limit, **params) + + if not messages: + return "No messages found matching the criteria." + + lines = [] + for msg in messages: + sender_name = get_sender_name(msg) + message_text = msg.message or "[Media/No text]" + reply_info = "" + if msg.reply_to and msg.reply_to.reply_to_msg_id: + reply_info = f" | reply to {msg.reply_to.reply_to_msg_id}" + + engagement_info = get_engagement_info(msg) + + lines.append( + f"ID: {msg.id} | {sender_name} | Date: {msg.date}{reply_info}{engagement_info} | Message: {message_text}" + ) + + return "\n".join(lines) + except Exception as e: + return log_and_format_error("list_messages", e, chat_id=chat_id) + + +@mcp.tool(annotations=ToolAnnotations(title="Get Message Context", openWorldHint=True, readOnlyHint=True)) +@validate_id("chat_id") +async def get_message_context( + chat_id: Union[int, str], message_id: int, context_size: int = 3 +) -> str: + """ + Retrieve context around a specific message. + + Args: + chat_id: The ID or username of the chat. + message_id: The ID of the central message. + context_size: Number of messages before and after to include. + """ + try: + chat = await client.get_entity(chat_id) + messages_before = await client.get_messages(chat, limit=context_size, max_id=message_id) + central_message = await client.get_messages(chat, ids=message_id) + if central_message is not None and not isinstance(central_message, list): + central_message = [central_message] + elif central_message is None: + central_message = [] + messages_after = await client.get_messages( + chat, limit=context_size, min_id=message_id, reverse=True + ) + if not central_message: + return f"Message with ID {message_id} not found in chat {chat_id}." + all_messages = list(messages_before) + list(central_message) + list(messages_after) + all_messages.sort(key=lambda m: m.id) + results = [f"Context for message {message_id} in chat {chat_id}:"] + for msg in all_messages: + sender_name = get_sender_name(msg) + highlight = " [THIS MESSAGE]" if msg.id == message_id else "" + + reply_content = "" + if msg.reply_to and msg.reply_to.reply_to_msg_id: + try: + replied_msg = await client.get_messages(chat, ids=msg.reply_to.reply_to_msg_id) + if replied_msg: + replied_sender = "Unknown" + if replied_msg.sender: + replied_sender = getattr( + replied_msg.sender, "first_name", "" + ) or getattr(replied_msg.sender, "title", "Unknown") + reply_content = f" | reply to {msg.reply_to.reply_to_msg_id}\n โ†’ Replied message: [{replied_sender}] {replied_msg.message or '[Media/No text]'}" + except Exception: + reply_content = ( + f" | reply to {msg.reply_to.reply_to_msg_id} (original message not found)" + ) + + results.append( + f"ID: {msg.id} | {sender_name} | {msg.date}{highlight}{reply_content}\n{msg.message or '[Media/No text]'}\n" + ) + return "\n".join(results) + except Exception as e: + return log_and_format_error( + "get_message_context", + e, + chat_id=chat_id, + message_id=message_id, + context_size=context_size, + ) + + +@mcp.tool( + annotations=ToolAnnotations(title="Forward Message", openWorldHint=True, destructiveHint=True) +) +@validate_id("from_chat_id", "to_chat_id") +async def forward_message( + from_chat_id: Union[int, str], message_id: int, to_chat_id: Union[int, str] +) -> str: + """ + Forward a message from one chat to another. + """ + try: + from_entity = await client.get_entity(from_chat_id) + to_entity = await client.get_entity(to_chat_id) + await client.forward_messages(to_entity, message_id, from_entity) + return f"Message {message_id} forwarded from {from_chat_id} to {to_chat_id}." + except Exception as e: + return log_and_format_error( + "forward_message", + e, + from_chat_id=from_chat_id, + message_id=message_id, + to_chat_id=to_chat_id, + ) + + +@mcp.tool( + annotations=ToolAnnotations(title="Edit Message", openWorldHint=True, destructiveHint=True, idempotentHint=True) +) +@validate_id("chat_id") +async def edit_message(chat_id: Union[int, str], message_id: int, new_text: str) -> str: + """ + Edit a message you sent. + """ + try: + entity = await client.get_entity(chat_id) + await client.edit_message(entity, message_id, new_text) + return f"Message {message_id} edited." + except Exception as e: + return log_and_format_error( + "edit_message", e, chat_id=chat_id, message_id=message_id, new_text=new_text + ) + + +@mcp.tool( + annotations=ToolAnnotations(title="Delete Message", openWorldHint=True, destructiveHint=True, idempotentHint=True) +) +@validate_id("chat_id") +async def delete_message(chat_id: Union[int, str], message_id: int) -> str: + """ + Delete a message by ID. + """ + try: + entity = await client.get_entity(chat_id) + await client.delete_messages(entity, message_id) + return f"Message {message_id} deleted." + except Exception as e: + return log_and_format_error("delete_message", e, chat_id=chat_id, message_id=message_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Pin Message", openWorldHint=True, destructiveHint=True, idempotentHint=True) +) +@validate_id("chat_id") +async def pin_message(chat_id: Union[int, str], message_id: int) -> str: + """ + Pin a message in a chat. + """ + try: + entity = await client.get_entity(chat_id) + await client.pin_message(entity, message_id) + return f"Message {message_id} pinned in chat {chat_id}." + except Exception as e: + return log_and_format_error("pin_message", e, chat_id=chat_id, message_id=message_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Unpin Message", openWorldHint=True, destructiveHint=True, idempotentHint=True) +) +@validate_id("chat_id") +async def unpin_message(chat_id: Union[int, str], message_id: int) -> str: + """ + Unpin a message in a chat. + """ + try: + entity = await client.get_entity(chat_id) + await client.unpin_message(entity, message_id) + return f"Message {message_id} unpinned in chat {chat_id}." + except Exception as e: + return log_and_format_error("unpin_message", e, chat_id=chat_id, message_id=message_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Mark As Read", openWorldHint=True, destructiveHint=True, idempotentHint=True) +) +@validate_id("chat_id") +async def mark_as_read(chat_id: Union[int, str]) -> str: + """ + Mark all messages as read in a chat. + """ + try: + entity = await client.get_entity(chat_id) + await client.send_read_acknowledge(entity) + return f"Marked all messages as read in chat {chat_id}." + except Exception as e: + return log_and_format_error("mark_as_read", e, chat_id=chat_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Reply To Message", openWorldHint=True, destructiveHint=True) +) +@validate_id("chat_id") +async def reply_to_message(chat_id: Union[int, str], message_id: int, text: str) -> str: + """ + Reply to a specific message in a chat. + """ + try: + entity = await client.get_entity(chat_id) + await client.send_message(entity, text, reply_to=message_id) + return f"Replied to message {message_id} in chat {chat_id}." + except Exception as e: + return log_and_format_error( + "reply_to_message", e, chat_id=chat_id, message_id=message_id, text=text + ) + + +@mcp.tool( + annotations=ToolAnnotations(title="Search Messages", openWorldHint=True, readOnlyHint=True) +) +@validate_id("chat_id") +async def search_messages(chat_id: Union[int, str], query: str, limit: int = 20) -> str: + """ + Search for messages in a chat by text. + """ + try: + entity = await client.get_entity(chat_id) + messages = await client.get_messages(entity, limit=limit, search=query) + + lines = [] + for msg in messages: + sender_name = get_sender_name(msg) + reply_info = "" + if msg.reply_to and msg.reply_to.reply_to_msg_id: + reply_info = f" | reply to {msg.reply_to.reply_to_msg_id}" + lines.append( + f"ID: {msg.id} | {sender_name} | Date: {msg.date}{reply_info} | Message: {msg.message}" + ) + return "\n".join(lines) + except Exception as e: + return log_and_format_error( + "search_messages", e, chat_id=chat_id, query=query, limit=limit + ) + + +@mcp.tool(annotations=ToolAnnotations(title="Get History", openWorldHint=True, readOnlyHint=True)) +@validate_id("chat_id") +async def get_history(chat_id: Union[int, str], limit: int = 100) -> str: + """ + Get full chat history (up to limit). + """ + try: + entity = await client.get_entity(chat_id) + messages = await client.get_messages(entity, limit=limit) + + lines = [] + for msg in messages: + sender_name = get_sender_name(msg) + reply_info = "" + if msg.reply_to and msg.reply_to.reply_to_msg_id: + reply_info = f" | reply to {msg.reply_to.reply_to_msg_id}" + lines.append( + f"ID: {msg.id} | {sender_name} | Date: {msg.date}{reply_info} | Message: {msg.message}" + ) + return "\n".join(lines) + except Exception as e: + return log_and_format_error("get_history", e, chat_id=chat_id, limit=limit) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Pinned Messages", openWorldHint=True, readOnlyHint=True) +) +@validate_id("chat_id") +async def get_pinned_messages(chat_id: Union[int, str]) -> str: + """ + Get all pinned messages in a chat. + """ + try: + entity = await client.get_entity(chat_id) + + try: + from telethon.tl.types import InputMessagesFilterPinned + messages = await client.get_messages(entity, filter=InputMessagesFilterPinned()) + except (ImportError, AttributeError): + all_messages = await client.get_messages(entity, limit=50) + messages = [m for m in all_messages if getattr(m, "pinned", False)] + + if not messages: + return "No pinned messages found in this chat." + + lines = [] + for msg in messages: + sender_name = get_sender_name(msg) + reply_info = "" + if msg.reply_to and msg.reply_to.reply_to_msg_id: + reply_info = f" | reply to {msg.reply_to.reply_to_msg_id}" + lines.append( + f"ID: {msg.id} | {sender_name} | Date: {msg.date}{reply_info} | Message: {msg.message or '[Media/No text]'}" + ) + + return "\n".join(lines) + except Exception as e: + logger.exception(f"get_pinned_messages failed (chat_id={chat_id})") + return log_and_format_error("get_pinned_messages", e, chat_id=chat_id) + + +@mcp.tool(annotations=ToolAnnotations(title="List Inline Buttons", openWorldHint=True, readOnlyHint=True)) +@validate_id("chat_id") +async def list_inline_buttons( + chat_id: Union[int, str], message_id: Optional[Union[int, str]] = None, limit: int = 20 +) -> str: + """ + Inspect inline buttons on a recent message to discover their indices/text/URLs. + """ + try: + if isinstance(message_id, str): + if message_id.isdigit(): + message_id = int(message_id) + else: + return "message_id must be an integer." + + entity = await client.get_entity(chat_id) + target_message = None + + if message_id is not None: + target_message = await client.get_messages(entity, ids=message_id) + if isinstance(target_message, list): + target_message = target_message[0] if target_message else None + else: + recent_messages = await client.get_messages(entity, limit=limit) + target_message = next( + (msg for msg in recent_messages if getattr(msg, "buttons", None)), None + ) + + if not target_message: + return "No message with inline buttons found." + + buttons_attr = getattr(target_message, "buttons", None) + if not buttons_attr: + return f"Message {target_message.id} does not contain inline buttons." + + buttons = [btn for row in buttons_attr for btn in row] + if not buttons: + return f"Message {target_message.id} does not contain inline buttons." + + lines = [ + f"Buttons for message {target_message.id} (date {target_message.date}):", + ] + for idx, btn in enumerate(buttons): + raw_button = getattr(btn, "button", None) + text = getattr(btn, "text", "") or "" + url = getattr(raw_button, "url", None) if raw_button else None + has_callback = bool(getattr(btn, "data", None)) + parts = [f"[{idx}] text='{text}'"] + parts.append("callback=yes" if has_callback else "callback=no") + if url: + parts.append(f"url={url}") + lines.append(", ".join(parts)) + + return "\n".join(lines) + except Exception as e: + return log_and_format_error( + "list_inline_buttons", + e, + chat_id=chat_id, + message_id=message_id, + limit=limit, + ) + + +@mcp.tool( + annotations=ToolAnnotations(title="Press Inline Button", openWorldHint=True, destructiveHint=True) +) +@validate_id("chat_id") +async def press_inline_button( + chat_id: Union[int, str], + message_id: Optional[Union[int, str]] = None, + button_text: Optional[str] = None, + button_index: Optional[int] = None, +) -> str: + """ + Press an inline button (callback) in a chat message. + + Args: + chat_id: Chat or bot where the inline keyboard exists. + message_id: Specific message ID to inspect. If omitted, searches recent messages for one containing buttons. + button_text: Exact text of the button to press (case-insensitive). + button_index: Zero-based index among all buttons if you prefer positional access. + """ + try: + if button_text is None and button_index is None: + return "Provide button_text or button_index to choose a button." + + if isinstance(message_id, str): + if message_id.isdigit(): + message_id = int(message_id) + else: + return "message_id must be an integer." + + if isinstance(button_index, str): + if button_index.isdigit(): + button_index = int(button_index) + else: + return "button_index must be an integer." + + entity = await client.get_entity(chat_id) + + target_message = None + if message_id is not None: + target_message = await client.get_messages(entity, ids=message_id) + if isinstance(target_message, list): + target_message = target_message[0] if target_message else None + else: + recent_messages = await client.get_messages(entity, limit=20) + target_message = next( + (msg for msg in recent_messages if getattr(msg, "buttons", None)), None + ) + + if not target_message: + return "No message with inline buttons found. Specify message_id to target a specific message." + + buttons_attr = getattr(target_message, "buttons", None) + if not buttons_attr: + return f"Message {target_message.id} does not contain inline buttons." + + buttons = [btn for row in buttons_attr for btn in row] + if not buttons: + return f"Message {target_message.id} does not contain inline buttons." + + target_button = None + if button_text: + normalized = button_text.strip().lower() + target_button = next( + ( + btn + for btn in buttons + if (getattr(btn, "text", "") or "").strip().lower() == normalized + ), + None, + ) + + if target_button is None and button_index is not None: + if button_index < 0 or button_index >= len(buttons): + return f"button_index out of range. Valid indices: 0-{len(buttons) - 1}." + target_button = buttons[button_index] + + if not target_button: + available = ", ".join( + f"[{idx}] {getattr(btn, 'text', '') or ''}" + for idx, btn in enumerate(buttons) + ) + return f"Button not found. Available buttons: {available}" + + if not getattr(target_button, "data", None): + raw_button = getattr(target_button, "button", None) + url = getattr(raw_button, "url", None) if raw_button else None + if url: + return f"Selected button opens a URL instead of sending a callback: {url}" + return "Selected button does not provide callback data to press." + + callback_result = await client( + functions.messages.GetBotCallbackAnswerRequest( + peer=entity, msg_id=target_message.id, data=target_button.data + ) + ) + + response_parts = [] + if getattr(callback_result, "message", None): + response_parts.append(callback_result.message) + if getattr(callback_result, "alert", None): + response_parts.append("Telegram displayed an alert to the user.") + if not response_parts: + response_parts.append("Button pressed successfully.") + + return " ".join(response_parts) + except Exception as e: + return log_and_format_error( + "press_inline_button", + e, + chat_id=chat_id, + message_id=message_id, + button_text=button_text, + button_index=button_index, + ) + + +@mcp.tool( + annotations=ToolAnnotations(title="Send Reaction", openWorldHint=True, destructiveHint=False, idempotentHint=True) +) +@validate_id("chat_id") +async def send_reaction( + chat_id: Union[int, str], + message_id: int, + emoji: str, + big: bool = False, +) -> str: + """ + Send a reaction to a message. + + Args: + chat_id: The chat ID or username + message_id: The message ID to react to + emoji: The emoji to react with (e.g., "๐Ÿ‘", "โค๏ธ", "๐Ÿ”ฅ", "๐Ÿ˜‚", "๐Ÿ˜ฎ", "๐Ÿ˜ข", "๐ŸŽ‰", "๐Ÿ’ฉ", "๐Ÿ‘Ž") + big: Whether to show a big animation for the reaction (default: False) + """ + try: + from telethon.tl.types import ReactionEmoji + + peer = await client.get_input_entity(chat_id) + await client( + functions.messages.SendReactionRequest( + peer=peer, + msg_id=message_id, + big=big, + reaction=[ReactionEmoji(emoticon=emoji)], + ) + ) + return f"Reaction '{emoji}' sent to message {message_id} in chat {chat_id}." + except Exception as e: + logger.exception( + f"send_reaction failed (chat_id={chat_id}, message_id={message_id}, emoji={emoji})" + ) + return log_and_format_error( + "send_reaction", e, chat_id=chat_id, message_id=message_id, emoji=emoji + ) + + +@mcp.tool( + annotations=ToolAnnotations(title="Remove Reaction", openWorldHint=True, destructiveHint=True, idempotentHint=True) +) +@validate_id("chat_id") +async def remove_reaction( + chat_id: Union[int, str], + message_id: int, +) -> str: + """ + Remove your reaction from a message. + + Args: + chat_id: The chat ID or username + message_id: The message ID to remove reaction from + """ + try: + peer = await client.get_input_entity(chat_id) + await client( + functions.messages.SendReactionRequest( + peer=peer, + msg_id=message_id, + reaction=[], + ) + ) + return f"Reaction removed from message {message_id} in chat {chat_id}." + except Exception as e: + logger.exception(f"remove_reaction failed (chat_id={chat_id}, message_id={message_id})") + return log_and_format_error("remove_reaction", e, chat_id=chat_id, message_id=message_id) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Message Reactions", openWorldHint=True, readOnlyHint=True, idempotentHint=True) +) +@validate_id("chat_id") +async def get_message_reactions( + chat_id: Union[int, str], + message_id: int, + limit: int = 50, +) -> str: + """ + Get the list of reactions on a message. + + Args: + chat_id: The chat ID or username + message_id: The message ID to get reactions from + limit: Maximum number of users to return per reaction (default: 50) + """ + try: + from telethon.tl.types import ReactionEmoji, ReactionCustomEmoji + + peer = await client.get_input_entity(chat_id) + + result = await client( + functions.messages.GetMessageReactionsListRequest( + peer=peer, + id=message_id, + limit=limit, + ) + ) + + if not result.reactions: + return f"No reactions on message {message_id} in chat {chat_id}." + + reactions_data = [] + for reaction in result.reactions: + user_id = reaction.peer_id.user_id if hasattr(reaction.peer_id, "user_id") else None + emoji = None + if isinstance(reaction.reaction, ReactionEmoji): + emoji = reaction.reaction.emoticon + elif isinstance(reaction.reaction, ReactionCustomEmoji): + emoji = f"custom:{reaction.reaction.document_id}" + + reactions_data.append( + { + "user_id": user_id, + "emoji": emoji, + "date": reaction.date.isoformat() if reaction.date else None, + } + ) + + return json.dumps( + { + "message_id": message_id, + "chat_id": str(chat_id), + "reactions": reactions_data, + "count": len(reactions_data), + }, + indent=2, + default=json_serializer, + ) + except Exception as e: + logger.exception( + f"get_message_reactions failed (chat_id={chat_id}, message_id={message_id})" + ) + return log_and_format_error( + "get_message_reactions", e, chat_id=chat_id, message_id=message_id + ) diff --git a/src/telegram_mcp/tools/misc.py b/src/telegram_mcp/tools/misc.py new file mode 100644 index 0000000..5b66ddf --- /dev/null +++ b/src/telegram_mcp/tools/misc.py @@ -0,0 +1,219 @@ +"""Miscellaneous tools for telegram-mcp (polls, drafts, etc.).""" + +import json +import random +from datetime import datetime +from typing import Union, Optional + +from telethon import functions +from telethon.tl.types import ( + InputMediaPoll, + Poll, + PollAnswer, + TextWithEntities, + InputReplyToMessage, +) + +from ..app import mcp, client +from ..exceptions import log_and_format_error +from ..validators import validate_id +from ..formatters import json_serializer +from ..logging_config import logger +from mcp.types import ToolAnnotations + + +@mcp.tool( + annotations=ToolAnnotations(title="Create Poll", openWorldHint=True, destructiveHint=True) +) +async def create_poll( + chat_id: int, + question: str, + options: list, + multiple_choice: bool = False, + quiz_mode: bool = False, + public_votes: bool = True, + close_date: Optional[str] = None, +) -> str: + """ + Create a poll in a chat using Telegram's native poll feature. + + Args: + chat_id: The ID of the chat to send the poll to + question: The poll question + options: List of answer options (2-10 options) + multiple_choice: Whether users can select multiple answers + quiz_mode: Whether this is a quiz (has correct answer) + public_votes: Whether votes are public + close_date: Optional close date in ISO format (YYYY-MM-DD HH:MM:SS) + """ + try: + peer = await client.get_input_entity(chat_id) + + if len(options) < 2: + return "Error: Poll must have at least 2 options." + if len(options) > 10: + return "Error: Poll can have at most 10 options." + + close_date_obj = None + if close_date: + try: + close_date_obj = datetime.fromisoformat(close_date.replace("Z", "+00:00")) + except ValueError: + return f"Invalid close_date format. Use YYYY-MM-DD HH:MM:SS format." + + poll = Poll( + id=random.randint(0, 2**63 - 1), + question=TextWithEntities(text=question, entities=[]), + answers=[ + PollAnswer(text=TextWithEntities(text=option, entities=[]), option=bytes([i])) + for i, option in enumerate(options) + ], + multiple_choice=multiple_choice, + quiz=quiz_mode, + public_voters=public_votes, + close_date=close_date_obj, + ) + + await client( + functions.messages.SendMediaRequest( + peer=peer, + media=InputMediaPoll(poll=poll), + message="", + random_id=random.randint(0, 2**63 - 1), + ) + ) + + return f"Poll created successfully in chat {chat_id}." + except Exception as e: + logger.exception(f"create_poll failed (chat_id={chat_id}, question='{question}')") + return log_and_format_error( + "create_poll", e, chat_id=chat_id, question=question, options=options + ) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Save Draft", openWorldHint=True, destructiveHint=False, idempotentHint=True + ) +) +@validate_id("chat_id") +async def save_draft( + chat_id: Union[int, str], + message: str, + reply_to_msg_id: Optional[int] = None, + no_webpage: bool = False, +) -> str: + """ + Save a draft message to a chat or channel. The draft will appear in the Telegram + app's input field when you open that chat, allowing you to review and send it manually. + + Args: + chat_id: The chat ID or username/channel to save the draft to + message: The draft message text + reply_to_msg_id: Optional message ID to reply to + no_webpage: If True, disable link preview in the draft + """ + try: + peer = await client.get_input_entity(chat_id) + + reply_to = None + if reply_to_msg_id: + reply_to = InputReplyToMessage(reply_to_msg_id=reply_to_msg_id) + + await client( + functions.messages.SaveDraftRequest( + peer=peer, + message=message, + no_webpage=no_webpage, + reply_to=reply_to, + ) + ) + + return f"Draft saved to chat {chat_id}. Open the chat in Telegram to see and send it." + except Exception as e: + logger.exception(f"save_draft failed (chat_id={chat_id})") + return log_and_format_error("save_draft", e, chat_id=chat_id) + + +@mcp.tool(annotations=ToolAnnotations(title="Get Drafts", openWorldHint=True, readOnlyHint=True)) +async def get_drafts() -> str: + """ + Get all draft messages across all chats. + Returns a list of drafts with their chat info and message content. + """ + try: + result = await client(functions.messages.GetAllDraftsRequest()) + + drafts_info = [] + + updates = getattr(result, "updates", None) + if updates: + for update in updates: + if hasattr(update, "draft") and update.draft: + draft = update.draft + peer_id = None + + if hasattr(update, "peer"): + peer = update.peer + if hasattr(peer, "user_id"): + peer_id = peer.user_id + elif hasattr(peer, "chat_id"): + peer_id = -peer.chat_id + elif hasattr(peer, "channel_id"): + peer_id = -1000000000000 - peer.channel_id + + draft_data = { + "peer_id": peer_id, + "message": getattr(draft, "message", ""), + "date": ( + draft.date.isoformat() + if hasattr(draft, "date") and draft.date + else None + ), + "no_webpage": getattr(draft, "no_webpage", False), + "reply_to_msg_id": ( + draft.reply_to.reply_to_msg_id + if hasattr(draft, "reply_to") and draft.reply_to + else None + ), + } + drafts_info.append(draft_data) + + if not drafts_info: + return "No drafts found." + + return json.dumps( + {"drafts": drafts_info, "count": len(drafts_info)}, indent=2, default=json_serializer + ) + except Exception as e: + logger.exception("get_drafts failed") + return log_and_format_error("get_drafts", e) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Clear Draft", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("chat_id") +async def clear_draft(chat_id: Union[int, str]) -> str: + """ + Clear/delete a draft from a specific chat. + + Args: + chat_id: The chat ID or username to clear the draft from + """ + try: + peer = await client.get_input_entity(chat_id) + + await client( + functions.messages.SaveDraftRequest( + peer=peer, + message="", + ) + ) + + return f"Draft cleared from chat {chat_id}." + except Exception as e: + logger.exception(f"clear_draft failed (chat_id={chat_id})") + return log_and_format_error("clear_draft", e, chat_id=chat_id) diff --git a/src/telegram_mcp/tools/users.py b/src/telegram_mcp/tools/users.py new file mode 100644 index 0000000..04c7dc7 --- /dev/null +++ b/src/telegram_mcp/tools/users.py @@ -0,0 +1,248 @@ +"""User and profile management tools for telegram-mcp.""" + +import json +from typing import Union, List, Optional + +from telethon import functions + +from ..app import mcp, client +from ..exceptions import log_and_format_error +from ..validators import validate_id +from ..formatters import format_entity +from ..logging_config import logger +from mcp.types import ToolAnnotations + + +@mcp.tool(annotations=ToolAnnotations(title="Get Me", openWorldHint=True, readOnlyHint=True)) +async def get_me() -> str: + """ + Get your own user information. + """ + try: + me = await client.get_me() + return json.dumps(format_entity(me), indent=2) + except Exception as e: + return log_and_format_error("get_me", e) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Update Profile", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +async def update_profile( + first_name: Optional[str] = None, last_name: Optional[str] = None, about: Optional[str] = None +) -> str: + """ + Update your profile information (name, bio). + """ + try: + await client( + functions.account.UpdateProfileRequest( + first_name=first_name, last_name=last_name, about=about + ) + ) + return "Profile updated." + except Exception as e: + return log_and_format_error( + "update_profile", e, first_name=first_name, last_name=last_name, about=about + ) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Set Profile Photo", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +async def set_profile_photo(file_path: str) -> str: + """ + Set a new profile photo. + """ + try: + await client( + functions.photos.UploadProfilePhotoRequest(file=await client.upload_file(file_path)) + ) + return "Profile photo updated." + except Exception as e: + return log_and_format_error("set_profile_photo", e, file_path=file_path) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Delete Profile Photo", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +async def delete_profile_photo() -> str: + """ + Delete your current profile photo. + """ + try: + from telethon.tl.types import InputUserSelf, InputPhoto + + photos_result = await client( + functions.photos.GetUserPhotosRequest(user_id=InputUserSelf(), offset=0, max_id=0, limit=1) + ) + photo_list = getattr(photos_result, "photos", []) + if not photo_list: + return "No profile photo to delete." + photo = photo_list[0] + input_photo = InputPhoto(id=photo.id, access_hash=photo.access_hash, file_reference=photo.file_reference) + await client(functions.photos.DeletePhotosRequest(id=[input_photo])) + return "Profile photo deleted." + except Exception as e: + return log_and_format_error("delete_profile_photo", e) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get Privacy Settings", openWorldHint=True, readOnlyHint=True) +) +async def get_privacy_settings() -> str: + """ + Get your privacy settings for last seen status. + """ + try: + from telethon.tl.types import InputPrivacyKeyStatusTimestamp + + try: + settings = await client( + functions.account.GetPrivacyRequest(key=InputPrivacyKeyStatusTimestamp()) + ) + return str(settings) + except TypeError as e: + if "TLObject was expected" in str(e): + return "Error: Privacy settings API call failed due to type mismatch. This is likely a version compatibility issue with Telethon." + else: + raise + except Exception as e: + logger.exception("get_privacy_settings failed") + return log_and_format_error("get_privacy_settings", e) + + +@mcp.tool( + annotations=ToolAnnotations( + title="Set Privacy Settings", openWorldHint=True, destructiveHint=True, idempotentHint=True + ) +) +@validate_id("allow_users", "disallow_users") +async def set_privacy_settings( + key: str, + allow_users: Optional[List[Union[int, str]]] = None, + disallow_users: Optional[List[Union[int, str]]] = None, +) -> str: + """ + Set privacy settings (e.g., last seen, phone, etc.). + + Args: + key: The privacy setting to modify ('status' for last seen, 'phone', 'profile_photo', etc.) + allow_users: List of user IDs or usernames to allow + disallow_users: List of user IDs or usernames to disallow + """ + try: + from telethon.tl.types import ( + InputPrivacyKeyStatusTimestamp, + InputPrivacyKeyPhoneNumber, + InputPrivacyKeyProfilePhoto, + InputPrivacyValueAllowUsers, + InputPrivacyValueDisallowUsers, + InputPrivacyValueAllowAll, + ) + + key_mapping = { + "status": InputPrivacyKeyStatusTimestamp, + "phone": InputPrivacyKeyPhoneNumber, + "profile_photo": InputPrivacyKeyProfilePhoto, + } + + if key not in key_mapping: + return f"Error: Unsupported privacy key '{key}'. Supported keys: {', '.join(key_mapping.keys())}" + + privacy_key = key_mapping[key]() + rules = [] + + if allow_users is None or len(allow_users) == 0: + rules.append(InputPrivacyValueAllowAll()) + else: + try: + allow_entities = [] + for user_id in allow_users: + try: + user = await client.get_entity(user_id) + allow_entities.append(user) + except Exception as user_err: + logger.warning(f"Could not get entity for user ID {user_id}: {user_err}") + + if allow_entities: + rules.append(InputPrivacyValueAllowUsers(users=allow_entities)) + except Exception as allow_err: + logger.error(f"Error processing allowed users: {allow_err}") + return log_and_format_error("set_privacy_settings", allow_err, key=key) + + if disallow_users and len(disallow_users) > 0: + try: + disallow_entities = [] + for user_id in disallow_users: + try: + user = await client.get_entity(user_id) + disallow_entities.append(user) + except Exception as user_err: + logger.warning(f"Could not get entity for user ID {user_id}: {user_err}") + + if disallow_entities: + rules.append(InputPrivacyValueDisallowUsers(users=disallow_entities)) + except Exception as disallow_err: + logger.error(f"Error processing disallowed users: {disallow_err}") + return log_and_format_error("set_privacy_settings", disallow_err, key=key) + + try: + await client( + functions.account.SetPrivacyRequest(key=privacy_key, rules=rules) + ) + return f"Privacy settings for {key} updated successfully." + except TypeError as type_err: + if "TLObject was expected" in str(type_err): + return "Error: Privacy settings API call failed due to type mismatch. This is likely a version compatibility issue with Telethon." + else: + raise + except Exception as e: + logger.exception(f"set_privacy_settings failed (key={key})") + return log_and_format_error("set_privacy_settings", e, key=key) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get User Photos", openWorldHint=True, readOnlyHint=True) +) +@validate_id("user_id") +async def get_user_photos(user_id: Union[int, str], limit: int = 10) -> str: + """ + Get profile photos of a user. + """ + try: + from telethon.utils import get_input_user + + user = await client.get_entity(user_id) + input_user = get_input_user(user) + photos = await client( + functions.photos.GetUserPhotosRequest(user_id=input_user, offset=0, max_id=0, limit=limit) + ) + photo_list = getattr(photos, "photos", []) + return json.dumps([p.id for p in photo_list], indent=2) + except Exception as e: + return log_and_format_error("get_user_photos", e, user_id=user_id, limit=limit) + + +@mcp.tool( + annotations=ToolAnnotations(title="Get User Status", openWorldHint=True, readOnlyHint=True) +) +@validate_id("user_id") +async def get_user_status(user_id: Union[int, str]) -> str: + """ + Get the online status of a user. + """ + try: + user = await client.get_entity(user_id) + status = getattr(user, "status", None) + if status is None: + return "Status not available for this entity type." + return str(status) + except Exception as e: + return log_and_format_error("get_user_status", e, user_id=user_id) diff --git a/src/telegram_mcp/validators.py b/src/telegram_mcp/validators.py new file mode 100644 index 0000000..805f72b --- /dev/null +++ b/src/telegram_mcp/validators.py @@ -0,0 +1,90 @@ +"""Validation decorators for telegram-mcp.""" + +import re +from functools import wraps + +from .exceptions import ValidationError, log_and_format_error + + +def validate_id(*param_names_to_validate): + """ + Decorator to validate chat_id and user_id parameters, including lists of IDs. + It checks for valid integer ranges, string representations of integers, + and username formats. + """ + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + for param_name in param_names_to_validate: + if param_name not in kwargs or kwargs[param_name] is None: + continue + + param_value = kwargs[param_name] + + def validate_single_id(value, p_name): + # Handle integer IDs + if isinstance(value, int): + if not (-(2**63) <= value <= 2**63 - 1): + return ( + None, + f"Invalid {p_name}: {value}. ID is out of the valid integer range.", + ) + return value, None + + # Handle string IDs + if isinstance(value, str): + try: + int_value = int(value) + if not (-(2**63) <= int_value <= 2**63 - 1): + return ( + None, + f"Invalid {p_name}: {value}. ID is out of the valid integer range.", + ) + return int_value, None + except ValueError: + if re.match(r"^@?[a-zA-Z0-9_]{5,}$", value): + return value, None + else: + return ( + None, + f"Invalid {p_name}: '{value}'. Must be a valid integer ID, or a username string.", + ) + + # Handle other invalid types + return ( + None, + f"Invalid {p_name}: {value}. Type must be an integer or a string.", + ) + + if isinstance(param_value, list): + validated_list = [] + for item in param_value: + validated_item, error_msg = validate_single_id(item, param_name) + if error_msg: + return log_and_format_error( + func.__name__, + ValidationError(error_msg), + prefix="VALIDATION-001", + user_message=error_msg, + **{param_name: param_value}, + ) + validated_list.append(validated_item) + kwargs[param_name] = validated_list + else: + validated_value, error_msg = validate_single_id(param_value, param_name) + if error_msg: + return log_and_format_error( + func.__name__, + ValidationError(error_msg), + prefix="VALIDATION-001", + user_message=error_msg, + **{param_name: param_value}, + ) + kwargs[param_name] = validated_value + + return await func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4763f2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package for telegram-mcp.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fcac886 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +"""Pytest configuration and fixtures for telegram-mcp tests.""" + +import os +import pytest + +os.environ.setdefault("TELEGRAM_API_ID", "12345") +os.environ.setdefault("TELEGRAM_API_HASH", "dummy_hash") +os.environ.setdefault("TELEGRAM_SESSION_NAME", "test_session") diff --git a/tests/test_formatters.py b/tests/test_formatters.py new file mode 100644 index 0000000..ec010b7 --- /dev/null +++ b/tests/test_formatters.py @@ -0,0 +1,33 @@ +"""Tests for formatter functions.""" + +import pytest +from datetime import datetime +from telegram_mcp.formatters import json_serializer + + +def test_json_serializer_datetime(): + dt = datetime(2024, 1, 15, 10, 30, 45) + result = json_serializer(dt) + assert result == "2024-01-15T10:30:45" + + +def test_json_serializer_bytes(): + data = b"hello world" + result = json_serializer(data) + assert result == "hello world" + + +def test_json_serializer_bytes_non_utf8(): + data = bytes([0x80, 0x81, 0x82]) + result = json_serializer(data) + # errors="replace" replaces invalid bytes with replacement character + assert "\ufffd" in result or "๏ฟฝ" in result + + +def test_json_serializer_unknown_type_raises(): + class UnknownType: + pass + + obj = UnknownType() + with pytest.raises(TypeError, match="is not JSON serializable"): + json_serializer(obj) diff --git a/tests/test_readonly_tools.py b/tests/test_readonly_tools.py new file mode 100644 index 0000000..f80eaca --- /dev/null +++ b/tests/test_readonly_tools.py @@ -0,0 +1,124 @@ +"""Integration tests for read-only tools. + +These tests require valid Telegram credentials in .env file. +They test actual API calls to Telegram. +""" + +import pytest +import asyncio +import os +from dotenv import load_dotenv + +load_dotenv() + +# Skip all tests if credentials are not available +pytestmark = pytest.mark.skipif( + not os.getenv("TELEGRAM_API_ID") or os.getenv("TELEGRAM_API_ID") == "12345", + reason="Valid Telegram credentials required" +) + + +@pytest.fixture(scope="module") +def event_loop(): + """Create event loop for async tests.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="module") +async def setup_client(): + """Setup Telegram client for testing.""" + from telegram_mcp.app import client + + if not client.is_connected(): + await client.connect() + + yield client + + +class TestUserTools: + """Test user-related read-only tools.""" + + @pytest.mark.asyncio + async def test_get_me(self, setup_client): + from telegram_mcp.tools.users import get_me + result = await get_me() + assert "id" in result or "Error" not in result + print(f"get_me result: {result[:200]}...") + + @pytest.mark.asyncio + async def test_get_privacy_settings(self, setup_client): + from telegram_mcp.tools.users import get_privacy_settings + result = await get_privacy_settings() + assert result is not None + print(f"get_privacy_settings result: {result[:200]}...") + + +class TestChatTools: + """Test chat-related read-only tools.""" + + @pytest.mark.asyncio + async def test_get_chats(self, setup_client): + from telegram_mcp.tools.chats import get_chats + result = await get_chats(limit=5) + assert result is not None + print(f"get_chats result: {result[:300]}...") + + @pytest.mark.asyncio + async def test_list_chats(self, setup_client): + from telegram_mcp.tools.chats import list_chats + result = await list_chats(limit=5) + assert result is not None + print(f"list_chats result: {result[:300]}...") + + +class TestContactTools: + """Test contact-related read-only tools.""" + + @pytest.mark.asyncio + async def test_list_contacts(self, setup_client): + from telegram_mcp.tools.contacts import list_contacts + result = await list_contacts() + assert result is not None + print(f"list_contacts result: {result[:300]}...") + + @pytest.mark.asyncio + async def test_get_blocked_users(self, setup_client): + from telegram_mcp.tools.contacts import get_blocked_users + result = await get_blocked_users() + assert result is not None + print(f"get_blocked_users result: {result[:200]}...") + + +class TestFolderTools: + """Test folder-related read-only tools.""" + + @pytest.mark.asyncio + async def test_list_folders(self, setup_client): + from telegram_mcp.tools.folders import list_folders + result = await list_folders() + assert result is not None + print(f"list_folders result: {result[:300]}...") + + +class TestMediaTools: + """Test media-related read-only tools.""" + + @pytest.mark.asyncio + async def test_get_sticker_sets(self, setup_client): + from telegram_mcp.tools.media import get_sticker_sets + result = await get_sticker_sets() + assert result is not None + print(f"get_sticker_sets result: {result[:200]}...") + + +class TestMiscTools: + """Test miscellaneous read-only tools.""" + + @pytest.mark.asyncio + async def test_get_drafts(self, setup_client): + from telegram_mcp.tools.misc import get_drafts + result = await get_drafts() + assert result is not None + print(f"get_drafts result: {result[:200]}...") diff --git a/test_validation.py b/tests/test_validators.py similarity index 89% rename from test_validation.py rename to tests/test_validators.py index 15da5ad..16a842a 100644 --- a/test_validation.py +++ b/tests/test_validators.py @@ -1,15 +1,10 @@ -import pytest -import os +"""Tests for the validate_id decorator and validation functions.""" -os.environ["TELEGRAM_API_ID"] = "12345" -os.environ["TELEGRAM_API_HASH"] = "dummy_hash" -from main import validate_id, ValidationError, log_and_format_error -from functools import wraps -import asyncio -from typing import Union, List, Optional +import pytest +from telegram_mcp.validators import validate_id +from telegram_mcp.exceptions import ValidationError -# A simple async function to be decorated for testing @validate_id("user_id", "chat_id", "user_ids") async def dummy_function(**kwargs): return "success", kwargs diff --git a/uv.lock b/uv.lock index c7729fb..25d424b 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'", @@ -40,6 +40,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "black" version = "25.9.0" @@ -394,6 +403,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -527,6 +545,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pyaes" version = "1.6.1" @@ -708,6 +735,38 @@ crypto = [ { name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.0" @@ -989,6 +1048,8 @@ dependencies = [ dev = [ { name = "black" }, { name = "flake8" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, ] [package.metadata] @@ -1006,6 +1067,8 @@ requires-dist = [ dev = [ { name = "black", specifier = ">=25.9.0" }, { name = "flake8", specifier = ">=7.3.0" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, ] [[package]]