fix(telegram): expose hidden text-link URLs

This commit is contained in:
WS
2026-07-30 13:35:33 +03:00
committed by Teknium
parent 44ddd117d2
commit f6cfbd2b16
2 changed files with 194 additions and 3 deletions

View File

@@ -5574,6 +5574,64 @@ class TelegramAdapter(BasePlatformAdapter):
"""Guest-mode bypass: explicit bot mention (caller already verified group chat)."""
return self._telegram_guest_mode() and self._message_mentions_bot(message)
def _expand_link_entities(self, message: Message) -> str:
"""Inline Telegram ``text_link`` URLs into visible message text.
Telegram stores hidden-link entity offsets as UTF-16 code units, while
Python string indexes are Unicode code points. Convert offsets before
inserting so links still expand correctly when text before the anchor
contains emoji or other non-BMP characters.
"""
text = getattr(message, "text", None)
if text:
entities = getattr(message, "entities", None) or []
else:
text = getattr(message, "caption", None) or ""
entities = getattr(message, "caption_entities", None) or []
if not text or not entities:
return text
def utf16_index(offset: int) -> Optional[int]:
units = 0
for index, char in enumerate(text):
if units == offset:
return index
units += 2 if ord(char) > 0xFFFF else 1
if units > offset:
return None
return len(text) if units == offset else None
utf16_length = sum(2 if ord(char) > 0xFFFF else 1 for char in text)
links: list[tuple[int, int, str]] = []
for entity in entities:
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
raw_url = getattr(entity, "url", None)
url = raw_url.strip() if isinstance(raw_url, str) else ""
if entity_type != "text_link" or not url:
continue
try:
offset = int(getattr(entity, "offset", -1))
length = int(getattr(entity, "length", 0))
except (TypeError, ValueError):
continue
if offset < 0 or length <= 0 or offset + length > utf16_length:
continue
start, end = utf16_index(offset), utf16_index(offset + length)
if start is None or end is None or end <= start:
continue
links.append((start, end, url))
expanded = text
for _start, end, url in sorted(links, reverse=True):
inline = f" ({url})"
# The guard makes repeated processing of an already-expanded event
# harmless without changing the original entity offsets.
if expanded[end:].startswith(inline):
continue
expanded = f"{expanded[:end]}{inline}{expanded[end:]}"
return expanded
def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]:
bot_username = self._current_bot_username()
if not text or not bot_username:
@@ -6224,14 +6282,14 @@ class TelegramAdapter(BasePlatformAdapter):
if self._should_observe_unmentioned_group_message(msg):
_event = self._build_message_event(msg, self._media_message_type(msg), update_id=update.update_id)
if msg.caption:
_event.text = self._clean_bot_trigger_text(msg.caption)
_event.text = self._clean_bot_trigger_text(self._expand_link_entities(msg))
await self._cache_observed_media(msg, _event)
self._observe_unmentioned_group_message(msg, _event.message_type, update_id=update.update_id, event=_event)
return
event = self._build_message_event(msg, self._media_message_type(msg), update_id=update.update_id)
if msg.caption:
from plugins.platforms.telegram.telegram_context import group_trigger_text
event.text = group_trigger_text(self, msg, msg.caption)
event.text = group_trigger_text(self, msg, self._expand_link_entities(msg))
# Stickers: _handle_sticker overwrites event.text with its vision description, so observe attribution must run after it.
if msg.sticker:
await self._handle_sticker(msg, event)
@@ -6532,7 +6590,7 @@ class TelegramAdapter(BasePlatformAdapter):
_chat_id_str = str(chat.id)
channel_prompt = resolve_channel_prompt(self.config.extra, thread_id_str or _chat_id_str, _chat_id_str if thread_id_str else None)
return MessageEvent(
text=message.text or "", message_type=msg_type, source=source, raw_message=message,
text=self._expand_link_entities(message), message_type=msg_type, source=source, raw_message=message,
message_id=str(message.message_id), platform_update_id=update_id,
reply_to_message_id=reply_to_id, reply_to_text=reply_to_text, auto_skill=topic_skill,
channel_prompt=group_identity_prompt(self, message, channel_prompt),

View File

@@ -0,0 +1,133 @@
"""Tests for Telegram ``text_link`` entity expansion in inbound messages.
Telegram delivers a URL attached to a word (e.g. "тут" -> github.com) as a
``text_link`` entity. The visible text carries no URL, so without expansion the
model only ever sees the bare word and cannot fetch the link. ``_expand_link_entities``
inlines the real URL right after its anchor for both ``text`` and ``caption``.
"""
import pytest
from plugins.platforms.telegram.adapter import TelegramAdapter
class _Entity:
def __init__(self, type, offset, length, url=None):
self.type = type
self.offset = offset
self.length = length
self.url = url
class _Message:
def __init__(self, text=None, caption=None, entities=None, caption_entities=None):
self.text = text
self.caption = caption
self.entities = entities
self.caption_entities = caption_entities
@pytest.fixture
def adapter():
# _expand_link_entities only uses getattr on the message, so an unbound
# instance is enough.
return TelegramAdapter.__new__(TelegramAdapter)
def test_hidden_link_in_word_is_inlined(adapter):
msg = _Message(
text="Ссылка: тут\n#tag",
entities=[_Entity("text_link", 8, 3, "https://github.com/Cysharp/R3")],
)
out = adapter._expand_link_entities(msg)
assert "https://github.com/Cysharp/R3" in out
assert out.startswith("Ссылка: тут (https://github.com/Cysharp/R3)")
def test_plain_text_without_entities_is_unchanged(adapter):
msg = _Message(text="просто текст без ссылок")
assert adapter._expand_link_entities(msg) == "просто текст без ссылок"
def test_caption_link_on_media_is_inlined(adapter):
msg = _Message(
caption="Смотри тут проект",
caption_entities=[_Entity("text_link", 7, 3, "https://example.com/x")],
)
assert adapter._expand_link_entities(msg) == "Смотри тут (https://example.com/x) проект"
def test_utf16_offset_is_respected_after_emoji(adapter):
# Telegram entity offsets are measured in UTF-16 code units. The emoji is
# two units, so the visible anchor starts at offset 3, not Python index 2.
msg = _Message(
text="🔥 тут",
entities=[_Entity("text_link", 3, 3, "https://example.com/emoji")],
)
assert adapter._expand_link_entities(msg) == "🔥 тут (https://example.com/emoji)"
def test_expansion_is_idempotent(adapter):
msg = _Message(
text="Ссылка: тут\n#tag",
entities=[_Entity("text_link", 8, 3, "https://github.com/Cysharp/R3")],
)
out = adapter._expand_link_entities(msg)
repeat = _Message(text=out, entities=[_Entity("text_link", 8, 3, "https://github.com/Cysharp/R3")])
assert adapter._expand_link_entities(repeat) == out
def test_multiple_distinct_links(adapter):
msg = _Message(
text="a b",
entities=[
_Entity("text_link", 0, 1, "https://one.com"),
_Entity("text_link", 2, 1, "https://two.com"),
],
)
out = adapter._expand_link_entities(msg)
assert out == "a (https://one.com) b (https://two.com)"
def test_non_text_link_entities_are_ignored(adapter):
msg = _Message(text="жирный текст", entities=[_Entity("bold", 0, 6)])
assert adapter._expand_link_entities(msg) == "жирный текст"
def test_anchor_repeats_later_in_text(adapter):
msg = _Message(
text="тут и ещё тут",
entities=[_Entity("text_link", 0, 3, "https://x.com")],
)
out = adapter._expand_link_entities(msg)
assert out.startswith("тут (https://x.com) и ещё тут")
@pytest.mark.parametrize(
"entity",
[
_Entity("text_link", 1, 99, "https://example.com/past-end"),
_Entity("text_link", 0, 1, 123),
_Entity("text_link", "invalid", 1, "https://example.com/bad-offset"),
],
)
def test_malformed_link_entities_are_ignored(adapter, entity):
msg = _Message(text="abc", entities=[entity])
assert adapter._expand_link_entities(msg) == "abc"
def test_text_does_not_use_caption_entities(adapter):
msg = _Message(
text="plain text",
caption="linked caption",
caption_entities=[_Entity("text_link", 0, 6, "https://example.com/caption")],
)
assert adapter._expand_link_entities(msg) == "plain text"
def test_offset_inside_utf16_surrogate_pair_is_ignored(adapter):
msg = _Message(
text="🔥 link",
entities=[_Entity("text_link", 1, 1, "https://example.com/mid-surrogate")],
)
assert adapter._expand_link_entities(msg) == "🔥 link"