Merge branch 'main' into rewbs/tool-use-charge-to-subscription

This commit is contained in:
Robin Fernandes
2026-03-31 09:29:43 +09:00
160 changed files with 5545 additions and 664 deletions

View File

@@ -24,6 +24,15 @@ logger = logging.getLogger(__name__)
def _coerce_bool(value: Any, default: bool = True) -> bool:
"""Coerce bool-ish config values, preserving a caller-provided default."""
if value is None:
return default
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("true", "1", "yes", "on"):
return True
if lowered in ("false", "0", "no", "off"):
return False
return default
return is_truthy_value(value, default=default)
@@ -510,6 +519,10 @@ def load_gateway_config() -> GatewayConfig:
)
if "reply_prefix" in platform_cfg:
bridged["reply_prefix"] = platform_cfg["reply_prefix"]
if "require_mention" in platform_cfg:
bridged["require_mention"] = platform_cfg["require_mention"]
if "mention_patterns" in platform_cfg:
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
if not bridged:
continue
plat_data = platforms_data.setdefault(plat.value, {})
@@ -534,6 +547,20 @@ def load_gateway_config() -> GatewayConfig:
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
# Telegram settings → env vars (env vars take precedence)
telegram_cfg = yaml_cfg.get("telegram", {})
if isinstance(telegram_cfg, dict):
if "require_mention" in telegram_cfg and not os.getenv("TELEGRAM_REQUIRE_MENTION"):
os.environ["TELEGRAM_REQUIRE_MENTION"] = str(telegram_cfg["require_mention"]).lower()
if "mention_patterns" in telegram_cfg and not os.getenv("TELEGRAM_MENTION_PATTERNS"):
import json as _json
os.environ["TELEGRAM_MENTION_PATTERNS"] = _json.dumps(telegram_cfg["mention_patterns"])
frc = telegram_cfg.get("free_response_chats")
if frc is not None and not os.getenv("TELEGRAM_FREE_RESPONSE_CHATS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
except Exception as e:
logger.warning(
"Failed to process config.yaml — falling back to .env / gateway.json values. "
@@ -876,4 +903,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
config.default_reset_policy.at_hour = int(reset_hour)
except ValueError:
pass

View File

@@ -898,6 +898,26 @@ class BasePlatformAdapter(ABC):
except Exception:
pass
# ── Processing lifecycle hooks ──────────────────────────────────────────
# Subclasses override these to react to message processing events
# (e.g. Discord adds 👀/✅/❌ reactions).
async def on_processing_start(self, event: MessageEvent) -> None:
"""Hook called when background processing begins."""
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
"""Hook called when background processing completes."""
async def _run_processing_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> None:
"""Run a lifecycle hook without letting failures break message flow."""
hook = getattr(self, hook_name, None)
if not callable(hook):
return
try:
await hook(*args, **kwargs)
except Exception as e:
logger.warning("[%s] %s hook failed: %s", self.name, hook_name, e)
@staticmethod
def _is_retryable_error(error: Optional[str]) -> bool:
"""Return True if the error string looks like a transient network failure."""
@@ -1060,6 +1080,18 @@ class BasePlatformAdapter(ABC):
async def _process_message_background(self, event: MessageEvent, session_key: str) -> None:
"""Background task that actually processes the message."""
# Track delivery outcomes for the processing-complete hook
delivery_attempted = False
delivery_succeeded = False
def _record_delivery(result):
nonlocal delivery_attempted, delivery_succeeded
if result is None:
return
delivery_attempted = True
if getattr(result, "success", False):
delivery_succeeded = True
# Create interrupt event for this session
interrupt_event = asyncio.Event()
self._active_sessions[session_key] = interrupt_event
@@ -1069,6 +1101,8 @@ class BasePlatformAdapter(ABC):
typing_task = asyncio.create_task(self._keep_typing(event.source.chat_id, metadata=_thread_metadata))
try:
await self._run_processing_hook("on_processing_start", event)
# Call the handler (this can take a while with tool calls)
response = await self._message_handler(event)
@@ -1138,6 +1172,7 @@ class BasePlatformAdapter(ABC):
reply_to=event.message_id,
metadata=_thread_metadata,
)
_record_delivery(result)
# Human-like pacing delay between text and media
human_delay = self._get_human_delay()
@@ -1237,6 +1272,10 @@ class BasePlatformAdapter(ABC):
except Exception as file_err:
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
# Determine overall success for the processing hook
processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
await self._run_processing_hook("on_processing_complete", event, processing_ok)
# Check if there's a pending message that was queued during our processing
if session_key in self._pending_messages:
pending_event = self._pending_messages.pop(session_key)
@@ -1253,7 +1292,11 @@ class BasePlatformAdapter(ABC):
await self._process_message_background(pending_event, session_key)
return # Already cleaned up
except asyncio.CancelledError:
await self._run_processing_hook("on_processing_complete", event, False)
raise
except Exception as e:
await self._run_processing_hook("on_processing_complete", event, False)
logger.error("[%s] Error handling message: %s", self.name, e, exc_info=True)
# Send the error to the user so they aren't left with radio silence
try:

View File

@@ -660,6 +660,41 @@ class DiscordAdapter(BasePlatformAdapter):
pass
logger.info("[%s] Disconnected", self.name)
async def _add_reaction(self, message: Any, emoji: str) -> bool:
"""Add an emoji reaction to a Discord message."""
if not message or not hasattr(message, "add_reaction"):
return False
try:
await message.add_reaction(emoji)
return True
except Exception as e:
logger.debug("[%s] add_reaction failed (%s): %s", self.name, emoji, e)
return False
async def _remove_reaction(self, message: Any, emoji: str) -> bool:
"""Remove the bot's own emoji reaction from a Discord message."""
if not message or not hasattr(message, "remove_reaction") or not self._client or not self._client.user:
return False
try:
await message.remove_reaction(emoji, self._client.user)
return True
except Exception as e:
logger.debug("[%s] remove_reaction failed (%s): %s", self.name, emoji, e)
return False
async def on_processing_start(self, event: MessageEvent) -> None:
"""Add an in-progress reaction for normal Discord message events."""
message = event.raw_message
if hasattr(message, "add_reaction"):
await self._add_reaction(message, "👀")
async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
"""Swap the in-progress reaction for a final success/failure reaction."""
message = event.raw_message
if hasattr(message, "add_reaction"):
await self._remove_reaction(message, "👀")
await self._add_reaction(message, "" if success else "")
async def send(
self,

View File

@@ -17,6 +17,8 @@ Environment variables:
from __future__ import annotations
import asyncio
import io
import json
import logging
import mimetypes
import os
@@ -512,8 +514,11 @@ class MatrixAdapter(BasePlatformAdapter):
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload an audio file as a voice message."""
return await self._send_local_file(chat_id, audio_path, "m.audio", caption, reply_to, metadata=metadata)
"""Upload an audio file as a voice message (MSC3245 native voice)."""
return await self._send_local_file(
chat_id, audio_path, "m.audio", caption, reply_to,
metadata=metadata, is_voice=True
)
async def send_video(
self,
@@ -546,13 +551,16 @@ class MatrixAdapter(BasePlatformAdapter):
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
is_voice: bool = False,
) -> SendResult:
"""Upload bytes to Matrix and send as a media message."""
import nio
# Upload to homeserver.
resp = await self._client.upload(
data,
# nio expects a DataProvider (callable) or file-like object, not raw bytes.
# nio.upload() returns a tuple (UploadResponse|UploadError, Optional[Dict])
resp, maybe_encryption_info = await self._client.upload(
io.BytesIO(data),
content_type=content_type,
filename=filename,
)
@@ -574,6 +582,10 @@ class MatrixAdapter(BasePlatformAdapter):
},
}
# Add MSC3245 voice flag for native voice messages.
if is_voice:
msg_content["org.matrix.msc3245.voice"] = {}
if reply_to:
msg_content["m.relates_to"] = {
"m.in_reply_to": {"event_id": reply_to}
@@ -601,6 +613,7 @@ class MatrixAdapter(BasePlatformAdapter):
reply_to: Optional[str] = None,
file_name: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
is_voice: bool = False,
) -> SendResult:
"""Read a local file and upload it."""
p = Path(file_path)
@@ -613,7 +626,7 @@ class MatrixAdapter(BasePlatformAdapter):
ct = mimetypes.guess_type(fname)[0] or "application/octet-stream"
data = p.read_bytes()
return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata)
return await self._upload_and_send(room_id, data, fname, ct, msgtype, caption, reply_to, metadata, is_voice)
# ------------------------------------------------------------------
# Sync loop
@@ -808,11 +821,19 @@ class MatrixAdapter(BasePlatformAdapter):
event_mimetype = (content_info.get("info") or {}).get("mimetype", "")
media_type = "application/octet-stream"
msg_type = MessageType.DOCUMENT
is_voice_message = False
if isinstance(event, nio.RoomMessageImage):
msg_type = MessageType.PHOTO
media_type = event_mimetype or "image/png"
elif isinstance(event, nio.RoomMessageAudio):
msg_type = MessageType.AUDIO
# Check for MSC3245 voice flag: org.matrix.msc3245.voice: {}
source_content = getattr(event, "source", {}).get("content", {})
if source_content.get("org.matrix.msc3245.voice") is not None:
is_voice_message = True
msg_type = MessageType.VOICE
else:
msg_type = MessageType.AUDIO
media_type = event_mimetype or "audio/ogg"
elif isinstance(event, nio.RoomMessageVideo):
msg_type = MessageType.VIDEO
@@ -850,6 +871,31 @@ class MatrixAdapter(BasePlatformAdapter):
if relates_to.get("rel_type") == "m.thread":
thread_id = relates_to.get("event_id")
# For voice messages, cache audio locally for transcription tools.
# Use the authenticated nio client to download (Matrix requires auth for media).
media_urls = [http_url] if http_url else None
media_types = [media_type] if http_url else None
if is_voice_message and url and url.startswith("mxc://"):
try:
import nio
from gateway.platforms.base import cache_audio_from_bytes
resp = await self._client.download(mxc=url)
if isinstance(resp, nio.MemoryDownloadResponse):
# Extract extension from mimetype or default to .ogg
ext = ".ogg"
if media_type and "/" in media_type:
subtype = media_type.split("/")[1]
ext = f".{subtype}" if subtype else ".ogg"
local_path = cache_audio_from_bytes(resp.body, ext)
media_urls = [local_path]
logger.debug("Matrix: cached voice message to %s", local_path)
else:
logger.warning("Matrix: failed to download voice: %s", getattr(resp, "message", resp))
except Exception as e:
logger.warning("Matrix: failed to cache voice message, using HTTP URL: %s", e)
source = self.build_source(
chat_id=room.room_id,
chat_type=chat_type,
@@ -858,8 +904,9 @@ class MatrixAdapter(BasePlatformAdapter):
thread_id=thread_id,
)
# Use cached local path for images, HTTP URL for other media types
media_urls = [cached_path] if cached_path else ([http_url] if http_url else None)
# Use cached local path for images (voice messages already handled above).
if cached_path:
media_urls = [cached_path]
media_types = [media_type] if media_urls else None
msg_event = MessageEvent(

View File

@@ -9,6 +9,7 @@ Uses slack-bolt (Python) with Socket Mode for:
"""
import asyncio
import json
import logging
import os
import re
@@ -73,6 +74,10 @@ class SlackAdapter(BasePlatformAdapter):
self._bot_user_id: Optional[str] = None
self._user_name_cache: Dict[str, str] = {} # user_id → display name
self._socket_mode_task: Optional[asyncio.Task] = None
# Multi-workspace support
self._team_clients: Dict[str, AsyncWebClient] = {} # team_id → WebClient
self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id
self._channel_team: Dict[str, str] = {} # channel_id → team_id
async def connect(self) -> bool:
"""Connect to Slack via Socket Mode."""
@@ -82,16 +87,34 @@ class SlackAdapter(BasePlatformAdapter):
)
return False
bot_token = self.config.token
raw_token = self.config.token
app_token = os.getenv("SLACK_APP_TOKEN")
if not bot_token:
if not raw_token:
logger.error("[Slack] SLACK_BOT_TOKEN not set")
return False
if not app_token:
logger.error("[Slack] SLACK_APP_TOKEN not set")
return False
# Support comma-separated bot tokens for multi-workspace
bot_tokens = [t.strip() for t in raw_token.split(",") if t.strip()]
# Also load tokens from OAuth token file
from hermes_constants import get_hermes_home
tokens_file = get_hermes_home() / "slack_tokens.json"
if tokens_file.exists():
try:
saved = json.loads(tokens_file.read_text(encoding="utf-8"))
for team_id, entry in saved.items():
tok = entry.get("token", "") if isinstance(entry, dict) else ""
if tok and tok not in bot_tokens:
bot_tokens.append(tok)
team_label = entry.get("team_name", team_id) if isinstance(entry, dict) else team_id
logger.info("[Slack] Loaded saved token for workspace %s", team_label)
except Exception as e:
logger.warning("[Slack] Failed to read %s: %s", tokens_file, e)
try:
# Acquire scoped lock to prevent duplicate app token usage
from gateway.status import acquire_scoped_lock
@@ -104,12 +127,30 @@ class SlackAdapter(BasePlatformAdapter):
self._set_fatal_error('slack_token_lock', message, retryable=False)
return False
self._app = AsyncApp(token=bot_token)
# First token is the primary — used for AsyncApp / Socket Mode
primary_token = bot_tokens[0]
self._app = AsyncApp(token=primary_token)
# Get our own bot user ID for mention detection
auth_response = await self._app.client.auth_test()
self._bot_user_id = auth_response.get("user_id")
bot_name = auth_response.get("user", "unknown")
# Register each bot token and map team_id → client
for token in bot_tokens:
client = AsyncWebClient(token=token)
auth_response = await client.auth_test()
team_id = auth_response.get("team_id", "")
bot_user_id = auth_response.get("user_id", "")
bot_name = auth_response.get("user", "unknown")
team_name = auth_response.get("team", "unknown")
self._team_clients[team_id] = client
self._team_bot_user_ids[team_id] = bot_user_id
# First token sets the primary bot_user_id (backward compat)
if self._bot_user_id is None:
self._bot_user_id = bot_user_id
logger.info(
"[Slack] Authenticated as @%s in workspace %s (team: %s)",
bot_name, team_name, team_id,
)
# Register message event handler
@self._app.event("message")
@@ -134,7 +175,10 @@ class SlackAdapter(BasePlatformAdapter):
self._socket_mode_task = asyncio.create_task(self._handler.start_async())
self._running = True
logger.info("[Slack] Connected as @%s (Socket Mode)", bot_name)
logger.info(
"[Slack] Socket Mode connected (%d workspace(s))",
len(self._team_clients),
)
return True
except Exception as e: # pragma: no cover - defensive logging
@@ -161,6 +205,13 @@ class SlackAdapter(BasePlatformAdapter):
logger.info("[Slack] Disconnected")
def _get_client(self, chat_id: str) -> AsyncWebClient:
"""Return the workspace-specific WebClient for a channel."""
team_id = self._channel_team.get(chat_id)
if team_id and team_id in self._team_clients:
return self._team_clients[team_id]
return self._app.client # fallback to primary
async def send(
self,
chat_id: str,
@@ -197,7 +248,7 @@ class SlackAdapter(BasePlatformAdapter):
if broadcast and i == 0:
kwargs["reply_broadcast"] = True
last_result = await self._app.client.chat_postMessage(**kwargs)
last_result = await self._get_client(chat_id).chat_postMessage(**kwargs)
return SendResult(
success=True,
@@ -219,7 +270,7 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app:
return SendResult(success=False, error="Not connected")
try:
await self._app.client.chat_update(
await self._get_client(chat_id).chat_update(
channel=chat_id,
ts=message_id,
text=content,
@@ -253,7 +304,7 @@ class SlackAdapter(BasePlatformAdapter):
return # Can only set status in a thread context
try:
await self._app.client.assistant_threads_setStatus(
await self._get_client(chat_id).assistant_threads_setStatus(
channel_id=chat_id,
thread_ts=thread_ts,
status="is thinking...",
@@ -295,7 +346,7 @@ class SlackAdapter(BasePlatformAdapter):
if not os.path.exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
result = await self._app.client.files_upload_v2(
result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id,
file=file_path,
filename=os.path.basename(file_path),
@@ -397,7 +448,7 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app:
return False
try:
await self._app.client.reactions_add(
await self._get_client(channel).reactions_add(
channel=channel, timestamp=timestamp, name=emoji
)
return True
@@ -413,7 +464,7 @@ class SlackAdapter(BasePlatformAdapter):
if not self._app:
return False
try:
await self._app.client.reactions_remove(
await self._get_client(channel).reactions_remove(
channel=channel, timestamp=timestamp, name=emoji
)
return True
@@ -423,7 +474,7 @@ class SlackAdapter(BasePlatformAdapter):
# ----- User identity resolution -----
async def _resolve_user_name(self, user_id: str) -> str:
async def _resolve_user_name(self, user_id: str, chat_id: str = "") -> str:
"""Resolve a Slack user ID to a display name, with caching."""
if not user_id:
return ""
@@ -434,7 +485,8 @@ class SlackAdapter(BasePlatformAdapter):
return user_id
try:
result = await self._app.client.users_info(user=user_id)
client = self._get_client(chat_id) if chat_id else self._app.client
result = await client.users_info(user=user_id)
user = result.get("user", {})
# Prefer display_name → real_name → user_id
profile = user.get("profile", {})
@@ -498,7 +550,7 @@ class SlackAdapter(BasePlatformAdapter):
response = await client.get(image_url)
response.raise_for_status()
result = await self._app.client.files_upload_v2(
result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id,
content=response.content,
filename="image.png",
@@ -558,7 +610,7 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error=f"Video file not found: {video_path}")
try:
result = await self._app.client.files_upload_v2(
result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id,
file=video_path,
filename=os.path.basename(video_path),
@@ -599,7 +651,7 @@ class SlackAdapter(BasePlatformAdapter):
display_name = file_name or os.path.basename(file_path)
try:
result = await self._app.client.files_upload_v2(
result = await self._get_client(chat_id).files_upload_v2(
channel=chat_id,
file=file_path,
filename=display_name,
@@ -627,7 +679,7 @@ class SlackAdapter(BasePlatformAdapter):
return {"name": chat_id, "type": "unknown"}
try:
result = await self._app.client.conversations_info(channel=chat_id)
result = await self._get_client(chat_id).conversations_info(channel=chat_id)
channel = result.get("channel", {})
is_dm = channel.get("is_im", False)
return {
@@ -660,6 +712,11 @@ class SlackAdapter(BasePlatformAdapter):
user_id = event.get("user", "")
channel_id = event.get("channel", "")
ts = event.get("ts", "")
team_id = event.get("team", "")
# Track which workspace owns this channel
if team_id and channel_id:
self._channel_team[channel_id] = team_id
# Determine if this is a DM or channel message
channel_type = event.get("channel_type", "")
@@ -676,11 +733,12 @@ class SlackAdapter(BasePlatformAdapter):
thread_ts = event.get("thread_ts") or ts # ts fallback for channels
# In channels, only respond if bot is mentioned
if not is_dm and self._bot_user_id:
if f"<@{self._bot_user_id}>" not in text:
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
if not is_dm and bot_uid:
if f"<@{bot_uid}>" not in text:
return
# Strip the bot mention from the text
text = text.replace(f"<@{self._bot_user_id}>", "").strip()
text = text.replace(f"<@{bot_uid}>", "").strip()
# Determine message type
msg_type = MessageType.TEXT
@@ -700,7 +758,7 @@ class SlackAdapter(BasePlatformAdapter):
if ext not in (".jpg", ".jpeg", ".png", ".gif", ".webp"):
ext = ".jpg"
# Slack private URLs require the bot token as auth header
cached = await self._download_slack_file(url, ext)
cached = await self._download_slack_file(url, ext, team_id=team_id)
media_urls.append(cached)
media_types.append(mimetype)
msg_type = MessageType.PHOTO
@@ -711,7 +769,7 @@ class SlackAdapter(BasePlatformAdapter):
ext = "." + mimetype.split("/")[-1].split(";")[0]
if ext not in (".ogg", ".mp3", ".wav", ".webm", ".m4a"):
ext = ".ogg"
cached = await self._download_slack_file(url, ext, audio=True)
cached = await self._download_slack_file(url, ext, audio=True, team_id=team_id)
media_urls.append(cached)
media_types.append(mimetype)
msg_type = MessageType.VOICE
@@ -742,7 +800,7 @@ class SlackAdapter(BasePlatformAdapter):
continue
# Download and cache
raw_bytes = await self._download_slack_file_bytes(url)
raw_bytes = await self._download_slack_file_bytes(url, team_id=team_id)
cached_path = cache_document_from_bytes(
raw_bytes, original_filename or f"document{ext}"
)
@@ -771,7 +829,7 @@ class SlackAdapter(BasePlatformAdapter):
logger.warning("[Slack] Failed to cache document from %s: %s", url, e, exc_info=True)
# Resolve user display name (cached after first lookup)
user_name = await self._resolve_user_name(user_id)
user_name = await self._resolve_user_name(user_id, chat_id=channel_id)
# Build source
source = self.build_source(
@@ -808,6 +866,11 @@ class SlackAdapter(BasePlatformAdapter):
text = command.get("text", "").strip()
user_id = command.get("user_id", "")
channel_id = command.get("channel_id", "")
team_id = command.get("team_id", "")
# Track which workspace owns this channel
if team_id and channel_id:
self._channel_team[channel_id] = team_id
# Map subcommands to gateway commands — derived from central registry.
# Also keep "compact" as a Slack-specific alias for /compress.
@@ -839,12 +902,12 @@ class SlackAdapter(BasePlatformAdapter):
await self.handle_message(event)
async def _download_slack_file(self, url: str, ext: str, audio: bool = False) -> str:
async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str:
"""Download a Slack file using the bot token for auth, with retry."""
import asyncio
import httpx
bot_token = self.config.token
bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token
last_exc = None
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
@@ -874,12 +937,12 @@ class SlackAdapter(BasePlatformAdapter):
raise
raise last_exc
async def _download_slack_file_bytes(self, url: str) -> bytes:
async def _download_slack_file_bytes(self, url: str, team_id: str = "") -> bytes:
"""Download a Slack file and return raw bytes, with retry."""
import asyncio
import httpx
bot_token = self.config.token
bot_token = self._team_clients[team_id].token if team_id and team_id in self._team_clients else self.config.token
last_exc = None
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:

View File

@@ -8,6 +8,7 @@ Uses python-telegram-bot library for:
"""
import asyncio
import json
import logging
import os
import re
@@ -122,6 +123,8 @@ class TelegramAdapter(BasePlatformAdapter):
super().__init__(config, Platform.TELEGRAM)
self._app: Optional[Application] = None
self._bot: Optional[Bot] = None
self._webhook_mode: bool = False
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.
@@ -456,7 +459,19 @@ class TelegramAdapter(BasePlatformAdapter):
self._persist_dm_topic_thread_id(int(chat_id), topic_name, thread_id)
async def connect(self) -> bool:
"""Connect to Telegram and start polling for updates."""
"""Connect to Telegram via polling or webhook.
By default, uses long polling (outbound connection to Telegram).
If ``TELEGRAM_WEBHOOK_URL`` is set, starts an HTTP webhook server
instead. Webhook mode is useful for cloud deployments (Fly.io,
Railway) where inbound HTTP can wake a suspended machine.
Env vars for webhook mode::
TELEGRAM_WEBHOOK_URL Public HTTPS URL (e.g. https://app.fly.dev/telegram)
TELEGRAM_WEBHOOK_PORT Local listen port (default 8443)
TELEGRAM_WEBHOOK_SECRET Secret token for update verification
"""
if not TELEGRAM_AVAILABLE:
logger.error(
"[%s] python-telegram-bot not installed. Run: pip install python-telegram-bot",
@@ -550,37 +565,76 @@ class TelegramAdapter(BasePlatformAdapter):
else:
raise
await self._app.start()
loop = asyncio.get_running_loop()
def _polling_error_callback(error: Exception) -> None:
if self._polling_error_task and not self._polling_error_task.done():
return
if self._looks_like_polling_conflict(error):
self._polling_error_task = loop.create_task(self._handle_polling_conflict(error))
elif self._looks_like_network_error(error):
logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error)
self._polling_error_task = loop.create_task(self._handle_polling_network_error(error))
else:
logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True)
# Decide between webhook and polling mode
webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip()
# Store reference for retry use in _handle_polling_conflict
self._polling_error_callback_ref = _polling_error_callback
if webhook_url:
# ── Webhook mode ─────────────────────────────────────
# Telegram pushes updates to our HTTP endpoint. This
# enables cloud platforms (Fly.io, Railway) to auto-wake
# suspended machines on inbound HTTP traffic.
webhook_port = int(os.getenv("TELEGRAM_WEBHOOK_PORT", "8443"))
webhook_secret = os.getenv("TELEGRAM_WEBHOOK_SECRET", "").strip() or None
from urllib.parse import urlparse
webhook_path = urlparse(webhook_url).path or "/telegram"
await self._app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=True,
error_callback=_polling_error_callback,
)
await self._app.updater.start_webhook(
listen="0.0.0.0",
port=webhook_port,
url_path=webhook_path,
webhook_url=webhook_url,
secret_token=webhook_secret,
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=True,
)
self._webhook_mode = True
logger.info(
"[%s] Webhook server listening on 0.0.0.0:%d%s",
self.name, webhook_port, webhook_path,
)
else:
# ── Polling mode (default) ───────────────────────────
loop = asyncio.get_running_loop()
def _polling_error_callback(error: Exception) -> None:
if self._polling_error_task and not self._polling_error_task.done():
return
if self._looks_like_polling_conflict(error):
self._polling_error_task = loop.create_task(self._handle_polling_conflict(error))
elif self._looks_like_network_error(error):
logger.warning("[%s] Telegram network error, scheduling reconnect: %s", self.name, error)
self._polling_error_task = loop.create_task(self._handle_polling_network_error(error))
else:
logger.error("[%s] Telegram polling error: %s", self.name, error, exc_info=True)
# Store reference for retry use in _handle_polling_conflict
self._polling_error_callback_ref = _polling_error_callback
await self._app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=True,
error_callback=_polling_error_callback,
)
# Register bot commands so Telegram shows a hint menu when users type /
# List is derived from the central COMMAND_REGISTRY — adding a new
# gateway command there automatically adds it to the Telegram menu.
try:
from telegram import BotCommand
from hermes_cli.commands import telegram_bot_commands
from hermes_cli.commands import telegram_menu_commands
# Telegram allows up to 100 commands but has an undocumented
# payload size limit. Skill descriptions are truncated to 40
# chars in telegram_menu_commands() to fit 100 commands safely.
menu_commands, hidden_count = telegram_menu_commands(max_commands=100)
await self._bot.set_my_commands([
BotCommand(name, desc) for name, desc in telegram_bot_commands()
BotCommand(name, desc) for name, desc in menu_commands
])
if hidden_count:
logger.info(
"[%s] Telegram menu: %d commands registered, %d hidden (over 100 limit). Use /commands for full list.",
self.name, len(menu_commands), hidden_count,
)
except Exception as e:
logger.warning(
"[%s] Could not register Telegram command menu: %s",
@@ -590,7 +644,8 @@ class TelegramAdapter(BasePlatformAdapter):
)
self._mark_connected()
logger.info("[%s] Connected and polling for Telegram updates", self.name)
mode = "webhook" if self._webhook_mode else "polling"
logger.info("[%s] Connected to Telegram (%s mode)", self.name, mode)
# Set up DM topics (Bot API 9.4 — Private Chat Topics)
# Runs after connection is established so the bot can call createForumTopic.
@@ -618,7 +673,7 @@ class TelegramAdapter(BasePlatformAdapter):
return False
async def disconnect(self) -> None:
"""Stop polling, cancel pending album flushes, and disconnect."""
"""Stop polling/webhook, cancel pending album flushes, and disconnect."""
pending_media_group_tasks = list(self._media_group_tasks.values())
for task in pending_media_group_tasks:
task.cancel()
@@ -1325,6 +1380,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 +1531,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 +1552,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 +1697,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 +1722,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:

View File

@@ -301,6 +301,50 @@ def _resolve_runtime_agent_kwargs() -> dict:
}
def _check_unavailable_skill(command_name: str) -> str | None:
"""Check if a command matches a known-but-inactive skill.
Returns a helpful message if the skill exists but is disabled or only
available as an optional install. Returns None if no match found.
"""
# Normalize: command uses hyphens, skill names may use hyphens or underscores
normalized = command_name.lower().replace("_", "-")
try:
from tools.skills_tool import SKILLS_DIR, _get_disabled_skill_names
disabled = _get_disabled_skill_names()
# Check disabled built-in skills
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
continue
name = skill_md.parent.name.lower().replace("_", "-")
if name == normalized and name in disabled:
return (
f"The **{command_name}** skill is installed but disabled.\n"
f"Enable it with: `hermes skills config`"
)
# Check optional skills (shipped with repo but not installed)
from hermes_constants import get_hermes_home
repo_root = Path(__file__).resolve().parent.parent
optional_dir = repo_root / "optional-skills"
if optional_dir.exists():
for skill_md in optional_dir.rglob("SKILL.md"):
name = skill_md.parent.name.lower().replace("_", "-")
if name == normalized:
# Build install path: official/<category>/<name>
rel = skill_md.parent.relative_to(optional_dir)
parts = list(rel.parts)
install_path = f"official/{'/'.join(parts)}"
return (
f"The **{command_name}** skill is available but not installed.\n"
f"Install it with: `hermes skills install {install_path}`"
)
except Exception:
pass
return None
def _platform_config_key(platform: "Platform") -> str:
"""Map a Platform enum to its config.yaml key (LOCAL→"cli", rest→enum value)."""
return "cli" if platform == Platform.LOCAL else platform.value
@@ -432,6 +476,13 @@ class GatewayRunner:
self._honcho_managers: Dict[str, Any] = {}
self._honcho_configs: Dict[str, Any] = {}
# Rate-limit compression warning messages sent to users.
# Keyed by chat_id — value is the timestamp of the last warning sent.
# Prevents the warning from firing on every message when a session
# remains above the threshold after compression.
self._compression_warn_sent: Dict[str, float] = {}
self._compression_warn_cooldown: int = 3600 # seconds (1 hour)
# Ensure tirith security scanner is available (downloads if needed)
try:
from tools.tirith_security import ensure_installed
@@ -1651,6 +1702,11 @@ class GatewayRunner:
# In DMs: offer pairing code. In groups: silently ignore.
if source.chat_type == "dm" and self._get_unauthorized_dm_behavior(source.platform) == "pair":
platform_name = source.platform.value if source.platform else "unknown"
# Rate-limit ALL pairing responses (code or rejection) to
# prevent spamming the user with repeated messages when
# multiple DMs arrive in quick succession.
if self.pairing_store._is_rate_limited(platform_name, source.user_id):
return None
code = self.pairing_store.generate_code(
platform_name, source.user_id, source.user_name or ""
)
@@ -1672,6 +1728,8 @@ class GatewayRunner:
"Too many pairing requests right now~ "
"Please try again later!"
)
# Record rate limit so subsequent messages are silently ignored
self.pairing_store._record_rate_limit(platform_name, source.user_id)
return None
# PRIORITY handling when an agent is already running for this session.
@@ -1817,7 +1875,13 @@ class GatewayRunner:
if canonical == "help":
return await self._handle_help_command(event)
if canonical == "commands":
return await self._handle_commands_command(event)
if canonical == "profile":
return await self._handle_profile_command(event)
if canonical == "status":
return await self._handle_status_command(event)
@@ -1830,6 +1894,9 @@ class GatewayRunner:
if canonical == "verbose":
return await self._handle_verbose_command(event)
if canonical == "yolo":
return await self._handle_yolo_command(event)
if canonical == "provider":
return await self._handle_provider_command(event)
@@ -1974,6 +2041,12 @@ class GatewayRunner:
if msg:
event.text = msg
# Fall through to normal message processing with skill content
else:
# Not an active skill — check if it's a known-but-disabled or
# uninstalled skill and give actionable guidance.
_unavail_msg = _check_unavailable_skill(command)
if _unavail_msg:
return _unavail_msg
except Exception as e:
logger.debug("Skill command check failed (non-fatal): %s", e)
@@ -2344,13 +2417,18 @@ class GatewayRunner:
pass
# Still too large after compression — warn user
# Rate-limited to once per cooldown period per
# chat to avoid spamming on every message.
if _new_tokens >= _warn_token_threshold:
logger.warning(
"Session hygiene: still ~%s tokens after "
"compression — suggesting /reset",
f"{_new_tokens:,}",
)
if _hyg_adapter:
_now = time.time()
_last_warn = self._compression_warn_sent.get(source.chat_id, 0)
if _hyg_adapter and _now - _last_warn >= self._compression_warn_cooldown:
self._compression_warn_sent[source.chat_id] = _now
try:
await _hyg_adapter.send(
source.chat_id,
@@ -2372,7 +2450,10 @@ class GatewayRunner:
if _approx_tokens >= _warn_token_threshold:
_hyg_adapter = self.adapters.get(source.platform)
_hyg_meta = {"thread_id": source.thread_id} if source.thread_id else None
if _hyg_adapter:
_now = time.time()
_last_warn = self._compression_warn_sent.get(source.chat_id, 0)
if _hyg_adapter and _now - _last_warn >= self._compression_warn_cooldown:
self._compression_warn_sent[source.chat_id] = _now
try:
await _hyg_adapter.send(
source.chat_id,
@@ -2999,6 +3080,36 @@ class GatewayRunner:
return f"{header}\n\n{session_info}"
return header
async def _handle_profile_command(self, event: MessageEvent) -> str:
"""Handle /profile — show active profile name and home directory."""
from hermes_constants import get_hermes_home, display_hermes_home
from pathlib import Path
home = get_hermes_home()
display = display_hermes_home()
# Detect profile name from HERMES_HOME path
# Profile paths look like: ~/.hermes/profiles/<name>
profiles_parent = Path.home() / ".hermes" / "profiles"
try:
rel = home.relative_to(profiles_parent)
profile_name = str(rel).split("/")[0]
except ValueError:
profile_name = None
if profile_name:
lines = [
f"👤 **Profile:** `{profile_name}`",
f"📂 **Home:** `{display}`",
]
else:
lines = [
"👤 **Profile:** default",
f"📂 **Home:** `{display}`",
]
return "\n".join(lines)
async def _handle_status_command(self, event: MessageEvent) -> str:
"""Handle /status command."""
source = event.source
@@ -3065,12 +3176,69 @@ class GatewayRunner:
from agent.skill_commands import get_skill_commands
skill_cmds = get_skill_commands()
if skill_cmds:
lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} installed):")
for cmd in sorted(skill_cmds):
lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} active):")
# Show first 10, then point to /commands for the rest
sorted_cmds = sorted(skill_cmds)
for cmd in sorted_cmds[:10]:
lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}")
if len(sorted_cmds) > 10:
lines.append(f"\n... and {len(sorted_cmds) - 10} more. Use `/commands` for the full paginated list.")
except Exception:
pass
return "\n".join(lines)
async def _handle_commands_command(self, event: MessageEvent) -> str:
"""Handle /commands [page] - paginated list of all commands and skills."""
from hermes_cli.commands import gateway_help_lines
raw_args = event.get_command_args().strip()
if raw_args:
try:
requested_page = int(raw_args)
except ValueError:
return "Usage: `/commands [page]`"
else:
requested_page = 1
# Build combined entry list: built-in commands + skill commands
entries = list(gateway_help_lines())
try:
from agent.skill_commands import get_skill_commands
skill_cmds = get_skill_commands()
if skill_cmds:
entries.append("")
entries.append("⚡ **Skill Commands**:")
for cmd in sorted(skill_cmds):
desc = skill_cmds[cmd].get("description", "").strip() or "Skill command"
entries.append(f"`{cmd}` — {desc}")
except Exception:
pass
if not entries:
return "No commands available."
from gateway.config import Platform
page_size = 15 if event.source.platform == Platform.TELEGRAM else 20
total_pages = max(1, (len(entries) + page_size - 1) // page_size)
page = max(1, min(requested_page, total_pages))
start = (page - 1) * page_size
page_entries = entries[start:start + page_size]
lines = [
f"📚 **Commands** ({len(entries)} total, page {page}/{total_pages})",
"",
*page_entries,
]
if total_pages > 1:
nav_parts = []
if page > 1:
nav_parts.append(f"`/commands {page - 1}` ← prev")
if page < total_pages:
nav_parts.append(f"next → `/commands {page + 1}`")
lines.extend(["", " | ".join(nav_parts)])
if page != requested_page:
lines.append(f"_(Requested page {requested_page} was out of range, showing page {page}.)_")
return "\n".join(lines)
async def _handle_provider_command(self, event: MessageEvent) -> str:
"""Handle /provider command - show available providers."""
@@ -3891,7 +4059,7 @@ class GatewayRunner:
# Send media files
for media_path in (media_files or []):
try:
await adapter.send_file(
await adapter.send_document(
chat_id=source.chat_id,
file_path=media_path,
)
@@ -3999,6 +4167,16 @@ class GatewayRunner:
else:
return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)"
async def _handle_yolo_command(self, event: MessageEvent) -> str:
"""Handle /yolo — toggle dangerous command approval bypass."""
current = bool(os.environ.get("HERMES_YOLO_MODE"))
if current:
os.environ.pop("HERMES_YOLO_MODE", None)
return "⚠️ YOLO mode **OFF** — dangerous commands will require approval."
else:
os.environ["HERMES_YOLO_MODE"] = "1"
return "⚡ YOLO mode **ON** — all commands auto-approved. Use with caution."
async def _handle_verbose_command(self, event: MessageEvent) -> str:
"""Handle /verbose command — cycle tool progress display mode.