feat(telegram): add configurable group mention policy
This commit is contained in:
@@ -179,6 +179,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
self._media_group_buffers: dict[str, dict] = {}
|
self._media_group_buffers: dict[str, dict] = {}
|
||||||
self._media_group_tasks: dict[str, asyncio.Task] = {}
|
self._media_group_tasks: dict[str, asyncio.Task] = {}
|
||||||
self._message_threads: dict[tuple[str, int], int] = {}
|
self._message_threads: dict[tuple[str, int], int] = {}
|
||||||
|
self._bot_user_id: int | None = None
|
||||||
|
self._bot_username: str | None = None
|
||||||
|
|
||||||
def is_allowed(self, sender_id: str) -> bool:
|
def is_allowed(self, sender_id: str) -> bool:
|
||||||
"""Preserve Telegram's legacy id|username allowlist matching."""
|
"""Preserve Telegram's legacy id|username allowlist matching."""
|
||||||
@@ -242,6 +244,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
|
|
||||||
# Get bot info and register command menu
|
# Get bot info and register command menu
|
||||||
bot_info = await self._app.bot.get_me()
|
bot_info = await self._app.bot.get_me()
|
||||||
|
self._bot_user_id = getattr(bot_info, "id", None)
|
||||||
|
self._bot_username = getattr(bot_info, "username", None)
|
||||||
logger.info("Telegram bot @{} connected", bot_info.username)
|
logger.info("Telegram bot @{} connected", bot_info.username)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -462,6 +466,70 @@ class TelegramChannel(BaseChannel):
|
|||||||
"is_forum": bool(getattr(message.chat, "is_forum", False)),
|
"is_forum": bool(getattr(message.chat, "is_forum", False)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _ensure_bot_identity(self) -> tuple[int | None, str | None]:
|
||||||
|
"""Load bot identity once and reuse it for mention/reply checks."""
|
||||||
|
if self._bot_user_id is not None or self._bot_username is not None:
|
||||||
|
return self._bot_user_id, self._bot_username
|
||||||
|
if not self._app:
|
||||||
|
return None, None
|
||||||
|
bot_info = await self._app.bot.get_me()
|
||||||
|
self._bot_user_id = getattr(bot_info, "id", None)
|
||||||
|
self._bot_username = getattr(bot_info, "username", None)
|
||||||
|
return self._bot_user_id, self._bot_username
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_mention_entity(
|
||||||
|
text: str,
|
||||||
|
entities,
|
||||||
|
bot_username: str,
|
||||||
|
bot_id: int | None,
|
||||||
|
) -> bool:
|
||||||
|
"""Check Telegram mention entities against the bot username."""
|
||||||
|
handle = f"@{bot_username}".lower()
|
||||||
|
for entity in entities or []:
|
||||||
|
entity_type = getattr(entity, "type", None)
|
||||||
|
if entity_type == "text_mention":
|
||||||
|
user = getattr(entity, "user", None)
|
||||||
|
if user is not None and bot_id is not None and getattr(user, "id", None) == bot_id:
|
||||||
|
return True
|
||||||
|
continue
|
||||||
|
if entity_type != "mention":
|
||||||
|
continue
|
||||||
|
offset = getattr(entity, "offset", None)
|
||||||
|
length = getattr(entity, "length", None)
|
||||||
|
if offset is None or length is None:
|
||||||
|
continue
|
||||||
|
if text[offset : offset + length].lower() == handle:
|
||||||
|
return True
|
||||||
|
return handle in text.lower()
|
||||||
|
|
||||||
|
async def _is_group_message_for_bot(self, message) -> bool:
|
||||||
|
"""Allow group messages when policy is open, @mentioned, or replying to the bot."""
|
||||||
|
if message.chat.type == "private" or self.config.group_policy == "open":
|
||||||
|
return True
|
||||||
|
|
||||||
|
bot_id, bot_username = await self._ensure_bot_identity()
|
||||||
|
if bot_username:
|
||||||
|
text = message.text or ""
|
||||||
|
caption = message.caption or ""
|
||||||
|
if self._has_mention_entity(
|
||||||
|
text,
|
||||||
|
getattr(message, "entities", None),
|
||||||
|
bot_username,
|
||||||
|
bot_id,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
if self._has_mention_entity(
|
||||||
|
caption,
|
||||||
|
getattr(message, "caption_entities", None),
|
||||||
|
bot_username,
|
||||||
|
bot_id,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
reply_user = getattr(getattr(message, "reply_to_message", None), "from_user", None)
|
||||||
|
return bool(bot_id and reply_user and reply_user.id == bot_id)
|
||||||
|
|
||||||
def _remember_thread_context(self, message) -> None:
|
def _remember_thread_context(self, message) -> None:
|
||||||
"""Cache topic thread id by chat/message id for follow-up replies."""
|
"""Cache topic thread id by chat/message id for follow-up replies."""
|
||||||
message_thread_id = getattr(message, "message_thread_id", None)
|
message_thread_id = getattr(message, "message_thread_id", None)
|
||||||
@@ -501,21 +569,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
# Store chat_id for replies
|
# Store chat_id for replies
|
||||||
self._chat_ids[sender_id] = chat_id
|
self._chat_ids[sender_id] = chat_id
|
||||||
|
|
||||||
# Enforce group_policy: in group chats with "mention" policy,
|
if not await self._is_group_message_for_bot(message):
|
||||||
# only respond when the bot is @mentioned or the message is a reply to the bot.
|
|
||||||
is_group = message.chat.type != "private"
|
|
||||||
if is_group and getattr(self.config, "group_policy", "open") == "mention":
|
|
||||||
bot_username = (await self._app.bot.get_me()).username if self._app else None
|
|
||||||
mentioned = False
|
|
||||||
# Check if bot is @mentioned in text
|
|
||||||
if bot_username and message.text:
|
|
||||||
mentioned = f"@{bot_username}" in message.text
|
|
||||||
# Check if the message is a reply to the bot
|
|
||||||
if not mentioned and message.reply_to_message and message.reply_to_message.from_user:
|
|
||||||
bot_id = (await self._app.bot.get_me()).id if self._app else None
|
|
||||||
if bot_id and message.reply_to_message.from_user.id == bot_id:
|
|
||||||
mentioned = True
|
|
||||||
if not mentioned:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build content from text and/or media
|
# Build content from text and/or media
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class TelegramConfig(Base):
|
|||||||
None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
|
None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
|
||||||
)
|
)
|
||||||
reply_to_message: bool = False # If true, bot replies quote the original message
|
reply_to_message: bool = False # If true, bot replies quote the original message
|
||||||
group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned or replied to
|
group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned or replied to, "open" responds to all
|
||||||
|
|
||||||
|
|
||||||
class FeishuConfig(Base):
|
class FeishuConfig(Base):
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ class _FakeUpdater:
|
|||||||
class _FakeBot:
|
class _FakeBot:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.sent_messages: list[dict] = []
|
self.sent_messages: list[dict] = []
|
||||||
|
self.get_me_calls = 0
|
||||||
|
|
||||||
async def get_me(self):
|
async def get_me(self):
|
||||||
return SimpleNamespace(username="nanobot_test")
|
self.get_me_calls += 1
|
||||||
|
return SimpleNamespace(id=999, username="nanobot_test")
|
||||||
|
|
||||||
async def set_my_commands(self, commands) -> None:
|
async def set_my_commands(self, commands) -> None:
|
||||||
self.commands = commands
|
self.commands = commands
|
||||||
@@ -37,6 +39,9 @@ class _FakeBot:
|
|||||||
async def send_message(self, **kwargs) -> None:
|
async def send_message(self, **kwargs) -> None:
|
||||||
self.sent_messages.append(kwargs)
|
self.sent_messages.append(kwargs)
|
||||||
|
|
||||||
|
async def send_chat_action(self, **kwargs) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class _FakeApp:
|
class _FakeApp:
|
||||||
def __init__(self, on_start_polling) -> None:
|
def __init__(self, on_start_polling) -> None:
|
||||||
@@ -87,6 +92,35 @@ class _FakeBuilder:
|
|||||||
return self.app
|
return self.app
|
||||||
|
|
||||||
|
|
||||||
|
def _make_telegram_update(
|
||||||
|
*,
|
||||||
|
chat_type: str = "group",
|
||||||
|
text: str | None = None,
|
||||||
|
caption: str | None = None,
|
||||||
|
entities=None,
|
||||||
|
caption_entities=None,
|
||||||
|
reply_to_message=None,
|
||||||
|
):
|
||||||
|
user = SimpleNamespace(id=12345, username="alice", first_name="Alice")
|
||||||
|
message = SimpleNamespace(
|
||||||
|
chat=SimpleNamespace(type=chat_type, is_forum=False),
|
||||||
|
chat_id=-100123,
|
||||||
|
text=text,
|
||||||
|
caption=caption,
|
||||||
|
entities=entities or [],
|
||||||
|
caption_entities=caption_entities or [],
|
||||||
|
reply_to_message=reply_to_message,
|
||||||
|
photo=None,
|
||||||
|
voice=None,
|
||||||
|
audio=None,
|
||||||
|
document=None,
|
||||||
|
media_group_id=None,
|
||||||
|
message_thread_id=None,
|
||||||
|
message_id=1,
|
||||||
|
)
|
||||||
|
return SimpleNamespace(message=message, effective_user=user)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_start_uses_request_proxy_without_builder_proxy(monkeypatch) -> None:
|
async def test_start_uses_request_proxy_without_builder_proxy(monkeypatch) -> None:
|
||||||
config = TelegramConfig(
|
config = TelegramConfig(
|
||||||
@@ -131,6 +165,10 @@ def test_get_extension_falls_back_to_original_filename() -> None:
|
|||||||
assert channel._get_extension("file", None, "archive.tar.gz") == ".tar.gz"
|
assert channel._get_extension("file", None, "archive.tar.gz") == ".tar.gz"
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_group_policy_defaults_to_mention() -> None:
|
||||||
|
assert TelegramConfig().group_policy == "mention"
|
||||||
|
|
||||||
|
|
||||||
def test_is_allowed_accepts_legacy_telegram_id_username_formats() -> None:
|
def test_is_allowed_accepts_legacy_telegram_id_username_formats() -> None:
|
||||||
channel = TelegramChannel(TelegramConfig(allow_from=["12345", "alice", "67890|bob"]), MessageBus())
|
channel = TelegramChannel(TelegramConfig(allow_from=["12345", "alice", "67890|bob"]), MessageBus())
|
||||||
|
|
||||||
@@ -182,3 +220,119 @@ async def test_send_reply_infers_topic_from_message_id_cache() -> None:
|
|||||||
|
|
||||||
assert channel._app.bot.sent_messages[0]["message_thread_id"] == 42
|
assert channel._app.bot.sent_messages[0]["message_thread_id"] == 42
|
||||||
assert channel._app.bot.sent_messages[0]["reply_parameters"].message_id == 10
|
assert channel._app.bot.sent_messages[0]["reply_parameters"].message_id == 10
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_policy_mention_ignores_unmentioned_group_message() -> None:
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._app = _FakeApp(lambda: None)
|
||||||
|
|
||||||
|
handled = []
|
||||||
|
|
||||||
|
async def capture_handle(**kwargs) -> None:
|
||||||
|
handled.append(kwargs)
|
||||||
|
|
||||||
|
channel._handle_message = capture_handle
|
||||||
|
channel._start_typing = lambda _chat_id: None
|
||||||
|
|
||||||
|
await channel._on_message(_make_telegram_update(text="hello everyone"), None)
|
||||||
|
|
||||||
|
assert handled == []
|
||||||
|
assert channel._app.bot.get_me_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_policy_mention_accepts_text_mention_and_caches_bot_identity() -> None:
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._app = _FakeApp(lambda: None)
|
||||||
|
|
||||||
|
handled = []
|
||||||
|
|
||||||
|
async def capture_handle(**kwargs) -> None:
|
||||||
|
handled.append(kwargs)
|
||||||
|
|
||||||
|
channel._handle_message = capture_handle
|
||||||
|
channel._start_typing = lambda _chat_id: None
|
||||||
|
|
||||||
|
mention = SimpleNamespace(type="mention", offset=0, length=13)
|
||||||
|
await channel._on_message(_make_telegram_update(text="@nanobot_test hi", entities=[mention]), None)
|
||||||
|
await channel._on_message(_make_telegram_update(text="@nanobot_test again", entities=[mention]), None)
|
||||||
|
|
||||||
|
assert len(handled) == 2
|
||||||
|
assert channel._app.bot.get_me_calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_policy_mention_accepts_caption_mention() -> None:
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._app = _FakeApp(lambda: None)
|
||||||
|
|
||||||
|
handled = []
|
||||||
|
|
||||||
|
async def capture_handle(**kwargs) -> None:
|
||||||
|
handled.append(kwargs)
|
||||||
|
|
||||||
|
channel._handle_message = capture_handle
|
||||||
|
channel._start_typing = lambda _chat_id: None
|
||||||
|
|
||||||
|
mention = SimpleNamespace(type="mention", offset=0, length=13)
|
||||||
|
await channel._on_message(
|
||||||
|
_make_telegram_update(caption="@nanobot_test photo", caption_entities=[mention]),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(handled) == 1
|
||||||
|
assert handled[0]["content"] == "@nanobot_test photo"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_policy_mention_accepts_reply_to_bot() -> None:
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._app = _FakeApp(lambda: None)
|
||||||
|
|
||||||
|
handled = []
|
||||||
|
|
||||||
|
async def capture_handle(**kwargs) -> None:
|
||||||
|
handled.append(kwargs)
|
||||||
|
|
||||||
|
channel._handle_message = capture_handle
|
||||||
|
channel._start_typing = lambda _chat_id: None
|
||||||
|
|
||||||
|
reply = SimpleNamespace(from_user=SimpleNamespace(id=999))
|
||||||
|
await channel._on_message(_make_telegram_update(text="reply", reply_to_message=reply), None)
|
||||||
|
|
||||||
|
assert len(handled) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_policy_open_accepts_plain_group_message() -> None:
|
||||||
|
channel = TelegramChannel(
|
||||||
|
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
|
||||||
|
MessageBus(),
|
||||||
|
)
|
||||||
|
channel._app = _FakeApp(lambda: None)
|
||||||
|
|
||||||
|
handled = []
|
||||||
|
|
||||||
|
async def capture_handle(**kwargs) -> None:
|
||||||
|
handled.append(kwargs)
|
||||||
|
|
||||||
|
channel._handle_message = capture_handle
|
||||||
|
channel._start_typing = lambda _chat_id: None
|
||||||
|
|
||||||
|
await channel._on_message(_make_telegram_update(text="hello group"), None)
|
||||||
|
|
||||||
|
assert len(handled) == 1
|
||||||
|
assert channel._app.bot.get_me_calls == 0
|
||||||
|
|||||||
Reference in New Issue
Block a user