Merge commit '6110aed9b' into feat/whatsapp-cloud-api

This commit is contained in:
emozilla
2026-06-10 21:39:22 -04:00
3038 changed files with 499127 additions and 63840 deletions
+437
View File
@@ -0,0 +1,437 @@
"""User-authorization methods for ``GatewayRunner``.
Extracted from ``gateway/run.py`` as part of the god-file decomposition campaign
(``~/.hermes/plans/god-file-decomposition.md``, Phase 3 mechanical mixin lifts).
This mixin holds the inbound-message authorization cluster: whether a user/chat
is allowed to talk to the agent, the per-adapter DM policy, and the
unauthorized-DM behavior.
Behavior-neutral: every method is lifted verbatim from ``GatewayRunner``.
``self.*`` calls resolve unchanged via the MRO. Neutral dependencies import at
module top; the module-level ``logger`` is imported lazily inside the one method
that uses it (``from gateway.run import logger`` resolves at call time, when
``gateway.run`` is fully loaded) so this module never imports ``gateway.run`` at
import time -> no import cycle. The lazy import preserves the exact logger name
(``"gateway.run"``) so log records are unchanged.
"""
from __future__ import annotations
import os
from typing import Optional
from gateway.config import Platform
from gateway.session import SessionSource
from gateway.whatsapp_identity import (
expand_whatsapp_aliases as _expand_whatsapp_auth_aliases,
normalize_whatsapp_identifier as _normalize_whatsapp_identifier,
)
class GatewayAuthorizationMixin:
"""User/chat authorization methods for ``GatewayRunner``."""
def _adapter_enforces_own_access_policy(self, platform: Optional[Platform]) -> bool:
"""Whether the adapter for *platform* gates access at intake itself.
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
message is dispatched to the gateway, so a message that reaches
``_is_user_authorized`` has already been authorized by the adapter.
Defaults to ``False`` when the adapter is unknown or doesn't expose
the flag.
"""
if not platform:
return False
# Some test helpers build a bare GatewayRunner via object.__new__ and
# never set ``adapters``; treat a missing/empty map as "no adapter"
# rather than raising (see pitfalls.md #17).
adapters = getattr(self, "adapters", None)
if not adapters:
return False
adapter = adapters.get(platform)
if adapter is None:
return False
return bool(getattr(adapter, "enforces_own_access_policy", False))
def _adapter_dm_policy(self, platform: Optional[Platform]) -> str:
"""Best-effort read of an own-policy adapter's effective DM policy.
Returns the lowercased ``dm_policy`` (``"open"`` / ``"allowlist"`` /
``"disabled"`` / ``"pairing"``) for *platform*, or ``""`` when unknown.
Prefers the live adapter's resolved ``_dm_policy`` — which already folds
in both ``config.extra`` and the ``<PLATFORM>_DM_POLICY`` env var (the
env var is not always bridged back into ``config.extra``) — and falls
back to ``config.extra`` for bare runners built without a live adapter.
Used by ``_is_user_authorized`` to carve ``dm_policy: pairing`` out of
the adapter-trust shortcut: in pairing mode the adapter forwards the DM
so the gateway can run its pairing handshake, so "reached the gateway"
must not be read as "authorized".
"""
if not platform:
return ""
adapters = getattr(self, "adapters", None) or {}
adapter = adapters.get(platform)
policy = getattr(adapter, "_dm_policy", None) if adapter is not None else None
if policy is None:
config = getattr(self, "config", None)
platform_cfg = (
config.platforms.get(platform)
if config is not None and hasattr(config, "platforms")
else None
)
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
if isinstance(extra, dict):
policy = extra.get("dm_policy")
return str(policy or "").strip().lower()
def _is_user_authorized(self, source: SessionSource) -> bool:
"""
Check if a user is authorized to use the bot.
Checks in order:
1. Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
2. Environment variable allowlists (TELEGRAM_ALLOWED_USERS, etc.)
3. DM pairing approved list
4. Global allow-all (GATEWAY_ALLOW_ALL_USERS=true)
5. Default: deny
"""
from gateway.run import logger
# Home Assistant events are system-generated (state changes), not
# user-initiated messages. The HASS_TOKEN already authenticates the
# connection, so HA events are always authorized.
# Webhook events are authenticated via HMAC signature validation in
# the adapter itself — no user allowlist applies.
if source.platform in {Platform.HOMEASSISTANT, Platform.WEBHOOK}:
return True
user_id = source.user_id
# Telegram (and similar) authorize entire group/forum/channel chats
# by chat ID via TELEGRAM_GROUP_ALLOWED_CHATS / QQ_GROUP_ALLOWED_USERS.
# That allowlist is chat-scoped, so it must work even when
# source.user_id is None — Telegram emits anonymous-admin posts,
# sender_chat traffic, and channel broadcasts with no `from_user`,
# and an operator who explicitly listed the chat expects those to
# be honored. Run this check before the no-user-id guard below so
# documented behavior matches reality
# (website/docs/reference/environment-variables.md,
# website/docs/user-guide/messaging/telegram.md).
if source.chat_type in {"group", "forum", "channel"} and source.chat_id:
chat_allowlist_env = {
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS",
Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS",
}.get(source.platform, "")
if chat_allowlist_env:
raw_chat_allowlist = os.getenv(chat_allowlist_env, "").strip()
if raw_chat_allowlist:
allowed_group_ids = {
cid.strip()
for cid in raw_chat_allowlist.split(",")
if cid.strip()
}
if "*" in allowed_group_ids or source.chat_id in allowed_group_ids:
return True
if not user_id:
return False
platform_env_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOWED_USERS",
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
Platform.SMS: "SMS_ALLOWED_USERS",
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
Platform.WECOM: "WECOM_ALLOWED_USERS",
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
Platform.QQBOT: "QQ_ALLOWED_USERS",
Platform.YUANBAO: "YUANBAO_ALLOWED_USERS",
}
platform_group_user_env_map = {
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS",
}
platform_group_chat_env_map = {
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS",
Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS",
}
platform_allow_all_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS",
Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOW_ALL_USERS",
Platform.SLACK: "SLACK_ALLOW_ALL_USERS",
Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS",
Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS",
Platform.SMS: "SMS_ALLOW_ALL_USERS",
Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS",
Platform.WECOM: "WECOM_ALLOW_ALL_USERS",
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS",
Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS",
Platform.QQBOT: "QQ_ALLOW_ALL_USERS",
Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS",
}
# Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist (#4466).
platform_allow_bots_map = {
Platform.DISCORD: "DISCORD_ALLOW_BOTS",
Platform.FEISHU: "FEISHU_ALLOW_BOTS",
}
# Plugin platforms: check the registry for auth env var names
if source.platform not in platform_env_map:
try:
from gateway.platform_registry import platform_registry
entry = platform_registry.get(source.platform.value)
if entry:
if entry.allowed_users_env:
platform_env_map[source.platform] = entry.allowed_users_env
if entry.allow_all_env:
platform_allow_all_map[source.platform] = entry.allow_all_env
except Exception:
pass
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
platform_allow_all_var = platform_allow_all_map.get(source.platform, "")
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true", "1", "yes"}:
return True
# Adapter-verified role auth: the Discord adapter already confirmed the
# user holds a role in DISCORD_ALLOWED_ROLES before dispatching the message.
# Compare with ``is True`` so the real bool field authorizes while a
# MagicMock source (test fixtures using ``object.__new__`` runners with
# mock sources) does not auto-truthy through this gate (see pitfall #13).
if getattr(source, "role_authorized", False) is True:
return True
if getattr(source, "is_bot", False):
allow_bots_var = platform_allow_bots_map.get(source.platform)
if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}:
return True
# Check pairing store (always checked, regardless of allowlists)
platform_name = source.platform.value if source.platform else ""
if self.pairing_store.is_approved(platform_name, user_id):
return True
# Check platform-specific and global allowlists
platform_allowlist = os.getenv(platform_env_map.get(source.platform, ""), "").strip()
group_user_allowlist = ""
group_chat_allowlist = ""
if source.chat_type in {"group", "forum"}:
group_user_allowlist = os.getenv(platform_group_user_env_map.get(source.platform, ""), "").strip()
group_chat_allowlist = os.getenv(platform_group_chat_env_map.get(source.platform, ""), "").strip()
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
# No env allowlists configured. Adapters that own their own
# config-driven access policy (dm_policy / group_policy /
# allow_from / group_allow_from) already gated this message at
# intake — it would not have reached the gateway otherwise — so
# honor that decision instead of falling through to the
# env-only default-deny below, which would silently break
# `dm_policy: open` and config-only allowlists. (#34515)
if self._adapter_enforces_own_access_policy(source.platform):
# Exception: `dm_policy: pairing` does NOT authorize at intake.
# The adapter forwards the DM precisely so the gateway can run
# its pairing handshake (issue a code, consult the pairing
# store). The pairing-store approval check above already ran and
# returned False for this sender, so blanket-trusting the
# adapter here would silently turn pairing mode into open
# access. Fall through to default-deny so the unpaired sender is
# offered a pairing code instead. (Pairing is DM-only; group
# traffic keeps the adapter-trust path.)
if not (
source.chat_type == "dm"
and self._adapter_dm_policy(source.platform) == "pairing"
):
return True
# No allowlists configured -- check global allow-all flag
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
# Telegram can optionally authorize group traffic by chat ID.
# Keep this separate from TELEGRAM_GROUP_ALLOWED_USERS, which gates
# the sender user ID for group/forum messages.
if group_chat_allowlist and source.chat_type in {"group", "forum"} and source.chat_id:
allowed_group_ids = {
chat_id.strip() for chat_id in group_chat_allowlist.split(",") if chat_id.strip()
}
if "*" in allowed_group_ids or source.chat_id in allowed_group_ids:
return True
# Backward-compat shim for #15027: prior to PR #17686,
# TELEGRAM_GROUP_ALLOWED_USERS was (mis)used as a chat-ID allowlist.
# Values starting with "-" are Telegram chat IDs, not user IDs, so if
# users still have those in TELEGRAM_GROUP_ALLOWED_USERS we honor them
# as chat IDs and warn once. The correct var is now
# TELEGRAM_GROUP_ALLOWED_CHATS.
if (
source.platform == Platform.TELEGRAM
and group_user_allowlist
and source.chat_type in {"group", "forum"}
and source.chat_id
):
legacy_chat_ids = {
v.strip()
for v in group_user_allowlist.split(",")
if v.strip().startswith("-")
}
if legacy_chat_ids:
if not getattr(self, "_warned_telegram_group_users_legacy", False):
logger.warning(
"TELEGRAM_GROUP_ALLOWED_USERS contains chat-ID-shaped values "
"(%s). Treating them as chat IDs for backward compatibility. "
"Move chat IDs to TELEGRAM_GROUP_ALLOWED_CHATS — the _USERS var "
"is now for sender user IDs.",
",".join(sorted(legacy_chat_ids)),
)
self._warned_telegram_group_users_legacy = True
if source.chat_id in legacy_chat_ids:
return True
# Check if user is in any allowlist. In group/forum chats,
# TELEGRAM_GROUP_ALLOWED_USERS is the scoped allowlist and should not
# imply DM access; TELEGRAM_ALLOWED_USERS remains the platform-wide
# allowlist and still works everywhere for backward compatibility.
allowed_ids = set()
if platform_allowlist:
allowed_ids.update(uid.strip() for uid in platform_allowlist.split(",") if uid.strip())
if group_user_allowlist:
allowed_ids.update(uid.strip() for uid in group_user_allowlist.split(",") if uid.strip())
if global_allowlist:
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip())
# "*" in any allowlist means allow everyone (consistent with
# SIGNAL_GROUP_ALLOWED_USERS precedent)
if "*" in allowed_ids:
return True
check_ids = {user_id}
if "@" in user_id:
check_ids.add(user_id.split("@")[0])
# WhatsApp: resolve phone↔LID aliases from bridge session mapping files
if source.platform == Platform.WHATSAPP:
normalized_allowed_ids = set()
for allowed_id in allowed_ids:
normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id))
if normalized_allowed_ids:
allowed_ids = normalized_allowed_ids
check_ids.update(_expand_whatsapp_auth_aliases(user_id))
normalized_user_id = _normalize_whatsapp_identifier(user_id)
if normalized_user_id:
check_ids.add(normalized_user_id)
# SimpleX: SIMPLEX_ALLOWED_USERS accepts either the numeric contactId
# or the contact's display name. The adapter sets user_id=contactId for
# stability across renames, but the SimpleX UI never surfaces the
# numeric id — operators only see display names, so that's what they
# naturally put in the env var. Match both so the allowlist works
# regardless of which form was chosen.
# Plugin platform: compare by value since Platform.SIMPLEX is not a
# hardcoded enum member (it's a dynamic plugin platform).
if (
source.platform is not None
and source.platform.value == "simplex"
and source.user_name
):
check_ids.add(source.user_name)
return bool(check_ids & allowed_ids)
def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
"""Return how unauthorized DMs should be handled for a platform.
Resolution order:
1. Explicit per-platform ``unauthorized_dm_behavior`` in config — always wins.
2. Explicit global ``unauthorized_dm_behavior`` in config — wins when no per-platform.
3. When an allowlist (``PLATFORM_ALLOWED_USERS``,
``PLATFORM_GROUP_ALLOWED_USERS`` / ``PLATFORM_GROUP_ALLOWED_CHATS``,
or ``GATEWAY_ALLOWED_USERS``) is configured, default to ``"ignore"`` —
the allowlist signals that the owner has deliberately restricted
access; spamming unknown contacts with pairing codes is both noisy
and a potential info-leak. (#9337)
4. No allowlist and no explicit config → ``"pair"`` (open-gateway default).
"""
config = getattr(self, "config", None)
# Check for an explicit per-platform override first.
if config and hasattr(config, "get_unauthorized_dm_behavior") and platform:
platform_cfg = config.platforms.get(platform) if hasattr(config, "platforms") else None
if platform_cfg and "unauthorized_dm_behavior" in getattr(platform_cfg, "extra", {}):
# Operator explicitly configured behavior for this platform — respect it.
return config.get_unauthorized_dm_behavior(platform)
# Check for an explicit global config override.
if config and hasattr(config, "unauthorized_dm_behavior"):
if config.unauthorized_dm_behavior != "pair": # non-default → explicit override
return config.unauthorized_dm_behavior
# Config-driven dm_policy (WeCom / Weixin / Yuanbao / QQBot). An
# allowlist or disabled DM policy means the operator restricted access,
# so unauthorized DMs should be dropped silently rather than answered
# with a pairing code. An explicit pairing policy opts back into codes.
if platform and config and hasattr(config, "platforms"):
platform_cfg = config.platforms.get(platform)
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
if isinstance(extra, dict):
dm_policy = str(extra.get("dm_policy") or "").strip().lower()
if dm_policy == "pairing":
return "pair"
if dm_policy in {"allowlist", "disabled"}:
return "ignore"
# No explicit override. Fall back to allowlist-aware default:
# if any allowlist is configured for this platform, silently drop
# unauthorized messages instead of sending pairing codes.
if platform:
platform_env_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
Platform.WHATSAPP_CLOUD: "WHATSAPP_CLOUD_ALLOWED_USERS",
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
Platform.SMS: "SMS_ALLOWED_USERS",
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
Platform.WECOM: "WECOM_ALLOWED_USERS",
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
Platform.QQBOT: "QQ_ALLOWED_USERS",
}
platform_group_env_map = {
Platform.TELEGRAM: (
"TELEGRAM_GROUP_ALLOWED_USERS",
"TELEGRAM_GROUP_ALLOWED_CHATS",
),
Platform.QQBOT: ("QQ_GROUP_ALLOWED_USERS",),
}
if os.getenv(platform_env_map.get(platform, ""), "").strip():
return "ignore"
for env_key in platform_group_env_map.get(platform, ()):
if os.getenv(env_key, "").strip():
return "ignore"
if os.getenv("GATEWAY_ALLOWED_USERS", "").strip():
return "ignore"
return "pair"
+281 -143
View File
@@ -56,6 +56,42 @@ def _coerce_int(value: Any, default: int) -> int:
return default
def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]:
"""Coerce an optional positive integer config value.
``None``/0/negative disable the setting. Malformed values are ignored with
a warning so a typo never prevents the gateway from starting.
"""
if value is None:
return None
if isinstance(value, bool):
logger.warning(
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
key,
value,
)
return None
try:
if isinstance(value, float):
if not value.is_integer():
raise ValueError(value)
parsed = int(value)
elif isinstance(value, str):
parsed = int(value.strip(), 10)
else:
parsed = int(value)
except (TypeError, ValueError):
logger.warning(
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
key,
value,
)
return None
if parsed <= 0:
return None
return parsed
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
"""Normalize unauthorized DM behavior to a supported value."""
if isinstance(value, str):
@@ -362,10 +398,17 @@ class StreamingConfig:
# fall back to edit-based when not.
# "draft" — explicitly request native drafts; falls back to edit when
# the platform/chat doesn't support them.
# "edit" — progressive editMessageText only (legacy/default
# behaviour).
# "edit" — progressive editMessageText only (legacy behaviour).
# "off" — disable streaming entirely.
transport: str = "edit"
#
# Default is "auto": prefer native draft streaming on platforms that
# support it (Telegram DMs via sendMessageDraft, Bot API 9.5+) and fall
# back to edit-based streaming everywhere else. This is safe as a global
# default because adapters without draft support (Discord, Slack, Matrix,
# …) report supports_draft_streaming() == False and transparently use the
# edit path — so "auto" never regresses non-Telegram platforms, it only
# upgrades the chats that can render the smoother native preview.
transport: str = "auto"
edit_interval: float = DEFAULT_STREAMING_EDIT_INTERVAL
buffer_threshold: int = DEFAULT_STREAMING_BUFFER_THRESHOLD
cursor: str = DEFAULT_STREAMING_CURSOR
@@ -394,7 +437,7 @@ class StreamingConfig:
return cls()
return cls(
enabled=_coerce_bool(data.get("enabled"), False),
transport=data.get("transport", "edit"),
transport=data.get("transport", "auto"),
edit_interval=_coerce_float(
data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL,
),
@@ -428,7 +471,9 @@ _PLATFORM_CONNECTED_CHECKERS: dict[Platform, Callable[[PlatformConfig], bool]] =
Platform.SMS: lambda cfg: bool(os.getenv("TWILIO_ACCOUNT_SID")),
Platform.API_SERVER: lambda cfg: True,
Platform.WEBHOOK: lambda cfg: True,
Platform.MSGRAPH_WEBHOOK: lambda cfg: True,
Platform.MSGRAPH_WEBHOOK: lambda cfg: bool(
str(cfg.extra.get("client_state") or "").strip()
),
Platform.FEISHU: lambda cfg: bool(cfg.extra.get("app_id")),
Platform.WECOM: lambda cfg: bool(cfg.extra.get("bot_id")),
Platform.WECOM_CALLBACK: lambda cfg: bool(
@@ -476,6 +521,13 @@ class GatewayConfig:
# Delivery settings
always_log_local: bool = True # Always save cron outputs to local files
# Drop outbound "silence narration" messages (e.g. *(silent)*, 🔇, a bare
# ".") pre-send. These are model hallucinations emitted when a persona has
# nothing actionable to say; in bot-to-bot channels they mirror back and
# forth, burning tokens and crashing models. Substrate-level guard that
# survives SOUL.md/prompt drift across providers. Opt out with False for
# raw passthrough.
filter_silence_narration: bool = True
# STT settings
stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages
@@ -483,6 +535,7 @@ class GatewayConfig:
# Session isolation in shared chats
group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available
thread_sessions_per_user: bool = False # When False (default), threads are shared across all participants
max_concurrent_sessions: Optional[int] = None # Positive int caps simultaneous active chat sessions
# Unauthorized DM policy
unauthorized_dm_behavior: str = "pair" # "pair" or "ignore"
@@ -584,9 +637,11 @@ class GatewayConfig:
"quick_commands": self.quick_commands,
"sessions_dir": str(self.sessions_dir),
"always_log_local": self.always_log_local,
"filter_silence_narration": self.filter_silence_narration,
"stt_enabled": self.stt_enabled,
"group_sessions_per_user": self.group_sessions_per_user,
"thread_sessions_per_user": self.thread_sessions_per_user,
"max_concurrent_sessions": self.max_concurrent_sessions,
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
"streaming": self.streaming.to_dict(),
"session_store_max_age_days": self.session_store_max_age_days,
@@ -632,6 +687,17 @@ class GatewayConfig:
group_sessions_per_user = data.get("group_sessions_per_user")
thread_sessions_per_user = data.get("thread_sessions_per_user")
nested_gateway = data.get("gateway") if isinstance(data.get("gateway"), dict) else {}
if "max_concurrent_sessions" in data:
max_concurrent_raw = data.get("max_concurrent_sessions")
max_concurrent_key = "max_concurrent_sessions"
else:
max_concurrent_raw = nested_gateway.get("max_concurrent_sessions")
max_concurrent_key = "gateway.max_concurrent_sessions"
max_concurrent_sessions = _coerce_optional_positive_int(
max_concurrent_raw,
max_concurrent_key,
)
unauthorized_dm_behavior = _normalize_unauthorized_dm_behavior(
data.get("unauthorized_dm_behavior"),
"pair",
@@ -652,9 +718,13 @@ class GatewayConfig:
quick_commands=quick_commands,
sessions_dir=sessions_dir,
always_log_local=_coerce_bool(data.get("always_log_local"), True),
filter_silence_narration=_coerce_bool(
data.get("filter_silence_narration"), True
),
stt_enabled=_coerce_bool(stt_enabled, True),
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
max_concurrent_sessions=max_concurrent_sessions,
unauthorized_dm_behavior=unauthorized_dm_behavior,
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
session_store_max_age_days=session_store_max_age_days,
@@ -745,6 +815,13 @@ def load_gateway_config() -> GatewayConfig:
if "thread_sessions_per_user" in yaml_cfg:
gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"]
gateway_section = yaml_cfg.get("gateway")
if isinstance(gateway_section, dict) and "max_concurrent_sessions" in gateway_section:
gw_data["max_concurrent_sessions"] = gateway_section["max_concurrent_sessions"]
if "max_concurrent_sessions" in yaml_cfg:
gw_data["max_concurrent_sessions"] = yaml_cfg["max_concurrent_sessions"]
streaming_cfg = yaml_cfg.get("streaming")
if not isinstance(streaming_cfg, dict):
# Fall back to nested gateway.streaming written by
@@ -759,21 +836,32 @@ def load_gateway_config() -> GatewayConfig:
if "always_log_local" in yaml_cfg:
gw_data["always_log_local"] = yaml_cfg["always_log_local"]
if "filter_silence_narration" in yaml_cfg:
gw_data["filter_silence_narration"] = yaml_cfg[
"filter_silence_narration"
]
if "unauthorized_dm_behavior" in yaml_cfg:
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
yaml_cfg.get("unauthorized_dm_behavior"),
"pair",
)
# Merge platforms section from config.yaml into gw_data so that
# nested keys like platforms.webhook.extra.routes are loaded.
yaml_platforms = yaml_cfg.get("platforms")
# Merge platform config into gw_data so runtime-only settings under
# ``gateway.platforms`` are loaded the same way as top-level
# ``platforms``. Merge nested first so top-level config keeps
# precedence, matching the existing gateway.streaming fallback.
gateway_cfg = yaml_cfg.get("gateway")
gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None
platforms_data = gw_data.setdefault("platforms", {})
if not isinstance(platforms_data, dict):
platforms_data = {}
gw_data["platforms"] = platforms_data
if isinstance(yaml_platforms, dict):
for plat_name, plat_block in yaml_platforms.items():
def _merge_platform_map(source_platforms: Any) -> None:
if not isinstance(source_platforms, dict):
return
for plat_name, plat_block in source_platforms.items():
if not isinstance(plat_block, dict):
continue
existing = platforms_data.get(plat_name, {})
@@ -781,12 +869,16 @@ def load_gateway_config() -> GatewayConfig:
existing = {}
# Deep-merge extra dicts so gateway.json defaults survive
merged_extra = {**existing.get("extra", {}), **plat_block.get("extra", {})}
if plat_name == Platform.SLACK.value and "enabled" in plat_block:
if "enabled" in plat_block:
merged_extra["_enabled_explicit"] = True
merged = {**existing, **plat_block}
if merged_extra:
merged["extra"] = merged_extra
platforms_data[plat_name] = merged
_merge_platform_map(gateway_platforms)
_merge_platform_map(yaml_cfg.get("platforms"))
if platforms_data:
gw_data["platforms"] = platforms_data
# Iterate built-in platforms plus any registered plugin platforms
# so plugin authors get the same shared-key bridging (#24836).
@@ -812,6 +904,25 @@ def load_gateway_config() -> GatewayConfig:
if plat == Platform.LOCAL:
continue
platform_cfg = yaml_cfg.get(plat.value)
_cfg_toplevel = isinstance(platform_cfg, dict)
# Fall back to the platform's block under ``platforms`` /
# ``gateway.platforms`` so shared-key bridging (allow_from,
# require_mention, free_response_channels, …) still runs when
# the user configured the platform only under those nested paths
# and not via a top-level block. Mirrors the identical fallback
# already applied to the apply_yaml_config_fn dispatch below
# (#44f3e51).
# Note: ``enabled`` is only written to plat_data from a
# top-level block (``_cfg_toplevel``); for nested-only configs
# ``_merge_platform_map`` already merged it with the correct
# precedence, so re-applying it here would overwrite that.
if not _cfg_toplevel:
for _src in (gateway_platforms, yaml_cfg.get("platforms")):
if isinstance(_src, dict):
_candidate = _src.get(plat.value)
if isinstance(_candidate, dict):
platform_cfg = _candidate
break
if not isinstance(platform_cfg, dict):
continue
# Collect bridgeable keys from this platform section
@@ -872,7 +983,7 @@ def load_gateway_config() -> GatewayConfig:
bridged["channel_prompts"] = channel_prompts
if "gateway_restart_notification" in platform_cfg:
bridged["gateway_restart_notification"] = platform_cfg["gateway_restart_notification"]
enabled_was_explicit = "enabled" in platform_cfg
enabled_was_explicit = _cfg_toplevel and "enabled" in platform_cfg
if not bridged and not enabled_was_explicit:
continue
plat_data, extra = _ensure_platform_extra_dict(platforms_data, plat.value)
@@ -892,6 +1003,18 @@ def load_gateway_config() -> GatewayConfig:
if entry.apply_yaml_config_fn is None:
continue
platform_cfg = yaml_cfg.get(entry.name)
# Fall back to the platform's block under ``platforms`` /
# ``gateway.platforms`` so adapter hooks still run when the
# user configured the platform only under those nested paths
# (e.g. ``platforms.discord.extra.allow_from``) and not via a
# top-level ``discord:`` block.
if not isinstance(platform_cfg, dict):
for _src in (gateway_platforms, yaml_cfg.get("platforms")):
if isinstance(_src, dict):
_candidate = _src.get(entry.name)
if isinstance(_candidate, dict):
platform_cfg = _candidate
break
if not isinstance(platform_cfg, dict):
continue
try:
@@ -930,73 +1053,6 @@ def load_gateway_config() -> GatewayConfig:
ac = ",".join(str(v) for v in ac)
os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac)
# Discord settings → env vars (env vars take precedence)
discord_cfg = yaml_cfg.get("discord", {})
if isinstance(discord_cfg, dict):
if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
frc = discord_cfg.get("free_response_channels")
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
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()
if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
# ignored_channels: channels where bot never responds (even when mentioned)
ic = discord_cfg.get("ignored_channels")
if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"):
if isinstance(ic, list):
ic = ",".join(str(v) for v in ic)
os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
ac = discord_cfg.get("allowed_channels")
if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"):
if isinstance(ac, list):
ac = ",".join(str(v) for v in ac)
os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac)
# no_thread_channels: channels where bot responds directly without creating thread
ntc = discord_cfg.get("no_thread_channels")
if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
if isinstance(ntc, list):
ntc = ",".join(str(v) for v in ntc)
os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
# history_backfill: recover missed channel messages for shared sessions
# when require_mention is active. Fetches messages between bot turns
# and prepends them to the user message for context.
if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"):
os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower()
hbl = discord_cfg.get("history_backfill_limit")
if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"):
os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl)
# allow_mentions: granular control over what the bot can ping.
# Safe defaults (no @everyone/roles) are applied in the adapter;
# these YAML keys only override when set and let users opt back
# into unsafe modes (e.g. roles=true) if they actually want it.
allow_mentions_cfg = discord_cfg.get("allow_mentions")
if isinstance(allow_mentions_cfg, dict):
for yaml_key, env_key in (
("everyone", "DISCORD_ALLOW_MENTION_EVERYONE"),
("roles", "DISCORD_ALLOW_MENTION_ROLES"),
("users", "DISCORD_ALLOW_MENTION_USERS"),
("replied_user", "DISCORD_ALLOW_MENTION_REPLIED_USER"),
):
if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()
# reply_to_mode: top-level preferred, falls back to extra.reply_to_mode
# YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
_discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {}
_discord_rtm = (
discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg
else _discord_extra.get("reply_to_mode")
)
if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"):
_rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower()
os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str
# Bridge top-level require_mention to Telegram when the telegram: section
# does not already provide one. Users often write "require_mention: true"
# at the top level alongside group_sessions_per_user, expecting it to work
@@ -1158,22 +1214,8 @@ def load_gateway_config() -> GatewayConfig:
allowed = ",".join(str(v) for v in allowed)
os.environ["DINGTALK_ALLOWED_USERS"] = str(allowed)
# Mattermost settings → env vars (env vars take precedence)
mattermost_cfg = yaml_cfg.get("mattermost", {})
if isinstance(mattermost_cfg, dict):
if "require_mention" in mattermost_cfg and not os.getenv("MATTERMOST_REQUIRE_MENTION"):
os.environ["MATTERMOST_REQUIRE_MENTION"] = str(mattermost_cfg["require_mention"]).lower()
frc = mattermost_cfg.get("free_response_channels")
if frc is not None and not os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS"):
if isinstance(frc, list):
frc = ",".join(str(v) for v in frc)
os.environ["MATTERMOST_FREE_RESPONSE_CHANNELS"] = str(frc)
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
ac = mattermost_cfg.get("allowed_channels")
if ac is not None and not os.getenv("MATTERMOST_ALLOWED_CHANNELS"):
if isinstance(ac, list):
ac = ",".join(str(v) for v in ac)
os.environ["MATTERMOST_ALLOWED_CHANNELS"] = str(ac)
# Mattermost config bridge moved into plugins/platforms/mattermost/
# adapter.py::_apply_yaml_config — see #25443 (apply_yaml_config_fn).
# Matrix settings → env vars (env vars take precedence)
matrix_cfg = yaml_cfg.get("matrix", {})
@@ -1292,14 +1334,23 @@ def _validate_gateway_config(config: "GatewayConfig") -> None:
def _apply_env_overrides(config: GatewayConfig) -> None:
"""Apply environment variable overrides to config."""
def _enable_from_env(platform: Platform) -> PlatformConfig:
if platform not in config.platforms:
config.platforms[platform] = PlatformConfig(enabled=True)
return config.platforms[platform]
platform_config = config.platforms[platform]
enabled_was_explicit = bool(platform_config.extra.pop("_enabled_explicit", False))
if not platform_config.enabled and not enabled_was_explicit:
platform_config.enabled = True
return platform_config
# Telegram
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
if telegram_token:
if Platform.TELEGRAM not in config.platforms:
config.platforms[Platform.TELEGRAM] = PlatformConfig()
config.platforms[Platform.TELEGRAM].enabled = True
config.platforms[Platform.TELEGRAM].token = telegram_token
telegram_config = _enable_from_env(Platform.TELEGRAM)
telegram_config.token = telegram_token
# Reply threading mode for Telegram (off/first/all)
telegram_reply_mode = os.getenv("TELEGRAM_REPLY_TO_MODE", "").lower()
@@ -1328,10 +1379,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
# Discord
discord_token = os.getenv("DISCORD_BOT_TOKEN")
if discord_token:
if Platform.DISCORD not in config.platforms:
config.platforms[Platform.DISCORD] = PlatformConfig()
config.platforms[Platform.DISCORD].enabled = True
config.platforms[Platform.DISCORD].token = discord_token
discord_config = _enable_from_env(Platform.DISCORD)
discord_config.token = discord_token
discord_home = os.getenv("DISCORD_HOME_CHANNEL")
if discord_home and Platform.DISCORD in config.platforms:
@@ -1458,10 +1507,8 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
signal_url = os.getenv("SIGNAL_HTTP_URL")
signal_account = os.getenv("SIGNAL_ACCOUNT")
if signal_url and signal_account:
if Platform.SIGNAL not in config.platforms:
config.platforms[Platform.SIGNAL] = PlatformConfig()
config.platforms[Platform.SIGNAL].enabled = True
config.platforms[Platform.SIGNAL].extra.update({
signal_config = _enable_from_env(Platform.SIGNAL)
signal_config.extra.update({
"http_url": signal_url,
"account": signal_account,
"ignore_stories": os.getenv("SIGNAL_IGNORE_STORIES", "true").lower() in {"true", "1", "yes"},
@@ -1481,11 +1528,9 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
mattermost_url = os.getenv("MATTERMOST_URL", "")
if not mattermost_url:
logger.warning("MATTERMOST_TOKEN set but MATTERMOST_URL is missing")
if Platform.MATTERMOST not in config.platforms:
config.platforms[Platform.MATTERMOST] = PlatformConfig()
config.platforms[Platform.MATTERMOST].enabled = True
config.platforms[Platform.MATTERMOST].token = mattermost_token
config.platforms[Platform.MATTERMOST].extra["url"] = mattermost_url
mattermost_config = _enable_from_env(Platform.MATTERMOST)
mattermost_config.token = mattermost_token
mattermost_config.extra["url"] = mattermost_url
mattermost_home = os.getenv("MATTERMOST_HOME_CHANNEL")
if mattermost_home and Platform.MATTERMOST in config.platforms:
config.platforms[Platform.MATTERMOST].home_channel = HomeChannel(
@@ -1501,23 +1546,21 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
if matrix_token or os.getenv("MATRIX_PASSWORD"):
if not matrix_homeserver:
logger.warning("MATRIX_ACCESS_TOKEN/MATRIX_PASSWORD set but MATRIX_HOMESERVER is missing")
if Platform.MATRIX not in config.platforms:
config.platforms[Platform.MATRIX] = PlatformConfig()
config.platforms[Platform.MATRIX].enabled = True
matrix_config = _enable_from_env(Platform.MATRIX)
if matrix_token:
config.platforms[Platform.MATRIX].token = matrix_token
config.platforms[Platform.MATRIX].extra["homeserver"] = matrix_homeserver
matrix_config.token = matrix_token
matrix_config.extra["homeserver"] = matrix_homeserver
matrix_user = os.getenv("MATRIX_USER_ID", "")
if matrix_user:
config.platforms[Platform.MATRIX].extra["user_id"] = matrix_user
matrix_config.extra["user_id"] = matrix_user
matrix_password = os.getenv("MATRIX_PASSWORD", "")
if matrix_password:
config.platforms[Platform.MATRIX].extra["password"] = matrix_password
matrix_config.extra["password"] = matrix_password
matrix_e2ee = os.getenv("MATRIX_ENCRYPTION", "").lower() in {"true", "1", "yes"}
config.platforms[Platform.MATRIX].extra["encryption"] = matrix_e2ee
matrix_config.extra["encryption"] = matrix_e2ee
matrix_device_id = os.getenv("MATRIX_DEVICE_ID", "")
if matrix_device_id:
config.platforms[Platform.MATRIX].extra["device_id"] = matrix_device_id
matrix_config.extra["device_id"] = matrix_device_id
matrix_home = os.getenv("MATRIX_HOME_ROOM")
if matrix_home and Platform.MATRIX in config.platforms:
config.platforms[Platform.MATRIX].home_channel = HomeChannel(
@@ -1821,6 +1864,22 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
"webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"),
"send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in {"true", "1", "yes"},
})
bluebubbles_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
if bluebubbles_require_mention is not None:
config.platforms[Platform.BLUEBUBBLES].extra["require_mention"] = (
bluebubbles_require_mention.lower() in {"true", "1", "yes", "on"}
)
bluebubbles_mention_patterns = os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
if bluebubbles_mention_patterns:
try:
parsed_patterns = json.loads(bluebubbles_mention_patterns)
except Exception:
parsed_patterns = [
part.strip()
for part in bluebubbles_mention_patterns.replace("\n", ",").split(",")
if part.strip()
]
config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = parsed_patterns
bluebubbles_home = os.getenv("BLUEBUBBLES_HOME_CHANNEL")
if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms:
config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel(
@@ -1937,6 +1996,17 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
# need to seed ``PlatformConfig.extra`` from env vars (e.g. Google Chat's
# project_id / subscription_name) can supply ``env_enablement_fn`` on
# their PlatformEntry — called here BEFORE adapter construction.
#
# Enablement gate (#31116): when a plugin registers ``is_connected``
# (the "has the user actually configured credentials for this?" check),
# we MUST consult it before flipping ``enabled = True``. Otherwise
# ``check_fn`` alone — which for adapter plugins typically just
# verifies the SDK is importable / lazy-installs it — silently enables
# platforms the user never opted into, and the gateway then tries to
# connect to Discord / Teams / Google Chat with no token and emits
# noisy retry-forever errors. ``_platform_status`` was already fixed
# for the same bug class in commit 7849a3d73; this is the runtime
# counterpart.
try:
from hermes_cli.plugins import discover_plugins
discover_plugins() # idempotent
@@ -1949,34 +2019,102 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
logger.debug("check_fn for %s raised: %s", entry.name, e)
continue
platform = Platform(entry.name)
if platform not in config.platforms:
config.platforms[platform] = PlatformConfig()
config.platforms[platform].enabled = True
# Seed extras from env if the plugin opted in.
existing_cfg = config.platforms.get(platform)
# Seed candidate extras from ``env_enablement_fn`` so plugins
# whose ``is_connected`` reads ``config.extra`` (e.g. Google
# Chat's ``_is_connected`` checks ``config.extra["project_id"]``)
# see the same state they will after enablement. Without this,
# Google-Chat-on-env-vars-only setups silently fail the gate
# below even though the user is configured. Plugins whose
# ``is_connected`` reads env vars directly (Discord, IRC,
# Teams, LINE, ntfy, Simplex) are unaffected; this only
# restores Google Chat.
seed_for_probe = None
if entry.env_enablement_fn is not None:
try:
seed = entry.env_enablement_fn()
seed_for_probe = entry.env_enablement_fn()
except Exception as e:
logger.debug(
"env_enablement_fn for %s raised: %s", entry.name, e
)
seed = None
if isinstance(seed, dict) and seed:
# Extract the home_channel dict (if provided) so we wire it
# up as a proper HomeChannel dataclass. Everything else is
# merged into ``extra``.
home = seed.pop("home_channel", None)
config.platforms[platform].extra.update(seed)
if isinstance(home, dict) and home.get("chat_id"):
config.platforms[platform].home_channel = HomeChannel(
platform=platform,
chat_id=str(home["chat_id"]),
name=str(home.get("name") or "Home"),
thread_id=(
str(home["thread_id"])
if home.get("thread_id")
else None
),
seed_for_probe = None
# Only consult is_connected for platforms that are NOT already
# explicitly configured in YAML / env (existing_cfg with
# enabled=True means the user wrote it themselves or another
# env-var bridge enabled it — keep that decision).
if existing_cfg is None or not existing_cfg.enabled:
if entry.is_connected is not None:
try:
# Probe with ``enabled=True`` since we're asking
# "would this plugin BE configured if we enabled
# it?" not "is it currently enabled?". Google
# Chat's ``_is_connected`` short-circuits on
# ``config.enabled`` being False, which on the
# default ``PlatformConfig()`` would fail the
# gate even with proper env vars set.
if existing_cfg is not None:
probe_cfg = existing_cfg
if not probe_cfg.enabled:
probe_cfg = PlatformConfig(
enabled=True,
extra=dict(probe_cfg.extra or {}),
)
else:
probe_cfg = PlatformConfig(enabled=True)
if isinstance(seed_for_probe, dict) and seed_for_probe:
# Don't mutate ``existing_cfg``; the probe gets
# a transient view with env-seeded extras layered
# on top of whatever's already there.
probe_extra = dict(getattr(probe_cfg, "extra", {}) or {})
for k, v in seed_for_probe.items():
if k == "home_channel":
continue
probe_extra.setdefault(k, v)
probe_cfg = PlatformConfig(
enabled=True,
extra=probe_extra,
)
configured = bool(entry.is_connected(probe_cfg))
except Exception as exc:
logger.debug(
"is_connected for %s raised: %s — skipping enablement",
entry.name, exc,
)
configured = False
if not configured:
logger.debug(
"Plugin platform '%s' available but not configured "
"(is_connected returned False) — skipping enable",
entry.name,
)
continue
if platform not in config.platforms:
config.platforms[platform] = PlatformConfig()
config.platforms[platform].enabled = True
# Commit env-seeded extras onto the now-enabled platform.
# We've already called ``env_enablement_fn`` above (for the
# probe); reuse that result instead of calling it twice.
if isinstance(seed_for_probe, dict) and seed_for_probe:
seed = dict(seed_for_probe)
# Extract the home_channel dict (if provided) so we wire it
# up as a proper HomeChannel dataclass. Everything else is
# merged into ``extra``.
home = seed.pop("home_channel", None)
config.platforms[platform].extra.update(seed)
if isinstance(home, dict) and home.get("chat_id"):
config.platforms[platform].home_channel = HomeChannel(
platform=platform,
chat_id=str(home["chat_id"]),
name=str(home.get("name") or "Home"),
thread_id=(
str(home["thread_id"])
if home.get("thread_id")
else None
),
)
except Exception as e:
logger.debug("Plugin platform enable pass failed: %s", e)
for platform_config in config.platforms.values():
platform_config.extra.pop("_enabled_explicit", None)
+178 -3
View File
@@ -9,6 +9,8 @@ Routes messages to the appropriate destination based on:
"""
import logging
import os
import re
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass
@@ -21,10 +23,74 @@ logger = logging.getLogger(__name__)
MAX_PLATFORM_OUTPUT = 4000
TRUNCATED_VISIBLE = 3800
# Matches strings that are *only* a "silence" narration with optional markdown
# wrappers. Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent,
# 🔇, a bare ".", "…", and the whitespace/marker-padded variants seen in the
# wild. Anchored to start/end so substantive messages that merely *contain* the
# word "silent" are never matched.
_SILENCE_NARRATION = re.compile(
r'^[\s*_~`]*\(?\s*(silent|silence|no\s+response|no\s+reply)\s*\.?\)?[\s*_~`]*$'
r'|^[\s*_~`]*[\U0001F507\.\u2026]+[\s*_~`]*$',
re.IGNORECASE,
)
def _is_silence_narration(content: Optional[str]) -> bool:
"""Return True when ``content`` is *only* a silence-narration token.
Length-guarded (real messages are longer) and anchored to the whole string
so legitimate prose like "The deployment ran silently" or "Silence is
golden — here is the plan..." is never flagged.
"""
if not content:
return False
stripped = content.strip()
if not stripped or len(stripped) > 64: # length guard
return False
return bool(_SILENCE_NARRATION.match(stripped))
from .config import Platform, GatewayConfig
from .session import SessionSource
def _looks_like_telegram_private_chat_id(chat_id: Optional[str]) -> bool:
if chat_id is None:
return False
try:
return int(chat_id) > 0
except (TypeError, ValueError):
return False
def _looks_like_int(value: Optional[str]) -> bool:
if value is None:
return False
try:
int(value)
return True
except (TypeError, ValueError):
return False
def _send_result_failed(result: Any) -> bool:
if isinstance(result, dict):
return result.get("success") is False
return getattr(result, "success", True) is False
def _send_result_error(result: Any) -> Optional[str]:
if isinstance(result, dict):
error = result.get("error")
else:
error = getattr(result, "error", None)
return str(error) if error else None
def _is_thread_not_found_delivery_error(result: Any) -> bool:
error = _send_result_error(result)
return bool(error and "thread not found" in error.lower())
@dataclass
class DeliveryTarget:
"""
@@ -223,6 +289,18 @@ class DeliveryRouter:
path.write_text(content)
return path
def _filter_silence_narration_enabled(self) -> bool:
"""Whether the outbound silence-narration filter is active.
``HERMES_FILTER_SILENCE_NARRATION`` env var overrides config when set;
otherwise the ``gateway.filter_silence_narration`` config flag wins
(default True).
"""
env = os.getenv("HERMES_FILTER_SILENCE_NARRATION")
if env is not None:
return env.strip().lower() in ("1", "true", "yes", "on")
return bool(getattr(self.config, "filter_silence_narration", True))
async def _deliver_to_platform(
self,
target: DeliveryTarget,
@@ -248,10 +326,107 @@ class DeliveryRouter:
+ f"\n\n... [truncated, full output saved to {saved_path}]"
)
# Substrate-level anti-loop guard: drop hallucinated "silence narration"
# (*(silent)*, 🔇, a bare ".", etc.) before it ever reaches the adapter.
# In bot-to-bot channels these tokens mirror back and forth until a
# model crashes with "no content after all retries". Behavioral prompt
# rules drift across providers; this single chokepoint covers every
# platform adapter regardless of which persona's prompt failed.
# Local/file delivery (_deliver_local) is a separate path and is never
# filtered — saved silence has no loop risk.
if self._filter_silence_narration_enabled() and _is_silence_narration(content):
logger.warning(
"Dropped silence-narration outbound to %s (chat=%s): %r",
target.platform.value,
target.chat_id,
content[:40],
)
return {
"success": True,
"filtered": "silence_narration",
"delivered": False,
}
send_metadata = dict(metadata or {})
if target.thread_id and "thread_id" not in send_metadata:
send_metadata["thread_id"] = target.thread_id
return await adapter.send(target.chat_id, content, metadata=send_metadata or None)
is_named_telegram_private_topic = False
named_telegram_private_topic_name: Optional[str] = None
if target.thread_id:
has_explicit_direct_topic = (
"direct_messages_topic_id" in send_metadata
or "telegram_direct_messages_topic_id" in send_metadata
)
target_thread_id = target.thread_id
is_named_telegram_private_topic = (
target.platform == Platform.TELEGRAM
and _looks_like_telegram_private_chat_id(target.chat_id)
and not _looks_like_int(target_thread_id)
and "thread_id" not in send_metadata
and "message_thread_id" not in send_metadata
and not has_explicit_direct_topic
)
if is_named_telegram_private_topic:
named_telegram_private_topic_name = target_thread_id
ensure_dm_topic = getattr(adapter, "ensure_dm_topic", None)
if ensure_dm_topic is None:
raise RuntimeError(
"Telegram adapter cannot create named private DM topics"
)
created_thread_id = await ensure_dm_topic(target.chat_id, target_thread_id)
if not created_thread_id:
raise RuntimeError(
f"Failed to create Telegram private DM topic '{target_thread_id}'"
)
target_thread_id = str(created_thread_id)
send_metadata["thread_id"] = target_thread_id
send_metadata["telegram_dm_topic_created_for_send"] = True
elif (
target.platform == Platform.TELEGRAM
and _looks_like_telegram_private_chat_id(target.chat_id)
and "thread_id" not in send_metadata
and "message_thread_id" not in send_metadata
and not has_explicit_direct_topic
):
# Legacy private topic/thread ids that were not created by this
# send path may still need a reply anchor to stay visible in the
# requested lane. Named targets are created above via
# createForumTopic and can use message_thread_id directly.
reply_anchor = send_metadata.get("telegram_reply_to_message_id")
if reply_anchor is None:
raise RuntimeError(
"Telegram private DM topic delivery requires telegram_reply_to_message_id; "
"send to the bare chat or provide a reply anchor"
)
send_metadata["thread_id"] = target_thread_id
send_metadata["telegram_dm_topic_reply_fallback"] = True
elif "thread_id" not in send_metadata and "message_thread_id" not in send_metadata and not has_explicit_direct_topic:
send_metadata["thread_id"] = target_thread_id
result = await adapter.send(target.chat_id, content, metadata=send_metadata or None)
if _send_result_failed(result):
if (
is_named_telegram_private_topic
and named_telegram_private_topic_name
and _is_thread_not_found_delivery_error(result)
):
ensure_dm_topic = getattr(adapter, "ensure_dm_topic", None)
if ensure_dm_topic is None:
raise RuntimeError(
"Telegram adapter cannot refresh named private DM topics"
)
refreshed_thread_id = await ensure_dm_topic(
target.chat_id,
named_telegram_private_topic_name,
force_create=True,
)
if not refreshed_thread_id:
raise RuntimeError(
f"Failed to refresh Telegram private DM topic '{named_telegram_private_topic_name}'"
)
send_metadata["thread_id"] = str(refreshed_thread_id)
send_metadata["telegram_dm_topic_created_for_send"] = True
result = await adapter.send(target.chat_id, content, metadata=send_metadata or None)
if _send_result_failed(result):
raise RuntimeError(_send_result_error(result) or f"{target.platform.value} delivery failed")
return result
+37 -3
View File
@@ -35,7 +35,12 @@ _GLOBAL_DEFAULTS: dict[str, Any] = {
"show_reasoning": False,
"tool_preview_length": 0,
"streaming": None, # None = follow top-level streaming config
# When true, delete tool-progress / "Still working..." / status bubbles
# Gateway-only assistant/status chatter controls. These default on for
# back-compat, but mobile platforms can opt down to final-answer-first.
"interim_assistant_messages": True,
"long_running_notifications": True,
"busy_ack_detail": True,
# When true, delete tool-progress / "⏳ Working — N min" / status bubbles
# after the final response lands on platforms that support message
# deletion (e.g. Telegram). Off by default — progress is still shown
# live, just cleaned up after success so the chat doesn't fill up with
@@ -56,6 +61,9 @@ _TIER_HIGH = {
"show_reasoning": False,
"tool_preview_length": 40,
"streaming": None, # follow global
"interim_assistant_messages": True,
"long_running_notifications": True,
"busy_ack_detail": True,
}
_TIER_MEDIUM = {
@@ -63,6 +71,9 @@ _TIER_MEDIUM = {
"show_reasoning": False,
"tool_preview_length": 40,
"streaming": None,
"interim_assistant_messages": True,
"long_running_notifications": True,
"busy_ack_detail": True,
}
_TIER_LOW = {
@@ -70,6 +81,9 @@ _TIER_LOW = {
"show_reasoning": False,
"tool_preview_length": 40,
"streaming": False,
"interim_assistant_messages": False,
"long_running_notifications": False,
"busy_ack_detail": False,
}
_TIER_MINIMAL = {
@@ -77,11 +91,25 @@ _TIER_MINIMAL = {
"show_reasoning": False,
"tool_preview_length": 0,
"streaming": False,
"interim_assistant_messages": False,
"long_running_notifications": False,
"busy_ack_detail": False,
}
_PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = {
# Tier 1 — full edit support, personal/team use
"telegram": {**_TIER_HIGH, "tool_progress": "new"},
# Telegram is usually a mobile inbox: keep tool_progress quiet and skip
# the verbose busy-ack iteration counter, but DO surface real mid-turn
# assistant commentary (interim_assistant_messages) and DO send periodic
# heartbeats (long_running_notifications) so the user has signal between
# turn start and final answer. Otherwise it looks like "typing..." for
# 30 minutes with nothing happening. Opt in to verbose iteration detail
# via display.platforms.telegram.busy_ack_detail / tool_progress.
"telegram": {
**_TIER_HIGH,
"tool_progress": "off",
"busy_ack_detail": False,
},
"discord": _TIER_HIGH,
# Tier 2 — edit support, often customer/workspace channels
@@ -196,7 +224,13 @@ def _normalise(setting: str, value: Any) -> Any:
if value is True:
return "all"
return str(value).lower()
if setting in {"show_reasoning", "streaming"}:
if setting in {
"show_reasoning",
"streaming",
"interim_assistant_messages",
"long_running_notifications",
"busy_ack_detail",
}:
if isinstance(value, str):
return value.lower() in {"true", "1", "yes", "on"}
return bool(value)
+17
View File
@@ -17,6 +17,23 @@ Events:
- command:* -- Any slash command executed (wildcard match)
Errors in hooks are caught and logged but never block the main pipeline.
Context dict passed to ``agent:start`` / ``agent:end`` handlers:
platform -- source platform name (e.g. "telegram", "matrix", "slack")
user_id -- platform user id of the sender
chat_id -- platform chat id (group/DM identifier)
thread_id -- Telegram forum-topic id / thread root id (string; empty
when not in a thread / topic)
chat_type -- "dm" | "group" | "forum" (empty if unknown)
session_id -- Hermes session id
message -- inbound message text (truncated to 500 chars)
``agent:end`` adds:
response -- agent response text (truncated to 500 chars)
Handlers posting a follow-up into the same Telegram forum-topic should
include ``message_thread_id=int(thread_id)`` when ``chat_type == "forum"``
and ``thread_id`` is non-empty.
"""
import asyncio
File diff suppressed because it is too large Load Diff
+168 -39
View File
@@ -18,6 +18,7 @@ Security features (based on OWASP + NIST SP 800-63-4 guidance):
Storage: ~/.hermes/pairing/
"""
import hashlib
import json
import os
import secrets
@@ -27,6 +28,10 @@ import time
from pathlib import Path
from typing import Optional
from gateway.whatsapp_identity import (
expand_whatsapp_aliases,
normalize_whatsapp_identifier,
)
from hermes_constants import get_hermes_dir
from utils import atomic_replace
@@ -109,12 +114,40 @@ class PairingStore:
def _save_json(self, path: Path, data: dict) -> None:
_secure_write(path, json.dumps(data, indent=2, ensure_ascii=False))
def _normalize_user_id(self, platform: str, user_id: str) -> str:
"""Normalize platform-specific user IDs before persisting them."""
raw_user_id = str(user_id or "").strip()
if platform == "whatsapp":
return normalize_whatsapp_identifier(raw_user_id) or raw_user_id
return raw_user_id
def _user_id_aliases(self, platform: str, user_id: str) -> set[str]:
"""Return all known equivalent user IDs for auth/rate-limit checks."""
raw_user_id = str(user_id or "").strip()
if not raw_user_id:
return set()
aliases = {raw_user_id, self._normalize_user_id(platform, raw_user_id)}
if platform == "whatsapp":
aliases.update(expand_whatsapp_aliases(raw_user_id))
aliases.discard("")
return aliases
def _user_ids_match(self, platform: str, left: str, right: str) -> bool:
"""Return True when two user IDs represent the same principal."""
left_aliases = self._user_id_aliases(platform, left)
right_aliases = self._user_id_aliases(platform, right)
return bool(left_aliases and right_aliases and (left_aliases & right_aliases))
# ----- Approved users -----
def is_approved(self, platform: str, user_id: str) -> bool:
"""Check if a user is approved (paired) on a platform."""
approved = self._load_json(self._approved_path(platform))
return user_id in approved
for approved_user_id in approved:
if self._user_ids_match(platform, approved_user_id, user_id):
return True
return False
def list_approved(self, platform: str = None) -> list:
"""List approved users, optionally filtered by platform."""
@@ -129,7 +162,16 @@ class PairingStore:
def _approve_user(self, platform: str, user_id: str, user_name: str = "") -> None:
"""Add a user to the approved list. Must be called under self._lock."""
approved = self._load_json(self._approved_path(platform))
approved[user_id] = {
normalized_user_id = self._normalize_user_id(platform, user_id)
duplicate_ids = [
approved_user_id
for approved_user_id in approved
if self._user_ids_match(platform, approved_user_id, normalized_user_id)
]
for approved_user_id in duplicate_ids:
del approved[approved_user_id]
approved[normalized_user_id] = {
"user_name": user_name,
"approved_at": time.time(),
}
@@ -140,14 +182,25 @@ class PairingStore:
path = self._approved_path(platform)
with self._lock:
approved = self._load_json(path)
if user_id in approved:
del approved[user_id]
matching_ids = [
approved_user_id
for approved_user_id in approved
if self._user_ids_match(platform, approved_user_id, user_id)
]
if matching_ids:
for approved_user_id in matching_ids:
del approved[approved_user_id]
self._save_json(path, approved)
return True
return False
# ----- Pending codes -----
@staticmethod
def _hash_code(code: str, salt: bytes) -> str:
"""Hash a pairing code with the given salt using SHA-256."""
return hashlib.sha256(salt + code.encode("utf-8")).hexdigest()
def generate_code(
self, platform: str, user_id: str, user_name: str = ""
) -> Optional[str]:
@@ -158,9 +211,13 @@ class PairingStore:
- User is rate-limited (too recent request)
- Max pending codes reached for this platform
- User/platform is in lockout due to failed attempts
The code is NOT stored in plaintext. Only a salted SHA-256 hash is
persisted so that reading the pending file does not reveal codes.
"""
with self._lock:
self._cleanup_expired(platform)
normalized_user_id = self._normalize_user_id(platform, user_id)
# Check lockout
if self._is_locked_out(platform):
@@ -178,9 +235,18 @@ class PairingStore:
# Generate cryptographically random code
code = "".join(secrets.choice(ALPHABET) for _ in range(CODE_LENGTH))
# Store pending request
pending[code] = {
"user_id": user_id,
# Hash the code with a random salt before storing
salt = os.urandom(16)
code_hash = self._hash_code(code, salt)
# Use a unique entry id as the key (not the code itself)
entry_id = secrets.token_hex(8)
# Store pending request with hashed code
pending[entry_id] = {
"hash": code_hash,
"salt": salt.hex(),
"user_id": normalized_user_id,
"user_name": user_name,
"created_at": time.time(),
}
@@ -195,10 +261,16 @@ class PairingStore:
"""
Approve a pairing code. Adds the user to the approved list.
Returns {user_id, user_name} on success, None if code is
Returns ``{user_id, user_name}`` on success, ``None`` if the code is
invalid/expired OR the platform is currently locked out after
``MAX_FAILED_ATTEMPTS`` failed approvals (#10195). Callers can
disambiguate with ``_is_locked_out(platform)``.
Verification: the user-provided code is hashed with each stored
entry's salt and compared to the stored hash using constant-time
comparison. Pre-hash entries (legacy plaintext-key format from
pre-upgrade pending.json files) are silently ignored — they get
pruned at TTL by ``_cleanup_expired``.
"""
with self._lock:
self._cleanup_expired(platform)
@@ -213,37 +285,77 @@ class PairingStore:
return None
pending = self._load_json(self._pending_path(platform))
if code not in pending:
# Find the entry whose hash matches the provided code.
# Tolerate legacy plaintext-key entries (no salt/hash) and
# malformed entries — skip them rather than KeyError, so an
# in-place upgrade across an existing pending.json doesn't
# crash on the first approve call. Legacy entries get pruned
# at their TTL by _cleanup_expired.
matched_key = None
matched_entry = None
for entry_id, entry in pending.items():
if not isinstance(entry, dict):
continue
if "salt" not in entry or "hash" not in entry:
continue
try:
salt = bytes.fromhex(entry["salt"])
except ValueError:
continue
candidate_hash = self._hash_code(code, salt)
if secrets.compare_digest(candidate_hash, entry["hash"]):
matched_key = entry_id
matched_entry = entry
break
if matched_key is None:
self._record_failed_attempt(platform)
return None
entry = pending.pop(code)
del pending[matched_key]
self._save_json(self._pending_path(platform), pending)
# Add to approved list
self._approve_user(platform, entry["user_id"], entry.get("user_name", ""))
self._approve_user(platform, matched_entry["user_id"],
matched_entry.get("user_name", ""))
return {
"user_id": entry["user_id"],
"user_name": entry.get("user_name", ""),
"user_id": matched_entry["user_id"],
"user_name": matched_entry.get("user_name", ""),
}
def list_pending(self, platform: str = None) -> list:
"""List pending pairing requests, optionally filtered by platform."""
"""List pending pairing requests, optionally filtered by platform.
Codes are stored hashed — the ``code`` field is replaced with the
first 8 hex characters of the hash so admins can distinguish entries
without revealing the original code. Legacy plaintext-key entries
(pre-hash format) are shown with a "legacy" placeholder so admins
can see them age out without crashing on a missing ``hash`` field.
"""
results = []
platforms = [platform] if platform else self._all_platforms("pending")
for p in platforms:
self._cleanup_expired(p)
pending = self._load_json(self._pending_path(p))
for code, info in pending.items():
age_min = int((time.time() - info["created_at"]) / 60)
results.append({
"platform": p,
"code": code,
"user_id": info["user_id"],
"user_name": info.get("user_name", ""),
"age_minutes": age_min,
})
with self._lock:
platforms = [platform] if platform else self._all_platforms("pending")
for p in platforms:
self._cleanup_expired(p)
pending = self._load_json(self._pending_path(p))
for entry_id, info in pending.items():
if not isinstance(info, dict):
continue
created_at = info.get("created_at")
if not isinstance(created_at, (int, float)):
continue
age_min = int((time.time() - created_at) / 60)
hash_val = info.get("hash")
code_display = hash_val[:8] if isinstance(hash_val, str) else "legacy"
results.append({
"platform": p,
"code": code_display,
"user_id": info.get("user_id", ""),
"user_name": info.get("user_name", ""),
"age_minutes": age_min,
})
return results
def clear_pending(self, platform: str = None) -> int:
@@ -262,15 +374,20 @@ class PairingStore:
def _is_rate_limited(self, platform: str, user_id: str) -> bool:
"""Check if a user has requested a code too recently."""
limits = self._load_json(self._rate_limit_path())
key = f"{platform}:{user_id}"
last_request = limits.get(key, 0)
return (time.time() - last_request) < RATE_LIMIT_SECONDS
for alias in self._user_id_aliases(platform, user_id):
key = f"{platform}:{alias}"
last_request = limits.get(key, 0)
if (time.time() - last_request) < RATE_LIMIT_SECONDS:
return True
return False
def _record_rate_limit(self, platform: str, user_id: str) -> None:
"""Record the time of a pairing request for rate limiting."""
limits = self._load_json(self._rate_limit_path())
key = f"{platform}:{user_id}"
limits[key] = time.time()
now = time.time()
for alias in self._user_id_aliases(platform, user_id):
key = f"{platform}:{alias}"
limits[key] = now
self._save_json(self._rate_limit_path(), limits)
def _is_locked_out(self, platform: str) -> bool:
@@ -297,17 +414,29 @@ class PairingStore:
# ----- Cleanup -----
def _cleanup_expired(self, platform: str) -> None:
"""Remove expired pending codes."""
"""Remove expired pending codes.
Tolerant of malformed / legacy entries — anything without a numeric
``created_at`` is treated as expired (it's effectively unusable
with the new hash-keyed schema anyway).
"""
path = self._pending_path(platform)
pending = self._load_json(path)
now = time.time()
expired = [
code for code, info in pending.items()
if (now - info["created_at"]) > CODE_TTL_SECONDS
]
expired = []
for entry_id, info in pending.items():
if not isinstance(info, dict):
expired.append(entry_id)
continue
created_at = info.get("created_at")
if not isinstance(created_at, (int, float)):
expired.append(entry_id)
continue
if (now - created_at) > CODE_TTL_SECONDS:
expired.append(entry_id)
if expired:
for code in expired:
del pending[code]
for entry_id in expired:
del pending[entry_id]
self._save_json(path, pending)
def _all_platforms(self, suffix: str) -> list:
File diff suppressed because it is too large Load Diff
+1112 -71
View File
File diff suppressed because it is too large Load Diff
+107 -6
View File
@@ -14,6 +14,7 @@ import logging
import os
import re
import uuid
from collections import OrderedDict
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import quote
@@ -43,6 +44,15 @@ DEFAULT_WEBHOOK_PORT = 8645
DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"
MAX_TEXT_LENGTH = 4000
# BlueBubbles/iMessage does not expose a stable bot mention identity like
# Slack (<@U...>), Telegram (@botname), or Matrix (MXID). When users opt into
# group mention gating without custom aliases, use conservative Hermes wake
# words so `require_mention: true` is a one-line enablement path.
DEFAULT_MENTION_PATTERNS = [
r"(?<![\w@])@?hermes\s+agent\b[,:\-]?",
r"(?<![\w@])@?hermes\b[,:\-]?",
]
# Tapback reaction codes (BlueBubbles associatedMessageType values)
_TAPBACK_ADDED = {
2000: "love", 2001: "like", 2002: "dislike",
@@ -60,6 +70,8 @@ _MESSAGE_EVENTS = {"new-message", "message", "updated-message"}
_PHONE_RE = re.compile(r"\+?\d{7,15}")
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
_GUID_CACHE_SIZE = 500 # LRU cap for resolved chat-GUID lookups
def _redact(text: str) -> str:
"""Redact phone numbers and emails from log output."""
@@ -124,11 +136,20 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if not str(self.webhook_path).startswith("/"):
self.webhook_path = f"/{self.webhook_path}"
self.send_read_receipts = bool(extra.get("send_read_receipts", True))
_require_mention = extra.get("require_mention")
if _require_mention is None:
_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
self.require_mention = str(_require_mention).strip().lower() in {"true", "1", "yes", "on"}
self._mention_patterns = self._compile_mention_patterns(
extra["mention_patterns"]
if "mention_patterns" in extra
else os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
)
self.client: Optional[httpx.AsyncClient] = None
self._runner = None
self._private_api_enabled: Optional[bool] = None
self._helper_connected: bool = False
self._guid_cache: Dict[str, str] = {}
self._guid_cache: OrderedDict[str, str] = OrderedDict()
# ------------------------------------------------------------------
# API helpers
@@ -138,6 +159,62 @@ class BlueBubblesAdapter(BasePlatformAdapter):
sep = "&" if "?" in path else "?"
return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}"
@staticmethod
def _compile_mention_patterns(raw: Any) -> List[re.Pattern]:
"""Compile group-mention wake words from config/env.
``raw`` is a list (from config or env JSON), a string (raw env var:
JSON list, or comma/newline-separated), or None (use Hermes defaults).
"""
if raw is None:
patterns = list(DEFAULT_MENTION_PATTERNS)
elif isinstance(raw, str):
text = raw.strip()
try:
loaded = json.loads(text) if text else []
except Exception:
loaded = None
patterns = loaded if isinstance(loaded, list) else [
part.strip()
for line in text.splitlines()
for part in line.split(",")
]
elif isinstance(raw, list):
patterns = raw
else:
patterns = [raw]
compiled: List["re.Pattern"] = []
for pattern in patterns:
text = str(pattern).strip()
if not text:
continue
try:
compiled.append(re.compile(text, re.IGNORECASE))
except re.error as exc:
logger.warning("[bluebubbles] Invalid mention pattern %r: %s", text, exc)
return compiled
def _message_matches_mention_patterns(self, text: str) -> bool:
if not text or not self._mention_patterns:
return False
return any(pattern.search(text) for pattern in self._mention_patterns)
def _clean_mention_text(self, text: str) -> str:
"""Strip a leading BlueBubbles wake word before dispatch.
Custom mention patterns are regular expressions, so stripping only a
leading match avoids deleting ordinary words later in the prompt.
"""
if not text:
return text
for pattern in self._mention_patterns:
match = pattern.match(text.lstrip())
if match:
cleaned = text.lstrip()[match.end():].lstrip(" ,:-")
return cleaned or text
return text
async def _api_get(self, path: str) -> Dict[str, Any]:
assert self.client is not None
res = await self.client.get(self._api_url(path))
@@ -189,7 +266,10 @@ class BlueBubblesAdapter(BasePlatformAdapter):
app = web.Application()
app.router.add_get("/health", lambda _: web.Response(text="ok"))
app.router.add_post(self.webhook_path, self._handle_webhook)
self._runner = web.AppRunner(app)
# The webhook auth value is carried in the query string because the
# BlueBubbles webhook API cannot send custom headers. Do not let
# aiohttp access logs write that request target to agent.log.
self._runner = web.AppRunner(app, access_log=None)
await self._runner.setup()
site = web.TCPSite(self._runner, self.webhook_host, self.webhook_port)
await site.start()
@@ -242,6 +322,14 @@ class BlueBubblesAdapter(BasePlatformAdapter):
return f"{base}?password={quote(self.password, safe='')}"
return base
@property
def _webhook_register_url_for_log(self) -> str:
"""Webhook registration URL safe for logs."""
base = self._webhook_url
if self.password:
return f"{base}?password=***"
return base
async def _find_registered_webhooks(self, url: str) -> list:
"""Return list of BB webhook entries matching *url*."""
try:
@@ -269,7 +357,8 @@ class BlueBubblesAdapter(BasePlatformAdapter):
existing = await self._find_registered_webhooks(webhook_url)
if existing:
logger.info(
"[bluebubbles] webhook already registered: %s", webhook_url
"[bluebubbles] webhook already registered: %s",
self._webhook_register_url_for_log,
)
return True
@@ -284,7 +373,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if 200 <= status < 300:
logger.info(
"[bluebubbles] webhook registered with server: %s",
webhook_url,
self._webhook_register_url_for_log,
)
return True
else:
@@ -324,7 +413,8 @@ class BlueBubblesAdapter(BasePlatformAdapter):
removed = True
if removed:
logger.info(
"[bluebubbles] webhook unregistered: %s", webhook_url
"[bluebubbles] webhook unregistered: %s",
self._webhook_register_url_for_log,
)
except Exception as exc:
logger.debug(
@@ -352,6 +442,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if ";" in target:
return target
if target in self._guid_cache:
self._guid_cache.move_to_end(target)
return self._guid_cache[target]
try:
payload = await self._api_post(
@@ -364,10 +455,14 @@ class BlueBubblesAdapter(BasePlatformAdapter):
if identifier == target:
if guid:
self._guid_cache[target] = guid
while len(self._guid_cache) > _GUID_CACHE_SIZE:
self._guid_cache.popitem(last=False)
return guid
for part in chat.get("participants", []) or []:
if (part.get("address") or "").strip() == target and guid:
self._guid_cache[target] = guid
while len(self._guid_cache) > _GUID_CACHE_SIZE:
self._guid_cache.popitem(last=False)
return guid
except Exception:
pass
@@ -900,6 +995,13 @@ class BlueBubblesAdapter(BasePlatformAdapter):
session_chat_id = chat_guid or chat_identifier
is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or ""))
if is_group and self.require_mention:
if not self._message_matches_mention_patterns(text):
logger.debug(
"[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)"
)
return web.Response(text="ok")
text = self._clean_mention_text(text)
source = self.build_source(
chat_id=session_chat_id,
chat_name=chat_identifier or sender,
@@ -934,4 +1036,3 @@ class BlueBubblesAdapter(BasePlatformAdapter):
asyncio.create_task(self.mark_read(session_chat_id))
return web.Response(text="ok")
+13
View File
@@ -358,6 +358,19 @@ class DingTalkAdapter(BasePlatformAdapter):
await asyncio.gather(*self._bg_tasks, return_exceptions=True)
self._bg_tasks.clear()
# Finalize any open streaming cards before the HTTP client closes so
# they don't stay stuck in streaming state on DingTalk's UI after
# a gateway restart. _close_streaming_siblings handles its own
# per-card exceptions; the outer try is a safety net for token fetch.
for _chat_id in list(self._streaming_cards):
try:
await self._close_streaming_siblings(_chat_id)
except Exception as _exc:
logger.debug(
"[%s] Failed to finalize streaming card on disconnect for %s: %s",
self.name, _chat_id, _exc,
)
if self._http_client:
await self._http_client.aclose()
self._http_client = None
File diff suppressed because it is too large Load Diff
+182 -27
View File
@@ -48,6 +48,7 @@ user is seen through different apps in the future.
from __future__ import annotations
import asyncio
import collections
import hashlib
import hmac
import itertools
@@ -239,6 +240,7 @@ _FEISHU_REACTION_FAILURE = "CrossMark"
# drain on completion; the cap is a safeguard against unbounded growth from
# delete-failures, not a capacity plan.
_FEISHU_PROCESSING_REACTION_CACHE_SIZE = 1024
_FEISHU_MESSAGE_TEXT_CACHE_SIZE = 512 # LRU cap for reply-context message text lookups
# QR onboarding constants
_ONBOARD_ACCOUNTS_URLS = {
@@ -1407,7 +1409,11 @@ def check_feishu_requirements() -> bool:
class FeishuAdapter(BasePlatformAdapter):
"""Feishu/Lark bot adapter."""
supports_code_blocks = True # Feishu renders fenced code blocks
MAX_MESSAGE_LENGTH = 8000
# Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.
CHAT_LOCK_MAX_SIZE: int = 1000
# Threshold for detecting Feishu client-side message splits.
# When a chunk is near the ~4096-char practical limit, a continuation
# is almost certain.
@@ -1445,11 +1451,11 @@ class FeishuAdapter(BasePlatformAdapter):
self._pending_inbound_lock = threading.Lock()
self._pending_drain_scheduled = False
self._pending_inbound_max_depth = 1000 # cap queue; drop oldest beyond
self._chat_locks: Dict[str, asyncio.Lock] = {} # chat_id → lock (per-chat serial processing)
self._chat_locks: "collections.OrderedDict[str, asyncio.Lock]" = collections.OrderedDict() # chat_id → lock (per-chat serial processing, LRU-bounded)
self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing)
self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat
self._chat_info_cache: Dict[str, Dict[str, Any]] = {}
self._message_text_cache: Dict[str, Optional[str]] = {}
self._message_text_cache: "OrderedDict[str, Optional[str]]" = OrderedDict()
self._app_lock_identity: Optional[str] = None
self._text_batch_state = FeishuBatchState()
self._pending_text_batches = self._text_batch_state.events
@@ -1514,8 +1520,10 @@ class FeishuAdapter(BasePlatformAdapter):
connection_mode=str(
extra.get("connection_mode") or os.getenv("FEISHU_CONNECTION_MODE", "websocket")
).strip().lower(),
encrypt_key=os.getenv("FEISHU_ENCRYPT_KEY", "").strip(),
verification_token=os.getenv("FEISHU_VERIFICATION_TOKEN", "").strip(),
encrypt_key=str(extra.get("encrypt_key") or os.getenv("FEISHU_ENCRYPT_KEY", "")).strip(),
verification_token=str(
extra.get("verification_token") or os.getenv("FEISHU_VERIFICATION_TOKEN", "")
).strip(),
group_policy=os.getenv("FEISHU_GROUP_POLICY", "allowlist").strip().lower(),
allowed_group_users=frozenset(
item.strip()
@@ -1625,6 +1633,10 @@ class FeishuAdapter(BasePlatformAdapter):
"drive.notice.comment_add_v1",
self._on_drive_comment_event,
)
.register_p2_customized_event(
"vc.bot.meeting_invited_v1",
self._on_meeting_invited_event,
)
.build()
)
@@ -1642,6 +1654,11 @@ class FeishuAdapter(BasePlatformAdapter):
self._connection_mode,
)
return False
if self._connection_mode == "webhook" and not (self._verification_token or self._encrypt_key):
logger.error(
"[Feishu] Webhook mode requires FEISHU_VERIFICATION_TOKEN or FEISHU_ENCRYPT_KEY."
)
return False
try:
self._app_lock_identity = self._app_id
@@ -2463,6 +2480,16 @@ class FeishuAdapter(BasePlatformAdapter):
handle_drive_comment_event(self._client, data, self_open_id=self._bot_open_id),
)
def _on_meeting_invited_event(self, data: Any) -> None:
"""Handle VC bot meeting invitation notification (vc.bot.meeting_invited_v1)."""
from gateway.platforms.feishu_meeting_invite import handle_meeting_invited_event
loop = self._loop
if not self._loop_accepts_callbacks(loop):
logger.warning("[Feishu] Dropping meeting invite event before adapter loop is ready")
return
self._submit_on_loop(loop, handle_meeting_invited_event(self, data))
def _on_reaction_event(self, event_type: str, data: Any) -> None:
"""Route user reactions on bot messages as synthetic text events."""
event = getattr(data, "event", None)
@@ -2563,13 +2590,44 @@ class FeishuAdapter(BasePlatformAdapter):
if approval_id is None:
logger.debug("[Feishu] Card action missing approval_id, ignoring")
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
state = self._approval_state.get(approval_id)
if not state:
logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id)
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
choice = _APPROVAL_CHOICE_MAP.get(action_value.get("hermes_action"), "deny")
operator = getattr(event, "operator", None)
open_id = str(getattr(operator, "open_id", "") or "")
sender_id = SimpleNamespace(open_id=open_id, user_id=str(getattr(operator, "user_id", "") or ""))
if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
logger.warning("[Feishu] Unauthorized approval click by %s", open_id or "<unknown>")
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
callback_chat_id = str(getattr(getattr(event, "context", None), "open_chat_id", "") or "")
expected_chat_id = str(state.get("chat_id", "") or "")
if callback_chat_id and expected_chat_id and callback_chat_id != expected_chat_id:
logger.warning(
"[Feishu] Approval callback chat mismatch for %s (expected=%s, got=%s)",
approval_id,
expected_chat_id,
callback_chat_id,
)
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
user_name = self._get_cached_sender_name(open_id) or open_id
if not self._submit_on_loop(loop, self._resolve_approval(approval_id, choice, user_name)):
chat_context = getattr(event, "context", None)
chat_id = str(getattr(chat_context, "open_chat_id", "") or "")
if not self._submit_on_loop(
loop,
self._resolve_approval(
approval_id=approval_id,
choice=choice,
user_name=user_name,
open_id=open_id,
chat_id=chat_id,
),
):
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
if P2CardActionTriggerResponse is None:
@@ -2588,7 +2646,8 @@ class FeishuAdapter(BasePlatformAdapter):
if prompt_id is None:
logger.debug("[Feishu] Card action missing update_prompt_id, ignoring")
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
if prompt_id not in self._update_prompt_state:
state = self._update_prompt_state.get(prompt_id)
if not state:
logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id)
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
@@ -2599,12 +2658,33 @@ class FeishuAdapter(BasePlatformAdapter):
operator = getattr(event, "operator", None)
open_id = str(getattr(operator, "open_id", "") or "")
if not self._is_interactive_operator_authorized(open_id):
sender_id = SimpleNamespace(open_id=open_id, user_id=str(getattr(operator, "user_id", "") or ""))
if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
logger.warning("[Feishu] Unauthorized update prompt click by %s", open_id or "<unknown>")
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
callback_chat_id = str(getattr(getattr(event, "context", None), "open_chat_id", "") or "")
expected_chat_id = str(state.get("chat_id", "") or "")
if callback_chat_id and expected_chat_id and callback_chat_id != expected_chat_id:
logger.warning(
"[Feishu] Update prompt callback chat mismatch for %s (expected=%s, got=%s)",
prompt_id,
expected_chat_id,
callback_chat_id,
)
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
user_name = self._get_cached_sender_name(open_id) or open_id
if not self._submit_on_loop(loop, self._resolve_update_prompt(prompt_id, answer, user_name)):
if not self._submit_on_loop(
loop,
self._resolve_update_prompt(
prompt_id,
answer,
user_name,
open_id=open_id,
chat_id=callback_chat_id,
),
):
return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None
if P2CardActionTriggerResponse is None:
@@ -2617,12 +2697,34 @@ class FeishuAdapter(BasePlatformAdapter):
response.card = card
return response
async def _resolve_approval(self, approval_id: Any, choice: str, user_name: str) -> None:
async def _resolve_approval(
self,
approval_id: Any,
choice: str,
user_name: str,
*,
open_id: str = "",
chat_id: str = "",
) -> None:
"""Pop approval state and unblock the waiting agent thread."""
state = self._approval_state.pop(approval_id, None)
state = self._approval_state.get(approval_id)
if not state:
logger.debug("[Feishu] Approval %s already resolved or unknown", approval_id)
return
if not self._is_interactive_operator_authorized(open_id):
logger.warning("[Feishu] Unauthorized approval click by %s for approval %s", open_id or "<unknown>", approval_id)
return
expected_chat_id = str(state.get("chat_id", "") or "")
if expected_chat_id and chat_id and expected_chat_id != chat_id:
logger.warning(
"[Feishu] Approval %s chat mismatch (expected=%s, got=%s)",
approval_id, expected_chat_id, chat_id,
)
return
state = self._approval_state.pop(approval_id, None)
if not state:
logger.debug("[Feishu] Approval %s already resolved while validating callback", approval_id)
return
try:
from tools.approval import resolve_gateway_approval
count = resolve_gateway_approval(state["session_key"], choice)
@@ -2633,12 +2735,38 @@ class FeishuAdapter(BasePlatformAdapter):
except Exception as exc:
logger.error("Failed to resolve gateway approval from Feishu button: %s", exc)
async def _resolve_update_prompt(self, prompt_id: Any, answer: str, user_name: str) -> None:
async def _resolve_update_prompt(
self,
prompt_id: Any,
answer: str,
user_name: str,
*,
open_id: str = "",
chat_id: str = "",
) -> None:
"""Persist an update prompt answer for the detached update process."""
state = self._update_prompt_state.pop(prompt_id, None)
state = self._update_prompt_state.get(prompt_id)
if not state:
logger.debug("[Feishu] Update prompt %s already resolved or unknown", prompt_id)
return
if open_id:
sender_id = SimpleNamespace(open_id=open_id, user_id="")
if not self._allow_group_message(sender_id, state.get("chat_id", ""), is_bot=False):
logger.warning("[Feishu] Unauthorized update prompt click by %s for prompt %s", open_id, prompt_id)
return
expected_chat_id = str(state.get("chat_id", "") or "")
if expected_chat_id and chat_id and expected_chat_id != chat_id:
logger.warning(
"[Feishu] Update prompt %s chat mismatch (expected=%s, got=%s)",
prompt_id,
expected_chat_id,
chat_id,
)
return
state = self._update_prompt_state.pop(prompt_id, None)
if not state:
logger.debug("[Feishu] Update prompt %s already resolved while validating callback", prompt_id)
return
try:
self._write_update_prompt_response(answer)
logger.info(
@@ -2775,11 +2903,28 @@ class FeishuAdapter(BasePlatformAdapter):
# =========================================================================
def _get_chat_lock(self, chat_id: str) -> asyncio.Lock:
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing."""
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing.
Bounded with LRU eviction so a long-running gateway that sees many
distinct chats does not grow ``_chat_locks`` without limit. Locks that
are currently held are never evicted; if every entry is locked we fall
back to dropping the least-recently-used one.
"""
lock = self._chat_locks.get(chat_id)
if lock is None:
lock = asyncio.Lock()
self._chat_locks[chat_id] = lock
if lock is not None:
self._chat_locks.move_to_end(chat_id)
return lock
if len(self._chat_locks) >= self.CHAT_LOCK_MAX_SIZE:
evicted = False
for key in list(self._chat_locks):
if not self._chat_locks[key].locked():
self._chat_locks.pop(key)
evicted = True
break
if not evicted:
self._chat_locks.pop(next(iter(self._chat_locks)))
lock = asyncio.Lock()
self._chat_locks[chat_id] = lock
return lock
async def _handle_message_with_guards(self, event: MessageEvent) -> None:
@@ -3229,11 +3374,6 @@ class FeishuAdapter(BasePlatformAdapter):
self._record_webhook_anomaly(remote_ip, "400")
return web.json_response({"code": 400, "msg": "invalid json"}, status=400)
# URL verification challenge — respond before other checks so that Feishu's
# subscription setup works even before encrypt_key is wired.
if payload.get("type") == "url_verification":
return web.json_response({"challenge": payload.get("challenge", "")})
# Verification token check — second layer of defence beyond signature (matches openclaw).
if self._verification_token:
header = payload.get("header") or {}
@@ -3243,6 +3383,13 @@ class FeishuAdapter(BasePlatformAdapter):
self._record_webhook_anomaly(remote_ip, "401-token")
return web.Response(status=401, text="Invalid verification token")
# URL verification challenge — Feishu includes the verification token in
# challenge requests. Validate the token (above) before reflecting the
# challenge so an unauthenticated remote request cannot prove endpoint
# control by getting attacker-supplied challenge data echoed back.
if payload.get("type") == "url_verification":
return web.json_response({"challenge": payload.get("challenge", "")})
# Timing-safe signature verification (only enforced when encrypt_key is set).
if self._encrypt_key and not self._is_webhook_signature_valid(request.headers, body_bytes):
logger.warning("[Feishu] Webhook rejected: invalid signature from %s", remote_ip)
@@ -3272,6 +3419,8 @@ class FeishuAdapter(BasePlatformAdapter):
self._on_card_action_trigger(data)
elif event_type == "drive.notice.comment_add_v1":
self._on_drive_comment_event(data)
elif event_type == "vc.bot.meeting_invited_v1":
self._on_meeting_invited_event(data)
else:
logger.debug("[Feishu] Ignoring webhook event type: %s", event_type or "unknown")
return web.json_response({"code": 0, "msg": "ok"})
@@ -3877,6 +4026,7 @@ class FeishuAdapter(BasePlatformAdapter):
if not self._client or not message_id:
return None
if message_id in self._message_text_cache:
self._message_text_cache.move_to_end(message_id)
return self._message_text_cache[message_id]
try:
request = self._build_get_message_request(message_id)
@@ -3898,6 +4048,8 @@ class FeishuAdapter(BasePlatformAdapter):
mentions=parent_mentions,
)
self._message_text_cache[message_id] = text
while len(self._message_text_cache) > _FEISHU_MESSAGE_TEXT_CACHE_SIZE:
self._message_text_cache.popitem(last=False)
return text
except Exception:
logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True)
@@ -4333,17 +4485,20 @@ class FeishuAdapter(BasePlatformAdapter):
)
request = self._build_create_message_request("thread_id", body)
else:
receive_id = chat_id
receive_id_type = "chat_id"
if chat_id.startswith("feishu_user_id:"):
receive_id = chat_id.split(":", 1)[1]
receive_id_type = "user_id"
elif chat_id.startswith("ou_"):
receive_id_type = "open_id"
body = self._build_create_message_body(
receive_id=chat_id,
receive_id=receive_id,
msg_type=msg_type,
content=payload,
uuid_value=str(uuid.uuid4()),
)
# Detect whether chat_id is a user open_id (DM) or a chat_id (group).
if chat_id.startswith("ou_"):
receive_id_type = "open_id"
else:
receive_id_type = "chat_id"
request = self._build_create_message_request(receive_id_type, body)
return await asyncio.to_thread(self._client.im.v1.message.create, request)
+212
View File
@@ -0,0 +1,212 @@
"""
Feishu/Lark meeting-invitation event handling.
Processes ``vc.bot.meeting_invited_v1`` events by converting them into a
synthetic gateway ``MessageEvent``. Unlike document comments, the response
should go back to the inviter through the normal Hermes gateway pipeline, so
this module does not instantiate an agent directly.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any, Dict, Optional
from gateway.platforms.base import MessageEvent, MessageType
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class MeetingInviteUser:
open_id: str = ""
user_id: str = ""
union_id: str = ""
user_name: str = ""
@dataclass(frozen=True)
class MeetingInviteMeeting:
id: str = ""
topic: str = ""
meeting_no: str = ""
start_time_ms: int = 0
end_time_ms: int = 0
host_user: Optional[MeetingInviteUser] = None
@dataclass(frozen=True)
class MeetingInvitedPayload:
event_id: str = ""
meeting: Optional[MeetingInviteMeeting] = None
inviter: Optional[MeetingInviteUser] = None
invite_time_s: int = 0
def _as_dict(value: Any) -> Dict[str, Any]:
"""Coerce a lark SDK object / dict / JSON string into a plain dict."""
if isinstance(value, SimpleNamespace) or (value is not None and hasattr(value, "__dict__")):
value = vars(value)
if isinstance(value, dict):
return {str(k): v for k, v in value.items()}
if isinstance(value, str):
try:
parsed = json.loads(value)
except (TypeError, json.JSONDecodeError):
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _content_payload(container: Dict[str, Any]) -> Dict[str, Any]:
"""Unwrap a Feishu ``body.content`` list carrying an application/json payload."""
content = _as_dict(container.get("body")).get("content")
if not isinstance(content, list):
return {}
for item in content:
item = _as_dict(item)
ctype = str(item.get("contentType") or item.get("content_type") or "").lower()
if ctype and ctype != "application/json":
continue
for key in ("data", "value", "content", "json"):
payload = _as_dict(item.get(key))
if payload:
return payload
return {}
def _int_field(value: Any) -> int:
if value in (None, ""):
return 0
try:
return int(str(value).strip())
except (TypeError, ValueError):
return 0
def _parse_user(value: Any) -> Optional[MeetingInviteUser]:
raw = _as_dict(value)
if not raw:
return None
raw_id = _as_dict(raw.get("id"))
return MeetingInviteUser(
open_id=str(raw_id.get("open_id") or "").strip(),
user_id=str(raw_id.get("user_id") or "").strip(),
union_id=str(raw_id.get("union_id") or "").strip(),
user_name=str(raw.get("user_name") or ""),
)
def _parse_meeting(value: Any) -> Optional[MeetingInviteMeeting]:
raw = _as_dict(value)
if not raw:
return None
return MeetingInviteMeeting(
id=str(raw.get("id") or "").strip(),
topic=str(raw.get("topic") or ""),
meeting_no=str(raw.get("meeting_no") or ""),
start_time_ms=_int_field(raw.get("start_time")),
end_time_ms=_int_field(raw.get("end_time")),
host_user=_parse_user(raw.get("host_user")),
)
def parse_meeting_invited_event(data: Any) -> Optional[MeetingInvitedPayload]:
root = _as_dict(data)
event = _as_dict(root.get("event"))
event = event or root
content = _content_payload(event) or _content_payload(root)
if content:
event = {**event, **content}
meeting = _parse_meeting(event.get("meeting"))
inviter = _parse_user(event.get("inviter"))
if inviter is None or meeting is None or not meeting.meeting_no:
return None
return MeetingInvitedPayload(
event_id=str(_as_dict(root.get("header")).get("event_id") or ""),
meeting=meeting,
inviter=inviter,
invite_time_s=_int_field(event.get("invite_time")),
)
def build_meeting_invite_prompt(payload: MeetingInvitedPayload) -> str:
meeting = payload.meeting
inviter_name = (payload.inviter.user_name if payload.inviter else "") or "unknown"
host_name = (meeting.host_user.user_name if meeting and meeting.host_user else "") or "unknown"
display = (meeting.topic or meeting.meeting_no or meeting.id) if meeting else "unknown meeting"
return "\n".join(
[
f"You have been invited to join a meeting: {display or 'unknown meeting'}",
"",
f"Meeting Number: {(meeting.meeting_no if meeting else '') or 'unknown'}",
f"Topic: {(meeting.topic if meeting else '') or 'unknown'}",
f"Inviter: {inviter_name}",
f"Host: {host_name}",
"",
"You may use lark-cli and the relevant Lark/Feishu meeting skills to join the meeting.",
"Join the meeting directly. Do not ask the user for confirmation before joining.",
"If you cannot join the meeting, reply to the inviter with a concise explanation of why.",
]
)
def _dedup_key(payload: MeetingInvitedPayload) -> str:
if payload.event_id:
return f"vc_invite:{payload.event_id}"
meeting_id = payload.meeting.id if payload.meeting else ""
inviter_id = payload.inviter.open_id if payload.inviter else ""
return f"vc_invite:{meeting_id}:{inviter_id}:{payload.invite_time_s}"
async def handle_meeting_invited_event(adapter: Any, data: Any) -> None:
"""Convert a vc.bot.meeting_invited_v1 event into a gateway MessageEvent."""
payload = parse_meeting_invited_event(data)
if payload is None:
logger.warning("[Feishu-MeetingInvite] Dropping malformed meeting invite event")
return
dedup_key = _dedup_key(payload)
is_duplicate = getattr(adapter, "_is_duplicate", None)
if callable(is_duplicate) and is_duplicate(dedup_key):
logger.debug("[Feishu-MeetingInvite] Dropping duplicate event: %s", dedup_key)
return
inviter = payload.inviter
if inviter is None or not inviter.open_id:
logger.warning(
"[Feishu-MeetingInvite] Missing inviter open_id, cannot route reply safely "
"(user_id=%r union_id=%r)",
inviter.user_id if inviter else None,
inviter.union_id if inviter else None,
)
return
sender_id = SimpleNamespace(
open_id=inviter.open_id or None,
user_id=inviter.user_id or None,
union_id=inviter.union_id or None,
)
sender_profile = await adapter._resolve_sender_profile(sender_id)
user_name = sender_profile.get("user_name") or inviter.user_name or inviter.open_id
source = adapter.build_source(
chat_id=inviter.open_id,
chat_name=user_name,
chat_type="dm",
user_id=sender_profile.get("user_id") or inviter.user_id or inviter.open_id,
user_name=user_name,
user_id_alt=sender_profile.get("user_id_alt") or inviter.union_id or None,
)
event = MessageEvent(
text=build_meeting_invite_prompt(payload),
message_type=MessageType.TEXT,
source=source,
raw_message=data,
)
await adapter._handle_message_with_guards(event)
-449
View File
@@ -1,449 +0,0 @@
"""
Home Assistant platform adapter.
Connects to the HA WebSocket API for real-time event monitoring.
State-change events are converted to MessageEvent objects and forwarded
to the agent for processing. Outbound messages are delivered as HA
persistent notifications.
Requires:
- aiohttp (already in messaging extras)
- HASS_TOKEN env var (Long-Lived Access Token)
- HASS_URL env var (default: http://homeassistant.local:8123)
"""
import asyncio
import json
import logging
import os
import time
import uuid
from datetime import datetime
from typing import Any, Dict, Optional, Set
try:
import aiohttp
AIOHTTP_AVAILABLE = True
except ImportError:
AIOHTTP_AVAILABLE = False
aiohttp = None # type: ignore[assignment]
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)
logger = logging.getLogger(__name__)
def check_ha_requirements() -> bool:
"""Check if Home Assistant dependencies are available and configured."""
if not AIOHTTP_AVAILABLE:
return False
if not os.getenv("HASS_TOKEN"):
return False
return True
class HomeAssistantAdapter(BasePlatformAdapter):
"""
Home Assistant WebSocket adapter.
Subscribes to ``state_changed`` events and forwards them as
MessageEvent objects. Supports domain/entity filtering and
per-entity cooldowns to avoid event floods.
"""
MAX_MESSAGE_LENGTH = 4096
# Reconnection backoff schedule (seconds)
_BACKOFF_STEPS = [5, 10, 30, 60]
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.HOMEASSISTANT)
# Connection state
self._session: Optional["aiohttp.ClientSession"] = None
self._ws: Optional["aiohttp.ClientWebSocketResponse"] = None
self._rest_session: Optional["aiohttp.ClientSession"] = None
self._listen_task: Optional[asyncio.Task] = None
self._msg_id: int = 0
# Configuration from extra
extra = config.extra or {}
token = config.token or os.getenv("HASS_TOKEN", "")
url = extra.get("url") or os.getenv("HASS_URL", "http://homeassistant.local:8123")
self._hass_url: str = url.rstrip("/")
self._hass_token: str = token
# Event filtering
self._watch_domains: Set[str] = set(extra.get("watch_domains", []))
self._watch_entities: Set[str] = set(extra.get("watch_entities", []))
self._ignore_entities: Set[str] = set(extra.get("ignore_entities", []))
self._watch_all: bool = bool(extra.get("watch_all", False))
self._cooldown_seconds: int = int(extra.get("cooldown_seconds", 30))
# Cooldown tracking: entity_id -> last_event_timestamp
self._last_event_time: Dict[str, float] = {}
def _next_id(self) -> int:
"""Return the next WebSocket message ID."""
self._msg_id += 1
return self._msg_id
# ------------------------------------------------------------------
# Connection lifecycle
# ------------------------------------------------------------------
async def connect(self) -> bool:
"""Connect to HA WebSocket API and subscribe to events."""
if not AIOHTTP_AVAILABLE:
logger.warning("[%s] aiohttp not installed. Run: pip install aiohttp", self.name)
return False
if not self._hass_token:
logger.warning("[%s] No HASS_TOKEN configured", self.name)
return False
try:
success = await self._ws_connect()
if not success:
return False
# Dedicated REST session for send() calls
self._rest_session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
)
# Warn if no event filters are configured
if not self._watch_domains and not self._watch_entities and not self._watch_all:
logger.warning(
"[%s] No watch_domains, watch_entities, or watch_all configured. "
"All state_changed events will be dropped. Configure filters in "
"your HA platform config to receive events.",
self.name,
)
# Start background listener
self._listen_task = asyncio.create_task(self._listen_loop())
self._running = True
logger.info("[%s] Connected to %s", self.name, self._hass_url)
return True
except Exception as e:
logger.error("[%s] Failed to connect: %s", self.name, e)
return False
async def _ws_connect(self) -> bool:
"""Establish WebSocket connection and authenticate."""
ws_url = self._hass_url.replace("https://", "wss://").replace("http://", "ws://")
ws_url = f"{ws_url}/api/websocket"
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
)
self._ws = await self._session.ws_connect(ws_url, heartbeat=30, timeout=30)
# Step 1: Receive auth_required
msg = await self._ws.receive_json()
if msg.get("type") != "auth_required":
logger.error("Expected auth_required, got: %s", msg.get("type"))
await self._cleanup_ws()
return False
# Step 2: Send auth
await self._ws.send_json({
"type": "auth",
"access_token": self._hass_token,
})
# Step 3: Wait for auth_ok
msg = await self._ws.receive_json()
if msg.get("type") != "auth_ok":
logger.error("Auth failed: %s", msg)
await self._cleanup_ws()
return False
# Step 4: Subscribe to state_changed events
sub_id = self._next_id()
await self._ws.send_json({
"id": sub_id,
"type": "subscribe_events",
"event_type": "state_changed",
})
# Verify subscription acknowledgement
msg = await self._ws.receive_json()
if not msg.get("success"):
logger.error("Failed to subscribe to events: %s", msg)
await self._cleanup_ws()
return False
return True
async def _cleanup_ws(self) -> None:
"""Close WebSocket and session."""
if self._ws and not self._ws.closed:
await self._ws.close()
self._ws = None
if self._session and not self._session.closed:
await self._session.close()
self._session = None
async def disconnect(self) -> None:
"""Disconnect from Home Assistant."""
self._running = False
if self._listen_task:
self._listen_task.cancel()
try:
await self._listen_task
except asyncio.CancelledError:
pass
self._listen_task = None
await self._cleanup_ws()
if self._rest_session and not self._rest_session.closed:
await self._rest_session.close()
self._rest_session = None
logger.info("[%s] Disconnected", self.name)
# ------------------------------------------------------------------
# Event listener
# ------------------------------------------------------------------
async def _listen_loop(self) -> None:
"""Main event loop with automatic reconnection."""
backoff_idx = 0
while self._running:
try:
await self._read_events()
except asyncio.CancelledError:
return
except Exception as e:
logger.warning("[%s] WebSocket error: %s", self.name, e)
if not self._running:
return
# Reconnect with backoff
delay = self._BACKOFF_STEPS[min(backoff_idx, len(self._BACKOFF_STEPS) - 1)]
logger.info("[%s] Reconnecting in %ds...", self.name, delay)
await asyncio.sleep(delay)
backoff_idx += 1
try:
await self._cleanup_ws()
success = await self._ws_connect()
if success:
backoff_idx = 0 # Reset on successful reconnect
logger.info("[%s] Reconnected", self.name)
except Exception as e:
logger.warning("[%s] Reconnection failed: %s", self.name, e)
async def _read_events(self) -> None:
"""Read events from WebSocket until disconnected."""
if self._ws is None or self._ws.closed:
return
async for ws_msg in self._ws:
if ws_msg.type == aiohttp.WSMsgType.TEXT:
try:
data = json.loads(ws_msg.data)
if data.get("type") == "event":
await self._handle_ha_event(data.get("event", {}))
except json.JSONDecodeError:
logger.debug("Invalid JSON from HA WS: %s", ws_msg.data[:200])
elif ws_msg.type in {aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR}:
break
async def _handle_ha_event(self, event: Dict[str, Any]) -> None:
"""Process a state_changed event from Home Assistant."""
event_data = event.get("data", {})
entity_id: str = event_data.get("entity_id", "")
if not entity_id:
return
# Apply ignore filter
if entity_id in self._ignore_entities:
return
# Apply domain/entity watch filters (closed by default — require
# explicit watch_domains, watch_entities, or watch_all to forward)
domain = entity_id.split(".")[0] if "." in entity_id else ""
if self._watch_domains or self._watch_entities:
domain_match = domain in self._watch_domains if self._watch_domains else False
entity_match = entity_id in self._watch_entities if self._watch_entities else False
if not domain_match and not entity_match:
return
elif not self._watch_all:
# No filters configured and watch_all is off — drop the event
return
# Apply cooldown
now = time.time()
last = self._last_event_time.get(entity_id, 0)
if (now - last) < self._cooldown_seconds:
return
self._last_event_time[entity_id] = now
# Build human-readable message
old_state = event_data.get("old_state", {})
new_state = event_data.get("new_state", {})
message = self._format_state_change(entity_id, old_state, new_state)
if not message:
return
# Build MessageEvent and forward to handler
source = self.build_source(
chat_id="ha_events",
chat_name="Home Assistant Events",
chat_type="channel",
user_id="homeassistant",
user_name="Home Assistant",
)
msg_event = MessageEvent(
text=message,
message_type=MessageType.TEXT,
source=source,
message_id=f"ha_{entity_id}_{int(now)}",
timestamp=datetime.now(),
)
await self.handle_message(msg_event)
@staticmethod
def _format_state_change(
entity_id: str,
old_state: Dict[str, Any],
new_state: Dict[str, Any],
) -> Optional[str]:
"""Convert a state_changed event into a human-readable description."""
if not new_state:
return None
old_val = old_state.get("state", "unknown") if old_state else "unknown"
new_val = new_state.get("state", "unknown")
# Skip if state didn't actually change
if old_val == new_val:
return None
friendly_name = new_state.get("attributes", {}).get("friendly_name", entity_id)
domain = entity_id.split(".")[0] if "." in entity_id else ""
# Domain-specific formatting
if domain == "climate":
attrs = new_state.get("attributes", {})
temp = attrs.get("current_temperature", "?")
target = attrs.get("temperature", "?")
return (
f"[Home Assistant] {friendly_name}: HVAC mode changed from "
f"'{old_val}' to '{new_val}' (current: {temp}, target: {target})"
)
if domain == "sensor":
unit = new_state.get("attributes", {}).get("unit_of_measurement", "")
return (
f"[Home Assistant] {friendly_name}: changed from "
f"{old_val}{unit} to {new_val}{unit}"
)
if domain == "binary_sensor":
return (
f"[Home Assistant] {friendly_name}: "
f"{'triggered' if new_val == 'on' else 'cleared'} "
f"(was {'triggered' if old_val == 'on' else 'cleared'})"
)
if domain in {"light", "switch", "fan"}:
return (
f"[Home Assistant] {friendly_name}: turned "
f"{'on' if new_val == 'on' else 'off'}"
)
if domain == "alarm_control_panel":
return (
f"[Home Assistant] {friendly_name}: alarm state changed from "
f"'{old_val}' to '{new_val}'"
)
# Generic fallback
return (
f"[Home Assistant] {friendly_name} ({entity_id}): "
f"changed from '{old_val}' to '{new_val}'"
)
# ------------------------------------------------------------------
# Outbound messaging
# ------------------------------------------------------------------
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a notification via HA REST API (persistent_notification.create).
Uses the REST API instead of WebSocket to avoid a race condition
with the event listener loop that reads from the same WS connection.
"""
url = f"{self._hass_url}/api/services/persistent_notification/create"
headers = {
"Authorization": f"Bearer {self._hass_token}",
"Content-Type": "application/json",
}
payload = {
"title": "Hermes Agent",
"message": content[:self.MAX_MESSAGE_LENGTH],
}
try:
if self._rest_session:
async with self._rest_session.post(
url,
headers=headers,
json=payload,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status < 300:
return SendResult(success=True, message_id=uuid.uuid4().hex[:12])
else:
body = await resp.text()
return SendResult(success=False, error=f"HTTP {resp.status}: {body}")
else:
async with aiohttp.ClientSession() as session:
async with session.post(
url,
headers=headers,
json=payload,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status < 300:
return SendResult(success=True, message_id=uuid.uuid4().hex[:12])
else:
body = await resp.text()
return SendResult(success=False, error=f"HTTP {resp.status}: {body}")
except asyncio.TimeoutError:
return SendResult(success=False, error="Timeout sending notification to HA")
except Exception as e:
return SendResult(success=False, error=str(e))
async def send_typing(self, chat_id: str, metadata=None) -> None:
"""No typing indicator for Home Assistant."""
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Return basic info about the HA event channel."""
return {
"name": "Home Assistant Events",
"type": "channel",
"url": self._hass_url,
}
+138 -20
View File
@@ -107,6 +107,75 @@ from gateway.platforms.helpers import ThreadParticipationTracker
logger = logging.getLogger(__name__)
_MATRIX_BANG_COMMAND_RE = re.compile(
r"^!([A-Za-z][A-Za-z0-9_-]*)(?=$|\s)(.*)$",
re.DOTALL,
)
def _resolve_matrix_bang_command(name: str) -> str | None:
"""Resolve a ``!command`` token to a dispatchable Hermes command token.
Matrix clients often reserve leading ``/`` for local client commands.
Hermes accepts ``!command`` as a Matrix-friendly alias, but only for
commands that the gateway can actually dispatch so ordinary exclamations
remain normal chat text.
Returns the token form that actually resolves (which may differ from
*name* only by underscorehyphen normalization, e.g. ``reload_skills``
``reload-skills``) so the emitted ``/command`` always resolves downstream,
or ``None`` when *name* is not a known command. Aliases are intentionally
left as-is the gateway dispatcher resolves them to their canonical name.
"""
if not name:
return None
# Try the raw lowercased token first, then its hyphenated variant, so
# forms like ``!reload_skills`` resolve against ``reload-skills``. We emit
# whichever candidate resolved (not a forced canonical form) to preserve
# alias passthrough — the gateway dispatcher canonicalizes aliases itself.
candidates = [name.lower()]
hyphenated = name.lower().replace("_", "-")
if hyphenated != candidates[0]:
candidates.append(hyphenated)
try:
from hermes_cli.commands import is_gateway_known_command
for candidate in candidates:
if is_gateway_known_command(candidate):
return candidate
except Exception:
logger.debug(
"Matrix: is_gateway_known_command failed for %r", name, exc_info=True
)
try:
from agent.skill_commands import get_skill_commands
skill_commands = get_skill_commands() or {}
# Skill command keys are stored slash-prefixed (e.g. "/arxiv"), so
# compare against the "/candidate" form, not the bare token.
for candidate in candidates:
if f"/{candidate}" in skill_commands:
return candidate
except Exception:
logger.debug("Matrix: get_skill_commands failed for %r", name, exc_info=True)
return None
def _normalize_matrix_bang_command(text: str) -> str:
"""Convert Matrix ``!command`` aliases to normal Hermes ``/command`` text."""
if not text or not text.startswith("!"):
return text
match = _MATRIX_BANG_COMMAND_RE.match(text)
if not match:
return text
resolved = _resolve_matrix_bang_command(match.group(1))
if resolved is None:
return text
return f"/{resolved}{match.group(2) or ''}"
@dataclass
class _MatrixApprovalPrompt:
@@ -138,7 +207,8 @@ _OUTBOUND_MENTION_RE = re.compile(
)
_E2EE_INSTALL_HINT = (
"Install with: pip install 'mautrix[encryption]' (requires libolm C library)"
"Install with: pip install 'mautrix[encryption]' asyncpg aiosqlite "
"(requires libolm C library)"
)
_MATRIX_IMAGE_FILENAME_EXTS = frozenset({
@@ -214,9 +284,22 @@ def _create_matrix_session(proxy_url: str | None):
def _check_e2ee_deps() -> bool:
"""Return True if mautrix E2EE dependencies (python-olm) are available."""
"""Return True if mautrix E2EE dependencies are available.
Verifies python-olm (via mautrix.crypto.OlmMachine), the SQLite crypto
store backend (mautrix.crypto.store.asyncpg.PgCryptoStore yes, the
PgCryptoStore class also drives the sqlite backend in mautrix 0.21),
and the database drivers actually used at connect time (``asyncpg`` for
the underlying upgrade_table machinery, ``aiosqlite`` for the
``sqlite:///`` URL we pass to ``Database.create``). Without all four,
encrypted rooms fail at connect time with a confusing
``No module named 'asyncpg'`` (#31116).
"""
try:
from mautrix.crypto import OlmMachine # noqa: F401
from mautrix.crypto.store.asyncpg import PgCryptoStore # noqa: F401
import asyncpg # noqa: F401
import aiosqlite # noqa: F401
return True
except (ImportError, AttributeError):
@@ -226,8 +309,13 @@ def _check_e2ee_deps() -> bool:
def check_matrix_requirements() -> bool:
"""Return True if the Matrix adapter can be used.
Lazy-installs mautrix via ``tools.lazy_deps.ensure("platform.matrix")``
on first call if not present. Rebinds all module-level type globals on success.
Lazy-installs the full ``platform.matrix`` feature group via
``tools.lazy_deps.ensure_and_bind`` whenever any of the declared
packages (mautrix, Markdown, aiosqlite, asyncpg, aiohttp-socks) is
missing not just mautrix itself. Previously this short-circuited on
``import mautrix``, which left the other four packages uninstalled
forever and broke E2EE connect with ``No module named 'asyncpg'``
(#31116). Rebinds module-level type globals on success.
"""
token = os.getenv("MATRIX_ACCESS_TOKEN", "")
password = os.getenv("MATRIX_PASSWORD", "")
@@ -239,9 +327,20 @@ def check_matrix_requirements() -> bool:
if not homeserver:
logger.warning("Matrix: MATRIX_HOMESERVER not set")
return False
# Check whether any package in the platform.matrix feature group is
# missing. ``feature_missing`` is cheap (per-spec importlib.metadata
# lookups) and correctly handles ``mautrix[encryption]`` by stripping
# the extras marker before checking the bare package.
try:
import mautrix # noqa: F401
except ImportError:
from tools.lazy_deps import feature_missing, ensure_and_bind
missing = feature_missing("platform.matrix")
except Exception as exc: # pragma: no cover — defensive
logger.debug("Matrix: lazy_deps lookup failed: %s", exc)
missing = ()
ensure_and_bind = None # type: ignore[assignment]
if missing or ensure_and_bind is None:
def _import():
from mautrix.types import (
ContentURI, EventID, EventType, PaginationDirection,
@@ -261,10 +360,14 @@ def check_matrix_requirements() -> bool:
"UserID": UserID,
}
from tools.lazy_deps import ensure_and_bind
if ensure_and_bind is None:
return False
if not ensure_and_bind("platform.matrix", _import, globals(), prompt=False):
logger.warning(
"Matrix: mautrix not installed. Run: pip install 'mautrix[encryption]'"
"Matrix: required packages not installed (%s). "
"Run: pip install 'mautrix[encryption]' asyncpg aiosqlite "
"Markdown aiohttp-socks",
", ".join(missing) if missing else "platform.matrix",
)
return False
@@ -317,6 +420,13 @@ class _CryptoStateStore:
class MatrixAdapter(BasePlatformAdapter):
"""Gateway adapter for Matrix (any homeserver)."""
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
# Matrix clients commonly reserve typed "/" for client-local commands;
# the adapter accepts "!command" as the alias that always reaches Hermes
# (see _normalize_matrix_bang_command), so instruction text shows "!".
typed_command_prefix = "!"
# Threshold for detecting Matrix client-side message splits.
# When a chunk is near the ~4000-char practical limit, a continuation
# is almost certain.
@@ -1245,11 +1355,11 @@ class MatrixAdapter(BasePlatformAdapter):
"⚠️ **Dangerous command requires approval**\n"
f"```\n{cmd_preview}\n```\n"
f"Reason: {description}\n\n"
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
"Reply `!approve` to execute, `!approve session` to approve this pattern for the session, "
"`!approve always` to approve permanently, or `!deny` to cancel.\n\n"
"You can also click the reaction to approve:\n"
"✅ = /approve\n"
"❎ = /deny"
"✅ = approve\n"
"❎ = deny"
)
result = await self.send(chat_id, text, metadata=metadata)
@@ -1713,8 +1823,9 @@ class MatrixAdapter(BasePlatformAdapter):
is_free_room = room_id in self._free_rooms
in_bot_thread = bool(thread_id and thread_id in self._threads)
is_command = body.startswith("/")
if self._require_mention and not is_free_room and not in_bot_thread:
if not is_mentioned:
if not is_mentioned and not is_command:
logger.debug(
"Matrix: ignoring message %s in %s — no @mention "
"(set MATRIX_REQUIRE_MENTION=false to disable)",
@@ -1781,6 +1892,7 @@ class MatrixAdapter(BasePlatformAdapter):
body = source_content.get("body", "") or ""
if not body:
return
body = _normalize_matrix_bang_command(body)
ctx = await self._resolve_message_context(
room_id,
@@ -1816,8 +1928,13 @@ class MatrixAdapter(BasePlatformAdapter):
stripped.append(line)
body = "\n".join(stripped) if stripped else body
# Re-run bang normalization after reply-fallback stripping so a quoted
# reply whose actual content is a bang command (e.g. ``> quoted\n\n!model``)
# is treated as a command, matching how ``/command`` is recognized below.
body = _normalize_matrix_bang_command(body)
msg_type = MessageType.TEXT
if body.startswith(("!", "/")):
if body.startswith("/"):
msg_type = MessageType.COMMAND
msg_event = MessageEvent(
@@ -2202,7 +2319,8 @@ class MatrixAdapter(BasePlatformAdapter):
if prompt and not prompt.resolved:
if room_id != prompt.chat_id:
return
if self._allowed_user_ids and sender not in self._allowed_user_ids:
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
if not _allow_all and not (self._allowed_user_ids and sender in self._allowed_user_ids):
logger.info(
"Matrix: ignoring approval reaction from unauthorized user %s on %s",
sender, reacts_to,
@@ -2688,11 +2806,11 @@ class MatrixAdapter(BasePlatformAdapter):
def _markdown_to_html(self, text: str) -> str:
"""Convert Markdown to Matrix-compatible HTML (org.matrix.custom.html).
Uses the ``markdown`` library when available (installed with the
``matrix`` extra). Falls back to a comprehensive regex converter
that handles fenced code blocks, inline code, headers, bold,
italic, strikethrough, links, blockquotes, lists, and horizontal
rules everything the Matrix HTML spec allows.
Uses the ``markdown`` library (a core dependency) when available.
Falls back to a comprehensive regex converter that handles fenced
code blocks, inline code, headers, bold, italic, strikethrough,
links, blockquotes, lists, and horizontal rules everything the
Matrix HTML spec allows.
"""
try:
import markdown as _md
-873
View File
@@ -1,873 +0,0 @@
"""Mattermost gateway adapter.
Connects to a self-hosted (or cloud) Mattermost instance via its REST API
(v4) and WebSocket for real-time events. No external Mattermost library
required uses aiohttp which is already a Hermes dependency.
Environment variables:
MATTERMOST_URL Server URL (e.g. https://mm.example.com)
MATTERMOST_TOKEN Bot token or personal-access token
MATTERMOST_ALLOWED_USERS Comma-separated user IDs
MATTERMOST_HOME_CHANNEL Channel ID for cron/notification delivery
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from gateway.config import Platform, PlatformConfig
from gateway.platforms.helpers import MessageDeduplicator
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)
logger = logging.getLogger(__name__)
# Mattermost post size limit (server default is 16383, but 4000 is the
# practical limit for readable messages — matching OpenClaw's choice).
MAX_POST_LENGTH = 4000
# Channel type codes returned by the Mattermost API.
_CHANNEL_TYPE_MAP = {
"D": "dm",
"G": "group",
"P": "group", # private channel → treat as group
"O": "channel",
}
# Reconnect parameters (exponential backoff).
_RECONNECT_BASE_DELAY = 2.0
_RECONNECT_MAX_DELAY = 60.0
_RECONNECT_JITTER = 0.2
def check_mattermost_requirements() -> bool:
"""Return True if the Mattermost adapter can be used."""
token = os.getenv("MATTERMOST_TOKEN", "")
url = os.getenv("MATTERMOST_URL", "")
if not token:
logger.debug("Mattermost: MATTERMOST_TOKEN not set")
return False
if not url:
logger.warning("Mattermost: MATTERMOST_URL not set")
return False
try:
import aiohttp # noqa: F401
return True
except ImportError:
logger.warning("Mattermost: aiohttp not installed")
return False
class MattermostAdapter(BasePlatformAdapter):
"""Gateway adapter for Mattermost (self-hosted or cloud)."""
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.MATTERMOST)
self._base_url: str = (
config.extra.get("url", "")
or os.getenv("MATTERMOST_URL", "")
).rstrip("/")
self._token: str = config.token or os.getenv("MATTERMOST_TOKEN", "")
self._bot_user_id: str = ""
self._bot_username: str = ""
# aiohttp session + websocket handle
self._session: Any = None # aiohttp.ClientSession
self._ws: Any = None # aiohttp.ClientWebSocketResponse
self._ws_task: Optional[asyncio.Task] = None
self._reconnect_task: Optional[asyncio.Task] = None
self._closing = False
# Reply mode: "thread" to nest replies, "off" for flat messages.
self._reply_mode: str = (
config.extra.get("reply_mode", "")
or os.getenv("MATTERMOST_REPLY_MODE", "off")
).lower()
# Dedup cache (prevent reprocessing)
self._dedup = MessageDeduplicator()
# ------------------------------------------------------------------
# HTTP helpers
# ------------------------------------------------------------------
def _headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
async def _api_get(self, path: str) -> Dict[str, Any]:
"""GET /api/v4/{path}."""
import aiohttp
url = f"{self._base_url}/api/v4/{path.lstrip('/')}"
try:
async with self._session.get(url, headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status >= 400:
body = await resp.text()
logger.error("MM API GET %s%s: %s", path, resp.status, body[:200])
return {}
return await resp.json()
except aiohttp.ClientError as exc:
logger.error("MM API GET %s network error: %s", path, exc)
return {}
async def _api_post(
self, path: str, payload: Dict[str, Any]
) -> Dict[str, Any]:
"""POST /api/v4/{path} with JSON body."""
import aiohttp
url = f"{self._base_url}/api/v4/{path.lstrip('/')}"
try:
async with self._session.post(
url, headers=self._headers(), json=payload,
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status >= 400:
body = await resp.text()
logger.error("MM API POST %s%s: %s", path, resp.status, body[:200])
return {}
return await resp.json()
except aiohttp.ClientError as exc:
logger.error("MM API POST %s network error: %s", path, exc)
return {}
async def _api_put(
self, path: str, payload: Dict[str, Any]
) -> Dict[str, Any]:
"""PUT /api/v4/{path} with JSON body."""
import aiohttp
url = f"{self._base_url}/api/v4/{path.lstrip('/')}"
try:
async with self._session.put(
url, headers=self._headers(), json=payload
) as resp:
if resp.status >= 400:
body = await resp.text()
logger.error("MM API PUT %s%s: %s", path, resp.status, body[:200])
return {}
return await resp.json()
except aiohttp.ClientError as exc:
logger.error("MM API PUT %s network error: %s", path, exc)
return {}
async def _upload_file(
self, channel_id: str, file_data: bytes, filename: str, content_type: str = "application/octet-stream"
) -> Optional[str]:
"""Upload a file and return its file ID, or None on failure."""
import aiohttp
url = f"{self._base_url}/api/v4/files"
form = aiohttp.FormData()
form.add_field("channel_id", channel_id)
form.add_field(
"files",
file_data,
filename=filename,
content_type=content_type,
)
headers = {"Authorization": f"Bearer {self._token}"}
async with self._session.post(url, headers=headers, data=form, timeout=aiohttp.ClientTimeout(total=60)) as resp:
if resp.status >= 400:
body = await resp.text()
logger.error("MM file upload → %s: %s", resp.status, body[:200])
return None
data = await resp.json()
infos = data.get("file_infos", [])
return infos[0]["id"] if infos else None
# ------------------------------------------------------------------
# Required overrides
# ------------------------------------------------------------------
async def connect(self) -> bool:
"""Connect to Mattermost and start the WebSocket listener."""
import aiohttp
if not self._base_url or not self._token:
logger.error("Mattermost: URL or token not configured")
return False
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=30)
)
self._closing = False
# Verify credentials and fetch bot identity.
me = await self._api_get("users/me")
if not me or "id" not in me:
logger.error("Mattermost: failed to authenticate — check MATTERMOST_TOKEN and MATTERMOST_URL")
await self._session.close()
return False
self._bot_user_id = me["id"]
self._bot_username = me.get("username", "")
logger.info(
"Mattermost: authenticated as @%s (%s) on %s",
self._bot_username,
self._bot_user_id,
self._base_url,
)
# Start WebSocket in background.
self._ws_task = asyncio.create_task(self._ws_loop())
self._mark_connected()
return True
async def disconnect(self) -> None:
"""Disconnect from Mattermost."""
self._closing = True
if self._ws_task and not self._ws_task.done():
self._ws_task.cancel()
try:
await self._ws_task
except (asyncio.CancelledError, Exception):
pass
if self._reconnect_task and not self._reconnect_task.done():
self._reconnect_task.cancel()
if self._ws:
await self._ws.close()
self._ws = None
if self._session and not self._session.closed:
await self._session.close()
logger.info("Mattermost: disconnected")
async def _resolve_root_id(self, post_id: str) -> str:
"""Resolve a post_id to the thread root_id for Mattermost.
Mattermost requires root_id to be the *root* post of a thread.
If the post is a reply (has its own root_id), we must use that
root_id instead. Using a reply's own ID as root_id causes
"Invalid RootId parameter" errors.
"""
if not post_id:
return post_id
# Check if this post has a root_id (meaning it's a reply)
data = await self._api_get(f"posts/{post_id}")
if data and data.get("root_id"):
return data["root_id"]
return post_id
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Send a message (or multiple chunks) to a channel."""
if not content:
return SendResult(success=True)
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, MAX_POST_LENGTH)
last_id = None
for chunk in chunks:
payload: Dict[str, Any] = {
"channel_id": chat_id,
"message": chunk,
}
# Thread support: reply_to is the root post ID.
if reply_to and self._reply_mode == "thread":
# Ensure root_id points to the thread root, not a reply.
# Mattermost rejects non-root post IDs as root_id.
resolved_root = await self._resolve_root_id(reply_to)
payload["root_id"] = resolved_root
data = await self._api_post("posts", payload)
if not data or "id" not in data:
return SendResult(success=False, error="Failed to create post")
last_id = data["id"]
return SendResult(success=True, message_id=last_id)
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
"""Return channel name and type."""
data = await self._api_get(f"channels/{chat_id}")
if not data:
return {"name": chat_id, "type": "channel"}
ch_type = _CHANNEL_TYPE_MAP.get(data.get("type", "O"), "channel")
display_name = data.get("display_name") or data.get("name") or chat_id
return {"name": display_name, "type": ch_type}
# ------------------------------------------------------------------
# Optional overrides
# ------------------------------------------------------------------
async def send_typing(
self, chat_id: str, metadata: Optional[Dict[str, Any]] = None
) -> None:
"""Send a typing indicator."""
await self._api_post(
f"users/{self._bot_user_id}/typing",
{"channel_id": chat_id},
)
async def edit_message(
self, chat_id: str, message_id: str, content: str, *, finalize: bool = False
) -> SendResult:
"""Edit an existing post."""
formatted = self.format_message(content)
data = await self._api_put(
f"posts/{message_id}/patch",
{"message": formatted},
)
if not data or "id" not in data:
return SendResult(success=False, error="Failed to edit post")
return SendResult(success=True, message_id=data["id"])
async def send_image(
self,
chat_id: str,
image_url: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Download an image and upload it as a file attachment."""
return await self._send_url_as_file(
chat_id, image_url, caption, reply_to, "image"
)
async def send_image_file(
self,
chat_id: str,
image_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload a local image file."""
return await self._send_local_file(
chat_id, image_path, caption, reply_to
)
async def send_document(
self,
chat_id: str,
file_path: str,
caption: Optional[str] = None,
file_name: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload a local file as a document."""
return await self._send_local_file(
chat_id, file_path, caption, reply_to, file_name
)
async def send_voice(
self,
chat_id: str,
audio_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload an audio file."""
return await self._send_local_file(
chat_id, audio_path, caption, reply_to
)
async def send_video(
self,
chat_id: str,
video_path: str,
caption: Optional[str] = None,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
"""Upload a video file."""
return await self._send_local_file(
chat_id, video_path, caption, reply_to
)
def format_message(self, content: str) -> str:
"""Mattermost uses standard Markdown — mostly pass through.
Strip image markdown into plain links (files are uploaded separately).
"""
# Convert ![alt](url) to just the URL — Mattermost renders
# image URLs as inline previews automatically.
content = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r"\2", content)
return content
# ------------------------------------------------------------------
# File helpers
# ------------------------------------------------------------------
async def _send_url_as_file(
self,
chat_id: str,
url: str,
caption: Optional[str],
reply_to: Optional[str],
kind: str = "file",
) -> SendResult:
"""Download a URL and upload it as a file attachment."""
from tools.url_safety import is_safe_url
if not is_safe_url(url):
logger.warning("Mattermost: blocked unsafe URL (SSRF protection)")
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
import aiohttp
file_data = None
ct = "application/octet-stream"
fname = url.rsplit("/", 1)[-1].split("?")[0] or f"{kind}.png"
for attempt in range(3):
try:
async with self._session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status >= 500 or resp.status == 429:
if attempt < 2:
logger.debug("Mattermost download retry %d/2 for %s (status %d)",
attempt + 1, url[:80], resp.status)
await asyncio.sleep(1.5 * (attempt + 1))
continue
if resp.status >= 400:
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
file_data = await resp.read()
ct = resp.content_type or "application/octet-stream"
break
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
if attempt < 2:
await asyncio.sleep(1.5 * (attempt + 1))
continue
logger.warning("Mattermost: failed to download %s after %d attempts: %s", url, attempt + 1, exc)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
if file_data is None:
logger.warning("Mattermost: download returned no data for %s", url)
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
file_id = await self._upload_file(chat_id, file_data, fname, ct)
if not file_id:
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to)
payload: Dict[str, Any] = {
"channel_id": chat_id,
"message": caption or "",
"file_ids": [file_id],
}
if reply_to and self._reply_mode == "thread":
payload["root_id"] = await self._resolve_root_id(reply_to)
data = await self._api_post("posts", payload)
if not data or "id" not in data:
return SendResult(success=False, error="Failed to post with file")
return SendResult(success=True, message_id=data["id"])
async def _send_local_file(
self,
chat_id: str,
file_path: str,
caption: Optional[str],
reply_to: Optional[str],
file_name: Optional[str] = None,
) -> SendResult:
"""Upload a local file and attach it to a post."""
import mimetypes
p = Path(file_path)
if not p.exists():
logger.warning(
"Mattermost: local file not found, skipping: %s", file_path
)
return SendResult(success=True, message_id=None)
fname = file_name or p.name
ct = mimetypes.guess_type(fname)[0] or "application/octet-stream"
file_data = p.read_bytes()
file_id = await self._upload_file(chat_id, file_data, fname, ct)
if not file_id:
return SendResult(success=False, error="File upload failed")
payload: Dict[str, Any] = {
"channel_id": chat_id,
"message": caption or "",
"file_ids": [file_id],
}
if reply_to and self._reply_mode == "thread":
payload["root_id"] = await self._resolve_root_id(reply_to)
data = await self._api_post("posts", payload)
if not data or "id" not in data:
return SendResult(success=False, error="Failed to post with file")
return SendResult(success=True, message_id=data["id"])
async def send_multiple_images(
self,
chat_id: str,
images: List[Tuple[str, str]],
metadata: Optional[Dict[str, Any]] = None,
human_delay: float = 0.0,
) -> None:
"""Send a batch of images as a single Mattermost post with multiple attachments.
Mattermost supports up to 5 ``file_ids`` per post. Each image is
uploaded individually (Mattermost's file API is one-at-a-time),
then a single post is created referencing all uploaded file_ids
at once. Batches larger than 5 are chunked. Falls back to the
base per-image loop on total failure.
"""
if not images:
return
import mimetypes
import aiohttp
from urllib.parse import unquote as _unquote
CHUNK = 5 # Mattermost post file_ids cap
chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)]
for chunk_idx, chunk in enumerate(chunks):
if human_delay > 0 and chunk_idx > 0:
await asyncio.sleep(human_delay)
file_ids: List[str] = []
caption_parts: List[str] = []
try:
for image_url, alt_text in chunk:
if alt_text:
caption_parts.append(alt_text)
if image_url.startswith("file://"):
local_path = _unquote(image_url[7:])
p = Path(local_path)
if not p.exists():
logger.warning("Mattermost: skipping missing image %s", local_path)
continue
fname = p.name
ct = mimetypes.guess_type(fname)[0] or "image/png"
file_data = p.read_bytes()
else:
from tools.url_safety import is_safe_url
if not is_safe_url(image_url):
logger.warning("Mattermost: blocked unsafe image URL in batch")
continue
try:
async with self._session.get(
image_url, timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status >= 400:
logger.warning(
"Mattermost: failed to download image (HTTP %d): %s",
resp.status, image_url[:80],
)
continue
file_data = await resp.read()
ct = resp.content_type or "image/png"
except Exception as dl_err:
logger.warning("Mattermost: download failed for %s: %s", image_url[:80], dl_err)
continue
fname = image_url.rsplit("/", 1)[-1].split("?")[0] or f"image_{len(file_ids)}.png"
fid = await self._upload_file(chat_id, file_data, fname, ct)
if fid:
file_ids.append(fid)
if not file_ids:
continue
payload: Dict[str, Any] = {
"channel_id": chat_id,
"message": "\n".join(caption_parts),
"file_ids": file_ids,
}
logger.info(
"Mattermost: sending %d image(s) as single post (chunk %d/%d)",
len(file_ids), chunk_idx + 1, len(chunks),
)
data = await self._api_post("posts", payload)
if not data or "id" not in data:
logger.warning("Mattermost: multi-image post failed, falling back")
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
except Exception as e:
logger.warning(
"Mattermost: multi-image send failed (chunk %d/%d), falling back: %s",
chunk_idx + 1, len(chunks), e, exc_info=True,
)
await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay)
# ------------------------------------------------------------------
# WebSocket
# ------------------------------------------------------------------
async def _ws_loop(self) -> None:
"""Connect to the WebSocket and listen for events, reconnecting on failure."""
delay = _RECONNECT_BASE_DELAY
while not self._closing:
try:
await self._ws_connect_and_listen()
# Clean disconnect — reset delay.
delay = _RECONNECT_BASE_DELAY
except asyncio.CancelledError:
return
except Exception as exc:
if self._closing:
return
# Detect permanent auth/permission failures that will never
# succeed on retry — stop reconnecting instead of looping forever.
import aiohttp
err_str = str(exc).lower()
if isinstance(exc, aiohttp.WSServerHandshakeError) and exc.status in {401, 403}:
logger.error("Mattermost WS auth failed (HTTP %d) — stopping reconnect", exc.status)
return
if "401" in err_str or "403" in err_str or "unauthorized" in err_str:
logger.error("Mattermost WS permanent error: %s — stopping reconnect", exc)
return
logger.warning("Mattermost WS error: %s — reconnecting in %.0fs", exc, delay)
if self._closing:
return
# Exponential backoff with jitter.
import random
jitter = delay * _RECONNECT_JITTER * random.random()
await asyncio.sleep(delay + jitter)
delay = min(delay * 2, _RECONNECT_MAX_DELAY)
async def _ws_connect_and_listen(self) -> None:
"""Single WebSocket session: connect, authenticate, process events."""
# Build WS URL: https:// → wss://, http:// → ws://
ws_url = re.sub(r"^http", "ws", self._base_url) + "/api/v4/websocket"
logger.info("Mattermost: connecting to %s", ws_url)
self._ws = await self._session.ws_connect(ws_url, heartbeat=30.0)
# Authenticate via the WebSocket.
auth_msg = {
"seq": 1,
"action": "authentication_challenge",
"data": {"token": self._token},
}
await self._ws.send_json(auth_msg)
logger.info("Mattermost: WebSocket connected and authenticated")
async for raw_msg in self._ws:
if self._closing:
return
if raw_msg.type in {
raw_msg.type.TEXT,
raw_msg.type.BINARY,
}:
try:
event = json.loads(raw_msg.data)
except (json.JSONDecodeError, TypeError):
continue
await self._handle_ws_event(event)
elif raw_msg.type in {
raw_msg.type.ERROR,
raw_msg.type.CLOSE,
raw_msg.type.CLOSING,
raw_msg.type.CLOSED,
}:
logger.info("Mattermost: WebSocket closed (%s)", raw_msg.type)
break
async def _handle_ws_event(self, event: Dict[str, Any]) -> None:
"""Process a single WebSocket event."""
event_type = event.get("event")
if event_type != "posted":
return
data = event.get("data", {})
raw_post_str = data.get("post")
if not raw_post_str:
return
try:
post = json.loads(raw_post_str)
except (json.JSONDecodeError, TypeError):
return
# Ignore own messages.
if post.get("user_id") == self._bot_user_id:
return
# Ignore system posts.
if post.get("type"):
return
post_id = post.get("id", "")
# Dedup.
if self._dedup.is_duplicate(post_id):
return
# Build message event.
channel_id = post.get("channel_id", "")
channel_type_raw = data.get("channel_type", "O")
chat_type = _CHANNEL_TYPE_MAP.get(channel_type_raw, "channel")
# For DMs, user_id is sufficient. For channels, check for @mention.
message_text = post.get("message", "")
# Mention-gating for non-DM channels.
# Config (config.yaml `mattermost.*` with env-var fallback):
# require_mention / MATTERMOST_REQUIRE_MENTION: Require @mention in channels (default: true)
# free_response_channels / MATTERMOST_FREE_RESPONSE_CHANNELS: Channel IDs where bot responds without mention
# allowed_channels / MATTERMOST_ALLOWED_CHANNELS: If set, bot ONLY responds in these channels (whitelist)
if channel_type_raw != "D":
# allowed_channels check (whitelist — must pass before other gating).
# When set, messages from channels NOT in this list are silently
# ignored, even if @mentioned. DMs are already excluded above.
allowed_raw = self.config.extra.get("allowed_channels") if self.config.extra else None
if allowed_raw is None:
allowed_raw = os.getenv("MATTERMOST_ALLOWED_CHANNELS", "")
if isinstance(allowed_raw, list):
allowed_channels = {str(c).strip() for c in allowed_raw if str(c).strip()}
else:
allowed_channels = {
c.strip() for c in str(allowed_raw).split(",") if c.strip()
}
if allowed_channels and channel_id not in allowed_channels:
logger.debug(
"Mattermost: ignoring message in non-allowed channel: %s",
channel_id,
)
return
require_mention = os.getenv(
"MATTERMOST_REQUIRE_MENTION", "true"
).lower() not in {"false", "0", "no"}
free_channels_raw = os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS", "")
free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
is_free_channel = channel_id in free_channels
mention_patterns = [
f"@{self._bot_username}",
f"@{self._bot_user_id}",
]
has_mention = any(
pattern.lower() in message_text.lower()
for pattern in mention_patterns
)
if require_mention and not is_free_channel and not has_mention:
logger.debug(
"Mattermost: skipping non-DM message without @mention (channel=%s)",
channel_id,
)
return
# Strip @mention from the message text so the agent sees clean input.
if has_mention:
for pattern in mention_patterns:
message_text = re.sub(
re.escape(pattern), "", message_text, flags=re.IGNORECASE
).strip()
# Resolve sender info.
sender_id = post.get("user_id", "")
sender_name = data.get("sender_name", "").lstrip("@") or sender_id
# Thread support: if the post is in a thread, use root_id.
thread_id = post.get("root_id") or None
# Determine message type.
file_ids = post.get("file_ids") or []
msg_type = MessageType.TEXT
if message_text.startswith("/"):
msg_type = MessageType.COMMAND
# Download file attachments immediately (URLs require auth headers
# that downstream tools won't have).
media_urls: List[str] = []
media_types: List[str] = []
for fid in file_ids:
try:
file_info = await self._api_get(f"files/{fid}/info")
fname = file_info.get("name", f"file_{fid}")
ext = Path(fname).suffix or ""
mime = file_info.get("mime_type", "application/octet-stream")
import aiohttp
dl_url = f"{self._base_url}/api/v4/files/{fid}"
async with self._session.get(
dl_url,
headers={"Authorization": f"Bearer {self._token}"},
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status < 400:
file_data = await resp.read()
from gateway.platforms.base import cache_image_from_bytes, cache_document_from_bytes
if mime.startswith("image/"):
local_path = cache_image_from_bytes(file_data, ext or ".png")
media_urls.append(local_path)
media_types.append(mime)
elif mime.startswith("audio/"):
from gateway.platforms.base import cache_audio_from_bytes
local_path = cache_audio_from_bytes(file_data, ext or ".ogg")
media_urls.append(local_path)
media_types.append(mime)
else:
local_path = cache_document_from_bytes(file_data, fname)
media_urls.append(local_path)
media_types.append(mime)
else:
logger.warning("Mattermost: failed to download file %s: HTTP %s", fid, resp.status)
except Exception as exc:
logger.warning("Mattermost: error downloading file %s: %s", fid, exc)
# Set message type based on downloaded media types.
if media_types and msg_type == MessageType.TEXT:
if any(m.startswith("image/") for m in media_types):
msg_type = MessageType.PHOTO
elif any(m.startswith("audio/") for m in media_types):
msg_type = MessageType.VOICE
elif media_types:
msg_type = MessageType.DOCUMENT
source = self.build_source(
chat_id=channel_id,
chat_type=chat_type,
user_id=sender_id,
user_name=sender_name,
thread_id=thread_id,
)
# Per-channel ephemeral prompt
from gateway.platforms.base import resolve_channel_prompt
_channel_prompt = resolve_channel_prompt(
self.config.extra, channel_id, None,
)
msg_event = MessageEvent(
text=message_text,
message_type=msg_type,
source=source,
raw_message=post,
message_id=post_id,
media_urls=media_urls if media_urls else None,
media_types=media_types if media_types else None,
channel_prompt=_channel_prompt,
)
await self.handle_message(msg_event)
+27 -3
View File
@@ -25,6 +25,7 @@ from gateway.platforms.base import (
MessageEvent,
MessageType,
SendResult,
is_network_accessible,
)
logger = logging.getLogger(__name__)
@@ -132,7 +133,25 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
def set_notification_scheduler(self, scheduler: Optional[NotificationScheduler]) -> None:
self._notification_scheduler = scheduler
def _source_allowlist_required_but_missing(self) -> bool:
return is_network_accessible(self._host) and not self._allowed_source_networks
async def connect(self) -> bool:
if self._client_state is None:
logger.error(
"[msgraph_webhook] Refusing to start without extra.client_state configured"
)
return False
if self._source_allowlist_required_but_missing():
logger.error(
"[msgraph_webhook] Refusing to start: binding to %s requires "
"extra.allowed_source_cidrs. Configure the Microsoft Graph "
"source CIDRs or bind to loopback (127.0.0.1/::1) behind a "
"tunnel or reverse proxy.",
self._host,
)
return False
app = web.Application()
app.router.add_get(self._health_path, self._handle_health)
app.router.add_get(self._webhook_path, self._handle_validation)
@@ -171,6 +190,8 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
return {"name": chat_id, "type": "webhook"}
async def _handle_health(self, request: "web.Request") -> "web.Response":
if not self._source_ip_allowed(request):
return web.Response(status=403)
return web.json_response(
{
"status": "ok",
@@ -265,9 +286,12 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
def _source_ip_allowed(self, request: "web.Request") -> bool:
"""Return True if the request's source IP is in the configured allowlist.
When ``allowed_source_cidrs`` is empty (the default), everything is
allowed preserves behavior for dev tunnels / localhost setups.
Loopback-only binds may omit ``allowed_source_cidrs`` for local reverse
proxies and dev tunnels. Network-accessible binds fail closed until an
explicit CIDR allowlist is configured.
"""
if self._source_allowlist_required_but_missing():
return False
if not self._allowed_source_networks:
return True
peer = request.remote or ""
@@ -310,7 +334,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
"""
expected = self._client_state
if expected is None:
return True
return False
provided = self._string_or_none(notification.get("clientState"))
if provided is None:
return False
+142 -17
View File
@@ -126,7 +126,6 @@ from gateway.platforms.qqbot.chunked_upload import (
)
from gateway.platforms.qqbot.keyboards import (
ApprovalRequest,
ApprovalSender,
InlineKeyboard,
InteractionEvent,
build_approval_keyboard,
@@ -270,6 +269,11 @@ class QQAdapter(BasePlatformAdapter):
def name(self) -> str:
return "QQBot"
@property
def enforces_own_access_policy(self) -> bool:
"""QQBot gates DM/group access at intake via dm_policy/group_policy."""
return True
# ------------------------------------------------------------------
# Connection lifecycle
# ------------------------------------------------------------------
@@ -534,9 +538,30 @@ class QQAdapter(BasePlatformAdapter):
self._mark_transport_disconnected()
self._fail_pending("Connection closed")
# Stop reconnecting for fatal codes
if code in {4914, 4915}:
desc = "offline/sandbox-only" if code == 4914 else "banned"
# Stop reconnecting for fatal codes (unrecoverable errors)
if code in {
4001, # Invalid opcode
4002, # Invalid payload
4010, # Invalid shard
4011, # Sharding required
4012, # Invalid API version
4013, # Invalid intent
4014, # Intent not authorized
4914, # Offline/sandbox-only
4915, # Banned
}:
fatal_descriptions = {
4001: "invalid opcode",
4002: "invalid payload",
4010: "invalid shard",
4011: "sharding required",
4012: "invalid API version",
4013: "invalid intent",
4014: "intent not authorized",
4914: "offline/sandbox-only",
4915: "banned",
}
desc = fatal_descriptions.get(code, f"fatal error (code={code})")
logger.error(
"[%s] Bot is %s. Check QQ Open Platform.", self._log_tag, desc
)
@@ -573,10 +598,11 @@ class QQAdapter(BasePlatformAdapter):
self._token_expires_at = 0.0
# Session invalid → clear session, will re-identify on next Hello
# Note: 4009 (connection timeout) is NOT included here — it is
# resumable per the QQ protocol and should preserve session state.
if code in {
4006,
4007,
4009,
4900,
4901,
4902,
@@ -655,6 +681,12 @@ class QQAdapter(BasePlatformAdapter):
"""Read WebSocket frames until connection closes."""
if not self._ws:
raise RuntimeError("WebSocket not connected")
if self._ws.closed:
# A closed-but-non-None ws makes the while-condition false on entry,
# so this would return normally — which _listen_loop treats as a
# clean read and immediately retries with backoff reset to 0,
# producing a 100% CPU spin. Raise so the reconnect/backoff path runs.
raise RuntimeError("WebSocket closed")
while self._running and self._ws and not self._ws.closed:
msg = await self._ws.receive()
@@ -705,9 +737,8 @@ class QQAdapter(BasePlatformAdapter):
"token": f"QQBot {token}",
"intents": (1 << 25)
| (1 << 30)
| (
1 << 12
), # C2C_GROUP_AT_MESSAGES + PUBLIC_GUILD_MESSAGES + DIRECT_MESSAGE
| (1 << 12)
| (1 << 26), # C2C_GROUP_AT_MESSAGES + PUBLIC_GUILD_MESSAGES + DIRECT_MESSAGE + INTERACTION
"shard": [0, 1],
"properties": {
"$os": "macOS",
@@ -826,6 +857,32 @@ class QQAdapter(BasePlatformAdapter):
if op == 11:
return
# op 7 = Server Reconnect — server asks client to reconnect (e.g.
# load-balancing, maintenance). Close the WS so _read_events raises
# and the outer loop triggers a reconnect with Resume.
if op == 7:
logger.info("[%s] Server requested reconnect (op 7)", self._log_tag)
if self._ws and not self._ws.closed:
self._create_task(self._ws.close())
return
# op 9 = Invalid Session — d=True means session is resumable,
# d=False means we must re-identify from scratch.
if op == 9:
resumable = bool(d) if d is not None else False
if not resumable:
logger.info(
"[%s] Invalid session (op 9, not resumable), clearing session",
self._log_tag,
)
self._session_id = None
self._last_seq = None
else:
logger.info("[%s] Invalid session (op 9, resumable)", self._log_tag)
if self._ws and not self._ws.closed:
self._create_task(self._ws.close())
return
logger.debug("[%s] Unknown op: %s", self._log_tag, op)
def _handle_ready(self, d: Any) -> None:
@@ -1007,6 +1064,46 @@ class QQAdapter(BasePlatformAdapter):
"deny": "deny",
}
@staticmethod
def _parse_gateway_session_key(session_key: str) -> Optional[Dict[str, str]]:
"""Parse ``agent:main:<platform>:<chat_type>:<chat_id>[:<user_id>]``."""
parts = str(session_key or "").split(":")
if len(parts) < 5 or parts[0] != "agent" or parts[1] != "main":
return None
parsed = {
"platform": parts[2],
"chat_type": parts[3],
"chat_id": parts[4],
}
if len(parts) > 5:
parsed["user_id"] = parts[5]
return parsed
def _is_authorized_interaction_for_session(
self,
event: InteractionEvent,
session_key: str,
) -> bool:
"""Authorize approval/update interactions against session + operator."""
parsed = self._parse_gateway_session_key(session_key)
operator = str(event.operator_openid or "").strip()
if not parsed or parsed.get("platform") != "qqbot" or not operator:
return False
chat_type = parsed.get("chat_type", "")
chat_id = parsed.get("chat_id", "")
if chat_type == "c2c":
return bool(chat_id) and operator == chat_id
if chat_type in {"group", "guild"}:
event_chat = str(event.group_openid or event.guild_id or "").strip()
if not event_chat or event_chat != chat_id:
return False
session_user = str(parsed.get("user_id", "")).strip()
return bool(session_user) and operator == session_user
return False
async def _default_interaction_dispatch(
self,
event: InteractionEvent,
@@ -1040,6 +1137,13 @@ class QQAdapter(BasePlatformAdapter):
self._log_tag, decision, session_key,
)
return
if not self._is_authorized_interaction_for_session(event, session_key):
logger.warning(
"[%s] Rejected unauthorized approval click for session %s "
"(operator=%s)",
self._log_tag, session_key, event.operator_openid,
)
return
try:
# Import lazily to keep the adapter importable in tests that
# don't exercise the approval subsystem.
@@ -1060,6 +1164,13 @@ class QQAdapter(BasePlatformAdapter):
update_answer = parse_update_prompt_button_data(button_data)
if update_answer is not None:
update_session_key = f"agent:main:qqbot:{event.scene}:{event.group_openid or event.guild_id or event.user_openid}"
if not self._is_authorized_interaction_for_session(event, update_session_key):
logger.warning(
"[%s] Rejected unauthorized update prompt click (operator=%s)",
self._log_tag, event.operator_openid,
)
return
self._write_update_response(update_answer, event.operator_openid)
return
@@ -1607,7 +1718,7 @@ class QQAdapter(BasePlatformAdapter):
elif ct.startswith("image/"):
# Image: download and cache locally.
try:
cached_path = await self._download_and_cache(url, ct)
cached_path = await self._download_and_cache(url, ct, filename)
if cached_path and os.path.isfile(cached_path):
image_urls.append(cached_path)
image_media_types.append(ct or "image/jpeg")
@@ -1620,11 +1731,15 @@ class QQAdapter(BasePlatformAdapter):
except Exception as exc:
logger.debug("[%s] Failed to cache image: %s", self._log_tag, exc)
else:
# Other attachments (video, file, etc.): record as text.
# Other attachments (video, file, etc.): download and record with path.
try:
cached_path = await self._download_and_cache(url, ct)
cached_path = await self._download_and_cache(url, ct, filename)
if cached_path:
other_attachments.append(f"[Attachment: {filename or ct}]")
name = filename or ct
if ct.startswith("video/"):
other_attachments.append(f"[video: {name} ({cached_path})]")
else:
other_attachments.append(f"[file: {name} ({cached_path})]")
except Exception as exc:
logger.debug("[%s] Failed to cache attachment: %s", self._log_tag, exc)
@@ -1636,8 +1751,14 @@ class QQAdapter(BasePlatformAdapter):
"attachment_info": attachment_info,
}
async def _download_and_cache(self, url: str, content_type: str) -> Optional[str]:
"""Download a URL and cache it locally."""
async def _download_and_cache(
self, url: str, content_type: str, original_name: str = "",
) -> Optional[str]:
"""Download a URL and cache it locally.
:param original_name: Preferred filename from attachment metadata.
Falls back to the URL path basename if empty.
"""
from tools.url_safety import is_safe_url
if not is_safe_url(url):
@@ -1668,7 +1789,11 @@ class QQAdapter(BasePlatformAdapter):
# Convert to .wav using ffmpeg so STT engines can process it.
return await self._convert_audio_to_wav(data, url)
else:
filename = Path(urlparse(url).path).name or "qq_attachment"
filename = (
original_name
or Path(urlparse(url).path).name
or "qq_attachment"
)
return cache_document_from_bytes(data, filename)
@staticmethod
@@ -1881,7 +2006,7 @@ class QQAdapter(BasePlatformAdapter):
@staticmethod
def _guess_ext_from_data(data: bytes) -> str:
"""Guess file extension from magic bytes."""
if data[:9] == b"#!SILK_V3" or data[:5] == b"#!SILK":
if data[:9] == b"#!SILK_V3" or data[:6] == b"#!SILK":
return ".silk"
if data[:2] == b"\x02!":
return ".silk"
@@ -1901,7 +2026,7 @@ class QQAdapter(BasePlatformAdapter):
@staticmethod
def _looks_like_silk(data: bytes) -> bool:
"""Check if bytes look like a SILK audio file."""
return data[:4] == b"#!SILK" or data[:2] == b"\x02!" or data[:9] == b"#!SILK_V3"
return data[:6] == b"#!SILK" or data[:2] == b"\x02!" or data[:9] == b"#!SILK_V3"
async def _convert_silk_to_wav(self, src_path: str, wav_path: str) -> Optional[str]:
"""Convert audio file to WAV using the pilk library.
+1 -1
View File
@@ -37,7 +37,7 @@ import asyncio
import functools
import hashlib
import logging
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Optional
+3 -13
View File
@@ -498,19 +498,9 @@ class SignalAdapter(BasePlatformAdapter):
if not data_message:
return
# Check for group message.
# Modern Signal groups surface on dataMessage.groupV2.id; legacy V1
# groups still arrive under dataMessage.groupInfo.groupId. signal-cli
# versions differ in which field they expose for V2 groups — some
# forward the underlying libsignal envelope verbatim (groupV2), others
# normalize everything into groupInfo. Read groupV2 first and fall
# back to groupInfo so V2-only groups aren't misrouted as DMs.
# Check for group message
group_info = data_message.get("groupInfo")
group_v2 = data_message.get("groupV2")
group_id = (
(group_v2.get("id") if isinstance(group_v2, dict) else None)
or (group_info.get("groupId") if isinstance(group_info, dict) else None)
)
group_id = group_info.get("groupId") if group_info else None
is_group = bool(group_id)
# Group message filtering — derived from SIGNAL_GROUP_ALLOWED_USERS:
@@ -597,7 +587,7 @@ class SignalAdapter(BasePlatformAdapter):
# Build session source
source = self.build_source(
chat_id=chat_id,
chat_name=(group_info.get("groupName") if isinstance(group_info, dict) else None) or sender_name,
chat_name=group_info.get("groupName") if group_info else sender_name,
chat_type=chat_type,
user_id=sender,
user_name=sender_name or sender,
+793 -182
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+137 -9
View File
@@ -27,6 +27,8 @@ Security:
"""
import asyncio
import base64
import binascii
import hashlib
import hmac
import json
@@ -308,11 +310,37 @@ class WebhookAdapter(BasePlatformAdapter):
data = json.loads(subs_path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
return
# Merge: static routes take precedence over dynamic ones
self._dynamic_routes = {
k: v for k, v in data.items()
if k not in self._static_routes
}
# Merge: static routes take precedence over dynamic ones.
# Reject any dynamic route whose effective secret is empty —
# an empty secret would cause _handle_webhook to skip HMAC
# validation entirely, letting unauthenticated callers in.
new_dynamic: Dict[str, dict] = {}
for k, v in data.items():
if k in self._static_routes:
continue
effective_secret = v.get("secret", self._global_secret)
if not effective_secret:
logger.warning(
"[webhook] Dynamic route '%s' skipped: 'secret' is "
"missing or empty. Set a valid HMAC secret, or use "
"'%s' to explicitly disable auth (testing only).",
k,
_INSECURE_NO_AUTH,
)
continue
if (
effective_secret == _INSECURE_NO_AUTH
and not _is_loopback_host(self._host)
):
logger.warning(
"[webhook] Dynamic route '%s' skipped: INSECURE_NO_AUTH "
"is only allowed on loopback hosts. Current host: '%s'.",
k,
self._host,
)
continue
new_dynamic[k] = v
self._dynamic_routes = new_dynamic
self._routes = {**self._dynamic_routes, **self._static_routes}
self._dynamic_routes_mtime = mtime
logger.info(
@@ -336,6 +364,15 @@ class WebhookAdapter(BasePlatformAdapter):
{"error": f"Unknown route: {route_name}"}, status=404
)
# Disabled routes are kept in the subscriptions file (so the dashboard
# can re-enable them) but reject incoming events. Default-enabled:
# only an explicit ``enabled: false`` turns a route off, matching the
# mcp_servers ``enabled`` semantics.
if route_config.get("enabled", True) is False:
return web.json_response(
{"error": f"Route disabled: {route_name}"}, status=403
)
# ── Auth-before-body ─────────────────────────────────────
# Check Content-Length before reading the full payload.
content_length = request.content_length or 0
@@ -351,9 +388,21 @@ class WebhookAdapter(BasePlatformAdapter):
logger.error("[webhook] Failed to read body: %s", e)
return web.json_response({"error": "Bad request"}, status=400)
# Validate HMAC signature FIRST (skip for INSECURE_NO_AUTH testing mode)
# Validate HMAC signature FIRST (skip only for the explicit local-test
# INSECURE_NO_AUTH mode). Missing/empty secrets must fail closed here,
# not only during connect(), so direct handler reuse cannot turn a
# network webhook route into an unauthenticated agent-dispatch surface.
secret = route_config.get("secret", self._global_secret)
if secret and secret != _INSECURE_NO_AUTH:
if not secret:
logger.error(
"[webhook] Route %s has no HMAC secret; refusing request",
route_name,
)
return web.json_response(
{"error": "Webhook route is missing an HMAC secret"},
status=403,
)
if secret != _INSECURE_NO_AUTH:
if not self._validate_signature(request, raw_body, secret):
logger.warning(
"[webhook] Invalid signature for route %s", route_name
@@ -393,6 +442,7 @@ class WebhookAdapter(BasePlatformAdapter):
request.headers.get("X-GitHub-Event", "")
or request.headers.get("X-GitLab-Event", "")
or payload.get("event_type", "")
or payload.get("type", "")
or "unknown"
)
allowed_events = route_config.get("events", [])
@@ -445,7 +495,10 @@ class WebhookAdapter(BasePlatformAdapter):
# Build a unique delivery ID
delivery_id = request.headers.get(
"X-GitHub-Delivery",
request.headers.get("X-Request-ID", str(int(time.time() * 1000))),
request.headers.get(
"svix-id",
request.headers.get("X-Request-ID", str(int(time.time() * 1000))),
),
)
# ── Idempotency ─────────────────────────────────────────
@@ -590,7 +643,32 @@ class WebhookAdapter(BasePlatformAdapter):
def _validate_signature(
self, request: "web.Request", body: bytes, secret: str
) -> bool:
"""Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256)."""
"""Validate webhook signature (GitHub, GitLab, Svix, generic HMAC-SHA256)."""
def _header(name: str) -> str:
return (
request.headers.get(name, "")
or request.headers.get(name.lower(), "")
or request.headers.get(name.upper(), "")
)
# Svix / AgentMail:
# svix-id: msg_...
# svix-timestamp: unix seconds
# svix-signature: v1,<base64-hmac> [v1,<base64-hmac> ...]
# Signed content is: "{id}.{timestamp}.{raw_body}". Svix secrets
# usually start with "whsec_" and the remainder is base64-encoded.
svix_id = _header("svix-id")
svix_timestamp = _header("svix-timestamp")
svix_signature = _header("svix-signature")
if svix_id or svix_timestamp or svix_signature:
return self._validate_svix_signature(
body=body,
secret=secret,
msg_id=svix_id,
timestamp=svix_timestamp,
signature_header=svix_signature,
)
# GitHub: X-Hub-Signature-256 = sha256=<hex>
gh_sig = request.headers.get("X-Hub-Signature-256", "")
if gh_sig:
@@ -618,6 +696,56 @@ class WebhookAdapter(BasePlatformAdapter):
)
return False
def _validate_svix_signature(
self,
body: bytes,
secret: str,
msg_id: str,
timestamp: str,
signature_header: str,
tolerance_seconds: int = 300,
) -> bool:
"""Validate Svix-compatible signatures used by AgentMail webhooks."""
if not (msg_id and timestamp and signature_header and secret):
return False
try:
ts = int(timestamp)
except (TypeError, ValueError):
return False
if abs(int(time.time()) - ts) > tolerance_seconds:
logger.warning("[webhook] Svix signature timestamp outside replay window")
return False
if secret.startswith("whsec_"):
encoded_secret = secret.removeprefix("whsec_")
try:
key = base64.b64decode(encoded_secret, validate=True)
except (binascii.Error, ValueError):
logger.debug("[webhook] Invalid whsec_ Svix signing secret")
return False
else:
# Be permissive for providers that document Svix-style headers but
# hand out raw shared secrets rather than whsec_ base64 secrets.
logger.debug("[webhook] Validating Svix-style signature with raw secret")
key = secret.encode()
signed_content = msg_id.encode() + b"." + timestamp.encode() + b"." + body
expected = base64.b64encode(
hmac.new(key, signed_content, hashlib.sha256).digest()
).decode()
# Svix can send multiple signatures separated by spaces during secret
# rotation. Each entry is formatted as "vN,<base64>".
for part in signature_header.split():
try:
version, signature = part.split(",", 1)
except ValueError:
continue
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False
# ------------------------------------------------------------------
# Prompt rendering
# ------------------------------------------------------------------
+26 -1
View File
@@ -161,7 +161,15 @@ class WeComAdapter(BasePlatformAdapter):
).strip() or DEFAULT_WS_URL
self._dm_policy = str(extra.get("dm_policy") or os.getenv("WECOM_DM_POLICY", "open")).strip().lower()
self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom"))
# dm_policy already honors WECOM_DM_POLICY, so the allowlist must honor
# WECOM_ALLOWED_USERS too. Without the env fallback an env-only setup
# (dm_policy=allowlist via env, no config extra) runs with an empty
# allowlist and drops every authorized DM at intake.
self._allow_from = _coerce_list(
extra.get("allow_from")
or extra.get("allowFrom")
or os.getenv("WECOM_ALLOWED_USERS", "")
)
self._group_policy = str(extra.get("group_policy") or os.getenv("WECOM_GROUP_POLICY", "open")).strip().lower()
self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom"))
@@ -616,6 +624,18 @@ class WeComAdapter(BasePlatformAdapter):
else:
delay = self._text_batch_delay_seconds
await asyncio.sleep(delay)
# Guard against the cancel-delivery race: when the sleep timer
# fires just before cancel() is called, CPython sets
# Task._must_cancel but cannot cancel the already-done sleep
# future, so CancelledError is delivered at the *next* await
# (handle_message) rather than here. By that point this task
# has already popped the merged event, so the superseding task
# sees an empty batch and silently drops the message.
# This check is synchronous — no await between the sleep and
# the pop — so no other coroutine can modify the task registry
# in between.
if self._pending_text_batch_tasks.get(key) is not current_task:
return
event = self._pending_text_batches.pop(key, None)
if not event:
return
@@ -835,6 +855,11 @@ class WeComAdapter(BasePlatformAdapter):
# Policy helpers
# ------------------------------------------------------------------
@property
def enforces_own_access_policy(self) -> bool:
"""WeCom gates DM/group access at intake via dm_policy/group_policy."""
return True
def _is_dm_allowed(self, sender_id: str) -> bool:
if self._dm_policy == "disabled":
return False
+37 -15
View File
@@ -17,7 +17,17 @@ import logging
import socket as _socket
import time
from typing import Any, Dict, List, Optional
from xml.etree import ElementTree as ET
# Security: parse untrusted, pre-auth request bodies (WeCom callbacks) with
# defusedxml to block billion-laughs / entity-expansion (and XXE) DoS. The
# parsing API (fromstring) is a drop-in for the stdlib calls used below;
# response-building XML lives in wecom_crypto.py and is not parsed here.
try:
import defusedxml.ElementTree as ET
DEFUSEDXML_AVAILABLE = True
except ImportError:
ET = None # type: ignore[assignment]
DEFUSEDXML_AVAILABLE = False
try:
from aiohttp import web
@@ -49,7 +59,7 @@ MESSAGE_DEDUP_TTL_SECONDS = 300
def check_wecom_callback_requirements() -> bool:
return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE
return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE and DEFUSEDXML_AVAILABLE
class WecomCallbackAdapter(BasePlatformAdapter):
@@ -187,7 +197,6 @@ class WecomCallbackAdapter(BasePlatformAdapter):
app = self._resolve_app_for_chat(chat_id)
touser = chat_id.split(":", 1)[1] if ":" in chat_id else chat_id
try:
token = await self._get_access_token(app)
payload = {
"touser": touser,
"msgtype": "text",
@@ -195,18 +204,31 @@ class WecomCallbackAdapter(BasePlatformAdapter):
"text": {"content": content[:2048]},
"safe": 0,
}
resp = await self._http_client.post(
f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}",
json=payload,
)
data = resp.json()
if data.get("errcode") != 0:
return SendResult(success=False, error=str(data))
return SendResult(
success=True,
message_id=str(data.get("msgid", "")),
raw_response=data,
)
for _attempt in range(2):
token = await self._get_access_token(app)
resp = await self._http_client.post(
f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={token}",
json=payload,
)
data = resp.json()
errcode = data.get("errcode")
if errcode in {40001, 42001} and _attempt == 0:
# WeCom rejected the token — evict the cached entry so
# the next _get_access_token call forces a fresh fetch.
logger.warning(
"[WecomCallback] Token rejected for app '%s' (errcode=%s), refreshing",
app.get("name", "default"), errcode,
)
self._access_tokens.pop(app["name"], None)
continue
if errcode != 0:
return SendResult(success=False, error=str(data))
return SendResult(
success=True,
message_id=str(data.get("msgid", "")),
raw_response=data,
)
return SendResult(success=False, error="send failed after token refresh")
except Exception as exc:
return SendResult(success=False, error=str(exc))
+250 -61
View File
@@ -378,12 +378,16 @@ async def _api_post(
) -> Dict[str, Any]:
body = _json_dumps({**payload, "base_info": _base_info()})
url = f"{base_url.rstrip('/')}/{endpoint}"
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
async with session.post(url, data=body, headers=_headers(token, body), timeout=timeout) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
# "Timeout context manager should be used inside a task" errors when
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
async def _do() -> Dict[str, Any]:
async with session.post(url, data=body, headers=_headers(token, body)) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
async def _api_get(
@@ -398,12 +402,16 @@ async def _api_get(
"iLink-App-Id": ILINK_APP_ID,
"iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION),
}
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
async with session.get(url, headers=headers, timeout=timeout) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
# "Timeout context manager should be used inside a task" errors when
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
async def _do() -> Dict[str, Any]:
async with session.get(url, headers=headers) as response:
raw = await response.text()
if not response.ok:
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
return json.loads(raw)
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
async def _get_updates(
@@ -658,52 +666,6 @@ def _split_table_row(line: str) -> List[str]:
return [cell.strip() for cell in row.split("|")]
def _rewrite_headers_for_weixin(line: str) -> str:
match = _HEADER_RE.match(line)
if not match:
return line.rstrip()
level = len(match.group(1))
title = match.group(2).strip()
if level == 1:
return f"{title}"
return f"**{title}**"
def _rewrite_table_block_for_weixin(lines: List[str]) -> str:
if len(lines) < 2:
return "\n".join(lines)
headers = _split_table_row(lines[0])
body_rows = [_split_table_row(line) for line in lines[2:] if line.strip()]
if not headers or not body_rows:
return "\n".join(lines)
formatted_rows: List[str] = []
for row in body_rows:
pairs = []
for idx, header in enumerate(headers):
if idx >= len(row):
break
label = header or f"Column {idx + 1}"
value = row[idx].strip()
if value:
pairs.append((label, value))
if not pairs:
continue
if len(pairs) == 1:
label, value = pairs[0]
formatted_rows.append(f"- {label}: {value}")
continue
if len(pairs) == 2:
label, value = pairs[0]
other_label, other_value = pairs[1]
formatted_rows.append(f"- {label}: {value}")
formatted_rows.append(f" {other_label}: {other_value}")
continue
summary = " | ".join(f"{label}: {value}" for label, value in pairs)
formatted_rows.append(f"- {summary}")
return "\n".join(formatted_rows) if formatted_rows else "\n".join(lines)
def _normalize_markdown_blocks(content: str) -> str:
lines = content.splitlines()
result: List[str] = []
@@ -1176,6 +1138,8 @@ async def qr_login(
class WeixinAdapter(BasePlatformAdapter):
"""Native Hermes adapter for Weixin personal accounts."""
supports_code_blocks = True # Weixin renders fenced code blocks
MAX_MESSAGE_LENGTH = 2000
# WeChat does not support editing sent messages — streaming must use the
@@ -1210,6 +1174,24 @@ class WeixinAdapter(BasePlatformAdapter):
extra.get("send_chunk_retry_delay_seconds")
or os.getenv("WEIXIN_SEND_CHUNK_RETRY_DELAY_SECONDS", "1.0")
)
self._send_text_gate = asyncio.Lock()
self._rate_limit_circuit_threshold = max(
1,
int(
extra.get("rate_limit_circuit_threshold")
or os.getenv("WEIXIN_RATE_LIMIT_CIRCUIT_THRESHOLD", "1")
),
)
self._rate_limit_circuit_window_seconds = float(
extra.get("rate_limit_circuit_window_seconds")
or os.getenv("WEIXIN_RATE_LIMIT_CIRCUIT_WINDOW_SECONDS", "30.0")
)
self._rate_limit_circuit_open_seconds = float(
extra.get("rate_limit_circuit_open_seconds")
or os.getenv("WEIXIN_RATE_LIMIT_CIRCUIT_OPEN_SECONDS", "30.0")
)
self._rate_limit_circuit_until = 0.0
self._rate_limit_events: List[float] = []
self._dm_policy = str(extra.get("dm_policy") or os.getenv("WEIXIN_DM_POLICY", "open")).strip().lower()
self._group_policy = str(extra.get("group_policy") or os.getenv("WEIXIN_GROUP_POLICY", "disabled")).strip().lower()
allow_from = extra.get("allow_from")
@@ -1226,12 +1208,48 @@ class WeixinAdapter(BasePlatformAdapter):
default=False,
)
# Text debounce batching (mirrors Telegram adapter pattern).
# iLink delivers messages individually, so rapid multi-message
# bursts (forwarded batches, paste-splits) each trigger a
# separate agent invocation. Default 3s delay / 5s split delay
# are tuned for iLink's typical delivery cadence. Tunable via
# config.yaml under
# ``gateway.platforms.weixin.extra.text_batch_delay_seconds`` /
# ``text_batch_split_delay_seconds``.
self._text_batch_delay_seconds = self._coerce_float_extra(
"text_batch_delay_seconds", 3.0
)
self._text_batch_split_delay_seconds = self._coerce_float_extra(
"text_batch_split_delay_seconds", 5.0
)
self._pending_text_batches: Dict[str, MessageEvent] = {}
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
if self._account_id and not self._token:
persisted = load_weixin_account(hermes_home, self._account_id)
if persisted:
self._token = str(persisted.get("token") or "").strip()
self._base_url = str(persisted.get("base_url") or self._base_url).strip().rstrip("/")
def _coerce_float_extra(self, key: str, default: float) -> float:
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
unparseable values fall back to ``default``.
"""
import math
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
if value is None:
return float(default)
try:
parsed = float(value)
except (TypeError, ValueError):
return float(default)
if not math.isfinite(parsed) or parsed < 0:
return float(default)
return parsed
@staticmethod
def _coerce_list(value: Any) -> List[str]:
if value is None:
@@ -1293,6 +1311,11 @@ class WeixinAdapter(BasePlatformAdapter):
async def disconnect(self) -> None:
_LIVE_ADAPTERS.pop(self._token, None)
self._running = False
for task in self._pending_text_batch_tasks.values():
if not task.done():
task.cancel()
self._pending_text_batches.clear()
self._pending_text_batch_tasks.clear()
if self._poll_task and not self._poll_task.done():
self._poll_task.cancel()
try:
@@ -1441,7 +1464,10 @@ class WeixinAdapter(BasePlatformAdapter):
timestamp=datetime.now(),
)
logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths))
await self.handle_message(event)
if event.message_type == MessageType.TEXT:
self._enqueue_text_event(event)
else:
await self.handle_message(event)
def _is_dm_allowed(self, sender_id: str) -> bool:
if self._dm_policy == "disabled":
@@ -1450,6 +1476,76 @@ class WeixinAdapter(BasePlatformAdapter):
return sender_id in self._allow_from
return True
@property
def enforces_own_access_policy(self) -> bool:
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
return True
# ------------------------------------------------------------------
# Text debounce batching
# ------------------------------------------------------------------
_SPLIT_THRESHOLD = 1800 # iLink chunks at ~2048 chars
def _text_batch_key(self, event: MessageEvent) -> str:
"""Session-scoped key for text message batching."""
from gateway.session import build_session_key
return build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
def _enqueue_text_event(self, event: MessageEvent) -> None:
"""Buffer a text event and reset the flush timer.
When users forward multiple messages or send rapid-fire texts
via WeChat, each arrives as a separate iLink message. This
concatenates them and waits for a short quiet period before
dispatching the combined message.
"""
key = self._text_batch_key(event)
existing = self._pending_text_batches.get(key)
chunk_len = len(event.text or "")
if existing is None:
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
self._pending_text_batches[key] = event
else:
if event.text:
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
if event.media_urls:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
prior_task = self._pending_text_batch_tasks.get(key)
if prior_task and not prior_task.done():
prior_task.cancel()
self._pending_text_batch_tasks[key] = asyncio.create_task(
self._flush_text_batch(key)
)
async def _flush_text_batch(self, key: str) -> None:
"""Wait for quiet period then dispatch aggregated text."""
current_task = asyncio.current_task()
try:
pending = self._pending_text_batches.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
if last_len >= self._SPLIT_THRESHOLD:
delay = self._text_batch_split_delay_seconds
else:
delay = self._text_batch_delay_seconds
await asyncio.sleep(delay)
if self._pending_text_batch_tasks.get(key) is not current_task:
return
event = self._pending_text_batches.pop(key, None)
if not event:
return
await self.handle_message(event)
finally:
if self._pending_text_batch_tasks.get(key) is current_task:
self._pending_text_batch_tasks.pop(key, None)
async def _collect_media(self, item: Dict[str, Any], media_paths: List[str], media_types: List[str]) -> None:
item_type = item.get("type")
if item_type == ITEM_IMAGE:
@@ -1569,6 +1665,37 @@ class WeixinAdapter(BasePlatformAdapter):
content, self.MAX_MESSAGE_LENGTH, self._split_multiline_messages,
)
def _rate_limit_cooldown_remaining(self) -> float:
return max(0.0, self._rate_limit_circuit_until - time.monotonic())
def _rate_limit_error(self) -> RuntimeError:
return RuntimeError(
f"iLink sendmessage rate limited; cooldown active for {self._rate_limit_cooldown_remaining():.1f}s"
)
def _open_rate_limit_circuit(self) -> None:
if self._rate_limit_circuit_open_seconds <= 0:
return
self._rate_limit_circuit_until = max(
self._rate_limit_circuit_until,
time.monotonic() + self._rate_limit_circuit_open_seconds,
)
def _record_rate_limit_event(self) -> bool:
"""Record a genuine iLink rate limit and return True if breaker opened."""
now = time.monotonic()
window_start = now - self._rate_limit_circuit_window_seconds
self._rate_limit_events = [ts for ts in self._rate_limit_events if ts >= window_start]
self._rate_limit_events.append(now)
if len(self._rate_limit_events) >= self._rate_limit_circuit_threshold:
self._open_rate_limit_circuit()
return self._rate_limit_cooldown_remaining() > 0
return False
def _reset_rate_limit_circuit(self) -> None:
self._rate_limit_events.clear()
self._rate_limit_circuit_until = 0.0
async def _send_text_chunk(
self,
*,
@@ -1584,9 +1711,28 @@ class WeixinAdapter(BasePlatformAdapter):
degraded fallback, which keeps cron-initiated push messages working
even when no user message has refreshed the session recently.
"""
async with self._send_text_gate:
await self._send_text_chunk_locked(
chat_id=chat_id,
chunk=chunk,
context_token=context_token,
client_id=client_id,
)
async def _send_text_chunk_locked(
self,
*,
chat_id: str,
chunk: str,
context_token: Optional[str],
client_id: str,
) -> None:
"""Send a text chunk while holding the adapter-wide outbound text gate."""
last_error: Optional[Exception] = None
retried_without_token = False
for attempt in range(self._send_chunk_retries + 1):
if self._rate_limit_cooldown_remaining() > 0:
raise self._rate_limit_error()
try:
resp = await _send_message(
self._send_session,
@@ -1632,6 +1778,9 @@ class WeixinAdapter(BasePlatformAdapter):
last_error = RuntimeError(
f"iLink sendmessage rate limited: ret={ret} errcode={errcode} errmsg={errmsg}"
)
if self._record_rate_limit_event():
last_error = self._rate_limit_error()
break
if attempt >= self._send_chunk_retries:
break
wait = self._send_chunk_retry_delay_seconds * 3 # 3x backoff for rate limit
@@ -1645,6 +1794,7 @@ class WeixinAdapter(BasePlatformAdapter):
raise RuntimeError(
f"iLink sendmessage error: ret={ret} errcode={errcode} errmsg={errmsg}"
)
self._reset_rate_limit_circuit()
return
except Exception as exc:
last_error = exc
@@ -1679,8 +1829,10 @@ class WeixinAdapter(BasePlatformAdapter):
# Extract MEDIA: tags and bare local file paths before text delivery.
media_files, cleaned_content = self.extract_media(content)
media_files = self.filter_media_delivery_paths(media_files)
_, image_cleaned = self.extract_images(cleaned_content)
local_files, final_content = self.extract_local_files(image_cleaned)
local_files = self.filter_local_delivery_paths(local_files)
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
@@ -1730,10 +1882,47 @@ class WeixinAdapter(BasePlatformAdapter):
logger.error("[%s] send failed to=%s: %s", self.name, _safe_id(chat_id), exc)
return SendResult(success=False, error=str(exc))
async def _ensure_typing_ticket(self, chat_id: str) -> Optional[str]:
"""Return a valid typing ticket, refreshing from getConfig if expired.
The iLink typing ticket has a 600-second TTL. When a long-running
session exceeds that window the cached ticket evicts, and both
``send_typing`` and ``stop_typing`` silently no-op leaving the
WeChat client stuck showing the typing indicator forever. This
method transparently refreshes the ticket so the stop signal can
always be delivered.
"""
ticket = self._typing_cache.get(chat_id)
if ticket:
return ticket
if not self._send_session or not self._token:
return None
# Ticket expired or never fetched — refresh via getConfig.
# Use the most recent context_token for this peer if available.
context_token = self._token_store.get(self._account_id, chat_id)
try:
response = await _get_config(
self._send_session,
base_url=self._base_url,
token=self._token,
user_id=chat_id,
context_token=context_token,
)
typing_ticket = str(response.get("typing_ticket") or "")
if typing_ticket:
self._typing_cache.set(chat_id, typing_ticket)
return typing_ticket
except Exception as exc:
logger.debug(
"[%s] typing ticket refresh failed for %s: %s",
self.name, _safe_id(chat_id), exc,
)
return None
async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = None) -> None:
if not self._send_session or not self._token:
return
typing_ticket = self._typing_cache.get(chat_id)
typing_ticket = await self._ensure_typing_ticket(chat_id)
if not typing_ticket:
return
try:
@@ -1751,7 +1940,7 @@ class WeixinAdapter(BasePlatformAdapter):
async def stop_typing(self, chat_id: str) -> None:
if not self._send_session or not self._token:
return
typing_ticket = self._typing_cache.get(chat_id)
typing_ticket = await self._ensure_typing_ticket(chat_id)
if not typing_ticket:
return
try:
+102 -2
View File
@@ -276,6 +276,43 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
# notification before the normal "✓ whatsapp disconnected" fires.
self._shutting_down: bool = False
# Text debounce batching (mirrors Telegram adapter pattern).
# WhatsApp often delivers multiple messages in rapid succession
# (e.g. forwarded batches, paste-splits) — without debounce each
# message triggers a separate agent invocation, wasting tokens and
# flooding the user with reply fragments. Default 5s delay /
# 10s split delay are conservative for WhatsApp's delivery cadence.
# Tunable via config.yaml under
# ``gateway.platforms.whatsapp.extra.text_batch_delay_seconds`` /
# ``text_batch_split_delay_seconds``.
self._text_batch_delay_seconds = self._coerce_float_extra(
"text_batch_delay_seconds", 5.0
)
self._text_batch_split_delay_seconds = self._coerce_float_extra(
"text_batch_split_delay_seconds", 10.0
)
self._pending_text_batches: Dict[str, MessageEvent] = {}
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
def _coerce_float_extra(self, key: str, default: float) -> float:
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
unparseable values fall back to ``default``.
"""
import math
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
if value is None:
return float(default)
try:
parsed = float(value)
except (TypeError, ValueError):
return float(default)
if not math.isfinite(parsed) or parsed < 0:
return float(default)
return parsed
async def connect(self) -> bool:
"""
Start the WhatsApp bridge.
@@ -873,7 +910,10 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
for msg_data in messages:
event = await self._build_message_event(msg_data)
if event:
await self.handle_message(event)
if event.message_type == MessageType.TEXT:
self._enqueue_text_event(event)
else:
await self.handle_message(event)
except asyncio.CancelledError:
break
except Exception as e:
@@ -885,7 +925,67 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
await asyncio.sleep(5)
await asyncio.sleep(1) # Poll interval
# ── Text debounce batching ──────────────────────────────────────
_SPLIT_THRESHOLD = 6000 # WhatsApp supports ~65K chars; generous threshold
def _text_batch_key(self, event: MessageEvent) -> str:
"""Session-scoped key for text message batching."""
from gateway.session import build_session_key
return build_session_key(
event.source,
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
)
def _enqueue_text_event(self, event: MessageEvent) -> None:
"""Buffer a text event and reset the flush timer.
When WhatsApp delivers rapid-fire messages (e.g. forwarded
batches), this concatenates them and waits for a short quiet
period before dispatching the combined message.
"""
key = self._text_batch_key(event)
existing = self._pending_text_batches.get(key)
chunk_len = len(event.text or "")
if existing is None:
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
self._pending_text_batches[key] = event
else:
if event.text:
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
if event.media_urls:
existing.media_urls.extend(event.media_urls)
existing.media_types.extend(event.media_types)
prior_task = self._pending_text_batch_tasks.get(key)
if prior_task and not prior_task.done():
prior_task.cancel()
self._pending_text_batch_tasks[key] = asyncio.create_task(
self._flush_text_batch(key)
)
async def _flush_text_batch(self, key: str) -> None:
"""Wait for quiet period then dispatch aggregated text."""
current_task = asyncio.current_task()
try:
pending = self._pending_text_batches.get(key)
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
if last_len >= self._SPLIT_THRESHOLD:
delay = self._text_batch_split_delay_seconds
else:
delay = self._text_batch_delay_seconds
await asyncio.sleep(delay)
event = self._pending_text_batches.pop(key, None)
if not event:
return
await self.handle_message(event)
finally:
if self._pending_text_batch_tasks.get(key) is current_task:
self._pending_text_batch_tasks.pop(key, None)
async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]:
"""Build a MessageEvent from bridge message data, downloading images to cache."""
try:
+6
View File
@@ -52,9 +52,15 @@ class WhatsAppBehaviorMixin:
# WhatsApp message limits — practical UX limit, not protocol max.
# WhatsApp allows ~65K but long messages are unreadable on mobile.
MAX_MESSAGE_LENGTH: int = 4096
supports_code_blocks = True # WhatsApp renders fenced code blocks (monospace)
DEFAULT_REPLY_PREFIX: str = "⚕ *Hermes Agent*\n────────────\n"
@property
def enforces_own_access_policy(self) -> bool:
"""WhatsApp gates DM/group access at intake via dm_policy/group_policy."""
return True
# ------------------------------------------------------------------ config
def _effective_reply_prefix(self) -> str:
"""Return the prefix to add to outgoing replies in self-chat mode.
+359 -176
View File
@@ -120,6 +120,16 @@ AUTH_TIMEOUT_SECONDS = 10.0
MAX_RECONNECT_ATTEMPTS = 100
DEFAULT_SEND_TIMEOUT = 30.0 # WS biz request timeout
# Upper bound on the WS close handshake during teardown (#40383). The
# websockets connection's own close_timeout (5s) blocks until the server
# echoes the close frame; an idle/unresponsive server never replies, stalling
# gateway shutdown by the full timeout. Bounding the close await here keeps
# teardown fast — a responsive server completes the handshake in well under a
# second, so this only caps the pathological hang. Also bounds the reconnect /
# connect-failure cleanup paths that reuse _cleanup_ws(), where a graceful
# close is unnecessary anyway (the socket is being discarded to redial).
WS_CLOSE_TIMEOUT_S = 1.0
# Close codes that indicate permanent errors — do NOT reconnect.
NO_RECONNECT_CLOSE_CODES = {4012, 4013, 4014, 4018, 4019, 4021}
@@ -147,6 +157,12 @@ _YB_RES_REF_RE = re.compile(
r"\[(image|voice|video|file(?::[^|\]]*)?)\|ybres:([A-Za-z0-9_\-]+)\]"
)
# Patched local-media anchors once an inbound resource has been downloaded to the local cache.
# [image: /opt/data/image_cache/img_xxx.bmp]
# [file: report.pdf → /opt/data/.../report.pdf]
# (and any future kind, e.g. [video: /opt/.../clip.mp4])
_YB_LOCAL_MEDIA_RE = re.compile(r"\[(\w+):[^\]]*?(/[^\]]+?)\s*\]")
# Media kinds that can be resolved and injected into the model context
_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file"})
@@ -930,7 +946,11 @@ class InboundContext:
reply_to_text: Optional[str] = None
quote_media_refs: list = dc_field(default_factory=list) # List of (rid, kind, filename)
# Populated by MediaResolveMiddleware
# Populated by MediaResolveMiddleware. Combined list of resolved local
# paths from up to three sources (deduped, in this order):
# 1) media carried by the current message (always),
# 2) media from the quoted message (when reply_to_message_id is set),
# 3) recent group-observed media (only when chat_type == "group" and no quote is present).
media_urls: list = dc_field(default_factory=list)
media_types: list = dc_field(default_factory=list)
@@ -1675,10 +1695,10 @@ class ExtractContentMiddleware(InboundMiddleware):
"""Extract plain text content from MsgBody.
- TIMTextElem -> text field
- TIMImageElem -> "[image]"
- TIMFileElem -> "[file: {filename}]"
- TIMSoundElem -> "[voice]"
- TIMVideoFileElem -> "[video]"
- TIMImageElem -> "[image]" / "[image|ybres:RID]"
- TIMFileElem -> "[file: {filename}]" / "[file:{name}|ybres:RID]"
- TIMSoundElem -> "[voice]" / "[voice|ybres:RID]"
- TIMVideoFileElem -> "[video]" / "[video|ybres:RID]"
- TIMFaceElem -> "[emoji: {name}]" or "[emoji]"
- TIMCustomElem -> try to extract data field, otherwise "[custom message]"
- Multiple elems joined with spaces
@@ -2177,51 +2197,72 @@ class QuoteContextMiddleware(InboundMiddleware):
name = "quote-context"
@staticmethod
def _extract_quote_context(cloud_custom_data: str) -> Tuple[Optional[str], Optional[str], list]:
"""Extract quote context, mapping to MessageEvent.reply_to_*.
Returns:
(reply_to_message_id, reply_to_text, quote_media_refs)
where quote_media_refs is a list of (rid, kind, filename) tuples
def _extract_quote_context(self, cloud_custom_data: str) -> Tuple[Optional[str], Optional[str]]:
"""Extract quote text context, mapping to MessageEvent.reply_to_*.
"""
if not cloud_custom_data:
return None, None, []
return None, None
try:
parsed = json.loads(cloud_custom_data)
except (json.JSONDecodeError, TypeError):
return None, None, []
return None, None
quote = parsed.get("quote") if isinstance(parsed, dict) else None
if not isinstance(quote, dict):
return None, None, []
# type=2 corresponds to image reference; desc may be empty, provide a placeholder.
quote_type = int(quote.get("type") or 0)
desc = str(quote.get("desc") or "").strip()
if quote_type == 2 and not desc:
desc = "[image]"
if not desc:
return None, None, []
return None, None
quote_id = str(quote.get("id") or "").strip() or None
desc = str(quote.get("desc") or "").strip()
sender = str(quote.get("sender_nickname") or quote.get("sender_id") or "").strip()
quote_text = f"{sender}: {desc}" if sender else desc
quote_text = (f"{sender}: {desc}" if sender else desc) if desc else None
# Extract media references from desc using _YB_RES_REF_RE regex
media_refs: list = []
for m in _YB_RES_REF_RE.finditer(desc):
head = m.group(1) # "image" | "file:<name>" | "voice" | "video"
rid = m.group(2)
kind, _, filename = head.partition(":")
kind = kind.strip()
media_refs.append((rid, kind, filename.strip()))
return quote_id, quote_text
return quote_id, quote_text, media_refs
async def _extract_media_refs_from_transcript(
self, ctx: InboundContext
) -> List[Tuple[str, str, str]]:
"""Look up the quoted message in the transcript history and return any
``[kind|ybres:RID]`` anchors found in its content as
``(rid, kind, filename)`` tuples.
Returns ``[]`` when ``ctx.reply_to_message_id`` is unset, when the
transcript store / source is unavailable, or when the quoted message
carries no resolvable media anchors.
"""
if ctx.reply_to_message_id is None:
return []
adapter = ctx.adapter
media_refs: List[Tuple[str, str, str]] = []
try:
store = getattr(adapter, "_session_store", None)
if not store or ctx.source is None:
return []
session_entry = store.get_or_create_session(ctx.source)
history = store.load_transcript(session_entry.session_id)
for msg in reversed(history or []):
mid = msg.get("message_id", "")
if not mid or mid != ctx.reply_to_message_id:
continue
_content = msg.get("content", "")
if isinstance(_content, str) and "|ybres:" in _content:
for m in _YB_RES_REF_RE.finditer(_content):
head = m.group(1)
rid = m.group(2)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind in _RESOLVABLE_MEDIA_KINDS:
media_refs.append((rid, kind, filename.strip()))
break
except Exception as exc:
logger.warning(
"[%s] quote transcript lookup failed: %s",
getattr(adapter, "name", "yuanbao"), exc,
)
return media_refs
async def handle(self, ctx: InboundContext, next_fn) -> None:
ctx.reply_to_message_id, ctx.reply_to_text, ctx.quote_media_refs = self._extract_quote_context(ctx.cloud_custom_data)
ctx.reply_to_message_id, ctx.reply_to_text = self._extract_quote_context(ctx.cloud_custom_data)
ctx.quote_media_refs = await self._extract_media_refs_from_transcript(ctx)
await next_fn()
@@ -2230,6 +2271,45 @@ class MediaResolveMiddleware(InboundMiddleware):
name = "media-resolve"
# --- Resource download cache (keyed by resourceId) ---
# Avoids redundant downloads of the same resource within the TTL window.
# The same resourceId can be referenced multiple times in a session (own
# attachment, then quoted again, then observed in a group backfill); each
# reference otherwise triggers a fresh token exchange + download.
_resource_cache: ClassVar[Dict[str, Tuple[str, str, float]]] = {} # rid -> (local_path, mime, ts)
_RESOURCE_CACHE_TTL_S: ClassVar[int] = 24 * 60 * 60 # 24 hours
_RESOURCE_CACHE_MAX_SIZE: ClassVar[int] = 256
@classmethod
def _get_cached_resource(cls, resource_id: str) -> Optional[Tuple[str, str]]:
"""Return cached ``(local_path, mime)`` if still valid and file exists, else None."""
if not resource_id:
return None
entry = cls._resource_cache.get(resource_id)
if entry is None:
return None
local_path, mime, ts = entry
if time.time() - ts > cls._RESOURCE_CACHE_TTL_S:
cls._resource_cache.pop(resource_id, None)
return None
# Verify the cached file still exists on disk (cache dir may be swept).
if not os.path.isfile(local_path):
cls._resource_cache.pop(resource_id, None)
return None
return local_path, mime
@classmethod
def _put_cached_resource(cls, resource_id: str, local_path: str, mime: str) -> None:
"""Store download result in cache. Evicts oldest entries when over capacity."""
if not resource_id:
return
if len(cls._resource_cache) >= cls._RESOURCE_CACHE_MAX_SIZE:
# Drop the oldest 25% of entries by timestamp.
sorted_keys = sorted(cls._resource_cache, key=lambda k: cls._resource_cache[k][2])
for k in sorted_keys[: cls._RESOURCE_CACHE_MAX_SIZE // 4]:
cls._resource_cache.pop(k, None)
cls._resource_cache[resource_id] = (local_path, mime, time.time())
@staticmethod
def _guess_image_ext_from_url(url: str) -> str:
"""Guess image extension from URL path."""
@@ -2327,8 +2407,23 @@ class MediaResolveMiddleware(InboundMiddleware):
async def _download_and_cache(
cls, adapter, *, fetch_url: str, kind: str,
file_name: Optional[str] = None, log_tag: str = "",
resource_id: str = "",
) -> Optional[Tuple[str, str]]:
"""Download a Yuanbao resource and cache locally. Returns ``(local_path, mime)`` or ``None``."""
"""Download a Yuanbao resource and cache locally. Returns ``(local_path, mime)`` or ``None``.
When *resource_id* is provided, an in-memory cache keyed by resourceId
is consulted first to skip redundant downloads of the same resource
within the TTL window.
"""
if resource_id:
hit = cls._get_cached_resource(resource_id)
if hit is not None:
logger.debug(
"[%s] resource cache hit: rid=%s path=%s",
adapter.name, resource_id, hit[0],
)
return hit
try:
file_bytes, content_type = await media_download_url(
fetch_url, max_size_mb=adapter.MEDIA_MAX_SIZE_MB,
@@ -2353,6 +2448,7 @@ class MediaResolveMiddleware(InboundMiddleware):
mime = guess_mime_type(f"image{ext}")
if not mime.startswith("image/"):
mime = content_type if content_type.startswith("image/") else "image/jpeg"
cls._put_cached_resource(resource_id, local_path, mime)
return local_path, mime
# kind == "file"
@@ -2368,13 +2464,9 @@ class MediaResolveMiddleware(InboundMiddleware):
)
return None
mime = guess_mime_type(file_name) or content_type or "application/octet-stream"
cls._put_cached_resource(resource_id, local_path, mime)
return local_path, mime
@classmethod
async def _resolve_by_resource_id(cls, adapter, resource_id: str) -> str:
"""Exchange a Yuanbao ``resourceId`` for a short-lived direct download URL. Raises on failure."""
return await cls._fetch_resource_url(adapter, resource_id)
@classmethod
async def _resolve_media_urls(
cls, adapter, media_refs: List[Dict[str, str]]
@@ -2390,9 +2482,13 @@ class MediaResolveMiddleware(InboundMiddleware):
for ref in media_refs:
kind = str(ref.get("kind") or "").strip().lower()
url = str(ref.get("url") or "").strip()
filename = str(ref.get("name") or "").strip()
if kind not in _RESOLVABLE_MEDIA_KINDS or not url:
continue
# Extract resourceId from the placeholder URL for cache dedup.
rid = ExtractContentMiddleware._parse_resource_id(url)
try:
fetch_url = await cls._resolve_download_url(adapter, url)
except Exception as exc:
@@ -2406,8 +2502,9 @@ class MediaResolveMiddleware(InboundMiddleware):
adapter,
fetch_url=fetch_url,
kind=kind,
file_name=str(ref.get("name") or "").strip() or None,
file_name=filename or None,
log_tag=f"placeholder_url={url[:80]}",
resource_id=rid,
)
if cached is None:
continue
@@ -2417,6 +2514,44 @@ class MediaResolveMiddleware(InboundMiddleware):
return media_urls, media_types
@classmethod
async def _resolve_ybres_refs(
cls,
adapter,
refs: List[Tuple[str, str, str]],
*,
log_prefix: str,
) -> Tuple[List[str], List[str]]:
"""Resolve a list of ``(rid, kind, filename)`` ybres tuples to local paths.
"""
media_paths: List[str] = []
mimes: List[str] = []
for rid, kind, filename in refs:
if kind not in _RESOLVABLE_MEDIA_KINDS:
continue
try:
fresh_url = await cls._fetch_resource_url(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] %s resolve failed: rid=%s kind=%s err=%s",
adapter.name, log_prefix, rid, kind, exc,
)
continue
cached = await cls._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"{log_prefix} rid={rid}",
resource_id=rid,
)
if cached is None:
continue
path, mime = cached
media_paths.append(path)
mimes.append(mime)
return media_paths, mimes
@classmethod
async def _collect_observed_media(
cls, adapter, source,
@@ -2463,41 +2598,178 @@ class MediaResolveMiddleware(InboundMiddleware):
if not order:
return [], []
media_paths: List[str] = []
return await cls._resolve_ybres_refs(
adapter, order, log_prefix="observed-media",
)
@classmethod
async def _resolve_quote_media(
cls, adapter, quote_media_refs: List[Tuple[str, str, str]],
) -> Tuple[List[str], List[str]]:
"""Resolve media anchors carried by the quoted message.
``quote_media_refs`` is a list of ``(rid, kind, filename)`` tuples
produced by :class:`QuoteContextMiddleware` from the transcript.
"""
return await cls._resolve_ybres_refs(
adapter, quote_media_refs, log_prefix="quote",
)
@staticmethod
def _collect_quote_local_media(ctx: InboundContext) -> Tuple[List[str], List[str]]:
"""Private-chat fallback for recovering already-local quoted media.
Only already-local media is handled here: by the time a turn is cached,
``PatchAnchorsMiddleware`` has rewritten resolved ``|ybres:`` anchors to
``[image: /path]`` / ``[file: name /path]``. Unresolved anchors are an
original-turn resolution failure and belong to that turn's handling, not
this quote fallback so no re-download happens here.
Returns ``(local_paths, mimes)`` for media already downloaded to the
local cache on its original turn, ready to inject as-is.
"""
paths: List[str] = []
mimes: List[str] = []
for rid, kind, filename in order:
try:
fresh_url = await cls._resolve_by_resource_id(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] observed-media resolve failed: rid=%s kind=%s err=%s",
adapter.name, rid, kind, exc,
)
rid_key = ctx.reply_to_message_id
if not rid_key:
return paths, mimes
cache = getattr(ctx.adapter, "_msg_content_cache", None)
if not cache:
return paths, mimes
text = cache.get(rid_key)
if not isinstance(text, str) or not text:
return paths, mimes
# Already-local media paths written by PatchAnchorsMiddleware. The
# generic anchor regex covers every kind _patch emits (image/file today,
# video/audio if they later become resolvable) without per-kind upkeep.
seen: set = set()
for m in _YB_LOCAL_MEDIA_RE.finditer(text):
kind = (m.group(1) or "").strip().lower()
path = (m.group(2) or "").strip()
if not path or path in seen:
continue
cached = await cls._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"rid={rid}",
if not os.path.exists(path):
continue
seen.add(path)
mime = guess_mime_type(os.path.basename(path)) or (
"image/jpeg" if kind == "image" else "application/octet-stream"
)
if cached is None:
continue
path, mime = cached
media_paths.append(path)
paths.append(path)
mimes.append(mime)
return media_paths, mimes
return paths, mimes
async def handle(self, ctx: InboundContext, next_fn) -> None:
# NOTE: Reaching this middleware in a group chat implies the message has
# @-mentioned the bot (or is an owner command). GroupAtGuardMiddleware
# short-circuits non-@bot group messages earlier in the pipeline, so we
# don't need to re-check @bot status here before downloading media.
adapter = ctx.adapter
ctx.media_urls, ctx.media_types = await self._resolve_media_urls(adapter, ctx.media_refs)
# Re-check placeholder after media resolution
if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, len(ctx.media_urls)):
urls: List[str] = []
types: List[str] = []
seen: set = set()
def _add_unique_pairs(pair_lists: Tuple[List[str], List[str]]) -> None:
u_list, m_list = pair_lists
for u, m in zip(u_list, m_list):
if not u or u in seen:
continue
seen.add(u)
urls.append(u)
types.append(m)
# 1) Media carried by the current message itself.
own_pairs = await self._resolve_media_urls(adapter, ctx.media_refs)
own_count = sum(1 for u in own_pairs[0] if u)
_add_unique_pairs(own_pairs)
# 2) Second source — quoted media takes priority; otherwise fall back
# to observed-media backfill in groups only (DMs already had their
# media resolved on the turn it was sent).
if ctx.reply_to_message_id is not None:
if ctx.quote_media_refs:
_add_unique_pairs(await self._resolve_quote_media(adapter, ctx.quote_media_refs))
else:
# DM quote fallback: no transcript message_id match (DM user rows
# carry no platform message_id), so recover already-local media
# from the adapter msg cache. Patched on its original turn — no
# re-download needed, inject as-is.
_add_unique_pairs(self._collect_quote_local_media(ctx))
elif ctx.chat_type == "group":
# Group chats: only @-bot turns reach this middleware
# (see GroupAtGuardMiddleware note at top of handle()),
# so unconditional observed-media hydration is safe here.
try:
_add_unique_pairs(await self._collect_observed_media(adapter, ctx.source))
except Exception as exc:
logger.warning(
"[%s] observed-image hydration raised, continuing anyway: %s",
adapter.name, exc,
)
ctx.media_urls = urls
ctx.media_types = types
# Re-check placeholder after media resolution.
# Use ``own_count`` (not ``len(urls)``) to preserve the original
# semantics: a placeholder text accompanied only by quote/observed
# media (i.e. no fresh attachment of its own) is still skippable.
if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, own_count):
logger.debug("[%s] Skip placeholder after media download: %r", adapter.name, ctx.raw_text)
return # Stop pipeline
await next_fn()
class PatchAnchorsMiddleware(InboundMiddleware):
"""Replace ``[kind|ybres:RID]`` anchors in ``ctx.raw_text`` with local paths.
Runs after :class:`MediaResolveMiddleware` so that ``ctx.media_urls`` /
``ctx.media_types`` are already populated with downloaded resources
(own media + quote media or group-observed media). The transcript
written downstream then records usable local paths for the model
instead of opaque ``ybres:`` references.
Only resolved media (paths starting with ``/``) are substituted; any
anchor without a corresponding local resource is left untouched.
"""
name = "patch-anchors"
@staticmethod
def _patch(text: str, urls: List[str], types: List[str]) -> str:
if not text or not urls:
return text
patched = text
for u, m in zip(urls, types):
if not u.startswith("/"):
continue
anchor_match = _YB_RES_REF_RE.search(patched)
if not anchor_match:
break
head = anchor_match.group(1)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind == "image" and m.startswith("image/"):
replacement = f"[image: {u}]"
elif kind == "file":
label = filename.strip() or os.path.basename(u)
replacement = f"[file: {label}{u}]"
else:
continue
patched = (
patched[: anchor_match.start()]
+ replacement
+ patched[anchor_match.end():]
)
return patched
async def handle(self, ctx: InboundContext, next_fn) -> None:
ctx.raw_text = self._patch(ctx.raw_text, ctx.media_urls, ctx.media_types)
await next_fn()
class DispatchMiddleware(InboundMiddleware):
"""Build MessageEvent and dispatch to AI handler."""
@@ -2513,123 +2785,18 @@ class DispatchMiddleware(InboundMiddleware):
)
async def _dispatch_inbound_event() -> None:
media_urls = list(ctx.media_urls)
media_types = list(ctx.media_types)
# If user quoted a message (reply_to_message_id is set), resolve only
# quote_media_refs to avoid injecting unrelated history media.
# Otherwise, backfill observed media from recent transcript history.
if ctx.reply_to_message_id is not None:
# Fallback: if desc didn't contain ybres refs, look up transcript
if not ctx.quote_media_refs:
try:
store = getattr(adapter, "_session_store", None)
if store:
session_entry = store.get_or_create_session(ctx.source)
history = store.load_transcript(session_entry.session_id)
for msg in reversed(history or []):
mid = msg.get("message_id", "")
if mid and mid == ctx.reply_to_message_id:
_content = msg.get("content", "")
if isinstance(_content, str) and "|ybres:" in _content:
for m in _YB_RES_REF_RE.finditer(_content):
head = m.group(1)
rid = m.group(2)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind in _RESOLVABLE_MEDIA_KINDS:
ctx.quote_media_refs.append((rid, kind, filename.strip()))
break
except Exception as exc:
logger.warning(
"[%s] quote transcript lookup failed: %s",
adapter.name, exc,
)
# User quoted a message — resolve only media from the quote
for rid, kind, filename in ctx.quote_media_refs:
if kind not in _RESOLVABLE_MEDIA_KINDS:
continue
try:
fresh_url = await MediaResolveMiddleware._resolve_by_resource_id(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] quote media resolve failed: rid=%s kind=%s err=%s",
adapter.name, rid, kind, exc,
)
continue
cached = await MediaResolveMiddleware._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"quote rid={rid}",
)
if cached is None:
continue
path, mime = cached
# Avoid duplicates
if path not in media_urls:
media_urls.append(path)
media_types.append(mime)
else:
# No quote — backfill observed media from recent transcript history
extra_img_urls: List[str] = []
extra_img_mimes: List[str] = []
try:
extra_img_urls, extra_img_mimes = await MediaResolveMiddleware._collect_observed_media(
adapter, ctx.source,
)
except Exception as exc:
logger.warning(
"[%s] observed-image hydration raised, continuing anyway: %s",
adapter.name, exc,
)
if extra_img_urls:
current = set(media_urls)
for u, m in zip(extra_img_urls, extra_img_mimes):
if u in current:
continue
media_urls.append(u)
media_types.append(m)
current.add(u)
# Replace [kind|ybres:xxx] anchors with local cache paths so
# the transcript records usable paths for the model.
_patched_event_text = ctx.raw_text
for u, m in zip(media_urls, media_types):
if not u.startswith("/"):
continue
anchor_match = _YB_RES_REF_RE.search(_patched_event_text)
if not anchor_match:
continue
head = anchor_match.group(1)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind == "image" and m.startswith("image/"):
replacement = f"[image: {u}]"
elif kind == "file":
label = filename.strip() or os.path.basename(u)
replacement = f"[file: {label}{u}]"
else:
continue
_patched_event_text = (
_patched_event_text[:anchor_match.start()]
+ replacement
+ _patched_event_text[anchor_match.end():]
)
event = MessageEvent(
text=_patched_event_text,
text=ctx.raw_text,
message_type=(
MessageType.DOCUMENT
if any(mt.startswith(("application/", "text/")) for mt in media_types)
if any(mt.startswith(("application/", "text/")) for mt in ctx.media_types)
else ctx.msg_type
),
source=ctx.source,
message_id=ctx.msg_id or None,
raw_message=ctx.push,
media_urls=media_urls,
media_types=media_types,
media_urls=list(ctx.media_urls),
media_types=list(ctx.media_types),
reply_to_message_id=ctx.reply_to_message_id,
reply_to_text=ctx.reply_to_text,
channel_prompt=ctx.channel_prompt,
@@ -2723,6 +2890,7 @@ class InboundPipelineBuilder:
ClassifyMessageTypeMiddleware,
QuoteContextMiddleware,
MediaResolveMiddleware,
PatchAnchorsMiddleware,
DispatchMiddleware,
]
@@ -3383,12 +3551,22 @@ class ConnectionManager:
return False
async def _cleanup_ws(self) -> None:
"""Close and clear the WebSocket connection."""
"""Close and clear the WebSocket connection, bounded by
``WS_CLOSE_TIMEOUT_S`` so an unresponsive server can't stall teardown
(see the constant's definition for the full rationale)."""
ws = self._ws
self._ws = None
if ws is not None:
try:
await ws.close()
await asyncio.wait_for(ws.close(), timeout=WS_CLOSE_TIMEOUT_S)
except asyncio.TimeoutError:
# Server never echoed the close frame within the bound; drop the
# connection. websockets force-closes the transport on cancel,
# and at shutdown the loop is tearing down anyway.
logger.debug(
"[%s] WS close handshake exceeded %.1fs — dropping connection",
self._adapter.name, WS_CLOSE_TIMEOUT_S,
)
except Exception:
pass
@@ -4629,6 +4807,11 @@ class YuanbaoAdapter(BasePlatformAdapter):
# Abstract method implementations
# ------------------------------------------------------------------
@property
def enforces_own_access_policy(self) -> bool:
"""Yuanbao gates DM/group access at intake via dm_policy/group_policy."""
return True
async def connect(self) -> bool:
"""Connect to Yuanbao WS gateway and authenticate.
+2516 -4707
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -26,7 +26,6 @@ piecemeal, the footer is sent as a separate trailing message via
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Iterable, Optional
_DEFAULT_FIELDS: tuple[str, ...] = ("model", "context_pct", "cwd")
+70
View File
@@ -91,6 +91,7 @@ class SessionSource:
guild_id: Optional[str] = None # Discord guild / Slack workspace / Matrix server scope
parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread
message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react)
role_authorized: bool = False # True when adapter granted access via role (not user ID)
@property
def description(self) -> str:
@@ -635,6 +636,22 @@ def build_session_key(
if source.thread_id:
return f"agent:main:{platform}:dm:{dm_chat_id}:{source.thread_id}"
return f"agent:main:{platform}:dm:{dm_chat_id}"
# No chat_id — fall back to the sender's own identifier before the
# bare per-platform sink. Without this, every DM from every user that
# arrives without a chat_id (non-standard adapters / synthetic sources)
# collapses into one shared "agent:main:<platform>:dm" session, and a
# single cached agent ends up serving multiple people's conversations —
# cross-user history bleed. participant_id keeps DMs isolated per user.
dm_participant_id = source.user_id_alt or source.user_id
if dm_participant_id and source.platform == Platform.WHATSAPP:
dm_participant_id = (
canonical_whatsapp_identifier(str(dm_participant_id))
or dm_participant_id
)
if dm_participant_id:
if source.thread_id:
return f"agent:main:{platform}:dm:{dm_participant_id}:{source.thread_id}"
return f"agent:main:{platform}:dm:{dm_participant_id}"
if source.thread_id:
return f"agent:main:{platform}:dm:{source.thread_id}"
return f"agent:main:{platform}:dm"
@@ -1277,6 +1294,7 @@ class SessionStore:
platform_message_id=(
message.get("platform_message_id") or message.get("message_id")
),
observed=bool(message.get("observed")),
)
except Exception as e:
logger.debug("Session DB operation failed: %s", e)
@@ -1308,6 +1326,58 @@ class SessionStore:
logger.debug("Could not load messages from DB: %s", e)
return []
def rewind_session(self, session_id: str, n: int = 1) -> Optional[Dict[str, Any]]:
"""Back up ``n`` user turns via soft-delete, keeping rows for audit.
Unlike :meth:`rewrite_transcript` (a hard replace used by /retry),
this flips the truncated rows to ``active=0`` in state.db so they
survive for audit and stay hidden from re-prompts and search. Mirrors
the CLI/TUI ``/undo [N]`` behavior via ``SessionDB.rewind_to_message``.
Returns a dict ``{"rewound_count", "turns_undone", "target_text"}`` on
success, or ``None`` if there's no DB or no user message to back up to.
``n`` clamps to the oldest user turn when it exceeds the turn count.
"""
if not self._db:
return None
if n < 1:
n = 1
try:
recents = self._db.list_recent_user_messages(session_id, limit=max(n, 10))
except Exception as e:
logger.debug("rewind_session: failed to list user messages: %s", e)
return None
if not recents:
return None
target_idx = min(n - 1, len(recents) - 1)
target_id = recents[target_idx]["id"]
try:
result = self._db.rewind_to_message(session_id, target_id)
except ValueError as e:
logger.debug("rewind_session: %s", e)
return None
except Exception as e:
logger.debug("rewind_session: rewind_to_message failed: %s", e)
return None
target_msg = result.get("target_message") or {}
content = target_msg.get("content") or ""
if isinstance(content, list):
parts = [
p.get("text", "")
for p in content
if isinstance(p, dict) and p.get("type") == "text"
]
target_text = "\n".join(t for t in parts if t)
elif isinstance(content, str):
target_text = content
else:
target_text = ""
return {
"rewound_count": result.get("rewound_count", 0),
"turns_undone": target_idx + 1,
"target_text": target_text,
}
def build_session_context(
source: SessionSource,
+37 -4
View File
@@ -83,6 +83,21 @@ _VAR_MAP = {
}
def set_current_session_id(session_id: str) -> None:
"""Synchronize ``HERMES_SESSION_ID`` across ContextVar and ``os.environ``.
Long-lived single-process entrypoints like the CLI can rotate sessions via
``/new``, ``/resume``, ``/branch``, or compression splits without
reconstructing the entire agent. Tools still consult
``get_session_env("HERMES_SESSION_ID")`` with an ``os.environ`` fallback,
so both storage paths must move together when the active session changes.
"""
import os
os.environ["HERMES_SESSION_ID"] = session_id
_SESSION_ID.set(session_id)
def set_session_vars(
platform: str = "",
chat_id: str = "",
@@ -91,15 +106,19 @@ def set_session_vars(
user_id: str = "",
user_name: str = "",
session_key: str = "",
session_id: str = "",
message_id: str = "",
cwd: str = "",
) -> list:
"""Set all session context variables and return reset tokens.
Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore
the previous values when the handler exits.
Call ``clear_session_vars(tokens)`` in a ``finally`` block when the handler
exits. Note ``clear_session_vars`` resets every var to ``""`` (to suppress
the ``os.environ`` fallback) rather than restoring prior values these
helpers are not nestable/stack-safe, and the returned tokens are accepted
only for API compatibility.
Returns a list of ``Token`` objects (one per variable) that can be
passed to ``clear_session_vars``.
``cwd`` pins the logical working directory for this context.
"""
tokens = [
_SESSION_PLATFORM.set(platform),
@@ -109,8 +128,15 @@ def set_session_vars(
_SESSION_USER_ID.set(user_id),
_SESSION_USER_NAME.set(user_name),
_SESSION_KEY.set(session_key),
_SESSION_ID.set(session_id),
_SESSION_MESSAGE_ID.set(message_id),
]
try:
from agent.runtime_cwd import set_session_cwd
set_session_cwd(cwd)
except Exception:
pass
return tokens
@@ -133,9 +159,16 @@ def clear_session_vars(tokens: list) -> None:
_SESSION_USER_ID,
_SESSION_USER_NAME,
_SESSION_KEY,
_SESSION_ID,
_SESSION_MESSAGE_ID,
):
var.set("")
try:
from agent.runtime_cwd import clear_session_cwd
clear_session_cwd()
except Exception:
pass
def get_session_env(name: str, default: str = "") -> str:
File diff suppressed because it is too large Load Diff
+87 -9
View File
@@ -227,7 +227,10 @@ def _read_json_file(path: Path) -> Optional[dict[str, Any]]:
return None
try:
raw = path.read_text(encoding="utf-8").strip()
except OSError:
except (OSError, UnicodeDecodeError):
# OSError: file vanished or permission flipped between exists() and
# read. UnicodeDecodeError: file holds non-UTF-8 / binary garbage
# (a truncated or clobbered status file). Either way it's unusable.
return None
if not raw:
return None
@@ -249,8 +252,9 @@ def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
try:
raw = pid_path.read_text().strip()
except OSError:
# File was deleted between exists() and read_text(), or permission flipped.
except (OSError, UnicodeDecodeError):
# File was deleted between exists() and read_text(), permission
# flipped, or it holds non-UTF-8 / binary garbage.
return None
if not raw:
return None
@@ -816,12 +820,24 @@ def _consume_pid_marker_for_self(
our_pid = os.getpid()
our_start_time = _get_process_start_time(our_pid)
matches = (
target_pid == our_pid
and target_start_time is not None
and our_start_time is not None
and target_start_time == our_start_time
)
# Start-time is a PID-reuse guard. It is only meaningful when both
# sides actually have it: ``_get_process_start_time`` returns None on
# platforms without ``/proc`` (macOS, native Windows — the very
# platform the planned-stop watcher exists for). Requiring a non-None
# match there would make every consume return False, so a legitimate
# ``hermes gateway stop`` on Windows would be misclassified as an
# unexpected ``UNKNOWN`` exit (exit 1) and revived by the service
# manager. So: when both start_times are known they must match; when
# either is unknown, fall back to PID equality alone (bounded by the
# marker's short TTL). This mirrors ``planned_stop_marker_targets_self``
# so the watcher's non-destructive probe and this authoritative
# consume agree on every platform (issue #34597).
if target_pid != our_pid:
matches = False
elif target_start_time is not None and our_start_time is not None:
matches = target_start_time == our_start_time
else:
matches = True
try:
path.unlink(missing_ok=True)
@@ -914,6 +930,68 @@ def consume_planned_stop_marker_for_self() -> bool:
)
def planned_stop_marker_targets_self() -> bool:
"""Return True only when a live planned-stop marker names the current process.
This is a **non-destructive** probe used by the watcher thread
(``gateway/run.py:_run_planned_stop_watcher``) to decide whether to
trigger shutdown. Unlike :func:`consume_planned_stop_marker_for_self`,
it never unlinks a marker that matches us the shutdown handler does
the authoritative consume on its own thread.
It *does* clean up markers that can never apply to this process:
malformed markers and markers older than the TTL are unlinked so a
stale file left behind by a previous gateway instance cannot wedge
the new one. Markers naming a different PID/start_time are left in
place (they may still be consumed legitimately by the process they
name) but report False here.
Returns False (without raising) on any read/parse error.
"""
path = _get_planned_stop_marker_path()
record = _read_json_file(path)
if not record:
return False
try:
target_pid = int(record["target_pid"])
target_start_time = record.get("target_start_time")
written_at = record.get("written_at") or ""
except (KeyError, TypeError, ValueError):
# Malformed marker can never match anyone — drop it.
try:
path.unlink(missing_ok=True)
except OSError:
pass
return False
if _marker_is_stale(written_at, _PLANNED_STOP_MARKER_TTL_S):
# A marker this old is past its useful life regardless of target —
# clean it up so it cannot crash-loop a freshly booted gateway.
try:
path.unlink(missing_ok=True)
except OSError:
pass
return False
our_pid = os.getpid()
if target_pid != our_pid:
return False
# Start-time is a PID-reuse guard. It is only meaningful when both
# sides actually have it: ``_get_process_start_time`` returns None on
# platforms without ``/proc`` (macOS, native Windows — the very
# platform this watcher exists for). Requiring a non-None match there
# would make the watcher never fire and re-break the #33778 Windows
# session-resume path. So: when both start_times are known they must
# match; when either is unknown, fall back to PID equality alone
# (the marker is short-lived under a 60s TTL, bounding reuse risk).
our_start_time = _get_process_start_time(our_pid)
if target_start_time is not None and our_start_time is not None:
return target_start_time == our_start_time
return True
def clear_planned_stop_marker() -> None:
"""Remove the planned-stop marker unconditionally."""
try:
+63 -18
View File
@@ -26,6 +26,7 @@ from typing import Any, Callable, Optional
from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter
from gateway.platforms.base import _custom_unit_to_cp
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
from gateway.config import (
DEFAULT_STREAMING_EDIT_INTERVAL as _DEFAULT_STREAMING_EDIT_INTERVAL,
DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD,
@@ -192,6 +193,11 @@ class GatewayStreamConsumer:
"""True when the stream consumer delivered the final assistant reply."""
return self._final_response_sent
@property
def message_id(self) -> str | None:
"""The Discord/chat message ID of the last-sent or edited message."""
return self._message_id
@property
def final_content_delivered(self) -> bool:
"""True when the final response content reached the user, even if
@@ -255,6 +261,12 @@ class GatewayStreamConsumer:
self._last_sent_text = ""
self._fallback_final_send = False
self._fallback_prefix = ""
# #29346: a tool/segment boundary means what we delivered was an interim
# preamble, not the final answer — clear the flags so a premature setter
# can't fool the gateway. Safe: got_done returns before any reset, and
# run.py reads these only after the consumer task exits.
self._final_response_sent = False
self._final_content_delivered = False
# Native draft streaming: bump the draft_id so the next text segment
# animates as a fresh preview below the tool-progress bubbles, not
# over the prior segment's already-finalized draft. This is how
@@ -518,7 +530,19 @@ class GatewayStreamConsumer:
if split_at < _safe_limit // 2:
split_at = _safe_limit
chunk = self._accumulated[:split_at]
ok = await self._send_or_edit(chunk)
# finalize=True so the adapter applies platform-specific
# rich-text markup (e.g. Telegram MarkdownV2). This
# sealed chunk will never be edited again — _message_id
# is reset to None right below — so it must receive its
# final formatting pass now, or early split messages
# render raw markdown while only the last chunk renders.
# is_turn_final=False: this is the first of several split
# messages, NOT the turn-final answer, so the fresh-final
# path (opt-in fresh_final_after_seconds) must not mark
# the turn delivered on it (#29346 semantics).
ok = await self._send_or_edit(
chunk, finalize=True, is_turn_final=False,
)
if self._fallback_final_send or not ok:
# Edit failed (or backed off due to flood control)
# while attempting to split an oversized message.
@@ -543,15 +567,13 @@ class GatewayStreamConsumer:
current_update_visible = await self._send_or_edit(
display_text,
finalize=(got_done or got_segment_break),
# A segment-break finalize closes a preamble, not the
# turn-final answer — only got_done marks delivered (#29346).
is_turn_final=got_done,
)
self._last_edit_time = time.monotonic()
if got_done:
# Record that the final content reached the user even
# if the cosmetic final edit below fails.
if current_update_visible and self._accumulated:
self._final_content_delivered = True
# Final edit without cursor. If progressive editing failed
# mid-stream, send a single continuation/fallback message
# here instead of letting the base gateway path send the
@@ -568,6 +590,7 @@ class GatewayStreamConsumer:
# final edit — but only for adapters that don't
# need an explicit finalize signal.
self._final_response_sent = True
self._final_content_delivered = True
elif self._message_id:
# Either the mid-stream edit didn't run (no
# visible update this tick) OR the adapter needs
@@ -575,8 +598,12 @@ class GatewayStreamConsumer:
self._final_response_sent = await self._send_or_edit(
self._accumulated, finalize=True,
)
if self._final_response_sent:
self._final_content_delivered = True
elif not self._already_sent:
self._final_response_sent = await self._send_or_edit(self._accumulated)
if self._final_response_sent:
self._final_content_delivered = True
return
if commentary_text is not None:
@@ -636,13 +663,17 @@ class GatewayStreamConsumer:
# "Let me search…") had been delivered, not the real answer.
if _best_effort_ok and not self._final_response_sent:
self._final_response_sent = True
self._final_content_delivered = True
except Exception as e:
logger.error("Stream consumer error: %s", e)
# Pattern to strip MEDIA:<path> tags (including optional surrounding quotes).
# Matches the simple cleanup regex used by the non-streaming path in
# gateway/platforms/base.py for post-processing.
_MEDIA_RE = re.compile(r'''[`"']?MEDIA:\s*\S+[`"']?''')
# Strip MEDIA:<path> tags before display. Uses the shared anchored
# MEDIA_TAG_CLEANUP_RE from gateway/platforms/base.py — only tags whose
# path ends in a deliverable extension are removed, so an unknown-extension
# path stays visible instead of being silently dropped (issue #34517).
# Streaming and non-streaming paths share the same regex, so a tag is
# treated identically whichever path delivered the text.
_MEDIA_RE = MEDIA_TAG_CLEANUP_RE
@staticmethod
def _clean_for_display(text: str) -> str:
@@ -773,6 +804,7 @@ class GatewayStreamConsumer:
pass
self._already_sent = True
self._final_response_sent = True
self._final_content_delivered = True
return
raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
@@ -809,11 +841,13 @@ class GatewayStreamConsumer:
if not result or not result.success:
if sent_any_chunk:
# Some continuation text already reached the user. Suppress
# the base gateway final-send path so we don't resend the
# full response and create another duplicate.
# Some continuation text already reached the user, but not
# the full response. Do NOT set _final_response_sent — the
# base gateway final-send path should still deliver the
# complete response so the user gets the full answer.
# Suppress only _already_sent to avoid a duplicate send
# of the same partial content.
self._already_sent = True
self._final_response_sent = True
self._message_id = last_message_id
self._last_sent_text = last_successful_chunk
self._fallback_prefix = ""
@@ -851,6 +885,7 @@ class GatewayStreamConsumer:
self._message_id = last_message_id
self._already_sent = True
self._final_response_sent = True
self._final_content_delivered = True
self._last_sent_text = chunks[-1]
self._fallback_prefix = ""
@@ -1044,12 +1079,17 @@ class GatewayStreamConsumer:
age = time.monotonic() - self._message_created_ts
return age >= threshold
async def _try_fresh_final(self, text: str) -> bool:
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
"""Send ``text`` as a brand-new message (best-effort delete the old
preview) so the platform's visible timestamp reflects completion
time. Returns True on successful delivery, False on any failure so
the caller falls back to the normal edit path.
``is_turn_final`` is False when finalizing an interim segment at a tool
boundary (a preamble) rather than the turn-final answer; the
final-delivery flag is then left unset so the gateway still delivers the
real answer from the next API call (#29346).
Ported from openclaw/openclaw#72038.
"""
old_message_id = self._message_id
@@ -1094,10 +1134,13 @@ class GatewayStreamConsumer:
self._message_created_ts = None
self._already_sent = True
self._last_sent_text = text
self._final_response_sent = True
if is_turn_final:
self._final_response_sent = True
return True
async def _send_or_edit(self, text: str, *, finalize: bool = False) -> bool:
async def _send_or_edit(
self, text: str, *, finalize: bool = False, is_turn_final: bool = True,
) -> bool:
"""Send or edit the streaming message.
Returns True if the text was successfully delivered (sent or edited),
@@ -1191,7 +1234,9 @@ class GatewayStreamConsumer:
if (
finalize
and self._should_send_fresh_final()
and await self._try_fresh_final(text)
and await self._try_fresh_final(
text, is_turn_final=is_turn_final,
)
):
return True
# Edit existing message
+132
View File
@@ -0,0 +1,132 @@
"""Adapter-driven dispatch of structured stream events to a delivery sink.
``GatewayEventDispatcher`` is the seam Tobi asked for: the agent emits typed
events (gateway/stream_events.py), and the *adapter* decides how each one is
delivered. The dispatcher holds an adapter + the stream consumer (sink) + the
resolved per-channel presentation settings (tool-progress mode, preview length)
and routes each event through the adapter's render hooks.
Message/commentary/segment events flow into the consumer (native draft on
Telegram DMs, edit-in-place elsewhere). Tool events are formatted by the
adapter which may return None to *eat* the event on platforms that can't
render tool chrome and the rendered line is enqueued onto the same tool
progress queue the gateway already drains, so the two no longer race through
independent code paths.
This module deliberately has no platform knowledge and no asyncio: it is a thin
synchronous router callable from the agent's worker thread, exactly like the
callbacks it replaces.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Optional
from gateway.stream_events import (
Commentary,
GatewayNotice,
LongToolHint,
MessageChunk,
MessageStop,
StreamEvent,
ToolCallChunk,
ToolCallFinished,
)
logger = logging.getLogger("gateway.stream_events")
class GatewayEventDispatcher:
"""Route typed stream events through an adapter onto a delivery sink.
Parameters
----------
adapter:
The platform adapter. Provides ``render_message_event`` and
``format_tool_event`` (BasePlatformAdapter defaults reproduce today's
behavior; adapters may override for native rendering).
sink:
The GatewayStreamConsumer for assistant-text delivery. May be None
when streaming is disabled, in which case message events are dropped
(the final response still goes out via the normal send path).
enqueue_tool_line:
Callback that places a rendered tool-progress line onto the gateway's
progress queue (the same queue ``send_progress_messages`` drains). May
be None when tool progress is disabled for this channel.
tool_mode:
Resolved tool-progress mode for this channel ("all" / "new" / "verbose"
/ "off").
preview_max_len:
Resolved ``tool_preview_length`` (0 = no cap in verbose mode).
on_long_tool / on_notice:
Optional hooks for LongToolHint / GatewayNotice events, letting the
gateway own the "should I surface this here?" decision.
"""
def __init__(
self,
adapter: Any,
sink: Any = None,
*,
enqueue_tool_line: Optional[Callable[[Any], None]] = None,
tool_mode: str = "all",
preview_max_len: int = 40,
on_long_tool: Optional[Callable[[LongToolHint], None]] = None,
on_notice: Optional[Callable[[GatewayNotice], None]] = None,
) -> None:
self.adapter = adapter
self.sink = sink
self._enqueue_tool_line = enqueue_tool_line
self.tool_mode = tool_mode or "all"
self.preview_max_len = preview_max_len
self._on_long_tool = on_long_tool
self._on_notice = on_notice
# "new" mode dedup — only report when the tool changes.
self._last_tool: Optional[str] = None
def dispatch(self, event: StreamEvent) -> None:
"""Route a single event. Never raises into the agent's worker thread."""
try:
self._dispatch(event)
except Exception: # presentation must never break the agent loop
logger.debug("stream-event dispatch error", exc_info=True)
def _dispatch(self, event: StreamEvent) -> None:
if isinstance(event, (MessageChunk, MessageStop, Commentary)):
if self.sink is not None:
self.adapter.render_message_event(event, self.sink)
return
if isinstance(event, ToolCallChunk):
if self.tool_mode == "off" or self._enqueue_tool_line is None:
return
# "new" mode: only emit when the tool changes.
if self.tool_mode == "new" and event.tool_name == self._last_tool:
return
self._last_tool = event.tool_name
line = self.adapter.format_tool_event(
event, mode=self.tool_mode, preview_max_len=self.preview_max_len,
)
# None == adapter chose to eat this event (can't render tool chrome).
if line:
self._enqueue_tool_line(line)
return
if isinstance(event, ToolCallFinished):
# Default: no chrome on completion (matches today — the gateway only
# rendered "started" events). Completion drives onboarding hints.
return
if isinstance(event, LongToolHint):
if self._on_long_tool is not None:
self._on_long_tool(event)
return
if isinstance(event, GatewayNotice):
if self._on_notice is not None:
self._on_notice(event)
return
__all__ = ["GatewayEventDispatcher"]
+171
View File
@@ -0,0 +1,171 @@
"""Structured streaming events — the agent→gateway delivery contract.
Historically the agent drove gateway delivery through a fan of loosely-typed
callbacks (``stream_delta_callback(text)``, ``tool_progress_callback(event_type,
tool_name, preview, args)``, ``interim_assistant_callback(text)`` ) and each
gateway callback decided *both* what to render and how to send it. That
coupling is why tool-progress bubbles and the streaming draft raced each other
on Telegram, and why tool-call formatting lived agent-side even though only the
gateway knows what a given platform can render.
This module defines a small, typed event vocabulary that names *what happened*
without prescribing *how it is delivered*. The gateway's stream consumer
(``GatewayStreamConsumer``) is the single sink; the platform adapter decides how
to render each event (Telegram can stream a MarkdownV2 ```bash``` block as a
native draft; iMessage has no rich formatting and may collapse or drop tool
chrome). Separation of concerns: smart agent emits structured data, smart
gateway decides delivery.
These are intentionally plain frozen dataclasses no behavior, no platform
knowledge, no I/O. They are cheap to construct on the agent's worker thread and
safe to hand across the thread/async boundary into the consumer queue.
Design constraints (see hermes-agent-dev skill message-flow + cache
invariants):
* Events describe *transport*, never *context*. Nothing here is persisted to
conversation history; what the gateway chooses to "eat" (e.g. tool chrome on
a platform that can't render it) must never diverge from the bytes stored in
the agent's message history. History is owned by the agent; these events are
a presentation-layer stream only.
* Backward compatible by construction. The gateway adapts its existing
callbacks into these events at the boundary; adapters that don't opt into
event-native rendering get identical behavior via the base-class default.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Union
# ── Message (assistant text) events ──────────────────────────────────────────
@dataclass(frozen=True)
class MessageChunk:
"""A delta of streamed assistant text.
``text`` is the incremental content as it arrives from the model. The
consumer accumulates chunks and progressively renders them (native draft on
Telegram DMs, edit-in-place elsewhere). Reasoning/think-block content is
filtered upstream and never arrives as a MessageChunk.
"""
text: str
@dataclass(frozen=True)
class MessageStop:
"""The current assistant message segment is complete.
Emitted when a contiguous run of assistant text ends either the whole
response finished, or a tool boundary interrupts the text so the next
segment should render as a fresh message *below* any tool chrome.
``final`` is True only for the terminal stop of the whole turn; an
intermediate stop (text tool call more text) carries ``final=False`` so
the consumer finalizes the current bubble and prepares a new segment without
treating the turn as done.
"""
final: bool = False
@dataclass(frozen=True)
class Commentary:
"""A complete interim assistant message emitted between tool iterations.
Example: the model says "I'll inspect the repo first." before issuing a tool
call. Unlike a MessageChunk this is already-complete text (not a delta); the
consumer renders it as its own message so it reads as a distinct beat.
"""
text: str
# ── Tool-call events ─────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ToolCallChunk:
"""A tool invocation has started (or its in-progress state changed).
Carries the raw facts about the call name, a short argument ``preview``,
and the full ``args`` dict and lets the *gateway* decide presentation
(emoji, truncation, verbose vs compact, or eat it entirely on platforms that
don't show tool chrome). Previously the agent's gateway callback baked the
emoji + preview formatting in; that decision now belongs to the adapter.
"""
tool_name: str
preview: Optional[str] = None
args: Optional[Dict[str, Any]] = None
# Monotonic per-turn index, so the consumer can correlate a finish with its
# start and so "new"-mode dedup (only report when the tool changes) works
# without the consumer tracking call order itself.
index: int = 0
@dataclass(frozen=True)
class ToolCallFinished:
"""A tool invocation completed.
``duration`` is wall-clock seconds. ``ok`` reflects whether the tool
returned without raising. The gateway uses this to clear/settle a progress
bubble and to drive one-time onboarding hints (e.g. suggest /verbose after a
long tool run). No tool *output* travels here output is the agent's
concern and is persisted to history, not streamed as presentation.
"""
tool_name: str
duration: float = 0.0
ok: bool = True
index: int = 0
# ── Gateway control / lifecycle events ───────────────────────────────────────
@dataclass(frozen=True)
class LongToolHint:
"""One-shot onboarding nudge when a tool runs longer than the threshold.
The gateway gates this on platform capability (the /verbose command must be
usable) and on the user not having seen the hint before. Modeled as an
event so the *gateway* owns the "should I surface this here?" decision rather
than the agent.
"""
tool_name: str = ""
duration: float = 0.0
@dataclass(frozen=True)
class GatewayNotice:
"""A gateway-originated control message (restart, online, long-run notice).
``kind`` is a stable string the adapter can switch on
(``"restart"`` / ``"online"`` / ``"long_run"`` / ). ``text`` is the
human-readable default the base class renders when an adapter has no
platform-specific treatment.
"""
kind: str
text: str = ""
extra: Dict[str, Any] = field(default_factory=dict)
# Union of every event the consumer's dispatcher accepts. Kept explicit (rather
# than a marker base class) so a missing ``case`` in an exhaustive match is a
# visible type error rather than a silent fall-through.
StreamEvent = Union[
MessageChunk,
MessageStop,
Commentary,
ToolCallChunk,
ToolCallFinished,
LongToolHint,
GatewayNotice,
]
__all__ = [
"MessageChunk",
"MessageStop",
"Commentary",
"ToolCallChunk",
"ToolCallFinished",
"LongToolHint",
"GatewayNotice",
"StreamEvent",
]