diff --git a/.github/workflows/python-lint-format.yml b/.github/workflows/python-lint-format.yml new file mode 100644 index 0000000..a994be7 --- /dev/null +++ b/.github/workflows/python-lint-format.yml @@ -0,0 +1,37 @@ +name: Python Lint & Format Check + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + lint-and-format: + name: Check Linting & Formatting + runs-on: ubuntu-latest + + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install black flake8 + + - name: Lint with Flake8 + run: | + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=88 --statistics + + - name: Check formatting with Black + run: | + black --check . \ No newline at end of file diff --git a/main.py b/main.py index c7899df..de46be0 100644 --- a/main.py +++ b/main.py @@ -16,23 +16,33 @@ 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 + User, + Chat, + Channel, + ChatAdminRights, + ChatBannedRights, + ChannelParticipantsKicked, + ChannelParticipantsAdmins, + InputChatPhoto, + InputChatUploadedPhoto, + InputChatPhotoEmpty, + InputPeerUser, + InputPeerChat, + InputPeerChannel, ) import telethon.errors.rpcerrorlist + 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') + 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")) @@ -64,18 +74,20 @@ def json_serializer(obj): 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 = logging.FileHandler(log_file_path, mode="a") # Append mode file_handler.setLevel(logging.ERROR) - + # Create formatter and add to handlers - formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(name)s - %(message)s - %(filename)s:%(lineno)d') + formatter = logging.Formatter( + "%(asctime)s [%(levelname)s] %(name)s - %(message)s - %(filename)s:%(lineno)d" + ) console_handler.setFormatter(formatter) file_handler.setFormatter(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}") @@ -92,19 +104,22 @@ def json_serializer(obj): "media": "MEDIA", "profile": "PROFILE", "auth": "AUTH", - "admin": "ADMIN" + "admin": "ADMIN", } -def log_and_format_error(function_name: str, error: Exception, prefix: str = None, **kwargs) -> str: + +def log_and_format_error( + function_name: str, error: Exception, prefix: str = None, **kwargs +) -> str: """ Centralized error handling function that logs the error and returns a formatted user-friendly message. - + Args: function_name: Name of the function where error occurred error: The exception that was raised prefix: Error code prefix (e.g., "CHAT", "MSG") - if None, will be derived from function_name **kwargs: Additional context parameters to include in log - + Returns: A user-friendly error message with error code """ @@ -117,22 +132,23 @@ def log_and_format_error(function_name: str, error: Exception, prefix: str = Non break if prefix is None: prefix = "GEN" # Generic prefix if none matches - + error_code = f"{prefix}-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.exception(f"{function_name} failed ({context}): {error}") - + # Return a user-friendly message return f"An error occurred (code: {error_code}). Check mcp_errors.log for details." + 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" @@ -148,7 +164,7 @@ def format_entity(entity) -> Dict[str, Any]: result["username"] = entity.username if hasattr(entity, "phone") and entity.phone: result["phone"] = entity.phone - + return result @@ -159,14 +175,14 @@ def format_message(message) -> Dict[str, Any]: "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 @@ -216,7 +232,9 @@ async def get_messages(chat_id: int, page: int = 1, page_size: int = 20) -> str: lines.append(f"ID: {msg.id} | Date: {msg.date} | 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) + return log_and_format_error( + "get_messages", e, chat_id=chat_id, page=page, page_size=page_size + ) @mcp.tool() @@ -248,8 +266,8 @@ async def list_contacts() -> str: lines = [] for user in users: name = f"{getattr(user, 'first_name', '')} {getattr(user, 'last_name', '')}".strip() - username = getattr(user, 'username', '') - phone = getattr(user, 'phone', '') + username = getattr(user, "username", "") + phone = getattr(user, "phone", "") contact_info = f"ID: {user.id}, Name: {name}" if username: contact_info += f", Username: @{username}" @@ -276,8 +294,8 @@ async def search_contacts(query: str) -> str: lines = [] for user in users: name = f"{getattr(user, 'first_name', '')} {getattr(user, 'last_name', '')}".strip() - username = getattr(user, 'username', '') - phone = getattr(user, 'phone', '') + username = getattr(user, "username", "") + phone = getattr(user, "phone", "") contact_info = f"ID: {user.id}, Name: {name}" if username: contact_info += f", Username: @{username}" @@ -304,11 +322,16 @@ async def get_contact_ids() -> str: @mcp.tool() -async def list_messages(chat_id: int, limit: int = 20, search_query: str = None, - from_date: str = None, to_date: str = None) -> str: +async def list_messages( + chat_id: int, + 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 of the chat to get messages from. limit: Maximum number of messages to retrieve. @@ -318,11 +341,11 @@ async def list_messages(chat_id: int, limit: int = 20, search_query: str = None, """ 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") @@ -334,10 +357,11 @@ async def list_messages(chat_id: int, limit: int = 20, search_query: str = None, 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") @@ -348,17 +372,18 @@ async def list_messages(chat_id: int, limit: int = 20, search_query: str = None, 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: - params['search'] = search_query - + params["search"] = search_query + messages = await client.get_messages(entity, limit=limit, **params) - + # Apply date filters (Telethon doesn't support date filtering in get_messages directly) if from_date_obj or to_date_obj: filtered_messages = [] @@ -369,19 +394,23 @@ async def list_messages(chat_id: int, limit: int = 20, search_query: str = None, continue filtered_messages.append(msg) messages = filtered_messages - + if not messages: return "No messages found matching the criteria." - + lines = [] for msg in messages: sender = "" if msg.sender: - sender_name = getattr(msg.sender, 'first_name', '') or getattr(msg.sender, 'title', 'Unknown') + sender_name = getattr(msg.sender, "first_name", "") or getattr( + msg.sender, "title", "Unknown" + ) sender = f"{sender_name} | " - - lines.append(f"ID: {msg.id} | {sender}Date: {msg.date} | Message: {msg.message or '[Media/No text]'}") - + + lines.append( + f"ID: {msg.id} | {sender}Date: {msg.date} | Message: {msg.message or '[Media/No text]'}" + ) + return "\n".join(lines) except Exception as e: return log_and_format_error("list_messages", e, chat_id=chat_id) @@ -391,18 +420,18 @@ async def list_messages(chat_id: int, limit: int = 20, search_query: str = None, 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): @@ -410,39 +439,39 @@ async def list_chats(chat_type: str = None, limit: int = 20) -> str: elif isinstance(entity, Chat): current_type = "group" elif isinstance(entity, Channel): - if getattr(entity, 'broadcast', False): + 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'): + + if hasattr(entity, "title"): chat_info += f", Title: {entity.title}" - elif hasattr(entity, 'first_name'): + elif hasattr(entity, "first_name"): name = f"{entity.first_name}" - if hasattr(entity, 'last_name') and entity.last_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: + + if hasattr(entity, "username") and entity.username: chat_info += f", Username: @{entity.username}" - + # Add unread count if available - if hasattr(dialog, 'unread_count') and dialog.unread_count > 0: + if hasattr(dialog, "unread_count") and dialog.unread_count > 0: chat_info += f", Unread: {dialog.unread_count}" - + 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) @@ -452,37 +481,39 @@ async def list_chats(chat_type: str = None, limit: int = 20) -> str: async def get_chat(chat_id: int) -> str: """ Get detailed information about a specific chat. - + Args: chat_id: The ID 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'): + 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 = ( + "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: + 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}") + 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})") + result.append(f"Participants: Error fetching ({pe})") elif is_user: name = f"{entity.first_name}" @@ -496,7 +527,7 @@ async def get_chat(chat_id: int) -> str: 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 @@ -509,16 +540,18 @@ async def get_chat(chat_id: int) -> str: 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 = 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) @@ -528,7 +561,7 @@ async def get_chat(chat_id: int) -> str: 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. """ @@ -540,12 +573,16 @@ async def get_direct_chat_by_contact(contact_query: str) -> str: 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)): + 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}'." @@ -553,18 +590,22 @@ async def get_direct_chat_by_contact(contact_query: str) -> str: results = [] dialogs = await client.get_dialogs() for contact in found_contacts: - contact_name = f"{getattr(contact, 'first_name', '')} {getattr(contact, 'last_name', '')}".strip() + 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', ''): + 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]) + 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: @@ -575,7 +616,7 @@ async def get_direct_chat_by_contact(contact_query: str) -> str: async def get_contact_chats(contact_id: int) -> str: """ List all chats involving a specific contact. - + Args: contact_id: The ID of the contact. """ @@ -584,15 +625,17 @@ async def get_contact_chats(contact_id: int) -> str: 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() - + + 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: @@ -601,21 +644,21 @@ async def get_contact_chats(contact_id: int) -> str: 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_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) @@ -625,7 +668,7 @@ async def get_contact_chats(contact_id: int) -> str: async def get_last_interaction(contact_id: int) -> str: """ Get the most recent message with a contact. - + Args: contact_id: The ID of the contact. """ @@ -634,22 +677,24 @@ async def get_last_interaction(contact_id: int) -> str: 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() - + + 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) @@ -659,7 +704,7 @@ async def get_last_interaction(contact_id: int) -> str: async def get_message_context(chat_id: int, message_id: int, context_size: int = 3) -> str: """ Retrieve context around a specific message. - + Args: chat_id: The ID of the chat. message_id: The ID of the central message. @@ -668,25 +713,15 @@ async def get_message_context(chat_id: int, message_id: int, context_size: int = 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 - ) + 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 + 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}." @@ -697,12 +732,22 @@ async def get_message_context(chat_id: int, message_id: int, context_size: int = for msg in all_messages: sender_name = "Unknown" if msg.sender: - sender_name = getattr(msg.sender, 'first_name', '') or getattr(msg.sender, 'title', 'Unknown') + sender_name = getattr(msg.sender, "first_name", "") or getattr( + msg.sender, "title", "Unknown" + ) highlight = " [THIS MESSAGE]" if msg.id == message_id else "" - results.append(f"ID: {msg.id} | {sender_name} | {msg.date}{highlight}\n{msg.message or '[Media/No text]'}\n") + results.append( + f"ID: {msg.id} | {sender_name} | {msg.date}{highlight}\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) + return log_and_format_error( + "get_message_context", + e, + chat_id=chat_id, + message_id=message_id, + context_size=context_size, + ) @mcp.tool() @@ -717,17 +762,16 @@ async def add_contact(phone: str, first_name: str, last_name: str = "") -> str: 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 - ) - ] - )) + + 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: @@ -735,15 +779,19 @@ async def add_contact(phone: str, first_name: str, last_name: str = "") -> str: 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: + 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)}" @@ -816,7 +864,7 @@ async def get_me() -> str: async def create_group(title: str, user_ids: list) -> str: """ Create a new group or supergroup and add users. - + Args: title: Title for the new group user_ids: List of user IDs to add to the group @@ -831,25 +879,22 @@ async def create_group(title: str, user_ids: list) -> str: 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 - )) - + result = await client(functions.messages.CreateChatRequest(users=users, title=title)) + # Check what type of response we got - if hasattr(result, 'chats') and result.chats: + 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: + elif hasattr(result, "chat") and result.chat: return f"Group created with ID: {result.chat.id}" - elif hasattr(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 @@ -859,10 +904,10 @@ async def create_group(title: str, user_ids: list) -> str: 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." @@ -877,7 +922,7 @@ async def create_group(title: str, user_ids: list) -> str: async def invite_to_group(group_id: int, user_ids: list) -> str: """ Invite users to a group or channel. - + Args: group_id: The ID of the group/channel. user_ids: List of user IDs to invite. @@ -885,36 +930,40 @@ async def invite_to_group(group_id: int, user_ids: list) -> str: 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 - )) - + result = await client( + functions.channels.InviteToChannelRequest(channel=entity, users=users_to_add) + ) + invited_count = 0 - if hasattr(result, 'users') and result.users: + if hasattr(result, "users") and result.users: invited_count = len(result.users) - elif hasattr(result, 'count'): + 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." + 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) + 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) @@ -922,62 +971,78 @@ async def invite_to_group(group_id: int, user_ids: list) -> str: async def leave_chat(chat_id: int) -> str: """ Leave a group or channel by chat ID. - + Args: chat_id: The chat ID 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)) + 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, # Use the entity ID directly - user_id=me - )) - chat_name = getattr(entity, 'title', str(chat_id)) + 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") - + 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)) + 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) - + 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", + 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) @@ -990,7 +1055,10 @@ async def get_participants(chat_id: int) -> str: """ 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] + 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) @@ -1014,7 +1082,9 @@ async def send_file(chat_id: int, file_path: str, caption: str = None) -> str: 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) + return log_and_format_error( + "send_file", e, chat_id=chat_id, file_path=file_path, caption=caption + ) @mcp.tool() @@ -1032,7 +1102,7 @@ async def download_media(chat_id: int, message_id: int, file_path: str) -> str: 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 '.' + 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) @@ -1040,7 +1110,9 @@ async def download_media(chat_id: int, message_id: int, file_path: str) -> str: 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) + return log_and_format_error( + "download_media", e, chat_id=chat_id, message_id=message_id, file_path=file_path + ) @mcp.tool() @@ -1049,14 +1121,16 @@ async def update_profile(first_name: str = None, last_name: str = None, about: s Update your profile information (name, bio). """ try: - await client(functions.account.UpdateProfileRequest( - first_name=first_name, - last_name=last_name, - about=about - )) + 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) + return log_and_format_error( + "update_profile", e, first_name=first_name, last_name=last_name, about=about + ) @mcp.tool() @@ -1065,9 +1139,9 @@ 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) - )) + 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) @@ -1079,7 +1153,9 @@ 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)) + 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])) @@ -1096,11 +1172,11 @@ async def get_privacy_settings() -> str: try: # Import needed types directly from telethon.tl.types import InputPrivacyKeyStatusTimestamp - + try: - settings = await client(functions.account.GetPrivacyRequest( - key=InputPrivacyKeyStatusTimestamp() - )) + settings = await client( + functions.account.GetPrivacyRequest(key=InputPrivacyKeyStatusTimestamp()) + ) return str(settings) except TypeError as e: if "TLObject was expected" in str(e): @@ -1113,43 +1189,45 @@ async def get_privacy_settings() -> str: @mcp.tool() -async def set_privacy_settings(key: str, allow_users: list = None, disallow_users: list = None) -> str: +async def set_privacy_settings( + key: str, allow_users: list = None, disallow_users: list = 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 to allow + allow_users: List of user IDs to allow disallow_users: List of user IDs to disallow """ try: # Import needed types from telethon.tl.types import ( - InputPrivacyKeyStatusTimestamp, + InputPrivacyKeyStatusTimestamp, InputPrivacyKeyPhoneNumber, InputPrivacyKeyProfilePhoto, - InputPrivacyValueAllowUsers, + InputPrivacyValueAllowUsers, InputPrivacyValueDisallowUsers, InputPrivacyValueAllowAll, - InputPrivacyValueDisallowAll + InputPrivacyValueDisallowAll, ) - + # Map the simplified keys to their corresponding input types key_mapping = { - 'status': InputPrivacyKeyStatusTimestamp, - 'phone': InputPrivacyKeyPhoneNumber, - 'profile_photo': InputPrivacyKeyProfilePhoto, + "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 @@ -1164,13 +1242,13 @@ async def set_privacy_settings(key: str, allow_users: list = None, disallow_user 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: @@ -1181,19 +1259,18 @@ async def set_privacy_settings(key: str, allow_users: list = None, disallow_user 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 - )) + 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): @@ -1211,7 +1288,15 @@ 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)] + 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: @@ -1249,14 +1334,14 @@ async def create_channel(title: str, about: str = "", megagroup: bool = False) - Create a new channel or supergroup. """ try: - result = await client(functions.channels.CreateChannelRequest( - title=title, - about=about, - megagroup=megagroup - )) + 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) + return log_and_format_error( + "create_channel", e, title=title, about=about, megagroup=megagroup + ) @mcp.tool() @@ -1267,11 +1352,11 @@ async def edit_chat_title(chat_id: int, title: str) -> str: try: entity = await client.get_entity(chat_id) if isinstance(entity, Channel): - await client(functions.channels.EditTitleRequest(channel=entity, title=title)) + await client(functions.channels.EditTitleRequest(channel=entity, title=title)) elif isinstance(entity, Chat): - await client(functions.messages.EditChatTitleRequest(chat_id=chat_id, title=title)) + 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"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}')") @@ -1285,24 +1370,26 @@ async def edit_chat_photo(chat_id: int, file_path: str) -> str: """ try: if not os.path.isfile(file_path): - return f"Photo file not found: {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}" + 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)) + # 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)) + # 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"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}')") @@ -1318,12 +1405,18 @@ async def delete_chat_photo(chat_id: int) -> str: 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())) + 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())) + # 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"Cannot delete photo for this entity type ({type(entity)})." return f"Chat {chat_id} photo deleted." except Exception as e: @@ -1335,7 +1428,7 @@ async def delete_chat_photo(chat_id: int) -> str: async def promote_admin(group_id: int, user_id: int, rights: dict = None) -> str: """ Promote a user to admin in a group/channel. - + Args: group_id: ID of the group/channel user_id: User ID to promote @@ -1344,52 +1437,54 @@ async def promote_admin(group_id: int, user_id: int, rights: dict = None) -> str 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 + "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) + 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" - )) + 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) + 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) @@ -1397,7 +1492,7 @@ async def promote_admin(group_id: int, user_id: int, rights: dict = None) -> str async def demote_admin(group_id: int, user_id: int) -> str: """ Demote a user from admin in a group/channel. - + Args: group_id: ID of the group/channel user_id: User ID to demote @@ -1405,7 +1500,7 @@ async def demote_admin(group_id: int, user_id: int) -> str: 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, @@ -1418,24 +1513,26 @@ async def demote_admin(group_id: int, user_id: int) -> str: add_admins=False, anonymous=False, manage_call=False, - other=False + other=False, ) - + try: - result = await client(functions.channels.EditAdminRequest( - channel=chat, - user_id=user, - admin_rights=admin_rights, - rank="" - )) + 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) + 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) @@ -1443,7 +1540,7 @@ async def demote_admin(group_id: int, user_id: int) -> str: async def ban_user(chat_id: int, user_id: int) -> str: """ Ban a user from a group or channel. - + Args: chat_id: ID of the group/channel user_id: User ID to ban @@ -1451,7 +1548,7 @@ async def ban_user(chat_id: int, user_id: int) -> str: 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 @@ -1466,15 +1563,15 @@ async def ban_user(chat_id: int, user_id: int) -> str: send_polls=True, change_info=True, invite_users=True, - pin_messages=True + pin_messages=True, ) - + try: - await client(functions.channels.EditBannedRequest( - channel=chat, - participant=user, - banned_rights=banned_rights - )) + 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." @@ -1489,7 +1586,7 @@ async def ban_user(chat_id: int, user_id: int) -> str: async def unban_user(chat_id: int, user_id: int) -> str: """ Unban a user from a group or channel. - + Args: chat_id: ID of the group/channel user_id: User ID to unban @@ -1497,7 +1594,7 @@ async def unban_user(chat_id: int, user_id: int) -> str: 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, @@ -1512,15 +1609,15 @@ async def unban_user(chat_id: int, user_id: int) -> str: send_polls=False, change_info=False, invite_users=False, - pin_messages=False + pin_messages=False, ) - + try: - await client(functions.channels.EditBannedRequest( - channel=chat, - participant=user, - banned_rights=unbanned_rights - )) + 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." @@ -1538,8 +1635,11 @@ async def get_admins(chat_id: int) -> str: """ 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] + 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})") @@ -1553,8 +1653,13 @@ async def get_banned_users(chat_id: int) -> str: """ 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] + 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})") @@ -1568,13 +1673,12 @@ async def get_invite_link(chat_id: int) -> str: """ 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 - )) + + result = await client(functions.messages.ExportChatInviteRequest(peer=entity)) return result.link except AttributeError: # If the function doesn't exist in the current Telethon version @@ -1582,25 +1686,23 @@ async def get_invite_link(chat_id: int) -> str: 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'): + 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})") @@ -1614,34 +1716,38 @@ async def join_chat_by_link(link: str) -> str: """ try: # Extract the hash from the invite link - if '/' in link: - hash_part = link.split('/')[-1] - if hash_part.startswith('+'): + 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: - from telethon.errors import (InviteHashExpiredError, InviteHashInvalidError, - UserAlreadyParticipantError, ChatAdminRequiredError, - UsersTooMuchError) - + 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_part)) - if hasattr(invite_info, 'chat') and invite_info.chat: + 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') + 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_part)) - if result and hasattr(result, 'chats') and result.chats: - chat_title = getattr(result.chats[0], 'title', 'Unknown Chat') + 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: @@ -1670,13 +1776,12 @@ async def export_chat_invite(chat_id: int) -> str: """ 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 - )) + + result = await client(functions.messages.ExportChatInviteRequest(peer=entity)) return result.link except AttributeError: # If the function doesn't exist in the current Telethon version @@ -1684,7 +1789,7 @@ async def export_chat_invite(chat_id: int) -> str: 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) @@ -1704,30 +1809,34 @@ async def import_chat_invite(hash: str) -> str: """ try: # Remove any prefixes like '+' if present - if hash.startswith('+'): + if hash.startswith("+"): hash = hash[1:] - + # Try checking the invite before joining try: - from telethon.errors import (InviteHashExpiredError, InviteHashInvalidError, - UserAlreadyParticipantError, ChatAdminRequiredError, - UsersTooMuchError) - + 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 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') + 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') + 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: @@ -1763,7 +1872,14 @@ async def send_voice(chat_id: int, file_path: str) -> str: 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'))): + 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) @@ -1783,7 +1899,13 @@ async def forward_message(from_chat_id: int, message_id: int, to_chat_id: int) - 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) + 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() @@ -1796,7 +1918,9 @@ async def edit_message(chat_id: int, message_id: int, new_text: str) -> str: 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) + return log_and_format_error( + "edit_message", e, chat_id=chat_id, message_id=message_id, new_text=new_text + ) @mcp.tool() @@ -1861,7 +1985,9 @@ async def reply_to_message(chat_id: int, message_id: int, text: str) -> str: 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) + return log_and_format_error( + "reply_to_message", e, chat_id=chat_id, message_id=message_id, text=text + ) @mcp.tool() @@ -1922,7 +2048,9 @@ async def search_messages(chat_id: int, query: str, limit: int = 20) -> str: messages = await client.get_messages(entity, limit=limit, search=query) return "\n".join([f"ID: {m.id} | {m.date} | {m.message}" for m in messages]) except Exception as e: - return log_and_format_error("search_messages", e, chat_id=chat_id, query=query, limit=limit) + return log_and_format_error( + "search_messages", e, chat_id=chat_id, query=query, limit=limit + ) @mcp.tool() @@ -1944,25 +2072,28 @@ async def mute_chat(chat_id: int) -> str: """ 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) - )) + 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 - } - )) + 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})") @@ -1979,25 +2110,28 @@ async def unmute_chat(chat_id: int) -> str: """ 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) - )) + 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 - } - )) + 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})") @@ -2013,10 +2147,11 @@ async def archive_chat(chat_id: int) -> str: Archive a chat. """ try: - await client(functions.messages.ToggleDialogPinRequest( - peer=await client.get_entity(chat_id), - pinned=True - )) + 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) @@ -2028,10 +2163,11 @@ async def unarchive_chat(chat_id: int) -> str: Unarchive a chat. """ try: - await client(functions.messages.ToggleDialogPinRequest( - peer=await client.get_entity(chat_id), - pinned=False - )) + 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) @@ -2062,7 +2198,7 @@ async def send_sticker(chat_id: int, file_path: str) -> str: 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'): + 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) @@ -2082,25 +2218,40 @@ async def get_gif_search(query: str, limit: int = 10) -> str: try: # Try approach 1: SearchGifsRequest try: - result = await client(functions.messages.SearchGifsRequest(q=query, offset_id=0, limit=limit)) + 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) + 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: + + 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'): + 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: @@ -2138,11 +2289,11 @@ async def get_bot_info(bot_username: str) -> str: 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'): + 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: @@ -2159,7 +2310,7 @@ async def get_bot_info(bot_username: str) -> str: } 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})") @@ -2172,7 +2323,7 @@ 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. @@ -2180,29 +2331,30 @@ async def set_bot_commands(bot_username: str, commands: list) -> str: try: # First check if the current client is a bot me = await client.get_me() - if not getattr(me, 'bot', False): + 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 + 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 - )) - + 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}") @@ -2232,7 +2384,9 @@ async def get_user_photos(user_id: int, limit: int = 10) -> str: """ try: user = await client.get_entity(user_id) - photos = await client(functions.photos.GetUserPhotosRequest(user_id=user, offset=0, max_id=0, limit=limit)) + 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) @@ -2256,19 +2410,15 @@ async def get_recent_actions(chat_id: int) -> 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 - )) - + 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: @@ -2287,16 +2437,19 @@ async def get_pinned_messages(chat_id: int) -> str: 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)] - + messages = [m for m in all_messages if getattr(m, "pinned", False)] + if not messages: return "No pinned messages found in this chat." - - return "\n".join([f"ID: {m.id} | {m.date} | {m.message or '[Media/No text]'}" for m in messages]) + + return "\n".join( + [f"ID: {m.id} | {m.date} | {m.message or '[Media/No text]'}" for m in messages] + ) 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) @@ -2310,7 +2463,7 @@ async def main() -> None: # 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() @@ -2319,7 +2472,7 @@ async def main() -> None: 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 + file=sys.stderr, ) sys.exit(1) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..3a058ed --- /dev/null +++ b/poetry.lock @@ -0,0 +1,666 @@ +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.9.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, + {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] +trio = ["trio (>=0.26.1)"] + +[[package]] +name = "certifi" +version = "2025.1.31" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, + {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, +] + +[[package]] +name = "click" +version = "8.1.8" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +description = "Deprecated package" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9"}, +] + +[package.dependencies] +python-dotenv = "*" + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.8" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpcore-1.0.8-py3-none-any.whl", hash = "sha256:5254cf149bcb5f75e9d1b2b9f729ea4a4b883d1ad7379fc632b727cec23674be"}, + {file = "httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "httpx-sse" +version = "0.4.0" +description = "Consume Server-Sent Event (SSE) messages with HTTPX." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"}, + {file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"}, +] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mcp" +version = "1.6.0" +description = "Model Context Protocol SDK" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0"}, + {file = "mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723"}, +] + +[package.dependencies] +anyio = ">=4.5" +httpx = ">=0.27" +httpx-sse = ">=0.4" +pydantic = ">=2.7.2,<3.0.0" +pydantic-settings = ">=2.5.2" +python-dotenv = {version = ">=1.0.0", optional = true, markers = "extra == \"cli\""} +sse-starlette = ">=1.6.1" +starlette = ">=0.27" +typer = {version = ">=0.12.4", optional = true, markers = "extra == \"cli\""} +uvicorn = ">=0.23.1" + +[package.extras] +cli = ["python-dotenv (>=1.0.0)", "typer (>=0.12.4)"] +rich = ["rich (>=13.9.4)"] +ws = ["websockets (>=15.0.1)"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +description = "Patch asyncio to allow nested event loops" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, + {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, +] + +[[package]] +name = "pyaes" +version = "1.6.1" +description = "Pure-Python Implementation of the AES block-cipher and common modes of operation" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f"}, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pydantic" +version = "2.11.3" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f"}, + {file = "pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.33.1" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.1" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26"}, + {file = "pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89"}, + {file = "pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde"}, + {file = "pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65"}, + {file = "pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc"}, + {file = "pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091"}, + {file = "pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383"}, + {file = "pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504"}, + {file = "pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24"}, + {file = "pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f"}, + {file = "pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77"}, + {file = "pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961"}, + {file = "pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1"}, + {file = "pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c"}, + {file = "pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896"}, + {file = "pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83"}, + {file = "pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89"}, + {file = "pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8"}, + {file = "pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d"}, + {file = "pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b"}, + {file = "pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39"}, + {file = "pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a"}, + {file = "pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db"}, + {file = "pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda"}, + {file = "pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4"}, + {file = "pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea"}, + {file = "pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a"}, + {file = "pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d"}, + {file = "pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4"}, + {file = "pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde"}, + {file = "pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e"}, + {file = "pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd"}, + {file = "pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f"}, + {file = "pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40"}, + {file = "pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523"}, + {file = "pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d"}, + {file = "pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c"}, + {file = "pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18"}, + {file = "pydantic_core-2.33.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5ab77f45d33d264de66e1884fca158bc920cb5e27fd0764a72f72f5756ae8bdb"}, + {file = "pydantic_core-2.33.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7aaba1b4b03aaea7bb59e1b5856d734be011d3e6d98f5bcaa98cb30f375f2ad"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fb66263e9ba8fea2aa85e1e5578980d127fb37d7f2e292773e7bc3a38fb0c7b"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f2648b9262607a7fb41d782cc263b48032ff7a03a835581abbf7a3bec62bcf5"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:723c5630c4259400818b4ad096735a829074601805d07f8cafc366d95786d331"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d100e3ae783d2167782391e0c1c7a20a31f55f8015f3293647544df3f9c67824"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177d50460bc976a0369920b6c744d927b0ecb8606fb56858ff542560251b19e5"}, + {file = "pydantic_core-2.33.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3edde68d1a1f9af1273b2fe798997b33f90308fb6d44d8550c89fc6a3647cf6"}, + {file = "pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a62c3c3ef6a7e2c45f7853b10b5bc4ddefd6ee3cd31024754a1a5842da7d598d"}, + {file = "pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:c91dbb0ab683fa0cd64a6e81907c8ff41d6497c346890e26b23de7ee55353f96"}, + {file = "pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f466e8bf0a62dc43e068c12166281c2eca72121dd2adc1040f3aa1e21ef8599"}, + {file = "pydantic_core-2.33.1-cp39-cp39-win32.whl", hash = "sha256:ab0277cedb698749caada82e5d099dc9fed3f906a30d4c382d1a21725777a1e5"}, + {file = "pydantic_core-2.33.1-cp39-cp39-win_amd64.whl", hash = "sha256:5773da0ee2d17136b1f1c6fbde543398d452a6ad2a7b54ea1033e2daa739b8d2"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add"}, + {file = "pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544"}, + {file = "pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7edbc454a29fc6aeae1e1eecba4f07b63b8d76e76a748532233c4c167b4cb9ea"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad05b683963f69a1d5d2c2bdab1274a31221ca737dbbceaa32bcb67359453cdd"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df6a94bf9452c6da9b5d76ed229a5683d0306ccb91cca8e1eea883189780d568"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7965c13b3967909a09ecc91f21d09cfc4576bf78140b988904e94f130f188396"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3f1fdb790440a34f6ecf7679e1863b825cb5ffde858a9197f851168ed08371e5"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:5277aec8d879f8d05168fdd17ae811dd313b8ff894aeeaf7cd34ad28b4d77e33"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8ab581d3530611897d863d1a649fb0644b860286b4718db919bfd51ece41f10b"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0483847fa9ad5e3412265c1bd72aad35235512d9ce9d27d81a56d935ef489672"}, + {file = "pydantic_core-2.33.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:de9e06abe3cc5ec6a2d5f75bc99b0bdca4f5c719a5b34026f8c57efbdecd2ee3"}, + {file = "pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pydantic-settings" +version = "2.9.0" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.9.0-py3-none-any.whl", hash = "sha256:4937fe39355b1f1f1e429e6695d68ce42501340d642c000447e48b1d9d418dfd"}, + {file = "pydantic_settings-2.9.0.tar.gz", hash = "sha256:24fd3fe19bc2c0eddeff91860dc5e303572b39fb70b1391c64955dd4d1875098"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + +[[package]] +name = "pygments" +version = "2.19.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, + {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d"}, + {file = "python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "rich" +version = "14.0.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, + {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" +typing-extensions = {version = ">=4.0.0,<5.0", markers = "python_version < \"3.11\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "rsa" +version = "4.2" +description = "Pure-Python RSA implementation" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "rsa-4.2.tar.gz", hash = "sha256:aaefa4b84752e3e99bd8333a2e1e3e7a7da64614042bd66f775573424370108a"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "sse-starlette" +version = "2.2.1" +description = "SSE plugin for Starlette" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "sse_starlette-2.2.1-py3-none-any.whl", hash = "sha256:6410a3d3ba0c89e7675d4c273a301d64649c03a5ef1ca101f10b47f895fd0e99"}, + {file = "sse_starlette-2.2.1.tar.gz", hash = "sha256:54470d5f19274aeed6b2d473430b08b4b379ea851d953b11d7f1c4a2c118b419"}, +] + +[package.dependencies] +anyio = ">=4.7.0" +starlette = ">=0.41.3" + +[package.extras] +examples = ["fastapi"] +uvicorn = ["uvicorn (>=0.34.0)"] + +[[package]] +name = "starlette" +version = "0.46.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"}, + {file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "telethon" +version = "1.39.0" +description = "Full-featured Telegram client library for Python 3" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "Telethon-1.39.0-py3-none-any.whl", hash = "sha256:aa9f394b94be144799a6f6a93ab463867bc7c63503ede9631751940a98f6c703"}, + {file = "telethon-1.39.0.tar.gz", hash = "sha256:35d4795d8c91deac515fb0bcb3723866b924de1c724e1d5c230460e96f284a63"}, +] + +[package.dependencies] +pyaes = "*" +rsa = "*" + +[package.extras] +cryptg = ["cryptg"] + +[[package]] +name = "typer" +version = "0.15.2" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc"}, + {file = "typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5"}, +] + +[package.dependencies] +click = ">=8.0.0" +rich = ">=10.11.0" +shellingham = ">=1.3.0" +typing-extensions = ">=3.7.4.3" + +[[package]] +name = "typing-extensions" +version = "4.13.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c"}, + {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f"}, + {file = "typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "uvicorn" +version = "0.34.1" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uvicorn-0.34.1-py3-none-any.whl", hash = "sha256:984c3a8c7ca18ebaad15995ee7401179212c59521e67bfc390c07fa2b8d2e065"}, + {file = "uvicorn-0.34.1.tar.gz", hash = "sha256:af981725fc4b7ffc5cb3b0e9eda6258a90c4b52cb2a83ce567ae0a7ae1757afc"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10" +content-hash = "15ec540426b145091f7177808a5c4939e281731ae172e4dad4b448fdc8e07592" diff --git a/pyproject.toml b/pyproject.toml index ec26d7c..5e19ddc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,11 @@ [build-system] requires = ["setuptools>=42", "wheel"] build-backend = "setuptools.build_meta" +package-mode = false [project] name = "telegram-mcp" -version = "2.0.0" +version = "2.0.1" description = "Telegram integration for Claude via the Model Context Protocol" readme = "README.md" authors = [ @@ -33,4 +34,21 @@ dependencies = [ [project.urls] "Homepage" = "https://github.com/chigwell/telegram-mcp" -"Bug Tracker" = "https://github.com/chigwell/telegram-mcp/issues" \ No newline at end of file +"Bug Tracker" = "https://github.com/chigwell/telegram-mcp/issues" + +[tool.black] +line-length = 99 +target-version = ['py311'] + +[tool.flake8] +ignore = ["E203", "E501", "W503"] +max-line-length = 99 +max-complexity = 10 +exclude = [ + ".git", + "__pycache__", + ".venv", + "build", + "dist", + "docs/source/conf.py", +] \ No newline at end of file diff --git a/session_string_generator.py b/session_string_generator.py index e63958c..5647ab5 100755 --- a/session_string_generator.py +++ b/session_string_generator.py @@ -40,46 +40,52 @@ print("\n----- Telegram Session String Generator -----\n") print("This script will generate a session string for your Telegram account.") -print("You will be asked to enter your phone number and the verification code sent to your Telegram app.") +print( + "You will be asked to enter your phone number and the verification code sent to your Telegram app." +) print("The generated session string can be added to your .env file.") -print("\nYour credentials will NOT be stored on any server and are only used for local authentication.\n") +print( + "\nYour credentials will NOT be stored on any server and are only used for local authentication.\n" +) try: # Connect to Telegram and generate the session string with TelegramClient(StringSession(), API_ID, API_HASH) as client: # The client.session.save() function from StringSession returns the session string session_string = StringSession.save(client.session) - + print("\nAuthentication successful!") print("\n----- Your Session String -----") print(f"\n{session_string}\n") print("Add this to your .env file as:") print(f"TELEGRAM_SESSION_STRING={session_string}") print("\nIMPORTANT: Keep this string private and never share it with anyone!") - + # Optional: auto-update the .env file - choice = input("\nWould you like to automatically update your .env file with this session string? (y/N): ") - if choice.lower() == 'y': + choice = input( + "\nWould you like to automatically update your .env file with this session string? (y/N): " + ) + if choice.lower() == "y": try: # Read the current .env file - with open('.env', 'r') as file: + with open(".env", "r") as file: env_contents = file.readlines() - + # Update or add the SESSION_STRING line session_string_line_found = False for i, line in enumerate(env_contents): - if line.startswith('TELEGRAM_SESSION_STRING='): + if line.startswith("TELEGRAM_SESSION_STRING="): env_contents[i] = f"TELEGRAM_SESSION_STRING={session_string}\n" session_string_line_found = True break - + if not session_string_line_found: env_contents.append(f"TELEGRAM_SESSION_STRING={session_string}\n") - + # Write back to the .env file - with open('.env', 'w') as file: + with open(".env", "w") as file: file.writelines(env_contents) - + print("\n.env file updated successfully!") except Exception as e: print(f"\nError updating .env file: {e}") @@ -88,4 +94,4 @@ except Exception as e: print(f"\nError: {e}") print("Failed to generate session string. Please try again.") - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/uv.lock b/uv.lock index dcff6d5..848b7bd 100644 --- a/uv.lock +++ b/uv.lock @@ -405,7 +405,7 @@ wheels = [ [[package]] name = "telegram-mcp" -version = "2.0.0" +version = "2.0.1" source = { editable = "." } dependencies = [ { name = "dotenv" },