Skip to content

Commit cc61ccd

Browse files
authored
Merge pull request #182 from aazimin/feat/forward-attribution
Keep channel attribution on forwarded messages
2 parents 70690bd + 2643b41 commit cc61ccd

2 files changed

Lines changed: 180 additions & 0 deletions

File tree

telegram_mcp/tools/messages.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
from telegram_mcp.runtime import *
44

5+
# Domain used to build message permalinks. Overridable because the default is a
6+
# single point of failure: on 2026-07-13 the .me registry put t.me on serverHold
7+
# over an OFAC listing and every t.me link on earth broke for about a day, while
8+
# telegram.me kept resolving. The domain has been ACTIVE again since 2026-07-14.
9+
LINK_DOMAIN = os.getenv("TELEGRAM_LINK_DOMAIN", "t.me")
10+
511

612
def get_media_label(msg) -> str:
713
"""Short label of attached media for a message, or "" if none.
@@ -159,6 +165,60 @@ def message_to_dict(msg) -> dict:
159165
fname = getattr(fwd, "from_name", None)
160166
if fname:
161167
finfo["from_name"] = sanitize_name(fname)
168+
169+
# from_name is set only when the original author hides their profile.
170+
# For an ordinary channel forward the origin sits in fwd.from_id, and
171+
# reading just from_name loses the attribution the Telegram UI shows as
172+
# "Forwarded from …". Telethon's msg.forward wrapper resolves that peer
173+
# from entities already present in the response — no extra API call.
174+
fo = getattr(msg, "forward", None)
175+
if fo is not None:
176+
chat = getattr(fo, "chat", None)
177+
if chat is not None:
178+
title = getattr(chat, "title", None) or " ".join(
179+
x
180+
for x in (getattr(chat, "first_name", None), getattr(chat, "last_name", None))
181+
if x
182+
)
183+
if title:
184+
finfo["from_chat"] = sanitize_name(title)
185+
uname = getattr(chat, "username", None)
186+
if uname:
187+
finfo["from_username"] = uname
188+
chat_id = getattr(fo, "chat_id", None)
189+
if chat_id is not None:
190+
finfo["from_chat_id"] = chat_id
191+
sender = getattr(fo, "sender", None)
192+
if sender is not None:
193+
sname = " ".join(
194+
x
195+
for x in (
196+
getattr(sender, "first_name", None),
197+
getattr(sender, "last_name", None),
198+
)
199+
if x
200+
)
201+
if sname:
202+
finfo["from_user"] = sanitize_name(sname)
203+
204+
post_id = getattr(fwd, "channel_post", None)
205+
if post_id is not None:
206+
finfo["channel_post"] = post_id
207+
author = getattr(fwd, "post_author", None)
208+
if author:
209+
finfo["post_author"] = sanitize_name(author)
210+
211+
# Canonical permalink, when the pieces are there: a public channel gives
212+
# <domain>/<username>/<post>, a private one the <domain>/c/<id>/<post>
213+
# form that only resolves for members.
214+
if post_id is not None:
215+
if finfo.get("from_username"):
216+
finfo["post_link"] = f"https://{LINK_DOMAIN}/{finfo['from_username']}/{post_id}"
217+
elif finfo.get("from_chat_id") is not None:
218+
finfo["post_link"] = (
219+
f"https://{LINK_DOMAIN}/c/{abs(finfo['from_chat_id']) % 10**10}/{post_id}"
220+
)
221+
162222
d["forwarded"] = finfo or True
163223

164224
via_bot_id = getattr(msg, "via_bot_id", None)

tests/test_forward_attribution.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import datetime
2+
3+
from telegram_mcp.tools import messages
4+
from telegram_mcp.tools.messages import message_to_dict
5+
6+
7+
class _FwdHeader:
8+
"""telethon.tl.types.MessageFwdHeader, trimmed to what the code reads."""
9+
10+
def __init__(self, date=None, from_name=None, channel_post=None, post_author=None):
11+
self.date = date
12+
self.from_name = from_name
13+
self.channel_post = channel_post
14+
self.post_author = post_author
15+
self.from_id = object() # a Peer; the code never inspects it directly
16+
17+
18+
class _Chat:
19+
def __init__(self, title=None, username=None, first_name=None, last_name=None):
20+
self.title = title
21+
self.username = username
22+
self.first_name = first_name
23+
self.last_name = last_name
24+
25+
26+
class _Forward:
27+
"""telethon.tl.custom.Forward — resolves the peer from response entities."""
28+
29+
def __init__(self, chat=None, chat_id=None, sender=None):
30+
self.chat = chat
31+
self.chat_id = chat_id
32+
self.sender = sender
33+
34+
35+
class _Msg:
36+
def __init__(self, fwd_from=None, forward=None):
37+
self.id = 184
38+
self.date = datetime.datetime(2026, 8, 5, 16, 38, 3, tzinfo=datetime.timezone.utc)
39+
self.message = "post body"
40+
self.sender = None
41+
self.fwd_from = fwd_from
42+
self.forward = forward
43+
44+
45+
FWD_DATE = datetime.datetime(2026, 7, 15, 12, 1, 2, tzinfo=datetime.timezone.utc)
46+
47+
48+
def test_public_channel_forward_keeps_attribution_and_builds_permalink():
49+
"""from_name is empty on an ordinary channel forward — the origin is in from_id.
50+
51+
Reading only from_name loses everything the Telegram UI shows as
52+
"Forwarded from ...", which is the common case rather than an edge one.
53+
"""
54+
msg = _Msg(
55+
fwd_from=_FwdHeader(date=FWD_DATE, channel_post=6279),
56+
forward=_Forward(
57+
chat=_Chat(title="Полезный Парфун", username="ParfunA"), chat_id=-1001626974925
58+
),
59+
)
60+
61+
fwd = message_to_dict(msg)["forwarded"]
62+
63+
assert fwd["date"] == FWD_DATE
64+
assert fwd["from_chat"] == "Полезный Парфун"
65+
assert fwd["from_username"] == "ParfunA"
66+
assert fwd["from_chat_id"] == -1001626974925
67+
assert fwd["channel_post"] == 6279
68+
assert fwd["post_link"] == "https://t.me/ParfunA/6279"
69+
70+
71+
def test_private_channel_forward_uses_the_members_only_link_form():
72+
msg = _Msg(
73+
fwd_from=_FwdHeader(date=FWD_DATE, channel_post=42),
74+
forward=_Forward(chat=_Chat(title="Private notes"), chat_id=-1001626974925),
75+
)
76+
77+
fwd = message_to_dict(msg)["forwarded"]
78+
79+
assert "from_username" not in fwd
80+
assert fwd["post_link"] == "https://t.me/c/1626974925/42"
81+
82+
83+
def test_user_forward_reports_the_sender_name():
84+
msg = _Msg(
85+
fwd_from=_FwdHeader(date=FWD_DATE),
86+
forward=_Forward(sender=_Chat(first_name="Ada", last_name="Lovelace"), chat_id=None),
87+
)
88+
89+
fwd = message_to_dict(msg)["forwarded"]
90+
91+
assert fwd["from_user"] == "Ada Lovelace"
92+
assert "post_link" not in fwd
93+
94+
95+
def test_hidden_profile_forward_still_falls_back_to_from_name():
96+
"""Telegram sets from_name only when the original author hides their profile."""
97+
msg = _Msg(
98+
fwd_from=_FwdHeader(date=FWD_DATE, from_name="Someone"),
99+
forward=_Forward(),
100+
)
101+
102+
fwd = message_to_dict(msg)["forwarded"]
103+
104+
assert fwd["from_name"] == "Someone"
105+
assert "from_chat" not in fwd
106+
107+
108+
def test_message_without_forward_header_is_unaffected():
109+
assert "forwarded" not in message_to_dict(_Msg())
110+
111+
112+
def test_link_domain_is_overridable(monkeypatch):
113+
"""t.me was unreachable for a day in July 2026; the domain must not be hardcoded."""
114+
monkeypatch.setattr(messages, "LINK_DOMAIN", "telegram.me")
115+
msg = _Msg(
116+
fwd_from=_FwdHeader(date=FWD_DATE, channel_post=6279),
117+
forward=_Forward(chat=_Chat(title="Полезный Парфун", username="ParfunA")),
118+
)
119+
120+
assert message_to_dict(msg)["forwarded"]["post_link"] == "https://telegram.me/ParfunA/6279"

0 commit comments

Comments
 (0)