mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(telegram): add group mention gating and regex triggers (#3870)
Adds Discord-style mention gating for Telegram groups: - telegram.require_mention: gate group messages (default: false) - telegram.mention_patterns: regex wake-word triggers - telegram.free_response_chats: bypass gating for specific chats When require_mention is enabled, group messages are accepted only for: - slash commands - replies to the bot - @botusername mentions - regex wake-word pattern matches DMs remain unrestricted. @mention text is stripped before passing to the agent. Invalid regex patterns are ignored with a warning. Config bridges follow the existing Discord pattern (yaml → env vars). Cherry-picked and adapted from PR #1977 by mcleay. Fixed ChatType comparison to work without python-telegram-bot installed (uses string matching instead of enum, consistent with other entity_type checks). Co-authored-by: mcleay <mcleay@users.noreply.github.com>
This commit is contained in:
parent
366bfc3c76
commit
839f798b74
4 changed files with 310 additions and 3 deletions
|
|
@ -8,6 +8,7 @@ Uses python-telegram-bot library for:
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
|
@ -122,6 +123,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
super().__init__(config, Platform.TELEGRAM)
|
||||
self._app: Optional[Application] = None
|
||||
self._bot: Optional[Bot] = None
|
||||
self._mention_patterns = self._compile_mention_patterns()
|
||||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||||
# Buffer rapid/album photo updates so Telegram image bursts are handled
|
||||
# as a single MessageEvent instead of self-interrupting multiple turns.
|
||||
|
|
@ -1325,6 +1327,148 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
|
||||
return text
|
||||
|
||||
# ── Group mention gating ──────────────────────────────────────────────
|
||||
|
||||
def _telegram_require_mention(self) -> bool:
|
||||
"""Return whether group chats should require an explicit bot trigger."""
|
||||
configured = self.config.extra.get("require_mention")
|
||||
if configured is not None:
|
||||
if isinstance(configured, str):
|
||||
return configured.lower() in ("true", "1", "yes", "on")
|
||||
return bool(configured)
|
||||
return os.getenv("TELEGRAM_REQUIRE_MENTION", "false").lower() in ("true", "1", "yes", "on")
|
||||
|
||||
def _telegram_free_response_chats(self) -> set[str]:
|
||||
raw = self.config.extra.get("free_response_chats")
|
||||
if raw is None:
|
||||
raw = os.getenv("TELEGRAM_FREE_RESPONSE_CHATS", "")
|
||||
if isinstance(raw, list):
|
||||
return {str(part).strip() for part in raw if str(part).strip()}
|
||||
return {part.strip() for part in str(raw).split(",") if part.strip()}
|
||||
|
||||
def _compile_mention_patterns(self) -> List[re.Pattern]:
|
||||
"""Compile optional regex wake-word patterns for group triggers."""
|
||||
patterns = self.config.extra.get("mention_patterns")
|
||||
if patterns is None:
|
||||
raw = os.getenv("TELEGRAM_MENTION_PATTERNS", "").strip()
|
||||
if raw:
|
||||
try:
|
||||
loaded = json.loads(raw)
|
||||
except Exception:
|
||||
loaded = [part.strip() for part in raw.splitlines() if part.strip()]
|
||||
if not loaded:
|
||||
loaded = [part.strip() for part in raw.split(",") if part.strip()]
|
||||
patterns = loaded
|
||||
|
||||
if patterns is None:
|
||||
return []
|
||||
if isinstance(patterns, str):
|
||||
patterns = [patterns]
|
||||
if not isinstance(patterns, list):
|
||||
logger.warning(
|
||||
"[%s] telegram mention_patterns must be a list or string; got %s",
|
||||
self.name,
|
||||
type(patterns).__name__,
|
||||
)
|
||||
return []
|
||||
|
||||
compiled: List[re.Pattern] = []
|
||||
for pattern in patterns:
|
||||
if not isinstance(pattern, str) or not pattern.strip():
|
||||
continue
|
||||
try:
|
||||
compiled.append(re.compile(pattern, re.IGNORECASE))
|
||||
except re.error as exc:
|
||||
logger.warning("[%s] Invalid Telegram mention pattern %r: %s", self.name, pattern, exc)
|
||||
if compiled:
|
||||
logger.info("[%s] Loaded %d Telegram mention pattern(s)", self.name, len(compiled))
|
||||
return compiled
|
||||
|
||||
def _is_group_chat(self, message: Message) -> bool:
|
||||
chat = getattr(message, "chat", None)
|
||||
if not chat:
|
||||
return False
|
||||
chat_type = str(getattr(chat, "type", "")).split(".")[-1].lower()
|
||||
return chat_type in ("group", "supergroup")
|
||||
|
||||
def _is_reply_to_bot(self, message: Message) -> bool:
|
||||
if not self._bot or not getattr(message, "reply_to_message", None):
|
||||
return False
|
||||
reply_user = getattr(message.reply_to_message, "from_user", None)
|
||||
return bool(reply_user and getattr(reply_user, "id", None) == getattr(self._bot, "id", None))
|
||||
|
||||
def _message_mentions_bot(self, message: Message) -> bool:
|
||||
if not self._bot:
|
||||
return False
|
||||
|
||||
bot_username = (getattr(self._bot, "username", None) or "").lstrip("@").lower()
|
||||
bot_id = getattr(self._bot, "id", None)
|
||||
|
||||
def _iter_sources():
|
||||
yield getattr(message, "text", None) or "", getattr(message, "entities", None) or []
|
||||
yield getattr(message, "caption", None) or "", getattr(message, "caption_entities", None) or []
|
||||
|
||||
for source_text, entities in _iter_sources():
|
||||
if bot_username and f"@{bot_username}" in source_text.lower():
|
||||
return True
|
||||
for entity in entities:
|
||||
entity_type = str(getattr(entity, "type", "")).split(".")[-1].lower()
|
||||
if entity_type == "mention" and bot_username:
|
||||
offset = int(getattr(entity, "offset", -1))
|
||||
length = int(getattr(entity, "length", 0))
|
||||
if offset < 0 or length <= 0:
|
||||
continue
|
||||
if source_text[offset:offset + length].strip().lower() == f"@{bot_username}":
|
||||
return True
|
||||
elif entity_type == "text_mention":
|
||||
user = getattr(entity, "user", None)
|
||||
if user and getattr(user, "id", None) == bot_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _message_matches_mention_patterns(self, message: Message) -> bool:
|
||||
if not self._mention_patterns:
|
||||
return False
|
||||
for candidate in (getattr(message, "text", None), getattr(message, "caption", None)):
|
||||
if not candidate:
|
||||
continue
|
||||
for pattern in self._mention_patterns:
|
||||
if pattern.search(candidate):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _clean_bot_trigger_text(self, text: Optional[str]) -> Optional[str]:
|
||||
if not text or not self._bot or not getattr(self._bot, "username", None):
|
||||
return text
|
||||
username = re.escape(self._bot.username)
|
||||
cleaned = re.sub(rf"(?i)@{username}\b[,:\-]*\s*", "", text).strip()
|
||||
return cleaned or text
|
||||
|
||||
def _should_process_message(self, message: Message, *, is_command: bool = False) -> bool:
|
||||
"""Apply Telegram group trigger rules.
|
||||
|
||||
DMs remain unrestricted. Group/supergroup messages are accepted when:
|
||||
- the chat is explicitly allowlisted in ``free_response_chats``
|
||||
- ``require_mention`` is disabled
|
||||
- the message is a command
|
||||
- the message replies to the bot
|
||||
- the bot is @mentioned
|
||||
- the text/caption matches a configured regex wake-word pattern
|
||||
"""
|
||||
if not self._is_group_chat(message):
|
||||
return True
|
||||
if str(getattr(getattr(message, "chat", None), "id", "")) in self._telegram_free_response_chats():
|
||||
return True
|
||||
if not self._telegram_require_mention():
|
||||
return True
|
||||
if is_command:
|
||||
return True
|
||||
if self._is_reply_to_bot(message):
|
||||
return True
|
||||
if self._message_mentions_bot(message):
|
||||
return True
|
||||
return self._message_matches_mention_patterns(message)
|
||||
|
||||
async def _handle_text_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle incoming text messages.
|
||||
|
||||
|
|
@ -1334,14 +1478,19 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
"""
|
||||
if not update.message or not update.message.text:
|
||||
return
|
||||
if not self._should_process_message(update.message):
|
||||
return
|
||||
|
||||
event = self._build_message_event(update.message, MessageType.TEXT)
|
||||
event.text = self._clean_bot_trigger_text(event.text)
|
||||
self._enqueue_text_event(event)
|
||||
|
||||
async def _handle_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle incoming command messages."""
|
||||
if not update.message or not update.message.text:
|
||||
return
|
||||
if not self._should_process_message(update.message, is_command=True):
|
||||
return
|
||||
|
||||
event = self._build_message_event(update.message, MessageType.COMMAND)
|
||||
await self.handle_message(event)
|
||||
|
|
@ -1350,6 +1499,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
"""Handle incoming location/venue pin messages."""
|
||||
if not update.message:
|
||||
return
|
||||
if not self._should_process_message(update.message):
|
||||
return
|
||||
|
||||
msg = update.message
|
||||
venue = getattr(msg, "venue", None)
|
||||
|
|
@ -1493,6 +1644,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
"""Handle incoming media messages, downloading images to local cache."""
|
||||
if not update.message:
|
||||
return
|
||||
if not self._should_process_message(update.message):
|
||||
return
|
||||
|
||||
msg = update.message
|
||||
|
||||
|
|
@ -1516,7 +1669,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
|
||||
# Add caption as text
|
||||
if msg.caption:
|
||||
event.text = msg.caption
|
||||
event.text = self._clean_bot_trigger_text(msg.caption)
|
||||
|
||||
# Handle stickers: describe via vision tool with caching
|
||||
if msg.sticker:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue