|
1 | 1 | """Chats MCP tools.""" |
2 | 2 |
|
| 3 | +import secrets |
3 | 4 | import struct |
4 | 5 |
|
5 | 6 | from telethon.tl.tlobject import TLObject, TLRequest |
@@ -71,6 +72,87 @@ def from_reader(cls, reader): |
71 | 72 | ) |
72 | 73 |
|
73 | 74 |
|
| 75 | +class CreateForumTopicRequest(TLRequest): |
| 76 | + """Raw request for messages.createForumTopic missing in Telethon 1.42.""" |
| 77 | + |
| 78 | + CONSTRUCTOR_ID = 0x2F98C3D5 |
| 79 | + SUBCLASS_OF_ID = 0x0 |
| 80 | + |
| 81 | + def __init__( |
| 82 | + self, |
| 83 | + peer, |
| 84 | + title, |
| 85 | + random_id, |
| 86 | + icon_color=None, |
| 87 | + icon_emoji_id=None, |
| 88 | + send_as=None, |
| 89 | + ): |
| 90 | + self.peer = peer |
| 91 | + self.title = title |
| 92 | + self.icon_color = icon_color |
| 93 | + self.icon_emoji_id = icon_emoji_id |
| 94 | + self.random_id = random_id |
| 95 | + self.send_as = send_as |
| 96 | + |
| 97 | + async def resolve(self, client, utils): |
| 98 | + self.peer = utils.get_input_peer(await client.get_input_entity(self.peer)) |
| 99 | + if self.send_as is not None: |
| 100 | + self.send_as = utils.get_input_peer(await client.get_input_entity(self.send_as)) |
| 101 | + |
| 102 | + def to_dict(self): |
| 103 | + return { |
| 104 | + "_": "CreateForumTopicRequest", |
| 105 | + "peer": self.peer.to_dict() if isinstance(self.peer, TLObject) else self.peer, |
| 106 | + "title": self.title, |
| 107 | + "icon_color": self.icon_color, |
| 108 | + "icon_emoji_id": self.icon_emoji_id, |
| 109 | + "random_id": self.random_id, |
| 110 | + "send_as": ( |
| 111 | + self.send_as.to_dict() if isinstance(self.send_as, TLObject) else self.send_as |
| 112 | + ), |
| 113 | + } |
| 114 | + |
| 115 | + def _bytes(self): |
| 116 | + flags = 0 |
| 117 | + if self.icon_color is not None: |
| 118 | + flags |= 1 << 0 |
| 119 | + if self.send_as is not None: |
| 120 | + flags |= 1 << 2 |
| 121 | + if self.icon_emoji_id is not None: |
| 122 | + flags |= 1 << 3 |
| 123 | + |
| 124 | + return b"".join( |
| 125 | + ( |
| 126 | + struct.pack("<I", self.CONSTRUCTOR_ID), |
| 127 | + struct.pack("<I", flags), |
| 128 | + self.peer._bytes(), |
| 129 | + self.serialize_bytes(self.title), |
| 130 | + b"" if self.icon_color is None else struct.pack("<i", self.icon_color), |
| 131 | + b"" if self.icon_emoji_id is None else struct.pack("<q", self.icon_emoji_id), |
| 132 | + struct.pack("<q", self.random_id), |
| 133 | + b"" if self.send_as is None else self.send_as._bytes(), |
| 134 | + ) |
| 135 | + ) |
| 136 | + |
| 137 | + @classmethod |
| 138 | + def from_reader(cls, reader): |
| 139 | + flags = reader.read_int() |
| 140 | + peer = reader.tgread_object() |
| 141 | + title = reader.tgread_string() |
| 142 | + icon_color = reader.read_int() if flags & (1 << 0) else None |
| 143 | + icon_emoji_id = reader.read_long() if flags & (1 << 3) else None |
| 144 | + random_id = reader.read_long() |
| 145 | + send_as = reader.tgread_object() if flags & (1 << 2) else None |
| 146 | + return cls( |
| 147 | + peer=peer, |
| 148 | + title=title, |
| 149 | + random_id=random_id, |
| 150 | + icon_color=icon_color, |
| 151 | + icon_emoji_id=icon_emoji_id, |
| 152 | + send_as=send_as, |
| 153 | + ) |
| 154 | + |
| 155 | + |
74 | 156 | @mcp.tool(annotations=ToolAnnotations(title="Get Chats", openWorldHint=True, readOnlyHint=True)) |
75 | 157 | @with_account(readonly=True) |
76 | 158 | async def get_chats(account: str = None, page: int = 1, page_size: int = 20) -> str: |
@@ -231,6 +313,139 @@ async def list_topics( |
231 | 313 | ) |
232 | 314 |
|
233 | 315 |
|
| 316 | +@mcp.tool( |
| 317 | + annotations=ToolAnnotations( |
| 318 | + title="Enable Forum Topics", openWorldHint=True, destructiveHint=True, idempotentHint=True |
| 319 | + ) |
| 320 | +) |
| 321 | +@with_account(readonly=False) |
| 322 | +@validate_id("chat_id") |
| 323 | +async def enable_forum_topics( |
| 324 | + chat_id: Union[int, str], tabs: bool = True, account: str = None |
| 325 | +) -> str: |
| 326 | + """ |
| 327 | + Enable Telegram forum topics for a supergroup. |
| 328 | +
|
| 329 | + Args: |
| 330 | + chat_id: The supergroup ID or username. |
| 331 | + tabs: Whether Telegram should display topics as tabs (default True). |
| 332 | +
|
| 333 | + The caller must be an admin with permission to change chat info. |
| 334 | + """ |
| 335 | + try: |
| 336 | + cl = get_client(account) |
| 337 | + entity = await resolve_entity(chat_id, cl) |
| 338 | + |
| 339 | + if not isinstance(entity, Channel) or not getattr(entity, "megagroup", False): |
| 340 | + return "The specified chat is not a supergroup." |
| 341 | + |
| 342 | + if getattr(entity, "forum", False): |
| 343 | + title = sanitize_name(getattr(entity, "title", str(chat_id))) |
| 344 | + return f"Forum topics already enabled for {title}." |
| 345 | + |
| 346 | + await cl(functions.channels.ToggleForumRequest(channel=entity, enabled=True, tabs=tabs)) |
| 347 | + # Keep the resolved entity in sync for callers/tests that reuse it. |
| 348 | + try: |
| 349 | + entity.forum = True |
| 350 | + except Exception: |
| 351 | + pass |
| 352 | + |
| 353 | + title = sanitize_name(getattr(entity, "title", str(chat_id))) |
| 354 | + return f"Forum topics enabled for {title}." |
| 355 | + except Exception as e: |
| 356 | + return log_and_format_error("enable_forum_topics", e, chat_id=chat_id, tabs=tabs) |
| 357 | + |
| 358 | + |
| 359 | +@mcp.tool( |
| 360 | + annotations=ToolAnnotations( |
| 361 | + title="Create Forum Topic", openWorldHint=True, destructiveHint=True |
| 362 | + ) |
| 363 | +) |
| 364 | +@with_account(readonly=False) |
| 365 | +@validate_id("chat_id") |
| 366 | +async def create_forum_topic( |
| 367 | + chat_id: Union[int, str], |
| 368 | + title: str, |
| 369 | + icon_color: int = None, |
| 370 | + icon_emoji_id: int = None, |
| 371 | + account: str = None, |
| 372 | +) -> str: |
| 373 | + """ |
| 374 | + Create a Telegram forum topic in a forum-enabled supergroup. |
| 375 | +
|
| 376 | + Args: |
| 377 | + chat_id: The forum-enabled supergroup ID or username. |
| 378 | + title: Topic title. |
| 379 | + icon_color: Optional Telegram topic icon color integer. |
| 380 | + icon_emoji_id: Optional custom emoji document ID for the topic icon. |
| 381 | +
|
| 382 | + Returns a JSON result with chat_id, topic_id (when Telegram returns it), and title. |
| 383 | + """ |
| 384 | + try: |
| 385 | + cl = get_client(account) |
| 386 | + entity = await resolve_entity(chat_id, cl) |
| 387 | + |
| 388 | + if not isinstance(entity, Channel) or not getattr(entity, "megagroup", False): |
| 389 | + return "The specified chat is not a supergroup." |
| 390 | + |
| 391 | + if not getattr(entity, "forum", False): |
| 392 | + return ( |
| 393 | + "The specified supergroup does not have forum topics enabled. " |
| 394 | + "Use enable_forum_topics first." |
| 395 | + ) |
| 396 | + |
| 397 | + clean_title = sanitize_user_content(title, max_length=128) |
| 398 | + result = await cl( |
| 399 | + CreateForumTopicRequest( |
| 400 | + peer=entity, |
| 401 | + title=clean_title, |
| 402 | + random_id=secrets.randbits(63), |
| 403 | + icon_color=icon_color, |
| 404 | + icon_emoji_id=icon_emoji_id, |
| 405 | + ) |
| 406 | + ) |
| 407 | + |
| 408 | + topic_id = _extract_created_topic_id(result) |
| 409 | + record = { |
| 410 | + "chat_id": get_marked_id(entity), |
| 411 | + "title": clean_title, |
| 412 | + } |
| 413 | + if topic_id is not None: |
| 414 | + record["topic_id"] = topic_id |
| 415 | + |
| 416 | + return format_tool_result([record]) |
| 417 | + except Exception as e: |
| 418 | + return log_and_format_error( |
| 419 | + "create_forum_topic", |
| 420 | + e, |
| 421 | + chat_id=chat_id, |
| 422 | + title=title, |
| 423 | + icon_color=icon_color, |
| 424 | + icon_emoji_id=icon_emoji_id, |
| 425 | + ) |
| 426 | + |
| 427 | + |
| 428 | +def _extract_created_topic_id(result) -> Optional[int]: |
| 429 | + """Best-effort extraction of the top message/topic ID from Updates.""" |
| 430 | + updates = getattr(result, "updates", None) or [] |
| 431 | + for update in updates: |
| 432 | + message = getattr(update, "message", None) |
| 433 | + message_id = getattr(message, "id", None) |
| 434 | + if isinstance(message_id, int): |
| 435 | + return message_id |
| 436 | + |
| 437 | + update_id = getattr(update, "id", None) |
| 438 | + if isinstance(update_id, int): |
| 439 | + return update_id |
| 440 | + |
| 441 | + message = getattr(result, "message", None) |
| 442 | + message_id = getattr(message, "id", None) |
| 443 | + if isinstance(message_id, int): |
| 444 | + return message_id |
| 445 | + |
| 446 | + return None |
| 447 | + |
| 448 | + |
234 | 449 | @mcp.tool(annotations=ToolAnnotations(title="List Chats", openWorldHint=True, readOnlyHint=True)) |
235 | 450 | @with_account(readonly=True) |
236 | 451 | async def list_chats( |
@@ -877,6 +1092,8 @@ async def get_message_link( |
877 | 1092 | __all__ = [ |
878 | 1093 | "get_chats", |
879 | 1094 | "list_topics", |
| 1095 | + "enable_forum_topics", |
| 1096 | + "create_forum_topic", |
880 | 1097 | "list_chats", |
881 | 1098 | "get_chat", |
882 | 1099 | "subscribe_public_channel", |
|
0 commit comments