Skip to content

Commit 6afce52

Browse files
committed
feat: add forum topic management tools (chigwell#5)
1 parent 5309cdf commit 6afce52

2 files changed

Lines changed: 335 additions & 0 deletions

File tree

telegram_mcp/tools/chats.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Chats MCP tools."""
22

3+
import secrets
34
import struct
45

56
from telethon.tl.tlobject import TLObject, TLRequest
@@ -71,6 +72,87 @@ def from_reader(cls, reader):
7172
)
7273

7374

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+
74156
@mcp.tool(annotations=ToolAnnotations(title="Get Chats", openWorldHint=True, readOnlyHint=True))
75157
@with_account(readonly=True)
76158
async def get_chats(account: str = None, page: int = 1, page_size: int = 20) -> str:
@@ -231,6 +313,139 @@ async def list_topics(
231313
)
232314

233315

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+
234449
@mcp.tool(annotations=ToolAnnotations(title="List Chats", openWorldHint=True, readOnlyHint=True))
235450
@with_account(readonly=True)
236451
async def list_chats(
@@ -877,6 +1092,8 @@ async def get_message_link(
8771092
__all__ = [
8781093
"get_chats",
8791094
"list_topics",
1095+
"enable_forum_topics",
1096+
"create_forum_topic",
8801097
"list_chats",
8811098
"get_chat",
8821099
"subscribe_public_channel",

tests/test_forum_topics.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import json
2+
from types import SimpleNamespace
3+
4+
import pytest
5+
from telethon.tl import functions
6+
from telethon.tl.types import Channel
7+
8+
from telegram_mcp.tools import chats
9+
10+
11+
def _supergroup(*, forum=False):
12+
return Channel(
13+
id=12345,
14+
title="Hermes Topics",
15+
photo=None,
16+
date=None,
17+
creator=True,
18+
left=False,
19+
broadcast=False,
20+
verified=False,
21+
megagroup=True,
22+
restricted=False,
23+
signatures=False,
24+
min=False,
25+
scam=False,
26+
has_link=False,
27+
has_geo=False,
28+
slowmode_enabled=False,
29+
call_active=False,
30+
call_not_empty=False,
31+
fake=False,
32+
gigagroup=False,
33+
noforwards=False,
34+
join_to_send=False,
35+
join_request=False,
36+
forum=forum,
37+
stories_hidden=False,
38+
stories_hidden_min=False,
39+
stories_unavailable=False,
40+
access_hash=67890,
41+
)
42+
43+
44+
class RecordingClient:
45+
def __init__(self, result=None):
46+
self.requests = []
47+
self.result = result or SimpleNamespace(updates=[])
48+
49+
async def __call__(self, request):
50+
self.requests.append(request)
51+
return self.result
52+
53+
54+
@pytest.mark.asyncio
55+
async def test_enable_forum_topics_sends_toggle_forum_request(monkeypatch):
56+
entity = _supergroup(forum=False)
57+
client = RecordingClient()
58+
59+
async def fake_resolve(chat_id, cl):
60+
return entity
61+
62+
monkeypatch.setattr(chats, "get_client", lambda account=None: client)
63+
monkeypatch.setattr(chats, "resolve_entity", fake_resolve)
64+
65+
result = await chats.enable_forum_topics(chat_id=12345)
66+
67+
assert result == "Forum topics enabled for Hermes Topics."
68+
assert len(client.requests) == 1
69+
request = client.requests[0]
70+
assert isinstance(request, functions.channels.ToggleForumRequest)
71+
assert request.channel is entity
72+
assert request.enabled is True
73+
assert request.tabs is True
74+
assert entity.forum is True
75+
76+
77+
@pytest.mark.asyncio
78+
async def test_create_forum_topic_sends_raw_create_forum_topic_request(monkeypatch):
79+
entity = _supergroup(forum=True)
80+
client = RecordingClient(SimpleNamespace(updates=[SimpleNamespace(id=777)]))
81+
82+
async def fake_resolve(chat_id, cl):
83+
return entity
84+
85+
monkeypatch.setattr(chats, "get_client", lambda account=None: client)
86+
monkeypatch.setattr(chats, "resolve_entity", fake_resolve)
87+
88+
result = await chats.create_forum_topic(chat_id=12345, title="Dev", icon_color=0x6FB9F0)
89+
90+
payload = json.loads(result)
91+
assert payload["results"] == [{"chat_id": -1000000012345, "topic_id": 777, "title": "Dev"}]
92+
assert len(client.requests) == 1
93+
request = client.requests[0]
94+
assert isinstance(request, chats.CreateForumTopicRequest)
95+
assert request.peer is entity
96+
assert request.title == "Dev"
97+
assert request.icon_color == 0x6FB9F0
98+
assert isinstance(request.random_id, int)
99+
100+
101+
@pytest.mark.asyncio
102+
async def test_create_forum_topic_requires_forum_enabled(monkeypatch):
103+
entity = _supergroup(forum=False)
104+
client = RecordingClient()
105+
106+
async def fake_resolve(chat_id, cl):
107+
return entity
108+
109+
monkeypatch.setattr(chats, "get_client", lambda account=None: client)
110+
monkeypatch.setattr(chats, "resolve_entity", fake_resolve)
111+
112+
result = await chats.create_forum_topic(chat_id=12345, title="Dev")
113+
114+
assert (
115+
result
116+
== "The specified supergroup does not have forum topics enabled. Use enable_forum_topics first."
117+
)
118+
assert client.requests == []

0 commit comments

Comments
 (0)