From 016fd31603280ff076bfcf0be651dc97e048e625 Mon Sep 17 00:00:00 2001 From: Korzhavin Ivan Date: Thu, 28 Aug 2025 22:18:58 +0200 Subject: [PATCH 1/6] Fix run how-to to use uv for consistency with production case --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 125f847..a7a579f 100644 --- a/README.md +++ b/README.md @@ -141,18 +141,16 @@ git clone https://github.com/chigwell/telegram-mcp.git cd telegram-mcp ``` -### 2. Create a Virtual Environment +### 2. Install Dependencies with uv ```bash -python3 -m venv .venv -source .venv/bin/activate # On Windows: .venv\Scripts\activate -pip install -r requirements.txt +uv sync ``` ### 3. Generate a Session String ```bash -python3 session_string_generator.py +uv run session_string_generator.py ``` Follow the prompts to authenticate and update your `.env` file. From 59fb2dcd10de14b8711abe9d1a48a9b463c97e00 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 29 Aug 2025 21:07:46 +0000 Subject: [PATCH 2/6] refactor: Replace hardcoded error prefixes with an Enum --- main.py | 56 ++++++++++++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/main.py b/main.py index 6fb590e..87cebe9 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ import sqlite3 import logging import mimetypes from datetime import datetime, timedelta +from enum import Enum from typing import List, Dict, Optional, Union, Any # Third-party libraries @@ -96,44 +97,51 @@ except Exception as log_error: logger.error(f"Failed to set up log file handler: {log_error}") # Error code prefix mapping for better error tracing -ERROR_PREFIXES = { - "chat": "CHAT", - "msg": "MSG", - "contact": "CONTACT", - "group": "GROUP", - "media": "MEDIA", - "profile": "PROFILE", - "auth": "AUTH", - "admin": "ADMIN", -} + + +class ErrorCategory(str, Enum): + CHAT = "CHAT" + MSG = "MSG" + CONTACT = "CONTACT" + GROUP = "GROUP" + MEDIA = "MEDIA" + PROFILE = "PROFILE" + AUTH = "AUTH" + ADMIN = "ADMIN" def log_and_format_error( - function_name: str, error: Exception, prefix: str = None, **kwargs + function_name: str, + error: Exception, + prefix: Optional[ErrorCategory] = None, + **kwargs, ) -> str: """ - Centralized error handling function that logs the error and returns a formatted user-friendly message. + Centralized error handling function. + + Logs an 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 + function_name: Name of the function where the error occurred. + error: The exception that was raised. + prefix: Error code prefix (e.g., "CHAT", "MSG"). + If None, it will be derived from the function_name. + **kwargs: Additional context parameters to include in the log. Returns: - A user-friendly error message with error code + A user-friendly error message with an error code. """ # Generate a consistent error code if prefix is None: # Try to derive prefix from function name - for key, value in ERROR_PREFIXES.items(): - if key in function_name.lower(): - prefix = value + for category in ErrorCategory: + if category.name.lower() in function_name.lower(): + prefix = category break - if prefix is None: - prefix = "GEN" # Generic prefix if none matches - error_code = f"{prefix}-ERR-{abs(hash(function_name)) % 1000:03d}" + prefix_str = prefix.value if prefix else "GEN" + + error_code = f"{prefix_str}-ERR-{abs(hash(function_name)) % 1000:03d}" # Format the additional context parameters context = ", ".join(f"{k}={v}" for k, v in kwargs.items()) @@ -142,7 +150,7 @@ def log_and_format_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." + return f"An error occurred (code: {error_code}). " f"Check mcp_errors.log for details." def format_entity(entity) -> Dict[str, Any]: From 553125cd76df8c14b12a7c6e90ab52cd64f8f34e Mon Sep 17 00:00:00 2001 From: Valeriy Pavlovich Date: Sun, 12 Oct 2025 07:14:25 +0300 Subject: [PATCH 3/6] Make `list_messages` predictable and efficient for date ranges and search: avoid over-fetch, apply `limit` after filtering, and separate search from date offsets #27 - stream search results via `client.iter_messages()` and apply date bounds in-code so we collect up to `limit` matches - use server-side iteration for date-only queries with inclusive upper bounds to avoid over-fetching - keep the existing `get_messages()` fast path when no filters are provided --- main.py | 49 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/main.py b/main.py index 87cebe9..4a58dc2 100644 --- a/main.py +++ b/main.py @@ -412,20 +412,47 @@ async def list_messages( # Prepare filter parameters params = {} if search_query: + # IMPORTANT: Do not combine offset_date with search. + # Use server-side search alone, then enforce date bounds client-side. params["search"] = search_query - - messages = 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 = [] - for msg in messages: - if from_date_obj and msg.date < from_date_obj: - continue + messages = [] + async for msg in client.iter_messages(entity, **params): # newest -> oldest if to_date_obj and msg.date > to_date_obj: continue - filtered_messages.append(msg) - messages = filtered_messages + if from_date_obj and msg.date < from_date_obj: + break + messages.append(msg) + if len(messages) >= limit: + break + + else: + # Use server-side iteration when only date bounds are present + # (no search) to avoid over-fetching. + if from_date_obj or to_date_obj: + messages = [] + if from_date_obj: + # Walk forward from start date (oldest -> newest) + async for msg in client.iter_messages( + entity, offset_date=from_date_obj, reverse=True + ): + if to_date_obj and msg.date > to_date_obj: + break + if msg.date < from_date_obj: + continue + messages.append(msg) + if len(messages) >= limit: + break + else: + # Only upper bound: walk backward from end bound + async for msg in client.iter_messages( + # offset_date is exclusive; +1µs makes to_date inclusive + entity, offset_date=to_date_obj + timedelta(microseconds=1) + ): + messages.append(msg) + if len(messages) >= limit: + break + else: + messages = await client.get_messages(entity, limit=limit, **params) if not messages: return "No messages found matching the criteria." From f08667c47a44f3440429ae548e3825e32262fb95 Mon Sep 17 00:00:00 2001 From: Valeriy Pavlovich Date: Mon, 13 Oct 2025 21:20:24 +0300 Subject: [PATCH 4/6] Format main.py with Black --- main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 4a58dc2..7d18494 100644 --- a/main.py +++ b/main.py @@ -446,7 +446,8 @@ async def list_messages( # Only upper bound: walk backward from end bound async for msg in client.iter_messages( # offset_date is exclusive; +1µs makes to_date inclusive - entity, offset_date=to_date_obj + timedelta(microseconds=1) + entity, + offset_date=to_date_obj + timedelta(microseconds=1), ): messages.append(msg) if len(messages) >= limit: From aa6d2761ceab4ca35b16c0d41e3ff616cc4de06a Mon Sep 17 00:00:00 2001 From: Valeriy Pavlovich Date: Tue, 14 Oct 2025 15:39:00 +0300 Subject: [PATCH 5/6] feat: Add forum topics support with list_topics tool Add new list_topics() MCP tool to retrieve forum topics from Telegram supergroups with forum feature enabled. Features: - Validates chat is a supergroup with forum enabled - Supports pagination via offset_topic parameter - Supports search filtering via search_query parameter - Returns comprehensive topic metadata: * Topic ID and title * Total messages and unread count * Status flags (closed, hidden) * Last activity timestamp - Proper error handling and logging Documentation: - Added list_topics to README Messaging tools list - Added complete code example with implementation - Added example output showing typical usage Uses Telethon's GetForumTopicsRequest API for reliable forum topic retrieval from supergroups. --- README.md | 1 + main.py | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/README.md b/README.md index a7a579f..f31982a 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ This MCP server exposes a huge suite of Telegram tools. **Every major Telegram/T ### Messaging - **get_messages(chat_id, page, page_size)**: Paginated messages - **list_messages(chat_id, limit, search_query, from_date, to_date)**: Filtered messages +- **list_topics(chat_id, limit, offset_topic, search_query)**: List forum topics in supergroups - **send_message(chat_id, message)**: Send a message - **reply_to_message(chat_id, message_id, text)**: Reply to a message - **edit_message(chat_id, message_id, new_text)**: Edit your message diff --git a/main.py b/main.py index 7d18494..c184821 100644 --- a/main.py +++ b/main.py @@ -475,6 +475,93 @@ async def list_messages( return log_and_format_error("list_messages", e, chat_id=chat_id) +@mcp.tool() +async def list_topics( + chat_id: int, + limit: int = 200, + offset_topic: int = 0, + search_query: str = None, +) -> str: + """ + Retrieve forum topics from a supergroup with the forum feature enabled. + + Note for LLM: You can send a message to a selected topic via reply_to_message tool + by using Topic ID as the message_id parameter. + + Args: + chat_id: The ID of the forum-enabled chat (supergroup). + limit: Maximum number of topics to retrieve. + offset_topic: Topic ID offset for pagination. + search_query: Optional query to filter topics by title. + """ + try: + entity = await client.get_entity(chat_id) + + if not isinstance(entity, Channel) or not getattr(entity, "megagroup", False): + return "The specified chat is not a supergroup." + + if not getattr(entity, "forum", False): + return "The specified supergroup does not have forum topics enabled." + + result = await client( + functions.channels.GetForumTopicsRequest( + channel=entity, + offset_date=0, + offset_id=0, + offset_topic=offset_topic, + limit=limit, + q=search_query or None, + ) + ) + + topics = getattr(result, "topics", None) or [] + if not topics: + return "No topics found for this chat." + + messages_map = {} + if getattr(result, "messages", None): + messages_map = {message.id: message for message in result.messages} + + lines = [] + for topic in topics: + line_parts = [f"Topic ID: {topic.id}"] + + title = getattr(topic, "title", None) or "(no title)" + line_parts.append(f"Title: {title}") + + total_messages = getattr(topic, "total_messages", None) + if total_messages is not None: + line_parts.append(f"Messages: {total_messages}") + + unread_count = getattr(topic, "unread_count", None) + if unread_count: + line_parts.append(f"Unread: {unread_count}") + + if getattr(topic, "closed", False): + line_parts.append("Closed: Yes") + + if getattr(topic, "hidden", False): + line_parts.append("Hidden: Yes") + + top_message_id = getattr(topic, "top_message", None) + top_message = messages_map.get(top_message_id) + if top_message and getattr(top_message, "date", None): + line_parts.append(f"Last Activity: {top_message.date.isoformat()}") + + lines.append(" | ".join(line_parts)) + + return "\n".join(lines) + except Exception as e: + return log_and_format_error( + "list_topics", + e, + chat_id=chat_id, + limit=limit, + offset_topic=offset_topic, + search_query=search_query, + ) + + @mcp.tool() async def list_chats(chat_type: str = None, limit: int = 20) -> str: """ From 7f078dd1e79226b5e36ce1492b1bec7e1a5f3b19 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Tue, 14 Oct 2025 12:08:30 +0200 Subject: [PATCH 6/6] feat: add poll creation functionality - Implement create_poll tool using Telethon's native poll API - Support for multiple choice, quiz mode, public votes, and close dates --- README.md | 1 + main.py | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/README.md b/README.md index f31982a..94e4df4 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ This MCP server exposes a huge suite of Telegram tools. **Every major Telegram/T - **get_history(chat_id, limit)**: Full chat history - **get_pinned_messages(chat_id)**: List pinned messages - **get_last_interaction(contact_id)**: Most recent message with a contact +- **create_poll(chat_id, question, options, multiple_choice, quiz_mode, public_votes, close_date)**: Create a poll ### Contact Management - **list_contacts()**: List all contacts diff --git a/main.py b/main.py index c184821..a484f4b 100644 --- a/main.py +++ b/main.py @@ -2623,6 +2623,79 @@ async def get_pinned_messages(chat_id: int) -> str: return log_and_format_error("get_pinned_messages", e, chat_id=chat_id) +@mcp.tool() +async def create_poll( + chat_id: int, + question: str, + options: list, + multiple_choice: bool = False, + quiz_mode: bool = False, + public_votes: bool = True, + close_date: str = None, +) -> str: + """ + Create a poll in a chat using Telegram's native poll feature. + + Args: + chat_id: The ID of the chat to send the poll to + question: The poll question + options: List of answer options (2-10 options) + multiple_choice: Whether users can select multiple answers + quiz_mode: Whether this is a quiz (has correct answer) + public_votes: Whether votes are public + close_date: Optional close date in ISO format (YYYY-MM-DD HH:MM:SS) + """ + try: + entity = await client.get_entity(chat_id) + + # Validate options + if len(options) < 2: + return "Error: Poll must have at least 2 options." + if len(options) > 10: + return "Error: Poll can have at most 10 options." + + # Parse close date if provided + close_date_obj = None + if close_date: + try: + close_date_obj = datetime.fromisoformat(close_date.replace("Z", "+00:00")) + except ValueError: + return f"Invalid close_date format. Use YYYY-MM-DD HH:MM:SS format." + + # Create the poll using InputMediaPoll with SendMediaRequest + from telethon.tl.types import InputMediaPoll, Poll, PollAnswer, TextWithEntities + import random + + poll = Poll( + id=random.randint(0, 2**63 - 1), + question=TextWithEntities(text=question, entities=[]), + answers=[ + PollAnswer(text=TextWithEntities(text=option, entities=[]), option=bytes([i])) + for i, option in enumerate(options) + ], + multiple_choice=multiple_choice, + quiz=quiz_mode, + public_voters=public_votes, + close_date=close_date_obj, + ) + + result = await client( + functions.messages.SendMediaRequest( + peer=entity, + media=InputMediaPoll(poll=poll), + message="", + random_id=random.randint(0, 2**63 - 1), + ) + ) + + return f"Poll created successfully in chat {chat_id}." + except Exception as e: + logger.exception(f"create_poll failed (chat_id={chat_id}, question='{question}')") + return log_and_format_error( + "create_poll", e, chat_id=chat_id, question=question, options=options + ) + + if __name__ == "__main__": nest_asyncio.apply()