diff --git a/plugins/platforms/a2a/__init__.py b/plugins/platforms/a2a/__init__.py index 7d4a5383a5..a67c86ccd8 100644 --- a/plugins/platforms/a2a/__init__.py +++ b/plugins/platforms/a2a/__init__.py @@ -1,10 +1,5 @@ -""" -A2A (Agent-to-Agent) plugin for Hermes Agent. - -Registers the ``a2a`` platform adapter (inbound: exposes Hermes as an A2A v1.0 -agent) and five client tools in the ``a2a`` toolset (outbound: call other -agents). Zero core edits — everything goes through the public PluginContext. -""" +"""A2A (Agent-to-Agent) plugin: registers the inbound ``a2a`` platform adapter and the +five outbound client tools of the ``a2a`` toolset through the public PluginContext.""" from __future__ import annotations @@ -46,48 +41,44 @@ def is_connected(config) -> bool: def interactive_setup() -> None: """`hermes gateway setup` flow for A2A.""" - from hermes_cli.setup import ( - prompt, prompt_yes_no, save_env_value, get_env_value, print_header, print_info, print_warning, - ) + from hermes_cli.setup import prompt, prompt_yes_no, save_env_value, get_env_value, print_header, print_info, print_warning print_header("A2A (Agent-to-Agent)") print_info("Expose Hermes as an A2A-discoverable agent and call other A2A agents.") print_info("Uses Python stdlib — no extra packages needed.") print() + def ask(label: str, env: str) -> str: + """Prompt with the current env value as default; save the stripped answer when non-blank.""" + value = prompt(label, default=get_env_value(env) or "") + if value: + save_env_value(env, value.strip()) + return value + port = prompt("Inbound A2A port (default 9900)", default=get_env_value("A2A_PORT") or "") if port: try: save_env_value("A2A_PORT", str(int(port))) except ValueError: print_warning("Invalid port — using default 9900") - name = prompt("Agent name to advertise (blank = hostname-derived)", default=get_env_value("A2A_AGENT_NAME") or "") - if name: - save_env_value("A2A_AGENT_NAME", name.strip()) + ask("Agent name to advertise (blank = hostname-derived)", "A2A_AGENT_NAME") print() - print_info("Security: with NO token configured the server binds to 127.0.0.1 only.") - print_info("Prefer per-peer tokens (A2A_PEER_TOKENS=\"alice:tok1,bob:tok2\") so each") - print_info("remote agent has its own authenticated identity.") + for line in ("Security: with NO token configured the server binds to 127.0.0.1 only.", + "Prefer per-peer tokens (A2A_PEER_TOKENS=\"alice:tok1,bob:tok2\") so each", + "remote agent has its own authenticated identity."): + print_info(line) if prompt_yes_no("Configure tokens to allow REMOTE A2A peers?", False): - peer_tokens = prompt( - "Per-peer tokens (name:token, comma-separated; blank to skip)", - default=get_env_value("A2A_PEER_TOKENS") or "", - ) - if peer_tokens: - save_env_value("A2A_PEER_TOKENS", peer_tokens.strip()) + peer_tokens = ask("Per-peer tokens (name:token, comma-separated; blank to skip)", "A2A_PEER_TOKENS") token = prompt("Shared bearer token (blank to skip)", password=True) if token: save_env_value("A2A_BEARER_TOKEN", token) if peer_tokens or token: - host = prompt("Bind host for remote access (e.g. 0.0.0.0)", default=get_env_value("A2A_HOST") or "") - if host: - save_env_value("A2A_HOST", host.strip()) + ask("Bind host for remote access (e.g. 0.0.0.0)", "A2A_HOST") else: print_warning("No tokens entered — staying localhost-only.") def register(ctx) -> None: - """Plugin entry point — called by the Hermes plugin system.""" - # Client tools register even when the inbound platform is disabled so the - # agent can call peers without exposing itself. + """Plugin entry point. Client tools register even when the inbound platform is disabled + so the agent can call peers without exposing itself.""" try: from .tools import register_tools register_tools(ctx) @@ -96,21 +87,12 @@ def register(ctx) -> None: try: from .adapter import A2AAdapter ctx.register_platform( - name="a2a", - label="A2A", - adapter_factory=lambda cfg: A2AAdapter(cfg), - check_fn=check_requirements, - validate_config=validate_config, - is_connected=is_connected, - required_env=[], - install_hint="No extra packages needed (stdlib only)", - setup_fn=interactive_setup, + name="a2a", label="A2A", adapter_factory=lambda cfg: A2AAdapter(cfg), + check_fn=check_requirements, validate_config=validate_config, is_connected=is_connected, + required_env=[], install_hint="No extra packages needed (stdlib only)", setup_fn=interactive_setup, emoji="\U0001f9e9", # puzzle piece - allowed_users_env="A2A_ALLOWED_USERS", - allow_all_env="A2A_ALLOW_ALL_USERS", - cron_deliver_env_var="A2A_HOME_CHANNEL", - allow_update_command=False, - platform_hint=_PLATFORM_HINT, + allowed_users_env="A2A_ALLOWED_USERS", allow_all_env="A2A_ALLOW_ALL_USERS", + cron_deliver_env_var="A2A_HOME_CHANNEL", allow_update_command=False, platform_hint=_PLATFORM_HINT, ) except Exception: logger.warning("A2A: failed to register platform adapter", exc_info=True) diff --git a/plugins/platforms/a2a/adapter.py b/plugins/platforms/a2a/adapter.py index 1f568be2a1..5e85344b42 100644 --- a/plugins/platforms/a2a/adapter.py +++ b/plugins/platforms/a2a/adapter.py @@ -1,25 +1,12 @@ -""" -A2A inbound platform adapter — exposes Hermes as an A2A-discoverable agent. - -Stdlib http.server in a daemon thread (no a2a-sdk, no asyncio dependency at -register() time). Serves the v1.0 Agent Card at GET /.well-known/agent-card.json -(legacy agent.json too), /metrics, and JSON-RPC at POST /: message/send, -message/stream (SSE), tasks/{get,list,cancel,subscribe}, -tasks/pushNotificationConfig/{create,get,list,delete}. Push payloads are v1.0 -StreamResponse objects, HMAC-signed; configs may arrive inline in message/send. - -Each inbound task is filtered + framed (security.wrap_inbound) and routed into -the agent's LIVE gateway session via the normal MessageEvent path (full memory, -not a clone). The reply returns through ``adapter.send()``, which fulfils the -per-task Future the HTTP handler blocks on; ``on_processing_complete`` resolves -failures promptly. Every exchange is persisted and audit-logged. - -Bind safety: with no token configured, the server binds 127.0.0.1 only. -""" +"""A2A inbound adapter: stdlib http.server (daemon thread) serving the Agent Card, /metrics and +JSON-RPC (message/send, message/stream SSE, tasks/*, push-config CRUD). Inbound tasks are framed +(security.wrap_inbound) and routed into the LIVE gateway session; ``send()`` fulfils the per-task +Future the HTTP handler blocks on. No token configured => binds 127.0.0.1 only.""" from __future__ import annotations import asyncio +import contextlib import json import logging import os @@ -38,15 +25,14 @@ from typing import Any, Dict, Optional from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, ProcessingOutcome, SendResult from gateway.config import Platform -from gateway.platforms._shared import profile_scoped as _profile_scoped +from gateway.platforms._shared import coerce_port as _to_int, profile_scoped as _profile_scoped from . import protocol, security logger = logging.getLogger(__name__) _DEFAULT_PORT = 9900 -_ORPHAN_TIMEOUT = 300 # seconds before a pending task is considered orphaned -_WATCHDOG_INTERVAL = 60 # seconds between orphaned task watchdog runs +_ORPHAN_TIMEOUT, _WATCHDOG_INTERVAL = 300, 60 # seconds: pending task considered orphaned / watchdog period _MAX_BODY = 1_048_576 # 1MB max request body — prevents DoS via memory exhaustion _SSE_KEEPALIVE = 5 # seconds between SSE keepalive comments _DEFAULT_DESCRIPTION = "Hermes Agent — a general-purpose agent reachable over A2A." @@ -82,16 +68,8 @@ def _reply_timeout() -> float: return 300.0 -def _to_int(value: Any, default: Any) -> Any: - try: - return int(value) if value is not None else default - except (TypeError, ValueError): - return default - - def _default_agent_name() -> str: - # Scope-aware: in a secondary multiplex profile os.environ holds the DEFAULT profile's - # A2A_AGENT_NAME — use the hostname default rather than another profile's identity. + # Scope-aware: a secondary multiplex profile must not borrow the default profile's A2A_AGENT_NAME. name = "" if _profile_scoped() else os.getenv("A2A_AGENT_NAME", "").strip() if name: return name @@ -103,15 +81,14 @@ def _default_agent_name() -> str: def _clean_slug(value: str) -> str: - """Return a URL-safe-ish single-segment slug for a served agent.""" + """URL-safe-ish single-segment slug for a served agent ("" for the root agent).""" slug = str(value or "").strip().strip("/") return "" if slug in ("", "default", "root") else slug.split("/")[0] def _join_url(base: str, prefix: str) -> str: base = (base or "").strip() or "/" - if not base.endswith("/"): - base += "/" + base = base if base.endswith("/") else base + "/" prefix = (prefix or "").strip("/") return urllib.parse.urljoin(base, prefix + "/") if prefix else base @@ -125,17 +102,21 @@ def _active_profile_name() -> str: def _profile_home(profile: str) -> Optional[str]: - try: + with contextlib.suppress(Exception): from hermes_cli.profiles import get_profile_dir return str(get_profile_dir(profile)) - except Exception: - if not profile or profile == "default": - try: - from hermes_cli.config import get_hermes_home - return str(get_hermes_home()) - except Exception: - return None + if profile and profile != "default": return os.path.expanduser(f"~/.hermes/profiles/{profile}") + with contextlib.suppress(Exception): + from hermes_cli.config import get_hermes_home + return str(get_hermes_home()) + return None + + +def _daemon_thread(target, name: str) -> threading.Thread: + t = threading.Thread(target=target, name=name, daemon=True) + t.start() + return t def _safe_context_slug(value: str, max_len: int = 96) -> str: @@ -147,16 +128,15 @@ def _safe_context_slug(value: str, max_len: int = 96) -> str: def _state_db(profile: str, sql: str, params: tuple, log_msg: str, *, commit: bool = False) -> str: """Run one statement against a profile's state.db; first column of the first row or "".""" home = _profile_home(profile) - db = os.path.join(home, "state.db") if home else None + db = os.path.join(home, "state.db") if home else "" if not db or not os.path.exists(db): return "" try: - con = sqlite3.connect(db, timeout=5) - cur = con.execute(sql, params) - row = None if commit else cur.fetchone() - if commit: - con.commit() - con.close() + with contextlib.closing(sqlite3.connect(db, timeout=5)) as con: + cur = con.execute(sql, params) + row = None if commit else cur.fetchone() + if commit: + con.commit() return str(row[0]) if row else "" except Exception: logger.debug(log_msg, exc_info=True) @@ -164,8 +144,7 @@ def _state_db(profile: str, sql: str, params: tuple, log_msg: str, *, commit: bo class A2ARequestHandler(BaseHTTPRequestHandler): - """HTTP handler for the A2A JSON-RPC surface. Module-level so routing is - unit-testable; all state lives on ``self.server.adapter`` (set in connect()).""" + """HTTP handler for the A2A JSON-RPC surface; all state lives on ``self.server.adapter``.""" @property def adapter(self) -> "A2AAdapter": @@ -177,8 +156,8 @@ class A2ARequestHandler(BaseHTTPRequestHandler): def _json(self, code: int, payload: dict): body = json.dumps(payload).encode("utf-8") self.send_response(code) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) + for k, v in (("Content-Type", "application/json"), ("Content-Length", str(len(body)))): + self.send_header(k, v) self.end_headers() self.wfile.write(body) @@ -189,42 +168,36 @@ class A2ARequestHandler(BaseHTTPRequestHandler): return self.client_address[0] if self.client_address else "" def _request_public_url(self) -> str: - """Routable URL for this request: A2A_PUBLIC_URL > X-Forwarded-Host / Host - (scheme from X-Forwarded-Proto) > "" (caller falls back to bind host).""" + """A2A_PUBLIC_URL > X-Forwarded-Host / Host (scheme from X-Forwarded-Proto) > "" (bind host).""" explicit = os.getenv("A2A_PUBLIC_URL", "").strip() if explicit: return explicit - host = self.headers.get("X-Forwarded-Host", "") or self.headers.get("Host", "") - if not host: - return "" - host = host.split(",")[0].strip() + host = (self.headers.get("X-Forwarded-Host", "") or self.headers.get("Host", "")).split(",")[0].strip() scheme = (self.headers.get("X-Forwarded-Proto", "") or "http").split(",")[0].strip() - return f"{scheme}://{host}/" + return f"{scheme}://{host}/" if host else "" def do_GET(self): # noqa: N802 adapter = self.adapter route = adapter._route_for_path(self.path) agent = route["agent"] subpath = route["subpath"].rstrip("/") or "/" + public_url = self._request_public_url() or None if subpath in ("/.well-known/agent.json", "/.well-known/agent-card.json"): - self._json(200, adapter._build_card(self._request_public_url() or None, agent=agent)) - elif subpath in ("/", "/health"): - payload = {"status": "ok", "agent": agent.get("name") or adapter.agent_name} - # Agent Cards are intentionally public; profile/tenant topology is not - # leaked on remote unauthenticated GETs. - sec = adapter._security_context - if sec.localhost_only() or sec.authenticate(self.headers.get("Authorization"), self._client_ip()) is not None: - payload["served_agents"] = adapter._served_agent_summary(public_url=self._request_public_url() or None) - self._json(200, payload) - elif subpath == "/metrics": - self._json(200, protocol.metrics.snapshot()) - else: - self._json(404, {"error": "not found"}) + return self._json(200, adapter._build_card(public_url, agent=agent)) + if subpath == "/metrics": + return self._json(200, protocol.metrics.snapshot()) + if subpath not in ("/", "/health"): + return self._json(404, {"error": "not found"}) + payload = {"status": "ok", "agent": agent.get("name") or adapter.agent_name} + # Agent Cards are public; profile/tenant topology is not leaked on remote unauthenticated GETs. + sec = adapter._security_context + if sec.localhost_only() or sec.authenticate(self.headers.get("Authorization"), self._client_ip()) is not None: + payload["served_agents"] = adapter._served_agent_summary(public_url=public_url) + self._json(200, payload) def do_POST(self): # noqa: N802 adapter = self.adapter - # Identity comes from the presented credential (or the socket in - # localhost-only mode) — never from the request body. + # Identity comes from the credential (or the socket in localhost-only mode) — never the body. identity = adapter._security_context.authenticate(self.headers.get("Authorization"), self._client_ip()) if identity is None: return self._error(401, None, protocol.ERR_UNAUTHORIZED, "unauthorized") @@ -232,32 +205,32 @@ class A2ARequestHandler(BaseHTTPRequestHandler): length = int(self.headers.get("Content-Length", 0)) if length > _MAX_BODY: return self._error(413, None, protocol.ERR_PARSE, "payload too large") - raw = self.rfile.read(length) if length else b"{}" - req = json.loads(raw.decode("utf-8")) + req = json.loads((self.rfile.read(length) if length else b"{}").decode("utf-8")) except Exception: return self._error(400, None, protocol.ERR_PARSE, "parse error") if not isinstance(req, dict): return self._error(400, None, protocol.ERR_INVALID_PARAMS, "JSON-RPC request must be an object") - req_id = req.get("id") - method = str(req.get("method", "")) + req_id, method = req.get("id"), str(req.get("method", "")) params = req["params"] if req.get("params") is not None else {} - if not isinstance(params, dict): - return self._error(200, req_id, protocol.ERR_INVALID_PARAMS, "params must be an object") version = (self.headers.get("A2A-Version") or "").strip() - if version and version not in {"1.0", "1.0.0"}: - return self._error(200, req_id, protocol.ERR_INVALID_PARAMS, f"unsupported A2A-Version: {version}") + route = adapter._route_for_request(self.path, params) if isinstance(params, dict) else {} handler_name, is_v1 = _METHODS.get(method, ("", False)) - route = adapter._route_for_request(self.path, params) - if route.get("error"): - return self._error(400, req_id, protocol.ERR_INVALID_PARAMS, route["error"]) + # Ordered, lazily-evaluated checks -> (http status, error code, message); first failure wins + # (the rate limiter must not be consulted for requests rejected before it). + checks = ( + (lambda: not isinstance(params, dict), 200, protocol.ERR_INVALID_PARAMS, "params must be an object"), + (lambda: version and version not in {"1.0", "1.0.0"}, 200, protocol.ERR_INVALID_PARAMS, f"unsupported A2A-Version: {version}"), + (lambda: route.get("error"), 400, protocol.ERR_INVALID_PARAMS, route.get("error")), + (lambda: not adapter._rate_limiter.allow(identity), 429, protocol.ERR_RATE_LIMITED, "rate limit exceeded"), + (lambda: not adapter._security_context.is_trusted_peer(identity), 403, protocol.ERR_UNTRUSTED_PEER, f"peer '{identity}' not trusted"), + (lambda: not handler_name, 200, protocol.ERR_METHOD_NOT_FOUND, f"method not found: {method}"), + ) + for failed, http, code, msg in checks: + if failed(): + if code == protocol.ERR_RATE_LIMITED: + protocol.metrics.rate_limit_triggers += 1 + return self._error(http, req_id, code, msg) agent = route["agent"] - if not adapter._rate_limiter.allow(identity): - protocol.metrics.rate_limit_triggers += 1 - return self._error(429, req_id, protocol.ERR_RATE_LIMITED, "rate limit exceeded") - if not adapter._security_context.is_trusted_peer(identity): - return self._error(403, req_id, protocol.ERR_UNTRUSTED_PEER, f"peer '{identity}' not trusted") - if not handler_name: - return self._error(200, req_id, protocol.ERR_METHOD_NOT_FOUND, f"method not found: {method}") if handler_name == "_rpc_message_send": self._json(200, adapter._rpc_message_send(req_id, params, identity, agent=agent, v1_response=is_v1)) elif handler_name == "_rpc_message_stream": @@ -274,9 +247,8 @@ class A2AAdapter(BasePlatformAdapter): def __init__(self, config, **kwargs): super().__init__(config=config, platform=Platform("a2a")) extra = getattr(config, "extra", {}) or {} - # Scope-aware: a secondary multiplex profile must not borrow the default profile's - # bridged A2A_PORT (falls closed to the module default). advertised_toolsets is - # deliberately left unscoped while its None-vs-empty-list semantics are in flux. + # Scope-aware: a secondary multiplex profile must not borrow the default profile's bridged + # A2A_PORT (falls closed to the module default). advertised_toolsets is deliberately unscoped. self._security_context = security.A2ASecurityContext.capture() _port_env = None if _profile_scoped() else os.getenv("A2A_PORT") self.port = int(_port_env or extra.get("port", _DEFAULT_PORT)) @@ -287,21 +259,15 @@ class A2AAdapter(BasePlatformAdapter): self._active_profile = _active_profile_name() self._agents = self._load_served_agents(extra) self._httpd: Optional[ThreadingHTTPServer] = None - self._server_thread: Optional[threading.Thread] = None + self._server_thread = self._watchdog_thread = None # type: Optional[threading.Thread] self._loop: Optional[asyncio.AbstractEventLoop] = None self._watchdog_stop = threading.Event() - self._watchdog_thread: Optional[threading.Thread] = None - # Per-adapter protocol state (not module-global). - self.tasks = protocol.TaskStore() - self._turns = protocol.TurnTracker() - self._rate_limiter = protocol.RateLimiter() - + self.tasks, self._turns, self._rate_limiter = protocol.TaskStore(), protocol.TurnTracker(), protocol.RateLimiter() # Forwarded profile sessions: (profile, agent_slug, context_id) -> session_id. self._profile_sessions: Dict[tuple[str, str, str], str] = {} self._profile_session_locks: Dict[tuple[str, str, str], threading.Lock] = {} self._profile_session_locks_guard = threading.Lock() - # Pending reply futures: task_id -> (context_id, Future). _pending_order keeps per-context # FIFO so adapter.send() — which only knows the context — resolves the oldest task. self._pending: Dict[str, tuple[str, Future]] = {} @@ -314,20 +280,13 @@ class A2AAdapter(BasePlatformAdapter): @property def authorization_is_upstream(self) -> bool: - """Every request is authenticated in ``do_POST`` before dispatch; without this - the gateway's ``A2A_ALLOWED_USERS`` allow-list would reject peers, whose identity - is a token-derived name or IP. Not fail-open: a wrong credential is still 401'd.""" + """Requests are authenticated in ``do_POST``; the gateway's A2A_ALLOWED_USERS list would + otherwise reject peers (identity is a token-derived name or IP). Wrong credentials still 401.""" return True - # ── Lifecycle ───────────────────────────────────────────────────────── - async def connect(self, **_kwargs) -> bool: - # **_kwargs: base lifecycle passes ``is_reconnect`` etc. Capture the gateway loop so - # the HTTP thread can marshal events onto it via run_coroutine_threadsafe. - try: - self._loop = asyncio.get_running_loop() - except RuntimeError: - self._loop = None + # Capture the gateway loop so the HTTP thread can marshal events via run_coroutine_threadsafe. + self._loop = asyncio.get_running_loop() try: self._httpd = ThreadingHTTPServer((self.host, self.port), A2ARequestHandler) except OSError as e: @@ -336,34 +295,27 @@ class A2AAdapter(BasePlatformAdapter): return False self._httpd.daemon_threads = True self._httpd.adapter = self # type: ignore[attr-defined] - self._server_thread = threading.Thread(target=self._httpd.serve_forever, name="a2a-http", daemon=True) - self._server_thread.start() + self._server_thread = _daemon_thread(self._httpd.serve_forever, "a2a-http") self._watchdog_stop.clear() # disconnect sets it; reset for reconnection - self._watchdog_thread = threading.Thread(target=self._watchdog_loop, name="a2a-watchdog", daemon=True) - self._watchdog_thread.start() + self._watchdog_thread = _daemon_thread(self._watchdog_loop, "a2a-watchdog") self._mark_connected() - exposure = "localhost-only" if self._security_context.localhost_only() else "REMOTE (bearer auth)" - logger.info("A2A: serving Agent Card + JSON-RPC on http://%s:%s (%s) as %r; %d routed agent(s)", - self.host, self.port, exposure, self.agent_name, len(self._agents)) - # Plugin-registered native handlers (ctx.register_platform_handler). - self._wire_plugin_handlers(None) + logger.info("A2A: serving Agent Card + JSON-RPC on http://%s:%s (%s) as %r; %d routed agent(s)", self.host, self.port, + "localhost-only" if self._security_context.localhost_only() else "REMOTE (bearer auth)", self.agent_name, len(self._agents)) + self._wire_plugin_handlers(None) # plugin-registered native handlers return True async def disconnect(self) -> None: self._mark_disconnected() self._watchdog_stop.set() if self._httpd is not None: - try: + with contextlib.suppress(Exception): self._httpd.shutdown() self._httpd.server_close() - except Exception: - pass self._httpd = None # Fail any in-flight replies so blocked HTTP threads don't hang. with self._pending_lock: - for _ctx, fut in self._pending.values(): - if not fut.done(): - fut.set_result((protocol.STATE_FAILED, "[agent shutting down]")) + for tid in list(self._pending): + self._resolve_locked(tid, protocol.STATE_FAILED, "[agent shutting down]") self._pending.clear() self._pending_order.clear() @@ -377,8 +329,6 @@ class A2AAdapter(BasePlatformAdapter): except Exception: logger.debug("A2A: watchdog error", exc_info=True) - # ── Agent routing + Agent Cards ─────────────────────────────────────── - def _load_served_agents(self, extra: dict) -> dict[str, dict]: """Served-agent routing from ``platforms.a2a.extra.agents`` (top-level ``a2a_served_agents`` fallback for scripts/tests). Root/default always maps to the live gateway session.""" @@ -387,11 +337,10 @@ class A2AAdapter(BasePlatformAdapter): try: from hermes_cli.config import load_config cfg = load_config() or {} - cfg = cfg if isinstance(cfg, dict) else {} except Exception: cfg = {} + cfg = cfg if isinstance(cfg, dict) else {} raw = cfg.get("a2a_served_agents") or (cfg.get("a2a") or {}).get("served_agents") - # Scope-aware like port: a secondary profile must not inherit A2A_AGENT_DESCRIPTION. default_desc = _DEFAULT_DESCRIPTION if _profile_scoped() else os.getenv("A2A_AGENT_DESCRIPTION", _DEFAULT_DESCRIPTION) agents: dict[str, dict] = {"": { @@ -415,7 +364,6 @@ class A2AAdapter(BasePlatformAdapter): toolsets = val.get("advertised_toolsets") or val.get("toolsets") or val.get("capabilities") or [] if isinstance(toolsets, str): toolsets = [t.strip() for t in toolsets.split(",") if t.strip()] - local = bool(val.get("local")) or profile in ("", "default", self._active_profile) tenant = str(val.get("tenant") or slug).strip() if tenant: if tenant in tenants: @@ -424,7 +372,8 @@ class A2AAdapter(BasePlatformAdapter): continue tenants[tenant] = slug agents[slug] = { - "slug": slug, "path": "/" + path_segment, "tenant": tenant, "profile": profile or slug, "local": local, + "slug": slug, "path": "/" + path_segment, "tenant": tenant, "profile": profile or slug, + "local": bool(val.get("local")) or profile in ("", "default", self._active_profile), "name": str(val.get("name") or f"Hermes {slug}"), "description": str(val.get("description") or f"Hermes profile '{profile or slug}' exposed over A2A."), "advertised_toolsets": list(toolsets or []), @@ -437,18 +386,15 @@ class A2AAdapter(BasePlatformAdapter): def _served_agent_summary(self, public_url: Optional[str] = None) -> list[dict]: base = self._base_url(public_url) - return [ - {"slug": a["slug"] or "default", "name": a.get("name"), "url": _join_url(base, a.get("path", "")), - "tenant": a.get("tenant") or None, "profile": a.get("profile"), "local": bool(a.get("local"))} - for a in self._agents.values() - ] + return [{"slug": a["slug"] or "default", "name": a.get("name"), "url": _join_url(base, a.get("path", "")), + "tenant": a.get("tenant") or None, "profile": a.get("profile"), "local": bool(a.get("local"))} + for a in self._agents.values()] def _route_for_path(self, raw_path: str) -> dict: path = urllib.parse.urlsplit(raw_path or "/").path or "/" # Longest prefix wins. Default/root agent is the fallback. for agent in sorted(self._agents.values(), key=lambda a: len(a.get("path", "")), reverse=True): - prefix = agent.get("path", "") or "" - if prefix and (path == prefix or path.startswith(prefix + "/")): + if (prefix := agent.get("path", "") or "") and (path == prefix or path.startswith(prefix + "/")): return {"agent": agent, "subpath": path[len(prefix):] or "/"} return {"agent": self._agents[""], "subpath": path} @@ -457,51 +403,39 @@ class A2AAdapter(BasePlatformAdapter): agent = route["agent"] tenant = str((params or {}).get("tenant") or "") # If no URL prefix chose a non-default agent, allow v1.0 tenant routing. - if agent.get("slug") == "" and tenant: - matches = [a for a in self._agents.values() if a.get("tenant") == tenant] - if matches: - route = {"agent": matches[0], "subpath": route["subpath"]} - agent = matches[0] + if agent.get("slug") == "" and tenant and (matches := [a for a in self._agents.values() if a.get("tenant") == tenant]): + agent = matches[0] + route = {"agent": agent, "subpath": route["subpath"]} expected = str(agent.get("tenant") or "") if tenant and expected and tenant != expected: return {"error": f"tenant {tenant!r} does not match routed agent {agent.get('slug') or 'default'}"} return route def _build_card(self, public_url: Optional[str] = None, agent: Optional[dict] = None) -> dict: - # Per-request public URL (X-Forwarded-Host / Host / A2A_PUBLIC_URL) beats - # the bind host so peers behind a reverse proxy can call back. + # Per-request public URL beats the bind host so peers behind a reverse proxy can call back. agent = agent or self._agents[""] return protocol.build_agent_card( - name=agent.get("name") or self.agent_name, - url=_join_url(self._base_url(public_url), agent.get("path", "")), - description=agent.get("description") or _DEFAULT_DESCRIPTION, - skills=self._advertised_skills(agent), - streaming=bool(agent.get("local", True)), - push_notifications=True, - auth_required=not self._security_context.localhost_only(), - tenant=str(agent.get("tenant") or ""), + name=agent.get("name") or self.agent_name, url=_join_url(self._base_url(public_url), agent.get("path", "")), + description=agent.get("description") or _DEFAULT_DESCRIPTION, skills=self._advertised_skills(agent), + streaming=bool(agent.get("local", True)), push_notifications=True, + auth_required=not self._security_context.localhost_only(), tenant=str(agent.get("tenant") or ""), ) def _advertised_skills(self, agent: Optional[dict] = None) -> list[dict]: - """Dynamic Agent Card skills from the live tool registry, restricted by - ``advertised_toolsets`` / A2A_ADVERTISED_TOOLSETS; static fallback without a registry.""" + """Agent Card skills from the live tool registry, restricted by ``advertised_toolsets``; + static fallback without a registry.""" configured = (agent or {}).get("advertised_toolsets") if agent else self._advertised_toolsets try: from tools.registry import registry as tool_registry allowed = set(configured or []) or None - mapping = { - n: tool_registry.get_tool_names_for_toolset(n) - for n in tool_registry.get_registered_toolset_names() - if allowed is None or n in allowed - } + mapping = {n: tool_registry.get_tool_names_for_toolset(n) + for n in tool_registry.get_registered_toolset_names() if allowed is None or n in allowed} if mapping: return protocol.skills_from_toolsets(mapping) except Exception: logger.debug("A2A: tool registry unavailable for Agent Card", exc_info=True) return protocol.skills_from_toolsets(configured or []) - # ── Pending reply plumbing ──────────────────────────────────────────── - def _add_pending(self, task_id: str, context_id: str) -> Future: fut: Future = Future() with self._pending_lock: @@ -513,18 +447,17 @@ class A2AAdapter(BasePlatformAdapter): with self._pending_lock: entry = self._pending.pop(task_id, None) order = self._pending_order.get(entry[0]) if entry else None - if order: - if task_id in order: - order.remove(task_id) - if not order: - self._pending_order.pop(entry[0], None) + if order and task_id in order: + order.remove(task_id) + if order is not None and not order: + self._pending_order.pop(entry[0], None) def _resolve_locked(self, task_id: str, state: str, text: str) -> bool: entry = self._pending.get(task_id) - if entry and not entry[1].done(): - entry[1].set_result((state, text)) - return True - return False + if not entry or entry[1].done(): + return False + entry[1].set_result((state, text)) + return True def _resolve_task(self, task_id: str, state: str, text: str) -> bool: with self._pending_lock: @@ -535,20 +468,16 @@ class A2AAdapter(BasePlatformAdapter): return any(self._resolve_locked(tid, state, text) for tid in self._pending_order.get(context_id, ())) def _scope_for_agent(self, agent: Optional[dict]) -> tuple[str, str]: - agent = agent or self._agents[""] - return str(agent.get("slug") or ""), str(agent.get("tenant") or "") + return tuple(str((agent or self._agents[""]).get(k) or "") for k in ("slug", "tenant")) def _forward_lock(self, key: tuple[str, str, str]) -> threading.Lock: with self._profile_session_locks_guard: return self._profile_session_locks.setdefault(key, threading.Lock()) - # ── Inbound task handling ───────────────────────────────────────────── - def _end_task(self, rec: dict, state: str, text: str, stored_reply: str = "") -> tuple[dict, None]: """Complete a task immediately (rejected / not ready) and build its terminal Task.""" self.tasks.complete(rec["task_id"], state, stored_reply) - if state == protocol.STATE_FAILED: - protocol.metrics.tasks_failed += 1 + protocol.metrics.tasks_failed += state == protocol.STATE_FAILED return protocol.build_task(rec["task_id"], rec["context_id"], state, text, created_at=rec["created_iso"]), None def _prepare_task(self, params: dict, peer: str, agent: Optional[dict] = None) -> tuple[Optional[dict], Optional[dict]]: @@ -564,11 +493,8 @@ class A2AAdapter(BasePlatformAdapter): if turn > max_turns: protocol.metrics.anti_loop_triggers += 1 logger.warning("A2A: anti-loop triggered for context %s (turn %d > %d)", context_id, turn, max_turns) - return self._end_task( - rec, protocol.STATE_REJECTED, - f"Anti-loop protection: context {context_id} exceeded {max_turns} turns. " - f"Start a new context or increase A2A_MAX_PINGPONG_TURNS.", - ) + return self._end_task(rec, protocol.STATE_REJECTED, f"Anti-loop protection: context {context_id} exceeded " + f"{max_turns} turns. Start a new context or increase A2A_MAX_PINGPONG_TURNS.") if not text: return self._end_task(rec, protocol.STATE_REJECTED, "Empty task — nothing to do.") framed = security.wrap_inbound(peer, text) @@ -583,12 +509,8 @@ class A2AAdapter(BasePlatformAdapter): if self._loop is None or self._message_handler is None: return self._end_task(rec, protocol.STATE_FAILED, "Agent gateway not ready to accept A2A tasks.") fut = self._add_pending(task_id, context_id) - event = MessageEvent( - text=framed, - message_type=MessageType.TEXT, - source=self.build_source(chat_id=context_id, chat_name=f"a2a:{peer}", chat_type="dm", user_id=peer, user_name=peer), - message_id=task_id, - ) + event = MessageEvent(text=framed, message_type=MessageType.TEXT, message_id=task_id, + source=self.build_source(chat_id=context_id, chat_name=f"a2a:{peer}", chat_type="dm", user_id=peer, user_name=peer)) try: asyncio.run_coroutine_threadsafe(self.handle_message(event), self._loop) except Exception as e: @@ -596,13 +518,11 @@ class A2AAdapter(BasePlatformAdapter): msg = security.redact_outbound(f"Dispatch failed: {e}") return self._end_task(rec, protocol.STATE_FAILED, msg, stored_reply=msg) self.tasks.set_state(task_id, protocol.STATE_WORKING) - return None, {"task_id": task_id, "context_id": context_id, "peer": peer, "future": fut, - "created_iso": rec["created_iso"], "started": time.time()} + return None, {"task_id": task_id, "context_id": context_id, "peer": peer, "future": fut, "created_iso": rec["created_iso"], "started": time.time()} def _forward_to_profile(self, agent: dict, peer: str, context_id: str, framed_text: str) -> tuple[str, str]: - """Forward a routed A2A task to another local Hermes profile via ``hermes chat``. - First contact creates a ``source=a2a`` session, records its id and titles it - deterministically; later turns ``--resume`` that concrete id (stable multi-turn continuity).""" + """Forward a routed task to another local profile via ``hermes chat``. First contact creates a + ``source=a2a`` session and titles it deterministically; later turns ``--resume`` that id.""" profile = str(agent.get("profile") or agent.get("slug") or "").strip() slug = str(agent.get("slug") or profile or "agent") safe_ctx = _safe_context_slug(context_id) @@ -612,16 +532,11 @@ class A2AAdapter(BasePlatformAdapter): with self._forward_lock(key): session_id = self._profile_sessions.get(key) or _state_db( profile, "SELECT id FROM sessions WHERE title = ? ORDER BY started_at DESC LIMIT 1", - (session_title,), "A2A: could not lookup forwarded session", - ) - cmd = ["hermes", "chat", "-q", framed_text, "-Q", "--source", "a2a"] - if session_id: - cmd.extend(["--resume", session_id]) - env = os.environ.copy() - home = _profile_home(profile) - if home: + (session_title,), "A2A: could not lookup forwarded session") + cmd = ["hermes", "chat", "-q", framed_text, "-Q", "--source", "a2a"] + (["--resume", session_id] if session_id else []) + env = {**os.environ, "HERMES_A2A_PEER": peer} + if home := _profile_home(profile): env["HERMES_HOME"] = home - env["HERMES_A2A_PEER"] = peer start = time.time() try: proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", @@ -633,15 +548,12 @@ class A2AAdapter(BasePlatformAdapter): if proc.returncode != 0: msg = (proc.stderr or proc.stdout or f"profile exited {proc.returncode}").strip() return security.redact_outbound(msg[-2000:]), protocol.STATE_FAILED - if not session_id: - session_id = _state_db( + if not session_id and (session_id := _state_db( profile, "SELECT id FROM sessions WHERE source = 'a2a' AND started_at >= ? ORDER BY started_at DESC LIMIT 1", - (start - 2.0,), "A2A: could not find latest forwarded session", - ) - if session_id: - self._profile_sessions[key] = session_id - _state_db(profile, "UPDATE sessions SET title = ? WHERE id = ?", (session_title, session_id), - "A2A: could not title forwarded session", commit=True) + (start - 2.0,), "A2A: could not find latest forwarded session")): + self._profile_sessions[key] = session_id + _state_db(profile, "UPDATE sessions SET title = ? WHERE id = ?", (session_title, session_id), + "A2A: could not title forwarded session", commit=True) return security.redact_outbound((proc.stdout or "").strip()), protocol.STATE_COMPLETED def _record_outcome(self, task_id: str, context_id: str, peer: str, state: str, reply: str, @@ -649,49 +561,49 @@ class A2AAdapter(BasePlatformAdapter): """Persist + audit + count a finished task, mark it terminal, and fire its push callback.""" protocol.persist_message(context_id, "agent", reply, task_id) security.audit("outbound", peer, task_id, reply) + m = protocol.metrics if state in (protocol.STATE_COMPLETED, protocol.STATE_INPUT_REQUIRED): - protocol.metrics.outbound_total += 1 - protocol.metrics.tasks_completed += 1 + m.outbound_total, m.tasks_completed = m.outbound_total + 1, m.tasks_completed + 1 if started is not None: - protocol.metrics.record_latency(time.time() - started) + m.record_latency(time.time() - started) else: - protocol.metrics.tasks_failed += 1 + m.tasks_failed += 1 self.tasks.complete(task_id, state, reply) self._send_push_notification(task_id, context_id, reply, state) def _finalize_task(self, pending: dict, state: str, reply: str) -> tuple[str, str]: - """Record the outcome of a dispatched task; returns (state, reply) after - redaction and input-required detection.""" + """Record a dispatched task's outcome; returns (state, reply) after redaction and + input-required detection (a leading marker flags a clarification request).""" task_id, context_id, peer = pending["task_id"], pending["context_id"], pending["peer"] self._pop_pending(task_id) reply = security.redact_outbound(reply or "") - # A leading marker flags a clarification request -> A2A input-required. - if state == protocol.STATE_COMPLETED: - stripped = reply.lstrip() - if stripped.upper().startswith(protocol.INPUT_REQUIRED_MARKER): - state = protocol.STATE_INPUT_REQUIRED - reply = stripped[len(protocol.INPUT_REQUIRED_MARKER):].strip() + stripped = reply.lstrip() + if state == protocol.STATE_COMPLETED and stripped.upper().startswith(protocol.INPUT_REQUIRED_MARKER): + state, reply = protocol.STATE_INPUT_REQUIRED, stripped[len(protocol.INPUT_REQUIRED_MARKER):].strip() self._record_outcome(task_id, context_id, peer, state, reply, started=pending["started"]) return state, reply - def _await_reply(self, pending: dict, keepalive=None) -> tuple[str, str]: - """Block until the task's future resolves (or times out). ``keepalive`` runs every - _SSE_KEEPALIVE seconds while waiting; if it raises, the client is gone and we stop.""" - fut: Future = pending["future"] - deadline = pending["started"] + _reply_timeout() + @staticmethod + def _await_future(fut: Future, deadline: float, keepalive, on_timeout: tuple[str, str]) -> tuple[str, str]: + """Block until ``fut`` resolves or ``deadline`` passes (-> ``on_timeout``). ``keepalive`` runs + every _SSE_KEEPALIVE seconds while waiting; if it raises, the client is gone and we stop.""" while True: try: return fut.result(timeout=_SSE_KEEPALIVE if keepalive else max(0.0, deadline - time.time())) except FuturesTimeout: if time.time() >= deadline: - return (protocol.STATE_FAILED, "[agent did not reply in time]") + return on_timeout if keepalive: try: keepalive() except Exception: return (protocol.STATE_FAILED, "[client disconnected]") except Exception: - return (protocol.STATE_FAILED, "[agent did not reply in time]") + return on_timeout + + def _await_reply(self, pending: dict, keepalive=None) -> tuple[str, str]: + return self._await_future(pending["future"], pending["started"] + _reply_timeout(), keepalive, + (protocol.STATE_FAILED, "[agent did not reply in time]")) def _rpc_message_send(self, req_id: Any, params: dict, peer: str, agent: Optional[dict] = None, v1_response: bool = False) -> dict: task, pending = self._prepare_task(params, peer, agent=agent) @@ -700,31 +612,31 @@ class A2AAdapter(BasePlatformAdapter): task = protocol.build_task(pending["task_id"], pending["context_id"], state, reply, created_at=pending["created_iso"]) return _ok(req_id, protocol.send_message_response(task) if v1_response else task) - # ── Streaming (SSE) ─────────────────────────────────────────────────── - @staticmethod def _sse_headers(handler) -> None: handler.send_response(200) - handler.send_header("Content-Type", "text/event-stream") - handler.send_header("Cache-Control", "no-cache") + for k, v in (("Content-Type", "text/event-stream"), ("Cache-Control", "no-cache")): + handler.send_header(k, v) handler.end_headers() - # v1.0: stream closure signals the terminal state, so the socket must - # actually close once we emit the done event. - handler.close_connection = True + handler.close_connection = True # v1.0: stream closure signals the terminal state @staticmethod def _sse_write(handler, chunk: str) -> None: handler.wfile.write(chunk.encode("utf-8")) handler.wfile.flush() + @classmethod + def _keepalive(cls, handler): + return lambda: cls._sse_write(handler, ": keepalive\n\n") + def _emit_terminal(self, handler, task_id: str, context_id: str, state: str, reply: str, req_id: Any = None) -> None: - """Emit the final artifact/status events and close the stream (v1.0: closure - signals terminal state). ``req_id`` threads into the JSON-RPC SSE envelope (§9.4).""" - if reply and state == protocol.STATE_COMPLETED: - self._sse_write(handler, protocol.sse_data(protocol.artifact_update(task_id, context_id, reply), req_id)) - self._sse_write(handler, protocol.sse_data(protocol.status_update(task_id, context_id, state), req_id)) - else: - self._sse_write(handler, protocol.sse_data(protocol.status_update(task_id, context_id, state, reply), req_id)) + """Emit the final artifact/status events and the closure marker. ``req_id`` threads into the + JSON-RPC SSE envelope (§9.4).""" + completed = bool(reply) and state == protocol.STATE_COMPLETED + events = ([protocol.artifact_update(task_id, context_id, reply)] if completed else []) + [ + protocol.status_update(task_id, context_id, state, "" if completed else reply)] + for ev in events: + self._sse_write(handler, protocol.sse_data(ev, req_id)) self._sse_write(handler, protocol.sse_done()) def _rpc_message_stream(self, handler, req_id: Any, params: dict, peer: str, agent: Optional[dict] = None) -> None: @@ -734,14 +646,13 @@ class A2AAdapter(BasePlatformAdapter): try: terminal, pending = self._prepare_task(params, peer, agent=agent) if terminal is not None: - text = protocol.extract_text(terminal.get("status", {}).get("message", {}) or {}) - return self._emit_terminal(handler, terminal["id"], terminal["contextId"], terminal["status"]["state"], text, req_id=req_id) + return self._emit_terminal(handler, terminal["id"], terminal["contextId"], terminal["status"]["state"], + protocol.extract_text(terminal.get("status", {}).get("message", {}) or {}), req_id=req_id) task_id, context_id = pending["task_id"], pending["context_id"] submitted = protocol.build_task(task_id, context_id, protocol.STATE_SUBMITTED, created_at=pending["created_iso"]) self._sse_write(handler, protocol.sse_data(protocol.stream_task(submitted), req_id)) self._sse_write(handler, protocol.sse_data(protocol.status_update(task_id, context_id, protocol.STATE_WORKING), req_id)) - state, reply = self._await_reply(pending, keepalive=lambda: self._sse_write(handler, ": keepalive\n\n")) - state, reply = self._finalize_task(pending, state, reply) + state, reply = self._finalize_task(pending, *self._await_reply(pending, keepalive=self._keepalive(handler))) self._emit_terminal(handler, task_id, context_id, state, reply, req_id=req_id) except (BrokenPipeError, ConnectionResetError): logger.debug("A2A: stream client disconnected") @@ -753,39 +664,23 @@ class A2AAdapter(BasePlatformAdapter): return handler._json(200, error) self._sse_headers(handler) try: - fut = self.tasks.watch(task_id, *self._scope_for_agent(agent)) - if fut is None: + if (fut := self.tasks.watch(task_id, *self._scope_for_agent(agent))) is None: return self._sse_write(handler, protocol.sse_done()) - deadline = time.time() + _reply_timeout() - while True: - try: - state, reply = fut.result(timeout=_SSE_KEEPALIVE) - break - except FuturesTimeout: - if time.time() >= deadline: - state, reply = rec["state"], rec.get("reply", "") - break - self._sse_write(handler, ": keepalive\n\n") + state, reply = self._await_future(fut, time.time() + _reply_timeout(), self._keepalive(handler), + (rec["state"], rec.get("reply", ""))) self._emit_terminal(handler, task_id, rec["context_id"], state, reply, req_id=req_id) except (BrokenPipeError, ConnectionResetError): logger.debug("A2A: subscribe client disconnected") - # ── Task queries ────────────────────────────────────────────────────── - def _find_task(self, req_id: Any, params: dict, agent: Optional[dict]) -> tuple[str, Optional[dict], Optional[dict]]: """(task_id, record, None) for a visible task, else (task_id, None, jsonrpc_error).""" task_id = str(params.get("taskId") or params.get("id") or "") rec = self.tasks.get(task_id, *self._scope_for_agent(agent)) - if not rec: - return task_id, None, _err(req_id, protocol.ERR_TASK_NOT_FOUND, f"task not found: {task_id}") - return task_id, rec, None + return task_id, rec, None if rec else _err(req_id, protocol.ERR_TASK_NOT_FOUND, f"task not found: {task_id}") def _rpc_tasks_get(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict: _task_id, rec, error = self._find_task(req_id, params, agent) - if error: - return error - history_len = _to_int(params.get("historyLength"), None) - return _ok(req_id, protocol.TaskStore.to_task(rec, history_length=history_len)) + return error or _ok(req_id, protocol.TaskStore.to_task(rec)) def _rpc_tasks_list(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict: offset = _to_int(params.get("pageToken") or 0, 0) @@ -793,16 +688,11 @@ class A2AAdapter(BasePlatformAdapter): agent_slug, tenant = self._scope_for_agent(agent) recs, next_offset, total = self.tasks.list( context_id=str(params.get("contextId") or ""), state=str(params.get("status") or params.get("state") or ""), - page_size=page_size, offset=max(0, offset), agent_slug=agent_slug, tenant=tenant, with_total=True, - ) + page_size=page_size, offset=max(0, offset), agent_slug=agent_slug, tenant=tenant, with_total=True) include_artifacts = bool(params.get("includeArtifacts", False)) - history_len = _to_int(params.get("historyLength"), None) - return _ok(req_id, { - "tasks": [protocol.TaskStore.to_task(r, history_length=history_len, include_artifacts=include_artifacts) for r in recs], - "nextPageToken": str(next_offset) if next_offset else "", - "pageSize": max(1, min(page_size, 100)), - "totalSize": total, - }) + return _ok(req_id, {"tasks": [protocol.TaskStore.to_task(r, include_artifacts=include_artifacts) for r in recs], + "nextPageToken": str(next_offset) if next_offset else "", + "pageSize": max(1, min(page_size, 100)), "totalSize": total}) def _rpc_tasks_cancel(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict: task_id, rec, error = self._find_task(req_id, params, agent) @@ -816,94 +706,75 @@ class A2AAdapter(BasePlatformAdapter): rec = self.tasks.get(task_id, *self._scope_for_agent(agent)) or rec return _ok(req_id, protocol.TaskStore.to_task(rec)) - # ── Push notifications ──────────────────────────────────────────────── - def _register_inline_push(self, task_id: str, params: dict, agent: Optional[dict] = None) -> None: """v1.0: message/send can carry configuration.taskPushNotificationConfig.""" cfg = (params.get("configuration") or {}).get("taskPushNotificationConfig") or {} - if not isinstance(cfg, dict): - return - url = cfg.get("url") or (cfg.get("pushNotificationConfig") or {}).get("url") or "" + url = (cfg.get("url") or (cfg.get("pushNotificationConfig") or {}).get("url") or "") if isinstance(cfg, dict) else "" if url: self.tasks.set_push_config(task_id, str(url), *self._scope_for_agent(agent)) def _rpc_push_config_create(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict: task_id = str(params.get("taskId") or "") - cfg = params.get("pushNotificationConfig") or params.get("config") or {} - url = str((cfg or {}).get("url") or "") + url = str((params.get("pushNotificationConfig") or params.get("config") or {}).get("url") or "") if not task_id or not url: return _err(req_id, protocol.ERR_INVALID_PARAMS, "taskId and pushNotificationConfig.url required") stored = self.tasks.set_push_config(task_id, url, *self._scope_for_agent(agent)) - if stored is None: - return _err(req_id, protocol.ERR_TASK_NOT_FOUND, f"task not found: {task_id}") - return _ok(req_id, stored) + return _ok(req_id, stored) if stored is not None else _err(req_id, protocol.ERR_TASK_NOT_FOUND, f"task not found: {task_id}") def _push_config_op(self, req_id: Any, params: dict, agent: Optional[dict], op, render) -> dict: """Shared get/list/delete: ``op(task_id, config_id, slug, tenant)`` falsy => not found.""" task_id = str(params.get("taskId") or "") if not task_id: return _err(req_id, protocol.ERR_INVALID_PARAMS, "taskId required") - config_id = str(params.get("id") or params.get("configId") or "") - found = op(task_id, config_id, *self._scope_for_agent(agent)) - if not found: - return _err(req_id, protocol.ERR_TASK_NOT_FOUND, f"push config not found for task: {task_id}") - return _ok(req_id, render(found)) + found = op(task_id, str(params.get("id") or params.get("configId") or ""), *self._scope_for_agent(agent)) + return _ok(req_id, render(found)) if found else _err(req_id, protocol.ERR_TASK_NOT_FOUND, f"push config not found for task: {task_id}") def _rpc_push_config_get(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict: return self._push_config_op(req_id, params, agent, self.tasks.get_push_config, lambda cfg: cfg) def _rpc_push_config_list(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict: - task_id = str(params.get("taskId") or "") - if not task_id: - return _err(req_id, protocol.ERR_INVALID_PARAMS, "taskId required") - configs = self.tasks.list_push_configs(task_id, *self._scope_for_agent(agent)) - return _ok(req_id, {"configs": configs, "nextPageToken": ""}) + # An empty list is a valid (non-error) result, hence the ``or [[]]`` sentinel through the shared op. + return self._push_config_op(req_id, params, agent, lambda tid, _cid, *scope: self.tasks.list_push_configs(tid, *scope) or [[]], + lambda found: {"configs": [c for c in found if c], "nextPageToken": ""}) def _rpc_push_config_delete(self, req_id: Any, params: dict, agent: Optional[dict] = None) -> dict: return self._push_config_op(req_id, params, agent, self.tasks.delete_push_config, lambda _: {"deleted": True}) def _send_push_notification(self, task_id: str, context_id: str, reply: str, state: str) -> None: - """POST a v1.0 StreamResponse payload to the task's registered callback. - The URL is SSRF-checked (internal/private/loopback blocked unless localhost-only mode).""" + """POST a v1.0 StreamResponse payload to the task's registered callback (SSRF-checked URL).""" + def fail(msg: str, *args) -> None: + protocol.metrics.push_failed += 1 + logger.warning("A2A: push notification for task %s " + msg, task_id, *args) + callback_url = self.tasks.pop_push_url(task_id) if not callback_url: return if not security.is_safe_callback_url(callback_url, localhost_mode=self._security_context.localhost_only()): - logger.warning("A2A: push notification for task %s blocked — unsafe callback URL: %s", task_id, callback_url) - protocol.metrics.push_failed += 1 - return + return fail("blocked — unsafe callback URL: %s", callback_url) payload = protocol.status_update(task_id, context_id, state, (reply or "")[:2000]) - signature = self._security_context.sign_push_payload(payload) headers = {"Content-Type": "application/json"} - if signature: + if signature := self._security_context.sign_push_payload(payload): headers["X-A2A-Signature"] = signature try: - data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request(callback_url, data=data, headers=headers, method="POST") + req = urllib.request.Request(callback_url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST") with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 - if 200 <= resp.status < 300: - protocol.metrics.push_sent += 1 - logger.debug("A2A: push notification sent for task %s", task_id) - else: - protocol.metrics.push_failed += 1 - logger.warning("A2A: push notification for task %s got HTTP %d", task_id, resp.status) + status = resp.status except Exception as e: - protocol.metrics.push_failed += 1 - logger.warning("A2A: push notification for task %s failed: %s", task_id, e) - - # ── Sending (the agent's reply path) ────────────────────────────────── + return fail("failed: %s", e) + if not 200 <= status < 300: + return fail("got HTTP %d", status) + protocol.metrics.push_sent += 1 + logger.debug("A2A: push notification sent for task %s", task_id) async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None): """Fulfil the oldest pending reply Future for this context (``chat_id`` = A2A context id). - Only sends carrying ``metadata['notify']`` (the base adapter's final-reply marker, - ``_mark_notify_metadata``) satisfy the caller; progress/status/preview sends must not.""" - message_id = str(int(time.time() * 1000)) + Only sends carrying ``metadata['notify']`` (the base adapter's final-reply marker) satisfy + the caller; progress/status/preview sends must not.""" if not (metadata or {}).get("notify"): logger.debug("A2A: ignoring non-final send for context %s", chat_id) - return SendResult(success=True, message_id=message_id) - if not self._resolve_oldest_for_context(chat_id, protocol.STATE_COMPLETED, content or ""): + elif not self._resolve_oldest_for_context(chat_id, protocol.STATE_COMPLETED, content or ""): logger.debug("A2A: send() for context %s had no pending waiter", chat_id) # late chunk / out-of-band - return SendResult(success=True, message_id=message_id) + return SendResult(success=True, message_id=str(int(time.time() * 1000))) async def send_typing(self, chat_id: str, metadata=None) -> None: return None @@ -912,13 +783,11 @@ class A2AAdapter(BasePlatformAdapter): return {"name": f"a2a:{chat_id}", "type": "dm"} async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None: - """Resolve the task future when processing ends without a reply send - (failures, cancellations, empty runs) so the HTTP thread returns promptly.""" + """Resolve the task future when processing ends without a reply send (failures, + cancellations, empty runs) so the HTTP thread returns promptly.""" task_id = str(getattr(event, "message_id", "") or "") - if not task_id: - return - state, text = { - ProcessingOutcome.FAILURE: (protocol.STATE_FAILED, "[agent processing failed]"), - ProcessingOutcome.CANCELLED: (protocol.STATE_CANCELED, ""), - }.get(outcome, (protocol.STATE_COMPLETED, "")) - self._resolve_task(task_id, state, text) + if task_id: + self._resolve_task(task_id, *{ + ProcessingOutcome.FAILURE: (protocol.STATE_FAILED, "[agent processing failed]"), + ProcessingOutcome.CANCELLED: (protocol.STATE_CANCELED, ""), + }.get(outcome, (protocol.STATE_COMPLETED, ""))) diff --git a/plugins/platforms/a2a/protocol.py b/plugins/platforms/a2a/protocol.py index bff69bc79c..013682510e 100644 --- a/plugins/platforms/a2a/protocol.py +++ b/plugins/platforms/a2a/protocol.py @@ -1,17 +1,11 @@ -""" -A2A protocol helpers — Agent Card, JSON-RPC framing, task store, conversation persistence. - -Wire shape is A2A v1.0 (JSON-RPC 2.0 over HTTP): SCREAMING_SNAKE_CASE states/roles; -Parts and StreamResponse events (``statusUpdate`` / ``artifactUpdate``) are -discriminated by member presence (no ``kind`` / ``final`` fields); SSE stream -closure signals the terminal state; push configs carry ``configId`` + ``createdAt``. -Stdlib only (no a2a-sdk). ``extract_text`` stays tolerant of v0.3 peers. -""" +"""A2A protocol helpers — Agent Card, JSON-RPC framing, task store, conversation persistence. +Wire shape is A2A v1.0: SCREAMING_SNAKE_CASE states/roles; Parts and StreamResponse events are +discriminated by member presence (no ``kind``/``final``); SSE closure signals the terminal state. +Stdlib only. ``extract_text`` stays tolerant of v0.3 peers.""" from __future__ import annotations import json -import copy import os import threading import time @@ -22,52 +16,33 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional +from gateway.platforms._shared import coerce_port as _coerce_int + PROTOCOL_VERSION = "1.0" -# A2A v1.0 task lifecycle states. -STATE_SUBMITTED = "TASK_STATE_SUBMITTED" -STATE_WORKING = "TASK_STATE_WORKING" -STATE_INPUT_REQUIRED = "TASK_STATE_INPUT_REQUIRED" -STATE_AUTH_REQUIRED = "TASK_STATE_AUTH_REQUIRED" -STATE_COMPLETED = "TASK_STATE_COMPLETED" -STATE_FAILED = "TASK_STATE_FAILED" -STATE_CANCELED = "TASK_STATE_CANCELED" -STATE_REJECTED = "TASK_STATE_REJECTED" - +# A2A v1.0 task lifecycle states + message roles. +STATE_SUBMITTED, STATE_WORKING, STATE_INPUT_REQUIRED = "TASK_STATE_SUBMITTED", "TASK_STATE_WORKING", "TASK_STATE_INPUT_REQUIRED" +STATE_COMPLETED, STATE_FAILED = "TASK_STATE_COMPLETED", "TASK_STATE_FAILED" +STATE_CANCELED, STATE_REJECTED = "TASK_STATE_CANCELED", "TASK_STATE_REJECTED" TERMINAL_STATES = frozenset({STATE_COMPLETED, STATE_FAILED, STATE_CANCELED, STATE_REJECTED}) +ROLE_USER, ROLE_AGENT = "ROLE_USER", "ROLE_AGENT" -# A2A v1.0 message roles. -ROLE_USER = "ROLE_USER" -ROLE_AGENT = "ROLE_AGENT" - -# The agent starts its reply with this marker when it needs clarification; the -# adapter maps such replies to TASK_STATE_INPUT_REQUIRED (marker stripped). +# A reply starting with this marker is a clarification request -> TASK_STATE_INPUT_REQUIRED (marker stripped). INPUT_REQUIRED_MARKER = "[INPUT_REQUIRED]" # JSON-RPC / A2A error codes. -32001..-32003 are A2A spec-defined; custom errors # live at -32050..-32059 (implementation-defined space, clear of the A2A block). -ERR_PARSE = -32700 -ERR_INVALID_PARAMS = -32602 -ERR_METHOD_NOT_FOUND = -32601 -ERR_TASK_NOT_FOUND = -32001 # A2A spec: TaskNotFoundError -ERR_TASK_NOT_CANCELABLE = -32002 # A2A spec: TaskNotCancelableError -ERR_UNAUTHORIZED = -32050 -ERR_RATE_LIMITED = -32051 -ERR_UNTRUSTED_PEER = -32052 +ERR_PARSE, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND = -32700, -32602, -32601 +ERR_TASK_NOT_FOUND, ERR_TASK_NOT_CANCELABLE = -32001, -32002 # A2A spec: TaskNotFoundError / TaskNotCancelableError +ERR_UNAUTHORIZED, ERR_RATE_LIMITED, ERR_UNTRUSTED_PEER = -32050, -32051, -32052 # Anti-loop: max inbound turns per context. A2A_MAX_PINGPONG_TURNS env, capped at 20. -_DEFAULT_MAX_PINGPONG = 5 -_HARD_MAX_PINGPONG = 20 - -_RATE_LIMIT_DEFAULT = 60 # requests per minute -_RATE_WINDOW = 60.0 # seconds +_DEFAULT_MAX_PINGPONG, _HARD_MAX_PINGPONG = 5, 20 +_RATE_LIMIT_DEFAULT, _RATE_WINDOW = 60, 60.0 # requests per minute, window seconds def _env_int(name: str, default: int) -> int: - try: - return int(os.getenv(name, str(default))) - except (ValueError, TypeError): - return default + return _coerce_int(os.getenv(name, default), default) def max_pingpong_turns() -> int: @@ -75,10 +50,6 @@ def max_pingpong_turns() -> int: return max(1, min(v, _HARD_MAX_PINGPONG)) -def _rate_limit_per_minute() -> int: - return max(1, _env_int("A2A_RATE_LIMIT", _RATE_LIMIT_DEFAULT)) - - def now_iso() -> str: """ISO 8601 UTC timestamp with millisecond precision (A2A v1.0).""" return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" @@ -92,19 +63,12 @@ def _hermes_home() -> Path: return Path(os.path.expanduser("~/.hermes")) -# ── Agent Card (v1.0) ───────────────────────────────────────────────────────── - def build_agent_card(*, name: str, url: str, description: str, skills: Optional[list[dict]] = None, streaming: bool = False, push_notifications: bool = False, auth_required: bool = False, tenant: str = "") -> dict: - """Construct an A2A v1.0 Agent Card. - - ``tenant`` is the optional v1.0 multi-tenancy routing key on AgentInterface; - when present, clients MUST echo it in request params. - """ - iface: dict[str, Any] = {"url": url, "protocolBinding": "JSONRPC", "protocolVersion": PROTOCOL_VERSION} - if tenant: - iface["tenant"] = tenant + """A2A v1.0 Agent Card. ``tenant`` is the optional multi-tenancy routing key on + AgentInterface; when present, clients MUST echo it in request params.""" + iface: dict[str, Any] = {"url": url, "protocolBinding": "JSONRPC", "protocolVersion": PROTOCOL_VERSION, **({"tenant": tenant} if tenant else {})} card: dict[str, Any] = { "name": name, "description": description, @@ -114,9 +78,7 @@ def build_agent_card(*, name: str, url: str, description: str, skills: Optional[ "supportedInterfaces": [iface], "capabilities": {"streaming": streaming, "pushNotifications": push_notifications, "stateTransitionHistory": False, "extendedAgentCard": False}, - "defaultInputModes": ["text/plain"], - "defaultOutputModes": ["text/plain"], - "skills": skills or [], + "defaultInputModes": ["text/plain"], "defaultOutputModes": ["text/plain"], "skills": skills or [], } if auth_required: card["securitySchemes"] = {"bearer": {"type": "http", "scheme": "bearer"}} @@ -125,20 +87,15 @@ def build_agent_card(*, name: str, url: str, description: str, skills: Optional[ def skills_from_toolsets(toolsets: "list[str] | dict[str, list[str]] | None") -> list[dict]: - """Derive A2A skill descriptors from toolset names, or a toolset → tool-names - mapping (tool names become tags, max 10, so peers can match tasks to us).""" + """A2A skill descriptors from toolset names or a toolset -> tool-names mapping (tool names + become tags, max 10).""" if not isinstance(toolsets, dict): toolsets = {ts: [] for ts in set(toolsets or [])} - skills = [ - {"id": f"toolset.{name}", "name": name, "description": f"Hermes '{name}' capabilities", - "tags": [name] + [str(t) for t in (toolsets[name] or [])][:10]} - for name in sorted(toolsets) - ] + skills = [{"id": f"toolset.{name}", "name": name, "description": f"Hermes '{name}' capabilities", + "tags": [name] + [str(t) for t in (toolsets[name] or [])][:10]} for name in sorted(toolsets)] return skills or [{"id": "general", "name": "general", "description": "General-purpose conversational agent", "tags": ["general"]}] -# ── JSON-RPC framing + message / part builders ──────────────────────────────── - def jsonrpc_result(req_id: Any, result: Any) -> dict: return {"jsonrpc": "2.0", "id": req_id, "result": result} @@ -148,15 +105,14 @@ def jsonrpc_error(req_id: Any, code: int, message: str) -> dict: def send_message_response(payload: dict) -> dict: - """A2A v1.0 SendMessageResponse oneof wrapper: exactly one of ``task`` / - ``message``. Legacy methods still return bare payloads.""" + """v1.0 SendMessageResponse oneof: exactly one of ``task`` / ``message``.""" if isinstance(payload, dict) and payload.get("status") and payload.get("id"): return {"task": payload} return {"message": payload} def unwrap_send_message_response(result: Any) -> Any: - """Return the Task/Message inside a v1.0 response, or pass legacy through.""" + """Task/Message inside a v1.0 response; legacy bare payloads pass through.""" if isinstance(result, dict): if isinstance(result.get("task"), dict): return result["task"] @@ -183,37 +139,14 @@ def text_part(text: str) -> dict: return {"text": text, "mediaType": "text/plain"} -def file_part(url: str = "", raw: str = "", filename: str = "", - media_type: str = "application/octet-stream") -> dict: - """v1.0 file Part: ``url`` (reference) or ``raw`` (base64 bytes).""" - part: dict[str, Any] = {"mediaType": media_type} - if filename: - part["filename"] = filename - if url: - part["url"] = url - elif raw: - part["raw"] = raw - return part - - -def data_part(data: Any, media_type: str = "application/json") -> dict: - """v1.0 data Part (structured data, no ``kind`` field).""" - return {"data": data, "mediaType": media_type} - - -def message_with_parts(role: str, parts: list[dict], context_id: str = "") -> dict: - """A2A v1.0 Message with arbitrary Parts (text, file, data).""" - msg: dict[str, Any] = {"role": role, "parts": parts, "messageId": uuid.uuid4().hex} +def text_message(role: str, text: str, context_id: str = "") -> dict: + """A2A v1.0 Message with a single text Part.""" + msg: dict[str, Any] = {"role": role, "parts": [text_part(text)], "messageId": uuid.uuid4().hex} if context_id: msg["contextId"] = context_id return msg -def text_message(role: str, text: str, context_id: str = "") -> dict: - """A2A v1.0 Message with a single text Part.""" - return message_with_parts(role, [text_part(text)], context_id) - - def _file_note(fname: str, body: str, mtype: str) -> str: label = f"[file: {fname}]" if fname else "[file]" return f"{label} {body}" + (f" ({mtype})" if mtype else "") @@ -227,15 +160,12 @@ def _json_or_str(data: Any) -> str: def extract_text(message_or_params: dict) -> str: - """Concatenated text from an A2A Message / Task-result / params payload. - - v1.0, v0.3 (``kind``) and pre-0.3 (``type``) Parts all carry a string ``text`` - member. File Parts render as URL/filename (raw base64 noted, not decoded); - data Parts render their JSON so the agent sees them.""" + """Concatenated text from an A2A Message / Task-result / params payload. v1.0, v0.3 + (``kind``) and pre-0.3 (``type``) Parts all carry ``text``; file Parts render as + URL/filename (raw base64 noted, not decoded); data Parts render their JSON.""" msg = message_or_params.get("message", message_or_params) - parts = msg.get("parts", []) if isinstance(msg, dict) else [] chunks = [] - for part in parts: + for part in msg.get("parts", []) if isinstance(msg, dict) else []: if not isinstance(part, dict): continue if isinstance(txt := part.get("text"), str): @@ -256,13 +186,12 @@ def extract_text(message_or_params: dict) -> str: def extract_context_id(params: dict) -> str: """v1.0 puts contextId inside the Message; tolerate legacy top-level.""" msg = params.get("message") or {} - ctx = str(msg.get("contextId") or "") if isinstance(msg, dict) else "" - return ctx or str(params.get("contextId") or "") + return (str(msg.get("contextId") or "") if isinstance(msg, dict) else "") or str(params.get("contextId") or "") def build_task(task_id: str, context_id: str, state: str, agent_text: str = "", *, created_at: str = "") -> dict: - """A2A v1.0 Task object. ``created_at`` is accepted but NOT serialized: the v1.0 - Task proto has no createdAt and strict ProtoJSON parsers (a2a-sdk) reject unknown fields.""" + """A2A v1.0 Task. ``created_at`` is accepted but NOT serialized: the v1.0 Task proto has no + createdAt and strict ProtoJSON parsers (a2a-sdk) reject unknown fields.""" task: dict[str, Any] = {"id": task_id, "contextId": context_id, "status": {"state": state, "timestamp": now_iso()}} if agent_text: task["status"]["message"] = text_message(ROLE_AGENT, agent_text, context_id) @@ -271,8 +200,6 @@ def build_task(task_id: str, context_id: str, state: str, agent_text: str = "", return task -# ── Streaming (v1.0 StreamResponse events) ──────────────────────────────────── - def status_update(task_id: str, context_id: str, state: str, text: str = "") -> dict: """v1.0 StreamResponse with a statusUpdate member.""" status: dict[str, Any] = {"state": state, "timestamp": now_iso()} @@ -288,46 +215,38 @@ def artifact_update(task_id: str, context_id: str, text: str) -> dict: def sse_data(payload: dict, req_id: Any = None) -> str: - """One StreamResponse as an SSE data frame. §9.4 requires a full JSON-RPC envelope - (a2a-sdk breaks on bare StreamResponses); ``req_id=None`` is the legacy no-envelope fallback.""" + """One StreamResponse as an SSE data frame. §9.4 requires a full JSON-RPC envelope (a2a-sdk + breaks on bare StreamResponses); ``req_id=None`` is the legacy no-envelope fallback.""" envelope = jsonrpc_result(req_id, payload) if req_id is not None else payload return f"data: {json.dumps(envelope, ensure_ascii=False)}\n\n" def sse_done() -> str: - """Stream-closure marker as an SSE *comment* — ``data: {}`` would make - JSON-RPC clients try to parse an empty response.""" + """Stream-closure marker as an SSE *comment* — ``data: {}`` would make JSON-RPC clients parse.""" return ": done\n\n" -# ── Anti-loop ping-pong protection (per-adapter instance) ───────────────────── - class TurnTracker: - """Counts inbound turns per context_id; beyond max_pingpong_turns() the - adapter rejects further messages for that context.""" + """Counts inbound turns per context_id; beyond max_pingpong_turns() the adapter rejects.""" _TTL = 3600 # prune contexts idle longer than 1 hour def __init__(self) -> None: - self._counts: dict[str, int] = defaultdict(int) - self._timestamps: dict[str, float] = {} + self._turns: dict[str, tuple[int, float]] = {} # context_id -> (count, last_seen) self._lock = threading.Lock() def track(self, context_id: str) -> int: """Increment and return the turn count; prunes stale contexts.""" with self._lock: now = time.time() - for cid in [cid for cid, ts in self._timestamps.items() if now - ts > self._TTL]: - self._counts.pop(cid, None) - self._timestamps.pop(cid, None) - self._counts[context_id] += 1 - self._timestamps[context_id] = now - return self._counts[context_id] + self._turns = {cid: v for cid, v in self._turns.items() if now - v[1] <= self._TTL} + count = self._turns.get(context_id, (0, now))[0] + 1 + self._turns[context_id] = (count, now) + return count def reset(self, context_id: str) -> None: with self._lock: - self._counts.pop(context_id, None) - self._timestamps.pop(context_id, None) + self._turns.pop(context_id, None) class RateLimiter: @@ -339,7 +258,7 @@ class RateLimiter: def allow(self, identity: str) -> bool: with self._lock: - limit = _rate_limit_per_minute() + limit = max(1, _env_int("A2A_RATE_LIMIT", _RATE_LIMIT_DEFAULT)) now = time.time() bucket = self._buckets[identity] while bucket and now - bucket[0] > _RATE_WINDOW: @@ -350,18 +269,16 @@ class RateLimiter: return True -# ── Metrics collection ──────────────────────────────────────────────────────── - class Metrics: - """Simple counters for A2A operations (module singleton ``metrics`` is shared - by the inbound adapter and outbound tools; not persisted).""" + """Counters for A2A operations (module singleton ``metrics`` shared by the inbound adapter + and outbound tools; not persisted).""" _COUNTERS = ("inbound_total", "outbound_total", "streams_started", "push_sent", "push_failed", "tasks_completed", "tasks_failed", "anti_loop_triggers", "rate_limit_triggers") def __init__(self) -> None: - self.inbound_total = self.outbound_total = self.streams_started = self.push_sent = self.push_failed = 0 - self.tasks_completed = self.tasks_failed = self.anti_loop_triggers = self.rate_limit_triggers = 0 + for name in self._COUNTERS: + setattr(self, name, 0) self._start_time = time.time() self._latencies: deque[float] = deque(maxlen=100) # last 100 completed inbound tasks @@ -372,19 +289,16 @@ class Metrics: return sum(self._latencies) / len(self._latencies) if self._latencies else 0.0 def snapshot(self) -> dict[str, Any]: - return {"uptime_seconds": round(time.time() - self._start_time, 1), - **{name: getattr(self, name) for name in self._COUNTERS}, + return {"uptime_seconds": round(time.time() - self._start_time, 1), **{n: getattr(self, n) for n in self._COUNTERS}, "avg_latency_ms": round(self.avg_latency() * 1000, 1)} metrics = Metrics() -# ── Task store — pending AND completed tasks (queryable via tasks/get, tasks/list) ─── - class TaskStore: - """In-memory store of A2A tasks, kept after completion for tasks/get. Records carry - agent slug + tenant; readers pass a scope and get not-found outside it (spec authz rule).""" + """In-memory A2A tasks, kept after completion for tasks/get. Records carry agent slug + + tenant; readers pass a scope and get not-found outside it (spec authz rule).""" _MAX_TERMINAL = 500 @@ -395,9 +309,7 @@ class TaskStore: @staticmethod def _in_scope(rec: dict, agent_slug: str = "", tenant: str = "") -> bool: - if agent_slug and rec.get("agent_slug", "") != agent_slug: - return False - return not (tenant and rec.get("tenant", "") != tenant) + return not ((agent_slug and rec.get("agent_slug", "") != agent_slug) or (tenant and rec.get("tenant", "") != tenant)) def _scoped(self, task_id: str, agent_slug: str = "", tenant: str = "") -> Optional[dict]: """Live record if visible in scope. Caller holds the lock.""" @@ -407,11 +319,9 @@ class TaskStore: def _push_rec(self, task_id: str, config_id: str = "", agent_slug: str = "", tenant: str = "") -> Optional[dict]: """Scoped record that has a push config (matching ``config_id`` if given). Caller holds the lock.""" rec = self._scoped(task_id, agent_slug, tenant) - if not rec or not rec.get("push_url"): - return None - if config_id and rec.get("push_config_id") != config_id: - return None - return rec + if rec and rec.get("push_url") and (not config_id or rec.get("push_config_id") == config_id): + return rec + return None @staticmethod def _push_config_view(rec: dict) -> dict: @@ -419,62 +329,50 @@ class TaskStore: "createdAt": rec.get("created_iso", ""), "pushNotificationConfig": {"url": rec.get("push_url") or ""}} def create(self, task_id: str, context_id: str, peer: str, agent_slug: str = "", tenant: str = "") -> dict: - rec = { - "task_id": task_id, "context_id": context_id, "peer": peer, - "agent_slug": agent_slug or "", "tenant": tenant or "", "state": STATE_SUBMITTED, "reply": "", - "created_at": time.time(), "created_iso": now_iso(), "push_url": "", "push_config_id": "", - } + rec = {"task_id": task_id, "context_id": context_id, "peer": peer, "agent_slug": agent_slug or "", "tenant": tenant or "", + "state": STATE_SUBMITTED, "reply": "", "created_at": time.time(), "created_iso": now_iso(), "push_url": "", "push_config_id": ""} with self._lock: self._tasks[task_id] = rec return dict(rec) def set_state(self, task_id: str, state: str) -> None: with self._lock: - rec = self._tasks.get(task_id) - if rec and rec["state"] not in TERMINAL_STATES: + if (rec := self._tasks.get(task_id)) and rec["state"] not in TERMINAL_STATES: rec["state"] = state def set_push_config(self, task_id: str, url: str, agent_slug: str = "", tenant: str = "") -> Optional[dict]: """Attach a push notification config; returns the stored config or None.""" with self._lock: - rec = self._scoped(task_id, agent_slug, tenant) - if not rec: + if not (rec := self._scoped(task_id, agent_slug, tenant)): return None - rec["push_url"] = url - rec["push_config_id"] = "cfg-" + uuid.uuid4().hex[:12] + rec["push_url"], rec["push_config_id"] = url, "cfg-" + uuid.uuid4().hex[:12] return self._push_config_view(rec) def get_push_config(self, task_id: str, config_id: str = "", agent_slug: str = "", tenant: str = "") -> Optional[dict]: with self._lock: - rec = self._push_rec(task_id, config_id, agent_slug, tenant) - return self._push_config_view(rec) if rec else None + return self._push_config_view(rec) if (rec := self._push_rec(task_id, config_id, agent_slug, tenant)) else None def list_push_configs(self, task_id: str, agent_slug: str = "", tenant: str = "") -> list[dict]: - with self._lock: - rec = self._push_rec(task_id, "", agent_slug, tenant) - return [self._push_config_view(rec)] if rec else [] + cfg = self.get_push_config(task_id, "", agent_slug, tenant) + return [cfg] if cfg else [] def delete_push_config(self, task_id: str, config_id: str = "", agent_slug: str = "", tenant: str = "") -> bool: with self._lock: rec = self._push_rec(task_id, config_id, agent_slug, tenant) - if not rec: - return False - rec["push_url"] = "" - rec["push_config_id"] = "" - return True + if rec: + rec["push_url"] = rec["push_config_id"] = "" + return rec is not None def pop_push_url(self, task_id: str) -> str: with self._lock: rec = self._tasks.get(task_id) - if not rec: - return "" - url, rec["push_url"] = rec["push_url"], "" - return url + if rec: + url, rec["push_url"] = rec["push_url"], "" + return url if rec else "" def get(self, task_id: str, agent_slug: str = "", tenant: str = "") -> Optional[dict]: with self._lock: - rec = self._scoped(task_id, agent_slug, tenant) - return dict(rec) if rec else None + return dict(rec) if (rec := self._scoped(task_id, agent_slug, tenant)) else None def complete(self, task_id: str, state: str, reply: str = "") -> Optional[dict]: """Transition a task to a terminal state. Idempotent.""" @@ -482,9 +380,7 @@ class TaskStore: rec = self._tasks.get(task_id) if not rec or rec["state"] in TERMINAL_STATES: return None - rec["state"] = state - rec["reply"] = reply - rec["completed_at"] = time.time() + rec.update(state=state, reply=reply, completed_at=time.time()) watchers = self._watchers.pop(task_id, []) self._trim_locked() out = dict(rec) @@ -495,8 +391,7 @@ class TaskStore: def watch(self, task_id: str, agent_slug: str = "", tenant: str = "") -> Optional[Future]: with self._lock: - rec = self._scoped(task_id, agent_slug, tenant) - if not rec: + if not (rec := self._scoped(task_id, agent_slug, tenant)): return None fut: Future = Future() if rec["state"] in TERMINAL_STATES: @@ -511,25 +406,18 @@ class TaskStore: ``(records, next_offset, total)`` with ``with_total`` (v1.0 ListTasks totalSize).""" page_size = max(1, min(int(page_size or 50), 100)) with self._lock: - recs = [dict(r) for r in reversed(self._tasks.values())] - if agent_slug or tenant: - recs = [r for r in recs if self._in_scope(r, agent_slug, tenant)] - if context_id: - recs = [r for r in recs if r["context_id"] == context_id] - if state: - recs = [r for r in recs if r["state"] == state] + recs = [dict(r) for r in reversed(self._tasks.values()) + if self._in_scope(r, agent_slug, tenant) + and (not context_id or r["context_id"] == context_id) and (not state or r["state"] == state)] total = len(recs) page = recs[offset:offset + page_size] next_offset = offset + page_size if offset + page_size < total else 0 - if with_total: - return page, next_offset, total - return page, next_offset + return (page, next_offset, total) if with_total else (page, next_offset) def fail_orphans(self, timeout_seconds: int = 300) -> list[str]: with self._lock: - now = time.time() stale = [tid for tid, rec in self._tasks.items() - if rec["state"] not in TERMINAL_STATES and now - rec["created_at"] > timeout_seconds] + if rec["state"] not in TERMINAL_STATES and time.time() - rec["created_at"] > timeout_seconds] return [tid for tid in stale if self.complete(tid, STATE_FAILED, "[task orphaned — no reply produced]")] def _trim_locked(self) -> None: @@ -538,61 +426,47 @@ class TaskStore: self._tasks.pop(tid, None) @staticmethod - def to_task(rec: dict, history_length: Optional[int] = None, include_artifacts: bool = True) -> dict: - """Render a stored record as an A2A v1.0 Task object.""" + def to_task(rec: dict, include_artifacts: bool = True) -> dict: + """Render a stored record as an A2A v1.0 Task.""" task = build_task(rec["task_id"], rec["context_id"], rec["state"], rec.get("reply", ""), created_at=rec.get("created_iso", "")) if not include_artifacts: task.pop("artifacts", None) - if history_length == 0: - task.pop("history", None) - return copy.deepcopy(task) - - -# ── Conversation persistence (outside the context-compaction pipeline) ──────── - -def _conv_dir() -> Path: - return _hermes_home() / "a2a_conversations" + return task def _conv_path(context_id: str) -> Path: safe = "".join(c for c in (context_id or "default") if c.isalnum() or c in "-_") or "default" - return _conv_dir() / f"{safe}.jsonl" + return _hermes_home() / "a2a_conversations" / f"{safe}.jsonl" def persist_message(context_id: str, role: str, text: str, task_id: str = "") -> None: - """Append one message to the context's on-disk conversation log.""" + """Append one message to the context's on-disk conversation log. Never raises.""" try: - _conv_dir().mkdir(parents=True, exist_ok=True) - rec = {"ts": time.time(), "role": role, "text": text, "task_id": task_id} - with _conv_path(context_id).open("a", encoding="utf-8") as fh: - fh.write(json.dumps(rec, ensure_ascii=False) + "\n") + path = _conv_path(context_id) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps({"ts": time.time(), "role": role, "text": text, "task_id": task_id}, ensure_ascii=False) + "\n") except Exception: pass def load_conversation(context_id: str, limit: int = 50) -> list[dict]: - """Load the last *limit* messages for a context (empty list if none).""" - path = _conv_path(context_id) - if not path.exists(): - return [] - out: list[dict] = [] + """Last *limit* messages for a context (empty list if none / unreadable).""" try: - with path.open("r", encoding="utf-8") as fh: - for line in fh: - if line.strip(): - try: - out.append(json.loads(line)) - except json.JSONDecodeError: - pass + lines = _conv_path(context_id).read_text(encoding="utf-8").splitlines() except Exception: return [] + out: list[dict] = [] + for line in lines: + if line.strip(): + try: + out.append(json.loads(line)) + except json.JSONDecodeError: + pass return out[-limit:] def list_conversations() -> list[str]: - """Return known context-ids that have persisted conversations.""" - d = _conv_dir() - if not d.exists(): - return [] - return sorted(p.stem for p in d.glob("*.jsonl")) + """Context-ids that have persisted conversations.""" + return sorted(p.stem for p in (_hermes_home() / "a2a_conversations").glob("*.jsonl")) diff --git a/plugins/platforms/a2a/security.py b/plugins/platforms/a2a/security.py index 442bd3d921..68df198d7b 100644 --- a/plugins/platforms/a2a/security.py +++ b/plugins/platforms/a2a/security.py @@ -1,13 +1,7 @@ -""" -A2A security primitives — shared by the inbound adapter and the client tools. - -A2A is a *network* surface (adversarial peers in, private context out). Layers, -opt-out only by explicit config: bind safety (no token => 127.0.0.1 only); peer -identity (A2A_PEER_TOKENS token->name, shared A2A_BEARER_TOKEN => ip:; rate -limiting and trust key on this, never on the body); inbound injection filtering; -outbound credential redaction; JSONL audit log; optional trusted-peer allow-list; -HMAC-SHA256 push signing + SSRF-safe callback URLs. -""" +"""A2A security primitives (adapter + client tools). A2A is a *network* surface: bind safety (no +token => 127.0.0.1 only); peer identity from credentials, never the body (A2A_PEER_TOKENS +token->name, shared A2A_BEARER_TOKEN => ip:); inbound injection filtering; outbound +credential redaction; JSONL audit; trusted-peer allow-list; HMAC push signing; SSRF-safe URLs.""" from __future__ import annotations @@ -28,11 +22,8 @@ logger = logging.getLogger(__name__) def _startup_env(name: str) -> str: - """Read one A2A setting from the active profile's scope, else the env. - - Inside a secondary profile's scope the scope is authoritative: a miss yields - "" and never falls through to ``os.environ`` (the default profile's tokens). - """ + """One A2A setting from the active profile's scope, else the env. Inside a secondary + profile's scope a miss yields "" and never falls through to the default profile's env.""" if _profile_scoped(): from agent.secret_scope import get_secret return (get_secret(name) or "").strip() @@ -41,14 +32,8 @@ def _startup_env(name: str) -> str: def _parse_peer_tokens(raw: str) -> dict[str, str]: """"alice:tok1,bob:tok2" -> {token: peer_name}.""" - out: dict[str, str] = {} - for pair in raw.split(","): - if ":" not in pair: - continue - name, token = (s.strip() for s in pair.split(":", 1)) - if name and token: - out[token] = name - return out + pairs = [tuple(s.strip() for s in pair.split(":", 1)) for pair in raw.split(",") if ":" in pair] + return {token: name for name, token in pairs if name and token} def _configured_trusted_peers() -> frozenset[str]: @@ -80,13 +65,10 @@ class A2ASecurityContext: @classmethod def capture(cls) -> "A2ASecurityContext": bearer_token = _startup_env("A2A_BEARER_TOKEN") - return cls( - bearer_token=bearer_token, - peer_tokens=tuple(_parse_peer_tokens(_startup_env("A2A_PEER_TOKENS")).items()), - trusted_peers=_configured_trusted_peers(), - allow_all_users=_startup_env("A2A_ALLOW_ALL_USERS").lower() in {"1", "true", "yes"}, - requested_host=_startup_env("A2A_HOST") or "127.0.0.1", - push_secret=_startup_env("A2A_PUSH_SECRET") or bearer_token) + return cls(bearer_token=bearer_token, peer_tokens=tuple(_parse_peer_tokens(_startup_env("A2A_PEER_TOKENS")).items()), + trusted_peers=_configured_trusted_peers(), + allow_all_users=_startup_env("A2A_ALLOW_ALL_USERS").lower() in {"1", "true", "yes"}, + requested_host=_startup_env("A2A_HOST") or "127.0.0.1", push_secret=_startup_env("A2A_PUSH_SECRET") or bearer_token) def localhost_only(self) -> bool: return not (self.bearer_token or self.peer_tokens) @@ -131,29 +113,11 @@ class A2ASecurityContext: return hmac.new(self.push_secret.encode("utf-8"), body, hashlib.sha256).hexdigest() -# Module-level conveniences: capture a fresh context from the current scope. -def authenticate(auth_header: Optional[str], client_ip: str = "") -> Optional[str]: - return A2ASecurityContext.capture().authenticate(auth_header, client_ip) - - def localhost_only() -> bool: + """Fresh-context convenience for callers outside the adapter.""" return A2ASecurityContext.capture().localhost_only() -def resolve_bind_host() -> str: - return A2ASecurityContext.capture().resolve_bind_host() - - -def is_trusted_peer(identity: str) -> bool: - return A2ASecurityContext.capture().is_trusted_peer(identity) - - -def sign_push_payload(payload: dict) -> str: - return A2ASecurityContext.capture().sign_push_payload(payload) - - -# ── Inbound injection filtering ─────────────────────────────────────────────── - # Neutralise (don't reject) so a task that merely *mentions* these still gets through. _INJECTION_PATTERNS: tuple[re.Pattern[str], ...] = ( re.compile(r"<\|im_(start|end)\|>", re.IGNORECASE), @@ -190,9 +154,7 @@ _REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( def filter_inbound(text: str) -> str: """Defang prompt-injection markers in inbound task text.""" - if not text: - return text - for pat in _INJECTION_PATTERNS: + for pat in _INJECTION_PATTERNS if text else (): text = pat.sub("[filtered]", text) return text @@ -205,18 +167,13 @@ def wrap_inbound(peer: str, text: str) -> str: def redact_outbound(text: str) -> str: """Scrub credential-shaped substrings before sending text to a peer.""" - if not text: - return text - for pat, repl in _REDACTION_PATTERNS: + for pat, repl in _REDACTION_PATTERNS if text else (): text = pat.sub(repl, text) return text -# ── SSRF protection for push notification callback URLs ─────────────────────── - -# Blocked even in localhost-only mode — a remote peer must not make us probe internal -# services: link-local/AWS metadata, loopback, RFC1918, unspecified, IPv6 loopback/link-local/ULA. -# Loopback is allowed only in localhost mode (local testing). +# Blocked even in localhost-only mode — a remote peer must not make us probe internal services +# (link-local/AWS metadata, RFC1918, unspecified, IPv6 link-local/ULA). Loopback only in localhost mode. _BLOCKED_PREFIXES = ("169.254.", "127.", "10.", *(f"172.{i}." for i in range(16, 32)), "192.168.", "0.0.0.0", "::1", "fe80:", "fc00:", "fd00:") @@ -225,15 +182,11 @@ def is_safe_callback_url(url: str, *, localhost_mode: Optional[bool] = None) -> """True when a push callback URL is http(s) and not internal/private/loopback.""" if localhost_mode is None: localhost_mode = localhost_only() - if not url or not isinstance(url, str): - return False try: - parsed = urllib.parse.urlparse(url) + parsed = urllib.parse.urlparse(url) if url and isinstance(url, str) else None except Exception: return False - if parsed.scheme not in ("http", "https"): - return False - hostname = parsed.hostname or "" + hostname = (parsed.hostname or "") if parsed and parsed.scheme in ("http", "https") else "" if not hostname: return False hostname_lower = hostname.lower() @@ -251,16 +204,13 @@ def is_safe_callback_url(url: str, *, localhost_mode: Optional[bool] = None) -> return True -# ── Audit log ───────────────────────────────────────────────────────────────── - def audit(direction: str, peer: str, task_id: str, summary: str) -> None: """Append an audit record (direction: inbound | outbound | push). Never raises.""" try: from .protocol import _hermes_home rec = {"ts": time.time(), "direction": direction, "peer": peer, "task_id": task_id, "summary": (summary or "")[:500]} - path = _hermes_home() / "a2a_audit.jsonl" - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("a", encoding="utf-8") as fh: + _hermes_home().mkdir(parents=True, exist_ok=True) + with (_hermes_home() / "a2a_audit.jsonl").open("a", encoding="utf-8") as fh: fh.write(json.dumps(rec, ensure_ascii=False) + "\n") except Exception: logger.debug("A2A: audit write failed", exc_info=True) diff --git a/plugins/platforms/a2a/tools.py b/plugins/platforms/a2a/tools.py index 6d3a40f0c2..b0753595fe 100644 --- a/plugins/platforms/a2a/tools.py +++ b/plugins/platforms/a2a/tools.py @@ -1,22 +1,10 @@ -""" -A2A client tools (``a2a`` toolset) — let the Hermes agent talk to *other* agents: -a2a_discover, a2a_call, a2a_list, a2a_history, a2a_orchestrate. - -Peers are resolved from config.yaml under ``a2a_agents``:: - - a2a_agents: - researcher: - url: "http://localhost:9999" - auth: { type: bearer, token: "sk-..." } - timeout: 120 - capabilities: [web_search, research] - -Transport is stdlib urllib (no a2a-sdk). Wire format is A2A v1.0 JSON-RPC -``SendMessage``; replies from v0.3 peers still parse. -""" +"""A2A client tools (``a2a`` toolset): a2a_discover/call/list/history/orchestrate talk to *other* +agents. Peers come from config.yaml ``a2a_agents: {name: {url, auth: {type: bearer, token}, timeout, +capabilities}}``. Stdlib urllib; wire format is A2A v1.0 ``SendMessage`` (v0.3 replies still parse).""" from __future__ import annotations +import contextlib import json import logging import os @@ -25,6 +13,8 @@ import urllib.request from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any, Optional +from gateway.platforms._shared import coerce_port as _coerce_int + from . import protocol, security logger = logging.getLogger(__name__) @@ -33,8 +23,6 @@ _DEFAULT_TIMEOUT = 120 _ORCHESTRATE_MAX_WORKERS = 6 # max parallel peers for fan-out -# ── Peer resolution ─────────────────────────────────────────────────────────── - def _load_config() -> dict: try: from hermes_cli.config import load_config @@ -53,23 +41,17 @@ def _peer_from_entry(entry: dict, **extra: Any) -> dict: def _resolve_peer(agent: str) -> Optional[dict]: - """Resolve a peer name to {url, auth, timeout, capabilities, tenant}, or treat ``agent`` as a URL.""" - if agent.startswith("http://") or agent.startswith("https://"): + """Peer name -> {url, auth, timeout, capabilities, tenant}, or treat ``agent`` as a URL.""" + if agent.startswith(("http://", "https://")): return {"url": agent, "auth": {}, "timeout": _DEFAULT_TIMEOUT, "capabilities": []} entry = _configured_peers().get(agent) - if not entry: - return None - return _peer_from_entry(entry, capabilities=entry.get("capabilities", []) or [], tenant=entry.get("tenant", "")) + return _peer_from_entry(entry, capabilities=entry.get("capabilities", []) or [], tenant=entry.get("tenant", "")) if entry else None def _auth_header(auth: dict) -> dict: - if auth and auth.get("type") == "bearer" and auth.get("token"): - return {"Authorization": f"Bearer {auth['token']}"} - return {} + return {"Authorization": f"Bearer {auth['token']}"} if auth and auth.get("type") == "bearer" and auth.get("token") else {} -# ── HTTP + Agent Card discovery ─────────────────────────────────────────────── - def _http_json(url: str, headers: dict, timeout: int, method: str, data: Optional[bytes] = None) -> dict: req = urllib.request.Request(url, data=data, headers=headers, method=method) with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 (configured peers) @@ -106,36 +88,28 @@ def _select_jsonrpc_interface(card: Optional[dict]) -> Optional[dict]: def _rpc_url(base_url: str, card: Optional[dict]) -> str: """Card's JSONRPC interface (v1.0 supportedInterfaces) > card's legacy top-level url > base.""" - iface = _select_jsonrpc_interface(card) - if iface: + if iface := _select_jsonrpc_interface(card): return str(iface["url"]) if isinstance(card, dict) and isinstance(card.get("url"), str) and card["url"]: return card["url"] return base_url.rstrip("/") -# ── Shared send path (used by a2a_call and a2a_orchestrate) ─────────────────── - def _send_task(agent_label: str, peer: dict, message: str, context_id: str) -> tuple[str, str, str]: - """Send one SendMessage to a peer -> (reply_text, context_id, state). Raises urllib errors / + """One SendMessage to a peer -> (reply_text, context_id, state). Raises urllib errors / ValueError for the caller to format; handles redaction, audit, persistence, metrics.""" base_url = peer.get("url", "") headers = _auth_header(peer.get("auth", {}) or {}) timeout = int(peer.get("timeout", _DEFAULT_TIMEOUT)) - card = None # best-effort, to learn the rpc URL try: - card = _fetch_card(base_url, headers, min(timeout, 30)) + card = _fetch_card(base_url, headers, min(timeout, 30)) # best-effort, to learn the rpc URL except Exception: - pass + card = None ctx = context_id or protocol.new_context_id() safe_message = security.redact_outbound(message) # v1.0: contextId lives inside the Message, not at the params top level. - rpc_body = { - "jsonrpc": "2.0", - "id": protocol.new_task_id(), - "method": "SendMessage", - "params": {"message": protocol.text_message(protocol.ROLE_USER, safe_message, context_id=ctx)}, - } + rpc_body = {"jsonrpc": "2.0", "id": protocol.new_task_id(), "method": "SendMessage", + "params": {"message": protocol.text_message(protocol.ROLE_USER, safe_message, context_id=ctx)}} iface = _select_jsonrpc_interface(card) tenant = str(iface["tenant"]) if iface and iface.get("tenant") else str(peer.get("tenant") or "") if tenant: @@ -145,8 +119,7 @@ def _send_task(agent_label: str, peer: dict, message: str, context_id: str) -> t protocol.metrics.outbound_total += 1 resp = _http_post_json(_rpc_url(base_url, card), rpc_body, headers, timeout) if "error" in resp: - err = resp["error"] - raise ValueError(f"Peer '{agent_label}' returned an error: {err.get('message', err)}") + raise ValueError(f"Peer '{agent_label}' returned an error: {resp['error'].get('message', resp['error'])}") payload = protocol.unwrap_send_message_response(resp.get("result", {})) reply = _reply_text_from_result(payload) reply_ctx, state = ctx, "" @@ -162,18 +135,16 @@ def _reply_text_from_result(result: Any) -> str: result = protocol.unwrap_send_message_response(result) if not isinstance(result, dict): return str(result) - # Artifacts first (final output), then status message (interim/clarify). + # Artifacts first (final output), then status message (interim/clarify), else bare Message. for artifact in result.get("artifacts", []) or []: txt = protocol.extract_text(artifact) if txt: return txt - msg = (result.get("status", {}) or {}).get("message") - if msg: - return protocol.extract_text(msg) - return protocol.extract_text(result) # bare Message result + return protocol.extract_text((result.get("status", {}) or {}).get("message") or result) -# ── Tool handlers ───────────────────────────────────────────────────────────── +_AUTH_ERR = "Error: peer '{agent}' rejected auth (HTTP {code}). Check the configured token." +_HTTP_CALL_ERRORS = {401: _AUTH_ERR, 403: _AUTH_ERR, 429: "Error: peer '{agent}' rate limited us (HTTP 429). Retry later."} def a2a_discover(args: dict, **_: Any) -> str: """Fetch and summarize the Agent Card at ``url``.""" @@ -193,14 +164,10 @@ def a2a_discover(args: dict, **_: Any) -> str: f"{i.get('protocolBinding', '?')} v{i.get('protocolVersion', '?')}" for i in (card.get("supportedInterfaces", []) or []) if isinstance(i, dict) ) or f"v{card.get('protocolVersion', '?')} (pre-1.0 card)" - lines = [ - f"Agent: {card.get('name', '?')}", - f"Description: {card.get('description', '')}", - f"URL: {_rpc_url(url, card)}", - f"Protocol: {proto}", - f"Streaming: {bool(caps.get('streaming'))} Push: {bool(caps.get('pushNotifications'))} Auth required: {auth}", - f"Skills ({len(skills)}):", - ] + lines = [f"Agent: {card.get('name', '?')}", f"Description: {card.get('description', '')}", f"URL: {_rpc_url(url, card)}", + f"Protocol: {proto}", + f"Streaming: {bool(caps.get('streaming'))} Push: {bool(caps.get('pushNotifications'))} Auth required: {auth}", + f"Skills ({len(skills)}):"] lines.extend(f" - {s.get('name', s.get('id', '?'))}: {s.get('description', '')}" for s in skills[:20]) return "\n".join(lines) @@ -219,11 +186,7 @@ def a2a_call(args: dict, **_: Any) -> str: try: reply, reply_ctx, state = _send_task(agent, peer, message, context_id) except urllib.error.HTTPError as e: - if e.code in (401, 403): - return f"Error: peer '{agent}' rejected auth (HTTP {e.code}). Check the configured token." - if e.code == 429: - return f"Error: peer '{agent}' rate limited us (HTTP 429). Retry later." - return f"Error: call to '{agent}' failed — HTTP {e.code}." + return _HTTP_CALL_ERRORS.get(e.code, "Error: call to '{agent}' failed — HTTP {code}.").format(agent=agent, code=e.code) except ValueError as e: return str(e) except Exception as e: @@ -243,14 +206,12 @@ def a2a_list(args: dict | None = None, **_: Any) -> str: if peers: lines.append(f"Configured peers ({len(peers)}):") for name, entry in peers.items(): - auth = (entry.get("auth") or {}).get("type", "none") caps = entry.get("capabilities", []) - cap_str = f" caps: {', '.join(caps)}" if caps else "" - lines.append(f" - {name}: {entry.get('url', '?')} (auth: {auth}){cap_str}") + lines.append(f" - {name}: {entry.get('url', '?')} (auth: {(entry.get('auth') or {}).get('type', 'none')})" + + (f" caps: {', '.join(caps)}" if caps else "")) else: lines.append("No peers configured. Add them under 'a2a_agents' in config.yaml.") - convos = protocol.list_conversations() - if convos: + if convos := protocol.list_conversations(): lines.append("") lines.append(f"Persisted conversations ({len(convos)}) — recall with a2a_history:") lines.extend(f" - {c}" for c in convos[:25]) @@ -263,39 +224,29 @@ def a2a_list(args: dict | None = None, **_: Any) -> str: def a2a_history(args: dict, **_: Any) -> str: - """Recall a persisted A2A conversation (~/.hermes/a2a_conversations/.jsonl) - — how prior exchanges survive compaction/restarts.""" + """Recall a persisted A2A conversation (survives compaction/restarts).""" context_id = str(args.get("context_id") or args.get("contextId") or "").strip() if not context_id: return "Error: 'context_id' is required (see a2a_list for known conversations)." - try: - limit = max(1, min(int(args.get("limit") or 50), 200)) - except (ValueError, TypeError): - limit = 50 + limit = max(1, min(_coerce_int(args.get("limit") or 50, 50), 200)) messages = protocol.load_conversation(context_id, limit=limit) if not messages: return f"No persisted conversation for context '{context_id}'." lines = [f"Conversation {context_id} (last {len(messages)} messages):"] for m in messages: text = (m.get("text") or "").strip() - if len(text) > 1000: - text = text[:1000] + " …[truncated]" - lines.append(f"[{m.get('role', '?')}] {text}") + lines.append(f"[{m.get('role', '?')}] {text[:1000] + ' …[truncated]' if len(text) > 1000 else text}") return "\n".join(lines) -# ── a2a_orchestrate: capability-based routing with fan-out ──────────────────── - def _match_peers_by_capability(capability: str) -> list[tuple[str, dict]]: """Configured peers that advertise the capability ('*' matches all).""" - return [ - (name, entry) for name, entry in _configured_peers().items() - if capability in (entry.get("capabilities", []) or []) or capability == "*" - ] + return [(name, entry) for name, entry in _configured_peers().items() + if capability in (entry.get("capabilities", []) or []) or capability == "*"] def _call_peer_sync(agent_name: str, peer_entry: dict, message: str, context_id: str = "") -> tuple[str, str]: - """Call a single peer synchronously. Returns (agent_name, reply_text).""" + """Call a single peer synchronously -> (agent_name, reply_text).""" try: reply, _ctx, _state = _send_task(agent_name, _peer_from_entry(peer_entry), message, context_id) return (agent_name, reply or "(no reply)") @@ -304,21 +255,19 @@ def _call_peer_sync(agent_name: str, peer_entry: dict, message: str, context_id: def a2a_orchestrate(args: dict, **_: Any) -> str: - """Fan-out a task to peers matching a capability (``a2a_agents.*.capabilities``). Modes: ``all``, - ``first`` (first successful), ``best`` (longest successful — coarse; use ``all`` to judge yourself).""" + """Fan-out a task to peers matching a capability. Modes: ``all``, ``first`` (first successful), + ``best`` (longest successful — coarse; use ``all`` to judge yourself).""" capability = str(args.get("capability") or "").strip() message = str(args.get("message") or args.get("task") or "").strip() mode = str(args.get("mode") or "all").strip().lower() + mode = mode if mode in ("all", "first", "best") else "all" context_id = str(args.get("context_id") or "").strip() if not message: return "Error: 'message' is required." if not capability: return "Error: 'capability' is required (or use '*' for all peers)." - matches = _match_peers_by_capability(capability) - if not matches: + if not (matches := _match_peers_by_capability(capability)): return f"Error: no configured peers advertise capability '{capability}'." - if mode not in ("all", "first", "best"): - mode = "all" results: list[tuple[str, str]] = [] with ThreadPoolExecutor(max_workers=min(len(matches), _ORCHESTRATE_MAX_WORKERS)) as pool: futures = {pool.submit(_call_peer_sync, name, entry, message, context_id): name for name, entry in matches} @@ -339,87 +288,62 @@ def a2a_orchestrate(args: dict, **_: Any) -> str: return "\n".join(["All peers failed:"] + [f" {name}: {reply}" for name, reply in results]) name, reply = max(successes, key=lambda r: len(r[1])) if mode == "best" else successes[0] return f"[{mode}: {name}]\n{reply}" - lines = [f"Orchestrated '{capability}' to {len(matches)} peer(s):"] - for name, reply in results: - lines.append(f"\n--- {name} ---") - lines.append(reply) - return "\n".join(lines) + return "\n".join([f"Orchestrated '{capability}' to {len(matches)} peer(s):"] + + [line for name, reply in results for line in (f"\n--- {name} ---", reply)]) -# ── Tool schemas + registration ─────────────────────────────────────────────── - def _str(description: str) -> dict: return {"type": "string", "description": description} # name -> (handler, description, properties, required) _TOOLS: dict[str, tuple[Any, str, dict, list[str]]] = { - "a2a_discover": ( - a2a_discover, - "Fetch and summarize another agent's A2A Agent Card from a URL (its name, description, " - "capabilities, and skills). Use this to find out what a remote agent can do before calling it.", - {"url": _str("Base URL of the remote A2A agent, e.g. http://localhost:9999")}, - ["url"], - ), - "a2a_call": ( - a2a_call, - "Send a natural-language task to a remote A2A agent and return its reply. The agent is a peer " - "(any A2A-compliant framework), not a sub-agent you control. Pass 'context_id' from a previous " - "reply to continue a multi-turn exchange.", - { - "agent": _str("Configured peer name (from a2a_agents) or a full http(s):// URL."), - "message": _str("The task / message to send the peer, in natural language."), - "context_id": _str("Optional: context id from a prior reply, to continue the conversation."), - }, - ["agent", "message"], - ), + "a2a_discover": (a2a_discover, + "Fetch and summarize another agent's A2A Agent Card from a URL (its name, description, " + "capabilities, and skills). Use this to find out what a remote agent can do before calling it.", + {"url": _str("Base URL of the remote A2A agent, e.g. http://localhost:9999")}, ["url"]), + "a2a_call": (a2a_call, + "Send a natural-language task to a remote A2A agent and return its reply. The agent is a peer " + "(any A2A-compliant framework), not a sub-agent you control. Pass 'context_id' from a previous " + "reply to continue a multi-turn exchange.", + {"agent": _str("Configured peer name (from a2a_agents) or a full http(s):// URL."), + "message": _str("The task / message to send the peer, in natural language."), + "context_id": _str("Optional: context id from a prior reply, to continue the conversation.")}, + ["agent", "message"]), "a2a_list": (a2a_list, "List configured A2A peer agents, persisted A2A conversations, and metrics.", {}, []), - "a2a_history": ( - a2a_history, - "Recall a persisted A2A conversation transcript by context_id (survives restarts and " - "context compaction). Use a2a_list to see known context ids.", - { - "context_id": _str("Context id of the conversation to recall."), - "limit": {"type": "integer", "description": "Max messages to return (default 50, max 200)."}, - }, - ["context_id"], - ), - "a2a_orchestrate": ( - a2a_orchestrate, - "Fan-out a task to multiple peer agents by capability. Peers are matched from config.yaml " - "a2a_agents.*.capabilities. Modes: 'all' (return all replies), 'first' (first successful), " - "'best' (longest successful reply).", - { - "capability": _str("Capability to match (e.g. 'research', 'code') or '*' for all peers."), - "message": _str("The task to send to all matching peers."), - "mode": {"type": "string", "enum": ["all", "first", "best"], "description": "How to aggregate results. Default: 'all'."}, - "context_id": _str("Optional: shared context id for all peers."), - }, - ["capability", "message"], - ), + "a2a_history": (a2a_history, + "Recall a persisted A2A conversation transcript by context_id (survives restarts and " + "context compaction). Use a2a_list to see known context ids.", + {"context_id": _str("Context id of the conversation to recall."), + "limit": {"type": "integer", "description": "Max messages to return (default 50, max 200)."}}, + ["context_id"]), + "a2a_orchestrate": (a2a_orchestrate, + "Fan-out a task to multiple peer agents by capability. Peers are matched from config.yaml " + "a2a_agents.*.capabilities. Modes: 'all' (return all replies), 'first' (first successful), " + "'best' (longest successful reply).", + {"capability": _str("Capability to match (e.g. 'research', 'code') or '*' for all peers."), + "message": _str("The task to send to all matching peers."), + "mode": {"type": "string", "enum": ["all", "first", "best"], "description": "How to aggregate results. Default: 'all'."}, + "context_id": _str("Optional: shared context id for all peers.")}, + ["capability", "message"]), } def _a2a_tools_available() -> bool: """check_fn: serve the client tools ONLY when the operator opted into A2A (peers under - ``a2a_agents``, inbound platform enabled, or A2A_PORT set). Unconditional registration cost - every session ~561 tok/call for tools that can only say 'no peers configured'. Fail closed.""" + ``a2a_agents``, inbound platform enabled, or A2A_PORT set). Fail closed.""" cfg = {} - try: + with contextlib.suppress(Exception): cfg = _load_config() if cfg.get("a2a_agents"): return True - except Exception: # noqa: BLE001 - pass try: if os.getenv("A2A_PORT"): return True a2a_cfg = (cfg.get("platforms") or {}).get("a2a") or {} - if isinstance(a2a_cfg, dict) and a2a_cfg.get("enabled"): - return True + return bool(isinstance(a2a_cfg, dict) and a2a_cfg.get("enabled")) except Exception: # noqa: BLE001 - pass - return False + return False def register_tools(ctx) -> None: @@ -428,12 +352,6 @@ def register_tools(ctx) -> None: parameters: dict[str, Any] = {"type": "object", "properties": properties} if required: parameters["required"] = required - ctx.register_tool( - name=name, - toolset="a2a", - schema={"name": name, "description": description, "parameters": parameters}, - handler=handler, - description=description, - emoji="\U0001f9e9", # puzzle piece - check_fn=_a2a_tools_available, - ) + ctx.register_tool(name=name, toolset="a2a", handler=handler, description=description, + schema={"name": name, "description": description, "parameters": parameters}, + emoji="\U0001f9e9", check_fn=_a2a_tools_available) # puzzle piece diff --git a/plugins/platforms/buzz/adapter.py b/plugins/platforms/buzz/adapter.py index 90d35a8384..8401d3c770 100644 --- a/plugins/platforms/buzz/adapter.py +++ b/plugins/platforms/buzz/adapter.py @@ -1,34 +1,11 @@ -"""Buzz Platform Adapter for Hermes Agent. +"""Buzz platform adapter (Block's Nostr-based human+agent community relay). -Connects to a Buzz community relay (Block's Nostr-based human+agent platform). -Outbound and polling go through the ``buzz`` CLI ("JSON in, JSON out", never a -shell); inbound prefers a NIP-42-authenticated WebSocket subscription with a -CLI poll-loop fallback. - -Configuration in config.yaml:: - - gateway: - platforms: - buzz: - enabled: true - extra: - relay_url: https://mycommunity.communities.buzz.xyz - channels: # channel UUIDs to watch (empty = all joined) - - ccc2bc1a-7a82-5a8f-8c4e-57a070cbe7cd - home_channel: ccc2bc1a-7a82-5a8f-8c4e-57a070cbe7cd - poll_interval: 4 # seconds between poll sweeps - cli_path: "" # path to the buzz binary (default: PATH, then ~/bin/buzz) - credentials_file: "" # JSON file holding the nsec (fallback for BUZZ_PRIVATE_KEY) - allowed_users: [] # empty = allow all; entries are hex pubkeys or npubs - reply_in_thread: true # false = post replies flat to the channel timeline - reaction_only_users: [] # acknowledge explicit tags without dispatching; allowed_users wins on overlap - -Or via environment variables (override config.yaml): BUZZ_RELAY_URL, BUZZ_CHANNELS, -BUZZ_HOME_CHANNEL, BUZZ_POLL_INTERVAL, BUZZ_CLI_PATH, BUZZ_CREDENTIALS_FILE, BUZZ_ALLOWED_USERS, -BUZZ_REACTION_ONLY_USERS, BUZZ_ALLOW_ALL_USERS, BUZZ_REPLY_IN_THREAD, BUZZ_REPLY_TO_MODE. - -The only secret is BUZZ_PRIVATE_KEY (nsec or hex) — it belongs in ``~/.hermes/.env``, -travels to the CLI via the subprocess environment and is never logged. +Outbound and polling go through the ``buzz`` CLI ("JSON in, JSON out", never a shell); +inbound prefers a NIP-42-authenticated WebSocket subscription with a CLI poll fallback. +Config lives in ``gateway.platforms.buzz.extra`` (relay_url, channels, home_channel, +poll_interval, cli_path, credentials_file, allowed_users, reply_in_thread, reaction_only_users) +or the matching ``BUZZ_*`` env vars (env overrides config). The only secret is +BUZZ_PRIVATE_KEY (nsec or hex): it reaches the CLI via the subprocess env and is never logged. """ import asyncio @@ -55,11 +32,9 @@ from gateway.platforms._shared import profile_scoped as _profile_scoped def _get_scoped_secret(name, default=None): - """Scope-aware credential read. An active profile scope is authoritative (a miss - returns ``default``, never an env borrow); the unscoped default profile uses its own - env. Extra unscoped rung (unlike ``_shared.get_scoped_secret``): the startup gate - runs before any scope exists, so externally managed secrets are consulted via a - one-shot profile-scope build. An ACTIVE scope shadows that rung.""" + """Scope-aware credential read: an active scope is authoritative (a miss is ``default``, never an env + borrow). Unscoped adds one rung over ``_shared``: the startup gate runs before any scope exists, so + externally managed secrets are consulted via a one-shot profile-scope build.""" try: val = _scoped_get_secret(name, None) except _UnscopedSecretError: @@ -73,11 +48,7 @@ _UNSCOPED_PROFILE_SECRETS: Optional[Dict[str, str]] = None def _unscoped_profile_secrets() -> Dict[str, str]: - """One-shot, process-cached profile secret mapping (external resolvers are slow). - - Any failure degrades to an empty mapping (platform reported as not configured). - Startup-gate-only: it pins the ``get_hermes_home()`` seen on first build. - """ + """Process-cached profile secret mapping (external resolvers are slow); failures degrade to {}.""" global _UNSCOPED_PROFILE_SECRETS if _UNSCOPED_PROFILE_SECRETS is None: try: @@ -96,12 +67,8 @@ def _unscoped_profile_secrets() -> Dict[str, str]: def _scoped_platform_setting(env_name, extra, key): - """Raw non-secret setting read. Inside a secondary profile scope ``os.environ`` - holds the DEFAULT profile's bridge output, so ``extra`` is authoritative and a - miss yields ``None``; elsewhere the legacy env-over-config read is preserved.""" - if _profile_scoped(): - return (extra or {}).get(key) - return os.getenv(env_name) + """Raw non-secret setting; in a secondary profile scope ``os.environ`` is the DEFAULT profile's, so ``extra`` wins.""" + return (extra or {}).get(key) if _profile_scoped() else os.getenv(env_name) logger = logging.getLogger(__name__) @@ -112,52 +79,39 @@ from gateway.platforms.base import ( from gateway.config import Platform -# Buzz chat messages are Nostr kind 9; ``messages get`` also returns housekeeping -# kinds (joins, canvas updates, …) which are never dispatched. -_CHAT_KIND = 9 -# Dispatched kinds: chat (9) plus forum post (45001) and forum comment (45003). -# Stream kinds (46010/40007/45002) are left out until their semantics are -# confirmed. ``_is_direct_message_event`` deliberately stays kind-9-only so a -# p-tagged forum post can't be reclassified as a DM and bypass mention gating. +_CHAT_KIND = 9 # ``messages get`` also returns housekeeping kinds, never dispatched +# Chat + forum post/comment; stream kinds wait for confirmed semantics. ``_is_direct_message_event`` +# stays kind-9-only so a p-tagged forum post can't be reclassified as a DM and bypass mention gating. _DISPATCH_KINDS = frozenset({_CHAT_KIND, 45001, 45003}) _UNRESOLVED_MENTION_ERROR_RE = re.compile(r"mention '@(?P[^']+)' does not match a current channel member") _BUZZ_PRESENTATION_MENTION_SEPARATOR = "\u200b" +_HEX64_RE = re.compile(r"[0-9a-f]{64}") def _escape_unresolved_presentation_mention(content: str, error: str) -> Optional[str]: - """Make one CLI-rejected ``@name`` token presentation-only via an invisible - separator after the ``@`` (Buzz p-tags whitespace-prefixed @tokens at publish, - so prose like ``@session:...`` fails preflight). ``None`` if not applicable.""" + """Make a CLI-rejected ``@name`` presentation-only via an invisible separator after the ``@`` (Buzz + p-tags whitespace-prefixed @tokens at publish, so prose like ``@session:...`` fails preflight).""" match = _UNRESOLVED_MENTION_ERROR_RE.search(error or "") - if match is None: - return None - name = match.group("name") + name = match.group("name") if match else "" if not name: return None token = re.compile(rf"(? str: name = "".join(c for c in name if ord(c) >= 32 and c != "\x7f").strip() if name in {"", ".", ".."}: return "attachment.bin" - suffix = Path(name).suffix - if len(suffix.encode("utf-8")) > 20: - suffix = "" + suffix = Path(name).suffix if len(Path(name).suffix.encode("utf-8")) <= 20 else "" stem = name[:-len(suffix)] if suffix else name byte_budget = _MAX_ATTACHMENT_FILENAME_BYTES - len(suffix.encode("utf-8")) safe_stem = stem.encode("utf-8")[:byte_budget].decode("utf-8", errors="ignore").rstrip(" .") @@ -196,29 +148,17 @@ def _attachment_origin(value: str) -> Optional[tuple[str, int]]: # WebSocket transport (NIP-42 authenticated Nostr subscription). _WS_AUTH_TIMEOUT = 20.0 -# Last-resort read bound: the library keepalive should catch a dead relay first, -# but a relay-side close the transport never surfaces (CLOSE_WAIT socket, loop -# parked on recv) leaves the gateway "connected" while inbound stops; this -# timeout forces the normal reconnect path. +# Last-resort read bound: an unsurfaced relay-side close (CLOSE_WAIT) would leave us "connected" with inbound stopped. _WS_READ_IDLE_TIMEOUT = 300.0 _WS_MAX_MESSAGE_BYTES = 2_000_000 _WS_MEMBERSHIP_KIND = 44100 # Buzz channel-membership event — live DM discovery _WS_MEMBERSHIP_SUB_ID = "hermes-buzz-membership" - -# Credentials JSON (keys: nsec / private_key_hex) fallback when BUZZ_PRIVATE_KEY -# is not set. Module-level so tests can point it at a tmpdir. +# Credentials JSON fallback when BUZZ_PRIVATE_KEY is not set; module-level so tests can point it at a tmpdir. _DEFAULT_CREDENTIALS_DIR = Path("~/.config/buzz").expanduser() - -# Buzz-hosted Blossom media is private to the community: same-relay markdown or -# bare media URLs must be authenticated and localised before vision sees them. -_MEDIA_URL_PATTERN = ( - r"https?://[^\s<>\[\]()]+/media/" - r"[0-9a-f]{64}(?:\.[a-z0-9]{1,10})?(?:\?[^\s<>\[\]()]*)?" -) +# Buzz-hosted media is private to the community: same-relay URLs must be authenticated + localised for vision. +_MEDIA_URL_PATTERN = r"https?://[^\s<>\[\]()]+/media/[0-9a-f]{64}(?:\.[a-z0-9]{1,10})?(?:\?[^\s<>\[\]()]*)?" _MARKDOWN_MEDIA_RE = re.compile( - rf"!\[(?P[^\]]*)\]\(\s*(?P{_MEDIA_URL_PATTERN})" - r"(?:\s+[\"'][^\"']*[\"'])?\s*\)", - re.IGNORECASE, + rf"!\[(?P[^\]]*)\]\(\s*(?P{_MEDIA_URL_PATTERN})(?:\s+[\"'][^\"']*[\"'])?\s*\)", re.IGNORECASE ) _BARE_MEDIA_RE = re.compile(_MEDIA_URL_PATTERN, re.IGNORECASE) _MEDIA_PATH_RE = re.compile(r"^/media/(?P[0-9a-f]{64})(?P\.[a-z0-9]{1,10})?/?$", re.IGNORECASE) @@ -235,14 +175,11 @@ def _effective_port(parsed) -> Optional[int]: def _is_relay_media_url(url: str, relay_url: str) -> bool: """Return whether *url* is a Buzz media object on the configured relay.""" - candidate = urlsplit(url) - relay = urlsplit(relay_url) + candidate, relay = urlsplit(url), urlsplit(relay_url) return bool( - candidate.scheme in ("http", "https") - and candidate.hostname and relay.hostname + candidate.scheme in ("http", "https") and candidate.hostname and relay.hostname and candidate.hostname.lower() == relay.hostname.lower() - and _effective_port(candidate) == _effective_port(relay) - and _MEDIA_PATH_RE.fullmatch(candidate.path) + and _effective_port(candidate) == _effective_port(relay) and _MEDIA_PATH_RE.fullmatch(candidate.path) ) @@ -272,17 +209,13 @@ def _find_relay_media_refs(text: str, relay_url: str) -> Tuple[List[str], List[T def _replace_media_refs(text: str, replacements: List[Tuple[int, int, str]]) -> str: - cleaned = text for start, end, replacement in sorted(replacements, reverse=True): - cleaned = f"{cleaned[:start]}{replacement}{cleaned[end:]}" - cleaned = re.sub(r"[ \t]+\n", "\n", cleaned) - cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) - return cleaned.strip() + text = f"{text[:start]}{replacement}{text[end:]}" + return re.sub(r"\n{3,}", "\n\n", re.sub(r"[ \t]+\n", "\n", text)).strip() def _load_nostr_auth(): - """Import sibling nostr_auth loader-agnostically (the test plugin loader imports this - file as a bare module, where relative imports have no parent package).""" + """Import sibling nostr_auth loader-agnostically (the test loader imports this file as a bare module).""" try: from . import nostr_auth # type: ignore[no-redef] return nostr_auth @@ -298,12 +231,10 @@ def _load_nostr_auth(): _nostr_auth = _load_nostr_auth() -# bech32 (BIP-173) helpers — npub <-> hex so mention detection and allow-lists -# accept either form. Same pure-stdlib implementation as nostr_auth / authz_mixin. -_BECH32_CHARSET = _nostr_auth.BECH32_CHARSET -_bech32_polymod = _nostr_auth._bech32_polymod -_bech32_hrp_expand = _nostr_auth._bech32_hrp_expand -from gateway.authz_mixin import _convertbits, _npub_to_hex as npub_to_hex # noqa: E402 +# bech32 (BIP-173) npub <-> hex so mention detection and allow-lists accept either form. +from gateway.authz_mixin import ( # noqa: E402 + _BECH32_CHARSET, _bech32_hrp_expand, _bech32_polymod, _convertbits, _npub_to_hex as npub_to_hex, +) def hex_to_npub(pubkey_hex: str) -> Optional[str]: @@ -312,13 +243,9 @@ def hex_to_npub(pubkey_hex: str) -> Optional[str]: raw = bytes.fromhex(pubkey_hex) except ValueError: return None - if len(raw) != 32: + if len(raw) != 32 or (data := _convertbits(raw, 8, 5)) is None: return None - data = _convertbits(raw, 8, 5) - if data is None: - return None - values = _bech32_hrp_expand("npub") + data - polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1 + polymod = _bech32_polymod(_bech32_hrp_expand("npub") + data + [0, 0, 0, 0, 0, 0]) ^ 1 checksum = [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] return "npub1" + "".join(_BECH32_CHARSET[d] for d in data + checksum) @@ -329,11 +256,7 @@ def _split_csv(raw): def _pubkey_set(raw) -> set: """Normalize a csv string / list of hex pubkeys or npubs to a set of hex pubkeys.""" - return { - normalized - for entry in _split_csv(raw) - if isinstance(entry, str) and (normalized := _normalize_user_ref(entry)) - } + return {n for entry in _split_csv(raw) if isinstance(entry, str) and (n := _normalize_user_ref(entry))} def _setting_or(env_name: str, extra: dict, key: str, default): @@ -342,6 +265,12 @@ def _setting_or(env_name: str, extra: dict, key: str, default): return extra.get(key, default) if raw is None else raw +def _ttl_get(cache: dict, key, ttl: float): + """Value of a ``(monotonic_ts, value)`` cache entry if younger than *ttl*, else None.""" + cached = cache.get(key) + return cached[1] if cached is not None and (time.monotonic() - cached[0]) < ttl else None + + def _add_pubkey(bucket: List[str], raw) -> None: """Append the lowercased pubkey once (empty values are skipped).""" pk = str(raw or "").lower() @@ -356,29 +285,22 @@ def _normalize_user_ref(ref: str) -> Optional[str]: return None if ref.startswith("npub1"): return npub_to_hex(ref) - if re.fullmatch(r"[0-9a-f]{64}", ref): - return ref - return None + return ref if _HEX64_RE.fullmatch(ref) else None # ── buzz-cli invocation helpers ────────────────────────────────────────────── def _reply_to_mode(config, extra: dict) -> str: - """Reply mode ("first"/"all" thread onto the parent, "off" posts flat). Env overrides - the PlatformConfig field; Slack-convention ``reply_in_thread: false`` aliases "off".""" - mode = os.getenv("BUZZ_REPLY_TO_MODE") or getattr(config, "reply_to_mode", "first") or "first" - mode = str(mode).strip().lower() + """Reply mode ("first"/"all" thread, "off" posts flat); env overrides config, ``reply_in_thread: false`` = "off".""" + mode = str(os.getenv("BUZZ_REPLY_TO_MODE") or getattr(config, "reply_to_mode", "first") or "first").strip().lower() rit = os.getenv("BUZZ_REPLY_IN_THREAD") if rit is None: rit = extra.get("reply_in_thread") - if rit is not None and str(rit).strip().lower() in ("false", "0", "no", "off"): - return "off" - return mode + return "off" if rit is not None and str(rit).strip().lower() in ("false", "0", "no", "off") else mode def _configured_relay(extra: dict) -> str: - raw = _scoped_platform_setting("BUZZ_RELAY_URL", extra, "relay_url") - return (raw or extra.get("relay_url", "")).strip() + return (_scoped_platform_setting("BUZZ_RELAY_URL", extra, "relay_url") or extra.get("relay_url", "")).strip() def _configured_home_channel(extra: dict) -> str: @@ -392,12 +314,9 @@ def _configured_cli_path(extra: dict) -> str: def _configured_credentials_file(extra: Optional[dict]) -> str: - # Scope-aware read: inside a secondary profile scope a miss falls to the profile's - # own config extra, never the default profile's os.environ; unscoped reads keep env - # precedence plus the external-secret rung. - return str(_get_scoped_secret("BUZZ_CREDENTIALS_FILE", "") or "").strip() or str( - (extra or {}).get("credentials_file", "") or "" - ).strip() + # Scoped: a miss falls to the profile's own extra, never the default profile's env; unscoped keeps env precedence. + configured = str(_get_scoped_secret("BUZZ_CREDENTIALS_FILE", "") or "").strip() + return configured or str((extra or {}).get("credentials_file", "") or "").strip() def _resolve_cli_path(configured: str = "") -> str: @@ -405,8 +324,7 @@ def _resolve_cli_path(configured: str = "") -> str: if configured: p = Path(configured).expanduser() return str(p) if p.is_file() else "" - found = shutil.which("buzz") - if found: + if found := shutil.which("buzz"): return found fallback = Path.home() / "bin" / "buzz" return str(fallback) if fallback.is_file() else "" @@ -456,26 +374,14 @@ def _resolve_private_key(extra: Optional[dict] = None) -> str: def _resolve_auth_tag(extra: Optional[dict] = None) -> str: """Resolve and validate the optional NIP-OA owner-attestation tag.""" - configured = str(_get_scoped_secret("BUZZ_AUTH_TAG", "") or "").strip() - if configured: - raw: Any = configured - else: - credentials_file = _configured_credentials_file(extra) - direct_key = str(_get_scoped_secret("BUZZ_PRIVATE_KEY", "") or "").strip() - if direct_key and not credentials_file: + raw: Any = str(_get_scoped_secret("BUZZ_AUTH_TAG", "") or "").strip() + if not raw: + if str(_get_scoped_secret("BUZZ_PRIVATE_KEY", "") or "").strip() and not _configured_credentials_file(extra): return "" - data = _resolve_credentials_data(extra) - if "auth_tag" not in data: + if "auth_tag" not in (data := _resolve_credentials_data(extra)): return "" raw = data["auth_tag"] - if isinstance(raw, str): - try: - raw = json.loads(raw) - except json.JSONDecodeError as exc: - raise ValueError("Buzz auth tag is not valid JSON") from exc - if not isinstance(raw, list) or len(raw) != 4 or raw[0] != "auth" or not all(isinstance(p, str) for p in raw): - raise ValueError("Buzz auth tag must be a four-string auth tag") - return json.dumps(raw, separators=(",", ":")) + return json.dumps(_nostr_auth.parse_auth_tag(raw, "Buzz auth tag"), separators=(",", ":")) async def _exec_buzz( @@ -490,8 +396,7 @@ async def _exec_buzz( if auth_tag: env["BUZZ_AUTH_TAG"] = auth_tag proc = await asyncio.create_subprocess_exec( - cli_path, *args, - stdin=asyncio.subprocess.PIPE if input_text is not None else asyncio.subprocess.DEVNULL, + cli_path, *args, stdin=asyncio.subprocess.PIPE if input_text is not None else asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env, ) try: @@ -500,12 +405,10 @@ async def _exec_buzz( except asyncio.TimeoutError: proc.kill() await proc.wait() - return 124, "", json.dumps({"error": "timeout", "message": f"buzz {args[0] if args else ''} timed out after {timeout}s"}) - return ( - proc.returncode if proc.returncode is not None else 4, - stdout.decode("utf-8", errors="replace"), - stderr.decode("utf-8", errors="replace"), - ) + detail = {"error": "timeout", "message": f"buzz {args[0] if args else ''} timed out after {timeout}s"} + return 124, "", json.dumps(detail) + rc = proc.returncode if proc.returncode is not None else 4 + return rc, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace") _MAX_CLI_MESSAGE_CHARS = 900 @@ -515,24 +418,18 @@ def _bounded_cli_message(message: str, redact_path: Optional[Path] = None) -> st """Keep untrusted CLI detail useful without exposing unbounded output.""" if redact_path is not None: message = message.replace(str(redact_path), redact_path.name) - if len(message) <= _MAX_CLI_MESSAGE_CHARS: - return message - return f"{message[: _MAX_CLI_MESSAGE_CHARS - 3]}..." + return message if len(message) <= _MAX_CLI_MESSAGE_CHARS else f"{message[: _MAX_CLI_MESSAGE_CHARS - 3]}..." def _cli_error_message(stderr: str, returncode: int, *, redact_path: Optional[Path] = None) -> str: """Extract a bounded human-readable message from the CLI error contract.""" text = (stderr or "").strip() - try: - data = json.loads(text) - if isinstance(data, dict): - detail = data.get("message") - category = data.get("error") - if isinstance(detail, str) and detail.strip(): - label = category.strip() if isinstance(category, str) and category.strip() else "error" - return _bounded_cli_message(f"{label}: {detail.strip()} (exit {returncode})", redact_path) - except ValueError: - pass + data = _json_or(text, None) + if isinstance(data, dict): + detail, category = data.get("message"), data.get("error") + if isinstance(detail, str) and detail.strip(): + label = category.strip() if isinstance(category, str) and category.strip() else "error" + return _bounded_cli_message(f"{label}: {detail.strip()} (exit {returncode})", redact_path) return _bounded_cli_message(text or f"buzz CLI failed with exit code {returncode}", redact_path) @@ -546,10 +443,8 @@ def _parse_send_receipt(stdout: str) -> Tuple[Optional[str], Optional[str]]: if not isinstance(detail, str) or not detail.strip(): detail = "message was not accepted" return None, _bounded_cli_message(detail.strip()) - if data.get("accepted") is not True: - return None, "invalid CLI response" event_id = data.get("event_id") - if not isinstance(event_id, str) or not event_id.strip(): + if data.get("accepted") is not True or not isinstance(event_id, str) or not event_id.strip(): return None, "invalid CLI response" return event_id.strip(), None @@ -568,21 +463,22 @@ def _parse_json_list(stdout: str) -> List[dict]: return [item for item in data if isinstance(item, dict)] if isinstance(data, list) else [] +def _e_tags(event: dict): + """Yield ``(target, marker)`` for each well-formed NIP-10 ``e`` tag (raw values, unstripped).""" + tags = event.get("tags") + if isinstance(tags, list): + for tag in tags: + if isinstance(tag, (list, tuple)) and len(tag) >= 2 and str(tag[0]) == "e": + yield tag[1], (str(tag[3]) if len(tag) > 3 else "") + + def _event_reply_parent_id(event: dict) -> Optional[str]: """Direct parent id from NIP-10 ``e`` tags: ``reply`` marker, then ``root``, else last positional.""" - tags = event.get("tags") - if not isinstance(tags, list): - return None - reply_id: Optional[str] = None - root_id: Optional[str] = None - last_e: Optional[str] = None - for tag in tags: - if not isinstance(tag, (list, tuple)) or len(tag) < 2 or tag[0] != "e": - continue - target = str(tag[1] or "").strip() + reply_id = root_id = last_e = None + for raw_target, marker in _e_tags(event): + target = str(raw_target or "").strip() if not target: continue - marker = str(tag[3] or "") if len(tag) > 3 else "" last_e = target if marker == "reply": reply_id = target @@ -591,27 +487,27 @@ def _event_reply_parent_id(event: dict) -> Optional[str]: return reply_id or root_id or last_e +def _p_tagged(event: dict, pubkey: str) -> bool: + """True when the event carries a ``p`` tag equal to *pubkey* (case-insensitive).""" + tags = event.get("tags") + return isinstance(tags, list) and any( + isinstance(tag, (list, tuple)) and len(tag) > 1 and tag[0] == "p" and str(tag[1]).lower() == pubkey for tag in tags) + + # Cap stored parent content snippets (gateway reply injection also clips). _EVENT_META_CONTENT_CAP = 500 _MEDIA_KIND_PRIORITY = (("image", MessageType.PHOTO), ("audio", MessageType.AUDIO), ("video", MessageType.VIDEO)) -_ATTACHMENT_KIND_TYPES = { - "image": MessageType.PHOTO, - "video": MessageType.VIDEO, - "audio": MessageType.AUDIO, - "document": MessageType.DOCUMENT, -} +_ATTACHMENT_KIND_TYPES = {"image": MessageType.PHOTO, "video": MessageType.VIDEO, "audio": MessageType.AUDIO, "document": MessageType.DOCUMENT} class BuzzAdapter(BasePlatformAdapter): """Buzz adapter (WebSocket push with poll fallback) for the BasePlatformAdapter interface.""" def __init__(self, config, **kwargs): - platform = Platform("buzz") - super().__init__(config=config, platform=platform) + super().__init__(config=config, platform=Platform("buzz")) extra = getattr(config, "extra", {}) or {} self._extra = extra - # Connection settings: env overrides config.yaml, except under a secondary - # multiplex profile scope where the profile's extra wins (see _scoped_platform_setting). + # Env overrides config.yaml, except under a secondary profile scope where extra wins (_scoped_platform_setting). self.relay_url = _configured_relay(extra) hosts = _split_csv(extra.get("attachment_hosts", [])) origins = (_attachment_origin(h) for h in hosts if isinstance(h, str)) @@ -626,45 +522,34 @@ class BuzzAdapter(BasePlatformAdapter): self.home_channel = _configured_home_channel(extra) _pi_raw = _scoped_platform_setting("BUZZ_POLL_INTERVAL", extra, "poll_interval") try: - interval = float(_pi_raw or extra.get("poll_interval", _DEFAULT_POLL_INTERVAL)) + self.poll_interval = max(_MIN_POLL_INTERVAL, float(_pi_raw or extra.get("poll_interval", _DEFAULT_POLL_INTERVAL))) except (TypeError, ValueError): - interval = _DEFAULT_POLL_INTERVAL - self.poll_interval = max(_MIN_POLL_INTERVAL, interval) + self.poll_interval = max(_MIN_POLL_INTERVAL, _DEFAULT_POLL_INTERVAL) # Channel messages must @mention the agent unless disabled; DMs always dispatch. _rm_cfg = _setting_or("BUZZ_REQUIRE_MENTION", extra, "require_mention", True) self.require_mention = str(_rm_cfg).strip().lower() not in ("false", "0", "no", "off") self._reply_to_mode: str = _reply_to_mode(config, extra) - # Inbound transport: "auto" (WebSocket with poll fallback), "websocket" - # (require WS; fail connect when it can't authenticate), or "poll". + # Inbound transport: "auto" (WebSocket with poll fallback), "websocket" (required), "poll". _transport_raw = _scoped_platform_setting("BUZZ_TRANSPORT", extra, "transport") _transport = (_transport_raw or str(extra.get("transport", "auto") or "auto")).strip().lower() self.transport = _transport if _transport in ("auto", "websocket", "poll") else "auto" - # Auth: entries may be hex pubkeys or npubs; normalized to hex. + # Entries may be hex or npub (normalized to hex). Reaction-only identities get a 👀 on explicit tags but + # never dispatch; allowed_users wins on overlap. self._allowed_pubkeys: set = _pubkey_set(_setting_or("BUZZ_ALLOWED_USERS", extra, "allowed_users", [])) - # Reaction-only identities get a 👀 receipt on explicit tags but never - # dispatch; allowed_users wins on overlap (normal dispatch path runs). - self._reaction_only_pubkeys: set = _pubkey_set( - os.getenv("BUZZ_REACTION_ONLY_USERS") or extra.get("reaction_only_users", []) - ) + self._reaction_only_pubkeys: set = _pubkey_set(os.getenv("BUZZ_REACTION_ONLY_USERS") or extra.get("reaction_only_users", [])) # Secret — resolved lazily (never at import time, never logged); connect() re-resolves. - self._private_key: str = "" - self._auth_tag: str = "" + self._private_key = self._auth_tag = "" # Identity — filled in by connect() from ``buzz users get`` - self._self_pubkey: str = "" - self._self_npub: str = "" - self._display_name: str = "" - # Runtime state + self._self_pubkey = self._self_npub = self._display_name = "" self._poll_task: Optional[asyncio.Task] = None self._ws_task: Optional[asyncio.Task] = None self._ws_ready: Optional[asyncio.Event] = None - self._membership_since = 0 + self._membership_since = self._poll_count = 0 self._lock_key: Optional[str] = None - # Channels the relay permanently rejected ("restricted: not a channel - # member"); persists across reconnects so we never re-subscribe. + # Channels the relay permanently rejected ("restricted"); persists across reconnects so we never re-subscribe. self._restricted_channels: set = set() - # channel_id -> {"chat_type", "last_ts", "seen": OrderedDict[event_id, None], - # "event_meta": OrderedDict[event_id, (author_pubkey, snippet)]}; event_meta - # backs NIP-10 reply-parent resolution (replies to our messages count as addressed). + # channel_id -> {"chat_type", "last_ts", "seen": OrderedDict[event_id, None], "event_meta": + # OrderedDict[event_id, (author_pubkey, snippet)]}; event_meta backs NIP-10 reply-parent resolution. self._channel_state: Dict[str, dict] = {} # Cursors read from disk at connect(), consumed by each channel's first seed. self._restored_cursors: Dict[str, dict] = {} @@ -672,11 +557,9 @@ class BuzzAdapter(BasePlatformAdapter): # channel_id -> raw ``channels list`` entry; drives DM-vs-channel classification. self._channel_meta: Dict[str, dict] = {} self._user_names: Dict[str, str] = {} - self._member_cache: Dict[str, Tuple[float, List[str]]] = {} + self._member_cache: Dict[str, Tuple[float, List[str]]] = {} # (monotonic, pubkeys) self._profile_name_cache: Dict[str, Tuple[float, str]] = {} - self._poll_count = 0 - # inbound event_id -> thread root id (None when top-level), so send() joins - # the user's thread instead of nesting a new one under every reply. + # inbound event_id -> thread root (None when top-level), so send() joins the user's thread instead of nesting. self._thread_roots: "OrderedDict[str, Optional[str]]" = OrderedDict() @property @@ -694,10 +577,13 @@ class BuzzAdapter(BasePlatformAdapter): if not self._private_key: self._private_key = _resolve_private_key(self._extra) self._auth_tag = _resolve_auth_tag(self._extra) - return await _exec_buzz( - self.cli_path, args, relay_url=self.relay_url, private_key=self._private_key, - auth_tag=self._auth_tag, input_text=input_text, - ) + return await _exec_buzz(self.cli_path, args, relay_url=self.relay_url, private_key=self._private_key, + auth_tag=self._auth_tag, input_text=input_text) + + async def _cli_json(self, args: List[str], default): + """``_run_cli`` -> parsed stdout on rc 0, else *default*.""" + code, out, _err = await self._run_cli(args) + return _json_or(out, default) if code == 0 else default # ── Connection lifecycle ────────────────────────────────────────────── @@ -710,25 +596,19 @@ class BuzzAdapter(BasePlatformAdapter): async def connect(self, *, is_reconnect: bool = False) -> bool: """Verify relay credentials, seed high-water marks, start polling.""" if not self.relay_url: - return self._connect_failed( - "config_missing", "BUZZ_RELAY_URL must be set", "Buzz: relay URL must be configured" - ) + return self._connect_failed("config_missing", "BUZZ_RELAY_URL must be set", "Buzz: relay URL must be configured") if not self.cli_path: return self._connect_failed( - "cli_missing", "buzz CLI binary not found", - "Buzz: buzz CLI binary not found (set BUZZ_CLI_PATH or put 'buzz' on PATH)", + "cli_missing", "buzz CLI binary not found", "Buzz: buzz CLI binary not found (set BUZZ_CLI_PATH or put 'buzz' on PATH)" ) try: self._private_key = _resolve_private_key(self._extra) self._auth_tag = _resolve_auth_tag(self._extra) except ValueError as exc: - return self._connect_failed( - "config_invalid", str(exc), "Buzz: invalid owner-auth configuration — %s", exc - ) + return self._connect_failed("config_invalid", str(exc), "Buzz: invalid owner-auth configuration — %s", exc) if not self._private_key: return self._connect_failed( - "config_missing", "BUZZ_PRIVATE_KEY must be set", - "Buzz: no private key (set BUZZ_PRIVATE_KEY or a credentials file)", + "config_missing", "BUZZ_PRIVATE_KEY must be set", "Buzz: no private key (set BUZZ_PRIVATE_KEY or a credentials file)" ) # Own identity: pubkey drives self-echo suppression, display name drives mention gating. code, out, err = await self._run_cli(["users", "get"]) @@ -742,22 +622,19 @@ class BuzzAdapter(BasePlatformAdapter): if not profiles or not profiles[0].get("pubkey"): return self._connect_failed( "connect_failed", "buzz users get returned no profile", - "Buzz: 'users get' returned no profile — is the key a member of this community?", - retryable=True, + "Buzz: 'users get' returned no profile — is the key a member of this community?", retryable=True, ) self._self_pubkey = str(profiles[0]["pubkey"]).lower() self._display_name = str(profiles[0].get("display_name") or "").strip() self._self_npub = hex_to_npub(self._self_pubkey) or "" - # Two profiles must not drive the same identity on one relay (duplicate - # replies, split de-dupe state). + # Two profiles must not drive the same identity on one relay (duplicate replies, split de-dupe state). try: from gateway.status import acquire_scoped_lock lock_key = f"{self.relay_url}:{self._self_pubkey}" if not acquire_scoped_lock("buzz", lock_key): return self._connect_failed( "lock_conflict", "Buzz identity in use by another profile", - "Buzz: identity %s… on %s already in use by another profile", - self._self_pubkey[:8], self.relay_url, + "Buzz: identity %s… on %s already in use by another profile", self._self_pubkey[:8], self.relay_url, ) self._lock_key = lock_key except ImportError: @@ -771,18 +648,16 @@ class BuzzAdapter(BasePlatformAdapter): ) self._channel_names = {} for ch in _parse_json_list(out): - if ch.get("channel_id"): - self._channel_names[str(ch["channel_id"])] = str(ch.get("name") or ch["channel_id"]) - self._channel_meta[str(ch["channel_id"])] = ch + if ch_id := ch.get("channel_id"): + self._channel_names[str(ch_id)] = str(ch.get("name") or ch_id) + self._channel_meta[str(ch_id)] = ch watch = self.channels or list(self._channel_names) if not watch: return self._connect_failed( - "config_missing", "no Buzz channels to watch", - "Buzz: no channels to watch (configure BUZZ_CHANNELS or join a channel)", + "config_missing", "no Buzz channels to watch", "Buzz: no channels to watch (configure BUZZ_CHANNELS or join a channel)" ) - # Seed high-water marks so a (re)start never replays history — except - # where a persisted cursor is restored so events that landed while we - # were down still dispatch. Relay-rejected channels are skipped. + # Seed high-water marks so a (re)start never replays history — except where a restored cursor lets + # events that landed while down still dispatch. self._load_cursors() for channel_id in watch: if channel_id in self._restricted_channels: @@ -791,16 +666,14 @@ class BuzzAdapter(BasePlatformAdapter): await self._seed_channel(channel_id, chat_type="group") await self._discover_dms(seed=True) self._save_cursors() - # Prefer the NIP-42 WebSocket push; fall back to CLI polling when it can't - # be established (transport="auto") or the user pinned transport="poll". + # Prefer the NIP-42 WebSocket push; poll when it can't be established (auto) or the user pinned "poll". transport_used = "poll" if self.transport in ("auto", "websocket"): if await self._start_websocket(): transport_used = "websocket" elif self.transport == "websocket": self._set_fatal_error( - "ws_auth_failed", "Buzz WebSocket transport did not authenticate (transport=websocket)", - retryable=True, + "ws_auth_failed", "Buzz WebSocket transport did not authenticate (transport=websocket)", retryable=True ) await self.disconnect() return False @@ -845,64 +718,46 @@ class BuzzAdapter(BasePlatformAdapter): # ── Sending ─────────────────────────────────────────────────────────── async def _channel_member_pubkeys(self, chat_id: str) -> List[str]: - """Candidate pubkeys for mention resolution: ``channels members`` (a non-member - ``--mention`` makes the CLI reject the publish), else recent traffic which can - over-approximate — ``send()`` recovers by retrying without mentions.""" + """Mention candidates: ``channels members`` (a non-member ``--mention`` is rejected by the CLI), else + recent traffic, which over-approximates — ``send()`` recovers by retrying without mentions.""" cache = self._member_cache - cached = cache.get(str(chat_id)) - if cached is not None and (time.monotonic() - cached[0]) < _MEMBER_CACHE_TTL: - return list(cached[1]) - code, out, _err = await self._run_cli(["channels", "members", "--channel", str(chat_id)]) - if code == 0: - pks: List[str] = [] - for row in _json_or(out, []): - _add_pubkey(pks, row.get("pubkey") if isinstance(row, dict) else row) - if pks: - cache[str(chat_id)] = (time.monotonic(), list(pks)) - return pks + if (cached := _ttl_get(cache, str(chat_id), _MEMBER_CACHE_TTL)) is not None: + return list(cached) + pks: List[str] = [] + for row in await self._cli_json(["channels", "members", "--channel", str(chat_id)], []): + _add_pubkey(pks, row.get("pubkey") if isinstance(row, dict) else row) + if pks: + cache[str(chat_id)] = (time.monotonic(), list(pks)) + return pks candidates: List[str] = [] - code, out, _err = await self._run_cli(["messages", "get", "--channel", str(chat_id), "--limit", "50"]) - if code == 0: - try: - for msg in json.loads(out or "[]"): - _add_pubkey(candidates, msg.get("pubkey")) - for t in msg.get("tags") or []: - if isinstance(t, list) and len(t) > 1 and t[0] == "p": - _add_pubkey(candidates, str(t[1])) - except ValueError: - pass + for msg in await self._cli_json(["messages", "get", "--channel", str(chat_id), "--limit", "50"], []): + _add_pubkey(candidates, msg.get("pubkey")) + for t in msg.get("tags") or []: + if isinstance(t, list) and len(t) > 1 and t[0] == "p": + _add_pubkey(candidates, str(t[1])) if candidates: cache[str(chat_id)] = (time.monotonic(), list(candidates)) return candidates async def _profile_display_name(self, pubkey: str) -> str: - """Display name for *pubkey* via ``users get --pubkey`` (bare ``users get`` may - return only our own profile), cached for ``_PROFILE_NAME_TTL`` so renames resolve.""" + """Display name via ``users get --pubkey`` (bare ``users get`` may return only our own profile), TTL-cached.""" cache = self._profile_name_cache - cached = cache.get(pubkey) - if cached is not None and (time.monotonic() - cached[0]) < _PROFILE_NAME_TTL: - return cached[1] + if (cached := _ttl_get(cache, pubkey, _PROFILE_NAME_TTL)) is not None: + return cached name = "" - code, out, _err = await self._run_cli(["users", "get", "--pubkey", pubkey]) - if code == 0: - profiles = _json_or(out, []) - if profiles and isinstance(profiles[0], dict): - p0 = profiles[0] - name = str(p0.get("display_name") or p0.get("name") or "").strip() - if not name and p0.get("content"): - try: - prof = json.loads(p0["content"]) - name = str(prof.get("display_name") or prof.get("name") or "").strip() - except ValueError: - pass + profiles = await self._cli_json(["users", "get", "--pubkey", pubkey], []) + if profiles and isinstance(p0 := profiles[0], dict): + name = str(p0.get("display_name") or p0.get("name") or "").strip() + if not name and p0.get("content"): + prof = _json_or(p0["content"], {}) + name = str(prof.get("display_name") or prof.get("name") or "").strip() cache[pubkey] = (time.monotonic(), name) return name async def _mention_pubkeys_for(self, chat_id: str, content: str) -> List[str]: - """Resolve ``@Name`` tokens to member pubkeys (explicit ``--mention`` keeps genuine - mentions notifying while unresolvable @-prose becomes plain text). Token semantics, - Unicode word-bounded: "email@Fizz", "@@Fizz", "@FizzBuzz" do NOT wake Fizz; "@Riley!!" - does. Longer names match first and consume their span; duplicates tag nobody.""" + """Resolve ``@Name`` tokens to member pubkeys so genuine mentions notify while @-prose stays text. + Word-bounded ("email@Fizz", "@@Fizz", "@FizzBuzz" don't wake Fizz; "@Riley!!" does); longer names + match first and consume their span; ambiguous names tag nobody.""" if "@" not in content: return [] by_name: Dict[str, List[str]] = {} @@ -915,9 +770,9 @@ class BuzzAdapter(BasePlatformAdapter): if not name: continue key = name.lower() - by_name.setdefault(key, []) - if pk not in by_name[key]: - by_name[key].append(pk) + pks = by_name.setdefault(key, []) + if pk not in pks: + pks.append(pk) display.setdefault(key, name) found: List[str] = [] text = content @@ -927,16 +782,14 @@ class BuzzAdapter(BasePlatformAdapter): pks = by_name[key] if len(pks) == 1 and pks[0] not in found: found.append(pks[0]) - # Consume the span either way so a shorter prefix name can't - # double-match and an ambiguous name stays presentation-only. + # Consume the span either way so a shorter prefix name can't double-match + # and an ambiguous name stays presentation-only. text = pattern.sub("\x00", text) return found async def _run_message_send(self, args: List[str], content: str, mention_pubkeys: Optional[List[str]] = None): - """Send with bounded mention-failure recovery (each rung at most once): - explicit ``--mention`` pubkeys; on "not channel members" retry without them; - on an unresolvable presentation ``@token`` escape it and retry; finally - ``--mention `` (any explicit identity downgrades @names to plain text).""" + """Send with bounded recovery (each rung once): explicit ``--mention``s; on "not channel members" retry + without; escape an unresolvable ``@token`` and retry; finally ``--mention `` (downgrades @names to text).""" mention_args: List[str] = [] for pk in mention_pubkeys or []: mention_args += ["--mention", pk] @@ -953,22 +806,14 @@ class BuzzAdapter(BasePlatformAdapter): code, out, err = await self._run_cli(args, input_text=escaped) if code == 0: return code, out, err - if ( - code != 0 - and "does not match a current channel member" in (err or "") - and getattr(self, "_self_pubkey", None) - ): + if "does not match a current channel member" in (err or "") and getattr(self, "_self_pubkey", None): code, out, err = await self._run_cli(args + ["--mention", self._self_pubkey], input_text=content) return code, out, err - async def send( - self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None - ) -> SendResult: + async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult: if not content: return SendResult(success=False, error="Empty message") - # Anchor preference: metadata.thread_id (Slack-style), then - # metadata.reply_to_message_id (stream consumer / progress sends), then - # the explicit reply_to; without any, interim commentary posts flat. + # Anchor: metadata.thread_id, then metadata.reply_to_message_id (stream/progress sends), then reply_to. meta = metadata or {} args = ["messages", "send", "--channel", str(chat_id), "--content", "-"] args += self._reply_args(meta.get("thread_id") or meta.get("reply_to_message_id") or reply_to) @@ -976,28 +821,19 @@ class BuzzAdapter(BasePlatformAdapter): code, out, err = await self._run_message_send(args, content, mention_pubkeys) result = self._send_result(chat_id, code, out, err) if result.success: - # Record event_meta so a thread reply to this send matches even if - # the WS/poll echo never arrives. + # Record event_meta so a thread reply to this send matches even if the echo never arrives. self._remember_event_meta(str(chat_id), result.message_id, self._self_pubkey, content) return result def _reply_args(self, anchor: Optional[str]) -> List[str]: """``--reply-to`` CLI args for *anchor*, honoring ``reply_to_mode``.""" reply_target = self._resolve_reply_anchor(anchor) - if reply_target and self._reply_to_mode != "off": - return ["--reply-to", str(reply_target)] - return [] + return ["--reply-to", str(reply_target)] if reply_target and self._reply_to_mode != "off" else [] - def _send_result( - self, chat_id: str, code: int, out: str, err: str, *, redact_path: Optional[Path] = None - ) -> SendResult: - """``messages send`` CLI result -> SendResult; marks the verified id seen - (belt-and-braces echo suppression on top of the inbound self-pubkey skip).""" + def _send_result(self, chat_id: str, code: int, out: str, err: str, *, redact_path: Optional[Path] = None) -> SendResult: + """``messages send`` result -> SendResult; marks the verified id seen (echo suppression belt-and-braces).""" if code != 0: - return SendResult( - success=False, error=_cli_error_message(err, code, redact_path=redact_path), - retryable=code == 2, - ) + return SendResult(success=False, error=_cli_error_message(err, code, redact_path=redact_path), retryable=code == 2) event_id, receipt_error = _parse_send_receipt(out) if receipt_error: return SendResult(success=False, error=receipt_error) @@ -1007,7 +843,6 @@ class BuzzAdapter(BasePlatformAdapter): async def send_typing(self, chat_id: str, metadata=None) -> None: """Buzz has no typing indicator API — no-op.""" - pass async def send_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool: """Best-effort reaction via buzz-cli; failures are logged, never raised.""" @@ -1016,19 +851,12 @@ class BuzzAdapter(BasePlatformAdapter): # The event id IS the dispatched message_id; channel is not a parameter here. code, _out, err = await self._run_cli(["reactions", "add", "--event", str(message_id), "--emoji", emoji]) if code != 0: - logger.debug( - "Buzz: reaction add failed for message %s in %s — %s", - message_id[:12], chat_id, _cli_error_message(err, code), - ) - return False - return True + logger.debug("Buzz: reaction add failed for message %s in %s — %s", message_id[:12], chat_id, _cli_error_message(err, code)) + return code == 0 - async def edit_message( - self, chat_id: str, message_id: str, content: str, *, finalize: bool = False - ) -> SendResult: - """Edit a sent message (enables streamed replies). buzz-cli reports a NEW event - id for the edit but the TARGET stays the original, which the stream consumer - holds across the stream — so return the given id, never the CLI's.""" + async def edit_message(self, chat_id: str, message_id: str, content: str, *, finalize: bool = False) -> SendResult: + """Edit a sent message (streamed replies). The CLI reports a NEW event id but the stream consumer + keeps addressing the original, so return the given id, never the CLI's.""" if not message_id: return SendResult(success=False, error="Buzz edit needs a message id") if not content: @@ -1036,14 +864,11 @@ class BuzzAdapter(BasePlatformAdapter): args = ["messages", "edit", "--event", str(message_id), "--content", "-"] code, out, err = await self._run_cli(args, input_text=content) if code != 0: - return SendResult( - success=False, error=_cli_error_message(err, code), retryable=code == 2, - ) + return SendResult(success=False, error=_cli_error_message(err, code), retryable=code == 2) data = _json_or(out, {}) - edit_event_id = data.get("event_id") - if edit_event_id: + if data.get("event_id"): # The edit is itself a relay event that echoes back on our subscription. - self._mark_seen(str(chat_id), str(edit_event_id)) + self._mark_seen(str(chat_id), str(data["event_id"])) return SendResult(success=bool(data.get("accepted", True)), message_id=str(message_id), raw_response=data) async def delete_message(self, chat_id: str, message_id: str) -> bool: @@ -1057,32 +882,26 @@ class BuzzAdapter(BasePlatformAdapter): data = json.loads(out or "{}") except ValueError: return True - event_id = data.get("event_id") - if event_id: - self._mark_seen(str(chat_id), str(event_id)) + if data.get("event_id"): + self._mark_seen(str(chat_id), str(data["event_id"])) return bool(data.get("accepted", True)) 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: + self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None) -> SendResult: """Send an image: local files upload via --file, URLs go as a link.""" local = Path(image_url).expanduser() if not image_url.startswith(("http://", "https://")) else None if local is not None and local.is_file(): - return await self._send_file_attachment( - chat_id, local, caption=caption, reply_to=reply_to, metadata=metadata, probe=False - ) + return await self._send_file_attachment(chat_id, local, caption=caption, reply_to=reply_to, metadata=metadata, probe=False) # Markdown renders in Buzz, so a URL arrives as a clickable image link. text = f"{caption}\n{image_url}" if caption else image_url return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata) async def _send_file_attachment( - self, chat_id: str, file_path: Path, *, caption: Optional[str] = None, - reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, - probe: bool = True, + self, chat_id: str, file_path: Path, *, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, probe: bool = True, ) -> SendResult: - """Upload a local file as a native attachment. ``probe=False`` skips the - existence re-check when the caller verified it (a second probe could race).""" + """Upload a local file as a native attachment; ``probe=False`` when the caller already verified it (a re-probe could race).""" local = Path(file_path).expanduser() if probe and not local.is_file(): # Never leak host filesystem paths into chat-visible errors. @@ -1093,65 +912,42 @@ class BuzzAdapter(BasePlatformAdapter): return self._send_result(chat_id, code, out, err, redact_path=local) 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, **kwargs, - ) -> SendResult: - """Upload a local image via ``--file``; missing paths keep the Base fallback - so host filesystem paths are never echoed into chat.""" + self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult: + """Upload a local image via ``--file``; missing paths keep the Base fallback so host paths never reach chat.""" local = Path(image_path).expanduser() if local.is_file(): - return await self._send_file_attachment( - chat_id, local, caption=caption, reply_to=reply_to, metadata=metadata, probe=False - ) - return await super().send_image_file( - chat_id=chat_id, image_path=image_path, caption=caption, reply_to=reply_to, metadata=metadata, **kwargs - ) + return await self._send_file_attachment(chat_id, local, caption=caption, reply_to=reply_to, metadata=metadata, probe=False) + return await super().send_image_file(chat_id=chat_id, image_path=image_path, caption=caption, reply_to=reply_to, metadata=metadata, **kwargs) 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, **kwargs, - ) -> SendResult: + 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, **kwargs) -> SendResult: """Upload a local document through Buzz's native ``--file`` path.""" - return await self._send_file_attachment( - chat_id, Path(file_path), caption=caption, reply_to=reply_to, metadata=metadata - ) + return await self._send_file_attachment(chat_id, Path(file_path), caption=caption, reply_to=reply_to, metadata=metadata) 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, **kwargs, - ) -> SendResult: + self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult: """Upload a local video through Buzz's native ``--file`` path.""" - return await self._send_file_attachment( - chat_id, Path(video_path), caption=caption, reply_to=reply_to, metadata=metadata - ) + return await self._send_file_attachment(chat_id, Path(video_path), caption=caption, reply_to=reply_to, metadata=metadata) 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, **kwargs, - ) -> SendResult: + self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult: """Upload a local audio file through Buzz's native ``--file`` path.""" - return await self._send_file_attachment( - chat_id, Path(audio_path), caption=caption, reply_to=reply_to, metadata=metadata - ) + return await self._send_file_attachment(chat_id, Path(audio_path), caption=caption, reply_to=reply_to, metadata=metadata) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: chat_id = str(chat_id) state = self._channel_state.get(chat_id) - chat_type = state["chat_type"] if state else "group" - name = self._channel_names.get(chat_id) - if name is None and self.cli_path: - code, out, _err = await self._run_cli(["channels", "get", "--channel", chat_id]) - if code == 0: - data = _json_or(out, {}) - if isinstance(data, dict) and data.get("name"): - name = str(data["name"]) - self._channel_names[chat_id] = name - return {"name": name or chat_id, "type": chat_type, "chat_id": chat_id} + if (name := self._channel_names.get(chat_id)) is None and self.cli_path: + data = await self._cli_json(["channels", "get", "--channel", chat_id], {}) + if isinstance(data, dict) and data.get("name"): + name = self._channel_names[chat_id] = str(data["name"]) + return {"name": name or chat_id, "type": state["chat_type"] if state else "group", "chat_id": chat_id} - # ── Inbound: WebSocket transport (NIP-42 authenticated) ────────────── - # Dispatches through the same _handle_event() as the poll loop so de-dupe, - # mention gating, DM latching and the allow-list behave identically. + # ── Inbound: WebSocket transport (NIP-42) — same _handle_event() as the poll loop ────── def _websocket_url(self) -> str: parsed = urlsplit(self.relay_url.strip()) @@ -1173,38 +969,30 @@ class BuzzAdapter(BasePlatformAdapter): self._ws_task = asyncio.create_task(self._websocket_loop()) try: await asyncio.wait_for(self._ws_ready.wait(), timeout=_WS_AUTH_TIMEOUT + 5) + return True except (asyncio.TimeoutError, TimeoutError): logger.warning("Buzz: WebSocket did not authenticate in time") await self._cancel_task(self._ws_task) self._ws_task = None return False - return True async def _authenticate_websocket(self, websocket) -> None: - """NIP-42: await the AUTH challenge, answer with a signed kind-22242 event - (plus the optional NIP-OA owner-attestation tag), await the OK.""" - raw = await asyncio.wait_for(websocket.recv(), timeout=_WS_AUTH_TIMEOUT) - message = json.loads(raw) + """NIP-42: await the AUTH challenge, answer with a signed kind-22242 event (+ optional NIP-OA tag), await OK.""" + message = json.loads(await asyncio.wait_for(websocket.recv(), timeout=_WS_AUTH_TIMEOUT)) if not isinstance(message, list) or len(message) < 2 or message[0] != "AUTH": raise ConnectionError("Buzz relay did not send a NIP-42 AUTH challenge") - # BUZZ_AUTH_TAG is per-identity, so it resolves through the profile secret - # scope (a scoped profile without one fails closed to "" rather than - # borrowing the default profile's env tag). Resolved lazily here too so a - # re-auth on a bare adapter (no connect()) stays scope-correct. + # BUZZ_AUTH_TAG is per-identity: a scoped profile without one fails closed to "" rather than borrowing + # the default profile's env tag. Resolved lazily so a re-auth on a bare adapter stays scope-correct. auth_tag = getattr(self, "_auth_tag", "") or "" if not auth_tag: try: auth_tag = _resolve_auth_tag(getattr(self, "_extra", None)) except ValueError: auth_tag = "" - event = _nostr_auth.build_auth_event( - private_key=self._private_key, challenge=str(message[1]), - relay_url=self._websocket_url(), auth_tag_json=auth_tag, - ) + event = _nostr_auth.build_auth_event(private_key=self._private_key, challenge=str(message[1]), relay_url=self._websocket_url(), auth_tag_json=auth_tag) await websocket.send(json.dumps(["AUTH", event], separators=(",", ":"))) while True: - raw = await asyncio.wait_for(websocket.recv(), timeout=_WS_AUTH_TIMEOUT) - response = json.loads(raw) + response = json.loads(await asyncio.wait_for(websocket.recv(), timeout=_WS_AUTH_TIMEOUT)) if not isinstance(response, list) or not response: continue if response[0] == "OK" and len(response) >= 4 and response[1] == event["id"]: @@ -1212,8 +1000,7 @@ class BuzzAdapter(BasePlatformAdapter): return raise ConnectionError(f"Buzz WebSocket AUTH rejected: {response[3]}") if response[0] in ("NOTICE", "CLOSED"): - detail = response[-1] if len(response) > 1 else "authentication failed" - raise ConnectionError(f"Buzz WebSocket AUTH failed: {detail}") + raise ConnectionError(f"Buzz WebSocket AUTH failed: {response[-1] if len(response) > 1 else 'authentication failed'}") @staticmethod async def _send_req(websocket, subscription_id: str, request_filter: dict) -> None: @@ -1227,29 +1014,22 @@ class BuzzAdapter(BasePlatformAdapter): # Resume from the high-water mark (same-second overlap de-duped by id). request_filter["since"] = max(last_ts - 1, 0) else: - # A conversation adopted mid-run with no high-water mark is fresh: its - # history IS the conversation, so subscribe from the beginning or the - # message that *created* it (created_at fractionally before this REQ) - # is silently dropped. Seeded channels always have last_ts != 0. + # A conversation adopted mid-run with no high-water mark is fresh: its history IS the conversation, + # so subscribe from the start or the message that *created* it is dropped. Seeded channels have last_ts != 0. request_filter["limit"] = _FETCH_LIMIT await self._send_req(websocket, subscription_id, request_filter) async def _subscribe_websocket(self, websocket) -> Dict[str, Optional[str]]: - """Subscribe to every watched conversation plus membership events - (kind 44100 p-tagged to us) for live DM discovery.""" + """Subscribe to every watched conversation plus membership events (kind 44100 p-tagged to us) for DM discovery.""" subscriptions: Dict[str, Optional[str]] = {} for index, channel_id in enumerate(list(self._channel_state)): if channel_id in self._restricted_channels: continue - subscription_id = f"hermes-buzz-{index}" - subscriptions[subscription_id] = channel_id - await self._send_channel_subscription(websocket, subscription_id, channel_id) + subscriptions[f"hermes-buzz-{index}"] = channel_id + await self._send_channel_subscription(websocket, f"hermes-buzz-{index}", channel_id) if self._self_pubkey: - await self._send_req(websocket, _WS_MEMBERSHIP_SUB_ID, { - "kinds": [_WS_MEMBERSHIP_KIND], - "#p": [self._self_pubkey], - "since": max(self._membership_since - 1, 0), - }) + membership = {"kinds": [_WS_MEMBERSHIP_KIND], "#p": [self._self_pubkey], "since": max(self._membership_since - 1, 0)} + await self._send_req(websocket, _WS_MEMBERSHIP_SUB_ID, membership) subscriptions[_WS_MEMBERSHIP_SUB_ID] = None return subscriptions @@ -1265,16 +1045,9 @@ class BuzzAdapter(BasePlatformAdapter): await self._send_channel_subscription(websocket, subscription_id, channel_id) logger.info("Buzz: subscribed to new conversation %s", channel_id) - async def _handle_membership_event(self, websocket, subscriptions: Dict[str, Optional[str]], event: dict) -> None: - """A membership event p-tagged to us: rediscover and subscribe to new conversations.""" - self._membership_since = max(self._membership_since, int(event.get("created_at") or 0)) - await self._rediscover_and_subscribe(websocket, subscriptions) - async def _ws_discovery_loop(self, websocket, subscriptions: Dict[str, Optional[str]]) -> None: - """Periodic discovery on the poll transport's cadence: relays don't - guarantee a kind-44100 membership event for every conversation that - materializes mid-session. Failures retry next tick; the read loop alone - owns connection health.""" + """Periodic discovery on the poll cadence: relays don't guarantee a kind-44100 event for every new + conversation. Failures retry next tick; the read loop alone owns connection health.""" interval = max(self.poll_interval * _DM_DISCOVERY_EVERY, _MIN_POLL_INTERVAL) while True: await asyncio.sleep(interval) @@ -1286,19 +1059,14 @@ class BuzzAdapter(BasePlatformAdapter): logger.warning("Buzz: WebSocket discovery sweep failed", exc_info=True) async def _websocket_loop(self) -> None: - """Persistent authenticated subscription with bounded reconnect backoff; - on reconnect per-channel `since` filters resume from the last timestamps.""" + """Persistent authenticated subscription with bounded reconnect backoff; `since` filters resume on reconnect.""" import websockets backoff = 1.0 while True: try: async with websockets.connect( - self._websocket_url(), - open_timeout=_WS_AUTH_TIMEOUT, - close_timeout=5, - ping_interval=20, - ping_timeout=20, - max_size=_WS_MAX_MESSAGE_BYTES, + self._websocket_url(), open_timeout=_WS_AUTH_TIMEOUT, close_timeout=5, + ping_interval=20, ping_timeout=20, max_size=_WS_MAX_MESSAGE_BYTES, ) as websocket: await self._authenticate_websocket(websocket) subscriptions = await self._subscribe_websocket(websocket) @@ -1307,24 +1075,7 @@ class BuzzAdapter(BasePlatformAdapter): backoff = 1.0 discovery_task = asyncio.create_task(self._ws_discovery_loop(websocket, subscriptions)) try: - frame_iter = websocket.__aiter__() - while True: - try: - raw = await asyncio.wait_for(frame_iter.__anext__(), timeout=_WS_READ_IDLE_TIMEOUT) - except StopAsyncIteration: - break - except asyncio.TimeoutError: - raise ConnectionError( - f"no WebSocket frame for {_WS_READ_IDLE_TIMEOUT:.0f}s; " - "assuming the connection went silent" - ) from None - try: - message = json.loads(raw) - except (ValueError, TypeError): - logger.warning("Buzz: ignoring malformed WebSocket frame") - continue - if isinstance(message, list) and message: - await self._handle_ws_message(websocket, subscriptions, message) + await self._ws_read_loop(websocket, subscriptions) finally: discovery_task.cancel() try: @@ -1338,42 +1089,51 @@ class BuzzAdapter(BasePlatformAdapter): await asyncio.sleep(backoff) backoff = min(backoff * 2, 30.0) + async def _ws_read_loop(self, websocket, subscriptions: Dict[str, Optional[str]]) -> None: + """Read frames until the relay closes; an idle read raises ConnectionError to reconnect.""" + frame_iter = websocket.__aiter__() + while True: + try: + raw = await asyncio.wait_for(frame_iter.__anext__(), timeout=_WS_READ_IDLE_TIMEOUT) + except StopAsyncIteration: + return + except asyncio.TimeoutError: + raise ConnectionError(f"no WebSocket frame for {_WS_READ_IDLE_TIMEOUT:.0f}s; assuming the connection went silent") from None + try: + message = json.loads(raw) + except (ValueError, TypeError): + logger.warning("Buzz: ignoring malformed WebSocket frame") + continue + if isinstance(message, list) and message: + await self._handle_ws_message(websocket, subscriptions, message) + async def _handle_ws_message(self, websocket, subscriptions: Dict[str, Optional[str]], message: list) -> None: """Route one parsed relay frame (EVENT / CLOSED / NOTICE).""" if message[0] == "EVENT" and len(message) >= 3: - subscription_id = str(message[1]) - event = message[2] + subscription_id, event = str(message[1]), message[2] if not isinstance(event, dict): return if subscription_id == _WS_MEMBERSHIP_SUB_ID: - await self._handle_membership_event(websocket, subscriptions, event) + # A membership event p-tagged to us: rediscover and subscribe to new conversations. + self._membership_since = max(self._membership_since, int(event.get("created_at") or 0)) + await self._rediscover_and_subscribe(websocket, subscriptions) return channel_id = subscriptions.get(subscription_id) state = self._channel_state.get(channel_id or "") if channel_id and state is not None: - before = self._cursor_mark(state) - await self._handle_event(channel_id, state, event) - self._trim_seen(state) - if self._cursor_mark(state) != before: - self._save_cursors() + await self._handle_events(channel_id, state, [event]) elif message[0] == "CLOSED": detail = message[-1] if len(message) > 2 else "subscription closed" sub_id = str(message[1]) if len(message) > 1 else "" closed_channel = subscriptions.get(sub_id) - detail_l = str(detail).lower() - # A membership rejection means the relay will never serve this - # subscription — drop it permanently instead of reconnect-looping. - is_membership_rejection = any(m in detail_l for m in ("restricted", "not a channel member", "auth-required")) - if is_membership_rejection and closed_channel: - logger.warning( - "Buzz: relay permanently rejected channel %s (%s) — removing from watch list", - closed_channel, detail, - ) - self._restricted_channels.add(closed_channel) - del subscriptions[sub_id] - self._channel_state.pop(closed_channel, None) - else: + # A membership rejection is permanent — drop the channel instead of reconnect-looping. + rejected = any(m in str(detail).lower() for m in ("restricted", "not a channel member", "auth-required")) + if not (rejected and closed_channel): raise ConnectionError(str(detail)) + logger.warning("Buzz: relay permanently rejected channel %s (%s) — removing from watch list", closed_channel, detail) + self._restricted_channels.add(closed_channel) + del subscriptions[sub_id] + self._channel_state.pop(closed_channel, None) elif message[0] == "NOTICE": logger.warning("Buzz: relay notice: %s", message[-1]) @@ -1405,23 +1165,18 @@ class BuzzAdapter(BasePlatformAdapter): return get_hermes_home() / _CURSOR_STATE_SUBDIR / _CURSOR_STATE_FILENAME def _load_cursors(self) -> None: - """Read persisted cursors. A file from another identity/relay is ignored (channel - ids would collide); read/parse failures degrade to seed-from-history.""" + """Read persisted cursors; another identity/relay's file is ignored (ids collide), failures seed from history.""" self._restored_cursors = {} try: - path = self._cursor_path() - if not path.exists(): + if not (path := self._cursor_path()).exists(): return data = json.loads(path.read_text(encoding="utf-8")) except Exception: logger.debug("Buzz: could not read channel cursors", exc_info=True) return - if not isinstance(data, dict) or data.get("identity") != self._self_pubkey: + if not isinstance(data, dict) or data.get("identity") != self._self_pubkey or data.get("relay") != self.relay_url: return - if data.get("relay") != self.relay_url: - return - channels = data.get("channels") - if not isinstance(channels, dict): + if not isinstance(channels := data.get("channels"), dict): return for channel_id, entry in channels.items(): if not isinstance(entry, dict): @@ -1432,16 +1187,13 @@ class BuzzAdapter(BasePlatformAdapter): continue raw_seen = entry.get("seen") seen = [str(event_id) for event_id in raw_seen][-_SEEN_CAP:] if isinstance(raw_seen, list) else [] - self._restored_cursors[str(channel_id)] = { - "chat_type": str(entry.get("chat_type") or ""), "last_ts": last_ts, "seen": seen, - } + self._restored_cursors[str(channel_id)] = {"chat_type": str(entry.get("chat_type") or ""), "last_ts": last_ts, "seen": seen} def _save_cursors(self) -> None: """Persist every watched channel's cursor. Never raises.""" channels = { channel_id: { - "chat_type": state.get("chat_type") or "group", - "last_ts": int(state.get("last_ts") or 0), + "chat_type": state.get("chat_type") or "group", "last_ts": int(state.get("last_ts") or 0), "seen": list(state.get("seen") or ()), } for channel_id, state in self._channel_state.items() @@ -1460,9 +1212,7 @@ class BuzzAdapter(BasePlatformAdapter): return int(state.get("last_ts") or 0), len(seen), (next(reversed(seen), None) if seen else None) def _restore_channel_state(self, channel_id: str, chat_type: str) -> bool: - """Install a persisted cursor for *channel_id*; True when one existed. This - closes the restart gap: seeding would mark everything that arrived while - the gateway was down as already seen.""" + """Install a persisted cursor (True when one existed): seeding would mark downtime arrivals as seen.""" restored = self._restored_cursors.pop(channel_id, None) if restored is None: return False @@ -1476,8 +1226,7 @@ class BuzzAdapter(BasePlatformAdapter): """Initialize a channel's high-water mark from its newest events.""" if self._restore_channel_state(channel_id, chat_type): return - state = self._new_channel_state(chat_type) - self._channel_state[channel_id] = state + state = self._channel_state[channel_id] = self._new_channel_state(chat_type) code, out, err = await self._run_cli(["messages", "get", "--channel", channel_id, "--limit", str(_FETCH_LIMIT)]) if code != 0: logger.warning("Buzz: could not seed channel %s — %s", channel_id, _cli_error_message(err, code)) @@ -1485,37 +1234,29 @@ class BuzzAdapter(BasePlatformAdapter): state["last_ts"] = int(time.time()) return for event in _parse_json_list(out): - event_id = event.get("id") - created_at = int(event.get("created_at") or 0) - if event_id: + if event_id := event.get("id"): state["seen"][str(event_id)] = None - state["last_ts"] = max(state["last_ts"], created_at) - # History is never dispatched but still feeds event_meta (post-restart - # thread replies to our earlier messages must match) and latches a DM - # that leaked in via ``channels list`` before the first poll. + state["last_ts"] = max(state["last_ts"], int(event.get("created_at") or 0)) + # History is never dispatched but feeds event_meta (post-restart replies to us must match) and latches DMs. self._remember_event(state, event) self._maybe_latch_dm(channel_id, state, event) self._trim_seen(state) async def _discover_dms(self, *, seed: bool) -> None: - """Watch DM conversations: startup ones are seeded, mid-run ones dispatch from - their beginning. ``dms list`` is best-effort (some relays return ``[]``); DMs - also surface in ``channels list`` as name "DM" + empty description, which is - the fallback shape. Named rooms and missing metadata fail closed as groups.""" + """Watch DMs: startup ones are seeded, mid-run ones dispatch from their start. ``dms list`` is best-effort + (some relays return ``[]``); the fallback shape is a ``channels list`` entry named "DM" with empty + description. Named rooms and missing metadata fail closed as groups.""" code, out, _err = await self._run_cli(["dms", "list"]) - if code == 0: - for dm in _parse_json_list(out): - dm_id = str(dm.get("dm_id") or "") - if not dm_id or dm_id in self._channel_state or dm_id in self._restricted_channels: - continue + for dm in _parse_json_list(out) if code == 0 else []: + dm_id = str(dm.get("dm_id") or "") + if dm_id and dm_id not in self._channel_state and dm_id not in self._restricted_channels: await self._adopt_conversation(dm_id, seed) self._channel_names.setdefault(dm_id, "DM") code, out, _err = await self._run_cli(["channels", "list"]) if code != 0: return for ch in _parse_json_list(out): - ch_id = str(ch.get("channel_id") or "") - if not ch_id: + if not (ch_id := str(ch.get("channel_id") or "")): continue self._channel_meta[ch_id] = ch self._channel_names.setdefault(ch_id, str(ch.get("name") or ch_id)) @@ -1527,13 +1268,8 @@ class BuzzAdapter(BasePlatformAdapter): self._channel_state[ch_id]["chat_type"] = "dm" else: await self._adopt_conversation(ch_id, seed) - continue - if ch_id in self._channel_state: - continue - # Watch-all mode adopts channels joined mid-run without a restart. - # Their history predates us, so seed from newest events (only later - # messages dispatch). Explicit watch lists stay authoritative. - if not seed and not self.channels: + elif ch_id not in self._channel_state and not seed and not self.channels: + # Watch-all mode adopts channels joined mid-run (seeded: history predates us); explicit lists stay authoritative. await self._seed_channel(ch_id, chat_type="group") logger.info("Buzz: adopted newly joined channel %s (%s)", ch_id, self._channel_names.get(ch_id, ch_id)) @@ -1556,11 +1292,14 @@ class BuzzAdapter(BasePlatformAdapter): if code != 0: logger.debug("Buzz: poll of channel %s failed — %s", channel_id, _cli_error_message(err, code)) return + await self._handle_events(channel_id, state, _parse_json_list(out)) + + async def _handle_events(self, channel_id: str, state: dict, events: List[dict]) -> None: + """Handle a batch, trim, and persist only when the cursor moved (idle channels don't rewrite the file).""" before = self._cursor_mark(state) - for event in _parse_json_list(out): + for event in events: await self._handle_event(channel_id, state, event) self._trim_seen(state) - # Persist only when the cursor moved so idle channels don't rewrite the file. if self._cursor_mark(state) != before: self._save_cursors() @@ -1571,8 +1310,7 @@ class BuzzAdapter(BasePlatformAdapter): if not isinstance(tags, list): return [], 0 attachments: List[dict] = [] - rejected = 0 - total_declared_bytes = 0 + rejected = total_declared_bytes = 0 for tag in tags: if not isinstance(tag, (list, tuple)) or not tag or tag[0] != "imeta": continue @@ -1580,16 +1318,10 @@ class BuzzAdapter(BasePlatformAdapter): rejected += 1 continue fields: Dict[str, str] = {} - for raw_field in tag[1:]: - if not isinstance(raw_field, str): - continue - key, separator, value = raw_field.partition(" ") + for key, separator, value in (f.partition(" ") for f in tag[1:] if isinstance(f, str)): if separator and key not in fields: fields[key] = value.strip() - url = fields.get("url", "") - digest = fields.get("x", "").lower() - filename = fields.get("filename", "") - mime_type = fields.get("m", "") + url, digest = fields.get("url", ""), fields.get("x", "").lower() try: size = int(fields.get("size", "")) parsed = urlsplit(url) @@ -1599,29 +1331,21 @@ class BuzzAdapter(BasePlatformAdapter): rejected += 1 continue if ( - parsed.scheme != "https" - or not parsed_hostname - or parsed.username - or parsed.password - or parsed.fragment - or not re.fullmatch(r"[0-9a-f]{64}", digest) - or not 0 < size <= _MAX_INBOUND_ATTACHMENT_BYTES + parsed.scheme != "https" or not parsed_hostname or parsed.username or parsed.password or parsed.fragment + or not _HEX64_RE.fullmatch(digest) or not 0 < size <= _MAX_INBOUND_ATTACHMENT_BYTES or total_declared_bytes + size > _MAX_INBOUND_ATTACHMENT_BYTES ): rejected += 1 continue total_declared_bytes += size - attachments.append({ - "url": url, "sha256": digest, "size": size, - "filename": _safe_attachment_filename(filename), "mime_type": mime_type[:255], - }) + attachments.append({"url": url, "sha256": digest, "size": size, + "filename": _safe_attachment_filename(fields.get("filename", "")), "mime_type": fields.get("m", "")[:255]}) return attachments, rejected @staticmethod def _imeta_attachments(event: dict) -> List[dict]: """Return bounded, structurally valid NIP-94 attachment metadata.""" - attachments, _rejected = BuzzAdapter._parse_imeta_attachments(event) - return attachments + return BuzzAdapter._parse_imeta_attachments(event)[0] @staticmethod def _attachment_rejection_note(rejected: int) -> str: @@ -1633,31 +1357,24 @@ class BuzzAdapter(BasePlatformAdapter): url = metadata["url"] try: parsed_url = urlsplit(url) - host = (parsed_url.hostname or "").lower().rstrip(".") - origin = (host, parsed_url.port or 443) + origin = ((parsed_url.hostname or "").lower().rstrip("."), parsed_url.port or 443) except ValueError: - parsed_url = None - origin = ("", 0) + parsed_url, origin = None, ("", 0) if parsed_url is None or parsed_url.scheme != "https" or origin not in self._attachment_origins: - logger.warning( - "Buzz: refusing attachment from untrusted origin %s:%s", origin[0] or "", origin[1] - ) + logger.warning("Buzz: refusing attachment from untrusted origin %s:%s", origin[0] or "", origin[1]) return None import httpx try: timeout = httpx.Timeout(_ATTACHMENT_DOWNLOAD_TIMEOUT) async with ( asyncio.timeout(_ATTACHMENT_DOWNLOAD_TIMEOUT), - httpx.AsyncClient( - follow_redirects=False, timeout=timeout, headers={"Accept-Encoding": "identity"} - ) as client, + httpx.AsyncClient(follow_redirects=False, timeout=timeout, headers={"Accept-Encoding": "identity"}) as client, client.stream("GET", url) as response, ): if response.status_code != 200: logger.warning("Buzz: attachment download returned HTTP %s", response.status_code) return None - content_length = response.headers.get("content-length") - if content_length: + if content_length := response.headers.get("content-length"): try: declared_response_size = int(content_length) except ValueError: @@ -1674,12 +1391,10 @@ class BuzzAdapter(BasePlatformAdapter): except (TimeoutError, httpx.HTTPError, OSError, ValueError) as exc: logger.warning("Buzz: attachment download failed: %s", exc) return None - if len(data) != metadata["size"]: - logger.warning("Buzz: attachment size does not match imeta") - return None - if hashlib.sha256(data).hexdigest() != metadata["sha256"]: - logger.warning("Buzz: attachment SHA-256 does not match imeta") - return None + for bad, what in ((len(data) != metadata["size"], "size"), (hashlib.sha256(data).hexdigest() != metadata["sha256"], "SHA-256")): + if bad: + logger.warning("Buzz: attachment %s does not match imeta", what) + return None try: return cache_media_bytes(bytes(data), filename=metadata["filename"], mime_type=metadata["mime_type"]) except (OSError, ValueError) as exc: @@ -1702,88 +1417,60 @@ class BuzzAdapter(BasePlatformAdapter): pubkey = str(event.get("pubkey") or "").lower() content = event.get("content") attachment_metadata, rejected_attachments = self._parse_imeta_attachments(event) - has_imeta = bool(attachment_metadata or rejected_attachments) - if not pubkey or not isinstance(content, str) or (not content.strip() and not has_imeta): + if not pubkey or not isinstance(content, str) or not (content.strip() or attachment_metadata or rejected_attachments): return - # Feed the event cache before any early return so self-echo and - # concurrent-author traffic can still be reply parents. + # Cache before any early return so self-echo and concurrent-author traffic can still be reply parents. self._remember_event(state, event) if pubkey == self._self_pubkey: return - # Reclassify a leaked DM before gating so its first un-mentioned - # message both latches the conversation and dispatches. + # Reclassify a leaked DM before gating so its first un-mentioned message both latches and dispatches. self._maybe_latch_dm(channel_id, state, event) is_dm = state["chat_type"] == "dm" reply_parent_id = _event_reply_parent_id(event) reply_meta = self._lookup_event_meta(state, reply_parent_id) if reply_parent_id else None reply_to_is_own = bool(reply_meta is not None and reply_meta[0] == self._self_pubkey) - # Shared channels dispatch only when addressed (text @mention OR signed - # recipient p-tag) or when the NIP-10 parent is one of our messages - # (parity with Signal/WhatsApp), unless require_mention is off. DMs always dispatch. + # Channels dispatch only when addressed (@mention or p-tag) or replying to us (Signal/WhatsApp parity), + # unless require_mention is off. DMs always dispatch. if not is_dm and self.require_mention and not self._is_addressed(event) and not reply_to_is_own: return # Adapter-level allow-list (gateway also applies it centrally); empty = no filter. if self._allowed_pubkeys and pubkey not in self._allowed_pubkeys: - explicitly_tagged = any( - isinstance(tag, (list, tuple)) and len(tag) > 1 and tag[0] == "p" - and str(tag[1]).lower() == self._self_pubkey - for tag in event.get("tags") or [] - ) - if pubkey in self._reaction_only_pubkeys and explicitly_tagged and self._is_mentioned(content): + if pubkey in self._reaction_only_pubkeys and _p_tagged(event, self._self_pubkey) and self._is_mentioned(content): await self.send_reaction(channel_id, event_id, "👀") logger.debug("Buzz: ignoring message from unauthorized pubkey %s…", pubkey[:8]) return - # Strip a leading @mention (both chat types — DMs often open with it too) - # so slash commands like "@Chip /whoami" are recognized. + # Strip a leading @mention (DMs often open with one too) so "@Chip /whoami" is recognized as a command. dispatch_text = self._strip_mention(content) - # NIP-10 thread root scopes the session; remember this message's root so - # our reply joins the SAME thread instead of nesting a new one. + # NIP-10 root scopes the session; remember it so our reply joins the SAME thread instead of nesting. thread_id = self._extract_thread_root(event) self._record_thread_root(event_id, event) - # Attachment fetch is a credentialed side effect: only the gateway's - # explicit ``True`` permits it (false/absent/failed all fail closed). The - # message still dispatches so GatewayRunner can apply denial/pairing. + # Attachment fetch spends credentials: only the gateway's explicit ``True`` permits it (else fail closed). + # The message still dispatches so GatewayRunner can apply denial/pairing. chat_type = "dm" if is_dm else "group" - attachment_fetch_allowed = bool(attachment_metadata) and ( - self._is_sender_authorized(pubkey, chat_type, channel_id) is True - ) - attachments = await self._cache_inbound_attachments(attachment_metadata) if attachment_fetch_allowed else [] + fetch_allowed = bool(attachment_metadata) and self._is_sender_authorized(pubkey, chat_type, channel_id) is True + attachments = await self._cache_inbound_attachments(attachment_metadata) if fetch_allowed else [] if rejected_attachments: dispatch_text = f"{dispatch_text}\n{self._attachment_rejection_note(rejected_attachments)}".strip() - if attachment_fetch_allowed and len(attachments) < len(attachment_metadata): - failed = len(attachment_metadata) - len(attachments) - dispatch_text = ( - f"{dispatch_text}\n" - f"[{failed} Buzz attachment(s) could not be downloaded or failed integrity checks.]" - ).strip() + if fetch_allowed and (failed := len(attachment_metadata) - len(attachments)): + dispatch_text = f"{dispatch_text}\n[{failed} Buzz attachment(s) could not be downloaded or failed integrity checks.]".strip() message_type = MessageType.TEXT if attachments: - # Mixed kinds use document semantics so an audio member is not - # mistaken for a voice note and sent through STT. - attachment_kinds = {attachment.kind for attachment in attachments} - message_type = MessageType.DOCUMENT - if len(attachment_kinds) == 1: - message_type = _ATTACHMENT_KIND_TYPES.get(next(iter(attachment_kinds)), MessageType.DOCUMENT) + # Mixed kinds use document semantics so an audio member is not mistaken for a voice note (STT). + kinds = {attachment.kind for attachment in attachments} + message_type = _ATTACHMENT_KIND_TYPES.get(next(iter(kinds)), MessageType.DOCUMENT) if len(kinds) == 1 else MessageType.DOCUMENT await self._dispatch_message( text=dispatch_text, chat_id=channel_id, chat_type=chat_type, user_id=pubkey, user_name=await self._resolve_user_name(pubkey), message_id=event_id, created_at=created_at, thread_id=thread_id, reply_to_message_id=reply_parent_id, - reply_to_text=reply_meta[1] if reply_meta else None, - reply_to_author_id=reply_meta[0] if reply_meta else None, - reply_to_is_own_message=reply_to_is_own, - media_urls=[attachment.path for attachment in attachments], - media_types=[attachment.media_type for attachment in attachments], - message_type=message_type, raw_message=event, + reply_to_text=reply_meta[1] if reply_meta else None, reply_to_author_id=reply_meta[0] if reply_meta else None, + reply_to_is_own_message=reply_to_is_own, media_urls=[attachment.path for attachment in attachments], + media_types=[attachment.media_type for attachment in attachments], message_type=message_type, raw_message=event, ) - # ── DM classification ───────────────────────────────────────────────── - # DMs can leak in through ``channels list`` as chat_type="group" (see - # _discover_dms). In a real channel a p-tag is only an addressing signal - # and must wake the agent without changing the conversation type. + # ── DM classification: DMs leak in via ``channels list`` as "group"; a real channel's p-tag is only addressing ── def _may_reclassify_as_dm(self, channel_id: str) -> bool: - """True when metadata does not rule out a DM: named "DM" with empty description. - Real channels never become DMs from a p-tag; missing metadata fails closed.""" + """True when metadata does not rule out a DM (name "DM", empty description); missing metadata fails closed.""" meta = self._channel_meta.get(channel_id) if meta is None: return False @@ -1791,25 +1478,15 @@ class BuzzAdapter(BasePlatformAdapter): def _p_tagged_to_self(self, event: dict) -> bool: """True when the signed event addresses this identity by pubkey.""" - tags = event.get("tags") - return bool(self._self_pubkey) and isinstance(tags, list) and any( - isinstance(tag, (list, tuple)) - and len(tag) > 1 - and tag[0] == "p" - and str(tag[1]).lower() == self._self_pubkey - for tag in tags - ) + return bool(self._self_pubkey) and _p_tagged(event, self._self_pubkey) def _is_direct_message_event(self, channel_id: str, event: dict) -> bool: - """True for a kind-9 message from another user, p-tagged to us, whose text does - NOT mention us — structural DM addressing, not the artifact of a typed @mention.""" + """Kind-9 from another user, p-tagged to us, text NOT mentioning us — structural DM addressing, not a typed @.""" pubkey = str(event.get("pubkey") or "").lower() content = event.get("content") return bool( - self._self_pubkey and self._may_reclassify_as_dm(channel_id) - and int(event.get("kind") or 0) == _CHAT_KIND - and pubkey and pubkey != self._self_pubkey - and self._p_tagged_to_self(event) + self._self_pubkey and self._may_reclassify_as_dm(channel_id) and int(event.get("kind") or 0) == _CHAT_KIND + and pubkey and pubkey != self._self_pubkey and self._p_tagged_to_self(event) and isinstance(content, str) and not self._is_mentioned(content) ) @@ -1825,7 +1502,7 @@ class BuzzAdapter(BasePlatformAdapter): """True when text explicitly addresses this agent (npub, hex, or @name).""" lowered = content.lower() patterns = [] - if self._self_pubkey and re.fullmatch(r"[0-9a-f]{64}", self._self_pubkey): + if self._self_pubkey and _HEX64_RE.fullmatch(self._self_pubkey): patterns.append(rf"(? str: - """Strip a LEADING @mention of this agent (case-insensitive) so the gateway's - ``is_command()`` sees "/whoami"; mid-sentence mentions are left intact.""" + """Strip a LEADING @mention of this agent so ``is_command()`` sees "/whoami"; mid-sentence mentions stay.""" text = content.strip() + # Display names require '@'; npub/hex identities are unambiguous without it. candidates = [] if self._display_name: candidates.append(rf"@{re.escape(self._display_name)}" + r"(?=$|[\s,;.!?:)\]}])") @@ -1851,26 +1528,16 @@ class BuzzAdapter(BasePlatformAdapter): candidates.append(rf"@?{re.escape(self._self_pubkey)}(?![0-9a-f])") if not candidates: return text - # Display names require '@'; npub/hex identities are unambiguous without it. - pattern = rf"^(?:{'|'.join(candidates)})[\s:,]*" - stripped = re.sub(pattern, "", text, count=1, flags=re.IGNORECASE) - return stripped.strip() + return re.sub(rf"^(?:{'|'.join(candidates)})[\s:,]*", "", text, count=1, flags=re.IGNORECASE).strip() async def _resolve_user_name(self, pubkey: str) -> str: - """Pubkey -> display name, cached (negatively too, so a profile-less pubkey - doesn't re-run ``users get`` every sweep); falls back to the npub prefix.""" - cached = self._user_names.get(pubkey) - if cached is not None: + """Pubkey -> display name, cached (negatively too, so profile-less pubkeys don't re-query); npub prefix fallback.""" + if (cached := self._user_names.get(pubkey)) is not None: return cached - name = "" code, out, _err = await self._run_cli(["users", "get", "--pubkey", pubkey]) - if code == 0: - profiles = _parse_json_list(out) - if profiles: - name = str(profiles[0].get("display_name") or "").strip() - if not name: - name = (hex_to_npub(pubkey) or pubkey)[:16] - self._user_names[pubkey] = name + profiles = _parse_json_list(out) if code == 0 else [] + name = str(profiles[0].get("display_name") or "").strip() if profiles else "" + name = self._user_names[pubkey] = name or (hex_to_npub(pubkey) or pubkey)[:16] return name @staticmethod @@ -1889,33 +1556,24 @@ class BuzzAdapter(BasePlatformAdapter): state["seen"][event_id] = None self._trim_seen(state) - # ── Thread anchoring ────────────────────────────────────────────────── - # NIP-10: a reply carries ["e", , "", "root"] + ["e", , "", "reply"]; - # a thread STARTER carries a single ["e", , "", "reply"]. The gateway - # anchors replies to the triggering message id, which inside a thread would - # nest a new sub-thread under every answer — so remember each inbound - # message's ROOT and reply against that when the trigger was in a thread. + # ── Thread anchoring: NIP-10 replies carry ["e", root, "", "root"] + ["e", parent, "", "reply"]; a thread + # STARTER carries a lone "reply". The gateway anchors on the trigger id, which inside a thread would nest + # a sub-thread under every answer — so remember each inbound message's ROOT and reply against that. _THREAD_ROOT_CACHE = 512 @staticmethod def _extract_thread_root(event: dict) -> Optional[str]: """Return the NIP-10 thread root of ``event``, or None if top-level.""" - tags = event.get("tags") - if not isinstance(tags, list): - return None - root = None - reply = None - for tag in tags: - if not isinstance(tag, (list, tuple)) or len(tag) < 2 or str(tag[0]) != "e": - continue - marker = str(tag[3]).lower() if len(tag) > 3 else "" + root = reply = None + for target, marker in _e_tags(event): + marker = marker.lower() if marker == "root": - root = str(tag[1]) + root = str(target) elif marker == "reply": - reply = str(tag[1]) + reply = str(target) elif not marker and reply is None: - reply = str(tag[1]) # unmarked (deprecated positional) e-tag = parent + reply = str(target) # unmarked (deprecated positional) e-tag = parent # A lone "reply" e-tag started a thread off ; that parent IS the root. return root or reply @@ -1931,31 +1589,26 @@ class BuzzAdapter(BasePlatformAdapter): def _resolve_reply_anchor(self, anchor: Optional[str]) -> Optional[str]: """Thread root when the trigger was inside a thread (reply joins it), else the anchor unchanged.""" - if not anchor: - return anchor - return self._thread_roots.get(str(anchor)) or anchor + return (self._thread_roots.get(str(anchor)) or anchor) if anchor else anchor def _remember_event(self, state: dict, event: dict) -> None: """Record author + content snippet for later NIP-10 parent lookup.""" event_id = str(event.get("id") or "") content = event.get("content") if event_id: - self._store_event_meta( - state, event_id, str(event.get("pubkey") or "").lower(), - content[:_EVENT_META_CONTENT_CAP] if isinstance(content, str) else "", - ) + snippet = content[:_EVENT_META_CONTENT_CAP] if isinstance(content, str) else "" + self._store_event_meta(state, event_id, str(event.get("pubkey") or "").lower(), snippet) def _remember_event_meta(self, channel_id: str, event_id: str, pubkey: str, content: str) -> None: state = self._channel_state.get(channel_id) - if state is not None and event_id: - self._store_event_meta(state, event_id, (pubkey or "").lower(), (content or "")[:_EVENT_META_CONTENT_CAP]) + if state is not None: + self._remember_event(state, {"id": event_id, "pubkey": pubkey, "content": content or ""}) @staticmethod def _store_event_meta(state: dict, event_id: str, pubkey: str, snippet: str) -> None: cache = state.setdefault("event_meta", OrderedDict()) if not isinstance(cache, OrderedDict): - cache = OrderedDict(cache) - state["event_meta"] = cache + cache = state["event_meta"] = OrderedDict(cache) cache[event_id] = (pubkey, snippet) cache.move_to_end(event_id) while len(cache) > _SEEN_CAP: @@ -1963,30 +1616,22 @@ class BuzzAdapter(BasePlatformAdapter): @staticmethod def _lookup_event_meta(state: dict, event_id: Optional[str]) -> Optional[Tuple[str, str]]: - if not event_id: - return None - cache = state.get("event_meta") or {} - entry = cache.get(event_id) + entry = (state.get("event_meta") or {}).get(event_id) if event_id else None if not entry or not isinstance(entry, tuple) or len(entry) < 2: return None return str(entry[0] or ""), str(entry[1] or "") async def _localize_inbound_media( - self, text: str, message_id: str, *, user_id: str = "", - chat_type: Optional[str] = None, chat_id: Optional[str] = None, + self, text: str, message_id: str, *, user_id: str = "", chat_type: Optional[str] = None, chat_id: Optional[str] = None, ) -> Tuple[str, List[str], List[str], MessageType]: - """Authenticate and cache same-relay media references in *text* (each object - independent; failures are skipped). Spends this agent's credentials on a - sender-chosen URL, so it runs only on the gateway's explicit ``True``.""" + """Authenticate and cache same-relay media refs in *text* (failures skipped per object). Spends our + credentials on a sender-chosen URL, so it runs only on the gateway's explicit ``True``.""" urls, replacements = _find_relay_media_refs(text, self.relay_url) if not urls: return text, [], [], MessageType.TEXT if self._is_sender_authorized(user_id, chat_type, chat_id) is not True: - logger.warning( - "Buzz: not localizing %d media reference(s) in message %s — " - "sender %s… is not explicitly authorized", - len(urls), message_id[:12], (user_id or "?")[:8], - ) + logger.warning("Buzz: not localizing %d media reference(s) in message %s — sender %s… is not explicitly authorized", + len(urls), message_id[:12], (user_id or "?")[:8]) return text, [], [], MessageType.TEXT cleaned_text = _replace_media_refs(text, replacements) media_urls: List[str] = [] @@ -1997,8 +1642,7 @@ class BuzzAdapter(BasePlatformAdapter): path_match = _MEDIA_PATH_RE.fullmatch(urlsplit(url).path) if path_match is None: continue - ext = (path_match.group("ext") or ".bin").lower() - label = f"{path_match.group('sha')[:12]}{ext}" + label = f"{path_match.group('sha')[:12]}{(path_match.group('ext') or '.bin').lower()}" try: with tempfile.TemporaryDirectory(prefix="hermes-buzz-media-") as temp_dir: download_path = Path(temp_dir) / f"buzz_{label}" @@ -2008,9 +1652,7 @@ class BuzzAdapter(BasePlatformAdapter): continue validate_inbound_media_size(download_path.stat().st_size, media_type="Buzz media") mime_type = mimetypes.guess_type(download_path.name)[0] or "application/octet-stream" - cached = cache_media_bytes( - download_path.read_bytes(), filename=download_path.name, mime_type=mime_type - ) + cached = cache_media_bytes(download_path.read_bytes(), filename=download_path.name, mime_type=mime_type) except Exception as exc: logger.warning("Buzz: failed to localize inbound media %s (%s)", label, type(exc).__name__) continue @@ -2023,9 +1665,7 @@ class BuzzAdapter(BasePlatformAdapter): if media_urls: logger.info("Buzz: localized %d inbound media attachment(s) for message %s", len(media_urls), message_id[:12]) # Priority order: image > audio > video > any other kind. - message_type = MessageType.TEXT if not media_kinds else next( - (mt for kind, mt in _MEDIA_KIND_PRIORITY if kind in media_kinds), MessageType.DOCUMENT - ) + message_type = MessageType.TEXT if not media_kinds else next((mt for k, mt in _MEDIA_KIND_PRIORITY if k in media_kinds), MessageType.DOCUMENT) if not cleaned_text: cleaned_text = "(attachment)" if media_urls else "(Buzz media attachment unavailable)" return cleaned_text, media_urls, media_types, message_type @@ -2043,12 +1683,9 @@ class BuzzAdapter(BasePlatformAdapter): return media_urls = list(media_urls or []) media_types = list(media_types or []) - # Same-relay URL references in the text are localized in addition to the - # native imeta attachments already cached by the caller (both gated on - # the gateway's explicit-True authorization). - text, localized_urls, localized_types, localized_type = await self._localize_inbound_media( - text, message_id, user_id=user_id, chat_type=chat_type, chat_id=chat_id - ) + # Same-relay URL refs are localized in addition to the caller's imeta attachments (both explicit-True gated). + localized = await self._localize_inbound_media(text, message_id, user_id=user_id, chat_type=chat_type, chat_id=chat_id) + text, localized_urls, localized_types, localized_type = localized for path, mime in zip(localized_urls, localized_types): if path not in media_urls: media_urls.append(path) @@ -2063,9 +1700,8 @@ class BuzzAdapter(BasePlatformAdapter): user_id=user_id, user_name=user_name, thread_id=thread_id, ) event = MessageEvent( - text=text, message_type=message_type, source=source, raw_message=raw_message, - message_id=message_id, media_urls=list(media_urls or []), - media_types=list(media_types or []), media_text_inlined=[False] * len(media_urls or []), + text=text, message_type=message_type, source=source, raw_message=raw_message, message_id=message_id, + media_urls=list(media_urls), media_types=list(media_types), media_text_inlined=[False] * len(media_urls), timestamp=datetime.fromtimestamp(created_at) if created_at else datetime.now(), reply_to_message_id=reply_to_message_id, reply_to_text=reply_to_text, reply_to_author_id=reply_to_author_id, reply_to_is_own_message=reply_to_is_own_message, @@ -2081,8 +1717,7 @@ class BuzzAdapter(BasePlatformAdapter): # ── Plugin registration ────────────────────────────────────────────────────── def _profile_buzz_extra() -> dict: - """``buzz.extra`` from the scoped profile's config.yaml, for ``check_requirements`` - (which has no PlatformConfig). Best-effort: failures yield {} and callers fail closed.""" + """``buzz.extra`` from the scoped profile's config.yaml for ``check_requirements``; failures yield {} (fail closed).""" if not _profile_scoped(): return {} try: @@ -2099,15 +1734,11 @@ def _profile_buzz_extra() -> dict: def check_requirements() -> bool: """Check if Buzz is configured: a relay URL plus a resolvable key.""" if _profile_scoped(): - # Secondary profile: os.environ's BUZZ_* are the default profile's bridge - # output and must not satisfy the gate; an unconfigured profile fails closed. + # Secondary profile: os.environ's BUZZ_* are the default profile's and must not satisfy the gate. extra = _profile_buzz_extra() - relay = str(extra.get("relay_url") or "").strip() - return bool(relay and _resolve_private_key(extra)) + return bool(str(extra.get("relay_url") or "").strip() and _resolve_private_key(extra)) # The gate runs before per-profile scopes install; the relay can be externally managed too. - if not (_get_scoped_secret("BUZZ_RELAY_URL", "") or "").strip(): - return False - return bool(_resolve_private_key()) + return bool((_get_scoped_secret("BUZZ_RELAY_URL", "") or "").strip()) and bool(_resolve_private_key()) def validate_config(config) -> bool: @@ -2130,30 +1761,22 @@ def is_connected(config) -> bool: # (extra key, env var, kind): "str" bridges truthy values as-is, "csv" joins lists, # "flag" lowercases when present, "thread" lowercases and ignores profile scope. _YAML_BRIDGE = ( - ("relay_url", "BUZZ_RELAY_URL", "str"), - ("cli_path", "BUZZ_CLI_PATH", "str"), - ("home_channel", "BUZZ_HOME_CHANNEL", "str"), - ("transport", "BUZZ_TRANSPORT", "str"), - ("channels", "BUZZ_CHANNELS", "csv"), - ("allowed_users", "BUZZ_ALLOWED_USERS", "csv"), - ("reaction_only_users", "BUZZ_REACTION_ONLY_USERS", "csv"), - ("allow_all_users", "BUZZ_ALLOW_ALL_USERS", "flag"), - ("require_mention", "BUZZ_REQUIRE_MENTION", "flag"), - ("reply_in_thread", "BUZZ_REPLY_IN_THREAD", "thread"), + ("relay_url", "BUZZ_RELAY_URL", "str"), ("cli_path", "BUZZ_CLI_PATH", "str"), + ("home_channel", "BUZZ_HOME_CHANNEL", "str"), ("transport", "BUZZ_TRANSPORT", "str"), + ("channels", "BUZZ_CHANNELS", "csv"), ("allowed_users", "BUZZ_ALLOWED_USERS", "csv"), + ("reaction_only_users", "BUZZ_REACTION_ONLY_USERS", "csv"), ("allow_all_users", "BUZZ_ALLOW_ALL_USERS", "flag"), + ("require_mention", "BUZZ_REQUIRE_MENTION", "flag"), ("reply_in_thread", "BUZZ_REPLY_IN_THREAD", "thread"), ("reply_to_mode", "BUZZ_REPLY_TO_MODE", "thread"), ) def _apply_yaml_config(yaml_cfg: dict, buzz_cfg: dict) -> Optional[dict]: - """Bridge ``buzz.extra`` into ``BUZZ_*`` env (``apply_yaml_config_fn``) so a - config.yaml-only setup passes the env-reading gate. Env wins over YAML; - ``BUZZ_PRIVATE_KEY`` is never sourced from config.yaml.""" + """Bridge ``buzz.extra`` into ``BUZZ_*`` env so a config.yaml-only setup passes the env-reading gate. + Env wins over YAML; ``BUZZ_PRIVATE_KEY`` is never sourced from config.yaml.""" extra = buzz_cfg.get("extra", buzz_cfg) or {} if not isinstance(extra, dict): return None - # A secondary multiplex profile must NOT write to the process-global env - # (first-writer-wins would pin its values for every profile); its adapter - # reads PlatformConfig.extra directly instead. + # A secondary profile must NOT write the process-global env (first-writer-wins would pin it for every profile). skip_env_bridge = _profile_scoped() interval = extra.get("poll_interval") if interval is not None and not skip_env_bridge and not os.getenv("BUZZ_POLL_INTERVAL"): @@ -2170,27 +1793,22 @@ def _apply_yaml_config(yaml_cfg: dict, buzz_cfg: dict) -> Optional[dict]: def _env_enablement() -> Optional[dict]: - """Seed ``PlatformConfig.extra`` from env so env-only setups show in gateway - status; ``None`` when not minimally configured.""" + """Seed ``PlatformConfig.extra`` from env so env-only setups show in gateway status; None if unconfigured.""" if _profile_scoped(): - # Process env holds the default profile's BUZZ_*; never fabricate a - # Buzz platform for a secondary profile that did not configure one. + # Process env holds the default profile's BUZZ_*; never fabricate Buzz for a secondary profile. return None relay = os.getenv("BUZZ_RELAY_URL", "").strip() if not relay or not _resolve_private_key(): return None seed: dict = {"relay_url": relay} - channels = os.getenv("BUZZ_CHANNELS", "").strip() - if channels: + if channels := os.getenv("BUZZ_CHANNELS", "").strip(): seed["channels"] = [c.strip() for c in channels.split(",") if c.strip()] - interval = os.getenv("BUZZ_POLL_INTERVAL", "").strip() - if interval: + if interval := os.getenv("BUZZ_POLL_INTERVAL", "").strip(): try: seed["poll_interval"] = float(interval) except ValueError: pass - cli_path = os.getenv("BUZZ_CLI_PATH", "").strip() - if cli_path: + if cli_path := os.getenv("BUZZ_CLI_PATH", "").strip(): seed["cli_path"] = cli_path # Cron delivery target; defaults to the first watched channel. home = os.getenv("BUZZ_HOME_CHANNEL", "").strip() or (seed.get("channels") or [""])[0] @@ -2216,33 +1834,25 @@ async def _standalone_send( return {"error": "Buzz standalone send: BUZZ_RELAY_URL and BUZZ_PRIVATE_KEY must be configured"} if not cli_path: return {"error": "Buzz standalone send: buzz CLI binary not found"} - target = (chat_id or "").strip() or _configured_home_channel(extra) - if not target: + if not (target := (chat_id or "").strip() or _configured_home_channel(extra)): return {"error": "Buzz standalone send: no target channel (set BUZZ_HOME_CHANNEL)"} args = ["messages", "send", "--channel", target, "--content", "-"] # Same reply_to_mode / reply_in_thread gate as the live adapter. if thread_id and _reply_to_mode(pconfig, extra) != "off": args += ["--reply-to", str(thread_id)] for media in media_files or []: - path = media[0] if isinstance(media, (list, tuple)) and media else media - args += ["--file", str(path)] + args += ["--file", str(media[0] if isinstance(media, (list, tuple)) and media else media)] try: - code, out, err = await _exec_buzz( - cli_path, args, relay_url=relay, private_key=private_key, auth_tag=auth_tag, input_text=message - ) - if code != 0: - escaped = _escape_unresolved_presentation_mention(message, err) - if escaped is not None: - logger.info("Buzz: retrying standalone message after unresolved presentation-mention preflight") - # Retry intentionally omits auth_tag (legacy behavior). - code, out, err = await _exec_buzz( - cli_path, args, relay_url=relay, private_key=private_key, input_text=escaped - ) + code, out, err = await _exec_buzz(cli_path, args, relay_url=relay, private_key=private_key, auth_tag=auth_tag, input_text=message) + escaped = _escape_unresolved_presentation_mention(message, err) if code != 0 else None + if escaped is not None: + logger.info("Buzz: retrying standalone message after unresolved presentation-mention preflight") + # Retry intentionally omits auth_tag (legacy behavior). + code, out, err = await _exec_buzz(cli_path, args, relay_url=relay, private_key=private_key, input_text=escaped) except asyncio.CancelledError: raise except OSError as e: - detail = _bounded_cli_message(str(e)) - return {"error": f"Buzz standalone send failed to launch CLI: {detail}"} + return {"error": f"Buzz standalone send failed to launch CLI: {_bounded_cli_message(str(e))}"} if code != 0: return {"error": f"Buzz standalone send failed: {_cli_error_message(err, code)}"} event_id, receipt_error = _parse_send_receipt(out) @@ -2289,8 +1899,7 @@ def interactive_setup() -> None: save_env_value("BUZZ_HOME_CHANNEL", home.strip()) print() print_info("🔒 Access control: restrict who can talk to the agent") - allow_all = prompt_yes_no("Allow all community members to talk to the agent?", False) - if allow_all: + if prompt_yes_no("Allow all community members to talk to the agent?", False): save_env_value("BUZZ_ALLOW_ALL_USERS", "true") save_env_value("BUZZ_ALLOWED_USERS", "") print_warning("⚠️ Open access — anyone in the community can command the agent.") @@ -2306,22 +1915,12 @@ def interactive_setup() -> None: def register(ctx): """Plugin entry point: called by the Hermes plugin system.""" ctx.register_platform( - name="buzz", - label="Buzz", - adapter_factory=lambda cfg: BuzzAdapter(cfg), - check_fn=check_requirements, - validate_config=validate_config, - is_connected=is_connected, - required_env=["BUZZ_RELAY_URL", "BUZZ_PRIVATE_KEY"], + name="buzz", label="Buzz", adapter_factory=lambda cfg: BuzzAdapter(cfg), check_fn=check_requirements, + validate_config=validate_config, is_connected=is_connected, required_env=["BUZZ_RELAY_URL", "BUZZ_PRIVATE_KEY"], install_hint="Requires the buzz CLI binary (https://github.com/block/buzz) on PATH or at BUZZ_CLI_PATH", - setup_fn=interactive_setup, - env_enablement_fn=_env_enablement, - apply_yaml_config_fn=_apply_yaml_config, - cron_deliver_env_var="BUZZ_HOME_CHANNEL", - standalone_sender_fn=_standalone_send, - allowed_users_env="BUZZ_ALLOWED_USERS", - allow_all_env="BUZZ_ALLOW_ALL_USERS", - emoji="🐝", + setup_fn=interactive_setup, env_enablement_fn=_env_enablement, apply_yaml_config_fn=_apply_yaml_config, + cron_deliver_env_var="BUZZ_HOME_CHANNEL", standalone_sender_fn=_standalone_send, + allowed_users_env="BUZZ_ALLOWED_USERS", allow_all_env="BUZZ_ALLOW_ALL_USERS", emoji="🐝", pii_safe=False, # identities are pubkeys, not phone numbers allow_update_command=True, platform_hint=( diff --git a/plugins/platforms/buzz/nostr_auth.py b/plugins/platforms/buzz/nostr_auth.py index 0c6d4af7d4..9b513573f9 100644 --- a/plugins/platforms/buzz/nostr_auth.py +++ b/plugins/platforms/buzz/nostr_auth.py @@ -1,4 +1,4 @@ -"""Dependency-free Nostr signing for Buzz WebSocket authentication.""" +"""Dependency-free Nostr signing (secp256k1 / BIP-340) for Buzz WebSocket authentication.""" from __future__ import annotations @@ -16,17 +16,17 @@ GENERATOR = ( 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, ) BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" +_BECH32_GENERATORS = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3) Point = Optional[tuple[int, int]] def _bech32_polymod(values: list[int]) -> int: - generators = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3) checksum = 1 for value in values: top = checksum >> 25 checksum = ((checksum & 0x1FFFFFF) << 5) ^ value - for index, generator in enumerate(generators): + for index, generator in enumerate(_BECH32_GENERATORS): if (top >> index) & 1: checksum ^= generator return checksum @@ -52,8 +52,7 @@ def _decode_nsec(value: str) -> bytes: raise ValueError("invalid character in nsec") from exc if _bech32_polymod(_bech32_hrp_expand(hrp) + data) != 1: raise ValueError("invalid nsec checksum") - accumulator = 0 - bits = 0 + accumulator = bits = 0 decoded = bytearray() for value5 in data[:-6]: accumulator = (accumulator << 5) | value5 @@ -86,10 +85,8 @@ def decode_private_key(value: str) -> int: def _point_add(left: Point, right: Point) -> Point: - if left is None: - return right - if right is None: - return left + if left is None or right is None: + return right if left is None else left x1, y1 = left x2, y2 = right if x1 == x2: @@ -100,8 +97,7 @@ def _point_add(left: Point, right: Point) -> Point: slope = (y2 - y1) * pow(x2 - x1, FIELD_ORDER - 2, FIELD_ORDER) slope %= FIELD_ORDER x3 = (slope * slope - x1 - x2) % FIELD_ORDER - y3 = (slope * (x1 - x3) - y1) % FIELD_ORDER - return x3, y3 + return x3, (slope * (x1 - x3) - y1) % FIELD_ORDER def _point_multiply(scalar: int, point: Point = GENERATOR) -> Point: @@ -127,9 +123,7 @@ def public_key_hex(private_key: str) -> str: return point[0].to_bytes(32, "big").hex() -def schnorr_sign( - message: bytes, private_key: str, *, auxiliary_randomness: Optional[bytes] = None, -) -> bytes: +def schnorr_sign(message: bytes, private_key: str, *, auxiliary_randomness: Optional[bytes] = None) -> bytes: if len(message) != 32: raise ValueError("BIP-340 signs a 32-byte message") secret = decode_private_key(private_key) @@ -138,7 +132,7 @@ def schnorr_sign( raise ValueError("invalid private key") public_x = public_point[0].to_bytes(32, "big") adjusted_secret = secret if public_point[1] % 2 == 0 else CURVE_ORDER - secret - aux = (auxiliary_randomness if auxiliary_randomness is not None else secrets.token_bytes(32)) + aux = auxiliary_randomness if auxiliary_randomness is not None else secrets.token_bytes(32) if len(aux) != 32: raise ValueError("auxiliary randomness must be 32 bytes") masked = (adjusted_secret ^ int.from_bytes(_tagged_hash("BIP0340/aux", aux), "big")).to_bytes(32, "big") @@ -148,12 +142,22 @@ def schnorr_sign( nonce_point = _point_multiply(nonce) if nonce_point is None: # pragma: no cover raise RuntimeError("BIP-340 produced an invalid nonce point") - adjusted_nonce = nonce if nonce_point[1] % 2 == 0 else CURVE_ORDER - nonce nonce_x = nonce_point[0].to_bytes(32, "big") - challenge_hash = _tagged_hash("BIP0340/challenge", nonce_x + public_x + message) - challenge = int.from_bytes(challenge_hash, "big") % CURVE_ORDER - signature_scalar = (adjusted_nonce + challenge * adjusted_secret) % CURVE_ORDER - return nonce_x + signature_scalar.to_bytes(32, "big") + adjusted_nonce = nonce if nonce_point[1] % 2 == 0 else CURVE_ORDER - nonce + challenge = int.from_bytes(_tagged_hash("BIP0340/challenge", nonce_x + public_x + message), "big") % CURVE_ORDER + return nonce_x + ((adjusted_nonce + challenge * adjusted_secret) % CURVE_ORDER).to_bytes(32, "big") + + +def parse_auth_tag(raw: Any, label: str) -> list[str]: + """Validate a NIP-OA owner-attestation tag (JSON text or list) -> ``["auth", a, b, c]``.""" + if isinstance(raw, str): + try: + raw = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"{label} is not valid JSON") from exc + if not isinstance(raw, list) or len(raw) != 4 or raw[0] != "auth" or not all(isinstance(p, str) for p in raw): + raise ValueError(f"{label} must be a four-string auth tag") + return raw def build_auth_event( @@ -162,25 +166,12 @@ def build_auth_event( ) -> dict[str, Any]: tags: list[list[str]] = [["relay", relay_url], ["challenge", challenge]] if auth_tag_json.strip(): - try: - auth_tag = json.loads(auth_tag_json) - except json.JSONDecodeError as exc: - raise ValueError("BUZZ_AUTH_TAG is not valid JSON") from exc - if not isinstance(auth_tag, list) or len(auth_tag) != 4 or auth_tag[0] != "auth" or not all( - isinstance(part, str) for part in auth_tag - ): - raise ValueError("BUZZ_AUTH_TAG must be a four-string auth tag") - tags.append(auth_tag) + tags.append(parse_auth_tag(auth_tag_json, "BUZZ_AUTH_TAG")) pubkey = public_key_hex(private_key) timestamp = int(time.time()) if created_at is None else int(created_at) serialized = json.dumps([0, pubkey, timestamp, 22242, tags, ""], separators=(",", ":"), ensure_ascii=False).encode() event_id = hashlib.sha256(serialized).digest() return { - "id": event_id.hex(), - "pubkey": pubkey, - "created_at": timestamp, - "kind": 22242, - "tags": tags, - "content": "", + "id": event_id.hex(), "pubkey": pubkey, "created_at": timestamp, "kind": 22242, "tags": tags, "content": "", "sig": schnorr_sign(event_id, private_key, auxiliary_randomness=auxiliary_randomness).hex(), } diff --git a/plugins/platforms/dingtalk/adapter.py b/plugins/platforms/dingtalk/adapter.py index 31a36cb8a5..95c0463efb 100644 --- a/plugins/platforms/dingtalk/adapter.py +++ b/plugins/platforms/dingtalk/adapter.py @@ -1,22 +1,7 @@ -"""DingTalk platform adapter (Stream Mode via dingtalk-stream >=0.20; replies via session -webhook markdown or AI Cards). Requires ``pip install "dingtalk-stream>=0.20" httpx``. - -Configuration in config.yaml: - platforms: - dingtalk: - enabled: true - # Optional group-chat gating (mirrors Slack/Telegram/Discord): - require_mention: true # or DINGTALK_REQUIRE_MENTION env var - # free_response_chats: # conversations that skip require_mention - # - cidABC== - # mention_patterns: # regex wake-words (e.g. Chinese bot names) - # - "^小马" - # allowed_users: # staff_id or sender_id list; "*" = any - # - "manager1234" - extra: - client_id: "your-app-key" # or DINGTALK_CLIENT_ID env var - client_secret: "your-secret" # or DINGTALK_CLIENT_SECRET env var -""" +"""DingTalk platform adapter (Stream Mode via dingtalk-stream >=0.20; replies via session webhook markdown or AI Cards). +Requires ``pip install "dingtalk-stream>=0.20" httpx``. config.yaml ``platforms.dingtalk``: ``enabled``, group gating +(``require_mention``, ``free_response_chats``, ``mention_patterns``, ``allowed_users``/``allowed_chats``) and +``extra.client_id`` / ``extra.client_secret`` (or DINGTALK_CLIENT_ID / DINGTALK_CLIENT_SECRET).""" import asyncio import json @@ -28,9 +13,8 @@ import uuid from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set -# Optional SDKs: catch broad Exception, not just ImportError — their transitive -# cryptography dependency can raise AttributeError on version skew, and a broken -# optional SDK must degrade gracefully instead of killing the plugin import. +# Optional SDKs: catch broad Exception, not just ImportError — their transitive cryptography +# dependency can raise AttributeError on version skew; a broken optional SDK must degrade gracefully. try: import dingtalk_stream from dingtalk_stream import ChatbotMessage @@ -39,9 +23,7 @@ try: DINGTALK_STREAM_AVAILABLE = True except Exception: # noqa: BLE001 DINGTALK_STREAM_AVAILABLE = False - dingtalk_stream = None # type: ignore[assignment] - ChatbotMessage = None # type: ignore[assignment] - CallbackMessage = None # type: ignore[assignment] + dingtalk_stream = ChatbotMessage = CallbackMessage = None # type: ignore[assignment] AckMessage = type("AckMessage", (), {"STATUS_OK": 200, "STATUS_SYSTEM_EXCEPTION": 500}) # type: ignore[assignment] try: @@ -86,51 +68,36 @@ _DINGTALK_WEBHOOK_RE = re.compile(r'^https://(?:api|oapi)\.dingtalk\.com/') _TRUTHY = {"true", "1", "yes", "on"} _EMOTION_ID = "2659900" _EMOTION_BG = "im_bg_1" -# recall? -> (TextEmotion model, Request model, Headers model, robot SDK method) — resolved -# on ``dingtalk_robot_models`` at call time. -_EMOTION_SDK = { - True: ("RobotRecallEmotionRequestTextEmotion", "RobotRecallEmotionRequest", "RobotRecallEmotionHeaders", "robot_recall_emotion_with_options_async"), - False: ("RobotReplyEmotionRequestTextEmotion", "RobotReplyEmotionRequest", "RobotReplyEmotionHeaders", "robot_reply_emotion_with_options_async"), -} +# recall? -> (TextEmotion model, Request model, Headers model, robot SDK method), resolved on ``dingtalk_robot_models`` at call time. +_EMOTION_SDK = {recall: (f"Robot{v}EmotionRequestTextEmotion", f"Robot{v}EmotionRequest", f"Robot{v}EmotionHeaders", f"robot_{v.lower()}_emotion_with_options_async") + for recall, v in ((True, "Recall"), (False, "Reply"))} +_NUMBERED_RE = re.compile(r"^\d+\.\s") +_NO_LOCAL_UPLOAD = "DingTalk session webhook replies do not support local %s. Only markdown/text replies are supported without OpenAPI %s." def _csv_set(raw: Any) -> Set[str]: """Split a list or comma-separated string into a set of stripped, non-empty items.""" - if isinstance(raw, list): - return {str(part).strip() for part in raw if str(part).strip()} - return {part.strip() for part in str(raw).split(",") if part.strip()} + parts = raw if isinstance(raw, list) else str(raw).split(",") + return {str(part).strip() for part in parts if str(part).strip()} def dingtalk_deps_present() -> bool: - """PASSIVE registry ``check_fn``: are dingtalk-stream/httpx importable right now? - - Called from status displays and config loading, so it must never install anything; - ``ensure_dingtalk_deps`` is the ACTIVE installer. Credentials are gated separately. - """ + """PASSIVE registry ``check_fn`` — must never install; credentials are gated separately.""" return DINGTALK_STREAM_AVAILABLE and HTTPX_AVAILABLE def ensure_dingtalk_deps() -> bool: - """ACTIVE deps-only installer (registry ``ensure_deps_fn``); rebinds module globals. - - Deliberately does NOT check credentials — otherwise a platform configured via - ``PlatformConfig.extra`` would pass enablement and then be vetoed on env-var grounds - before ever installing (deadlock). Credentials are gated by ``is_connected``. - """ - global DINGTALK_STREAM_AVAILABLE, dingtalk_stream, ChatbotMessage, CallbackMessage, AckMessage - global HTTPX_AVAILABLE, httpx + """ACTIVE deps-only installer (registry ``ensure_deps_fn``); rebinds module globals. Deliberately does NOT + check credentials: an ``extra``-configured platform would otherwise be vetoed before ever installing (deadlock).""" + global DINGTALK_STREAM_AVAILABLE, dingtalk_stream, ChatbotMessage, CallbackMessage, AckMessage, HTTPX_AVAILABLE, httpx if DINGTALK_STREAM_AVAILABLE and HTTPX_AVAILABLE: return True try: from tools.lazy_deps import ensure as _lazy_ensure _lazy_ensure("platform.dingtalk", prompt=False) - except Exception: - return False - try: - import dingtalk_stream as _ds + import dingtalk_stream as _ds, httpx as _httpx # noqa: E401 from dingtalk_stream import ChatbotMessage as _CM from dingtalk_stream.frames import CallbackMessage as _CBM, AckMessage as _AM - import httpx as _httpx except Exception: return False dingtalk_stream, ChatbotMessage, CallbackMessage, AckMessage, httpx = _ds, _CM, _CBM, _AM, _httpx @@ -138,20 +105,20 @@ def ensure_dingtalk_deps() -> bool: return True +def _credentials(extra: Optional[dict]) -> tuple: + """(client_id, client_secret) from PlatformConfig.extra first, then env / scoped secret.""" + extra = extra or {} + return (extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID", ""), extra.get("client_secret") or _get_scoped_secret("DINGTALK_CLIENT_SECRET", "")) + + def check_dingtalk_requirements() -> bool: """Combined deps (lazy-installed) + credentials check for setup/status callers.""" - if not ensure_dingtalk_deps(): - return False - return bool(os.getenv("DINGTALK_CLIENT_ID") and _get_scoped_secret("DINGTALK_CLIENT_SECRET")) + return ensure_dingtalk_deps() and all(_credentials(None)) class DingTalkAdapter(BasePlatformAdapter): - """DingTalk chatbot adapter using Stream Mode. - - The dingtalk-stream SDK maintains a long-lived WebSocket; incoming messages arrive via a - ChatbotHandler callback. Replies go through the message's session_webhook (httpx) or, - when ``card_template_id`` is configured, streaming AI Cards. - """ + """Stream Mode adapter: the SDK keeps a long-lived WebSocket and messages arrive via a ChatbotHandler + callback; replies go through the message's session_webhook (httpx) or, with ``card_template_id``, AI Cards.""" MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH @@ -160,87 +127,54 @@ class DingTalkAdapter(BasePlatformAdapter): """Edits only exist with AI Cards; the gateway gates streaming cursor/edit on this.""" return bool(self._card_template_id and self._card_sdk) - @property - def REQUIRES_EDIT_FINALIZE(self) -> bool: # noqa: N802 - """AI Cards need an explicit ``finalize=True`` edit to close the streaming indicator.""" - return bool(self._card_template_id and self._card_sdk) + REQUIRES_EDIT_FINALIZE = SUPPORTS_MESSAGE_EDITING # AI Cards need an explicit ``finalize=True`` edit to close the streaming indicator def __init__(self, config: PlatformConfig): super().__init__(config, Platform.DINGTALK) - extra = config.extra or {} - self._client_id: str = extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID", "") - self._client_secret: str = extra.get("client_secret") or _get_scoped_secret("DINGTALK_CLIENT_SECRET", "") - + self._client_id, self._client_secret = _credentials(extra) # Group-chat gating; mention state is the SDK's structured ``is_in_at_list``, not text parsing. self._mention_patterns: List[re.Pattern] = self._compile_mention_patterns() - self._allowed_users: Set[str] = self._load_allowed_users() - - self._stream_client: Any = None - self._stream_task: Optional[asyncio.Task] = None - self._http_client: Optional["httpx.AsyncClient"] = None - self._card_sdk: Optional[Any] = None - self._robot_sdk: Optional[Any] = None + self._allowed_users: Set[str] = {item.lower() for item in self._csv_setting("allowed_users", "DINGTALK_ALLOWED_USERS")} + self._stream_client = self._stream_task = self._http_client = self._card_sdk = self._robot_sdk = None self._robot_code: str = extra.get("robot_code") or self._client_id - self._dedup = MessageDeduplicator(max_size=1000) self._session_webhooks: Dict[str, tuple[str, int]] = {} # chat_id -> (webhook, expired_time_ms) self._message_contexts: Dict[str, Any] = {} # chat_id -> last inbound ChatbotMessage (per-chat: no clobber) self._card_template_id: Optional[str] = extra.get("card_template_id") - # Chats whose Done reaction already fired this turn — prevents double-firing across - # segment boundaries / parallel flows. Reset on each inbound message. - self._done_emoji_fired: Set[str] = set() - # Cards left open in streaming state: chat_id -> {out_track_id: last_content}. - # ``edit_message(finalize=False)`` re-opens a finalized card (DingTalk allows it), so we - # track them and auto-close as siblings on the next ``send()`` — otherwise tool-progress - # cards stay stuck in streaming state forever. + self._done_emoji_fired: Set[str] = set() # chats whose Done reaction fired this turn; reset per inbound + # Open streaming cards: chat_id -> {out_track_id: last_content}. ``edit_message(finalize=False)`` + # re-opens a finalized card, so we track them and auto-close as siblings on the next ``send()``. self._streaming_cards: Dict[str, Dict[str, str]] = {} - # Fire-and-forget emoji tasks, kept referenced (GC) and cancellable on disconnect. - self._bg_tasks: Set[asyncio.Task] = set() - - # -- Connection lifecycle ----------------------------------------------- + self._bg_tasks: Set[asyncio.Task] = set() # fire-and-forget emoji tasks, kept referenced (GC) + cancellable async def connect(self, *, is_reconnect: bool = False) -> bool: """Connect to DingTalk via Stream Mode.""" - for ok, problem in ( - (DINGTALK_STREAM_AVAILABLE, "dingtalk-stream not installed. Run: pip install 'dingtalk-stream>=0.20'"), - (HTTPX_AVAILABLE, "httpx not installed. Run: pip install httpx"), - (self._client_id and self._client_secret, "DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET required"), - ): + for ok, problem in ((DINGTALK_STREAM_AVAILABLE, "dingtalk-stream not installed. Run: pip install 'dingtalk-stream>=0.20'"), + (HTTPX_AVAILABLE, "httpx not installed. Run: pip install httpx"), + (self._client_id and self._client_secret, "DINGTALK_CLIENT_ID and DINGTALK_CLIENT_SECRET required")): if not ok: logger.warning("[%s] " + problem, self.name) return False - try: - # Tighter keepalive so idle CLOSE_WAIT drains promptly. - from gateway.platforms._http_client_limits import platform_httpx_limits + from gateway.platforms._http_client_limits import platform_httpx_limits # tighter keepalive: idle CLOSE_WAIT drains promptly self._http_client = httpx.AsyncClient(timeout=30.0, limits=platform_httpx_limits()) - - credential = dingtalk_stream.Credential(self._client_id, self._client_secret) - self._stream_client = dingtalk_stream.DingTalkStreamClient(credential) - + self._stream_client = dingtalk_stream.DingTalkStreamClient(dingtalk_stream.Credential(self._client_id, self._client_secret)) if CARD_SDK_AVAILABLE: sdk_config = open_api_models.Config() - sdk_config.protocol = "https" - sdk_config.region_id = "central" + sdk_config.protocol, sdk_config.region_id = "https", "central" if self._card_template_id: self._card_sdk = dingtalk_card_client.Client(sdk_config) - self._robot_sdk = dingtalk_robot_client.Client(sdk_config) + self._robot_sdk = dingtalk_robot_client.Client(sdk_config) # needed for media download even without cards + if self._card_template_id: logger.info("[%s] Card SDK initialized with template: %s", self.name, self._card_template_id) else: - # Robot SDK alone is still needed for media download. - self._robot_sdk = dingtalk_robot_client.Client(sdk_config) logger.info("[%s] Robot SDK initialized (media download)", self.name) - - # Capture the current event loop for cross-thread dispatch - handler = _IncomingHandler(self, asyncio.get_running_loop()) - self._stream_client.register_callback_handler(dingtalk_stream.ChatbotMessage.TOPIC, handler) - + self._stream_client.register_callback_handler(dingtalk_stream.ChatbotMessage.TOPIC, _IncomingHandler(self, asyncio.get_running_loop())) self._stream_task = asyncio.create_task(self._run_stream()) self._mark_connected() logger.info("[%s] Connected via Stream Mode", self.name) - # Plugin-registered native handlers (DingTalkStreamClient — register_callback_handler()). - self._wire_plugin_handlers(self._stream_client) + self._wire_plugin_handlers(self._stream_client) # plugin-registered native handlers return True except Exception as e: logger.error("[%s] Failed to connect: %s", self.name, e) @@ -256,9 +190,8 @@ class DingTalkAdapter(BasePlatformAdapter): except asyncio.CancelledError: return except Exception as e: - if not self._running: - return - logger.warning("[%s] Stream client error: %s", self.name, e) + if self._running: + logger.warning("[%s] Stream client error: %s", self.name, e) if not self._running: return delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] @@ -266,167 +199,111 @@ class DingTalkAdapter(BasePlatformAdapter): await asyncio.sleep(delay) backoff_idx += 1 + async def _quiet(self, coro, debug_fmt: str = "", *args) -> None: + """Await *coro*, swallowing any exception (logged at debug as ``debug_fmt % (name, *args, exc)`` when given).""" + try: + await coro + except Exception as e: + if debug_fmt: + logger.debug(debug_fmt, self.name, *args, e) + async def disconnect(self) -> None: """Disconnect from DingTalk.""" self._running = False self._mark_disconnected() - - # Close the websocket first so the stream task sees the disconnect instead of - # awaiting frames that never arrive. + # Close the websocket first so the stream task sees the disconnect instead of awaiting frames that never arrive. websocket = getattr(self._stream_client, "websocket", None) if self._stream_client else None if websocket is not None: - try: - await websocket.close() - except Exception as e: - logger.debug("[%s] websocket close during disconnect failed: %s", self.name, e) - + await self._quiet(websocket.close(), "[%s] websocket close during disconnect failed: %s") if self._stream_task: if hasattr(self._stream_client, "close"): - try: - await asyncio.to_thread(self._stream_client.close) # sync close() may block on I/O - except Exception: - pass + await self._quiet(asyncio.to_thread(self._stream_client.close)) # sync close() may block on I/O self._stream_task.cancel() try: await asyncio.wait_for(self._stream_task, timeout=5.0) except (asyncio.CancelledError, asyncio.TimeoutError): logger.debug("[%s] stream task did not exit cleanly during disconnect", self.name) self._stream_task = None - + for task in list(self._bg_tasks): + task.cancel() if self._bg_tasks: - for task in list(self._bg_tasks): - task.cancel() await asyncio.gather(*self._bg_tasks, return_exceptions=True) - self._bg_tasks.clear() - # Finalize open streaming cards BEFORE the HTTP client closes so they don't stay stuck # in streaming state after a gateway restart. Outer try guards the 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) - + await self._quiet(self._close_streaming_siblings(_chat_id), "[%s] Failed to finalize streaming card on disconnect for %s: %s", _chat_id) if self._http_client: await self._http_client.aclose() - self._http_client = None - - self._stream_client = None - self._session_webhooks.clear() - self._message_contexts.clear() - self._streaming_cards.clear() - self._done_emoji_fired.clear() - self._dedup.clear() + self._http_client = self._stream_client = None + for store in (self._session_webhooks, self._message_contexts, self._streaming_cards, self._done_emoji_fired, self._dedup, self._bg_tasks): + store.clear() logger.info("[%s] Disconnected", self.name) - # -- Group gating -------------------------------------------------------- - - def _dingtalk_require_mention(self) -> bool: - """Whether group chats require an explicit bot trigger.""" - configured = self.config.extra.get("require_mention") - if configured is not None: - if isinstance(configured, str): - return configured.lower() in _TRUTHY - return bool(configured) - return os.getenv("DINGTALK_REQUIRE_MENTION", "false").lower() in _TRUTHY - - def _dingtalk_free_response_chats(self) -> Set[str]: - raw = self.config.extra.get("free_response_chats") - if raw is None: - raw = os.getenv("DINGTALK_FREE_RESPONSE_CHATS", "") - return _csv_set(raw) - - def _extra_get(self, key: str): - return self.config.extra.get(key) if self.config.extra else None + def _extra_get(self, key: str, env_name: str = "", env_default: str = ""): + """config.extra[key]; when *env_name* is given, absent keys fall back to the env var.""" + value = self.config.extra.get(key) if self.config.extra else None + return os.getenv(env_name, env_default) if value is None and env_name else value def _csv_setting(self, key: str, env_name: str) -> Set[str]: """List/CSV setting from config.extra[key], falling back to the env var.""" - raw = self._extra_get(key) - return _csv_set(os.getenv(env_name, "") if raw is None else raw) + return _csv_set(self._extra_get(key, env_name)) + + def _dingtalk_require_mention(self) -> bool: + """Whether group chats require an explicit bot trigger.""" + configured = self._extra_get("require_mention", "DINGTALK_REQUIRE_MENTION", "false") + return configured.lower() in _TRUTHY if isinstance(configured, str) else bool(configured) def _dingtalk_allowed_chats(self) -> Set[str]: """Group chat whitelist; non-empty = hard gate even when @mentioned. DMs never filtered.""" return self._csv_setting("allowed_chats", "DINGTALK_ALLOWED_CHATS") def _compile_mention_patterns(self) -> List[re.Pattern]: - """Compile optional regex wake-word patterns for group triggers.""" + """Compile optional regex wake-word patterns (config list, or env as JSON / lines / CSV).""" patterns = self._extra_get("mention_patterns") - if patterns is None: - raw = os.getenv("DINGTALK_MENTION_PATTERNS", "").strip() - if raw: - try: - loaded = json.loads(raw) - except Exception: - loaded = [part.strip() for part in raw.splitlines() if part.strip()] - if not loaded: - loaded = [part.strip() for part in raw.split(",") if part.strip()] - patterns = loaded - if patterns is None: - # Return before touching ``self.name`` on the no-patterns path (historical parity). + if patterns is None and (raw := os.getenv("DINGTALK_MENTION_PATTERNS", "").strip()): + try: + patterns = json.loads(raw) + except Exception: + patterns = [part.strip() for part in raw.splitlines() if part.strip()] + if not patterns: + patterns = [part.strip() for part in raw.split(",") if part.strip()] + if patterns is None: # return before touching ``self.name`` on the no-patterns path (historical parity) return [] - return compile_mention_patterns( - patterns, log_prefix=self.name, platform_label="dingtalk", display_label="DingTalk", logger_=logger, - ) - - def _load_allowed_users(self) -> Set[str]: - """Allowed-users from config.extra or env; matched case-insensitively; ``*`` disables.""" - return {item.lower() for item in self._csv_setting("allowed_users", "DINGTALK_ALLOWED_USERS")} + return compile_mention_patterns(patterns, log_prefix=self.name, platform_label="dingtalk", display_label="DingTalk", logger_=logger) def _is_user_allowed(self, sender_id: str, sender_staff_id: str) -> bool: if not self._allowed_users or "*" in self._allowed_users: return True - candidates = {(sender_id or "").lower(), (sender_staff_id or "").lower()} - candidates.discard("") - return bool(candidates & self._allowed_users) - - def _message_mentions_bot(self, message: "ChatbotMessage") -> bool: - """True if the bot was @-mentioned (SDK sets ``is_in_at_list``).""" - return bool(getattr(message, "is_in_at_list", False)) + return bool(({(sender_id or "").lower(), (sender_staff_id or "").lower()} - {""}) & self._allowed_users) def _message_matches_mention_patterns(self, text: str) -> bool: return bool(text and self._mention_patterns) and any(p.search(text) for p in self._mention_patterns) def _should_process_message(self, message: "ChatbotMessage", text: str, is_group: bool, chat_id: str) -> bool: - """Group trigger rules (DMs always pass; ``allowed_users`` is enforced earlier). - - Group messages are accepted when the chat passes ``allowed_chats`` (hard gate when set) - and any of: chat in ``free_response_chats``, ``require_mention`` off, bot @mentioned, - or text matches a wake-word pattern. - """ + """Group trigger rules (DMs always pass; ``allowed_users`` is enforced earlier): ``allowed_chats`` is a hard + gate, then any of free_response_chats / require_mention off / @mentioned (SDK ``is_in_at_list``) / wake-word.""" if not is_group: return True allowed = self._dingtalk_allowed_chats() if allowed and chat_id and chat_id not in allowed: return False - if chat_id and chat_id in self._dingtalk_free_response_chats(): - return True - if not self._dingtalk_require_mention(): - return True - if self._message_mentions_bot(message): - return True - return self._message_matches_mention_patterns(text) + return ( + bool(chat_id and chat_id in self._csv_setting("free_response_chats", "DINGTALK_FREE_RESPONSE_CHATS")) + or not self._dingtalk_require_mention() + or bool(getattr(message, "is_in_at_list", False)) + or self._message_matches_mention_patterns(text) + ) def _spawn_bg(self, coro) -> None: """Start a fire-and-forget coroutine and track it for cleanup.""" - task = asyncio.create_task(coro) - self._bg_tasks.add(task) + self._bg_tasks.add(task := asyncio.create_task(coro)) task.add_done_callback(self._bg_tasks.discard) - # -- AI Card lifecycle helpers ------------------------------------------ - async def _close_streaming_siblings(self, chat_id: str) -> None: - """Finalize previously-open streaming cards for this chat. - - Called at the start of every ``send()`` — there is no explicit "turn end" signal from - the gateway, so this is what closes lingering tool-progress cards. - """ + """Finalize open streaming cards for this chat at the start of every ``send()`` — the gateway has no "turn end" signal, so this is what closes lingering tool-progress cards.""" cards = self._streaming_cards.pop(chat_id, None) - if not cards: - return - token = await self._get_access_token() - if not token: - return - for out_track_id, last_content in list(cards.items()): + token = await self._get_access_token() if cards else None + for out_track_id, last_content in list(cards.items()) if token else (): try: await self._stream_card_content(out_track_id, token, last_content, finalize=True) logger.debug("[%s] AI Card sibling closed: %s", self.name, out_track_id) @@ -439,164 +316,92 @@ class DingTalkAdapter(BasePlatformAdapter): return self._done_emoji_fired.add(chat_id) msg = self._message_contexts.get(chat_id) - if not msg: - return - msg_id = getattr(msg, "message_id", "") or "" - conversation_id = getattr(msg, "conversation_id", "") or "" + msg_id, conversation_id = (getattr(msg, "message_id", None) or "", getattr(msg, "conversation_id", None) or "") if msg else ("", "") if not (msg_id and conversation_id): return - async def _swap() -> None: await self._send_emotion(msg_id, conversation_id, "🤔Thinking", recall=True) await self._send_emotion(msg_id, conversation_id, "🥳Done", recall=False) - self._spawn_bg(_swap()) - # -- Inbound message processing ----------------------------------------- - async def _on_message(self, message: "ChatbotMessage") -> None: """Process an incoming DingTalk chatbot message.""" msg_id = getattr(message, "message_id", None) or uuid.uuid4().hex if self._dedup.is_duplicate(msg_id): - logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id) - return - - conversation_id = getattr(message, "conversation_id", "") or "" + return logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id) + conversation_id, sender_id, sender_nick, sender_staff_id = (getattr(message, k, "") or "" for k in ("conversation_id", "sender_id", "sender_nick", "sender_staff_id")) is_group = str(getattr(message, "conversation_type", "1")) == "2" - sender_id = getattr(message, "sender_id", "") or "" - sender_nick = getattr(message, "sender_nick", "") or sender_id - sender_staff_id = getattr(message, "sender_staff_id", "") or "" + sender_nick = sender_nick or sender_id chat_id = conversation_id or sender_id - chat_type = "group" if is_group else "dm" - if not self._is_user_allowed(sender_id, sender_staff_id): - logger.debug( - "[%s] Dropping message from non-allowlisted user staff_id=%s sender_id=%s", - self.name, sender_staff_id, sender_id, - ) - return - - # Group mention/pattern gate needs the text early for wake-word matching. - _early_text = self._extract_text(message) or "" - if not self._should_process_message(message, _early_text, is_group, chat_id): - logger.debug( - "[%s] Dropping group message that failed mention gate message_id=%s chat_id=%s", - self.name, msg_id, chat_id, - ) - return - + return logger.debug("[%s] Dropping message from non-allowlisted user staff_id=%s sender_id=%s", self.name, sender_staff_id, sender_id) + if not self._should_process_message(message, self._extract_text(message) or "", is_group, chat_id): # wake-word gate needs text early + return logger.debug("[%s] Dropping group message that failed mention gate message_id=%s chat_id=%s", self.name, msg_id, chat_id) # Per-chat context; reset the Done marker so this message gets its own Thinking→Done cycle. if chat_id: self._message_contexts[chat_id] = message self._done_emoji_fired.discard(chat_id) - session_webhook = getattr(message, "session_webhook", None) or "" - session_webhook_expired_time = getattr(message, "session_webhook_expired_time", 0) or 0 if session_webhook and chat_id and _DINGTALK_WEBHOOK_RE.match(session_webhook): if len(self._session_webhooks) >= _SESSION_WEBHOOKS_MAX: self._session_webhooks.pop(next(iter(self._session_webhooks))) # evict oldest (dict is non-empty here) - self._session_webhooks[chat_id] = (session_webhook, session_webhook_expired_time) - - # Resolve media download codes to URLs so vision tools can use them - await self._resolve_media_codes(message) + self._session_webhooks[chat_id] = (session_webhook, getattr(message, "session_webhook_expired_time", 0) or 0) + await self._resolve_media_codes(message) # download codes -> URLs so vision tools can use them text = self._extract_text(message) msg_type, media_urls, media_types = self._extract_media(message) if not text and not media_urls: - logger.debug("[%s] Empty message, skipping", self.name) - return - - source = self.build_source( - chat_id=chat_id, chat_name=getattr(message, "conversation_title", None), chat_type=chat_type, - user_id=sender_id, user_name=sender_nick, user_id_alt=sender_staff_id if sender_staff_id else None, - ) - + return logger.debug("[%s] Empty message, skipping", self.name) + source = self.build_source(chat_id=chat_id, chat_name=getattr(message, "conversation_title", None), chat_type="group" if is_group else "dm", + user_id=sender_id, user_name=sender_nick, user_id_alt=sender_staff_id if sender_staff_id else None) create_at = getattr(message, "create_at", None) try: - timestamp = ( - datetime.fromtimestamp(int(create_at) / 1000, tz=timezone.utc) - if create_at - else datetime.now(tz=timezone.utc) - ) + timestamp = datetime.fromtimestamp(int(create_at) / 1000, tz=timezone.utc) if create_at else datetime.now(tz=timezone.utc) except (ValueError, OSError, TypeError): timestamp = datetime.now(tz=timezone.utc) - - event = MessageEvent( - text=text, message_type=msg_type, source=source, message_id=msg_id, raw_message=message, - media_urls=media_urls, media_types=media_types, timestamp=timestamp, - ) - logger.debug( - "[%s] Message from %s in %s: %s", - self.name, sender_nick, chat_id[:20] if chat_id else "?", text[:80] if text else "(media)", - ) - await self.handle_message(event) + logger.debug("[%s] Message from %s in %s: %s", self.name, sender_nick, chat_id[:20] if chat_id else "?", text[:80] if text else "(media)") + await self.handle_message(MessageEvent(text=text, message_type=msg_type, source=source, message_id=msg_id, raw_message=message, + media_urls=media_urls, media_types=media_types, timestamp=timestamp)) _extract_text = staticmethod(extract_text) def _extract_media(self, message: "ChatbotMessage"): return extract_media(message) - # -- Outbound messaging ------------------------------------------------- - - async def send( - self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: + async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult: """Send a reply via AI Card (when configured) or DingTalk session webhook markdown.""" metadata = metadata or {} - logger.debug( - "[%s] send() chat_id=%s card_enabled=%s", - self.name, chat_id, bool(self._card_template_id and self._card_sdk), - ) - - session_webhook = metadata.get("session_webhook") + logger.debug("[%s] send() chat_id=%s card_enabled=%s", self.name, chat_id, bool(self._card_template_id and self._card_sdk)) + session_webhook = metadata.get("session_webhook") or (self._get_valid_webhook(chat_id) or ("",))[0] if not session_webhook: - webhook_info = self._get_valid_webhook(chat_id) - if not webhook_info: - logger.warning("[%s] No valid session_webhook for chat_id=%s", self.name, chat_id) - return SendResult( - success=False, - error="No valid session_webhook available. Reply must follow an incoming message.", - ) - session_webhook, _ = webhook_info - + logger.warning("[%s] No valid session_webhook for chat_id=%s", self.name, chat_id) + return SendResult(success=False, error="No valid session_webhook available. Reply must follow an incoming message.") if not self._http_client: return SendResult(success=False, error="HTTP client not initialized") - current_message = self._message_contexts.get(chat_id) - - # ``reply_to`` is only set by base.py:_send_with_retry for the FINAL reply to an inbound - # message; tool-progress, commentary and stream first-sends leave it None. It decides - # (1) finalize-on-create (intermediate cards stay open so edits don't flicker) and - # (2) whether to fire the Done reaction. + # ``reply_to`` is only set by base.py:_send_with_retry for the FINAL reply to an inbound message; + # tool-progress, commentary and stream first-sends leave it None. It decides (1) finalize-on-create + # (intermediate cards stay open so edits don't flicker) and (2) whether to fire the Done reaction. is_final_reply = reply_to is not None - if self._card_template_id and current_message and self._card_sdk: - # Close lingering open cards (tool-progress → final handoff) before creating a new one. - await self._close_streaming_siblings(chat_id) + await self._close_streaming_siblings(chat_id) # close lingering tool-progress cards before creating a new one result = await self._create_and_stream_card(chat_id, current_message, content, finalize=is_final_reply) if result and result.success: if is_final_reply: self._fire_done_reaction(chat_id) - else: - # Keep open + track so the next send() auto-closes it, or edit_message(finalize=True) does. + else: # keep open + track so the next send() auto-closes it, or edit_message(finalize=True) does self._streaming_cards.setdefault(chat_id, {})[result.message_id] = content return result logger.warning("[%s] AI Card send failed, falling back to webhook", self.name) - logger.debug("[%s] Sending via webhook", self.name) - normalized = self._normalize_markdown(content[: self.MAX_MESSAGE_LENGTH]) - payload = { - "msgtype": "markdown", - "markdown": {"title": "Hermes", "text": normalized}, - } + payload = {"msgtype": "markdown", "markdown": {"title": "Hermes", "text": self._normalize_markdown(content[: self.MAX_MESSAGE_LENGTH])}} try: resp = await self._http_client.post(session_webhook, json=payload, timeout=15.0) if resp.status_code < 300: if is_final_reply: self._fire_done_reaction(chat_id) return SendResult(success=True, message_id=uuid.uuid4().hex[:12]) - body = resp.text - logger.warning("[%s] Send failed HTTP %d: %s", self.name, resp.status_code, body[:200]) - return SendResult(success=False, error=f"HTTP {resp.status_code}: {body[:200]}") + logger.warning("[%s] Send failed HTTP %d: %s", self.name, resp.status_code, resp.text[:200]) + return SendResult(success=False, error=f"HTTP {resp.status_code}: {resp.text[:200]}") except httpx.TimeoutException: return SendResult(success=False, error="Timeout sending message to DingTalk") except Exception as e: @@ -605,42 +410,19 @@ class DingTalkAdapter(BasePlatformAdapter): async def send_typing(self, chat_id: str, metadata=None) -> None: """DingTalk does not support typing indicators.""" - pass - 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: + async def send_image(self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata=None) -> SendResult: """Render a remote image inline via markdown (session webhook has no native attachments).""" image_block = f"![image]({image_url})" - content = f"{caption}\n\n{image_block}" if caption else image_block - return await self.send(chat_id=chat_id, content=content, reply_to=reply_to, metadata=metadata) + return await self.send(chat_id=chat_id, content=f"{caption}\n\n{image_block}" if caption else image_block, reply_to=reply_to, metadata=metadata) - 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, **kwargs, - ) -> SendResult: - """DingTalk webhook replies cannot send local image files directly.""" - return SendResult( - success=False, - error=( - "DingTalk session webhook replies do not support local image uploads. " - "Only markdown/text replies are supported without OpenAPI media upload." - ), - ) + async def send_image_file(self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata=None, **kwargs) -> SendResult: + """Webhook replies cannot upload local images.""" + return SendResult(success=False, error=_NO_LOCAL_UPLOAD % ("image uploads", "media upload")) - 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, **kwargs, - ) -> SendResult: - """DingTalk webhook replies cannot send local file attachments directly.""" - return SendResult( - success=False, - error=( - "DingTalk session webhook replies do not support local file attachments. " - "Only markdown/text replies are supported without OpenAPI message send." - ), - ) + async def send_document(self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, reply_to=None, metadata=None, **kwargs) -> SendResult: + """Webhook replies cannot upload local files.""" + return SendResult(success=False, error=_NO_LOCAL_UPLOAD % ("file attachments", "message send")) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return basic info about a DingTalk conversation.""" @@ -649,122 +431,76 @@ class DingTalkAdapter(BasePlatformAdapter): def _get_valid_webhook(self, chat_id: str) -> Optional[tuple[str, int]]: """Get a non-expired session webhook for chat_id (5-minute safety margin).""" info = self._session_webhooks.get(chat_id) - if not info: + expired_time_ms = info[1] if info else 0 + if expired_time_ms and expired_time_ms > 0 and int(datetime.now(tz=timezone.utc).timestamp() * 1000) + 5 * 60 * 1000 >= expired_time_ms: + self._session_webhooks.pop(chat_id, None) return None - webhook, expired_time_ms = info - if expired_time_ms and expired_time_ms > 0: - now_ms = int(datetime.now(tz=timezone.utc).timestamp() * 1000) - if now_ms + 5 * 60 * 1000 >= expired_time_ms: - self._session_webhooks.pop(chat_id, None) - return None - return info + return info or None - async def _create_and_stream_card( - self, chat_id: str, message: Any, content: str, *, finalize: bool = True, - ) -> Optional[SendResult]: - """Create an AI Card, deliver it to the conversation, and stream initial content. - - ``finalize=False`` leaves the card open for ``edit_message`` streaming updates keyed by - the returned out_track_id. - """ + async def _create_and_stream_card(self, chat_id: str, message: Any, content: str, *, finalize: bool = True) -> Optional[SendResult]: + """Create, deliver and stream an AI Card; ``finalize=False`` leaves it open for ``edit_message`` by out_track_id.""" try: token = await self._get_access_token() if not token: return None - - out_track_id = f"hermes_{uuid.uuid4().hex[:12]}" - conversation_id = getattr(message, "conversation_id", "") or "" + out_track_id, models = f"hermes_{uuid.uuid4().hex[:12]}", dingtalk_card_models is_group = str(getattr(message, "conversation_type", "1")) == "2" sender_staff_id = getattr(message, "sender_staff_id", "") or "" - runtime = tea_util_models.RuntimeOptions() - - # Step 1: Create card with STREAM callback type - create_request = dingtalk_card_models.CreateCardRequest( - card_template_id=self._card_template_id, - out_track_id=out_track_id, - card_data=dingtalk_card_models.CreateCardRequestCardData(card_param_map={"content": ""}), - callback_type="STREAM", - im_group_open_space_model=dingtalk_card_models.CreateCardRequestImGroupOpenSpaceModel(support_forward=True), - im_robot_open_space_model=dingtalk_card_models.CreateCardRequestImRobotOpenSpaceModel(support_forward=True), - ) - create_headers = dingtalk_card_models.CreateCardHeaders(x_acs_dingtalk_access_token=token) - await self._card_sdk.create_card_with_options_async(create_request, create_headers, runtime) - - # Step 2: Deliver card to the conversation + create_request = models.CreateCardRequest( + card_template_id=self._card_template_id, out_track_id=out_track_id, callback_type="STREAM", + card_data=models.CreateCardRequestCardData(card_param_map={"content": ""}), + im_group_open_space_model=models.CreateCardRequestImGroupOpenSpaceModel(support_forward=True), + im_robot_open_space_model=models.CreateCardRequestImRobotOpenSpaceModel(support_forward=True)) + await self._sdk_call(self._card_sdk.create_card_with_options_async, create_request, models.CreateCardHeaders, token) if is_group: - open_space_id = f"dtv1.card//IM_GROUP.{conversation_id}" - deliver_model = {"im_group_open_deliver_model": ( - dingtalk_card_models.DeliverCardRequestImGroupOpenDeliverModel(robot_code=self._robot_code) - )} - else: - if not sender_staff_id: - logger.warning("[%s] AI Card skipped: missing sender_staff_id for DM", self.name) - return None + open_space_id = f"dtv1.card//IM_GROUP.{getattr(message, 'conversation_id', '') or ''}" + deliver_model = {"im_group_open_deliver_model": models.DeliverCardRequestImGroupOpenDeliverModel(robot_code=self._robot_code)} + elif sender_staff_id: open_space_id = f"dtv1.card//IM_ROBOT.{sender_staff_id}" - deliver_model = {"im_robot_open_deliver_model": ( - dingtalk_card_models.DeliverCardRequestImRobotOpenDeliverModel(space_type="IM_ROBOT") - )} - deliver_request = dingtalk_card_models.DeliverCardRequest( - out_track_id=out_track_id, user_id_type=1, open_space_id=open_space_id, **deliver_model, - ) - deliver_headers = dingtalk_card_models.DeliverCardHeaders(x_acs_dingtalk_access_token=token) - await self._card_sdk.deliver_card_with_options_async(deliver_request, deliver_headers, runtime) - - # Step 3: Stream initial content (finalize=True closes the card immediately). + deliver_model = {"im_robot_open_deliver_model": models.DeliverCardRequestImRobotOpenDeliverModel(space_type="IM_ROBOT")} + else: + return logger.warning("[%s] AI Card skipped: missing sender_staff_id for DM", self.name) + deliver_request = models.DeliverCardRequest(out_track_id=out_track_id, user_id_type=1, open_space_id=open_space_id, **deliver_model) + await self._sdk_call(self._card_sdk.deliver_card_with_options_async, deliver_request, models.DeliverCardHeaders, token) await self._stream_card_content(out_track_id, token, content, finalize=finalize) - logger.info( - "[%s] AI Card %s: %s", - self.name, "created+finalized" if finalize else "created (streaming)", out_track_id, - ) + logger.info("[%s] AI Card %s: %s", self.name, "created+finalized" if finalize else "created (streaming)", out_track_id) return SendResult(success=True, message_id=out_track_id) except Exception as e: logger.warning("[%s] AI Card create failed: %s\n%s", self.name, e, traceback.format_exc()) return None - async def edit_message( - self, chat_id: str, message_id: str, content: str, *, finalize: bool = False, - ) -> SendResult: - """Edit an AI Card by streaming updated content. - - ``message_id`` is the out_track_id returned by the ``send()`` that created the card; - callers track their own ids so parallel flows on one chat don't interfere. - """ - if not message_id: - return SendResult(success=False, error="message_id required") - token = await self._get_access_token() + async def edit_message(self, chat_id: str, message_id: str, content: str, *, finalize: bool = False) -> SendResult: + """Stream updated content to an AI Card; ``message_id`` is the creating ``send()``'s out_track_id (callers track their own ids so parallel flows on one chat don't interfere).""" + token = await self._get_access_token() if message_id else None if not token: - return SendResult(success=False, error="No access token") + return SendResult(success=False, error="message_id required" if not message_id else "No access token") try: await self._stream_card_content(message_id, token, content, finalize=finalize) - if finalize: - # Canonical "response ended" signal from the stream consumer's final edit. + if finalize: # canonical "response ended" signal from the stream consumer's final edit self._streaming_cards.get(chat_id, {}).pop(message_id, None) if not self._streaming_cards.get(chat_id): self._streaming_cards.pop(chat_id, None) logger.debug("[%s] AI Card finalized (edit): %s", self.name, message_id) self._fire_done_reaction(chat_id) - else: - # Non-final edit reopens the card into streaming state — track for sibling close. + else: # non-final edit reopens the card into streaming state — track for sibling close self._streaming_cards.setdefault(chat_id, {})[message_id] = content return SendResult(success=True, message_id=message_id) except Exception as e: logger.warning("[%s] Card edit failed: %s", self.name, e) return SendResult(success=False, error=str(e)) + @staticmethod + async def _sdk_call(method, request, headers_cls, token: str): + """``await method(request, headers_cls(token), RuntimeOptions())`` — the alibabacloud SDK call shape.""" + return await method(request, headers_cls(x_acs_dingtalk_access_token=token), tea_util_models.RuntimeOptions()) + async def _stream_card_content(self, out_track_id: str, token: str, content: str, finalize: bool = False) -> None: """Stream content to an existing AI Card.""" stream_request = dingtalk_card_models.StreamingUpdateRequest( - out_track_id=out_track_id, - guid=str(uuid.uuid4()), - key="content", - content=content[: self.MAX_MESSAGE_LENGTH], - is_full=True, - is_finalize=finalize, - is_error=False, + out_track_id=out_track_id, guid=str(uuid.uuid4()), key="content", content=content[: self.MAX_MESSAGE_LENGTH], + is_full=True, is_finalize=finalize, is_error=False, ) - stream_headers = dingtalk_card_models.StreamingUpdateHeaders(x_acs_dingtalk_access_token=token) - runtime = tea_util_models.RuntimeOptions() - await self._card_sdk.streaming_update_with_options_async(stream_request, stream_headers, runtime) + await self._sdk_call(self._card_sdk.streaming_update_with_options_async, stream_request, dingtalk_card_models.StreamingUpdateHeaders, token) async def _get_access_token(self) -> Optional[str]: """Get access token via the SDK's cached (sync, requests-based) getter.""" @@ -776,9 +512,7 @@ class DingTalkAdapter(BasePlatformAdapter): logger.error("[%s] Failed to get access token: %s", self.name, e) return None - async def _send_emotion( - self, open_msg_id: str, open_conversation_id: str, emoji_name: str, *, recall: bool = False, - ) -> None: + async def _send_emotion(self, open_msg_id: str, open_conversation_id: str, emoji_name: str, *, recall: bool = False) -> None: """Add (or recall) an emoji reaction on a message.""" if not self._robot_sdk or not open_msg_id or not open_conversation_id: return @@ -788,23 +522,10 @@ class DingTalkAdapter(BasePlatformAdapter): if not token: return text_emotion_cls, request_cls, headers_cls, sdk_method = _EMOTION_SDK[recall] - emotion_kwargs = { - "robot_code": self._robot_code, - "open_msg_id": open_msg_id, - "open_conversation_id": open_conversation_id, - "emotion_type": 2, - "emotion_name": emoji_name, - } - runtime = tea_util_models.RuntimeOptions() - emotion_kwargs["text_emotion"] = getattr(dingtalk_robot_models, text_emotion_cls)( - emotion_id=_EMOTION_ID, - emotion_name=emoji_name, - text=emoji_name, - background_id=_EMOTION_BG, - ) - request = getattr(dingtalk_robot_models, request_cls)(**emotion_kwargs) - sdk_headers = getattr(dingtalk_robot_models, headers_cls)(x_acs_dingtalk_access_token=token) - await getattr(self._robot_sdk, sdk_method)(request, sdk_headers, runtime) + text_emotion = getattr(dingtalk_robot_models, text_emotion_cls)(emotion_id=_EMOTION_ID, emotion_name=emoji_name, text=emoji_name, background_id=_EMOTION_BG) + request = getattr(dingtalk_robot_models, request_cls)(robot_code=self._robot_code, open_msg_id=open_msg_id, open_conversation_id=open_conversation_id, + emotion_type=2, emotion_name=emoji_name, text_emotion=text_emotion) + await self._sdk_call(getattr(self._robot_sdk, sdk_method), request, getattr(dingtalk_robot_models, headers_cls), token) logger.info("[%s] _send_emotion: %s %s on msg=%s", self.name, action, emoji_name, open_msg_id[:24]) except Exception: logger.debug("[%s] _send_emotion %s failed", self.name, action, exc_info=True) @@ -815,36 +536,27 @@ class DingTalkAdapter(BasePlatformAdapter): if not token: return robot_code = getattr(message, "robot_code", None) or self._client_id - codes_to_resolve = collect_download_codes(message) - if not codes_to_resolve: - return - tasks = [] - for obj, key in codes_to_resolve: - code = getattr(obj, key, None) if hasattr(obj, key) else obj.get(key) - if code: - tasks.append(self._fetch_download_url(code, robot_code, token, obj, key)) - await asyncio.gather(*tasks, return_exceptions=True) + pairs = [(getattr(obj, key, None) if hasattr(obj, key) else obj.get(key), obj, key) for obj, key in collect_download_codes(message)] + tasks = [self._fetch_download_url(code, robot_code, token, obj, key) for code, obj, key in pairs if code] + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) async def _fetch_download_url(self, code: str, robot_code: str, token: str, obj, key: str) -> None: """Fetch the download URL for one code via the robot SDK and write it back to ``obj[key]``.""" if not self._robot_sdk: - logger.warning("[%s] Robot SDK not initialized, cannot resolve media code", self.name) - return + return logger.warning("[%s] Robot SDK not initialized, cannot resolve media code", self.name) try: - request = dingtalk_robot_models.RobotMessageFileDownloadRequest(download_code=code, robot_code=robot_code) - headers = dingtalk_robot_models.RobotMessageFileDownloadHeaders(x_acs_dingtalk_access_token=token) - runtime = tea_util_models.RuntimeOptions() - response = await self._robot_sdk.robot_message_file_download_with_options_async(request, headers, runtime) + response = await self._sdk_call(self._robot_sdk.robot_message_file_download_with_options_async, + dingtalk_robot_models.RobotMessageFileDownloadRequest(download_code=code, robot_code=robot_code), + dingtalk_robot_models.RobotMessageFileDownloadHeaders, token) body = response.body if response else None - if body: - url = getattr(body, "download_url", None) - if url: - if hasattr(obj, key): - setattr(obj, key, url) - elif isinstance(obj, dict): - obj[key] = url - else: + url = getattr(body, "download_url", None) if body else None + if not body: logger.warning("[%s] Failed to download media: empty response for code %s", self.name, code) + elif url and hasattr(obj, key): + setattr(obj, key, url) + elif url and isinstance(obj, dict): + obj[key] = url except Exception as e: logger.error("[%s] Error resolving media code %s: %s", self.name, code, e) @@ -854,66 +566,40 @@ class DingTalkAdapter(BasePlatformAdapter): lines = text.split("\n") out = [] for i, line in enumerate(lines): - is_numbered = re.match(r"^\d+\.\s", line.strip()) - if is_numbered and i > 0: - prev = lines[i - 1] - if prev.strip() and not re.match(r"^\d+\.\s", prev.strip()): - out.append("") - if line.strip().startswith("```") and line != line.lstrip(): - line = line[len(line) - len(line.lstrip()):] - out.append(line) + prev = lines[i - 1].strip() if i > 0 else "" + if prev and _NUMBERED_RE.match(line.strip()) and not _NUMBERED_RE.match(prev): + out.append("") + out.append(line.lstrip() if line.strip().startswith("```") else line) return "\n".join(out) -# --------------------------------------------------------------------------- -# Internal stream handler -# --------------------------------------------------------------------------- - - class _IncomingHandler(dingtalk_stream.ChatbotHandler if DINGTALK_STREAM_AVAILABLE else object): - """dingtalk-stream ChatbotHandler that forwards messages to the adapter. - - SDK >= 0.20: ``process()`` is async and receives a CallbackMessage whose ``.data`` dict we - parse into a ChatbotMessage before forwarding. - """ + """ChatbotHandler forwarding to the adapter (SDK >= 0.20: async ``process()`` gets a CallbackMessage ``.data`` dict).""" def __init__(self, adapter: DingTalkAdapter, loop: Optional[asyncio.AbstractEventLoop] = None): if DINGTALK_STREAM_AVAILABLE: super().__init__() - self._adapter = adapter - self._loop = loop + self._adapter, self._loop = adapter, loop def pre_start(self) -> None: """No-op hook the SDK calls on every handler before opening the WebSocket (missing → AttributeError).""" return async def process(self, message: "CallbackMessage"): - """SDK callback: convert to ChatbotMessage, then ACK immediately. - - Processing is dispatched as a background task — blocking here would stall the SDK's - heartbeats and eventually disconnect the stream. - """ + """Convert to ChatbotMessage, dispatch as a background task, ACK immediately (blocking would stall SDK heartbeats).""" try: - data = message.data - if isinstance(data, str): - data = json.loads(data) + data = json.loads(message.data) if isinstance(message.data, str) else message.data chatbot_msg = ChatbotMessage.from_dict(data) - - # Backfill fields from_dict() may not map (field names vary across SDK versions). - if not getattr(chatbot_msg, "session_webhook", None): - webhook = (data.get("sessionWebhook") or data.get("session_webhook") or "") if isinstance(data, dict) else "" - if webhook: - chatbot_msg.session_webhook = webhook - if not getattr(chatbot_msg, "is_in_at_list", False) and isinstance(data, dict) and data.get("isInAtList"): + data = data if isinstance(data, dict) else {} # backfill fields from_dict() may not map (names vary across SDK versions) + webhook = data.get("sessionWebhook") or data.get("session_webhook") or "" + if webhook and not getattr(chatbot_msg, "session_webhook", None): + chatbot_msg.session_webhook = webhook + if not getattr(chatbot_msg, "is_in_at_list", False) and data.get("isInAtList"): chatbot_msg.is_in_at_list = True - - msg_id = getattr(chatbot_msg, "message_id", None) or "" - conversation_id = getattr(chatbot_msg, "conversation_id", None) or "" + msg_id, conversation_id = getattr(chatbot_msg, "message_id", None) or "", getattr(chatbot_msg, "conversation_id", None) or "" if msg_id and conversation_id: self._adapter._spawn_bg(self._adapter._send_emotion(msg_id, conversation_id, "🤔Thinking", recall=False)) - - # _safe_on_message surfaces exceptions in logs instead of losing them in the loop. - asyncio.create_task(self._safe_on_message(chatbot_msg)) + asyncio.create_task(self._safe_on_message(chatbot_msg)) # surfaces exceptions in logs instead of losing them except Exception: logger.exception("[%s] Error preparing incoming message", self._adapter.name) return AckMessage.STATUS_SYSTEM_EXCEPTION, "error" @@ -926,39 +612,26 @@ class _IncomingHandler(dingtalk_stream.ChatbotHandler if DINGTALK_STREAM_AVAILAB logger.exception("[%s] Error processing incoming message", self._adapter.name) -# --------------------------------------------------------------------------- -# Plugin glue: register(ctx) + hook implementations replacing the former -# per-platform core touchpoints (gateway/run.py, gateway/config.py, -# hermes_cli/gateway.py, tools/send_message_tool.py). -# --------------------------------------------------------------------------- - - async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False): - """Out-of-process delivery (standalone_sender_fn) via the static robot webhook URL. - - Per-session webhooks aren't available out-of-process (deliver=dingtalk cron jobs), so this - uses DINGTALK_WEBHOOK_URL / extra ``webhook_url``. - """ - extra = getattr(pconfig, "extra", {}) or {} + """Out-of-process delivery (standalone_sender_fn) via the static robot webhook (DINGTALK_WEBHOOK_URL / extra + ``webhook_url``) — per-session webhooks aren't available to cron jobs.""" try: import httpx except ImportError: return {"error": "httpx not installed"} + webhook_url = (getattr(pconfig, "extra", {}) or {}).get("webhook_url") or os.getenv("DINGTALK_WEBHOOK_URL", "") + if not webhook_url: + return {"error": "DingTalk not configured. Set DINGTALK_WEBHOOK_URL env var or webhook_url in dingtalk platform extra config."} try: - webhook_url = extra.get("webhook_url") or os.getenv("DINGTALK_WEBHOOK_URL", "") - if not webhook_url: - return {"error": "DingTalk not configured. Set DINGTALK_WEBHOOK_URL env var or webhook_url in dingtalk platform extra config."} async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post(webhook_url, json={"msgtype": "text", "text": {"content": message}}) resp.raise_for_status() data = resp.json() - if data.get("errcode", 0) != 0: - return {"error": f"DingTalk API error: {data.get('errmsg', 'unknown')}"} + if data.get("errcode", 0) != 0: + return {"error": f"DingTalk API error: {data.get('errmsg', 'unknown')}"} return {"success": True, "platform": "dingtalk", "chat_id": chat_id} except Exception as e: - # Redact access_token from webhook URLs in the exception text via send_message_tool._error - # (lazy import avoids a circular at module load). - try: + try: # send_message_tool._error redacts access_token from webhook URLs (lazy import avoids a circular) from tools.send_message_tool import _error as _redact_error return _redact_error(f"DingTalk send failed: {e}") except Exception: @@ -970,37 +643,26 @@ def interactive_setup() -> None: from hermes_cli.config import get_env_value, save_env_value from hermes_cli.setup import prompt_choice from hermes_cli.cli_output import prompt, prompt_yes_no, print_header, print_success, print_warning - print_header("DingTalk") - existing = get_env_value("DINGTALK_CLIENT_ID") - if existing: + if existing := get_env_value("DINGTALK_CLIENT_ID"): print_success(f"DingTalk is already configured (Client ID: {existing}).") if not prompt_yes_no("Reconfigure DingTalk?", False): return - - method = prompt_choice( - "Choose setup method", - ["QR Code Scan (Recommended, auto-obtain Client ID and Client Secret)", "Manual Input (Client ID and Client Secret)"], - default=0, - ) - if method == 0: + choices = ["QR Code Scan (Recommended, auto-obtain Client ID and Client Secret)", "Manual Input (Client ID and Client Secret)"] + result = None + if prompt_choice("Choose setup method", choices, default=0) == 0: try: from hermes_cli.dingtalk_auth import dingtalk_qr_auth + result = dingtalk_qr_auth() + if result is None: + print_warning("QR auth incomplete, falling back to manual input.") except ImportError as exc: print_warning(f"QR auth module failed to load ({exc}), falling back to manual input.") - _manual_credential_entry(prompt, save_env_value, print_success) - return - result = dingtalk_qr_auth() - if result is None: - print_warning("QR auth incomplete, falling back to manual input.") - _manual_credential_entry(prompt, save_env_value, print_success) - return - client_id, client_secret = result - save_env_value("DINGTALK_CLIENT_ID", client_id) - save_env_value("DINGTALK_CLIENT_SECRET", client_secret) - print_success("DingTalk configured via QR scan!") - else: - _manual_credential_entry(prompt, save_env_value, print_success) + if result is not None: + for key, value in zip(("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"), result): + save_env_value(key, value) + return print_success("DingTalk configured via QR scan!") + _manual_credential_entry(prompt, save_env_value, print_success) def _manual_credential_entry(prompt, save_env_value, print_success) -> None: @@ -1008,8 +670,7 @@ def _manual_credential_entry(prompt, save_env_value, print_success) -> None: if not client_id: return save_env_value("DINGTALK_CLIENT_ID", client_id) - client_secret = prompt("DingTalk Client Secret", password=True) - if client_secret: + if client_secret := prompt("DingTalk Client Secret", password=True): save_env_value("DINGTALK_CLIENT_SECRET", client_secret) print_success("DingTalk credentials saved") @@ -1017,79 +678,49 @@ def _manual_credential_entry(prompt, save_env_value, print_success) -> None: def _bridge_list_env(env_name: str, value) -> None: """Export a YAML list/scalar as a comma-joined env var unless the env var is already set.""" if value is not None and not os.getenv(env_name): - if isinstance(value, list): - value = ",".join(str(v) for v in value) - os.environ[env_name] = str(value) + os.environ[env_name] = ",".join(str(v) for v in value) if isinstance(value, list) else str(value) + + +def _nested_allowed_users(yaml_cfg: dict, dingtalk_cfg: dict): + """Allowlist from ``extra.allowed_users``: this block's own extra first, then ``gateway.platforms.dingtalk.extra`` and ``platforms.dingtalk.extra``.""" + _gw = yaml_cfg.get("gateway") + containers = (_gw.get("platforms") if isinstance(_gw, dict) else None, yaml_cfg.get("platforms")) + for holder in (dingtalk_cfg, *(c.get("dingtalk") if isinstance(c, dict) else None for c in containers)): + _extra = holder.get("extra") if isinstance(holder, dict) else None + if isinstance(_extra, dict) and _extra.get("allowed_users") is not None: + return _extra.get("allowed_users") + return None def _apply_yaml_config(yaml_cfg: dict, dingtalk_cfg: dict) -> dict | None: - """Translate config.yaml dingtalk: keys into DINGTALK_* env vars (apply_yaml_config_fn). - - Env vars take precedence over YAML. Returns None — everything flows through env. - """ - import json as _json - if "require_mention" in dingtalk_cfg and not os.getenv("DINGTALK_REQUIRE_MENTION"): - os.environ["DINGTALK_REQUIRE_MENTION"] = str(dingtalk_cfg["require_mention"]).lower() - if "mention_patterns" in dingtalk_cfg and not os.getenv("DINGTALK_MENTION_PATTERNS"): - os.environ["DINGTALK_MENTION_PATTERNS"] = _json.dumps(dingtalk_cfg["mention_patterns"]) - _bridge_list_env("DINGTALK_FREE_RESPONSE_CHATS", dingtalk_cfg.get("free_response_chats")) - _bridge_list_env("DINGTALK_ALLOWED_CHATS", dingtalk_cfg.get("allowed_chats")) + """Translate config.yaml dingtalk: keys into DINGTALK_* env vars (apply_yaml_config_fn); env wins, returns None. The docs put the allowlist at + ``gateway.platforms.dingtalk.extra.allowed_users`` but gateway authz only consults DINGTALK_ALLOWED_USERS, so nested-only allowlists are bridged too.""" + for key, env, encode in (("require_mention", "DINGTALK_REQUIRE_MENTION", lambda v: str(v).lower()), ("mention_patterns", "DINGTALK_MENTION_PATTERNS", json.dumps)): + if key in dingtalk_cfg and not os.getenv(env): + os.environ[env] = encode(dingtalk_cfg[key]) allowed = dingtalk_cfg.get("allowed_users") - if allowed is None: - # The docs configure the allowlist at gateway.platforms.dingtalk.extra.allowed_users; the - # adapter reads PlatformConfig.extra but gateway authz only consults DINGTALK_ALLOWED_USERS, - # so bridge nested-only allowlists too: this block's own extra first (the dispatch loop - # passes the platforms block when no top-level ``dingtalk:`` exists), then both containers. - _extra = dingtalk_cfg.get("extra") - if isinstance(_extra, dict): - allowed = _extra.get("allowed_users") - if allowed is None: - _gw = yaml_cfg.get("gateway") - _gw_platforms = _gw.get("platforms") if isinstance(_gw, dict) else None - for _container in (_gw_platforms, yaml_cfg.get("platforms")): - if not isinstance(_container, dict): - continue - _dt = _container.get("dingtalk") - _dt_extra = _dt.get("extra") if isinstance(_dt, dict) else None - if isinstance(_dt_extra, dict) and _dt_extra.get("allowed_users") is not None: - allowed = _dt_extra.get("allowed_users") - break - _bridge_list_env("DINGTALK_ALLOWED_USERS", allowed) + for env, value in (("DINGTALK_FREE_RESPONSE_CHATS", dingtalk_cfg.get("free_response_chats")), ("DINGTALK_ALLOWED_CHATS", dingtalk_cfg.get("allowed_chats")), + ("DINGTALK_ALLOWED_USERS", _nested_allowed_users(yaml_cfg, dingtalk_cfg) if allowed is None else allowed)): + _bridge_list_env(env, value) return None def _is_connected(config) -> bool: """Connected when client_id + client_secret are present (PlatformConfig.extra first, then env).""" - extra = getattr(config, "extra", {}) or {} - return bool( - (extra.get("client_id") or os.getenv("DINGTALK_CLIENT_ID")) - and (extra.get("client_secret") or _get_scoped_secret("DINGTALK_CLIENT_SECRET")) - ) + return all(_credentials(getattr(config, "extra", {}))) def _build_adapter(config): - """Factory wrapper that constructs DingTalkAdapter from a PlatformConfig.""" return DingTalkAdapter(config) def register(ctx) -> None: """Plugin entry point — called by the Hermes plugin system.""" ctx.register_platform( - name="dingtalk", - label="DingTalk", - adapter_factory=_build_adapter, - check_fn=dingtalk_deps_present, - ensure_deps_fn=ensure_dingtalk_deps, - is_connected=_is_connected, - validate_config=_is_connected, - required_env=["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"], - install_hint="pip install 'dingtalk-stream>=0.20' httpx", - setup_fn=interactive_setup, - apply_yaml_config_fn=_apply_yaml_config, - allowed_users_env="DINGTALK_ALLOWED_USERS", - allow_all_env="DINGTALK_ALLOW_ALL_USERS", - cron_deliver_env_var="DINGTALK_HOME_CHANNEL", - standalone_sender_fn=_standalone_send, - emoji="🐳", - allow_update_command=True, + name="dingtalk", label="DingTalk", adapter_factory=_build_adapter, check_fn=dingtalk_deps_present, + ensure_deps_fn=ensure_dingtalk_deps, is_connected=_is_connected, validate_config=_is_connected, + required_env=["DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"], install_hint="pip install 'dingtalk-stream>=0.20' httpx", + setup_fn=interactive_setup, apply_yaml_config_fn=_apply_yaml_config, allowed_users_env="DINGTALK_ALLOWED_USERS", + allow_all_env="DINGTALK_ALLOW_ALL_USERS", cron_deliver_env_var="DINGTALK_HOME_CHANNEL", + standalone_sender_fn=_standalone_send, emoji="🐳", allow_update_command=True, ) diff --git a/plugins/platforms/dingtalk/inbound.py b/plugins/platforms/dingtalk/inbound.py index 65af2f09b1..83a79d19cc 100644 --- a/plugins/platforms/dingtalk/inbound.py +++ b/plugins/platforms/dingtalk/inbound.py @@ -19,11 +19,7 @@ EXT_MAP = { } # rich-text runtime type → (media_types entry, MessageType promotion when still TEXT) -_RICH_MEDIA = { - "image": ("image", MessageType.PHOTO), - "video": ("video", MessageType.VIDEO), - "file": ("application/octet-stream", MessageType.DOCUMENT), -} +_RICH_MEDIA = {"image": ("image", MessageType.PHOTO), "video": ("video", MessageType.VIDEO), "file": ("application/octet-stream", MessageType.DOCUMENT)} def _extensions(message: Any) -> Any: @@ -45,46 +41,33 @@ def _rich_list(message: Any) -> Optional[list]: return rich_list if isinstance(rich_list, list) else None +def _url_of(raw: Any) -> str: + return (raw.get("url", "") or raw.get("docUrl", "")) if isinstance(raw, dict) else "" + + def _card_text(message: Any) -> str: """msgtype='card' (钉钉文档分享卡片 / link card): title + doc URL from ``extensions['card']``.""" extensions = _extensions(message) - content = "" card = extensions.get("card", {}) + content = "" if isinstance(card, dict): - title = card.get("title", "") - raw_content = card.get("content", "") - doc_url = "" - if isinstance(raw_content, dict): - doc_url = raw_content.get("url", "") or raw_content.get("docUrl", "") - elif isinstance(raw_content, str) and raw_content.strip(): + title, raw_content = card.get("title", ""), card.get("content", "") + doc_url = _url_of(raw_content) + if isinstance(raw_content, str) and raw_content.strip(): try: - parsed = json.loads(raw_content.strip()) - if isinstance(parsed, dict): - doc_url = parsed.get("url", "") or parsed.get("docUrl", "") + doc_url = _url_of(json.loads(raw_content.strip())) except (ValueError, TypeError): doc_url = raw_content - parts = ([f"[文档] {title}"] if title else []) + ([doc_url] if doc_url else []) - if parts: - content = " ".join(parts) - if not content: - # Last-resort: raw text field from extensions (if present) - ext_text = extensions.get("text", {}) - if isinstance(ext_text, dict): - content = (ext_text.get("content", "") or "").strip() - return content + content = " ".join(([f"[文档] {title}"] if title else []) + ([doc_url] if doc_url else [])) + ext_text = extensions.get("text", {}) # last-resort: raw text field from extensions + return content or ((ext_text.get("content", "") or "").strip() if isinstance(ext_text, dict) else "") def _interactive_card_text(message: Any) -> str: """msgtype='interactiveCard': ``extensions['content']`` carries title + biz_custom_action_url.""" - ext_content = _ext_content(message) - if not ext_content: - return "" - doc_url = ext_content.get("biz_custom_action_url", "") - title = ext_content.get("title", "") - if not (doc_url or title): - return "" - parts = [f"[文档卡片] {title}" if title else "[文档卡片]"] + ([doc_url] if doc_url else []) - return " ".join(parts) + ext_content = _ext_content(message) or {} + doc_url, title = ext_content.get("biz_custom_action_url", ""), ext_content.get("title", "") + return " ".join([f"[文档卡片] {title}" if title else "[文档卡片]"] + ([doc_url] if doc_url else [])) if (doc_url or title) else "" def _ext_field(message: Any, field: str) -> Any: @@ -92,65 +75,34 @@ def _ext_field(message: Any, field: str) -> Any: return ext_content.get(field, "") if ext_content else "" -def _audio_text(message: Any) -> str: - """msgtype='audio': DingTalk-provided speech recognition text.""" - recognition = _ext_field(message, "recognition") - return recognition.strip() if recognition else "" +# Fallbacks by msgtype when no plain/rich text was found (types are exclusive): audio -> DingTalk speech +# recognition text; file -> fileName; card / interactiveCard -> title + doc URL. +_EMPTY_TEXT_FALLBACKS = { + "audio": lambda m: (_ext_field(m, "recognition") or "").strip(), + "file": lambda m: f"[文件] {_ext_field(m, 'fileName')}" if _ext_field(m, "fileName") else "", + "card": _card_text, + "interactiveCard": _interactive_card_text, +} -def _file_text(message: Any) -> str: - """msgtype='file': use fileName as text.""" - fname = _ext_field(message, "fileName") - return f"[文件] {fname}" if fname else "" - - -# Fallbacks by msgtype when no plain/rich text was found (types are exclusive). -_EMPTY_TEXT_FALLBACKS = ( - ("audio", _audio_text), - ("file", _file_text), - ("card", _card_text), - ("interactiveCard", _interactive_card_text), -) +def _rich_item_text(item: Any) -> str: + if isinstance(item, dict): + return item.get("text") or item.get("content") or "" + return getattr(item, "text", "") or "" def extract_text(message: Any) -> str: - """Extract plain text from a DingTalk chatbot message. - - Handles both SDK payload shapes: legacy ``message.text`` dict ``{"content": ...}`` and - >= 0.20 ``TextContent`` (whose ``__str__`` is ``"TextContent(content=...)"`` — always read - ``.content`` first); rich text via ``rich_text_content.rich_text_list`` or legacy ``rich_text``. - """ + """Extract plain text from a DingTalk chatbot message. Handles both SDK shapes: legacy ``message.text`` dict ``{"content": ...}`` and >= 0.20 ``TextContent`` + (whose ``__str__`` is ``"TextContent(content=...)"`` — always read ``.content`` first).""" text = getattr(message, "text", None) or "" - if hasattr(text, "content"): - content = (text.content or "").strip() - elif isinstance(text, dict): - content = text.get("content", "").strip() - else: - content = str(text).strip() - - if not content: - rich_list = _rich_list(message) - if rich_list is not None: - parts = [] - for item in rich_list: - if isinstance(item, dict): - t = item.get("text") or item.get("content") or "" - if t: - parts.append(t) - elif hasattr(item, "text") and item.text: - parts.append(item.text) - content = " ".join(parts).strip() - - if not content: - msg_type = getattr(message, "message_type", "") - for kind, fallback in _EMPTY_TEXT_FALLBACKS: - if msg_type == kind: - content = fallback(message) - break - + content = (text.content or "" if hasattr(text, "content") else text.get("content", "") if isinstance(text, dict) else str(text)).strip() + rich_list = _rich_list(message) if not content else None + if rich_list is not None: + content = " ".join(t for t in map(_rich_item_text, rich_list) if t).strip() + fallback = _EMPTY_TEXT_FALLBACKS.get(getattr(message, "message_type", "")) if not content else None # Do NOT strip "@bot": the mention is routed structurally (callback ``isInAtList``), and # regex-stripping @handles would damage e-mails, SSH URLs and literal "@openai" references. - return content + return fallback(message) if fallback else content def extract_media(message: Any) -> Tuple[MessageType, List[str], List[str]]: @@ -158,35 +110,25 @@ def extract_media(message: Any) -> Tuple[MessageType, List[str], List[str]]: msg_type = MessageType.TEXT media_urls: List[str] = [] media_types: List[str] = [] - image_content = getattr(message, "image_content", None) - if image_content: - download_code = getattr(image_content, "download_code", None) - if download_code: - media_urls.append(download_code) - media_types.append("image") - msg_type = MessageType.PHOTO - + if download_code := (getattr(image_content, "download_code", None) if image_content else None): + media_urls.append(download_code) + media_types.append("image") + msg_type = MessageType.PHOTO for item in _rich_list(message) or (): if not isinstance(item, dict): continue dl_code = item.get("downloadCode") or item.get("download_code") or "" - item_type = item.get("type", "") if not dl_code: continue + item_type = item.get("type", "") mapped = DINGTALK_TYPE_MAPPING.get(item_type, "file") + # "voice" items are native voice notes → STT (VOICE); "audio" file uploads stay AUDIO. + mime, promoted = ("audio", MessageType.VOICE if item_type == "voice" else MessageType.AUDIO) if mapped == "audio" else _RICH_MEDIA[mapped] media_urls.append(dl_code) - if mapped == "audio": - media_types.append("audio") - if msg_type == MessageType.TEXT: - # "voice" items are native voice notes → STT (VOICE); "audio" file uploads stay AUDIO. - msg_type = MessageType.VOICE if item_type == "voice" else MessageType.AUDIO - else: - mime, promoted = _RICH_MEDIA[mapped] - media_types.append(mime) - if msg_type == MessageType.TEXT: - msg_type = promoted - + media_types.append(mime) + if msg_type == MessageType.TEXT: + msg_type = promoted msg_type_str = getattr(message, "message_type", "") or "" if msg_type_str == "picture" and not media_urls: msg_type = MessageType.PHOTO @@ -201,24 +143,14 @@ def extract_media(message: Any) -> Tuple[MessageType, List[str], List[str]]: if msg_type == MessageType.TEXT: msg_type = MessageType.VOICE elif msg_type_str in ("file", "image"): - ext_content = _ext_content(message) - if ext_content: - dl_code = ext_content.get("downloadCode") or "" + ext_content = _ext_content(message) or {} + if dl_code := ext_content.get("downloadCode") or "": fname = ext_content.get("fileName", "") - if dl_code: - media_urls.append(dl_code) - mime = "application/octet-stream" - if fname: - ext = fname.rsplit(".", 1)[-1].lower() if "." in fname else "" - mime = EXT_MAP.get(ext, mime) - media_types.append(mime) - if msg_type == MessageType.TEXT: - # Image messages, and files with image MIME (a .png sent as attachment), → PHOTO. - if msg_type_str == "image" or mime.startswith("image/"): - msg_type = MessageType.PHOTO - else: - msg_type = MessageType.DOCUMENT - + mime = EXT_MAP.get(fname.rsplit(".", 1)[-1].lower() if fname and "." in fname else "", "application/octet-stream") + media_urls.append(dl_code) + media_types.append(mime) + if msg_type == MessageType.TEXT: # image messages, and files with image MIME (a .png attachment) → PHOTO + msg_type = MessageType.PHOTO if (msg_type_str == "image" or mime.startswith("image/")) else MessageType.DOCUMENT return msg_type, media_urls, media_types @@ -229,12 +161,9 @@ def collect_download_codes(message: Any) -> List[Tuple[Any, str]]: if img_content and getattr(img_content, "download_code", None): codes.append((img_content, "download_code")) rich_text = getattr(message, "rich_text_content", None) - if rich_text: - for item in getattr(rich_text, "rich_text_list", []) or []: - if isinstance(item, dict): - for key in ("downloadCode", "pictureDownloadCode", "download_code"): - if item.get(key): - codes.append((item, key)) + for item in (getattr(rich_text, "rich_text_list", []) or []) if rich_text else []: + if isinstance(item, dict): + codes.extend((item, key) for key in ("downloadCode", "pictureDownloadCode", "download_code") if item.get(key)) if (getattr(message, "message_type", "") or "") in ("file", "image"): ext_content = _ext_content(message) if ext_content and ext_content.get("downloadCode"): diff --git a/plugins/platforms/discord/ffmpeg_utils.py b/plugins/platforms/discord/ffmpeg_utils.py index a22e1d10ce..a17a85d67a 100644 --- a/plugins/platforms/discord/ffmpeg_utils.py +++ b/plugins/platforms/discord/ffmpeg_utils.py @@ -1,11 +1,6 @@ -"""Shared ffmpeg executable discovery for Discord voice paths. - -Discovery itself is owned by ``tools.transcription_tools`` (the same helper -the STT pipeline uses — PATH plus common Homebrew/local prefixes); this module -only layers the Discord-voice-specific extras on top: an explicit -``FFMPEG_PATH`` override and a Windows winget fallback for installs that -never touch PATH. -""" +"""ffmpeg discovery for Discord voice: ``tools.transcription_tools`` owns the shared lookup +(PATH + Homebrew/local prefixes); this layers an explicit ``FFMPEG_PATH`` override and a +Windows winget fallback (installs that never touch PATH) on top.""" from __future__ import annotations @@ -25,16 +20,13 @@ def _shared_find_ffmpeg(): def resolve_ffmpeg_executable() -> str: """Return an ffmpeg command that also covers common Windows installs.""" - explicit = os.getenv("FFMPEG_PATH") - if explicit and explicit.strip(): - return os.path.expandvars(os.path.expanduser(explicit.strip())) - discovered = _shared_find_ffmpeg() - if discovered: + explicit = (os.getenv("FFMPEG_PATH") or "").strip() + if explicit: + return os.path.expandvars(os.path.expanduser(explicit)) + if discovered := _shared_find_ffmpeg(): return discovered - local_appdata = os.getenv("LOCALAPPDATA") - if local_appdata: - packages_dir = Path(local_appdata) / "Microsoft" / "WinGet" / "Packages" - candidates = sorted(packages_dir.glob("Gyan.FFmpeg_*/*/bin/ffmpeg.exe")) + if local_appdata := os.getenv("LOCALAPPDATA"): + candidates = sorted((Path(local_appdata) / "Microsoft" / "WinGet" / "Packages").glob("Gyan.FFmpeg_*/*/bin/ffmpeg.exe")) if candidates: return str(candidates[-1]) return "ffmpeg" diff --git a/plugins/platforms/discord/recovery.py b/plugins/platforms/discord/recovery.py index bed52b5962..6c005b4eed 100644 --- a/plugins/platforms/discord/recovery.py +++ b/plugins/platforms/discord/recovery.py @@ -55,52 +55,26 @@ class DiscordRecoveryStore: def _initialize(self, conn: sqlite3.Connection) -> None: from hermes_state import apply_wal_with_fallback apply_wal_with_fallback(conn, db_label="discord_recovery.db") - conn.execute(""" + conn.executescript(""" CREATE TABLE IF NOT EXISTS discord_messages ( - message_id TEXT PRIMARY KEY, - channel_id TEXT, - thread_id TEXT, - parent_channel_id TEXT, - author_id TEXT, - created_at TEXT, - status TEXT NOT NULL, - replied INTEGER NOT NULL DEFAULT 0, - emoji_ack INTEGER NOT NULL DEFAULT 0, - outage_response INTEGER NOT NULL DEFAULT 0, - response_message_id TEXT, - attempts INTEGER NOT NULL DEFAULT 0, - last_attempt_at TEXT, - last_error TEXT, + message_id TEXT PRIMARY KEY, channel_id TEXT, thread_id TEXT, parent_channel_id TEXT, + author_id TEXT, created_at TEXT, status TEXT NOT NULL, + replied INTEGER NOT NULL DEFAULT 0, emoji_ack INTEGER NOT NULL DEFAULT 0, + outage_response INTEGER NOT NULL DEFAULT 0, response_message_id TEXT, + attempts INTEGER NOT NULL DEFAULT 0, last_attempt_at TEXT, last_error TEXT, updated_at TEXT NOT NULL - ) - """) - conn.execute(""" + ); CREATE TABLE IF NOT EXISTS discord_recovery_scans ( - scan_id TEXT PRIMARY KEY, - started_at TEXT NOT NULL, - completed_at TEXT, - status TEXT NOT NULL, - channels TEXT NOT NULL, - window_seconds REAL NOT NULL, - limit_count INTEGER NOT NULL, - scanned INTEGER NOT NULL DEFAULT 0, - missed INTEGER NOT NULL DEFAULT 0, - dispatched INTEGER NOT NULL DEFAULT 0, - error TEXT - ) - """) - conn.execute(""" + scan_id TEXT PRIMARY KEY, started_at TEXT NOT NULL, completed_at TEXT, status TEXT NOT NULL, + channels TEXT NOT NULL, window_seconds REAL NOT NULL, limit_count INTEGER NOT NULL, + scanned INTEGER NOT NULL DEFAULT 0, missed INTEGER NOT NULL DEFAULT 0, + dispatched INTEGER NOT NULL DEFAULT 0, error TEXT + ); CREATE TABLE IF NOT EXISTS discord_recovery_cursors ( - channel_id TEXT PRIMARY KEY, - last_message_id TEXT NOT NULL, - updated_at TEXT NOT NULL - ) + channel_id TEXT PRIMARY KEY, last_message_id TEXT NOT NULL, updated_at TEXT NOT NULL + ); """) cutoff = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=_RETENTION_DAYS)).isoformat() conn.execute("DELETE FROM discord_messages WHERE updated_at < ?", (cutoff,)) - conn.execute( - "DELETE FROM discord_recovery_scans " - "WHERE COALESCE(completed_at, started_at) < ?", - (cutoff,), - ) + conn.execute("DELETE FROM discord_recovery_scans WHERE COALESCE(completed_at, started_at) < ?", (cutoff,)) conn.execute("DELETE FROM discord_recovery_cursors WHERE updated_at < ?", (cutoff,)) diff --git a/plugins/platforms/discord/voice_mixer.py b/plugins/platforms/discord/voice_mixer.py index 912fec6f3f..3ab4951d88 100644 --- a/plugins/platforms/discord/voice_mixer.py +++ b/plugins/platforms/discord/voice_mixer.py @@ -1,46 +1,9 @@ from __future__ import annotations -""" -Continuous PCM audio mixer for Discord voice channels. - -discord.py (Rapptz) ships no audio mixer: ``VoiceClient.play()`` accepts a -single :class:`discord.AudioSource` and raises ``ClientException`` if called -while already playing. One opus stream per connection, one source feeding it. - -This module adds software mixing *upstream* of that single stream. A -:class:`VoiceMixer` is itself a ``discord.AudioSource`` that discord.py polls -every 20 ms via :meth:`read`. Internally it sums the 20 ms PCM frames of any -number of child sources, clamps to int16, and returns one blended frame. -discord.py never knows several streams were combined underneath — it just -encodes and sends the single mixed frame. - -This gives us, for one voice connection at once: - - * an always-on low-volume **ambient/idle loop** (the "thinking" sound), - * a **speech** channel (TTS replies, verbal acknowledgements) that plays - *over* the ambient bed, automatically **ducking** the ambient gain down - while speech is active and restoring it when speech ends — the smooth - Grok-voice-mode feel, instead of stop-and-swap. - -Design notes ------------- -* The mixer is installed **once** per guild on join (``vc.play(mixer)``) and - runs continuously until the bot leaves. Children come and go; the mixer - itself never stops, so there is no ``is_playing()`` race between an - acknowledgement and the final reply. -* Frame format is Discord-native: 48 kHz, 2 channels, signed 16-bit LE, - 20 ms per frame == ``discord.opus.Encoder.FRAME_SIZE`` bytes - (3840 = 960 samples * 2 channels * 2 bytes). -* Mixing is a single vectorised int32 add + clip per 20 ms frame (numpy, - already a core dependency). CPU cost is negligible. -* :meth:`read` is called from discord.py's audio sender **thread**, while - children are added/removed from the asyncio event loop thread, so all - shared state is guarded by a plain ``threading.Lock``. - -The mixer NEVER touches the inbound receive path: it only produces the bot's -*outgoing* stream. The :class:`VoiceReceiver` decodes incoming SSRCs only, so -the mixer's output cannot echo back into transcription. -""" +"""Continuous PCM mixer for Discord voice. discord.py allows one AudioSource per VoiceClient; +:class:`VoiceMixer` IS that source: installed once per guild, never stops, and every 20 ms sums its +children (looping ambient bed + one-shot speech that ducks the bed) clamped to int16. ``read`` runs on +discord.py's sender thread while children change on the asyncio loop, hence the Lock. Outgoing only.""" import logging import threading @@ -60,22 +23,13 @@ logger = logging.getLogger(__name__) def _require_numpy(): - """Import numpy lazily. - - numpy ships in the optional ``voice`` extra, not the base install, so this - module must import cleanly without it (the Discord adapter imports this - file unconditionally). Callers that actually mix audio call this; if the - voice extra isn't installed they get a clear error instead of a top-level - ImportError that would break the whole adapter import. - """ + """Lazy numpy import: the adapter imports this module unconditionally, so a missing + ``voice`` extra must fail at mix time, not at import time.""" import numpy as np # noqa: PLC0415 — intentional lazy import return np -# Discord-native frame geometry (matches discord.opus.Encoder). -SAMPLE_RATE = 48000 -CHANNELS = 2 -SAMPLE_WIDTH = 2 # bytes per sample (s16) -FRAME_LENGTH_MS = 20 +# Discord-native frame geometry (matches discord.opus.Encoder): 48 kHz, stereo, s16, 20 ms frames. +SAMPLE_RATE, CHANNELS, SAMPLE_WIDTH, FRAME_LENGTH_MS = 48000, 2, 2, 20 SAMPLES_PER_FRAME = SAMPLE_RATE * FRAME_LENGTH_MS // 1000 # 960 FRAME_SIZE = SAMPLES_PER_FRAME * CHANNELS * SAMPLE_WIDTH # 3840 bytes BYTES_PER_MS = SAMPLE_RATE * CHANNELS * SAMPLE_WIDTH // 1000 # 192 @@ -83,43 +37,21 @@ SILENCE_FRAME = b"\x00" * FRAME_SIZE class MixerChild: - """A single audio stream feeding into :class:`VoiceMixer`. + """One 48 kHz / stereo / s16le PCM stream feeding :class:`VoiceMixer`; ``read_frame`` + yields 20 ms frames, optionally looping, with per-child gain and linear fade-in.""" - Wraps raw 48 kHz / stereo / s16le PCM bytes. ``read_frame`` hands back one - 20 ms frame at a time, optionally looping, with a per-child gain applied. - """ + __slots__ = ("_pcm", "_pos", "loop", "gain", "fade_frames", "_fade_done", "_finished") - __slots__ = ( - "name", "_pcm", "_pos", "loop", "gain", - "is_speech", "fade_frames", "_fade_done", "_finished", - ) - - def __init__( - self, name: str, pcm: bytes, *, loop: bool = False, gain: float = 1.0, - is_speech: bool = False, fade_in_ms: int = 0, - ): - # Pad to a whole number of frames so looping is seamless and the final - # partial frame doesn't click. - remainder = len(pcm) % FRAME_SIZE - if remainder: - pcm = pcm + b"\x00" * (FRAME_SIZE - remainder) - self.name = name - self._pcm = pcm - self._pos = 0 - self.loop = loop - self.gain = float(gain) - self.is_speech = is_speech - # Linear fade-in over N frames avoids a click when a loud child starts. + def __init__(self, pcm: bytes, *, loop: bool = False, gain: float = 1.0, fade_in_ms: int = 0): + # Pad to whole frames so looping is seamless and the final partial frame doesn't click. + self._pcm = pcm + b"\x00" * (-len(pcm) % FRAME_SIZE) + self._pos = self._fade_done = 0 + self.loop, self.gain = loop, float(gain) self.fade_frames = max(0, fade_in_ms // FRAME_LENGTH_MS) - self._fade_done = 0 self._finished = False - @property - def finished(self) -> bool: - return self._finished - def read_frame(self) -> "Optional[np.ndarray]": - """Return the next 20 ms frame as an int16 ndarray, or None if done.""" + """Next 20 ms frame as a float32 ndarray, or None when done.""" if self._finished: return None if self._pos >= len(self._pcm): @@ -144,40 +76,23 @@ class MixerChild: class VoiceMixer(discord.AudioSource): - """A continuous ``discord.AudioSource`` that mixes N child streams. + """Continuous ``discord.AudioSource`` mixing N children: :meth:`set_ambient` installs the + looping idle bed, :meth:`play_speech` layers a one-shot clip over it (ducking the bed). + Both are safe from the asyncio thread while discord.py drains :meth:`read`.""" - Use :meth:`set_ambient` to install/replace the looping idle bed and - :meth:`play_speech` to layer a one-shot clip over it (ducking the ambient - while it plays). Both are safe to call from the asyncio loop thread while - discord.py drains :meth:`read` from its sender thread. - """ - - # discord.AudioSource subclasses set is_opus()==False to receive PCM. def is_opus(self) -> bool: # pragma: no cover - trivial return False - def __init__( - self, *, ambient_gain: float = 0.18, duck_gain: float = 0.06, speech_gain: float = 1.0, - duck_release_ms: int = 400, - ): + def __init__(self, *, ambient_gain: float = 0.18, duck_gain: float = 0.06, speech_gain: float = 1.0, + duck_release_ms: int = 400): self._lock = threading.Lock() self._ambient: Optional[MixerChild] = None self._speech: List[MixerChild] = [] - self._ambient_gain = float(ambient_gain) - self._duck_gain = float(duck_gain) - self._speech_gain = float(speech_gain) - # When speech ends, ramp the ambient back up over this many frames - # instead of jumping, so the bed swells back smoothly. + self._ambient_gain, self._duck_gain, self._speech_gain = float(ambient_gain), float(duck_gain), float(speech_gain) + # When speech ends, ramp the ambient back up over this many frames instead of jumping. self._duck_release_frames = max(1, duck_release_ms // FRAME_LENGTH_MS) self._duck_release_left = 0 - self._closed = False - # Tracks whether speech is currently active, for external callers that - # want to avoid double-ducking or know when a reply is mid-flight. - self._speech_active = False - - # ------------------------------------------------------------------ - # Ambient (idle / "thinking") bed - # ------------------------------------------------------------------ + self._closed = self._speech_active = False def set_ambient(self, pcm: Optional[bytes], *, gain: Optional[float] = None) -> None: """Install (or clear, with ``pcm=None``) the looping ambient bed.""" @@ -187,28 +102,17 @@ class VoiceMixer(discord.AudioSource): if not pcm: self._ambient = None return - self._ambient = MixerChild( - "ambient", pcm, loop=True, gain=self._effective_ambient_gain(), fade_in_ms=200, - ) + gain_now = self._duck_gain if self._speech_active else self._ambient_gain + self._ambient = MixerChild(pcm, loop=True, gain=gain_now, fade_in_ms=200) - def _effective_ambient_gain(self) -> float: - return self._duck_gain if self._speech_active else self._ambient_gain - - # ------------------------------------------------------------------ - # Speech (TTS replies, verbal acks) layered over the ambient bed - # ------------------------------------------------------------------ - - def play_speech(self, pcm: bytes, *, gain: Optional[float] = None, - fade_in_ms: int = 40) -> None: + def play_speech(self, pcm: bytes, *, gain: Optional[float] = None, fade_in_ms: int = 40) -> None: """Layer a one-shot speech clip over the ambient bed (ducks ambient).""" if not pcm: return with self._lock: - child = MixerChild( - "speech", pcm, loop=False, gain=self._speech_gain if gain is None else float(gain), - is_speech=True, fade_in_ms=fade_in_ms, - ) - self._speech.append(child) + self._speech.append(MixerChild( + pcm, gain=self._speech_gain if gain is None else float(gain), fade_in_ms=fade_in_ms, + )) self._speech_active = True self._duck_release_left = 0 if self._ambient is not None: @@ -229,17 +133,9 @@ class VoiceMixer(discord.AudioSource): self._speech_active = False self._duck_release_left = self._duck_release_frames - # ------------------------------------------------------------------ - # AudioSource interface — called from discord.py's sender thread - # ------------------------------------------------------------------ - def read(self) -> bytes: - """Return one 20 ms mixed PCM frame (always FRAME_SIZE bytes). - - Returning a non-empty frame keeps discord.py's player alive; we never - return b"" because that would stop the single underlying stream and we - want the mixer to run continuously for the lifetime of the connection. - """ + """One 20 ms mixed PCM frame (always FRAME_SIZE bytes) — never b"", which would stop + discord.py's player; the mixer must run for the lifetime of the connection.""" with self._lock: if self._closed: return SILENCE_FRAME @@ -262,10 +158,7 @@ class VoiceMixer(discord.AudioSource): if self._duck_release_left > 0 and not self._speech_active: self._duck_release_left -= 1 frac = 1.0 - (self._duck_release_left / self._duck_release_frames) - self._ambient.gain = ( - self._duck_gain - + (self._ambient_gain - self._duck_gain) * frac - ) + self._ambient.gain = self._duck_gain + (self._ambient_gain - self._duck_gain) * frac elif not self._speech_active and self._duck_release_left == 0: self._ambient.gain = self._ambient_gain amb = self._ambient.read_frame() @@ -283,52 +176,32 @@ class VoiceMixer(discord.AudioSource): self._speech.clear() -# ---------------------------------------------------------------------- -# PCM helpers -# ---------------------------------------------------------------------- - def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]: - """Decode any audio file to 48 kHz / stereo / s16le PCM via ffmpeg. - - Returns the raw PCM bytes, or None on failure. ffmpeg is already a hard - requirement of the voice path (see ``VoiceReceiver.pcm_to_wav``). - """ + """Decode any audio file to 48 kHz / stereo / s16le PCM via ffmpeg; None on failure.""" import subprocess try: proc = subprocess.run( - [ - resolve_ffmpeg_executable(), "-y", "-loglevel", "error", "-i", path, "-f", "s16le", - "-ar", str(SAMPLE_RATE), "-ac", str(CHANNELS), "pipe:1", - ], - capture_output=True, - timeout=timeout, - stdin=subprocess.DEVNULL, + [resolve_ffmpeg_executable(), "-y", "-loglevel", "error", "-i", path, "-f", "s16le", + "-ar", str(SAMPLE_RATE), "-ac", str(CHANNELS), "pipe:1"], + capture_output=True, timeout=timeout, stdin=subprocess.DEVNULL, ) except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e: logger.warning("decode_to_pcm failed for %s: %s", path, e) return None if proc.returncode != 0: - logger.warning( - "ffmpeg decode failed for %s (rc=%d): %s", - path, proc.returncode, (proc.stderr or b"").decode("utf-8", "replace")[:200], - ) + logger.warning("ffmpeg decode failed for %s (rc=%d): %s", + path, proc.returncode, (proc.stderr or b"").decode("utf-8", "replace")[:200]) return None return proc.stdout or None def synth_ambient_pcm(seconds: float = 4.0) -> bytes: - """Synthesise a subtle looping ambient bed (no asset file required). - - A soft, slowly-pulsing low pad: two detuned sine partials with a gentle - tremolo, plus a touch of filtered noise. Designed to loop seamlessly - (whole number of cycles, zero-crossing endpoints) and sit quietly under - speech. Mono content duplicated to stereo. - """ + """Synthesise a subtle looping ambient bed: two detuned sine partials with a slow tremolo + plus filtered noise; whole-cycle frequencies make the loop point click-free. Mono -> stereo.""" np = _require_numpy() n = int(SAMPLE_RATE * seconds) t = np.arange(n, dtype=np.float64) / SAMPLE_RATE - # Choose base frequencies that complete whole cycles over the loop so the - # wrap point is click-free. + def _whole_cycle_freq(target: float) -> float: cycles = max(1, round(target * seconds)) return cycles / seconds @@ -338,7 +211,6 @@ def synth_ambient_pcm(seconds: float = 4.0) -> bytes: pad = (0.55 * np.sin(2 * np.pi * f1 * t) + 0.45 * np.sin(2 * np.pi * f2 * t)) tremolo = 0.6 + 0.4 * (0.5 * (1 + np.sin(2 * np.pi * trem * t))) signal = pad * tremolo - # Smooth filtered noise for air, kept very low. rng = np.random.default_rng(7) noise = rng.standard_normal(n) kernel = np.ones(64) / 64.0 diff --git a/plugins/platforms/email/adapter.py b/plugins/platforms/email/adapter.py index c67c5d7b6e..9bbd709cf7 100644 --- a/plugins/platforms/email/adapter.py +++ b/plugins/platforms/email/adapter.py @@ -1,27 +1,9 @@ -""" -Email platform adapter for the Hermes gateway. - -Allows users to interact with Hermes by sending emails. -Uses IMAP to receive and SMTP to send messages. - -Environment variables: - EMAIL_IMAP_HOST — IMAP server host (e.g., imap.gmail.com) - EMAIL_IMAP_PORT — IMAP server port (default: 993) - EMAIL_IMAP_SECURITY — IMAP transport: tls, starttls, or plain (default: tls) - EMAIL_IMAP_TLS_VERIFY — Verify the IMAP TLS certificate (default: true) - EMAIL_SMTP_HOST — SMTP server host (e.g., smtp.gmail.com) - EMAIL_SMTP_PORT — SMTP server port (default: 587) - EMAIL_SMTP_SECURITY — SMTP transport: tls, starttls, or plain (port-based default) - EMAIL_SMTP_TLS_VERIFY — Verify the SMTP TLS certificate (default: true) - EMAIL_ADDRESS — Email address for the agent - EMAIL_PASSWORD — Email password or app-specific password - EMAIL_POLL_INTERVAL — Seconds between mailbox checks (default: 15) - EMAIL_ALLOWED_USERS — Comma-separated list of allowed sender addresses -""" +"""Email platform adapter for the Hermes gateway: users talk to Hermes by sending email; IMAP (polled) +receives, SMTP sends. Configured via EMAIL_* env vars or ``platforms.email`` in config.yaml (see website docs).""" import asyncio import email as email_lib -from contextlib import contextmanager +from contextlib import contextmanager, suppress import imaplib import logging import os @@ -39,57 +21,55 @@ from email import encoders from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -from gateway.platforms.base import ( - BasePlatformAdapter, - MessageEvent, - MessageType, - SendResult, - cache_document_from_bytes, - cache_image_from_bytes, -) +from gateway.platforms.base import (BasePlatformAdapter, MessageEvent, MessageType, SendResult, + cache_document_from_bytes, cache_image_from_bytes) from gateway.config import Platform, PlatformConfig from utils import is_truthy_value -from gateway.platforms._shared import get_scoped_secret as _get_esecret, coerce_port +from gateway.platforms._shared import get_scoped_secret as _get_secret, coerce_port logger = logging.getLogger(__name__) - -# Backwards-compatible alias. -_get_secret = _get_esecret +_SECURITY_ALIASES = {"tls": "tls", "ssl": "tls", "implicit": "tls", "starttls": "starttls", "plain": "plain", "none": "plain"} +# Automated senders (address substrings / bulk-mail headers) are silently ignored. +_NOREPLY_PATTERNS = ("noreply", "no-reply", "no_reply", "donotreply", "do-not-reply", "mailer-daemon", "postmaster", + "bounce", "notifications@", "automated@", "auto-confirm", "auto-reply", "automailer") +_AUTOMATED_HEADERS = {"Auto-Submitted": lambda v: v.lower() != "no", + "Precedence": lambda v: v.lower() in {"bulk", "list", "junk"}, + "X-Auto-Response-Suppress": lambda v: bool(v), "List-Unsubscribe": lambda v: bool(v)} +MAX_MESSAGE_LENGTH = 50_000 # Gmail-safe max length per email body +SMTP_CONNECT_TIMEOUT = 30 +_TRUTHY = {"true", "1", "yes"} +_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} +# Charset labels seen in the wild that Python's codec registry doesn't know: "unknown-8bit"/"x-unknown" are +# RFC 1428 placeholders (QQ Mail emits them); gb2312/gbk map to the gb18030 superset so GBK extensions decode. +_CHARSET_ALIASES = {"unknown-8bit": "utf-8", "unknown": "utf-8", "x-unknown": "utf-8", "default": "utf-8", + "ansi_x3.110-1983": "latin-1", "cp-850": "cp850", + "gb2312": "gb18030", "gbk": "gb18030", "ks_c_5601-1987": "cp949"} +# Ordered (pattern, replacement) substitutions for _strip_html. +_HTML_SUBS = ((re.compile(r"", re.IGNORECASE), "\n"), (re.compile(r"]*>", re.IGNORECASE), "\n"), + (re.compile(r"

", re.IGNORECASE), "\n"), (re.compile(r"<[^>]+>"), ""), (re.compile(r" "), " "), + (re.compile(r"&"), "&"), (re.compile(r"<"), "<"), (re.compile(r">"), ">"), (re.compile(r"\n{3,}"), "\n\n")) +# "method=result" tokens (``dmarc=pass``) and property values (``header.from=x``) in Authentication-Results. +_AUTH_METHOD_RE = re.compile(r"\b(dmarc|dkim|spf)\s*=\s*([a-z]+)", re.IGNORECASE) +_AUTH_PROP_RE = re.compile(r"\b(header\.from|header\.d|smtp\.mailfrom|smtp\.from|envelope-from)\s*=\s*([^\s;]+)", re.IGNORECASE) def _esecret_int(name: str, default: int) -> int: - """Scope-aware integer read (``env_int`` variant of ``_get_esecret``).""" - return coerce_port(str(_get_esecret(name, "")).strip() or default, default) + """Scope-aware integer read.""" + return coerce_port(str(_get_secret(name, "")).strip() or default, default) def _esecret_bool(name: str, default: bool = False) -> bool: - """Scope-aware boolean read (``env_bool`` variant of ``_get_esecret``).""" - raw = str(_get_esecret(name, "")).strip() - return is_truthy_value(raw, default=default) if raw else default - - -_SECURITY_ALIASES = { - "tls": "tls", "ssl": "tls", "implicit": "tls", - "starttls": "starttls", - "plain": "plain", "none": "plain", -} + """Scope-aware boolean read.""" + return is_truthy_value(raw, default=default) if (raw := str(_get_secret(name, "")).strip()) else default def _normalize_security(value: Any, default: str = "tls") -> str: - """Map an IMAP/SMTP security setting to ``tls`` | ``starttls`` | ``plain``. - - Unknown values warn and fall back to *default* so a typo never silently - downgrades to plaintext. - """ + """Map to ``tls`` | ``starttls`` | ``plain``; unknown values warn and fall back to *default* (a typo never downgrades to plaintext).""" raw = str(value or "").strip().lower().replace("-", "").replace("_", "") - if not raw: - return default - mode = _SECURITY_ALIASES.get(raw) - if mode is None: + if raw and raw not in _SECURITY_ALIASES: logger.warning("Unknown email security mode %r; using %r", value, default) - return default - return mode + return _SECURITY_ALIASES.get(raw, default) def _tls_context(verify: bool, host: str) -> ssl.SSLContext: @@ -101,52 +81,20 @@ def _tls_context(verify: bool, host: str) -> ssl.SSLContext: return ssl._create_unverified_context() -# Automated sender patterns — emails from these are silently ignored -_NOREPLY_PATTERNS = ( - "noreply", "no-reply", "no_reply", "donotreply", "do-not-reply", - "mailer-daemon", "postmaster", "bounce", "notifications@", - "automated@", "auto-confirm", "auto-reply", "automailer", -) - -# RFC headers that indicate bulk/automated mail -_AUTOMATED_HEADERS = { - "Auto-Submitted": lambda v: v.lower() != "no", - "Precedence": lambda v: v.lower() in {"bulk", "list", "junk"}, - "X-Auto-Response-Suppress": lambda v: bool(v), - "List-Unsubscribe": lambda v: bool(v), -} - -# Gmail-safe max length per email body -MAX_MESSAGE_LENGTH = 50_000 - -SMTP_CONNECT_TIMEOUT = 30 - -_TRUTHY = {"true", "1", "yes"} - - def _close_imap(imap: "imaplib.IMAP4") -> None: - """Best-effort teardown that guarantees the socket is closed. - - ``IMAP4.logout()`` only guards ``OSError``; ``IMAP4.abort`` on a broken - connection escapes before its ``shutdown()``, leaking one fd per failed poll - (fatal on macOS's 256 soft limit). Chase it with an unconditional ``shutdown()``. - """ + """Teardown that guarantees the socket closes: ``logout()`` only guards ``OSError``, so ``IMAP4.abort`` on a + broken connection skipped ``shutdown()`` and leaked one fd per failed poll (fatal on macOS's 256 soft limit).""" try: imap.logout() except Exception: - try: + with suppress(Exception): imap.shutdown() - except Exception: - pass def _create_ipv4_connection(host: str, port: int, timeout: float, source_address: Any = None) -> socket.socket: - """``socket.create_connection`` constrained to ``AF_INET`` (no process-global - socket mutation — email sends run in executor threads).""" + """``socket.create_connection`` constrained to ``AF_INET`` (no process-global socket mutation — sends run in executor threads).""" last_error: OSError | None = None - for family, socktype, proto, _canonname, sockaddr in socket.getaddrinfo( - host, port, socket.AF_INET, socket.SOCK_STREAM - ): + for family, socktype, proto, _canonname, sockaddr in socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_STREAM): sock = socket.socket(family, socktype, proto) sock.settimeout(timeout) try: @@ -157,9 +105,7 @@ def _create_ipv4_connection(host: str, port: int, timeout: float, source_address except OSError as exc: last_error = exc sock.close() - if last_error is not None: - raise last_error - raise OSError(f"No IPv4 address found for {host}:{port}") + raise last_error if last_error is not None else OSError(f"No IPv4 address found for {host}:{port}") class _IPv4SMTP(smtplib.SMTP): @@ -169,12 +115,10 @@ class _IPv4SMTP(smtplib.SMTP): class _IPv4SMTP_SSL(smtplib.SMTP_SSL): def _get_socket(self, host, port, timeout): # type: ignore[override] - raw_sock = _create_ipv4_connection(host, port, timeout, source_address=self.source_address) - return self.context.wrap_socket(raw_sock, server_hostname=getattr(self, "_host", host)) + return self.context.wrap_socket(_create_ipv4_connection(host, port, timeout, source_address=self.source_address), server_hostname=getattr(self, "_host", host)) -def _open_smtp(host: str, port: int, security: str, ctx: ssl.SSLContext, - smtp_cls: type, smtp_ssl_cls: type, **kwargs: Any) -> smtplib.SMTP: +def _open_smtp(host: str, port: int, security: str, ctx: ssl.SSLContext, smtp_cls: type, smtp_ssl_cls: type, **kwargs: Any) -> smtplib.SMTP: """Open one SMTP connection with TLS established per *security*; *kwargs* go to the constructor.""" if security == "tls": return smtp_ssl_cls(host, port, context=ctx, **kwargs) @@ -188,71 +132,36 @@ def _open_smtp(host: str, port: int, security: str, ctx: ssl.SSLContext, return smtp -# Supported image extensions for inline detection -_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} - - def _send_imap_id(imap: "imaplib.IMAP4") -> None: - """Send RFC 2971 IMAP ID. 163/NetEase require it after LOGIN (else every UID - command returns ``BYE Unsafe Login``); other servers may reject it, so failures are swallowed.""" + """Send RFC 2971 IMAP ID: 163/NetEase require it after LOGIN (else every UID command + returns ``BYE Unsafe Login``); other servers may reject it, so failures are swallowed.""" try: try: from hermes_cli import __version__ as _hermes_version except Exception: # noqa: BLE001 — keep ID best-effort if import fails _hermes_version = "0" - imap.xatom( - "ID", - f'("name" "hermes-agent" "version" "{_hermes_version}" ' - '"vendor" "NousResearch" ' - '"support-email" "noreply@nousresearch.com")', - ) + imap.xatom("ID", f'("name" "hermes-agent" "version" "{_hermes_version}" ' + '"vendor" "NousResearch" "support-email" "noreply@nousresearch.com")') except Exception as e: # noqa: BLE001 — best-effort, never fatal logger.debug("[Email] IMAP ID command not accepted: %s", e) def _is_automated_sender(address: str, headers: dict) -> bool: - """Return True if this email is from an automated/noreply source.""" + """True if this email is from an automated/noreply source.""" addr = address.lower() - if any(pattern in addr for pattern in _NOREPLY_PATTERNS): - return True - for header, check in _AUTOMATED_HEADERS.items(): - value = headers.get(header, "") - if value and check(value): - return True - return False + return any(pattern in addr for pattern in _NOREPLY_PATTERNS) or any( + (value := headers.get(header, "")) and check(value) for header, check in _AUTOMATED_HEADERS.items()) def check_email_requirements() -> bool: - """True when all email settings are present and non-blank (blank ``EMAIL_*`` - keys left by an abandoned setup must not enable the platform).""" - return all( - _get_secret(name, "").strip() - for name in ("EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST") - ) - - -_CHARSET_ALIASES = { - # Aliases seen in the wild that Python's codec registry doesn't know. - # "unknown-8bit" / "x-unknown" are RFC 1428 placeholders some MTAs (QQ - # Mail among them) emit when the original charset was lost. - "unknown-8bit": "utf-8", - "unknown": "utf-8", - "x-unknown": "utf-8", - "default": "utf-8", - "ansi_x3.110-1983": "latin-1", - "cp-850": "cp850", - "gb2312": "gb18030", # superset; avoids failures on GBK extensions - "gbk": "gb18030", - "ks_c_5601-1987": "cp949", -} + """True when all email settings are present and non-blank (blank keys left by an abandoned setup must not enable the platform).""" + return all(_get_secret(name, "").strip() for name in ("EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST")) def _safe_decode(payload: bytes, charset: "Optional[str]") -> str: - """Decode *payload* without ever raising: ``errors="replace"`` does not guard a - missing codec (``LookupError``), so fall back via alias table → UTF-8 → latin-1.""" + """Decode without ever raising: ``errors="replace"`` does not guard a missing codec (``LookupError``), so fall back alias → UTF-8 → latin-1.""" label = (charset or "utf-8").strip().strip("\"'").lower() or "utf-8" - label = _CHARSET_ALIASES.get(label, label) - for candidate in (label, "utf-8"): + for candidate in (_CHARSET_ALIASES.get(label, label), "utf-8"): try: return payload.decode(candidate, errors="replace") except (LookupError, ValueError): @@ -266,48 +175,28 @@ def _decode_header_value(raw: str) -> str: parts = decode_header(raw) except Exception: # malformed RFC 2047 structure return raw - return " ".join( - _safe_decode(part, charset) if isinstance(part, bytes) else part - for part, charset in parts - ) + return " ".join(_safe_decode(part, charset) if isinstance(part, bytes) else part for part, charset in parts) def _first_body_part(msg: email_lib.message.Message, content_type: str) -> str: """Decoded text of the first non-attachment part of *content_type*, or ''.""" for part in msg.walk(): - if "attachment" in str(part.get("Content-Disposition", "")): + if "attachment" in str(part.get("Content-Disposition", "")) or part.get_content_type() != content_type: continue - if part.get_content_type() == content_type: - payload = part.get_payload(decode=True) - if payload: - return _safe_decode(payload, part.get_content_charset()) + if payload := part.get_payload(decode=True): + return _safe_decode(payload, part.get_content_charset()) return "" def _extract_text_body(msg: email_lib.message.Message) -> str: """Extract the plain-text body from a potentially multipart email.""" if msg.is_multipart(): - text = _first_body_part(msg, "text/plain") - if text: - return text html = _first_body_part(msg, "text/html") - return _strip_html(html) if html else "" - payload = msg.get_payload(decode=True) - if not payload: - return "" - text = _safe_decode(payload, msg.get_content_charset()) + return _first_body_part(msg, "text/plain") or (_strip_html(html) if html else "") + text = _safe_decode(payload, msg.get_content_charset()) if (payload := msg.get_payload(decode=True)) else "" return _strip_html(text) if msg.get_content_type() == "text/html" else text -# Ordered (pattern, replacement) substitutions for _strip_html. -_HTML_SUBS = ( - (re.compile(r"", re.IGNORECASE), "\n"), (re.compile(r"]*>", re.IGNORECASE), "\n"), - (re.compile(r"

", re.IGNORECASE), "\n"), (re.compile(r"<[^>]+>"), ""), - (re.compile(r" "), " "), (re.compile(r"&"), "&"), (re.compile(r"<"), "<"), - (re.compile(r">"), ">"), (re.compile(r"\n{3,}"), "\n\n"), -) - - def _strip_html(html: str) -> str: """Naive HTML tag stripper for fallback text extraction.""" for pattern, repl in _HTML_SUBS: @@ -322,9 +211,8 @@ def _extract_email_address(raw: str) -> str: def _domain_of(address: str) -> str: - """Return the lowercased domain part of an email address, or ''.""" - _, _, domain = address.rpartition("@") - return domain.strip().lower() + """Lowercased domain part of an email address, or ''.""" + return address.rpartition("@")[2].strip().lower() def _domains_aligned(a: str, b: str) -> bool: @@ -334,61 +222,32 @@ def _domains_aligned(a: str, b: str) -> bool: return bool(a and b) and (a == b or a.endswith("." + b) or b.endswith("." + a)) -# "method=result" tokens (``dmarc=pass``) and property values -# (``header.from=example.com``) in an Authentication-Results header. -_AUTH_METHOD_RE = re.compile(r"\b(dmarc|dkim|spf)\s*=\s*([a-z]+)", re.IGNORECASE) -_AUTH_PROP_RE = re.compile( - r"\b(header\.from|header\.d|smtp\.mailfrom|smtp\.from|envelope-from)\s*=\s*([^\s;]+)", - re.IGNORECASE, -) - - -def _verify_sender_authentication(msg: email_lib.message.Message, from_addr: str, *, - authserv_id: str = "") -> Tuple[bool, str]: - """Verify that the message's ``From:`` domain is authenticated. - - ``From:`` is attacker-controlled (GHSA-rxqh-5572-8m77); the only trustworthy - signal is the ``Authentication-Results`` header stamped by the *receiving* - server. It prepends, so the FIRST instance is trusted and any injected copy - sorts below it. Pinned to *authserv_id* when given. - - Returns ``(authenticated, reason)``; True on a DMARC pass, an aligned SPF - pass, or an aligned DKIM (``header.d``) pass. No header → fail-closed - (opt out via ``EmailAdapter._require_authenticated_sender``). - """ +def _verify_sender_authentication(msg: email_lib.message.Message, from_addr: str, *, authserv_id: str = "") -> Tuple[bool, str]: + """Verify the ``From:`` domain is authenticated; returns ``(authenticated, reason)``. + ``From:`` is attacker-controlled (GHSA-rxqh-5572-8m77); the only trustworthy signal is the + ``Authentication-Results`` header stamped by the *receiving* server. It prepends, so the FIRST + instance is trusted and an injected copy sorts below it; pinned to *authserv_id* when given. + True on DMARC pass, aligned SPF pass, or aligned DKIM (``header.d``) pass. No header → fail-closed + (opt out via ``EmailAdapter._require_authenticated_sender``).""" from_domain = _domain_of(from_addr) if not from_domain: return False, "missing From domain" - headers = msg.get_all("Authentication-Results") or [] - if not headers: + if not (headers := msg.get_all("Authentication-Results")): return False, "no Authentication-Results header" - trusted = None - for raw in headers: - value = " ".join(str(raw).split()) - if authserv_id: - # authserv-id is the first token before the first ';' - serv = value.split(";", 1)[0].strip().lower() - if not _domains_aligned(serv, authserv_id) and serv != authserv_id.lower(): - continue - trusted = value - break + values = (" ".join(str(raw).split()) for raw in headers) # authserv-id precedes the first ';' + trusted = next((v for v in values if not authserv_id or (serv := v.split(";", 1)[0].strip().lower()) == authserv_id.lower() + or _domains_aligned(serv, authserv_id)), None) if trusted is None: return False, "no Authentication-Results from trusted authserv-id" - methods = {m.lower(): r.lower() for m, r in _AUTH_METHOD_RE.findall(trusted)} props = {p.lower(): v.strip().strip('"') for p, v in _AUTH_PROP_RE.findall(trusted)} - # DMARC already enforces From alignment, so a pass is sufficient. - if methods.get("dmarc") == "pass": + if methods.get("dmarc") == "pass": # DMARC already enforces From alignment return True, "dmarc=pass" - # SPF pass: envelope/MAIL FROM domain must align with From. - if methods.get("spf") == "pass": - spf_domain = (_domain_of(props.get("smtp.mailfrom", "")) or props.get("smtp.from", "") - or props.get("envelope-from", "")) - spf_domain = _domain_of(spf_domain) if "@" in spf_domain else spf_domain - if _domains_aligned(spf_domain, from_domain): + if methods.get("spf") == "pass": # envelope/MAIL FROM domain must align with From + spf_domain = _domain_of(props.get("smtp.mailfrom", "")) or props.get("smtp.from", "") or props.get("envelope-from", "") + if _domains_aligned(_domain_of(spf_domain) if "@" in spf_domain else spf_domain, from_domain): return True, "spf=pass aligned" - # DKIM pass: signing domain header.d must align with From. - if methods.get("dkim") == "pass": + if methods.get("dkim") == "pass": # signing domain header.d must align with From dkim_domain = props.get("header.d", "") or _domain_of(props.get("header.from", "")) if _domains_aligned(dkim_domain, from_domain): return True, "dkim=pass aligned" @@ -396,39 +255,27 @@ def _verify_sender_authentication(msg: email_lib.message.Message, from_addr: str def _extract_attachments(msg: email_lib.message.Message, skip_attachments: bool = False) -> List[Dict[str, Any]]: - """Extract attachment metadata and cache files locally. - - When *skip_attachments* is True, all attachment/inline parts are ignored. - """ + """Extract attachment metadata and cache files locally (nothing when *skip_attachments*).""" attachments = [] if not msg.is_multipart(): return attachments for part in msg.walk(): - disposition = str(part.get("Content-Disposition", "")) - if skip_attachments or ("attachment" not in disposition and "inline" not in disposition): + disposition, content_type = str(part.get("Content-Disposition", "")), part.get_content_type() + if skip_attachments or ("attachment" not in disposition and ( + "inline" not in disposition or content_type in {"text/plain", "text/html"})): + continue # not an attachment, or an inline text/html body part + filename = _decode_header_value(fn) if (fn := part.get_filename()) else f"attachment.{part.get_content_subtype() or 'bin'}" + if not (payload := part.get_payload(decode=True)): continue - # Skip text/plain and text/html body parts - content_type = part.get_content_type() - if content_type in {"text/plain", "text/html"} and "attachment" not in disposition: - continue - filename = part.get_filename() - filename = _decode_header_value(filename) if filename else f"attachment.{part.get_content_subtype() or 'bin'}" - payload = part.get_payload(decode=True) - if not payload: - continue - ext = Path(filename).suffix.lower() - if ext in _IMAGE_EXTS: + if (ext := Path(filename).suffix.lower()) in _IMAGE_EXTS: try: - cached_path = cache_image_from_bytes(payload, ext) + cached_path, kind = cache_image_from_bytes(payload, ext), "image" except ValueError: logger.debug("Skipping non-image attachment %s (invalid magic bytes)", filename) continue - kind = "image" else: - cached_path = cache_document_from_bytes(payload, filename) - kind = "document" + cached_path, kind = cache_document_from_bytes(payload, filename), "document" attachments.append({"path": cached_path, "filename": filename, "type": kind, "media_type": content_type}) - return attachments @@ -445,27 +292,18 @@ def _attach_file(msg: MIMEMultipart, path: Path, filename: str) -> None: class EmailAdapter(BasePlatformAdapter): """Email gateway adapter using IMAP (receive) and SMTP (send).""" - # Per-account seen-UID snapshot surviving adapter recreation: the reconnect - # watcher builds a FRESH adapter per retry, and without this - # connect(is_reconnect=True) would re-mark the mailbox seen and skip mail - # that arrived during the outage. Keyed by address (multiplex gateways run - # several accounts). Same-process only — a full restart re-baselines. + # Per-account seen-UID snapshot surviving adapter recreation: the reconnect watcher builds a FRESH + # adapter per retry; without this connect(is_reconnect=True) would re-mark the mailbox seen and skip + # mail that arrived during the outage. Keyed by address (multiplex runs several accounts); same-process only. _seen_uids_snapshot: Dict[str, set] = {} def __init__(self, config: PlatformConfig): super().__init__(config, Platform.EMAIL) - - # Env vars first, then PlatformConfig.extra so a config.yaml-only setup - # works. Host/address are stripped: a stray newline made IMAP4_SSL raise - # ``[Errno 8] nodename nor servname`` instead of "host not set". + # Env first, then PlatformConfig.extra (config.yaml-only setups). Host/address are stripped: a stray + # newline made IMAP4_SSL raise ``[Errno 8] nodename nor servname`` instead of "host not set". extra = config.extra or {} - - def setting(env: str, key: str) -> str: - return _get_secret(env, "") or extra.get(key, "") - - def tls_verify(env: str, key: str) -> bool: - return _esecret_bool(env, is_truthy_value(extra.get(key), default=True)) - + setting = lambda env, key: _get_secret(env, "") or extra.get(key, "") # noqa: E731 + tls_verify = lambda env, key: _esecret_bool(env, is_truthy_value(extra.get(key), default=True)) # noqa: E731 self._address = setting("EMAIL_ADDRESS", "address").strip() self._password = _get_secret("EMAIL_PASSWORD", "") self._imap_host = setting("EMAIL_IMAP_HOST", "imap_host").strip() @@ -474,35 +312,23 @@ class EmailAdapter(BasePlatformAdapter): self._imap_tls_verify = tls_verify("EMAIL_IMAP_TLS_VERIFY", "imap_tls_verify") self._smtp_host = setting("EMAIL_SMTP_HOST", "smtp_host").strip() self._smtp_port = _esecret_int("EMAIL_SMTP_PORT", 587) - self._smtp_security = _normalize_security(setting("EMAIL_SMTP_SECURITY", "smtp_security"), - default="tls" if self._smtp_port == 465 else "starttls") + self._smtp_security = _normalize_security(setting("EMAIL_SMTP_SECURITY", "smtp_security"), default="tls" if self._smtp_port == 465 else "starttls") self._smtp_tls_verify = tls_verify("EMAIL_SMTP_TLS_VERIFY", "smtp_tls_verify") self._poll_interval = _esecret_int("EMAIL_POLL_INTERVAL", 15) - - # config.yaml: platforms.email.skip_attachments: true - self._skip_attachments = extra.get("skip_attachments", False) - - # Require an authenticated From: domain (SPF/DKIM/DMARC) before trusting - # it for authorization (GHSA-rxqh-5572-8m77). Default ON; opt out via - # platforms.email.require_authenticated_sender: false or EMAIL_TRUST_FROM_HEADER=true. + self._skip_attachments = extra.get("skip_attachments", False) # platforms.email.skip_attachments + # Require an authenticated From: domain (SPF/DKIM/DMARC) before trusting it for authorization + # (GHSA-rxqh-5572-8m77). Default ON; opt out via require_authenticated_sender: false / EMAIL_TRUST_FROM_HEADER=true. if "require_authenticated_sender" in extra: self._require_authenticated_sender = bool(extra["require_authenticated_sender"]) - elif _esecret_bool("EMAIL_TRUST_FROM_HEADER", False): - self._require_authenticated_sender = False else: - self._require_authenticated_sender = True - - # Optional authserv-id pinning Authentication-Results to the operator's - # own receiving server (defends against an injected header sorting first). + self._require_authenticated_sender = not _esecret_bool("EMAIL_TRUST_FROM_HEADER", False) + # Optional authserv-id pinning Authentication-Results to the operator's own server (defeats an injected header sorting first). self._authserv_id = (extra.get("authserv_id", "") or _get_secret("EMAIL_AUTHSERV_ID", "")).strip().lower() - self._seen_uids: set = set() self._seen_uids_max: int = 2000 # cap to prevent unbounded memory growth self._poll_task: Optional[asyncio.Task] = None - # Distinguish "checked, nothing new" from "the check itself failed". - self._last_fetch_failed: bool = False - self._last_fetch_error: str = "" - # Map chat_id (sender email) -> last subject + message-id for threading + self._last_fetch_failed, self._last_fetch_error = False, "" # "checked, nothing new" vs "the check itself failed" + # chat_id (sender email) -> last subject + message-id for threading self._thread_context: Dict[str, Dict[str, str]] = {} logger.info("[Email] Adapter initialized for %s", self._address) @@ -511,21 +337,16 @@ class EmailAdapter(BasePlatformAdapter): if len(self._seen_uids) <= self._seen_uids_max: return try: - # UIDs are bytes like b'1234' — sort numerically and keep top half - sorted_uids = sorted(self._seen_uids, key=lambda u: int(u)) - keep = self._seen_uids_max // 2 - self._seen_uids = set(sorted_uids[-keep:]) + sorted_uids = sorted(self._seen_uids, key=lambda u: int(u)) # UIDs are bytes like b'1234' + self._seen_uids = set(sorted_uids[-(self._seen_uids_max // 2):]) logger.debug("[Email] Trimmed seen UIDs to %d entries", len(self._seen_uids)) except (ValueError, TypeError): - # Fallback: just clear old entries if sort fails self._seen_uids = set(list(self._seen_uids)[-self._seen_uids_max // 2:]) def _connect_imap(self) -> imaplib.IMAP4: """Create an IMAP connection using implicit TLS, STARTTLS, or plaintext.""" if self._imap_security == "tls": - return imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30, - ssl_context=_tls_context(self._imap_tls_verify, self._imap_host)) - + return imaplib.IMAP4_SSL(self._imap_host, self._imap_port, timeout=30, ssl_context=_tls_context(self._imap_tls_verify, self._imap_host)) imap = imaplib.IMAP4(self._imap_host, self._imap_port, timeout=30) if self._imap_security == "starttls": try: @@ -537,8 +358,7 @@ class EmailAdapter(BasePlatformAdapter): @contextmanager def _inbox(self): - """Logged-in IMAP handle on INBOX; always ``_close_imap``-ed on exit (a - login/select failure used to leak one fd per reconnect attempt).""" + """Logged-in IMAP handle on INBOX; always ``_close_imap``-ed on exit (a login/select failure used to leak one fd per reconnect).""" imap = self._connect_imap() try: imap.login(self._address, self._password) @@ -549,23 +369,16 @@ class EmailAdapter(BasePlatformAdapter): _close_imap(imap) def _connect_smtp(self) -> smtplib.SMTP: - """SMTP connection with TLS established (callers go straight to ``login()``). - - An unreachable IPv6 address can hang until the socket timeout, so - connection-level failures retry through an IPv4-only socket path (no - global resolver mutation). TLS verification errors are not retried. - """ - host, port, security = self._smtp_host, self._smtp_port, self._smtp_security - ctx = _tls_context(self._smtp_tls_verify, host) + """SMTP connection with TLS established (callers go straight to ``login()``). An unreachable IPv6 address can + hang until the socket timeout, so connection-level failures retry through an IPv4-only socket path (no global + resolver mutation); TLS verification errors are not retried.""" + host, port, security, ctx = self._smtp_host, self._smtp_port, self._smtp_security, _tls_context(self._smtp_tls_verify, self._smtp_host) try: - return _open_smtp(host, port, security, ctx, smtplib.SMTP, smtplib.SMTP_SSL, - timeout=SMTP_CONNECT_TIMEOUT) + return _open_smtp(host, port, security, ctx, smtplib.SMTP, smtplib.SMTP_SSL, timeout=SMTP_CONNECT_TIMEOUT) except (socket.timeout, TimeoutError, ConnectionError, OSError) as exc: if isinstance(exc, ssl.SSLError): raise - # Connection-level failure (may be unreachable IPv6): retry IPv4 only. - return _open_smtp(host, port, security, ctx, _IPv4SMTP, _IPv4SMTP_SSL, - timeout=SMTP_CONNECT_TIMEOUT) + return _open_smtp(host, port, security, ctx, _IPv4SMTP, _IPv4SMTP_SSL, timeout=SMTP_CONNECT_TIMEOUT) def _fail(self, log_fmt: str, err: object, code: str, detail: str, *, retryable: bool) -> bool: """Log *err*, record a fatal error for the gateway's reconnect machinery, return False.""" @@ -579,31 +392,22 @@ class EmailAdapter(BasePlatformAdapter): with self._inbox() as imap: snapshot = self._seen_uids_snapshot.get(self._address) if is_reconnect and snapshot is not None: - # Same-process reconnect: restore the previous adapter's - # baseline so mail that arrived during the outage stays - # eligible for the next poll instead of being skipped. + # Same-process reconnect: restore the previous adapter's baseline so mail that + # arrived during the outage stays eligible for the next poll. self._seen_uids = set(snapshot) - self._trim_seen_uids() - logger.info( - "[Email] IMAP reconnect test passed. Restored %d seen UIDs; " - "messages received during the outage will be processed.", - len(self._seen_uids), - ) - else: - # First connect (or no snapshot): mark all existing messages seen. + passed = "[Email] IMAP reconnect test passed. Restored %d seen UIDs; messages received during the outage will be processed." + else: # first connect (or no snapshot): mark all existing messages seen status, data = imap.uid("search", None, "ALL") - if status == "OK" and data and data[0]: - self._seen_uids.update(data[0].split()) - self._trim_seen_uids() - logger.info("[Email] IMAP connection test passed. %d existing messages skipped.", len(self._seen_uids)) + self._seen_uids.update(data[0].split() if status == "OK" and data and data[0] else ()) + passed = "[Email] IMAP connection test passed. %d existing messages skipped." + self._trim_seen_uids() + logger.info(passed, len(self._seen_uids)) self._seen_uids_snapshot[self._address] = set(self._seen_uids) return True except Exception as e: - # Always set an explicit fatal code, else the gateway treats every - # failure as transient with zero owner signal. retryable=True because - # imaplib raises the same generic IMAP4.error for bad credentials AND - # transient NOs (Gmail "too many simultaneous connections"); long-lived - # loops surface via the reconnect watcher's NEEDS_ATTENTION escalation. + # Always set an explicit fatal code, else the gateway treats every failure as transient with zero + # owner signal. retryable=True because imaplib raises the same generic IMAP4.error for bad credentials + # AND transient NOs (Gmail "too many simultaneous connections"); loops surface via NEEDS_ATTENTION. return self._fail("[Email] IMAP connection failed: %s", e, "email_imap_connect_error", f"IMAP connection to {self._imap_host}:{self._imap_port} failed: {e}", retryable=True) @@ -618,39 +422,28 @@ class EmailAdapter(BasePlatformAdapter): logger.info("[Email] SMTP connection test passed.") return True except smtplib.SMTPAuthenticationError as e: - # Typed auth failure (535 & friends) can never self-heal, so drop out - # of the reconnect queue — unambiguous, unlike IMAP4.error above. - return self._fail( - "[Email] SMTP authentication failed: %s", e, "email_auth_error", - f"SMTP authentication failed for {self._address}: {e}. " - "Check EMAIL_PASSWORD (for Gmail/Outlook this must be an " - "app password, not the account password).", - retryable=False, - ) + # Typed auth failure (535 & friends) can never self-heal, so drop out of the reconnect queue — unambiguous, unlike IMAP4.error. + return self._fail("[Email] SMTP authentication failed: %s", e, "email_auth_error", + f"SMTP authentication failed for {self._address}: {e}. Check EMAIL_PASSWORD (for Gmail/Outlook " + "this must be an app password, not the account password).", retryable=False) except Exception as e: return self._fail("[Email] SMTP connection failed: %s", e, "email_smtp_connect_error", f"SMTP connection to {self._smtp_host} failed: {e}", retryable=True) async def connect(self, *, is_reconnect: bool = False) -> bool: """Connect to the IMAP server and start polling for new messages.""" - # Validate up front so a missing host is an actionable config error, not - # IMAP4_SSL("") raising ``[Errno 8] nodename nor servname provided``. - required = (("EMAIL_ADDRESS", self._address), ("EMAIL_PASSWORD", self._password), - ("EMAIL_IMAP_HOST", self._imap_host), ("EMAIL_SMTP_HOST", self._smtp_host)) - missing = [name for name, value in required if not value] - if missing: - message = ("Not configured — missing " + ", ".join(missing) - + ". Set it via `hermes gateway setup` (env) or platforms.email in config.yaml.") - # Non-retryable: a blank-but-present env var (``EMAIL_IMAP_HOST=``) - # used to drive an indefinite retry loop that leaked until OOM. + # Validate up front so a missing host is an actionable config error, not IMAP4_SSL("") raising ``[Errno 8]``. + required = (("EMAIL_ADDRESS", self._address), ("EMAIL_PASSWORD", self._password), ("EMAIL_IMAP_HOST", self._imap_host), ("EMAIL_SMTP_HOST", self._smtp_host)) + if missing := [name for name, value in required if not value]: + message = f"Not configured — missing {', '.join(missing)}. Set it via `hermes gateway setup` (env) or platforms.email in config.yaml." + # Non-retryable: a blank-but-present env var used to drive an indefinite retry loop that leaked until OOM. return self._fail("[Email] %s", message, "email_missing_configuration", message, retryable=False) if not self._probe_imap(is_reconnect) or not self._probe_smtp(): return False self._running = True self._poll_task = asyncio.create_task(self._poll_loop()) print(f"[Email] Connected as {self._address}") - # Plugin-registered native handlers (ctx.register_platform_handler). - self._wire_plugin_handlers(None) + self._wire_plugin_handlers(None) # plugin-registered native handlers return True async def disconnect(self) -> None: @@ -658,10 +451,8 @@ class EmailAdapter(BasePlatformAdapter): self._running = False if self._poll_task: self._poll_task.cancel() - try: + with suppress(asyncio.CancelledError): await self._poll_task - except asyncio.CancelledError: - pass self._poll_task = None logger.info("[Email] Disconnected.") @@ -678,17 +469,13 @@ class EmailAdapter(BasePlatformAdapter): async def _check_inbox(self) -> None: """Check INBOX for unseen messages and dispatch them.""" - loop = asyncio.get_running_loop() - messages = await loop.run_in_executor(None, self._fetch_new_messages) - # Dispatch partial results BEFORE escalating a failure — a mid-batch - # exception returns what was fetched, and those are already marked seen. + messages = await asyncio.get_running_loop().run_in_executor(None, self._fetch_new_messages) + # Dispatch partial results BEFORE escalating a failure — a mid-batch exception returns what was fetched (already marked seen). for msg_data in messages: await self._dispatch_message(msg_data) if self._last_fetch_failed: - # The IMAP check itself failed (not an empty inbox): route through the - # fatal-error hook so the gateway's reconnect/backoff re-establishes the - # mailbox. The handler runs in a detached task (gateway/run.py), so - # awaiting it from our own poll task is safe despite teardown cancelling us. + # The IMAP check itself failed (not an empty inbox): route through the fatal-error hook so the gateway's + # reconnect/backoff re-establishes the mailbox. The handler runs detached (gateway/run.py), so awaiting it is safe. self._last_fetch_failed = False self._set_fatal_error("email_imap_fetch_failed", self._last_fetch_error or "IMAP fetch failed", retryable=True) await self._notify_fatal_error() @@ -699,21 +486,16 @@ class EmailAdapter(BasePlatformAdapter): try: with self._inbox() as imap: status, data = imap.uid("search", None, "UNSEEN") - uids = data[0].split() if status == "OK" and data and data[0] else [] - for uid in uids: + for uid in (data[0].split() if status == "OK" and data and data[0] else []): if uid in self._seen_uids: continue status, msg_data = imap.uid("fetch", uid, "(RFC822)") if status != "OK": - # Transient per-UID refusal: leave UID unseen so the next poll retries. - continue - # Mark seen once a response arrived (even malformed) so garbage - # is skipped once, not retried forever — but NOT before the - # fetch: a connection failure must leave the rest of the batch - # eligible for the next poll. + continue # transient per-UID refusal: leave unseen so the next poll retries + # Mark seen once a response arrived (even malformed) so garbage is skipped once, not retried forever — + # but NOT before the fetch: a connection failure must leave the rest of the batch eligible for the next poll. self._seen_uids.add(uid) - if len(self._seen_uids) > self._seen_uids_max: - self._trim_seen_uids() + self._trim_seen_uids() try: raw_email = msg_data[0][1] except (IndexError, TypeError): @@ -722,9 +504,7 @@ class EmailAdapter(BasePlatformAdapter): if not isinstance(raw_email, (bytes, bytearray)): logger.warning("[Email] Non-bytes IMAP payload for UID %s, skipping", uid) continue - # One poison message (unparseable headers, pathological - # attachment, DNS hiccup in SPF/DKIM) must not abort the batch - # or escalate to a reconnect — it is already marked seen. + # One poison message (unparseable headers, pathological attachment, DNS hiccup) must not abort the batch or force a reconnect. try: parsed = self._parse_fetched_message(uid, raw_email) except Exception as parse_exc: @@ -734,65 +514,37 @@ class EmailAdapter(BasePlatformAdapter): results.append(parsed) except Exception as e: logger.error("[Email] IMAP fetch error: %s", e) - self._last_fetch_failed = True - self._last_fetch_error = str(e) - # Keep the reconnect snapshot current so a mid-outage adapter recreation - # does not re-dispatch messages this instance already processed. + self._last_fetch_failed, self._last_fetch_error = True, str(e) + # Keep the reconnect snapshot current so a mid-outage adapter recreation does not re-dispatch messages already processed. self._seen_uids_snapshot[self._address] = set(self._seen_uids) return results def _parse_fetched_message(self, uid: bytes, raw_email: "bytes | bytearray") -> Optional[Dict[str, Any]]: - """Parse one fetched RFC822 payload into a dispatchable dict. - - Returns ``None`` for automated/noreply senders. Raises on pathological - input — the caller's per-message guard logs the UID and continues. - """ + """Parse one RFC822 payload into a dispatchable dict; ``None`` for automated senders. Raises on pathological input (caller logs + continues).""" msg = email_lib.message_from_bytes(raw_email) - - sender_raw = msg.get("From", "") - sender_addr = _extract_email_address(sender_raw) - sender_name = _decode_header_value(sender_raw) - # Remove email from name if present + sender_addr, sender_name = _extract_email_address(msg.get("From", "")), _decode_header_value(msg.get("From", "")) if "<" in sender_name: sender_name = sender_name.split("<")[0].strip().strip('"') - subject = _decode_header_value(msg.get("Subject", "(no subject)")) - # Skip automated/noreply senders before any processing if _is_automated_sender(sender_addr, dict(msg.items())): logger.debug("[Email] Skipping automated sender: %s", sender_addr) return None - - # Verify From: authentication while the raw message (and its trusted - # Authentication-Results header) is in scope; the verdict is consumed - # at dispatch where authorization is decided (GHSA-rxqh-5572-8m77). + # Verify From: while the trusted Authentication-Results header is in scope; the verdict is consumed at dispatch (GHSA-rxqh-5572-8m77). sender_authenticated, auth_reason = _verify_sender_authentication(msg, sender_addr, authserv_id=self._authserv_id) - return { - "uid": uid, - "sender_addr": sender_addr, - "sender_name": sender_name, - "subject": subject, - "message_id": msg.get("Message-ID", ""), - "in_reply_to": msg.get("In-Reply-To", ""), - "body": _extract_text_body(msg), - "attachments": _extract_attachments(msg, skip_attachments=self._skip_attachments), - "date": msg.get("Date", ""), - "sender_authenticated": sender_authenticated, - "auth_reason": auth_reason, - } + return {"uid": uid, "sender_addr": sender_addr, "sender_name": sender_name, "subject": subject, + "message_id": msg.get("Message-ID", ""), "in_reply_to": msg.get("In-Reply-To", ""), + "body": _extract_text_body(msg), + "attachments": _extract_attachments(msg, skip_attachments=self._skip_attachments), + "date": msg.get("Date", ""), "sender_authenticated": sender_authenticated, "auth_reason": auth_reason} @staticmethod def _allow_all_senders() -> bool: """True when the operator opted into any sender (EMAIL_ or GATEWAY_ALLOW_ALL_USERS).""" - return (_get_secret("EMAIL_ALLOW_ALL_USERS", "").strip().lower() in _TRUTHY - or os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in _TRUTHY) + return (_get_secret("EMAIL_ALLOW_ALL_USERS", "").strip().lower() in _TRUTHY or os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in _TRUTHY) @staticmethod def _allowlist_in_effect() -> bool: - """True when EMAIL_ALLOWED_USERS or GATEWAY_ALLOWED_USERS gates access. - - Without one the gateway default-denies every sender, so the spoofable - From: identity grants nothing and the authentication gate is moot. - """ + """True when EMAIL_/GATEWAY_ALLOWED_USERS gates access (without one the gateway default-denies, so the spoofable From: grants nothing).""" return bool(_get_secret("EMAIL_ALLOWED_USERS", "").strip() or os.getenv("GATEWAY_ALLOWED_USERS", "").strip()) def _sender_accepted(self, sender_addr: str, msg_data: Dict[str, Any]) -> bool: @@ -802,32 +554,24 @@ class EmailAdapter(BasePlatformAdapter): if _is_automated_sender(sender_addr, {}): logger.debug("[Email] Dropping automated sender at dispatch: %s", sender_addr) return False - - # Drop senders the gateway would never authorize before a MessageEvent - # (and thread context) exists — otherwise a race between dispatch and - # authorization can send a reply even though the handler returned None. + # Drop senders the gateway would never authorize before a MessageEvent (and thread context) exists — + # otherwise a dispatch/authorization race can send a reply even though the handler returned None. allowed_raw = _get_secret("EMAIL_ALLOWED_USERS", "").strip() if not allowed_raw: if not self._allow_all_senders(): - logger.debug("[Email] Dropping sender at dispatch — EMAIL_ALLOWED_USERS is unset " - "and open access is not opted in: %s", sender_addr) + logger.debug("[Email] Dropping sender at dispatch — EMAIL_ALLOWED_USERS is unset and open access is not opted in: %s", sender_addr) return False elif sender_addr.lower() not in {a.strip().lower() for a in allowed_raw.split(",") if a.strip()}: logger.debug("[Email] Dropping non-allowlisted sender at dispatch: %s", sender_addr) return False - - # Reject spoofed senders (GHSA-rxqh-5572-8m77): the allowlist keys on the - # attacker-controlled From:. Only matters when an allowlist GRANTS access - # and allow-all is off; fail-closed before matching against the allowlist. + # Reject spoofed senders (GHSA-rxqh-5572-8m77): the allowlist keys on the attacker-controlled + # From:. Only matters when an allowlist GRANTS access and allow-all is off; fail-closed. if (self._require_authenticated_sender and self._allowlist_in_effect() and not self._allow_all_senders() and not msg_data.get("sender_authenticated", False)): - logger.warning( - "[Email] Dropping sender with unauthenticated From: %s (%s). " - "If your mail server does not stamp Authentication-Results, set " - "platforms.email.require_authenticated_sender: false (or " - "EMAIL_TRUST_FROM_HEADER=true) to accept the risk.", - sender_addr, msg_data.get("auth_reason", "no verdict"), - ) + logger.warning("[Email] Dropping sender with unauthenticated From: %s (%s). If your mail server does not " + "stamp Authentication-Results, set platforms.email.require_authenticated_sender: false " + "(or EMAIL_TRUST_FROM_HEADER=true) to accept the risk.", + sender_addr, msg_data.get("auth_reason", "no verdict")) return False return True @@ -836,79 +580,51 @@ class EmailAdapter(BasePlatformAdapter): sender_addr = msg_data["sender_addr"] if not self._sender_accepted(sender_addr, msg_data): return - subject, body, attachments = msg_data["subject"], msg_data["body"].strip(), msg_data["attachments"] - # Include subject as context unless it is a reply - text = f"[Subject: {subject}]\n\n{body}" if subject and not subject.startswith("Re:") else body - - msg_type = MessageType.TEXT - for att in attachments: - if att["type"] == "image" and msg_type == MessageType.TEXT: - msg_type = MessageType.PHOTO - elif att["type"] == "document": - # Document wins over PHOTO for mixed attachments: run.py keys - # image handling off the per-path mime type regardless of - # message_type, but document-context injection gates strictly - # on MessageType.DOCUMENT — so DOCUMENT surfaces both. - msg_type = MessageType.DOCUMENT - - # Store thread context for reply threading + text = f"[Subject: {subject}]\n\n{body}" if subject and not subject.startswith("Re:") else body # subject unless reply + # DOCUMENT wins over PHOTO for mixed attachments: run.py keys image handling off the per-path mime type regardless + # of message_type, but document-context injection gates strictly on MessageType.DOCUMENT — so DOCUMENT surfaces both. + kinds = {att["type"] for att in attachments} self._thread_context[sender_addr] = {"subject": subject, "message_id": msg_data["message_id"]} name = msg_data["sender_name"] or sender_addr event = MessageEvent( - text=text or "(empty email)", - message_type=msg_type, - source=self.build_source(chat_id=sender_addr, chat_name=name, chat_type="dm", - user_id=sender_addr, user_name=name), - message_id=msg_data["message_id"], - media_urls=[att["path"] for att in attachments], - media_types=[att["media_type"] for att in attachments], - reply_to_message_id=msg_data["in_reply_to"] or None, - ) + text=text or "(empty email)", message_id=msg_data["message_id"], + message_type=MessageType.DOCUMENT if "document" in kinds else MessageType.PHOTO if "image" in kinds else MessageType.TEXT, + source=self.build_source(chat_id=sender_addr, chat_name=name, chat_type="dm", user_id=sender_addr, user_name=name), + media_urls=[att["path"] for att in attachments], media_types=[att["media_type"] for att in attachments], + reply_to_message_id=msg_data["in_reply_to"] or None) logger.info("[Email] New message from %s: %s", sender_addr, subject) await self.handle_message(event) async def _run_send(self, fn, args: tuple, log_fmt: str, *log_args) -> SendResult: """Run a blocking SMTP sender in the executor; wrap its Message-ID in a SendResult.""" try: - message_id = await asyncio.get_running_loop().run_in_executor(None, fn, *args) - return SendResult(success=True, message_id=message_id) + return SendResult(success=True, message_id=await asyncio.get_running_loop().run_in_executor(None, fn, *args)) except Exception as e: logger.error(log_fmt, *log_args, e) return SendResult(success=False, error=str(e)) - async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None) -> SendResult: + async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult: """Send an email reply to the given address.""" - return await self._run_send(self._send_email, (chat_id, content, reply_to), - "[Email] Send failed to %s: %s", chat_id) + return await self._run_send(self._send_email, (chat_id, content, reply_to), "[Email] Send failed to %s: %s", chat_id) def _message_id_domain(self) -> str: """Domain for generated Message-IDs; ``localhost`` when EMAIL_ADDRESS lacks ``@``.""" return (self._address.rsplit("@", 1)[-1] if "@" in self._address else "") or "localhost" - def _new_reply(self, to_addr: str, body: str, reply_to_msg_id: Optional[str] = None, - *, attach_empty_body: bool = False) -> Tuple[MIMEMultipart, str, str]: + def _new_reply(self, to_addr: str, body: str, reply_to_msg_id: Optional[str] = None, *, + attach_empty_body: bool = False) -> Tuple[MIMEMultipart, str, str]: """Build a threaded reply skeleton. Returns ``(msg, msg_id, subject)``.""" - msg = MIMEMultipart() - msg["From"] = self._address - msg["To"] = to_addr - - ctx = self._thread_context.get(to_addr, {}) + msg, ctx = MIMEMultipart(), self._thread_context.get(to_addr, {}) subject = ctx.get("subject", "Hermes Agent") if not subject.startswith("Re:"): subject = f"Re: {subject}" - msg["Subject"] = subject - original_msg_id = reply_to_msg_id or ctx.get("message_id") - if original_msg_id: - msg["In-Reply-To"] = original_msg_id - msg["References"] = original_msg_id - - msg["Date"] = formatdate(localtime=True) + threading = (("In-Reply-To", original_msg_id), ("References", original_msg_id)) if original_msg_id else () msg_id = f"" - msg["Message-ID"] = msg_id - + for key, value in (("From", self._address), ("To", to_addr), ("Subject", subject), *threading, + ("Date", formatdate(localtime=True)), ("Message-ID", msg_id)): + msg[key] = value if body or attach_empty_body: msg.attach(MIMEText(body, "plain", "utf-8")) return msg, msg_id, subject @@ -932,115 +648,83 @@ class EmailAdapter(BasePlatformAdapter): logger.info("[Email] Sent reply to %s (subject: %s)", to_addr, subject) return msg_id + def _send_with_files(self, to_addr: str, body: str, files: List[Tuple[Path, str]], *, lenient: bool) -> str: + """Send a reply with attachments; *lenient* logs-and-skips unattachable files instead of raising.""" + msg, msg_id, _ = self._new_reply(to_addr, body) + for path, name in files: + try: + _attach_file(msg, path, name) + except Exception as e: + if not lenient: + raise + logger.warning("[Email] Failed to attach %s: %s", path, e) + self._smtp_send(msg) + return msg_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: """Send an image URL as part of an email body (``metadata`` unused).""" - text = caption or "" - text += f"\n\nImage: {image_url}" - return await self.send(chat_id, text.strip(), reply_to) + return await self.send(chat_id, f"{caption or ''}\n\nImage: {image_url}".strip(), reply_to) 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 one email with multiple MIME attachments. - - Local files are attached; URL images are linked in the body (no remote - download). No hard cap beyond SMTP message size limits. - """ + """One email per batch: local files attached, URL images linked in the body (no remote download); base-class fallback on failure.""" if not images: return - from urllib.parse import unquote as _unquote - - body_parts: List[str] = [] - local_paths: List[str] = [] + body_parts, local_paths = [], [] for image_url, alt_text in images: if alt_text: body_parts.append(alt_text) - if image_url.startswith("file://"): - local_path = _unquote(image_url[7:]) - if Path(local_path).exists(): - local_paths.append(local_path) - else: - logger.warning("[Email] Skipping missing image: %s", local_path) + if not image_url.startswith("file://"): + body_parts.append(f"Image: {image_url}") # parity with send_image + elif Path(local_path := _unquote(image_url[7:])).exists(): + local_paths.append(local_path) else: - # Remote URLs just get linked in the body (parity with send_image) - body_parts.append(f"Image: {image_url}") + logger.warning("[Email] Skipping missing image: %s", local_path) if not local_paths and not body_parts: return try: - await asyncio.get_running_loop().run_in_executor( - None, self._send_email_with_attachments, chat_id, "\n\n".join(body_parts), local_paths) + await asyncio.get_running_loop().run_in_executor(None, self._send_email_with_attachments, chat_id, "\n\n".join(body_parts), local_paths) except Exception as e: logger.error("[Email] Multi-image send failed, falling back: %s", e, exc_info=True) await super().send_multiple_images(chat_id, images, metadata, human_delay) def _send_email_with_attachments(self, to_addr: str, body: str, file_paths: List[str]) -> str: """Send an email with multiple file attachments via SMTP (unattachable files are skipped).""" - msg, msg_id, _ = self._new_reply(to_addr, body) - for file_path in file_paths: - p = Path(file_path) - try: - _attach_file(msg, p, p.name) - except Exception as e: - logger.warning("[Email] Failed to attach %s: %s", file_path, e) - self._smtp_send(msg) + msg_id = self._send_with_files(to_addr, body, [(Path(f), Path(f).name) for f in file_paths], lenient=True) logger.info("[Email] Sent multi-attachment email to %s (%d files)", to_addr, len(file_paths)) return msg_id 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, **kwargs) -> SendResult: """Send a file as an email attachment.""" - return await self._run_send(self._send_email_with_attachment, (chat_id, caption or "", file_path, file_name), - "[Email] Send document failed: %s") + return await self._run_send(self._send_email_with_attachment, (chat_id, caption or "", file_path, file_name), "[Email] Send document failed: %s") - def _send_email_with_attachment(self, to_addr: str, body: str, file_path: str, - file_name: Optional[str] = None) -> str: - """Send an email with a file attachment via SMTP.""" - msg, msg_id, _ = self._new_reply(to_addr, body) - p = Path(file_path) - _attach_file(msg, p, file_name or p.name) - self._smtp_send(msg) - return msg_id + def _send_email_with_attachment(self, to_addr: str, body: str, file_path: str, file_name: Optional[str] = None) -> str: + """Send an email with a single file attachment via SMTP (raises if unattachable).""" + return self._send_with_files(to_addr, body, [(Path(file_path), file_name or Path(file_path).name)], lenient=False) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return basic info about the email chat.""" - ctx = self._thread_context.get(chat_id, {}) - return {"name": chat_id, "type": "dm", "chat_id": chat_id, "subject": ctx.get("subject", "")} - - -# Plugin glue: register() exposes the platform via the registry (replacing the -# former Platform.EMAIL branches in gateway/run.py, gateway/config.py, -# hermes_cli/gateway.py and tools/send_message_tool.py). EMAIL_* env → -# PlatformConfig seeding stays in core. + return {"name": chat_id, "type": "dm", "chat_id": chat_id, "subject": self._thread_context.get(chat_id, {}).get("subject", "")} +# Plugin glue: register() exposes the platform via the registry; EMAIL_* env → PlatformConfig seeding stays in core. async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False): """Out-of-process Email delivery via SMTP (one-shot); standalone_sender_fn contract.""" extra = getattr(pconfig, "extra", {}) or {} - address = extra.get("address") or _get_secret("EMAIL_ADDRESS", "") - password = _get_secret("EMAIL_PASSWORD", "") - smtp_host = extra.get("smtp_host") or _get_secret("EMAIL_SMTP_HOST", "") - smtp_port = _esecret_int("EMAIL_SMTP_PORT", 587) - smtp_security = _normalize_security( - _get_secret("EMAIL_SMTP_SECURITY", "") or extra.get("smtp_security"), - default="tls" if smtp_port == 465 else "starttls", - ) - smtp_tls_verify = _esecret_bool( - "EMAIL_SMTP_TLS_VERIFY", is_truthy_value(extra.get("smtp_tls_verify"), default=True) - ) - + address, password = extra.get("address") or _get_secret("EMAIL_ADDRESS", ""), _get_secret("EMAIL_PASSWORD", "") + smtp_host, smtp_port = extra.get("smtp_host") or _get_secret("EMAIL_SMTP_HOST", ""), _esecret_int("EMAIL_SMTP_PORT", 587) + smtp_security = _normalize_security(_get_secret("EMAIL_SMTP_SECURITY", "") or extra.get("smtp_security"), default="tls" if smtp_port == 465 else "starttls") + smtp_tls_verify = _esecret_bool("EMAIL_SMTP_TLS_VERIFY", is_truthy_value(extra.get("smtp_tls_verify"), default=True)) if not all([address, password, smtp_host]): return {"error": "Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)"} - try: msg = MIMEText(message, "plain", "utf-8") - msg["From"] = address - msg["To"] = chat_id - msg["Subject"] = "Hermes Agent" - msg["Date"] = formatdate(localtime=True) - - ctx = _tls_context(smtp_tls_verify, smtp_host) - server = _open_smtp(smtp_host, smtp_port, smtp_security, ctx, smtplib.SMTP, smtplib.SMTP_SSL) + for key, value in (("From", address), ("To", chat_id), ("Subject", "Hermes Agent"), ("Date", formatdate(localtime=True))): + msg[key] = value + server = _open_smtp(smtp_host, smtp_port, smtp_security, _tls_context(smtp_tls_verify, smtp_host), smtplib.SMTP, smtplib.SMTP_SSL) server.login(address, password) server.send_message(msg) server.quit() @@ -1055,8 +739,7 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f def _is_connected(config) -> bool: """Connected when an address is configured (PlatformConfig.extra or EMAIL_ADDRESS).""" - extra = getattr(config, "extra", {}) or {} - if extra.get("address"): + if (getattr(config, "extra", {}) or {}).get("address"): return True import hermes_cli.gateway as gateway_mod return bool((gateway_mod.get_env_value("EMAIL_ADDRESS") or "").strip()) @@ -1070,19 +753,8 @@ def _build_adapter(config): def register(ctx) -> None: """Plugin entry point — called by the Hermes plugin system.""" ctx.register_platform( - name="email", - label="Email", - adapter_factory=_build_adapter, - check_fn=check_email_requirements, - is_connected=_is_connected, + name="email", label="Email", adapter_factory=_build_adapter, check_fn=check_email_requirements, is_connected=_is_connected, required_env=["EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_SMTP_HOST"], - install_hint="Email uses the Python stdlib (smtplib/imaplib) — no extra deps", - allowed_users_env="EMAIL_ALLOWED_USERS", - allow_all_env="EMAIL_ALLOW_ALL_USERS", - cron_deliver_env_var="EMAIL_HOME_ADDRESS", - standalone_sender_fn=_standalone_send, - max_message_length=50_000, - pii_safe=True, - emoji="📧", - allow_update_command=True, - ) + install_hint="Email uses the Python stdlib (smtplib/imaplib) — no extra deps", allowed_users_env="EMAIL_ALLOWED_USERS", + allow_all_env="EMAIL_ALLOW_ALL_USERS", cron_deliver_env_var="EMAIL_HOME_ADDRESS", standalone_sender_fn=_standalone_send, + max_message_length=50_000, pii_safe=True, emoji="📧", allow_update_command=True) diff --git a/plugins/platforms/feishu/feishu_comment.py b/plugins/platforms/feishu/feishu_comment.py index e194349974..3c67f8bc05 100644 --- a/plugins/platforms/feishu/feishu_comment.py +++ b/plugins/platforms/feishu/feishu_comment.py @@ -1,14 +1,7 @@ -""" -Feishu/Lark drive document comment handling. - -Processes ``drive.notice.comment_add_v1`` events against the Drive v1/v2 comment -APIs, kept separate from the main adapter so comment logic can evolve independently. - -Flow: parse event -> access check -> OK reaction -> parallel fetch (doc meta + comment) --> timeline (whole-doc comments or local thread replies) -> prompt -> AIAgent with -feishu_doc + feishu_drive tools -> deliver reply (whole -> add_whole_comment; -local -> reply_to_comment, falling back to add_whole_comment on 1069302). -""" +"""Feishu/Lark drive document comment handling (``drive.notice.comment_add_v1`` → Drive v1/v2 comment APIs). +Flow: parse event -> access check -> OK reaction -> parallel fetch (doc meta + comment) -> timeline (whole-doc comments +or local thread replies) -> prompt -> AIAgent with feishu_doc + feishu_drive tools -> deliver reply (whole -> +add_whole_comment; local -> reply_to_comment, falling back to add_whole_comment on 1069302).""" from __future__ import annotations @@ -22,92 +15,77 @@ from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger(__name__) -# --- Lark SDK helpers (lazy-imported) --- async def _exec_request(client, method, uri, paths=None, queries=None, body=None): """Execute a lark API request (tenant token) and return (code, msg, data_dict).""" - logger.info("[Feishu-Comment] API >>> %s %s paths=%s queries=%s body=%s", - method, uri, paths, queries, - json.dumps(body, ensure_ascii=False)[:500] if body else None) + logger.info("[Feishu-Comment] API >>> %s %s paths=%s queries=%s body=%s", method, uri, paths, queries, json.dumps(body, ensure_ascii=False)[:500] if body else None) from lark_oapi import AccessTokenType from lark_oapi.core.enum import HttpMethod from lark_oapi.core.model.base_request import BaseRequest - http_method = HttpMethod.GET if method == "GET" else HttpMethod.POST - builder = BaseRequest.builder().http_method(http_method).uri(uri).token_types({AccessTokenType.TENANT}) - if paths: - builder = builder.paths(paths) - if queries: - builder = builder.queries(queries) - if body is not None: - builder = builder.body(body) + builder = BaseRequest.builder().http_method(HttpMethod.GET if method == "GET" else HttpMethod.POST).uri(uri).token_types({AccessTokenType.TENANT}) + for setter, value in (("paths", paths), ("queries", queries), ("body", body)): + if value or (setter == "body" and value is not None): + builder = getattr(builder, setter)(value) response = await asyncio.to_thread(client.request, builder.build()) - code = getattr(response, "code", None) - msg = getattr(response, "msg", "") - data: dict = {} - raw = getattr(response, "raw", None) - if raw and hasattr(raw, "content"): - try: - data = json.loads(raw.content).get("data", {}) - except (json.JSONDecodeError, AttributeError): - pass - if not data: - resp_data = getattr(response, "data", None) - if isinstance(resp_data, dict): - data = resp_data - elif resp_data and hasattr(resp_data, "__dict__"): - data = vars(resp_data) - logger.info("[Feishu-Comment] API <<< %s %s code=%s msg=%s data_keys=%s", - method, uri, code, msg, list(data.keys()) if data else "empty") + code, msg, raw = getattr(response, "code", None), getattr(response, "msg", ""), getattr(response, "raw", None) + has_raw = bool(raw and hasattr(raw, "content")) + try: + data: dict = json.loads(raw.content).get("data", {}) if has_raw else {} + except (json.JSONDecodeError, AttributeError): + data = {} + resp_data = None if data else getattr(response, "data", None) + if isinstance(resp_data, dict) or (resp_data and hasattr(resp_data, "__dict__")): + data = _as_dict(resp_data) + logger.info("[Feishu-Comment] API <<< %s %s code=%s msg=%s data_keys=%s", method, uri, code, msg, list(data.keys()) if data else "empty") if code != 0: - raw_content = "" - if raw and hasattr(raw, "content"): - raw_content = raw.content[:500] if isinstance(raw.content, (str, bytes)) else str(raw.content)[:500] - logger.warning("[Feishu-Comment] API FAIL raw response: %s", raw_content) + shown = (raw.content if isinstance(raw.content, (str, bytes)) else str(raw.content))[:500] if has_raw else "" + logger.warning("[Feishu-Comment] API FAIL raw response: %s", shown) return code, msg, data -# --- Event parsing --- +def _log_outcome(code, fail_fmt: str, fail_args: tuple, ok_fmt: str, ok_args: tuple) -> None: + """warning(fail_fmt) when *code* != 0 else info(ok_fmt).""" + if code != 0: + logger.warning(fail_fmt, *fail_args) + else: + logger.info(ok_fmt, *ok_args) + def _as_dict(obj: Any) -> dict: """Coerce a dict or SDK object (via ``vars()``) into a dict; anything else -> {}.""" - if isinstance(obj, dict): - return obj - return vars(obj) if hasattr(obj, "__dict__") else {} + return obj if isinstance(obj, dict) else (vars(obj) if hasattr(obj, "__dict__") else {}) + + +_BAD_JSON = object() + + +def _maybe_json(value: Any, fallback: Any = _BAD_JSON) -> Any: + """Decode *value* when it is a JSON string (undecodable -> *fallback*); non-strings pass through.""" + try: + return json.loads(value) if isinstance(value, str) else value + except (json.JSONDecodeError, TypeError): + return fallback def parse_drive_comment_event(data: Any) -> Optional[Dict[str, Any]]: - """Extract a flat field dict from a ``drive.notice.comment_add_v1`` payload. - - *data* may be a ``CustomizedEvent`` (WebSocket) whose ``.event`` is a dict, - or a ``SimpleNamespace`` (Webhook) built from the full JSON body. - Returns ``None`` when the payload is malformed. - """ + """Extract a flat field dict from a ``drive.notice.comment_add_v1`` payload, or None when malformed. *data* is a ``CustomizedEvent`` (WebSocket; ``.event`` is a + dict) or a ``SimpleNamespace`` (Webhook body).""" logger.debug("[Feishu-Comment] parse_drive_comment_event: data type=%s", type(data).__name__) event = getattr(data, "event", None) if event is None: - logger.debug("[Feishu-Comment] parse_drive_comment_event: no .event attribute, returning None") - return None + return logger.debug("[Feishu-Comment] parse_drive_comment_event: no .event attribute, returning None") evt = _as_dict(event) logger.debug("[Feishu-Comment] parse_drive_comment_event: evt keys=%s", list(evt.keys())) notice_meta = _as_dict(evt.get("notice_meta") or {}) - from_user = _as_dict(notice_meta.get("from_user_id") or {}) - to_user = _as_dict(notice_meta.get("to_user_id") or {}) - return { - "event_id": str(evt.get("event_id") or ""), - "comment_id": str(evt.get("comment_id") or ""), - "reply_id": str(evt.get("reply_id") or ""), - "is_mentioned": bool(evt.get("is_mentioned")), - "timestamp": str(evt.get("timestamp") or ""), - "file_token": str(notice_meta.get("file_token") or ""), - "file_type": str(notice_meta.get("file_type") or ""), - "notice_type": str(notice_meta.get("notice_type") or ""), - "from_open_id": str(from_user.get("open_id") or ""), - "to_open_id": str(to_user.get("open_id") or ""), + fields = { + **{k: evt.get(k) for k in ("event_id", "comment_id", "reply_id", "timestamp")}, + **{k: notice_meta.get(k) for k in ("file_token", "file_type", "notice_type")}, + "from_open_id": _as_dict(notice_meta.get("from_user_id") or {}).get("open_id"), + "to_open_id": _as_dict(notice_meta.get("to_user_id") or {}).get("open_id"), } + return {**{k: str(v or "") for k, v in fields.items()}, "is_mentioned": bool(evt.get("is_mentioned"))} -# --- Drive comment API --- - _REACTION_URI = "/open-apis/drive/v2/files/:file_token/comments/reaction" _BATCH_QUERY_META_URI = "/open-apis/drive/v1/metas/batch_query" _BATCH_QUERY_COMMENT_URI = "/open-apis/drive/v1/files/:file_token/comments/batch_query" @@ -119,119 +97,105 @@ _WIKI_GET_NODE_URI = "/open-apis/wiki/v2/spaces/get_node" _COMMENT_RETRY_LIMIT = 6 _COMMENT_RETRY_DELAY_S = 1.0 _MAX_PAGES = 5 # 5 x page_size 100 - _REACTION_VERBS = {"add": "added", "delete": "deleted"} +_REPLY_CHUNK_SIZE = 4000 +_ELEMENT_TEXT_KEYS = {"text_run": "text", "docs_link": "url"} # reply element type -> key holding its plain text +_FEISHU_DOC_URL_RE = re.compile( + r"(?:feishu\.cn|larkoffice\.com|larksuite\.com|lark\.suite\.com)" + r"/(?Pwiki|doc|docx|sheet|sheets|slides|mindnote|bitable|base|file)" + r"/(?P[A-Za-z0-9_-]{10,40})" +) +_PROMPT_TEXT_LIMIT = 220 +_LOCAL_TIMELINE_LIMIT = 20 +_WHOLE_TIMELINE_LIMIT = 12 +_MENTION_NOTE = "This comment mentioned you (@mention is for routing, not task content)." +_NO_REPLY_SENTINEL = "NO_REPLY" +_ALLOWED_NOTICE_TYPES = {"add_comment", "add_reply"} +_SESSION_MAX_MESSAGES = 50 # cross-card memory within one document: keep last N messages per document session +_SESSION_TTL_S = 3600 # expire sessions after 1 hour of inactivity +_session_cache_lock = threading.Lock() +_session_cache: Dict[str, Dict] = {} # key -> {"messages": [...], "last_access": float} +Timeline = List[Tuple[str, str, bool]] # [(user_id, text, is_self)] -async def update_comment_reaction( - client: Any, action: str, *, file_token: str, file_type: str, reply_id: str, reaction_type: str = "OK", -) -> bool: - """Add (``action="add"``) or remove (``"delete"``) an emoji reaction on a comment reply (Drive v2). - - Best-effort: returns ``True`` on success, ``False`` on failure (errors are logged). - """ - if action == "add": # the add path is the first SDK touch per event: surface a missing lark_oapi cleanly - try: +async def update_comment_reaction(client: Any, action: str, *, file_token: str, file_type: str, reply_id: str, reaction_type: str = "OK") -> bool: + """Add (``action="add"``) or remove (``"delete"``) an emoji reaction on a comment reply (Drive v2); best-effort bool.""" + try: # the add path is the first SDK touch per event: surface a missing lark_oapi cleanly + if action == "add": from lark_oapi import AccessTokenType # noqa: F401 - except ImportError: - logger.error("[Feishu-Comment] lark_oapi not available") - return False - code, msg, _ = await _exec_request( - client, "POST", _REACTION_URI, paths={"file_token": file_token}, - queries=[("file_type", file_type)], - body={"action": action, "reply_id": reply_id, "reaction_type": reaction_type}, - ) - if code == 0: - logger.info("[Feishu-Comment] Reaction '%s' %s: file=%s:%s reply=%s", - reaction_type, _REACTION_VERBS[action], file_type, file_token, reply_id) - else: - logger.warning("[Feishu-Comment] Reaction API failed: code=%s msg=%s file=%s:%s reply=%s", - code, msg, file_type, file_token, reply_id) + except ImportError: + logger.error("[Feishu-Comment] lark_oapi not available") + return False + code, msg, _ = await _exec_request(client, "POST", _REACTION_URI, paths={"file_token": file_token}, queries=[("file_type", file_type)], + body={"action": action, "reply_id": reply_id, "reaction_type": reaction_type}) + _log_outcome(code, "[Feishu-Comment] Reaction API failed: code=%s msg=%s file=%s:%s reply=%s", (code, msg, file_type, file_token, reply_id), + "[Feishu-Comment] Reaction '%s' %s: file=%s:%s reply=%s", (reaction_type, _REACTION_VERBS[action], file_type, file_token, reply_id)) return code == 0 async def query_document_meta(client: Any, file_token: str, file_type: str) -> Dict[str, Any]: """Fetch ``{"title", "url", "doc_type"}`` via the batch_query meta API; empty dict on failure.""" - body = {"request_docs": [{"doc_token": file_token, "doc_type": file_type}], "with_url": True} logger.debug("[Feishu-Comment] query_document_meta: file_token=%s file_type=%s", file_token, file_type) - code, msg, data = await _exec_request(client, "POST", _BATCH_QUERY_META_URI, body=body) + code, msg, data = await _exec_request(client, "POST", _BATCH_QUERY_META_URI, body={"request_docs": [{"doc_token": file_token, "doc_type": file_type}], "with_url": True}) if code != 0: - logger.warning("[Feishu-Comment] Meta batch_query failed: code=%s msg=%s", code, msg) - return {} + return logger.warning("[Feishu-Comment] Meta batch_query failed: code=%s msg=%s", code, msg) or {} metas = data.get("metas", []) - logger.debug("[Feishu-Comment] query_document_meta: raw metas type=%s value=%s", - type(metas).__name__, str(metas)[:300]) - if metas: - meta = metas[0] if isinstance(metas, list) else {} - elif isinstance(metas, dict): # alternate response shape: keyed by token - meta = metas.get(file_token, {}) - else: - logger.debug("[Feishu-Comment] query_document_meta: no metas found") - return {} + logger.debug("[Feishu-Comment] query_document_meta: raw metas type=%s value=%s", type(metas).__name__, str(metas)[:300]) + if not metas and not isinstance(metas, dict): + return logger.debug("[Feishu-Comment] query_document_meta: no metas found") or {} + # alternate response shape: dict keyed by token + meta = (metas[0] if isinstance(metas, list) else {}) if metas else metas.get(file_token, {}) result = {"title": meta.get("title", ""), "url": meta.get("url", ""), "doc_type": meta.get("doc_type", file_type)} - logger.info("[Feishu-Comment] query_document_meta: title=%s url=%s", - result["title"], result["url"][:80] if result["url"] else "") + logger.info("[Feishu-Comment] query_document_meta: title=%s url=%s", result["title"], result["url"][:80] if result["url"] else "") return result -async def batch_query_comment(client: Any, file_token: str, file_type: str, comment_id: str) -> Dict[str, Any]: - """Fetch one comment's details (``is_whole``, ``quote``, ``reply_list``...); empty dict on failure. +async def _retry_pause(attempt: int, retry_fmt: str, retry_args: tuple, fail_fmt: str, fail_args: tuple, lead: tuple = ()) -> bool: + """Between retries: log ``retry_fmt % (*lead, attempt+1, LIMIT, *retry_args)`` and sleep -> True; on the last + attempt log ``fail_fmt % (*lead, LIMIT, *fail_args)`` at warning -> False.""" + if attempt < _COMMENT_RETRY_LIMIT - 1: + logger.info(retry_fmt, *lead, attempt + 1, _COMMENT_RETRY_LIMIT, *retry_args) + await asyncio.sleep(_COMMENT_RETRY_DELAY_S) + return True + return logger.warning(fail_fmt, *lead, _COMMENT_RETRY_LIMIT, *fail_args) or False - Retries up to ``_COMMENT_RETRY_LIMIT`` times: the comment may not be queryable - yet when the notice arrives (eventual consistency). - """ + +async def batch_query_comment(client: Any, file_token: str, file_type: str, comment_id: str) -> Dict[str, Any]: + """Fetch one comment's details (``is_whole``, ``quote``, ``reply_list``...); empty dict on failure. Retries up to ``_COMMENT_RETRY_LIMIT`` times: the comment + may not be queryable yet when the notice arrives.""" logger.debug("[Feishu-Comment] batch_query_comment: file_token=%s comment_id=%s", file_token, comment_id) for attempt in range(_COMMENT_RETRY_LIMIT): - code, msg, data = await _exec_request( - client, "POST", _BATCH_QUERY_COMMENT_URI, paths={"file_token": file_token}, - queries=[("file_type", file_type), ("user_id_type", "open_id")], - body={"comment_ids": [comment_id]}, - ) + code, msg, data = await _exec_request(client, "POST", _BATCH_QUERY_COMMENT_URI, paths={"file_token": file_token}, + queries=[("file_type", file_type), ("user_id_type", "open_id")], body={"comment_ids": [comment_id]}) if code == 0: break - if attempt < _COMMENT_RETRY_LIMIT - 1: - logger.info("[Feishu-Comment] batch_query_comment retry %d/%d: code=%s msg=%s", - attempt + 1, _COMMENT_RETRY_LIMIT, code, msg) - await asyncio.sleep(_COMMENT_RETRY_DELAY_S) - else: - logger.warning("[Feishu-Comment] batch_query_comment failed after %d attempts: code=%s msg=%s", - _COMMENT_RETRY_LIMIT, code, msg) + if not await _retry_pause(attempt, "[Feishu-Comment] batch_query_comment retry %d/%d: code=%s msg=%s", (code, msg), + "[Feishu-Comment] batch_query_comment failed after %d attempts: code=%s msg=%s", (code, msg)): return {} items = data.get("items", []) logger.debug("[Feishu-Comment] batch_query_comment: got %d items", len(items) if isinstance(items, list) else 0) - if items and isinstance(items, list): - item = items[0] - logger.info("[Feishu-Comment] batch_query_comment: is_whole=%s quote=%s reply_count=%s", - item.get("is_whole"), (item.get("quote", "") or "")[:60], - len(item.get("reply_list", {}).get("replies", [])) if isinstance(item.get("reply_list"), dict) else "?") - return item - logger.warning("[Feishu-Comment] batch_query_comment: empty items, raw data keys=%s", list(data.keys())) - return {} + if not items or not isinstance(items, list): + return logger.warning("[Feishu-Comment] batch_query_comment: empty items, raw data keys=%s", list(data.keys())) or {} + item = items[0] + logger.info("[Feishu-Comment] batch_query_comment: is_whole=%s quote=%s reply_count=%s", item.get("is_whole"), (item.get("quote", "") or "")[:60], + len(item.get("reply_list", {}).get("replies", [])) if isinstance(item.get("reply_list"), dict) else "?") + return item -async def _list_all_pages( - client: Any, uri: str, paths: dict, queries: list, *, fail_msg: str, page_msg: str = "", -) -> Tuple[List[Dict[str, Any]], bool]: - """GET up to ``_MAX_PAGES`` pages of ``items``; returns ``(items, fetch_ok)``. - - *fail_msg* is logged with ``(code, msg)`` on failure; *page_msg* (optional) at debug with ``(page_n, total)``. - """ +async def _list_all_pages(client: Any, uri: str, paths: dict, queries: list, *, fail_msg: str, page_msg: str = "") -> Tuple[List[Dict[str, Any]], bool]: + """GET up to ``_MAX_PAGES`` pages of ``items``; returns ``(items, fetch_ok)``. *fail_msg* is logged with ``(code, msg)`` on failure; *page_msg* (optional) at + debug with ``(page_n, total)``.""" items_out: List[Dict[str, Any]] = [] page_token = "" for _ in range(_MAX_PAGES): - page_queries = queries + ([("page_token", page_token)] if page_token else []) - code, msg, data = await _exec_request(client, "GET", uri, paths=paths, queries=page_queries) + code, msg, data = await _exec_request(client, "GET", uri, paths=paths, queries=queries + ([("page_token", page_token)] if page_token else [])) if code != 0: - logger.warning(fail_msg, code, msg) - return items_out, False + return logger.warning(fail_msg, code, msg) or (items_out, False) items = data.get("items", []) - if isinstance(items, list): - items_out.extend(items) - if page_msg: - logger.debug(page_msg, len(items), len(items_out)) - if not data.get("has_more"): - break - page_token = data.get("page_token", "") + items_out.extend(items if isinstance(items, list) else []) + if isinstance(items, list) and page_msg: + logger.debug(page_msg, len(items), len(items_out)) + page_token = data.get("page_token", "") if data.get("has_more") else "" if not page_token: break return items_out, True @@ -241,41 +205,24 @@ async def list_whole_comments(client: Any, file_token: str, file_type: str) -> L """List all whole-document comments (paginated, up to 500).""" logger.debug("[Feishu-Comment] list_whole_comments: file_token=%s", file_token) all_comments, _ = await _list_all_pages( - client, _LIST_COMMENTS_URI, {"file_token": file_token}, - [("file_type", file_type), ("is_whole", "true"), ("page_size", "100"), ("user_id_type", "open_id")], - fail_msg="[Feishu-Comment] List whole comments failed: code=%s msg=%s", - page_msg="[Feishu-Comment] list_whole_comments: page got %d items, total=%d", - ) + client, _LIST_COMMENTS_URI, {"file_token": file_token}, [("file_type", file_type), ("is_whole", "true"), ("page_size", "100"), ("user_id_type", "open_id")], + fail_msg="[Feishu-Comment] List whole comments failed: code=%s msg=%s", page_msg="[Feishu-Comment] list_whole_comments: page got %d items, total=%d") logger.info("[Feishu-Comment] list_whole_comments: total %d whole comments fetched", len(all_comments)) return all_comments -async def list_comment_replies( - client: Any, file_token: str, file_type: str, comment_id: str, *, expect_reply_id: str = "", -) -> List[Dict[str, Any]]: - """List all replies in a comment thread (paginated, up to 500). - - If *expect_reply_id* is set and absent from the fetched thread, retries up to - ``_COMMENT_RETRY_LIMIT`` times (the new reply may not be listed yet). - """ +async def list_comment_replies(client: Any, file_token: str, file_type: str, comment_id: str, *, expect_reply_id: str = "") -> List[Dict[str, Any]]: + """List all replies in a comment thread (paginated, up to 500). If *expect_reply_id* is set and absent from the fetched thread, retries up to + ``_COMMENT_RETRY_LIMIT`` times (the new reply may not be listed yet).""" logger.debug("[Feishu-Comment] list_comment_replies: file_token=%s comment_id=%s", file_token, comment_id) for attempt in range(_COMMENT_RETRY_LIMIT): all_replies, fetch_ok = await _list_all_pages( - client, _REPLIES_URI, {"file_token": file_token, "comment_id": comment_id}, - [("file_type", file_type), ("page_size", "100"), ("user_id_type", "open_id")], - fail_msg="[Feishu-Comment] List replies failed: code=%s msg=%s", - ) - if not expect_reply_id or not fetch_ok: + client, _REPLIES_URI, {"file_token": file_token, "comment_id": comment_id}, [("file_type", file_type), ("page_size", "100"), ("user_id_type", "open_id")], + fail_msg="[Feishu-Comment] List replies failed: code=%s msg=%s") + if not expect_reply_id or not fetch_ok or any(r.get("reply_id") == expect_reply_id for r in all_replies): break - if any(r.get("reply_id") == expect_reply_id for r in all_replies): - break - if attempt < _COMMENT_RETRY_LIMIT - 1: - logger.info("[Feishu-Comment] list_comment_replies: reply_id=%s not found, retry %d/%d", - expect_reply_id, attempt + 1, _COMMENT_RETRY_LIMIT) - await asyncio.sleep(_COMMENT_RETRY_DELAY_S) - else: - logger.warning("[Feishu-Comment] list_comment_replies: reply_id=%s not found after %d attempts", - expect_reply_id, _COMMENT_RETRY_LIMIT) + await _retry_pause(attempt, "[Feishu-Comment] list_comment_replies: reply_id=%s not found, retry %d/%d", (), + "[Feishu-Comment] list_comment_replies: reply_id=%s not found after %d attempts", (), lead=(expect_reply_id,)) logger.info("[Feishu-Comment] list_comment_replies: total %d replies fetched", len(all_replies)) return all_replies @@ -285,21 +232,14 @@ def _sanitize_comment_text(text: str) -> str: return text.replace("&", "&").replace("<", "<").replace(">", ">") -async def reply_to_comment( - client: Any, file_token: str, file_type: str, comment_id: str, text: str, -) -> Tuple[bool, int]: +async def reply_to_comment(client: Any, file_token: str, file_type: str, comment_id: str, text: str) -> Tuple[bool, int]: """Post a reply to a local comment thread. Returns ``(success, code)``.""" text = _sanitize_comment_text(text) logger.info("[Feishu-Comment] reply_to_comment: comment_id=%s text=%s", comment_id, text[:100]) - code, msg, _ = await _exec_request( - client, "POST", _REPLIES_URI, paths={"file_token": file_token, "comment_id": comment_id}, - queries=[("file_type", file_type)], - body={"content": {"elements": [{"type": "text_run", "text_run": {"text": text}}]}}, - ) - if code != 0: - logger.warning("[Feishu-Comment] reply_to_comment FAILED: code=%s msg=%s comment_id=%s", code, msg, comment_id) - else: - logger.info("[Feishu-Comment] reply_to_comment OK: comment_id=%s", comment_id) + code, msg, _ = await _exec_request(client, "POST", _REPLIES_URI, paths={"file_token": file_token, "comment_id": comment_id}, queries=[("file_type", file_type)], + body={"content": {"elements": [{"type": "text_run", "text_run": {"text": text}}]}}) + _log_outcome(code, "[Feishu-Comment] reply_to_comment FAILED: code=%s msg=%s comment_id=%s", (code, msg, comment_id), + "[Feishu-Comment] reply_to_comment OK: comment_id=%s", (comment_id,)) return code == 0, code @@ -307,93 +247,55 @@ async def add_whole_comment(client: Any, file_token: str, file_type: str, text: """Add a new whole-document comment. Returns ``True`` on success.""" text = _sanitize_comment_text(text) logger.info("[Feishu-Comment] add_whole_comment: file_token=%s text=%s", file_token, text[:100]) - code, msg, _ = await _exec_request( - client, "POST", _ADD_COMMENT_URI, paths={"file_token": file_token}, - body={"file_type": file_type, "reply_elements": [{"type": "text", "text": text}]}, - ) - if code != 0: - logger.warning("[Feishu-Comment] add_whole_comment FAILED: code=%s msg=%s", code, msg) - else: - logger.info("[Feishu-Comment] add_whole_comment OK") + code, msg, _ = await _exec_request(client, "POST", _ADD_COMMENT_URI, paths={"file_token": file_token}, + body={"file_type": file_type, "reply_elements": [{"type": "text", "text": text}]}) + _log_outcome(code, "[Feishu-Comment] add_whole_comment FAILED: code=%s msg=%s", (code, msg), "[Feishu-Comment] add_whole_comment OK", ()) return code == 0 -_REPLY_CHUNK_SIZE = 4000 - - def _chunk_text(text: str, limit: int = _REPLY_CHUNK_SIZE) -> List[str]: """Split text into chunks for delivery, preferring line breaks.""" - if len(text) <= limit: - return [text] chunks = [] - while text: - if len(text) <= limit: - chunks.append(text) - break - cut = text.rfind("\n", 0, limit) - if cut <= 0: - cut = limit + while len(text) > limit: + cut = nl if (nl := text.rfind("\n", 0, limit)) > 0 else limit chunks.append(text[:cut]) text = text[cut:].lstrip("\n") - return chunks + return chunks + ([text] if text or not chunks else []) -async def deliver_comment_reply( - client: Any, file_token: str, file_type: str, comment_id: str, text: str, is_whole: bool, -) -> bool: - """Route the agent reply to the right API, chunking long text. - - Whole comment -> add_whole_comment. Local comment -> reply_to_comment; on - 1069302 (reply not allowed) fall back to add_whole_comment for this and all - later chunks. - """ +async def deliver_comment_reply(client: Any, file_token: str, file_type: str, comment_id: str, text: str, is_whole: bool) -> bool: + """Route the agent reply to the right API, chunking long text. Whole comment -> add_whole_comment. Local comment -> reply_to_comment; on 1069302 (reply not + allowed) fall back to add_whole_comment for this and all later chunks.""" chunks = _chunk_text(text) - logger.info("[Feishu-Comment] deliver_comment_reply: is_whole=%s comment_id=%s text_len=%d chunks=%d", - is_whole, comment_id, len(text), len(chunks)) + logger.info("[Feishu-Comment] deliver_comment_reply: is_whole=%s comment_id=%s text_len=%d chunks=%d", is_whole, comment_id, len(text), len(chunks)) for i, chunk in enumerate(chunks): if len(chunks) > 1: - logger.info("[Feishu-Comment] deliver_comment_reply: sending chunk %d/%d (%d chars)", - i + 1, len(chunks), len(chunk)) + logger.info("[Feishu-Comment] deliver_comment_reply: sending chunk %d/%d (%d chars)", i + 1, len(chunks), len(chunk)) + ok, code = (True, 0) if is_whole else await reply_to_comment(client, file_token, file_type, comment_id, chunk) + if not is_whole and not ok and code == 1069302: + logger.info("[Feishu-Comment] Reply not allowed (1069302), falling back to add_whole_comment") + is_whole = True if is_whole: ok = await add_whole_comment(client, file_token, file_type, chunk) - else: - ok, code = await reply_to_comment(client, file_token, file_type, comment_id, chunk) - if not ok and code == 1069302: - logger.info("[Feishu-Comment] Reply not allowed (1069302), falling back to add_whole_comment") - ok = await add_whole_comment(client, file_token, file_type, chunk) - is_whole = True if not ok: return False return True -# --- Comment content extraction helpers --- - def _extract_reply_text(reply: Dict[str, Any], *, semantic: bool = False, self_open_id: str = "") -> str: - """Plain text of a reply's content (text_run / docs_link / person elements). - - Person mentions render as ``@``. In *semantic* mode (for the prompt's - "current text"), the self @mention is dropped (it is routing, not content), an - unknown mention renders as ``@`` and whitespace is collapsed. - """ - content = reply.get("content", {}) - if isinstance(content, str): - try: - content = json.loads(content) - except (json.JSONDecodeError, TypeError): - return content - missing_uid = "" if semantic else "unknown" + """Plain text of a reply's content (text_run / docs_link / person elements). Person mentions render as ``@``. In *semantic* mode (for the prompt's + "current text"), the self @mention is dropped (routing, not content), an unknown mention renders as ``@`` and whitespace is collapsed.""" + raw = reply.get("content", {}) + content = _maybe_json(raw) + if content is _BAD_JSON: + return raw parts = [] for elem in content.get("elements", []): etype = elem.get("type") - if etype == "text_run": - parts.append(elem.get("text_run", {}).get("text", "")) - elif etype == "docs_link": - parts.append(elem.get("docs_link", {}).get("url", "")) - elif etype == "person": - uid = elem.get("person", {}).get("user_id", missing_uid) - if semantic and self_open_id and uid == self_open_id: - continue + uid = elem.get("person", {}).get("user_id", "" if semantic else "unknown") if etype == "person" else None + if etype in _ELEMENT_TEXT_KEYS: + parts.append(elem.get(etype, {}).get(_ELEMENT_TEXT_KEYS[etype], "")) + elif etype == "person" and not (semantic and self_open_id and uid == self_open_id): parts.append(f"@{uid}") text = "".join(parts) return " ".join(text.split()).strip() if semantic else text @@ -402,35 +304,12 @@ def _extract_reply_text(reply: Dict[str, Any], *, semantic: bool = False, self_o def _get_reply_user_id(reply: Dict[str, Any]) -> str: """Extract user_id from a reply dict.""" user_id = reply.get("user_id", "") - if isinstance(user_id, dict): - return user_id.get("open_id", "") or user_id.get("user_id", "") - return str(user_id) - - -def _extract_semantic_text(reply: Dict[str, Any], self_open_id: str = "") -> str: - """Semantic text of a reply: self @mention stripped, whitespace collapsed.""" - return _extract_reply_text(reply, semantic=True, self_open_id=self_open_id) + return (user_id.get("open_id", "") or user_id.get("user_id", "")) if isinstance(user_id, dict) else str(user_id) def _reply_list_replies(whole_comment: Dict[str, Any]) -> List[Dict[str, Any]]: """Return the ``reply_list.replies`` of a whole comment (``reply_list`` may be a JSON string).""" - reply_list = whole_comment.get("reply_list", {}) - if isinstance(reply_list, str): - try: - reply_list = json.loads(reply_list) - except (json.JSONDecodeError, TypeError): - reply_list = {} - return reply_list.get("replies", []) - - -# --- Document link parsing and wiki resolution --- - -# Matches feishu/lark document URLs and extracts doc_type + token -_FEISHU_DOC_URL_RE = re.compile( - r"(?:feishu\.cn|larkoffice\.com|larksuite\.com|lark\.suite\.com)" - r"/(?Pwiki|doc|docx|sheet|sheets|slides|mindnote|bitable|base|file)" - r"/(?P[A-Za-z0-9_-]{10,40})" -) + return _maybe_json(whole_comment.get("reply_list", {}), {}).get("replies", []) def _extract_docs_links(replies: List[Dict[str, Any]]) -> List[Dict[str, str]]: @@ -438,51 +317,41 @@ def _extract_docs_links(replies: List[Dict[str, Any]]) -> List[Dict[str, str]]: seen_tokens = set() links = [] for reply in replies: - content = reply.get("content", {}) - if isinstance(content, str): - try: - content = json.loads(content) - except (json.JSONDecodeError, TypeError): - continue + content = _maybe_json(reply.get("content", {})) + if content is _BAD_JSON: + continue for elem in content.get("elements", []): - if elem.get("type") not in {"docs_link", "link"}: - continue - url = (elem.get("docs_link") or elem.get("link") or {}).get("url", "") + url = (elem.get("docs_link") or elem.get("link") or {}).get("url", "") if elem.get("type") in {"docs_link", "link"} else "" m = _FEISHU_DOC_URL_RE.search(url) if url else None - if not m or m.group("token") in seen_tokens: - continue - seen_tokens.add(m.group("token")) - links.append({"url": url, "doc_type": m.group("doc_type"), "token": m.group("token")}) + if m and m.group("token") not in seen_tokens: + seen_tokens.add(m.group("token")) + links.append({"url": url, "doc_type": m.group("doc_type"), "token": m.group("token")}) return links +async def _wiki_node(client: Any, queries: list, fail_msg: str, *fail_args) -> Optional[dict]: + """GET a wiki node; logs *fail_msg* with ``(code, msg, *fail_args)`` and returns None on API failure.""" + code, msg, data = await _exec_request(client, "GET", _WIKI_GET_NODE_URI, queries=queries) + return logger.warning(fail_msg, code, msg, *fail_args) if code != 0 else data.get("node", {}) + + async def _reverse_lookup_wiki_token(client: Any, obj_type: str, obj_token: str) -> Optional[str]: """Return the wiki node_token owning *obj_token*, or None if not a wiki doc / API failure.""" - code, msg, data = await _exec_request( - client, "GET", _WIKI_GET_NODE_URI, queries=[("token", obj_token), ("obj_type", obj_type)], - ) - if code == 0: - return data.get("node", {}).get("node_token", "") or None - logger.warning("[Feishu-Comment] Wiki reverse lookup failed: code=%s msg=%s obj=%s:%s", code, msg, obj_type, obj_token) - return None + node = await _wiki_node(client, [("token", obj_token), ("obj_type", obj_type)], "[Feishu-Comment] Wiki reverse lookup failed: code=%s msg=%s obj=%s:%s", obj_type, obj_token) + return (node.get("node_token", "") or None) if node is not None else None async def _resolve_wiki_nodes(client: Any, links: List[Dict[str, str]]) -> List[Dict[str, str]]: """Annotate wiki links in-place with ``resolved_type``/``resolved_token``; non-wiki links untouched.""" - for link in links: - if link["doc_type"] != "wiki": - continue + for link in (l for l in links if l["doc_type"] == "wiki"): wiki_token = link["token"] - code, msg, data = await _exec_request(client, "GET", _WIKI_GET_NODE_URI, queries=[("token", wiki_token)]) - if code != 0: - logger.warning("[Feishu-Comment] Wiki resolve failed: code=%s msg=%s token=%s", code, msg, wiki_token) + node = await _wiki_node(client, [("token", wiki_token)], "[Feishu-Comment] Wiki resolve failed: code=%s msg=%s token=%s", wiki_token) + if node is None: continue - node = data.get("node", {}) resolved_type, resolved_token = node.get("obj_type", ""), node.get("obj_token", "") if resolved_type and resolved_token: logger.info("[Feishu-Comment] Wiki resolved: %s -> %s:%s", wiki_token, resolved_type, resolved_token) - link["resolved_type"] = resolved_type - link["resolved_token"] = resolved_token + link["resolved_type"], link["resolved_token"] = resolved_type, resolved_token else: logger.warning("[Feishu-Comment] Wiki resolve returned empty: %s", wiki_token) return links @@ -490,32 +359,17 @@ async def _resolve_wiki_nodes(client: Any, links: List[Dict[str, str]]) -> List[ def _format_referenced_docs(links: List[Dict[str, str]], current_file_token: str = "") -> str: """Format resolved document links for prompt embedding.""" - if not links: - return "" lines = ["", "Referenced documents in comments:"] for link in links: - rtype = link.get("resolved_type", link["doc_type"]) - rtoken = link.get("resolved_token", link["token"]) - suffix = " (same as current document)" if rtoken == current_file_token else "" - lines.append(f"- {rtype}:{rtoken}{suffix} ({link['url'][:80]})") - return "\n".join(lines) + rtype, rtoken = link.get("resolved_type", link["doc_type"]), link.get("resolved_token", link["token"]) + lines.append(f"- {rtype}:{rtoken}{' (same as current document)' if rtoken == current_file_token else ''} ({link['url'][:80]})") + return "\n".join(lines) if links else "" async def _referenced_docs_text(client: Any, replies: List[Dict[str, Any]], file_token: str) -> str: """Extract, wiki-resolve and format the document links found in *replies*.""" doc_links = _extract_docs_links(replies) - if doc_links: - doc_links = await _resolve_wiki_nodes(client, doc_links) - return _format_referenced_docs(doc_links, file_token) - - -# --- Prompt construction --- - -_PROMPT_TEXT_LIMIT = 220 -_LOCAL_TIMELINE_LIMIT = 20 -_WHOLE_TIMELINE_LIMIT = 12 - -Timeline = List[Tuple[str, str, bool]] # [(user_id, text, is_self)] + return _format_referenced_docs(await _resolve_wiki_nodes(client, doc_links) if doc_links else doc_links, file_token) def _truncate(text: str, limit: int = _PROMPT_TEXT_LIMIT) -> str: @@ -524,28 +378,20 @@ def _truncate(text: str, limit: int = _PROMPT_TEXT_LIMIT) -> str: def _select_timeline(timeline: Timeline, limit: int, center: int, pinned: Tuple[int, ...] = ()) -> Timeline: - """Select up to *limit* entries: *pinned* + *center*, then expand outward from *center*. - - Out-of-range indices are ignored; if nothing is selectable, falls back to the last *limit* entries. - """ - if len(timeline) <= limit: - return timeline + """Select up to *limit* entries: *pinned* + *center*, then expand outward from *center*. Out-of-range indices are ignored; if nothing is selectable, falls back + to the last *limit* entries.""" n = len(timeline) + if n <= limit: + return timeline selected = {i for i in (*pinned, center) if 0 <= i < n} - budget = limit - len(selected) lo, hi = center - 1, center + 1 - while budget > 0 and (lo >= 0 or hi < n): - if lo >= 0 and lo not in selected: + while len(selected) < limit and (lo >= 0 or hi < n): + if lo >= 0: selected.add(lo) - budget -= 1 - lo -= 1 - if budget > 0 and hi < n and hi not in selected: + if len(selected) < limit and hi < n: selected.add(hi) - budget -= 1 - hi += 1 - if not selected: - return timeline[-limit:] - return [timeline[i] for i in sorted(selected)] + lo, hi = lo - 1, hi + 1 + return [timeline[i] for i in sorted(selected)] if selected else timeline[-limit:] _COMMON_INSTRUCTIONS = """ @@ -564,14 +410,14 @@ If no reply is needed, output exactly NO_REPLY. """.strip() -def _finish_prompt(lines: List[str], selected: Timeline, referenced_docs: str) -> str: - """Append the timeline entries, referenced docs and common instructions.""" - for user_id, text, is_self in selected: - lines.append(f"[{user_id}] {_truncate(text)}{' <-- YOU' if is_self else ''}") +def _build_prompt(intro: List[str], doc_url: str, file_type: str, file_token: str, ids: List[str], label: str, timeline: Timeline, selected: Timeline, referenced_docs: str) -> str: + """Intro lines + document block + ``label`` timeline header, the selected entries, referenced docs and common instructions.""" + lines = [*intro, _MENTION_NOTE, f"Document link: {doc_url}", "Current commented document:", f"- file_type={file_type}", f"- file_token={file_token}", + *ids, "", f"{label} ({len(selected)}/{len(timeline)} entries):"] + lines += [f"[{user_id}] {_truncate(text)}{' <-- YOU' if is_self else ''}" for user_id, text, is_self in selected] if referenced_docs: lines.append(referenced_docs) - lines += ["", _COMMON_INSTRUCTIONS] - return "\n".join(lines) + return "\n".join(lines + ["", _COMMON_INSTRUCTIONS]) def build_local_comment_prompt( @@ -580,22 +426,10 @@ def build_local_comment_prompt( target_index: int = -1, referenced_docs: str = "", ) -> str: """Build the prompt for a local (quoted-text) comment.""" + intro = [f'The user added a reply in "{doc_title}".', f'Current user comment text: "{_truncate(target_reply_text)}"', + f'Original comment text: "{_truncate(root_comment_text)}"', f'Quoted content: "{_truncate(quote_text, 500)}"'] selected = _select_timeline(timeline, _LOCAL_TIMELINE_LIMIT, target_index, pinned=(0, len(timeline) - 1)) - lines = [ - f'The user added a reply in "{doc_title}".', - f'Current user comment text: "{_truncate(target_reply_text)}"', - f'Original comment text: "{_truncate(root_comment_text)}"', - f'Quoted content: "{_truncate(quote_text, 500)}"', - "This comment mentioned you (@mention is for routing, not task content).", - f"Document link: {doc_url}", - "Current commented document:", - f"- file_type={file_type}", - f"- file_token={file_token}", - f"- comment_id={comment_id}", - "", - f"Current comment card timeline ({len(selected)}/{len(timeline)} entries):", - ] - return _finish_prompt(lines, selected, referenced_docs) + return _build_prompt(intro, doc_url, file_type, file_token, [f"- comment_id={comment_id}"], "Current comment card timeline", timeline, selected, referenced_docs) def build_whole_comment_prompt( @@ -603,46 +437,25 @@ def build_whole_comment_prompt( self_open_id: str, current_index: int = -1, nearest_self_index: int = -1, referenced_docs: str = "", ) -> str: """Build the prompt for a whole-document comment.""" + intro = [f'The user added a comment in "{doc_title}".', f'Current user comment text: "{_truncate(comment_text)}"', "This is a whole-document comment."] selected = _select_timeline(timeline, _WHOLE_TIMELINE_LIMIT, current_index, pinned=(nearest_self_index,)) - lines = [ - f'The user added a comment in "{doc_title}".', - f'Current user comment text: "{_truncate(comment_text)}"', - "This is a whole-document comment.", - "This comment mentioned you (@mention is for routing, not task content).", - f"Document link: {doc_url}", - "Current commented document:", - f"- file_type={file_type}", - f"- file_token={file_token}", - "", - f"Whole-document comment timeline ({len(selected)}/{len(timeline)} entries):", - ] - return _finish_prompt(lines, selected, referenced_docs) + return _build_prompt(intro, doc_url, file_type, file_token, [], "Whole-document comment timeline", timeline, selected, referenced_docs) -# --- Agent execution --- - def _resolve_model_and_runtime() -> Tuple[str, dict]: """Resolve model and provider credentials, same as gateway message handling.""" from gateway.run import _load_gateway_config, _resolve_gateway_model, _resolve_runtime_agent_kwargs model = _resolve_gateway_model(_load_gateway_config()) runtime_kwargs = _resolve_runtime_agent_kwargs() - if not model and runtime_kwargs.get("provider"): # fall back to the provider's default model - try: + try: + if not model and runtime_kwargs.get("provider"): # fall back to the provider's default model from hermes_cli.models import get_default_model_for_provider model = get_default_model_for_provider(runtime_kwargs["provider"]) - except Exception: - pass + except Exception: + pass return model, runtime_kwargs -# Session cache for cross-card memory within the same document. -_SESSION_MAX_MESSAGES = 50 # keep last N messages per document session -_SESSION_TTL_S = 3600 # expire sessions after 1 hour of inactivity - -_session_cache_lock = threading.Lock() -_session_cache: Dict[str, Dict] = {} # key -> {"messages": [...], "last_access": float} - - def _session_key(file_type: str, file_token: str) -> str: return f"comment-doc:{file_type}:{file_token}" @@ -651,56 +464,43 @@ def _load_session_history(key: str) -> List[Dict[str, Any]]: """Load conversation history for a document session (expires after ``_SESSION_TTL_S``).""" with _session_cache_lock: entry = _session_cache.get(key) - if entry is None: - return [] - if time.time() - entry["last_access"] > _SESSION_TTL_S: + if entry is not None and time.time() - entry["last_access"] > _SESSION_TTL_S: del _session_cache[key] logger.info("[Feishu-Comment] Session expired: %s", key) return [] - entry["last_access"] = time.time() - return list(entry["messages"]) + if entry is not None: + entry["last_access"] = time.time() + return list(entry["messages"]) if entry is not None else [] def _save_session_history(key: str, messages: List[Dict[str, Any]]) -> None: """Save the last N user/assistant messages (system messages and tool internals stripped).""" - cleaned = [m for m in messages if m.get("role") in {"user", "assistant"} and m.get("content")] - cleaned = cleaned[-_SESSION_MAX_MESSAGES:] + cleaned = [m for m in messages if m.get("role") in {"user", "assistant"} and m.get("content")][-_SESSION_MAX_MESSAGES:] with _session_cache_lock: _session_cache[key] = {"messages": cleaned, "last_access": time.time()} logger.info("[Feishu-Comment] Session saved: %s (%d messages)", key, len(cleaned)) def _run_comment_agent(prompt: str, client: Any, session_key: str = "") -> str: - """Create an AIAgent with feishu tools and run the prompt; empty string on failure. - - *session_key*, if given, loads/saves history for cross-card memory in the same document. - """ + """Create an AIAgent with feishu tools and run the prompt; empty string on failure. *session_key*, if given, loads/saves history for cross-card memory in the same document.""" from run_agent import AIAgent - logger.info("[Feishu-Comment] _run_comment_agent: injecting lark client into tool thread-locals") from tools import feishu_doc_tool, feishu_drive_tool + logger.info("[Feishu-Comment] _run_comment_agent: injecting lark client into tool thread-locals") tool_mods = (feishu_doc_tool, feishu_drive_tool) for mod in tool_mods: mod.set_client(client) try: model, runtime_kwargs = _resolve_model_and_runtime() - logger.info("[Feishu-Comment] _run_comment_agent: model=%s provider=%s base_url=%s", - model, runtime_kwargs.get("provider"), (runtime_kwargs.get("base_url") or "")[:50]) + logger.info("[Feishu-Comment] _run_comment_agent: model=%s provider=%s base_url=%s", model, runtime_kwargs.get("provider"), (runtime_kwargs.get("base_url") or "")[:50]) history = _load_session_history(session_key) if session_key else [] if history: - logger.info("[Feishu-Comment] _run_comment_agent: loaded %d history messages from session %s", - len(history), session_key) - agent = AIAgent( - model=model, - **{k: runtime_kwargs.get(k) for k in ("base_url", "api_key", "provider", "api_mode", "credential_pool")}, - quiet_mode=True, skip_context_files=True, skip_memory=True, max_iterations=15, - enabled_toolsets=["feishu_doc", "feishu_drive"], - ) - logger.info("[Feishu-Comment] _run_comment_agent: calling run_conversation (prompt=%d chars, history=%d)", - len(prompt), len(history)) + logger.info("[Feishu-Comment] _run_comment_agent: loaded %d history messages from session %s", len(history), session_key) + agent = AIAgent(model=model, **{k: runtime_kwargs.get(k) for k in ("base_url", "api_key", "provider", "api_mode", "credential_pool")}, + quiet_mode=True, skip_context_files=True, skip_memory=True, max_iterations=15, enabled_toolsets=["feishu_doc", "feishu_drive"]) + logger.info("[Feishu-Comment] _run_comment_agent: calling run_conversation (prompt=%d chars, history=%d)", len(prompt), len(history)) result = agent.run_conversation(prompt, conversation_history=history or None) response = (result.get("final_response") or "").strip() - logger.info("[Feishu-Comment] _run_comment_agent: done api_calls=%d response_len=%d response=%s", - result.get("api_calls", 0), len(response), response[:200]) + logger.info("[Feishu-Comment] _run_comment_agent: done api_calls=%d response_len=%d response=%s", result.get("api_calls", 0), len(response), response[:200]) if session_key and result.get("messages", []): _save_session_history(session_key, result["messages"]) return response @@ -712,18 +512,9 @@ def _run_comment_agent(prompt: str, client: Any, session_key: str = "") -> str: mod.set_client(None) -# --- Event handler entry point --- - -_NO_REPLY_SENTINEL = "NO_REPLY" -_ALLOWED_NOTICE_TYPES = {"add_comment", "add_reply"} - - def _last_index_where(timeline: Timeline, pred) -> Optional[Tuple[str, int]]: """Return ``(text, index)`` of the last timeline entry matching *pred*, or None.""" - for i in range(len(timeline) - 1, -1, -1): - if pred(timeline[i]): - return timeline[i][1], i - return None + return next(((timeline[i][1], i) for i in range(len(timeline) - 1, -1, -1) if pred(timeline[i])), None) def _timeline_entry(r: Dict[str, Any], self_open_id: str) -> Tuple[str, str, bool]: @@ -732,152 +523,98 @@ def _timeline_entry(r: Dict[str, Any], self_open_id: str) -> Tuple[str, str, boo async def _whole_comment_prompt(client: Any, from_open_id: str, doc: dict) -> str: - """Build the prompt for a whole-document comment from all whole comments on the doc. - - *doc* = build_*_prompt's shared kwargs (doc_title, doc_url, file_token, file_type, self_open_id). - """ + """Build the prompt for a whole-document comment from all whole comments on the doc. *doc* = build_*_prompt's shared kwargs (doc_title, doc_url, file_token, + file_type, self_open_id).""" file_token, file_type, self_open_id = doc["file_token"], doc["file_type"], doc["self_open_id"] logger.info("[Feishu-Comment] Fetching whole-document comments for timeline...") whole_comments = await list_whole_comments(client, file_token, file_type) - timeline: Timeline = [] - all_raw_replies: List[Dict[str, Any]] = [] + all_raw_replies: List[Dict[str, Any]] = [r for wc in whole_comments for r in _reply_list_replies(wc)] + timeline: Timeline = [_timeline_entry(r, self_open_id) for r in all_raw_replies] current_text, current_index, nearest_self_index = "", -1, -1 - for wc in whole_comments: - replies = _reply_list_replies(wc) - all_raw_replies.extend(replies) - for r in replies: - uid, _, is_self = entry = _timeline_entry(r, self_open_id) - idx = len(timeline) - timeline.append(entry) - if uid == from_open_id: - current_text, current_index = _extract_semantic_text(r, self_open_id), idx - if is_self: - nearest_self_index = idx + for idx, (r, (uid, _, is_self)) in enumerate(zip(all_raw_replies, timeline)): + if uid == from_open_id: + current_text, current_index = _extract_reply_text(r, semantic=True, self_open_id=self_open_id), idx + if is_self: + nearest_self_index = idx if not current_text and (found := _last_index_where(timeline, lambda e: not e[2])): current_text, current_index = found logger.info("[Feishu-Comment] Whole timeline: %d entries, current_idx=%d, self_idx=%d, text=%s", len(timeline), current_index, nearest_self_index, current_text[:80] if current_text else "(empty)") - return build_whole_comment_prompt( - comment_text=current_text, timeline=timeline, current_index=current_index, nearest_self_index=nearest_self_index, - referenced_docs=await _referenced_docs_text(client, all_raw_replies, file_token), **doc, - ) + return build_whole_comment_prompt(comment_text=current_text, timeline=timeline, current_index=current_index, nearest_self_index=nearest_self_index, + referenced_docs=await _referenced_docs_text(client, all_raw_replies, file_token), **doc) -async def _local_comment_prompt( - client: Any, comment_id: str, reply_id: str, from_open_id: str, quote_text: str, doc: dict, -) -> str: +async def _local_comment_prompt(client: Any, comment_id: str, reply_id: str, from_open_id: str, quote_text: str, doc: dict) -> str: """Build the prompt for a local comment from its thread replies (*doc* as in _whole_comment_prompt).""" file_token, file_type, self_open_id = doc["file_token"], doc["file_type"], doc["self_open_id"] logger.info("[Feishu-Comment] Fetching comment thread replies...") replies = await list_comment_replies(client, file_token, file_type, comment_id, expect_reply_id=reply_id) timeline: Timeline = [_timeline_entry(r, self_open_id) for r in replies] - root_text = _extract_semantic_text(replies[0], self_open_id) if replies else "" - target_text, target_index = "", -1 - for i, r in enumerate(replies): - rid = r.get("reply_id", "") - if rid and rid == reply_id: - target_text, target_index = _extract_semantic_text(r, self_open_id), i + root_text = _extract_reply_text(replies[0], semantic=True, self_open_id=self_open_id) if replies else "" + hits = [(_extract_reply_text(r, semantic=True, self_open_id=self_open_id), i) for i, r in enumerate(replies) if reply_id and r.get("reply_id", "") == reply_id] + target_text, target_index = hits[-1] if hits else ("", -1) if not target_text and (found := _last_index_where(timeline, lambda e: e[0] == from_open_id)): target_text, target_index = found logger.info("[Feishu-Comment] Local timeline: %d entries, target_idx=%d, quote=%s root=%s target=%s", - len(timeline), target_index, quote_text[:60] if quote_text else "(empty)", - root_text[:60] if root_text else "(empty)", - target_text[:60] if target_text else "(empty)") - return build_local_comment_prompt( - comment_id=comment_id, quote_text=quote_text, root_comment_text=root_text, target_reply_text=target_text, - timeline=timeline, target_index=target_index, - referenced_docs=await _referenced_docs_text(client, replies, file_token), **doc, - ) + len(timeline), target_index, *(t[:60] if t else "(empty)" for t in (quote_text, root_text, target_text))) + return build_local_comment_prompt(comment_id=comment_id, quote_text=quote_text, root_comment_text=root_text, target_reply_text=target_text, timeline=timeline, + target_index=target_index, referenced_docs=await _referenced_docs_text(client, replies, file_token), **doc) async def handle_drive_comment_event(client: Any, data: Any, *, self_open_id: str = "") -> None: - """Full orchestration for a drive comment event. - - Parse + filter (self-reply, receiver, notice_type) -> access rules -> OK reaction - -> parallel fetch (doc meta + comment) -> build timeline/prompt by is_whole - -> run agent -> deliver reply -> remove OK reaction. - """ + """Full orchestration for a drive comment event. Parse + filter (self-reply, receiver, notice_type) -> access rules -> OK reaction -> parallel fetch (doc meta + + comment) -> build timeline/prompt by is_whole -> run agent -> deliver reply -> remove OK reaction.""" logger.info("[Feishu-Comment] ========== handle_drive_comment_event START ==========") parsed = parse_drive_comment_event(data) if parsed is None: - logger.warning("[Feishu-Comment] Dropping malformed drive comment event") - return + return logger.warning("[Feishu-Comment] Dropping malformed drive comment event") logger.info("[Feishu-Comment] [Step 0/5] Event parsed successfully") - file_token, file_type, comment_id, reply_id, from_open_id, to_open_id, notice_type = ( - parsed[k] for k in ("file_token", "file_type", "comment_id", "reply_id", "from_open_id", "to_open_id", "notice_type") - ) - - if from_open_id and self_open_id and from_open_id == self_open_id: - logger.debug("[Feishu-Comment] Skipping self-authored event: from=%s", from_open_id) - return - if not to_open_id or (self_open_id and to_open_id != self_open_id): - logger.debug("[Feishu-Comment] Skipping event not addressed to self: to=%s", to_open_id or "(empty)") - return - if notice_type and notice_type not in _ALLOWED_NOTICE_TYPES: - logger.debug("[Feishu-Comment] Skipping notice_type=%s", notice_type) - return - if not file_token or not file_type or not comment_id: - logger.warning("[Feishu-Comment] Missing required fields, skipping") - return - logger.info("[Feishu-Comment] Event: notice=%s file=%s:%s comment=%s from=%s", - notice_type, file_type, file_token, comment_id, from_open_id) - - # Access control. Wiki-hosted docs report their underlying obj token, so when no - # exact rule matched and the config has wiki: keys, reverse-lookup the wiki node. + parsed[k] for k in ("file_token", "file_type", "comment_id", "reply_id", "from_open_id", "to_open_id", "notice_type")) + for skip, level, fmt, arg in ( # ordered early-exit filters + (from_open_id and self_open_id and from_open_id == self_open_id, logging.DEBUG, "[Feishu-Comment] Skipping self-authored event: from=%s", from_open_id), + (not to_open_id or (self_open_id and to_open_id != self_open_id), logging.DEBUG, "[Feishu-Comment] Skipping event not addressed to self: to=%s", to_open_id or "(empty)"), + (notice_type and notice_type not in _ALLOWED_NOTICE_TYPES, logging.DEBUG, "[Feishu-Comment] Skipping notice_type=%s", notice_type), + (not file_token or not file_type or not comment_id, logging.WARNING, "[Feishu-Comment] Missing required fields, skipping", None), + ): + if skip: + return logger.log(level, fmt, *([arg] if arg is not None else [])) + logger.info("[Feishu-Comment] Event: notice=%s file=%s:%s comment=%s from=%s", notice_type, file_type, file_token, comment_id, from_open_id) + # Access control. Wiki-hosted docs report their underlying obj token, so when no exact rule + # matched and the config has wiki: keys, reverse-lookup the wiki node. from plugins.platforms.feishu.feishu_comment_rules import load_config, resolve_rule, is_user_allowed, has_wiki_keys - comments_cfg = load_config() rule = resolve_rule(comments_cfg, file_type, file_token) - if rule.match_source in {"wildcard", "top"} and has_wiki_keys(comments_cfg): - wiki_token = await _reverse_lookup_wiki_token(client, file_type, file_token) - if wiki_token: - rule = resolve_rule(comments_cfg, file_type, file_token, wiki_token=wiki_token) + if rule.match_source in {"wildcard", "top"} and has_wiki_keys(comments_cfg) and (wiki_token := await _reverse_lookup_wiki_token(client, file_type, file_token)): + rule = resolve_rule(comments_cfg, file_type, file_token, wiki_token=wiki_token) if not rule.enabled: - logger.info("[Feishu-Comment] Comments disabled for %s:%s, skipping", file_type, file_token) - return + return logger.info("[Feishu-Comment] Comments disabled for %s:%s, skipping", file_type, file_token) if not is_user_allowed(rule, from_open_id): - logger.info("[Feishu-Comment] User %s denied (policy=%s, rule=%s)", from_open_id, rule.policy, rule.match_source) - return + return logger.info("[Feishu-Comment] User %s denied (policy=%s, rule=%s)", from_open_id, rule.policy, rule.match_source) logger.info("[Feishu-Comment] Access granted: user=%s policy=%s rule=%s", from_open_id, rule.policy, rule.match_source) - reaction_kwargs = dict(file_token=file_token, file_type=file_type, reply_id=reply_id, reaction_type="OK") if reply_id: asyncio.ensure_future(update_comment_reaction(client, "add", **reaction_kwargs)) - logger.info("[Feishu-Comment] [Step 2/5] Parallel fetch: doc meta + comment batch_query") - doc_meta, comment_detail = await asyncio.gather( - asyncio.ensure_future(query_document_meta(client, file_token, file_type)), - asyncio.ensure_future(batch_query_comment(client, file_token, file_type, comment_id)), - ) - doc_title = doc_meta.get("title", "Untitled") - doc_url = doc_meta.get("url", "") + doc_meta, comment_detail = await asyncio.gather(asyncio.ensure_future(query_document_meta(client, file_token, file_type)), + asyncio.ensure_future(batch_query_comment(client, file_token, file_type, comment_id))) + doc = dict(doc_title=doc_meta.get("title", "Untitled"), doc_url=doc_meta.get("url", ""), file_token=file_token, file_type=file_type, self_open_id=self_open_id) is_whole = bool(comment_detail.get("is_whole")) - logger.info("[Feishu-Comment] Comment context: title=%s is_whole=%s", doc_title, is_whole) - + logger.info("[Feishu-Comment] Comment context: title=%s is_whole=%s", doc["doc_title"], is_whole) logger.info("[Feishu-Comment] [Step 3/5] Building timeline (is_whole=%s)", is_whole) - doc = dict(doc_title=doc_title, doc_url=doc_url, file_token=file_token, file_type=file_type, self_open_id=self_open_id) - if is_whole: - prompt = await _whole_comment_prompt(client, from_open_id, doc) - else: - prompt = await _local_comment_prompt(client, comment_id, reply_id, from_open_id, comment_detail.get("quote", ""), doc) + prompt = await (_whole_comment_prompt(client, from_open_id, doc) if is_whole + else _local_comment_prompt(client, comment_id, reply_id, from_open_id, comment_detail.get("quote", ""), doc)) logger.info("[Feishu-Comment] [Step 4/5] Prompt built (%d chars), running agent...", len(prompt)) logger.debug("[Feishu-Comment] Full prompt:\n%s", prompt) - # run_conversation is synchronous -> thread. Session key groups all comment cards on one doc. - sess_key = _session_key(file_type, file_token) - response = await asyncio.get_running_loop().run_in_executor(None, _run_comment_agent, prompt, client, sess_key) - + response = await asyncio.get_running_loop().run_in_executor(None, _run_comment_agent, prompt, client, _session_key(file_type, file_token)) if not response or _NO_REPLY_SENTINEL in response: logger.info("[Feishu-Comment] Agent returned NO_REPLY, skipping delivery") else: logger.info("[Feishu-Comment] Agent response (%d chars): %s", len(response), response[:200]) logger.info("[Feishu-Comment] [Step 5/5] Delivering reply (is_whole=%s, comment_id=%s)", is_whole, comment_id) - if await deliver_comment_reply(client, file_token, file_type, comment_id, response, is_whole): - logger.info("[Feishu-Comment] Reply delivered successfully") - else: - logger.error("[Feishu-Comment] Failed to deliver reply") - + delivered = await deliver_comment_reply(client, file_token, file_type, comment_id, response, is_whole) + logger.log(logging.INFO if delivered else logging.ERROR, "[Feishu-Comment] Reply delivered successfully" if delivered else "[Feishu-Comment] Failed to deliver reply") if reply_id: # best-effort cleanup of the OK reaction await update_comment_reaction(client, "delete", **reaction_kwargs) logger.info("[Feishu-Comment] ========== handle_drive_comment_event END ==========") diff --git a/plugins/platforms/feishu/feishu_comment_rules.py b/plugins/platforms/feishu/feishu_comment_rules.py index d4e5f9706a..f9fe10a859 100644 --- a/plugins/platforms/feishu/feishu_comment_rules.py +++ b/plugins/platforms/feishu/feishu_comment_rules.py @@ -1,11 +1,6 @@ -""" -Feishu document comment access-control rules. - -3-tier rule resolution: exact doc > wildcard "*" > top-level > code defaults. -Each field (enabled/policy/allow_from) falls back independently. -Config: ~/.hermes/feishu_comment_rules.json (mtime-cached, hot-reload). -Pairing store: ~/.hermes/feishu_comment_pairing.json. -""" +"""Feishu document comment access-control rules: exact doc > wildcard "*" > top-level > code defaults, each field +(enabled/policy/allow_from) falling back independently. Config ~/.hermes/feishu_comment_rules.json (mtime-cached, +hot-reload); pairing store ~/.hermes/feishu_comment_pairing.json.""" from __future__ import annotations @@ -59,60 +54,44 @@ class _MtimeCache: """Mtime-based JSON file cache: ``stat()`` per access, re-read only on change.""" def __init__(self, path: Path): - self._path = path - self._mtime: float = 0.0 - self._data: Optional[dict] = None + self._path, self._mtime, self._data = path, 0.0, None def load(self) -> dict: try: mtime = self._path.stat().st_mtime except FileNotFoundError: - self._mtime = 0.0 - self._data = {} + self._mtime, self._data = 0.0, {} return {} if mtime == self._mtime and self._data is not None: return self._data try: with open(self._path, "r", encoding="utf-8") as f: data = json.load(f) - if not isinstance(data, dict): - data = {} except (json.JSONDecodeError, OSError): logger.warning("[Feishu-Rules] Failed to read %s, using empty config", self._path) data = {} - self._mtime = mtime - self._data = data - return data + self._mtime, self._data = mtime, (data if isinstance(data, dict) else {}) + return self._data _rules_cache = _MtimeCache(RULES_FILE) _pairing_cache = _MtimeCache(PAIRING_FILE) -# --- Config parsing --- - def _parse_frozenset(raw: Any) -> Optional[frozenset]: """Parse a list of strings into a frozenset; None if absent or not a list.""" - if isinstance(raw, (list, tuple)): - return frozenset(str(u).strip() for u in raw if str(u).strip()) - return None + return frozenset(str(u).strip() for u in raw if str(u).strip()) if isinstance(raw, (list, tuple)) else None def _parse_policy(raw: Any, default: Optional[str]) -> Optional[str]: """Normalize a policy value; unknown/invalid values fall back to *default*.""" - if raw is None: - return default - policy = str(raw).strip().lower() + policy = str(raw).strip().lower() if raw is not None else None return policy if policy in _VALID_POLICIES else default def _parse_document_rule(raw: dict) -> CommentDocumentRule: enabled = raw.get("enabled") - return CommentDocumentRule( - enabled=None if enabled is None else bool(enabled), - policy=_parse_policy(raw.get("policy"), None), - allow_from=_parse_frozenset(raw.get("allow_from")), - ) + return CommentDocumentRule(enabled=None if enabled is None else bool(enabled), policy=_parse_policy(raw.get("policy"), None), allow_from=_parse_frozenset(raw.get("allow_from"))) def load_config() -> CommentsConfig: @@ -121,20 +100,13 @@ def load_config() -> CommentsConfig: if not raw: return CommentsConfig() raw_docs = raw.get("documents", {}) - documents = { - str(key): _parse_document_rule(rule_raw) - for key, rule_raw in (raw_docs.items() if isinstance(raw_docs, dict) else ()) - if isinstance(rule_raw, dict) - } + documents = {str(key): _parse_document_rule(rule_raw) for key, rule_raw in (raw_docs.items() if isinstance(raw_docs, dict) else ()) if isinstance(rule_raw, dict)} return CommentsConfig( - enabled=raw.get("enabled", True), - policy=_parse_policy(raw.get("policy", "pairing"), "pairing"), + enabled=raw.get("enabled", True), policy=_parse_policy(raw.get("policy", "pairing"), "pairing"), allow_from=_parse_frozenset(raw.get("allow_from")) or frozenset(), documents=documents, ) -# --- Rule resolution (field-by-field fallback) --- - def has_wiki_keys(cfg: CommentsConfig) -> bool: """Check if any document rule key starts with 'wiki:'.""" return any(k.startswith("wiki:") for k in cfg.documents) @@ -148,53 +120,34 @@ def resolve_rule(cfg: CommentsConfig, file_type: str, file_token: str, wiki_toke exact_key = f"wiki:{wiki_token}" exact = cfg.documents.get(exact_key) layers = [(exact, f"exact:{exact_key}"), (cfg.documents.get("*"), "wildcard")] - - def _pick(field_name: str): - # First non-None document-layer value wins; otherwise the top-level value (even if None). - for layer, src in layers: - if layer is not None and getattr(layer, field_name) is not None: - return getattr(layer, field_name), src - return getattr(cfg, field_name), "top" - + def _pick(field_name: str): # first non-None document-layer value wins; otherwise the top-level value (even if None) + return next(((getattr(layer, field_name), src) for layer, src in layers if layer is not None and getattr(layer, field_name) is not None), (getattr(cfg, field_name), "top")) enabled, en_src = _pick("enabled") policy, pol_src = _pick("policy") - allow_from, _ = _pick("allow_from") # match_source = highest-priority tier that contributed enabled or policy priority_order = {"exact": 0, "wildcard": 1, "top": 2} best_src = min([en_src, pol_src], key=lambda s: priority_order.get(s.split(":")[0], 3)) - return ResolvedCommentRule(enabled=enabled, policy=policy, allow_from=allow_from, match_source=best_src) + return ResolvedCommentRule(enabled=enabled, policy=policy, allow_from=_pick("allow_from")[0], match_source=best_src) -# --- Pairing store --- - def _load_pairing_approved() -> set: """Return set of approved user open_ids (mtime-cached).""" approved = _pairing_cache.load().get("approved", {}) - if isinstance(approved, dict): - return set(approved.keys()) - if isinstance(approved, list): - return {str(u) for u in approved if u} - return set() + return set(approved.keys()) if isinstance(approved, dict) else ({str(u) for u in approved if u} if isinstance(approved, list) else set()) def _save_pairing(data: dict) -> None: PAIRING_FILE.parent.mkdir(parents=True, exist_ok=True) - tmp = PAIRING_FILE.with_suffix(".tmp") - with open(tmp, "w", encoding="utf-8") as f: + with open(PAIRING_FILE.with_suffix(".tmp"), "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) - tmp.replace(PAIRING_FILE) - _pairing_cache._mtime = 0.0 # invalidate so the next load re-reads - _pairing_cache._data = None + PAIRING_FILE.with_suffix(".tmp").replace(PAIRING_FILE) + _pairing_cache._mtime, _pairing_cache._data = 0.0, None # invalidate so the next load re-reads def _mutate_pairing(user_open_id: str, add: bool) -> bool: """Add/remove *user_open_id* in the approved dict; True when the store actually changed.""" data = _pairing_cache.load() - approved = data.get("approved", {}) - if not isinstance(approved, dict): - if not add: - return False - approved = {} + approved = data.get("approved", {}) if isinstance(data.get("approved"), dict) else {} if (user_open_id in approved) == add: return False if add: @@ -222,34 +175,24 @@ def pairing_list() -> Dict[str, Any]: return dict(approved) if isinstance(approved, dict) else {} -# --- Access check (public API for feishu_comment.py) --- - def is_user_allowed(rule: ResolvedCommentRule, user_open_id: str) -> bool: """Check if user passes the resolved rule's policy gate.""" - if user_open_id in rule.allow_from: - return True - if rule.policy == "pairing": - return user_open_id in _load_pairing_approved() - return False + return user_open_id in rule.allow_from or (rule.policy == "pairing" and user_open_id in _load_pairing_approved()) -# --- CLI --- +def _fmt_allow(allow_from) -> str: + return f"{sorted(allow_from) if allow_from else '[]'}" + def _print_status() -> None: cfg = load_config() - print(f"Rules file: {RULES_FILE}\n exists: {RULES_FILE.exists()}") - print(f"Pairing file: {PAIRING_FILE}\n exists: {PAIRING_FILE.exists()}\n") - print(f"Top-level:\n enabled: {cfg.enabled}\n policy: {cfg.policy}") - print(f" allow_from: {sorted(cfg.allow_from) if cfg.allow_from else '[]'}\n") - if cfg.documents: - print(f"Document rules ({len(cfg.documents)}):") - for key, rule in sorted(cfg.documents.items()): - fields = (("enabled", rule.enabled), ("policy", rule.policy), - ("allow_from", sorted(rule.allow_from) if rule.allow_from is not None else None)) - parts = [f"{name}={value}" for name, value in fields if value is not None] - print(f" [{key}] {', '.join(parts) if parts else '(empty — inherits all)'}") - else: - print("Document rules: (none)") + print(f"Rules file: {RULES_FILE}\n exists: {RULES_FILE.exists()}\nPairing file: {PAIRING_FILE}\n exists: {PAIRING_FILE.exists()}\n") + print(f"Top-level:\n enabled: {cfg.enabled}\n policy: {cfg.policy}\n allow_from: {_fmt_allow(cfg.allow_from)}\n") + print(f"Document rules ({len(cfg.documents)}):" if cfg.documents else "Document rules: (none)") + for key, rule in sorted(cfg.documents.items()): + fields = (("enabled", rule.enabled), ("policy", rule.policy), ("allow_from", sorted(rule.allow_from) if rule.allow_from is not None else None)) + parts = [f"{name}={value}" for name, value in fields if value is not None] + print(f" [{key}] {', '.join(parts) if parts else '(empty — inherits all)'}") print() approved = pairing_list() print(f"Pairing approved ({len(approved)}):") @@ -258,82 +201,64 @@ def _print_status() -> None: def _do_check(doc_key: str, user_open_id: str) -> None: - cfg = load_config() parts = doc_key.split(":", 1) if len(parts) != 2: - print(f"Error: doc_key must be 'fileType:fileToken', got '{doc_key}'") - return - rule = resolve_rule(cfg, parts[0], parts[1]) + return print(f"Error: doc_key must be 'fileType:fileToken', got '{doc_key}'") + rule = resolve_rule(load_config(), parts[0], parts[1]) allowed = is_user_allowed(rule, user_open_id) - print(f"Document: {doc_key}\nUser: {user_open_id}\nResolved rule:") - print(f" enabled: {rule.enabled}\n policy: {rule.policy}") - print(f" allow_from: {sorted(rule.allow_from) if rule.allow_from else '[]'}") - print(f" match_source: {rule.match_source}\nResult: {'ALLOWED' if allowed else 'DENIED'}") + print(f"Document: {doc_key}\nUser: {user_open_id}\nResolved rule:\n enabled: {rule.enabled}\n policy: {rule.policy}") + print(f" allow_from: {_fmt_allow(rule.allow_from)}\n match_source: {rule.match_source}\nResult: {'ALLOWED' if allowed else 'DENIED'}") + + +_PAIRING_OPS = {"add": (pairing_add, "Added: {}", "Already approved: {}"), "remove": (pairing_remove, "Removed: {}", "Not in approved list: {}")} def _pairing_cmd(args: list) -> int: """Handle ``pairing [user]``; returns the exit code.""" - if len(args) < 2: - print("Usage: pairing [args]") - return 1 - sub = args[1] + sub = args[1] if len(args) > 1 else None if sub == "list": approved = pairing_list() - if not approved: - print("(no approved users)") - for uid, meta in sorted(approved.items()): - print(f" {uid} approved_at={meta.get('approved_at', '?')}") + print(*(f" {uid} approved_at={meta.get('approved_at', '?')}" for uid, meta in sorted(approved.items())) if approved else ("(no approved users)",), sep="\n") return 0 - ops = {"add": (pairing_add, "Added: {}", "Already approved: {}"), - "remove": (pairing_remove, "Removed: {}", "Not in approved list: {}")} - if sub not in ops: - print(f"Unknown pairing subcommand: {sub}") - return 1 - if len(args) < 3: - print(f"Usage: pairing {sub} ") - return 1 - fn, ok_msg, noop_msg = ops[sub] - print((ok_msg if fn(args[2]) else noop_msg).format(args[2])) - return 0 + if sub in _PAIRING_OPS and len(args) >= 3: + fn, ok_msg, noop_msg = _PAIRING_OPS[sub] + print((ok_msg if fn(args[2]) else noop_msg).format(args[2])) + return 0 + print("Usage: pairing [args]" if sub is None else f"Usage: pairing {sub} " if sub in _PAIRING_OPS else f"Unknown pairing subcommand: {sub}") + return 1 def _main() -> int: try: - from hermes_cli.env_loader import load_hermes_dotenv - load_hermes_dotenv() + __import__("hermes_cli.env_loader", fromlist=["load_hermes_dotenv"]).load_hermes_dotenv() except Exception: pass - usage = ( - "Usage: python -m gateway.platforms.feishu_comment_rules [args]\n" - "\n" - "Commands:\n" - " status Show rules config and pairing state\n" - " check Simulate access check\n" - " pairing add Add user to pairing-approved list\n" - " pairing remove Remove user from pairing-approved list\n" - " pairing list List pairing-approved users\n" - "\n" - f"Rules config file: {RULES_FILE}\n" - " Edit this JSON file directly to configure policies and document rules.\n" - " Changes take effect on the next comment event (no restart needed).\n" - ) + usage = f"""Usage: python -m gateway.platforms.feishu_comment_rules [args] + +Commands: + status Show rules config and pairing state + check Simulate access check + pairing add Add user to pairing-approved list + pairing remove Remove user from pairing-approved list + pairing list List pairing-approved users + +Rules config file: {RULES_FILE} + Edit this JSON file directly to configure policies and document rules. + Changes take effect on the next comment event (no restart needed). +""" args = sys.argv[1:] - if not args: - print(usage) - return 1 - cmd = args[0] + cmd = args[0] if args else "" if cmd == "status": _print_status() - elif cmd == "check": - if len(args) < 3: - print("Usage: check ") - return 1 + elif cmd == "check" and len(args) >= 3: _do_check(args[1], args[2]) + elif cmd == "check": + print("Usage: check ") + return 1 elif cmd == "pairing": return _pairing_cmd(args) else: - print(f"Unknown command: {cmd}\n") - print(usage) + print(f"Unknown command: {cmd}\n{usage}" if cmd else usage) return 1 return 0 diff --git a/plugins/platforms/feishu/feishu_meeting_invite.py b/plugins/platforms/feishu/feishu_meeting_invite.py index 8a4d8516dc..6a55280db7 100644 --- a/plugins/platforms/feishu/feishu_meeting_invite.py +++ b/plugins/platforms/feishu/feishu_meeting_invite.py @@ -1,10 +1,5 @@ -""" -Feishu/Lark meeting-invitation event handling. - -Converts ``vc.bot.meeting_invited_v1`` events into a synthetic gateway ``MessageEvent`` -so the reply reaches the inviter through the normal Hermes gateway pipeline (unlike -document comments, no agent is instantiated here). -""" +"""Feishu/Lark meeting-invitation events: ``vc.bot.meeting_invited_v1`` -> synthetic gateway ``MessageEvent`` so the +reply reaches the inviter through the normal gateway pipeline (no agent is instantiated here).""" from __future__ import annotations @@ -49,101 +44,69 @@ 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 {} + try: + value = json.loads(value) if isinstance(value, str) else value + except (TypeError, json.JSONDecodeError): + return {} + return {str(k): v for k, v in value.items()} if isinstance(value, dict) else {} 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) + for item in map(_as_dict, content if isinstance(content, list) else ()): 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 + payload = next((p for p in map(_as_dict, (item.get(k) for k in ("data", "value", "content", "json"))) if p), {}) if ctype in ("", "application/json") else {} + if payload: + return payload return {} def _str_field(raw: Dict[str, Any], key: str, strip: bool = True) -> str: - value = str(raw.get(key) or "") - return value.strip() if strip else value + return str(raw.get(key) or "").strip() if strip else str(raw.get(key) or "") def _int_field(value: Any) -> int: - if value in (None, ""): - return 0 try: - return int(str(value).strip()) + return int(str(value).strip()) if value not in (None, "") else 0 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_field(raw_id, "open_id"), user_id=_str_field(raw_id, "user_id"), - union_id=_str_field(raw_id, "union_id"), - user_name=_str_field(raw, "user_name", strip=False), - ) + return MeetingInviteUser(open_id=_str_field(raw_id, "open_id"), user_id=_str_field(raw_id, "user_id"), union_id=_str_field(raw_id, "union_id"), + user_name=_str_field(raw, "user_name", strip=False)) if raw else None def _parse_meeting(value: Any) -> Optional[MeetingInviteMeeting]: raw = _as_dict(value) - if not raw: - return None return MeetingInviteMeeting( - id=_str_field(raw, "id"), topic=_str_field(raw, "topic", strip=False), - meeting_no=_str_field(raw, "meeting_no", strip=False), - 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")), - ) + id=_str_field(raw, "id"), topic=_str_field(raw, "topic", strip=False), meeting_no=_str_field(raw, "meeting_no", strip=False), + 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")), + ) if raw else None def parse_meeting_invited_event(data: Any) -> Optional[MeetingInvitedPayload]: root = _as_dict(data) event = _as_dict(root.get("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")) + event = {**event, **(_content_payload(event) or _content_payload(root))} + meeting, inviter = _parse_meeting(event.get("meeting")), _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")), - ) + 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 + meeting = payload.meeting or MeetingInviteMeeting() 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" + host_name = (meeting.host_user.user_name if meeting.host_user else "") or "unknown" + display = (meeting.topic or meeting.meeting_no or meeting.id) or "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}", - "", + f"You have been invited to join a meeting: {display}", "", + f"Meeting Number: {meeting.meeting_no or 'unknown'}", f"Topic: {meeting.topic 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.", @@ -153,41 +116,26 @@ def build_meeting_invite_prompt(payload: MeetingInvitedPayload) -> str: 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}" + return f"vc_invite:{payload.meeting.id if payload.meeting else ''}:{payload.inviter.open_id if payload.inviter else ''}:{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 + return logger.warning("[Feishu-MeetingInvite] Dropping malformed meeting invite event") 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 + return logger.debug("[Feishu-MeetingInvite] Dropping duplicate event: %s", dedup_key) 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) + return 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) + sender_profile = await adapter._resolve_sender_profile(SimpleNamespace(open_id=inviter.open_id or None, user_id=inviter.user_id or None, union_id=inviter.union_id or None)) 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, - ) + 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) diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 2c79593ab8..60975ac293 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -1,17 +1,10 @@ -""" -Google Chat platform adapter. +"""Google Chat platform adapter. -Inbound: authenticated HTTP callbacks or a Cloud Pub/Sub pull subscription -(for no-public-URL deployments). Outbound: Google Chat REST API. - -Concurrency model: the Pub/Sub SubscriberClient invokes its callback in a -background thread; ``handle_message`` must run on the asyncio loop, so the -callback schedules it thread-safely and never blocks on ``.result()`` (that -would saturate the Pub/Sub executor). All outbound Chat REST calls go through -``asyncio.to_thread`` because googleapiclient is synchronous. - -Event routing: only MESSAGE events dispatch to the agent. ADDED_TO_SPACE caches -the bot's resource name; CARD_CLICKED is only ACK'd (interactivity deferred). +Inbound: authenticated HTTP callbacks or a Cloud Pub/Sub pull subscription. Outbound: +Chat REST API (synchronous googleapiclient via ``asyncio.to_thread``). The Pub/Sub +callback runs on a background thread, so ``handle_message`` is scheduled thread-safely +onto the loop and never awaited there. Only MESSAGE events reach the agent; membership +events cache the bot id, card clicks are ACK'd only. """ from __future__ import annotations @@ -28,6 +21,7 @@ import threading import time from pathlib import Path as _Path from typing import Any, Callable, Dict, List, Optional, Tuple +from urllib.parse import urlparse from agent.secret_scope import is_multiplex_active from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret @@ -36,43 +30,26 @@ from .cards import card_spec_to_cards_v2, format_message as _format_message # n def _adc_would_borrow_foreign_credentials() -> bool: - """True when ADC would silently read another profile's SA from process env. - - ``google.auth.default()`` consults ``os.environ`` directly; under multiplexing - a scoped profile reaching the ADC branch would authenticate as the default - profile's identity if the process env carries one. Fail closed instead. - """ + """True when ADC would read another profile's SA from the process env under + multiplexing (``google.auth.default()`` consults ``os.environ`` directly).""" return is_multiplex_active() and bool( - os.environ.get("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") - or os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") + os.environ.get("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") or os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") ) -# Heavy google-cloud + googleapiclient imports are deferred to first adapter use -# (eager import cost ~110ms / ~33MB on every CLI invocation via plugin discovery). -# ``_load_google_modules()`` rebinds these globals. ``HttpError = Exception`` is -# the placeholder: ``except HttpError`` clauses bind the *current* global at -# evaluation time, and ``__init__`` always loads the real class first. +# Heavy google imports are deferred to first adapter use (~110ms / ~33MB on every CLI +# start). ``_load_google_modules()`` rebinds these; ``HttpError = Exception`` keeps +# ``except HttpError`` valid meanwhile. GOOGLE_CHAT_AVAILABLE: bool = False -httplib2: Any = None # type: ignore -pubsub_v1: Any = None # type: ignore -gax_exceptions: Any = None # type: ignore -service_account: Any = None # type: ignore -AuthorizedHttp: Any = None # type: ignore -build_service: Any = None # type: ignore +httplib2 = pubsub_v1 = gax_exceptions = service_account = AuthorizedHttp = build_service = MediaFileUpload = None # type: ignore HttpError: Any = Exception # type: ignore -MediaFileUpload: Any = None # type: ignore _google_modules_loaded: bool = False # (global name, module, attribute-or-None) rebound by ``_load_google_modules``. _GOOGLE_IMPORTS = ( - ("httplib2", "httplib2", None), - ("pubsub_v1", "google.cloud.pubsub_v1", None), - ("gax_exceptions", "google.api_core.exceptions", None), - ("service_account", "google.oauth2.service_account", None), - ("AuthorizedHttp", "google_auth_httplib2", "AuthorizedHttp"), - ("build_service", "googleapiclient.discovery", "build"), - ("HttpError", "googleapiclient.errors", "HttpError"), - ("MediaFileUpload", "googleapiclient.http", "MediaFileUpload"), + ("httplib2", "httplib2", None), ("pubsub_v1", "google.cloud.pubsub_v1", None), + ("gax_exceptions", "google.api_core.exceptions", None), ("service_account", "google.oauth2.service_account", None), + ("AuthorizedHttp", "google_auth_httplib2", "AuthorizedHttp"), ("build_service", "googleapiclient.discovery", "build"), + ("HttpError", "googleapiclient.errors", "HttpError"), ("MediaFileUpload", "googleapiclient.http", "MediaFileUpload"), ) _GOOGLE_ID_TOKEN_CERTS_TTL_SECONDS = 300 _google_id_token_request: Any = None @@ -80,10 +57,10 @@ _google_id_token_request_lock = threading.Lock() class _CachedGoogleAuthRequest: + """Caches successful GET responses (Google cert fetches) for ``ttl_seconds``.""" + def __init__(self, request: Any, ttl_seconds: int = _GOOGLE_ID_TOKEN_CERTS_TTL_SECONDS) -> None: - self._request = request - self._ttl_seconds = ttl_seconds - self._lock = threading.Lock() + self._request, self._ttl_seconds, self._lock = request, ttl_seconds, threading.Lock() self._cache: Dict[Tuple[str, str], Tuple[float, Any]] = {} def __call__(self, url: str, method: str = "GET", **kwargs: Any) -> Any: @@ -123,11 +100,7 @@ def _verify_google_id_token(token: str, audience: str) -> Dict[str, Any]: def _load_google_modules() -> bool: - """Lazily import the google-cloud + googleapiclient stack; idempotent. - - Returns True when the optional deps imported; rebinds the module globals so - code using ``pubsub_v1``, ``service_account``, ``HttpError`` sees real classes. - """ + """Lazily import the google stack (idempotent); True when the optional deps exist.""" global GOOGLE_CHAT_AVAILABLE, _google_modules_loaded if _google_modules_loaded: return GOOGLE_CHAT_AVAILABLE @@ -146,9 +119,8 @@ def _load_google_modules() -> bool: from gateway.config import Platform, PlatformConfig -# Register the dynamic ``google_chat`` enum member at import time: ``_missing_()`` -# caches the pseudo-member so ``Platform.GOOGLE_CHAT`` attribute access works -# before any adapter instance is constructed. +# Register the dynamic enum member at import time so ``Platform.GOOGLE_CHAT`` +# resolves before any adapter instance exists. Platform("google_chat") from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import ( @@ -156,30 +128,39 @@ from gateway.platforms.base import ( cache_audio_from_bytes, cache_document_from_bytes, cache_image_from_bytes, cache_video_from_bytes, ) - -# Pinned to the legacy module path so operator log filters keep matching after -# the in-tree → plugin migration (``__name__`` is namespaced by the plugin loader). +# Pinned to the legacy module path so operator log filters keep matching. logger = logging.getLogger("gateway.platforms.google_chat") _SUBSCRIPTION_PATH_RE = re.compile(r"^projects/(?P[^/]+)/subscriptions/(?P[^/]+)$") - -# chat.bot covers the bot's own messaging ops (create/patch/delete, spaces, -# memberships, media.download). The bot CANNOT call media.upload — Google -# requires user OAuth for it; see ``oauth.py`` for the per-user consent flow. +# chat.bot covers the bot's own messaging ops; it CANNOT call media.upload +# (user OAuth required — see ``oauth.py``). _CHAT_SCOPES = ["https://www.googleapis.com/auth/chat.bot", "https://www.googleapis.com/auth/pubsub"] - -# Google Chat text-message size limit is 4096; leave margin. -_MAX_TEXT_LENGTH = 4000 -# Per-space rate-limit hit counter threshold; warn if exceeded. +_MAX_TEXT_LENGTH = 4000 # Chat limit is 4096; leave margin. _RATE_LIMIT_WARN_THRESHOLD = 5 - -# Outbound retry: transient 429/5xx would otherwise drop user-visible messages; -# backoff stays bounded so a true outage surfaces quickly. -_RETRY_MAX_ATTEMPTS = 3 -_RETRY_BASE_DELAY = 1.0 -_RETRY_MAX_DELAY = 8.0 -_RETRY_JITTER = 0.3 +# Bounded outbound retry for transient 429/5xx so a true outage surfaces quickly. +_RETRY_MAX_ATTEMPTS, _RETRY_BASE_DELAY, _RETRY_MAX_DELAY, _RETRY_JITTER = 3, 1.0, 8.0, 0.3 _RETRYABLE_HTTP_STATUSES = frozenset({429, 500, 502, 503, 504}) +_TRANSPORT_ERROR_PHRASES = ("timeout", "timed out", "broken pipe", "remote disconnected") +# Left in ``_typing_messages`` after ``send()`` patched the typing card: stops +# ``_keep_typing`` from creating a fresh card and ``stop_typing`` from deleting +# (tombstoning) the response we just patched. +_TYPING_CONSUMED_SENTINEL = "" +# SSRF guard: attachment download URIs must target a Google-owned https host +# (else the SA bearer token could be sent to e.g. the GCE metadata service). +_TRUSTED_ATTACHMENT_HOSTS = ( + "googleapis.com", "chat.google.com", "drive.google.com", "docs.google.com", + "lh3.googleusercontent.com", "lh4.googleusercontent.com", "lh5.googleusercontent.com", "lh6.googleusercontent.com", +) +_REDACTIONS = ( + (re.compile(r"projects/[^/\s]+/subscriptions/[^/\s]+"), "projects//subscriptions/"), + (re.compile(r"projects/[^/\s]+/topics/[^/\s]+"), "projects//topics/"), + (re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.iam\.gserviceaccount\.com"), "@.iam.gserviceaccount.com"), +) +_MIME_MESSAGE_TYPES = (("image/", MessageType.PHOTO), ("audio/", MessageType.AUDIO), ("video/", MessageType.VIDEO)) +_MEDIA_CACHERS = ( + ("image/", cache_image_from_bytes, ".jpg"), ("audio/", cache_audio_from_bytes, ".ogg"), + ("video/", cache_video_from_bytes, ".mp4"), +) def _http_status(exc: BaseException) -> Any: @@ -194,17 +175,9 @@ def _is_retryable_error(exc: BaseException) -> bool: return status in _RETRYABLE_HTTP_STATUSES # SSL/socket errors carry no HTTP status: match common transport wording. text = str(exc).lower() - if "timeout" in text or "timed out" in text: - return True - if "connection" in text and ("reset" in text or "refused" in text or "aborted" in text): - return True - return "broken pipe" in text or "remote disconnected" in text - -# Left in ``_typing_messages`` after ``send()`` patches the typing marker into the -# real response: keeps ``_keep_typing`` from creating a fresh card before the base -# class cancels its typing task, and tells ``stop_typing`` to skip the API delete -# (which would tombstone the response we just patched). -_TYPING_CONSUMED_SENTINEL = "" + return any(p in text for p in _TRANSPORT_ERROR_PHRASES) or ( + "connection" in text and any(w in text for w in ("reset", "refused", "aborted")) + ) def check_google_chat_requirements() -> bool: @@ -212,27 +185,14 @@ def check_google_chat_requirements() -> bool: return _load_google_modules() -# Only these hosts may serve attachment download URIs: anything else is rejected -# to block SSRF (e.g. downloadUri pointing at the GCE metadata service with the -# SA bearer token attached). -_TRUSTED_ATTACHMENT_HOSTS = ( - "googleapis.com", "chat.google.com", "drive.google.com", "docs.google.com", - "lh3.googleusercontent.com", "lh4.googleusercontent.com", "lh5.googleusercontent.com", "lh6.googleusercontent.com", -) - - def _is_google_owned_host(url: str) -> bool: """Return True iff *url* is https and targets a Google-owned domain.""" try: - from urllib.parse import urlparse - parsed = urlparse(url) except Exception: return False - if parsed.scheme != "https": - return False host = (parsed.hostname or "").lower() - if not host: + if parsed.scheme != "https" or not host: return False return any(host == h or host.endswith("." + h) for h in _TRUSTED_ATTACHMENT_HOSTS) @@ -241,19 +201,11 @@ def _redact_sensitive(text: str) -> str: """Redact Pub/Sub resource paths and SA emails from user-facing error strings.""" if not text: return text - text = re.sub(r"projects/[^/\s]+/subscriptions/[^/\s]+", "projects//subscriptions/", text) - text = re.sub(r"projects/[^/\s]+/topics/[^/\s]+", "projects//topics/", text) - text = re.sub( - r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.iam\.gserviceaccount\.com", - "@.iam.gserviceaccount.com", - text, - ) + for pattern, repl in _REDACTIONS: + text = pattern.sub(repl, text) return text -_MIME_MESSAGE_TYPES = (("image/", MessageType.PHOTO), ("audio/", MessageType.AUDIO), ("video/", MessageType.VIDEO)) - - def _mime_for_message_type(mime: str) -> MessageType: """Map a MIME string to a MessageType; non-media falls through to DOCUMENT.""" for prefix, message_type in _MIME_MESSAGE_TYPES: @@ -267,26 +219,22 @@ class _SACredentialError(Exception): def __init__(self, kind: str, detail: Optional[BaseException] = None) -> None: super().__init__(kind) - self.kind = kind - self.detail = detail + self.kind, self.detail = kind, detail def _load_sa_credentials_from(sa_value: Optional[str]) -> Any: """Build SA credentials from a path / inline JSON, or fall back to ADC. - Raises ``_SACredentialError`` with kind in {inline_invalid, not_found, - file_invalid, adc_foreign, adc_no_auth, adc_failed}; callers map kinds to - their own user-facing messages. - """ + file_invalid, adc_foreign, adc_no_auth, adc_failed}.""" if sa_value: if sa_value.lstrip().startswith("{"): try: info = json.loads(sa_value) except json.JSONDecodeError as exc: raise _SACredentialError("inline_invalid", exc) from exc + elif not os.path.exists(sa_value): + raise _SACredentialError("not_found") else: - if not os.path.exists(sa_value): - raise _SACredentialError("not_found") try: with open(sa_value, "r", encoding="utf-8") as fh: info = json.load(fh) @@ -308,17 +256,11 @@ def _load_sa_credentials_from(sa_value: Optional[str]) -> Any: class _ThreadCountStore: - """Per-(chat_id, thread_name) inbound message counter, persisted to disk. - - Drives the DM main-flow vs side-thread heuristic: prev_count == 0 means Chat - just auto-created the thread for a top-level message (shared DM session, reply - top-level); prev_count >= 1 means the user engaged an existing thread (isolate - session, reply in-thread). Persistence matters: a restart that wiped counts - would demote active side-threads to main flow and leak context. - - File format: ``{"": {"": }}``. Missing or - corrupt file resets to empty (warned); write-through on every ``incr``. - """ + """Persisted per-(chat_id, thread_name) inbound counter driving the DM main-flow vs + side-thread heuristic (0: Chat auto-created the thread for a top-level message; >=1: + user engaged an existing thread). Persisted because a restart that wiped counts would + demote active side-threads to main flow and leak context. Format + ``{"": {"": }}``; missing/corrupt resets to empty.""" def __init__(self, path: _Path): self._path = path @@ -326,30 +268,23 @@ class _ThreadCountStore: def load(self) -> None: """Load counts from disk; missing file → empty, corrupt JSON → empty + warn.""" + self._counts = {} if not self._path.exists(): - self._counts = {} return try: raw = self._path.read_text(encoding="utf-8") data = json.loads(raw) if raw.strip() else {} - except json.JSONDecodeError as exc: - logger.warning("[GoogleChat] thread-count store at %s is corrupt; starting fresh: %s", self._path, exc) - self._counts = {} + except (json.JSONDecodeError, OSError) as exc: + fmt = ("[GoogleChat] thread-count store at %s is corrupt; starting fresh: %s" if isinstance(exc, ValueError) + else "[GoogleChat] could not read thread-count store at %s: %s") + logger.warning(fmt, self._path, exc) return - except OSError as exc: - logger.warning("[GoogleChat] could not read thread-count store at %s: %s", self._path, exc) - self._counts = {} - return - # Validate shape — anything off-schema gets dropped silently. - clean: Dict[str, Dict[str, int]] = {} - if isinstance(data, dict): - for chat_id, threads in data.items(): - if not isinstance(chat_id, str) or not isinstance(threads, dict): - continue - clean_threads = {t: c for t, c in threads.items() if isinstance(t, str) and isinstance(c, int)} - if clean_threads: - clean[chat_id] = clean_threads - self._counts = clean + # Anything off-schema is dropped silently. + for chat_id, threads in (data.items() if isinstance(data, dict) else ()): + if isinstance(chat_id, str) and isinstance(threads, dict): + clean = {t: c for t, c in threads.items() if isinstance(t, str) and isinstance(c, int)} + if clean: + self._counts[chat_id] = clean def get(self, chat_id: str, thread_name: str) -> int: return self._counts.get(chat_id, {}).get(thread_name, 0) @@ -377,93 +312,78 @@ _SA_ERROR_MESSAGES = { "inline_invalid": "Inline SA JSON is not valid JSON: {exc}", "not_found": "Service Account JSON file not found at configured path.", "file_invalid": "Service Account JSON file is not valid JSON: {exc}", - "adc_foreign": ( - "Google Chat ADC skipped for this profile: service-account credentials are set in the process " - "environment but not in this profile's secret scope. Set GOOGLE_CHAT_SERVICE_ACCOUNT_JSON in this " - "profile's .env." - ), - "adc_no_auth": ( - "No Service Account credentials configured. Set GOOGLE_CHAT_SERVICE_ACCOUNT_JSON or " - "GOOGLE_APPLICATION_CREDENTIALS, or install google-auth to use Application Default Credentials." - ), - "adc_failed": ( - "No Service Account credentials configured and Application Default Credentials are unavailable. Set " - "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON or run ``gcloud auth application-default login``. ADC error: {exc}" - ), + "adc_foreign": ("Google Chat ADC skipped for this profile: service-account credentials are set in the process " + "environment but not in this profile's secret scope. Set GOOGLE_CHAT_SERVICE_ACCOUNT_JSON in this " + "profile's .env."), + "adc_no_auth": ("No Service Account credentials configured. Set GOOGLE_CHAT_SERVICE_ACCOUNT_JSON or " + "GOOGLE_APPLICATION_CREDENTIALS, or install google-auth to use Application Default Credentials."), + "adc_failed": ("No Service Account credentials configured and Application Default Credentials are unavailable. Set " + "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON or run ``gcloud auth application-default login``. ADC error: {exc}"), } +def _thread_body(text: str, thread_id: Optional[str]) -> Dict[str, Any]: + """``{"text": ...}`` plus ``thread.name`` when replying into a thread.""" + body: Dict[str, Any] = {"text": text} + if thread_id: + body["thread"] = {"name": thread_id} + return body + + +def _create_kwargs(chat_id: str, body: Dict[str, Any]) -> Dict[str, Any]: + """messages.create kwargs. With ``thread.name`` we MUST pass + ``messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`` — the default silently + ignores thread.name; FALLBACK (vs OR_FAIL) still delivers when the thread is gone.""" + kwargs: Dict[str, Any] = {"parent": chat_id, "body": body} + if (body.get("thread") or {}).get("name"): + kwargs["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" + return kwargs + + class GoogleChatAdapter(BasePlatformAdapter): - """ - Google Chat bot adapter using Pub/Sub pull + Chat REST API. - - Required environment (see gateway/config.py Google Chat block): - GOOGLE_CHAT_PROJECT_ID (or GOOGLE_CLOUD_PROJECT fallback) - GOOGLE_CHAT_SUBSCRIPTION_NAME (or GOOGLE_CHAT_SUBSCRIPTION fallback) - GOOGLE_CHAT_SERVICE_ACCOUNT_JSON (or GOOGLE_APPLICATION_CREDENTIALS) - - Optional: - GOOGLE_CHAT_ALLOWED_USERS, GOOGLE_CHAT_ALLOW_ALL_USERS - GOOGLE_CHAT_HOME_CHANNEL - GOOGLE_CHAT_MAX_MESSAGES (FlowControl, default 1) - GOOGLE_CHAT_MAX_BYTES (FlowControl, default 16_777_216 = 16 MiB) - """ + """Google Chat bot adapter: Pub/Sub pull (or HTTP callbacks) + Chat REST API. Env vars + are documented in gateway/config.py (GOOGLE_CHAT_PROJECT_ID, GOOGLE_CHAT_SUBSCRIPTION_NAME, + GOOGLE_CHAT_SERVICE_ACCOUNT_JSON + optional allowlist/home-channel/flow-control keys).""" MAX_MESSAGE_LENGTH = _MAX_TEXT_LENGTH # Pub/Sub supervisor configuration. _MAX_RECONNECT_ATTEMPTS = 10 _RECONNECT_BASE_DELAY = 2.0 _RECONNECT_MAX_DELAY = 120.0 + _LEGACY_USER_IDENTITY = "__legacy__" def __init__(self, config: PlatformConfig): - # ``Platform("google_chat")`` resolves via ``_missing_()``; bundled platform - # plugins are looked up by value, not enum attribute (matches Teams, IRC). + # Bundled platform plugins are looked up by enum value (matches Teams, IRC). super().__init__(config, Platform("google_chat")) - # Load the google stack here (not only in connect()) so code paths that - # construct the adapter and call methods directly see real classes for - # ``MediaFileUpload`` / ``HttpError`` / etc. Idempotent. + # Load here (not only in connect()) so direct method calls see real + # ``MediaFileUpload`` / ``HttpError`` classes. Idempotent. _load_google_modules() - self._subscriber: Optional[Any] = None - self._chat_api: Optional[Any] = None - # User-authed Chat clients for native ``media.upload`` (bot identity is - # rejected there). Multi-user: each user's token lives under their email - # and ``_send_file`` picks the requesting user's via ``_last_sender_by_chat``. - # ``_user_credentials`` / ``_user_chat_api`` hold the LEGACY single-user - # token as a last-ditch fallback for pre-multi-user installs. - self._user_chat_api: Optional[Any] = None - self._user_credentials: Optional[Any] = None + self._subscriber = self._chat_api = self._credentials = self._streaming_pull_future = None + self._project_id = self._subscription_path = self._bot_user_id = None # bot id is users/{id} + self._supervisor_task: Optional[asyncio.Task] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + # User-authed Chat clients for native ``media.upload`` (bot identity is rejected + # there) keyed by sender email; ``_user_credentials``/``_user_chat_api`` = LEGACY fallback. + self._user_chat_api = self._user_credentials = None self._user_creds_by_email: Dict[str, Any] = {} self._user_chat_api_by_email: Dict[str, Any] = {} # chat_id → most-recent inbound sender email (drives per-user token lookup). self._last_sender_by_chat: Dict[str, str] = {} - self._credentials: Optional[Any] = None - self._project_id: Optional[str] = None - self._subscription_path: Optional[str] = None - self._streaming_pull_future: Optional[Any] = None - self._supervisor_task: Optional[asyncio.Task] = None - self._loop: Optional[asyncio.AbstractEventLoop] = None - self._bot_user_id: Optional[str] = None # users/{id} self._dedup = MessageDeduplicator() - self._typing_messages: Dict[str, str] = {} - self._clarify_state: Dict[str, str] = {} self._shutting_down = False - self._rate_limit_hits: Dict[str, int] = {} - # Last inbound thread per space. DMs create a NEW thread per top-level - # message but users see one conversation: thread_id is dropped from the - # source (stable session key) and cached here so replies still land in - # the right visual thread. + self._typing_messages: Dict[str, str] = {} + self._clarify_state, self._rate_limit_hits = {}, {} + # Last inbound thread per space: DMs get a NEW thread per top-level message but users + # see one conversation, so thread_id leaves the source (stable session key) and is cached here. self._last_inbound_thread: Dict[str, str] = {} - # Persisted per-(chat, thread) inbound counts for the side-thread heuristic - # (survives gateway restarts). try: from hermes_constants import get_hermes_home as _get_hermes_home _hermes_home = _get_hermes_home() except (ModuleNotFoundError, ImportError): _hermes_home = _Path.home() / ".hermes" self._thread_count_store = _ThreadCountStore(_hermes_home / "google_chat_thread_counts.json") - # In-flight typing-card creates per chat_id: send_typing() reserves an Event - # BEFORE the API call so concurrent _keep_typing calls wait instead of - # duplicating cards. + # In-flight typing-card creates per chat_id: reserved BEFORE the API call so + # concurrent _keep_typing calls wait instead of duplicating cards. self._typing_card_inflight: Dict[str, asyncio.Event] = {} # Typing cards that lost a race with send(); patched away at end of turn. self._orphan_typing_messages: Dict[str, List[str]] = {} @@ -473,16 +393,13 @@ class GoogleChatAdapter(BasePlatformAdapter): self._max_messages = self._int_setting(extra, "max_messages", "GOOGLE_CHAT_MAX_MESSAGES", 1) self._max_bytes = self._int_setting(extra, "max_bytes", "GOOGLE_CHAT_MAX_BYTES", 16 * 1024 * 1024) self._bootstrap_spaces = str( - extra.get("bootstrap_spaces") or _get_scoped_secret("GOOGLE_CHAT_BOOTSTRAP_SPACES", "") or "" - ).strip() + extra.get("bootstrap_spaces") or _get_scoped_secret("GOOGLE_CHAT_BOOTSTRAP_SPACES", "") or "").strip() self._debug_raw = bool(extra.get("debug_raw") or _get_scoped_secret("GOOGLE_CHAT_DEBUG_RAW")) self._http_events_url = self._str_setting(extra, "http_events_url", "GOOGLE_CHAT_HTTP_EVENTS_URL") self._http_events_audience = self._str_setting( - extra, "http_events_audience", "GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE", self._http_events_url - ) + extra, "http_events_audience", "GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE", self._http_events_url) self._http_events_service_account_email = self._str_setting( - extra, "http_events_service_account_email", "GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL" - ).lower() + extra, "http_events_service_account_email", "GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL").lower() @staticmethod def _int_setting(extra: Dict[str, Any], key: str, env_name: str, default: int) -> int: @@ -495,12 +412,9 @@ class GoogleChatAdapter(BasePlatformAdapter): def _str_setting(extra: Dict[str, Any], key: str, env_name: str, fallback: str = "") -> str: return (extra.get(key) or _get_scoped_secret(env_name, "") or fallback).strip() - # ------------------------------------------------------------------ - # Configuration loading and validation - # ------------------------------------------------------------------ + # -- configuration ------------------------------------------------------- def _load_sa_credentials(self) -> Any: - """Load SA credentials: ``extra['service_account_json']`` (path or inline - JSON) → ``GOOGLE_APPLICATION_CREDENTIALS`` → Application Default Credentials.""" + """SA credentials: ``extra['service_account_json']`` → GOOGLE_APPLICATION_CREDENTIALS → ADC.""" sa_path = self.config.extra.get("service_account_json") or _get_scoped_secret("GOOGLE_APPLICATION_CREDENTIALS") try: credentials = _load_sa_credentials_from(sa_path) @@ -515,13 +429,10 @@ class GoogleChatAdapter(BasePlatformAdapter): def _validate_config(self) -> Tuple[str, Optional[str]]: """Return (project_id, subscription_path); the latter is None for HTTP inbound. - - Raises ValueError with a sanitized message on any config problem. - """ + Raises ValueError with a sanitized message on any config problem.""" project_id = (self.config.extra.get("project_id") or "").strip() subscription = (self.config.extra.get("subscription_name") or "").strip() http_events_url = (self.config.extra.get("http_events_url") or "").strip() - if subscription: match = _SUBSCRIPTION_PATH_RE.match(subscription) if not match: @@ -529,8 +440,7 @@ class GoogleChatAdapter(BasePlatformAdapter): subscription_project = match.group("project") if project_id and subscription_project != project_id: raise ValueError( - "project_id in GOOGLE_CHAT_PROJECT_ID does not match the " - "project embedded in GOOGLE_CHAT_SUBSCRIPTION_NAME." + "project_id in GOOGLE_CHAT_PROJECT_ID does not match the project embedded in GOOGLE_CHAT_SUBSCRIPTION_NAME." ) return project_id or subscription_project, subscription if http_events_url: @@ -542,9 +452,7 @@ class GoogleChatAdapter(BasePlatformAdapter): "Set GOOGLE_CHAT_HTTP_EVENTS_URL for HTTP callback mode." ) - # ------------------------------------------------------------------ - # Loop bridge helpers (thread -> asyncio loop) - # ------------------------------------------------------------------ + # -- loop bridge (Pub/Sub thread -> asyncio loop) ------------------------ @staticmethod def _log_background_failure(future: Any) -> None: try: @@ -558,43 +466,31 @@ class GoogleChatAdapter(BasePlatformAdapter): def _submit_on_loop(self, coro: Any) -> None: """Schedule a coroutine on the adapter loop from a Pub/Sub callback thread.""" - loop = self._loop - if not self._loop_accepts_callbacks(loop): + if not self._loop_accepts_callbacks(loop := self._loop): # Shutdown race: safe to drop, Pub/Sub redelivers on next reconnect. logger.warning("[GoogleChat] Loop not accepting callbacks; dropping event") return try: from agent.async_utils import safe_schedule_threadsafe future = safe_schedule_threadsafe( - coro, loop, - logger=logger, - log_message="[GoogleChat] Failed to schedule background callback", + coro, loop, logger=logger, log_message="[GoogleChat] Failed to schedule background callback", log_level=logging.WARNING, ) except RuntimeError: logger.warning("[GoogleChat] Loop closed between check and submit") return - if future is None: - return - future.add_done_callback(self._log_background_failure) + if future is not None: + future.add_done_callback(self._log_background_failure) - # ------------------------------------------------------------------ - # Bot identity resolution - # ------------------------------------------------------------------ + # -- bot identity -------------------------------------------------------- def _bot_id_cache_path(self) -> _Path: - """Cache location for the resolved bot user_id; resolved at call time so - multiplexed profiles (connect() runs in-scope) don't share one file.""" + """Resolved at call time so multiplexed profiles don't share one cache file.""" from hermes_constants import get_hermes_home as _get_hermes_home - return _get_hermes_home() / "google_chat_bot_id.json" def _load_cached_bot_id(self) -> Optional[str]: - path = self._bot_id_cache_path() - if not path.exists(): - return None try: - data = json.loads(path.read_text(encoding="utf-8")) - return data.get("bot_user_id") or None + return json.loads(self._bot_id_cache_path().read_text(encoding="utf-8")).get("bot_user_id") or None except (OSError, json.JSONDecodeError): return None @@ -607,11 +503,8 @@ class GoogleChatAdapter(BasePlatformAdapter): logger.debug("[GoogleChat] Could not persist bot_user_id cache", exc_info=True) async def _resolve_bot_user_id(self) -> Optional[str]: - """Resolve ``users/{id}`` via members.list on the home channel, then bootstrap spaces. - - Returns None when no space is known; the self-filter then falls back to - ``sender.type == 'BOT'`` (safe but less precise). - """ + """Resolve ``users/{id}`` via members.list on the home channel, then bootstrap + spaces. None when no space is known (self-filter falls back to ``sender.type == 'BOT'``).""" candidate_spaces: List[str] = [] if self.config.home_channel and self.config.home_channel.chat_id: candidate_spaces.append(self.config.home_channel.chat_id) @@ -620,67 +513,31 @@ class GoogleChatAdapter(BasePlatformAdapter): for space in candidate_spaces: try: members = await asyncio.to_thread( - lambda s=space: self._chat_api.spaces() - .members() - .list(parent=s, pageSize=50) + lambda s=space: self._chat_api.spaces().members().list(parent=s, pageSize=50) .execute(http=self._new_authed_http()) ) except HttpError as exc: logger.debug("[GoogleChat] members.list failed on %s: %s", space, _redact_sensitive(str(exc))) continue for member in members.get("memberships", []): - if member.get("member", {}).get("type") == "BOT": - name = member.get("member", {}).get("name") - if name: - return name + if member.get("member", {}).get("type") == "BOT" and member.get("member", {}).get("name"): + return member["member"]["name"] return None - # ------------------------------------------------------------------ - # Connection lifecycle - # ------------------------------------------------------------------ - async def connect(self, *, is_reconnect: bool = False) -> bool: - """Validate config, authenticate, start Pub/Sub pull, resolve bot id.""" - # connect() is the gate: everything after sees the real google classes. - if not _load_google_modules(): - self._set_fatal_error( - code="missing_deps", - message="google-cloud-pubsub / google-api-python-client not installed", - retryable=False, - ) - return False + # -- connection lifecycle ------------------------------------------------ + def _connect_failed(self, log_fmt: str, exc: BaseException, code: str, *, retryable: bool) -> bool: + """Log a redacted connect-time failure, record it as fatal, return False.""" + msg = _redact_sensitive(str(exc)) + logger.error(log_fmt, msg) + self._set_fatal_error(code=code, message=msg, retryable=retryable) + return False - self._loop = asyncio.get_running_loop() + async def _load_user_oauth(self) -> None: + """Legacy single-user OAuth (per-user tokens load lazily on first send). + Failure is NON-fatal: only attachments degrade to a text notice.""" try: - project_id, subscription_path = self._validate_config() - credentials = self._load_sa_credentials() - except (ValueError, FileNotFoundError) as exc: - msg = _redact_sensitive(str(exc)) - logger.error("[GoogleChat] Config validation failed: %s", msg) - self._set_fatal_error(code="config_invalid", message=msg, retryable=False) - return False - - self._project_id = project_id - self._subscription_path = subscription_path - self._credentials = credentials - - try: - self._chat_api = await asyncio.to_thread( - lambda: build_service("chat", "v1", credentials=credentials, cache_discovery=False) - ) - except Exception as exc: - msg = _redact_sensitive(str(exc)) - logger.error("[GoogleChat] Failed to build Chat API client: %s", msg) - self._set_fatal_error(code="chat_api_init", message=msg, retryable=False) - return False - - # Legacy single-user OAuth (per-user tokens load lazily on first send). - # Failure is NON-fatal: only attachments degrade to a text notice. - try: - from .oauth import ( - load_user_credentials as _load_user_creds, - build_user_chat_service as _build_user_chat, - list_authorized_emails as _list_emails, - ) + from .oauth import load_user_credentials as _load_user_creds, build_user_chat_service as _build_user_chat + from .oauth import list_authorized_emails as _list_emails user_creds = await asyncio.to_thread(_load_user_creds) if user_creds is not None: self._user_credentials = user_creds @@ -691,49 +548,60 @@ class GoogleChatAdapter(BasePlatformAdapter): logger.info("[GoogleChat] %d per-user OAuth tokens on disk: %s", len(authorized), ", ".join(authorized)) elif user_creds is None: logger.info( - "[GoogleChat] No user OAuth tokens at setup — file " - "attachments will degrade to text-only fallback. " - "Each user runs /setup-files once in their own DM " - "to enable native attachments." + "[GoogleChat] No user OAuth tokens at setup — file attachments will degrade to text-only fallback. " + "Each user runs /setup-files once in their own DM to enable native attachments." ) except Exception as exc: logger.warning( "[GoogleChat] User OAuth load failed (attachments will degrade to text-only fallback): %s", _redact_sensitive(str(exc)), ) - self._user_credentials = None - self._user_chat_api = None + self._user_credentials = self._user_chat_api = None + async def _check_subscription(self, subscription_path: str, credentials: Any) -> bool: + """Create the subscriber and verify the subscription exists / SA has access.""" + fatals = { + gax_exceptions.NotFound: ("subscription_not_found", "Pub/Sub subscription not found at configured path"), + gax_exceptions.PermissionDenied: ( + "subscription_permission", "Service Account lacks roles/pubsub.subscriber on the subscription"), + } + self._subscriber = pubsub_v1.SubscriberClient(credentials=credentials) + try: + await asyncio.to_thread(lambda: self._subscriber.get_subscription(request={"subscription": subscription_path})) + except (gax_exceptions.NotFound, gax_exceptions.PermissionDenied) as exc: + code, message = fatals[type(exc)] + self._set_fatal_error(code=code, message=message, retryable=False) + return False + except Exception as exc: + return self._connect_failed("[GoogleChat] subscription.get failed: %s", exc, "subscription_check", retryable=True) + return True + + async def connect(self, *, is_reconnect: bool = False) -> bool: + """Validate config, authenticate, start Pub/Sub pull, resolve bot id.""" + # connect() is the gate: everything after sees the real google classes. + if not _load_google_modules(): + self._set_fatal_error(code="missing_deps", retryable=False, + message="google-cloud-pubsub / google-api-python-client not installed") + return False + self._loop = asyncio.get_running_loop() + try: + project_id, subscription_path = self._validate_config() + credentials = self._load_sa_credentials() + except (ValueError, FileNotFoundError) as exc: + return self._connect_failed("[GoogleChat] Config validation failed: %s", exc, "config_invalid", retryable=False) + self._project_id, self._subscription_path, self._credentials = project_id, subscription_path, credentials + try: + self._chat_api = await asyncio.to_thread( + lambda: build_service("chat", "v1", credentials=credentials, cache_discovery=False)) + except Exception as exc: + return self._connect_failed("[GoogleChat] Failed to build Chat API client: %s", exc, "chat_api_init", retryable=False) + await self._load_user_oauth() try: await asyncio.to_thread(self._thread_count_store.load) except Exception: logger.warning("[GoogleChat] thread-count store load failed (treating all threads as fresh)", exc_info=True) - - if subscription_path is not None: - # Sanity check: subscription exists / SA has access. - subscription_fatals = { - gax_exceptions.NotFound: dict( - code="subscription_not_found", message="Pub/Sub subscription not found at configured path" - ), - gax_exceptions.PermissionDenied: dict( - code="subscription_permission", - message="Service Account lacks roles/pubsub.subscriber on the subscription", - ), - } - self._subscriber = pubsub_v1.SubscriberClient(credentials=credentials) - try: - await asyncio.to_thread( - lambda: self._subscriber.get_subscription(request={"subscription": subscription_path}) - ) - except (gax_exceptions.NotFound, gax_exceptions.PermissionDenied) as exc: - self._set_fatal_error(**subscription_fatals[type(exc)], retryable=False) - return False - except Exception as exc: - msg = _redact_sensitive(str(exc)) - logger.error("[GoogleChat] subscription.get failed: %s", msg) - self._set_fatal_error(code="subscription_check", message=msg, retryable=True) - return False - + if subscription_path is not None and not await self._check_subscription(subscription_path, credentials): + return False # Resolve bot user_id (eager): cache first, then members.list. self._bot_user_id = self._load_cached_bot_id() if not self._bot_user_id: @@ -742,24 +610,14 @@ class GoogleChatAdapter(BasePlatformAdapter): self._save_cached_bot_id(self._bot_user_id) else: logger.info("[GoogleChat] bot_user_id not yet resolved; will resolve on first addedToSpace or member lookup") - - if subscription_path is not None: - self._supervisor_task = asyncio.create_task(self._run_supervisor()) - inbound = "pubsub" - else: - self._supervisor_task = None - inbound = "http" - + self._supervisor_task = asyncio.create_task(self._run_supervisor()) if subscription_path is not None else None self._mark_connected() logger.info( "[GoogleChat] Connected; project=%s, inbound=%s, subscription=%s, " "bot_user_id=%s, flow_control(msgs=%s, bytes=%s)", - project_id or "", - inbound, - "" if subscription_path else "", - self._bot_user_id or "", - self._max_messages, - self._max_bytes, + project_id or "", "pubsub" if subscription_path is not None else "http", + "" if subscription_path else "", self._bot_user_id or "", + self._max_messages, self._max_bytes, ) # Plugin-registered native handlers (ctx.register_platform_handler). self._wire_plugin_handlers(None) @@ -772,37 +630,31 @@ class GoogleChatAdapter(BasePlatformAdapter): self._supervisor_task.cancel() with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError): await asyncio.wait_for(self._supervisor_task, timeout=5.0) - if self._streaming_pull_future is not None: + if (future := self._streaming_pull_future) is not None: with contextlib.suppress(Exception): - self._streaming_pull_future.cancel() - await asyncio.to_thread(self._streaming_pull_future.result, 10.0) + future.cancel() + await asyncio.to_thread(future.result, 10.0) self._streaming_pull_future = None - if self._subscriber is not None: + if (subscriber := self._subscriber) is not None: with contextlib.suppress(Exception): - await asyncio.to_thread(self._subscriber.close) + await asyncio.to_thread(subscriber.close) self._subscriber = None self._mark_disconnected() logger.info("[GoogleChat] Disconnected") - # ------------------------------------------------------------------ - # Pub/Sub supervisor (reconnect loop) - # ------------------------------------------------------------------ async def _run_supervisor(self) -> None: """Run streaming_pull with exponential backoff + full jitter; fatal after N attempts. - - ``subscribe()`` returns a Future that resolves when the stream dies; we - await ``future.result()`` in a worker thread and react to exceptions. - """ + ``subscribe()`` returns a Future that resolves when the stream dies.""" + pubsub_fatals = { + gax_exceptions.Unauthenticated: ("pubsub_auth", "Pub/Sub authentication failed (SA key invalid/revoked)"), + gax_exceptions.PermissionDenied: ("pubsub_permission", "SA lacks pubsub.subscriber on the subscription"), + } attempt = 0 while not self._shutting_down: flow = pubsub_v1.types.FlowControl(max_messages=self._max_messages, max_bytes=self._max_bytes) try: - future = self._subscriber.subscribe( - self._subscription_path, - callback=self._on_pubsub_message, - flow_control=flow, - ) - self._streaming_pull_future = future + self._streaming_pull_future = future = self._subscriber.subscribe( + self._subscription_path, callback=self._on_pubsub_message, flow_control=flow) if attempt > 0: logger.info("[GoogleChat] Pub/Sub stream reconnected after %d attempts", attempt) attempt = 0 @@ -812,26 +664,17 @@ class GoogleChatAdapter(BasePlatformAdapter): return except asyncio.CancelledError: return - except gax_exceptions.Unauthenticated: - self._set_fatal_error( - code="pubsub_auth", message="Pub/Sub authentication failed (SA key invalid/revoked)", retryable=False - ) - return - except gax_exceptions.PermissionDenied: - self._set_fatal_error( - code="pubsub_permission", message="SA lacks pubsub.subscriber on the subscription", retryable=False - ) + except (gax_exceptions.Unauthenticated, gax_exceptions.PermissionDenied) as exc: + code, message = pubsub_fatals[type(exc)] + self._set_fatal_error(code=code, message=message, retryable=False) return except Exception as exc: attempt += 1 - msg = _redact_sensitive(str(exc)) - logger.warning( - "[GoogleChat] Pub/Sub stream died (attempt %d/%d): %s", attempt, self._MAX_RECONNECT_ATTEMPTS, msg - ) + logger.warning("[GoogleChat] Pub/Sub stream died (attempt %d/%d): %s", attempt, + self._MAX_RECONNECT_ATTEMPTS, _redact_sensitive(str(exc))) if attempt >= self._MAX_RECONNECT_ATTEMPTS: self._set_fatal_error( - code="pubsub_reconnect_exhausted", - message=f"Pub/Sub reconnect failed {attempt} times; giving up", + code="pubsub_reconnect_exhausted", message=f"Pub/Sub reconnect failed {attempt} times; giving up", retryable=False, ) return @@ -841,91 +684,58 @@ class GoogleChatAdapter(BasePlatformAdapter): except asyncio.CancelledError: return - # ------------------------------------------------------------------ - # Inbound event handling (Pub/Sub callback runs in a thread) - # ------------------------------------------------------------------ + # -- inbound (Pub/Sub callback runs in a thread) ------------------------- @staticmethod - def _extract_message_payload( - envelope: Dict[str, Any], ce_type: str = "" - ) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], str]]: - """Detect the envelope format and return ``(message, space, format_name)``. - - Returns None for unrecognized envelopes or non-MESSAGE events. Formats: - 1. Workspace Add-ons: ``{"chat": {"messagePayload": {"message", "space"}}}`` - 2. Native Chat API Pub/Sub: ``{"type": "MESSAGE", "message", "space"}`` - 3. Relay / flat: ``{"event_type", "sender_email", "text", "space_name", - "thread_name", "message_name"}`` — a Chat-API-shaped ``message`` is - synthesized so downstream code needs no branching. - """ - # Format 1: membership/card payloads are handled by _on_pubsub_message first. - chat_block = envelope.get("chat") or {} - msg_payload_wrapper = chat_block.get("messagePayload") if chat_block else None + def _extract_message_payload(envelope: Dict[str, Any], + ce_type: str = "") -> Optional[Tuple[Dict[str, Any], Dict[str, Any], str]]: + """Return ``(message, space, format_name)`` or None for unknown / non-MESSAGE + envelopes. Formats: Workspace Add-ons ``{"chat": {"messagePayload": ...}}``; native + ``{"type": "MESSAGE", "message", "space"}``; relay/flat ``{"event_type", "sender_email", + "text", ...}`` (a Chat-shaped ``message`` is synthesized).""" + msg_payload_wrapper = (envelope.get("chat") or {}).get("messagePayload") if msg_payload_wrapper: msg = msg_payload_wrapper.get("message") or {} - space = msg_payload_wrapper.get("space") or msg.get("space") or {} - return msg, space, "workspace_addons" - - # Format 2. + return msg, msg_payload_wrapper.get("space") or msg.get("space") or {}, "workspace_addons" if isinstance(envelope.get("message"), dict): if envelope.get("type", "") != "MESSAGE": return None msg = envelope["message"] - space = envelope.get("space") or msg.get("space") or {} - return msg, space, "native_chat_api" - - # Format 3. + return msg, envelope.get("space") or msg.get("space") or {}, "native_chat_api" if "event_type" in envelope or "sender_email" in envelope: if envelope.get("event_type", "MESSAGE") != "MESSAGE": return None sender_email = (envelope.get("sender_email") or "").strip() - sender_display = envelope.get("sender_display_name") or sender_email or "Unknown" - # No Chat resource name for relay events: synthesize a stable surrogate - # from the email so dedup keys / session IDs are deterministic. - sender_name_surrogate = "users/relay-" + (sender_email or "unknown").replace("@", "_at_").replace(".", "_") text = envelope.get("text", "") or "" # Honor the relay's ``sender_type`` so the BOT self-filter fires for - # forwarded bot replies (relays MUST forward sender.type); default to - # HUMAN for backward compatibility when absent. - sender_type = str(envelope.get("sender_type") or "HUMAN").strip().upper() or "HUMAN" - if sender_type not in {"HUMAN", "BOT"}: - sender_type = "HUMAN" + # forwarded bot replies; default HUMAN for backward compatibility. + sender_type = str(envelope.get("sender_type") or "HUMAN").strip().upper() msg: Dict[str, Any] = { "name": envelope.get("message_name", "") or "", "sender": { - "name": sender_name_surrogate, - "email": sender_email, - "displayName": sender_display, - "type": sender_type, + # No Chat resource name for relay events: a stable surrogate keeps dedup/session ids deterministic. + "name": "users/relay-" + (sender_email or "unknown").replace("@", "_at_").replace(".", "_"), + "email": sender_email, "displayName": envelope.get("sender_display_name") or sender_email or "Unknown", + "type": sender_type if sender_type in {"HUMAN", "BOT"} else "HUMAN", }, - "text": text, - "argumentText": text, - } - thread_name = envelope.get("thread_name") or "" - if thread_name: - msg["thread"] = {"name": thread_name} - space = { - "name": envelope.get("space_name", "") or "", - "spaceType": envelope.get("space_type", "SPACE"), + "text": text, "argumentText": text, } + if envelope.get("thread_name"): + msg["thread"] = {"name": envelope["thread_name"]} + space = {"name": envelope.get("space_name", "") or "", "spaceType": envelope.get("space_type", "SPACE")} return msg, space, "relay_flat" - return None - def _prepare_inbound( - self, envelope: Dict[str, Any], ce_type: Optional[str] = None - ) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]: - """Extract + self-filter + dedup an inbound envelope. - - Returns ``(msg_with_space, enriched_envelope)`` ready for - ``_dispatch_message``, or None when the event must be dropped. Debug - logs only on the Pub/Sub path (``ce_type`` given). - """ + def _prepare_inbound(self, envelope: Dict[str, Any], + ce_type: Optional[str] = None) -> Optional[Tuple[Dict[str, Any], Dict[str, Any]]]: + """Extract + self-filter + dedup an inbound envelope. Returns ``(msg_with_space, + enriched_envelope)`` for ``_dispatch_message``, or None when the event must be + dropped. Debug logs only on the Pub/Sub path (``ce_type`` given).""" extracted = self._extract_message_payload(envelope, ce_type or "") if extracted is None: if ce_type is not None: logger.debug( - "[GoogleChat] Envelope did not match a known message format; " - "ce-type=%s, keys=%s", ce_type, list(envelope.keys()) + "[GoogleChat] Envelope did not match a known message format; ce-type=%s, keys=%s", + ce_type, list(envelope.keys()), ) return None msg, space, _fmt = extracted @@ -939,22 +749,16 @@ class GoogleChatAdapter(BasePlatformAdapter): logger.debug("[GoogleChat] Dedup drop for %s", msg_name) return None # Give both dicts a top-level "space" so the dispatch side has one shape. - msg_with_space = dict(msg) - if "space" not in msg_with_space and space: - msg_with_space["space"] = space - enriched_env = dict(envelope) - if "space" not in enriched_env and space: - enriched_env["space"] = space + msg_with_space, enriched_env = dict(msg), dict(envelope) + for d in (msg_with_space, enriched_env): + if "space" not in d and space: + d["space"] = space return msg_with_space, enriched_env def _on_pubsub_message(self, message: Any) -> None: """Pub/Sub callback — parse envelope and dispatch to the asyncio loop. - Runs in a SubscriberClient worker thread: never block, never raise (that - triggers nack + infinite redelivery). Event type comes from the ``ce-type`` - attribute; the body's ``chat`` object carries ``messagePayload`` or - ``membershipPayload`` accordingly. - """ + triggers nack + infinite redelivery). Event type comes from ``ce-type``.""" if self._shutting_down: message.nack() return @@ -964,47 +768,35 @@ class GoogleChatAdapter(BasePlatformAdapter): logger.exception("[GoogleChat] Could not parse Pub/Sub envelope") message.ack() return - - attrs = dict(getattr(message, "attributes", {}) or {}) - ce_type = attrs.get("ce-type") or "" + ce_type = dict(getattr(message, "attributes", {}) or {}).get("ce-type") or "" logger.debug("[GoogleChat] Envelope keys=%s, ce-type=%s", list(envelope.keys()), ce_type) if self._debug_raw: - # Contains message text + sender email: redact and gate at DEBUG so - # default log configs never surface it. + # Contains message text + sender email: redact and gate at DEBUG. try: from agent.redact import redact_sensitive_text - dump = redact_sensitive_text(json.dumps(envelope)) except Exception: dump = "" logger.debug("[GoogleChat] RAW envelope (redacted): %s", dump[:2000]) - try: - chat_block = envelope.get("chat") or {} if "membership" in ce_type or "MEMBERSHIP" in ce_type: - mpl = chat_block.get("membershipPayload") or {} + mpl = (envelope.get("chat") or {}).get("membershipPayload") or {} space = mpl.get("space") or {} - membership = mpl.get("membership") or {} if "created" in ce_type: # ADDED_TO_SPACE for this bot — resolve self user_id. - member = membership.get("member") or {} - if member.get("type") == "BOT" and not self._bot_user_id: - name = member.get("name") - if name: - self._bot_user_id = name - self._save_cached_bot_id(name) + member = (mpl.get("membership") or {}).get("member") or {} + if member.get("type") == "BOT" and not self._bot_user_id and member.get("name"): + self._bot_user_id = member["name"] + self._save_cached_bot_id(member["name"]) logger.info("[GoogleChat] ADDED_TO_SPACE %s", space.get("name", "?")) else: logger.info("[GoogleChat] REMOVED_FROM_SPACE %s", space.get("name", "?")) - message.ack() - return - if "widget" in ce_type or "card" in ce_type.lower(): + elif "widget" in ce_type or "card" in ce_type.lower(): logger.info("[GoogleChat] Card/widget event ack'd (v2 feature, deferred)") - message.ack() - return - prepared = self._prepare_inbound(envelope, ce_type) - if prepared is not None: - self._submit_on_loop(self._dispatch_message(*prepared)) + else: + prepared = self._prepare_inbound(envelope, ce_type) + if prepared is not None: + self._submit_on_loop(self._dispatch_message(*prepared)) message.ack() except Exception: logger.exception("[GoogleChat] Error in _on_pubsub_message") @@ -1020,9 +812,7 @@ class GoogleChatAdapter(BasePlatformAdapter): def verify_http_event_request(self, auth_header: str) -> Tuple[bool, str]: if not self._http_events_audience or not self._http_events_service_account_email: return False, "google_chat_http_events_not_configured" - if not auth_header.startswith("Bearer "): - return False, "missing_google_bearer" - token = auth_header[7:].strip() + token = auth_header[7:].strip() if auth_header.startswith("Bearer ") else "" if not token: return False, "missing_google_bearer" try: @@ -1031,42 +821,32 @@ class GoogleChatAdapter(BasePlatformAdapter): logger.warning("[GoogleChat] HTTP event bearer verification failed: %s", _redact_sensitive(str(exc))) return False, "invalid_google_bearer" expected = {item.strip().lower() for item in self._http_events_service_account_email.split(",") if item.strip()} - claim_email = str(claims.get("email") or "").strip().lower() - if not claim_email or claim_email not in expected: + if str(claims.get("email") or "").strip().lower() not in expected: return False, "unexpected_google_bearer_identity" return True, "" async def _dispatch_message(self, msg: Dict[str, Any], envelope: Dict[str, Any]) -> None: """Translate a Chat message to a MessageEvent and hand off. - - ``/setup-files`` is intercepted BEFORE the agent sees it (bot-local OAuth flow). - """ + ``/setup-files`` is intercepted BEFORE the agent sees it (bot-local OAuth flow).""" try: event = await self._build_message_event(msg, envelope) if event is None: return text = (event.text or "").strip() - if text.startswith("/setup-files") and event.source is not None: - # The sender email (user_id_alt) is the per-user OAuth token key. - sender_email = event.source.user_id_alt if event.source and event.source.user_id_alt else None - handled = await self._handle_setup_files_command( - chat_id=event.source.chat_id, - thread_id=event.source.thread_id, - raw_text=text, - sender_email=sender_email, - ) - if handled: - return + # The sender email (user_id_alt) is the per-user OAuth token key. + if text.startswith("/setup-files") and event.source is not None and await self._handle_setup_files_command( + chat_id=event.source.chat_id, thread_id=event.source.thread_id, raw_text=text, + sender_email=event.source.user_id_alt or None, + ): + return await self.handle_message(event) except Exception: logger.exception("[GoogleChat] _dispatch_message failed") - async def _handle_setup_files_command( - self, chat_id: str, thread_id: Optional[str], raw_text: str, sender_email: Optional[str] = None - ) -> bool: + async def _handle_setup_files_command(self, chat_id: str, thread_id: Optional[str], raw_text: str, + sender_email: Optional[str] = None) -> bool: """In-chat OAuth setup flow; see ``setup_files.handle_setup_files_command``.""" from .setup_files import handle_setup_files_command - return await handle_setup_files_command(self, chat_id, thread_id, raw_text, sender_email) async def _build_message_event(self, msg: Dict[str, Any], envelope: Dict[str, Any]) -> Optional[MessageEvent]: @@ -1074,25 +854,19 @@ class GoogleChatAdapter(BasePlatformAdapter): space = envelope.get("space") or msg.get("space") or {} space_name = space.get("name") or "" # "spaces/XXX" space_type = (space.get("type") or space.get("spaceType") or "").upper() - thread = msg.get("thread") or {} - thread_name = thread.get("name") or None + thread_name = (msg.get("thread") or {}).get("name") or None sender = msg.get("sender") or {} sender_name = sender.get("name") or "" - sender_display = sender.get("displayName") or sender.get("email") or sender_name sender_email = sender.get("email") or "" - # Cache the asker's email per space so _send_file picks the right per-user # OAuth token (lower-cased to match the sanitized token-file lookup). if sender_email and space_name: self._last_sender_by_chat[space_name] = sender_email.strip().lower() - chat_type = "dm" if space_type in {"DIRECT_MESSAGE", "DM"} else "group" text = (msg.get("argumentText") or msg.get("text") or "").strip() - # Slash command: emit MessageType.COMMAND with normalized text. slash = msg.get("slashCommand") or {} - is_slash = bool(slash) - if is_slash: + if slash: command_id = str(slash.get("commandId") or "") if command_id and not text.startswith("/"): text = f"/cmd_{command_id} {text}".strip() @@ -1113,19 +887,14 @@ class GoogleChatAdapter(BasePlatformAdapter): # Prefer the first-seen type for MessageType if no text present. if message_type == MessageType.TEXT and not text: message_type = _mime_for_message_type(mime or "") - if is_slash: + if slash: message_type = MessageType.COMMAND # PRE-increment count (persisted) drives the main-flow-vs-side-thread heuristic. - prev_thread_count = 0 - if thread_name and space_name: - prev_thread_count = self._thread_count_store.incr(space_name, thread_name) - - # DMs: prev_count == 0 → Chat auto-created this thread for a top-level - # message; share one DM session and reply top-level (passing thread.name - # would render the pair as an expandable thread). prev_count >= 1 → user - # engaged an existing thread; isolate the session and reply in-thread. - # Groups: threads are real containers; always isolate AND reply in-thread. + prev_thread_count = self._thread_count_store.incr(space_name, thread_name) if thread_name and space_name else 0 + # DMs: prev_count == 0 → Chat auto-created this thread for a top-level message: share one + # DM session, reply top-level (thread.name would render an expandable thread); >= 1 → user + # engaged an existing thread: isolate + reply in-thread. Groups: always isolate + in-thread. if chat_type == "dm": is_side_thread = prev_thread_count > 0 session_thread_id = thread_name if is_side_thread else None @@ -1138,46 +907,29 @@ class GoogleChatAdapter(BasePlatformAdapter): session_thread_id = thread_name if thread_name and space_name: self._last_inbound_thread[space_name] = thread_name - source = self.build_source( - chat_id=space_name, - chat_name=space.get("displayName") or space.get("name") or "", - chat_type=chat_type, - # Email is the canonical id (allowlists are configured with emails); - # the ``users/{id}`` resource name moves to user_id_alt. Falls back to - # the resource name when the sender has no email. - user_id=(sender_email or sender_name), - user_name=sender_display, - thread_id=session_thread_id, - user_id_alt=(sender_name or None), + chat_id=space_name, chat_name=space.get("displayName") or space.get("name") or "", chat_type=chat_type, + # Email is the canonical id (allowlists use emails); the ``users/{id}`` + # resource name moves to user_id_alt. + user_id=(sender_email or sender_name), user_name=sender.get("displayName") or sender_email or sender_name, + thread_id=session_thread_id, user_id_alt=(sender_name or None), ) return MessageEvent( - text=text, - message_type=message_type, - source=source, - raw_message=msg, - message_id=msg.get("name") or None, - media_urls=media_urls, - media_types=media_types, + text=text, message_type=message_type, source=source, raw_message=msg, message_id=msg.get("name") or None, + media_urls=media_urls, media_types=media_types, ) async def _download_attachment(self, attachment: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]: - """Download an inbound attachment to the local cache; return (path, mime). - - Bot SA path is ``media.download`` via ``attachmentDataRef.resourceName``. - Drive-picker shares without a resourceName need user OAuth + Drive scope - (skipped). ``downloadUri`` is a last resort — it is meant for user OAuth - tokens and usually 401s for SA tokens, kept for forward-compat. - """ + """Download an inbound attachment to the local cache; return (path, mime). Bot SA + path is ``media.download`` via ``attachmentDataRef.resourceName``; Drive-picker shares + without one need user OAuth (skipped); ``downloadUri`` is a last resort (usually 401s).""" mime = attachment.get("contentType") or "" - source = attachment.get("source") or "" name = attachment.get("name") or "" resource_name = (attachment.get("attachmentDataRef") or {}).get("resourceName") or "" download_uri = attachment.get("downloadUri") or "" - # Chat tags BOTH drag-and-drop uploads AND Drive-picker shares as DRIVE_FILE; # only the former carry a resourceName the bot path can use. - if source == "DRIVE_FILE" and not resource_name: + if (attachment.get("source") or "") == "DRIVE_FILE" and not resource_name: logger.info("[GoogleChat] Skipping Drive-picker attachment (no resourceName, would need user-OAuth Drive scope)") return None, mime @@ -1189,18 +941,14 @@ class GoogleChatAdapter(BasePlatformAdapter): import io buf = io.BytesIO() - downloader = MediaIoBaseDownload(buf, req) - done = False + downloader, done = MediaIoBaseDownload(buf, req), False while not done: _status, done = downloader.next_chunk() return buf.getvalue() - try: data = await asyncio.to_thread(_fetch_media) except HttpError as exc: logger.warning("[GoogleChat] media.download_media failed: %s", _redact_sensitive(str(exc))) - data = None - if data is None and download_uri: if not _is_google_owned_host(download_uri): logger.warning("[GoogleChat] Rejecting attachment fetch: non-Google host") @@ -1209,55 +957,37 @@ class GoogleChatAdapter(BasePlatformAdapter): def _fetch_uri() -> bytes: import google.auth.transport.requests as gar - authed_session = gar.AuthorizedSession(self._credentials) - resp = authed_session.get(download_uri, timeout=30) + resp = gar.AuthorizedSession(self._credentials).get(download_uri, timeout=30) resp.raise_for_status() return resp.content - try: data = await asyncio.to_thread(_fetch_uri) except Exception as exc: logger.warning( - "[GoogleChat] downloadUri fetch failed (SA tokens often " - "lack access here; this is expected for user-uploaded " - "content): %s", - _redact_sensitive(str(exc)), + "[GoogleChat] downloadUri fetch failed (SA tokens often lack access here; this is expected for " + "user-uploaded content): %s", _redact_sensitive(str(exc)), ) return None, mime - if data is None: return None, mime - # cache_* helpers take ``ext`` for media and a positional filename for docs. filename = name.split("/")[-1] if name else "attachment" ext = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else "" - if mime.startswith("image/"): - local = cache_image_from_bytes(data, ext=ext or ".jpg") - elif mime.startswith("audio/"): - local = cache_audio_from_bytes(data, ext=ext or ".ogg") - elif mime.startswith("video/"): - local = cache_video_from_bytes(data, ext=ext or ".mp4") - else: - local = cache_document_from_bytes(data, filename) - return local, mime + for prefix, cache_fn, default_ext in _MEDIA_CACHERS: + if mime.startswith(prefix): + return cache_fn(data, ext=ext or default_ext), mime + return cache_document_from_bytes(data, filename), mime - # ------------------------------------------------------------------ - # Outbound send paths - # ------------------------------------------------------------------ + # -- outbound ------------------------------------------------------------ def _note_rate_limit(self, chat_id: str) -> int: self._rate_limit_hits[chat_id] = self._rate_limit_hits.get(chat_id, 0) + 1 return self._rate_limit_hits[chat_id] - async def send( - self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None - ) -> SendResult: - """Send a text message; ``metadata`` may carry ``thread_id``. - - A tracked typing card is transformed in-place via ``messages.patch`` (delete - would leave a "Message deleted" tombstone). The base class's ``_keep_typing`` - is paused for this chat meanwhile. Over-long content: the first chunk patches - the typing card, subsequent chunks are new messages. - """ + async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None) -> SendResult: + """Send a text message; ``metadata`` may carry ``thread_id``. A tracked typing card is + patched in place (delete would leave a "Message deleted" tombstone) with the first + chunk, further chunks are new messages; ``_keep_typing`` is paused meanwhile.""" thread_id = self._resolve_thread_id(reply_to, metadata, chat_id=chat_id) self.pause_typing_for_chat(chat_id) try: @@ -1265,31 +995,25 @@ class GoogleChatAdapter(BasePlatformAdapter): chunks = self._chunk_text(self.format_message(content)) if not chunks: return SendResult(success=False, error="empty message") - last_result: Optional[SendResult] = None typing_msg_name = self._typing_messages.pop(chat_id, None) if typing_msg_name == _TYPING_CONSUMED_SENTINEL: typing_msg_name = None patched_typing = False - for idx, chunk in enumerate(chunks): - body: Dict[str, Any] = {"text": chunk} # Only set thread on the create path; patch inherits. - if thread_id and (idx > 0 or not typing_msg_name): - body["thread"] = {"name": thread_id} + body = _thread_body(chunk, thread_id if (idx > 0 or not typing_msg_name) else None) try: if idx == 0 and typing_msg_name: - result = await self._patch_message(typing_msg_name, body) + last_result = await self._patch_message(typing_msg_name, body) patched_typing = True else: - result = await self._create_message(chat_id, body) - last_result = result + last_result = await self._create_message(chat_id, body) except HttpError as exc: status = _http_status(exc) if status == 403: self._set_fatal_error( - code="chat_forbidden", - message="Bot lacks access (removed from space or perms revoked)", + code="chat_forbidden", message="Bot lacks access (removed from space or perms revoked)", retryable=False, ) return SendResult(success=False, error=str(exc)) @@ -1298,8 +1022,7 @@ class GoogleChatAdapter(BasePlatformAdapter): if idx == 0 and typing_msg_name: logger.info("[GoogleChat] Typing card disappeared; creating new message") typing_msg_name = None - result = await self._create_message(chat_id, body) - last_result = result + last_result = await self._create_message(chat_id, body) continue logger.info("[GoogleChat] send target 404; skipping") return SendResult(success=False, error="target not found") @@ -1307,13 +1030,11 @@ class GoogleChatAdapter(BasePlatformAdapter): hits = self._note_rate_limit(chat_id) if hits >= _RATE_LIMIT_WARN_THRESHOLD: logger.warning("[GoogleChat] Rate limit hit %d times on chat; throttling", hits) - raise raise if last_result is None: return SendResult(success=False, error="empty message") - # Sentinel keeps a trailing _keep_typing tick from posting a fresh marker - # that stop_typing would then delete and tombstone. Cleared in - # on_processing_complete. + # Sentinel keeps a trailing _keep_typing tick from posting a fresh marker that + # stop_typing would then delete and tombstone. Cleared in on_processing_complete. if patched_typing: self._typing_messages[chat_id] = _TYPING_CONSUMED_SENTINEL return last_result @@ -1331,10 +1052,7 @@ class GoogleChatAdapter(BasePlatformAdapter): return result except HttpError as exc: return SendResult( - success=False, - error=_redact_sensitive(str(exc)), - retryable=_http_status(exc) in _RETRYABLE_HTTP_STATUSES, - ) + success=False, error=_redact_sensitive(str(exc)), retryable=_http_status(exc) in _RETRYABLE_HTTP_STATUSES) except Exception as exc: logger.debug("[GoogleChat] send_card failed", exc_info=True) return SendResult(success=False, error=_redact_sensitive(str(exc)), retryable=_is_retryable_error(exc)) @@ -1346,31 +1064,17 @@ class GoogleChatAdapter(BasePlatformAdapter): if not choices: return await super().send_clarify(chat_id, question, choices, clarify_id, session_key, metadata) + def _button(text: str, choice: str) -> Dict[str, Any]: + return {"text": text, "action": "hermes_clarify", "parameters": {"clarify_id": clarify_id, "choice": choice}} buttons: List[Dict[str, Any]] = [] for choice in choices: choice_text = str(choice).strip() - if not choice_text: - continue - label = choice_text if len(choice_text) <= 80 else choice_text[:77] + "..." - buttons.append({ - "text": label, - "action": "hermes_clarify", - "parameters": {"clarify_id": clarify_id, "choice": choice_text}, - }) - buttons.append({ - "text": "Other / type answer", - "action": "hermes_clarify", - "parameters": {"clarify_id": clarify_id, "choice": "__other__"}, - }) + if choice_text: + buttons.append(_button(choice_text if len(choice_text) <= 80 else choice_text[:77] + "...", choice_text)) + buttons.append(_button("Other / type answer", "__other__")) card = card_spec_to_cards_v2({ - "card_id": f"clarify-{clarify_id}", - "header": {"title": "Question"}, - "sections": [{ - "widgets": [ - {"type": "text", "text": f"❓ {question}"}, - {"type": "buttons", "buttons": buttons}, - ] - }], + "card_id": f"clarify-{clarify_id}", "header": {"title": "Question"}, + "sections": [{"widgets": [{"type": "text", "text": f"❓ {question}"}, {"type": "buttons", "buttons": buttons}]}], }) result = await self.send_card(chat_id, card, metadata=metadata) if result.success: @@ -1379,13 +1083,9 @@ class GoogleChatAdapter(BasePlatformAdapter): return await super().send_clarify(chat_id, question, choices, clarify_id, session_key, metadata) async def edit_message(self, chat_id: str, message_id: str, content: str, *, finalize: bool = False) -> SendResult: - """Edit a sent message via ``messages.patch``. - - Required by the tool-progress + token-streaming pipeline (``GatewayStreamConsumer`` - gates on this override). ``finalize`` is unused: Chat's patch has no - streaming lifecycle. 404/403 are reported as non-success so the gateway - falls back to ``send()``. - """ + """Edit via ``messages.patch`` (required by the tool-progress / token-streaming + pipeline). ``finalize`` is unused: Chat's patch has no streaming lifecycle. + 404/403 are non-success so the gateway falls back to ``send()``.""" if not message_id: return SendResult(success=False, error="missing message_id") if len(content) > _MAX_TEXT_LENGTH: @@ -1401,49 +1101,33 @@ class GoogleChatAdapter(BasePlatformAdapter): return SendResult(success=False, error=str(exc)) async def delete_message(self, chat_id: str, message_id: str) -> bool: - """Delete a message. Prefer ``edit_message`` internally — deletion leaves a - "Message deleted by its author" tombstone; provided for stream-consumer - fallback paths that genuinely need removal.""" + """Delete a message. Prefer ``edit_message`` — deletion leaves a "Message + deleted by its author" tombstone; kept for stream-consumer fallback paths.""" if not message_id: return False - def _do_delete() -> None: - self._chat_api.spaces().messages().delete(name=message_id).execute(http=self._new_authed_http()) - try: - await asyncio.to_thread(_do_delete) + await asyncio.to_thread( + lambda: self._chat_api.spaces().messages().delete(name=message_id).execute(http=self._new_authed_http())) return True except HttpError as exc: - if _http_status(exc) in {403, 404}: - return False - logger.debug("[GoogleChat] delete_message failed: %s", _redact_sensitive(str(exc))) - return False + if _http_status(exc) not in {403, 404}: + logger.debug("[GoogleChat] delete_message failed: %s", _redact_sensitive(str(exc))) except Exception: logger.debug("[GoogleChat] delete_message failed", exc_info=True) - return False + return False async def _patch_message(self, message_name: str, body: Dict[str, Any]) -> SendResult: """Update a message's text (and optionally cards) in-place.""" update_mask = ",".join(k for k in ("text", "cardsV2") if k in body) or "text" - # Patch body cannot carry thread (immutable). - patch_body = {k: v for k, v in body.items() if k not in {"thread",}} - - def _do_patch() -> Dict[str, Any]: - return ( - self._chat_api.spaces() - .messages() - .patch(name=message_name, updateMask=update_mask, body=patch_body) - .execute(http=self._new_authed_http()) - ) - - resp = await asyncio.to_thread(_do_patch) + patch_body = {k: v for k, v in body.items() if k != "thread"} # thread is immutable + resp = await asyncio.to_thread( + lambda: self._chat_api.spaces().messages().patch(name=message_name, updateMask=update_mask, body=patch_body) + .execute(http=self._new_authed_http()) + ) return SendResult(success=True, message_id=resp.get("name", message_name)) def _chunk_text(self, text: str) -> List[str]: - if not text: - return [] - if len(text) <= _MAX_TEXT_LENGTH: - return [text] chunks: List[str] = [] remaining = text while remaining: @@ -1463,32 +1147,21 @@ class GoogleChatAdapter(BasePlatformAdapter): """Convert standard Markdown to Google Chat's dialect (see ``cards.format_message``).""" return _format_message(content) - def _resolve_thread_id( - self, reply_to: Optional[str], metadata: Optional[Dict[str, Any]], chat_id: Optional[str] = None - ) -> Optional[str]: - """Return the thread resource name to reply under, or None. - - Priority: ``metadata['thread_id']`` (gateway session plumbing) → - ``thread_name`` / ``thread_ts`` aliases → ``reply_to`` when it is already a - ``spaces/X/threads/Y`` name → ``_last_inbound_thread[chat_id]`` (DM replies - would otherwise land top-level, disconnected from the user's question). - Cron deliveries (``job_id`` in metadata) skip the last fallback so output - starts a fresh top-level message instead of burying itself in a stale thread. - """ + def _resolve_thread_id(self, reply_to: Optional[str], metadata: Optional[Dict[str, Any]], + chat_id: Optional[str] = None) -> Optional[str]: + """Thread to reply under, or None: ``metadata['thread_id']`` → ``thread_name`` / + ``thread_ts`` aliases → ``reply_to`` when already a ``spaces/X/threads/Y`` name → + ``_last_inbound_thread[chat_id]`` (else DM replies land top-level). Cron deliveries + (``job_id`` in metadata) skip the last fallback so output is not buried in a stale thread.""" if metadata: for key in ("thread_id", "thread_name", "thread_ts"): - value = metadata.get(key) - if value: - return str(value) + if metadata.get(key): + return str(metadata[key]) if reply_to and "/threads/" in reply_to and "/messages/" not in reply_to: return reply_to if metadata and metadata.get("job_id"): return None - if chat_id: - cached = self._last_inbound_thread.get(chat_id) - if cached: - return cached - return None + return (self._last_inbound_thread.get(chat_id) or None) if chat_id else None def _new_authed_http(self) -> Any: """Fresh AuthorizedHttp per call: httplib2 shares SSL state, so the discovery @@ -1496,22 +1169,16 @@ class GoogleChatAdapter(BasePlatformAdapter): return AuthorizedHttp(self._credentials, http=httplib2.Http(timeout=30)) async def _call_with_retry(self, sync_fn: Callable[[], Any], *, op_name: str = "chat-api-call") -> Any: - """Run ``sync_fn`` in a thread with bounded retry + jittered backoff. - - Only transient failures (see ``_is_retryable_error``) are retried; permanent - ones bubble up on the first attempt. Cancellation propagates immediately. - """ + """Run ``sync_fn`` in a thread with bounded retry + jittered backoff; only + transient failures are retried, permanent ones bubble up on the first attempt.""" delay = _RETRY_BASE_DELAY for attempt in range(1, _RETRY_MAX_ATTEMPTS + 1): try: return await asyncio.to_thread(sync_fn) - except asyncio.CancelledError: - raise except Exception as exc: if not _is_retryable_error(exc) or attempt >= _RETRY_MAX_ATTEMPTS: raise - jitter = delay * _RETRY_JITTER * random.random() - wait = min(delay + jitter, _RETRY_MAX_DELAY + _RETRY_JITTER) + wait = min(delay + delay * _RETRY_JITTER * random.random(), _RETRY_MAX_DELAY + _RETRY_JITTER) logger.warning( "[GoogleChat] %s attempt %d/%d failed (%s); retrying in %.2fs", op_name, attempt, _RETRY_MAX_ATTEMPTS, _redact_sensitive(str(exc)), wait, @@ -1520,9 +1187,8 @@ class GoogleChatAdapter(BasePlatformAdapter): delay = min(delay * 2, _RETRY_MAX_DELAY) def _track_outbound_thread(self, chat_id: str, resp: Dict[str, Any]) -> None: - """Count the outbound destination thread so a later user "Reply in thread" - on the bot's message resolves as a known side-thread (prev_count >= 1) - instead of looking fresh and being misclassified as main flow.""" + """Count the outbound destination thread so a later user "Reply in thread" on + the bot's message resolves as a known side-thread instead of main flow.""" resp_thread = (resp.get("thread") or {}).get("name") or "" if chat_id and resp_thread: try: @@ -1531,37 +1197,22 @@ class GoogleChatAdapter(BasePlatformAdapter): logger.debug("[GoogleChat] outbound thread-count incr failed", exc_info=True) async def _create_message(self, chat_id: str, body: Dict[str, Any]) -> SendResult: - """POST spaces/{space}/messages via REST, returning SendResult. - - With ``thread.name`` in the body we MUST pass - ``messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`` — the default - silently ignores thread.name and starts a new thread. FALLBACK (vs OR_FAIL) - still delivers when a stale thread no longer exists. - """ - kwargs: Dict[str, Any] = {"parent": chat_id, "body": body} - if (body.get("thread") or {}).get("name"): - kwargs["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" - - def _do_create() -> Dict[str, Any]: - return self._chat_api.spaces().messages().create(**kwargs).execute(http=self._new_authed_http()) - - resp = await self._call_with_retry(_do_create, op_name="messages.create") + """POST spaces/{space}/messages via REST (with retry), returning SendResult.""" + kwargs = _create_kwargs(chat_id, body) + resp = await self._call_with_retry( + lambda: self._chat_api.spaces().messages().create(**kwargs).execute(http=self._new_authed_http()), + op_name="messages.create", + ) self._track_outbound_thread(chat_id, resp) return SendResult(success=True, message_id=resp.get("name")) async def send_typing(self, chat_id: str, metadata: Any = None) -> None: - """Post a visible 'Hermes is thinking…' marker message (Chat has no typing API). - - ``send()`` PATCHes the marker with the real reply; ``on_processing_complete`` - reaps it otherwise. The card must be created in the user's thread because - ``messages.patch`` cannot move a message between threads. - - Cancellation safety: ``_keep_typing`` wraps this in ``wait_for(timeout=1.5)``. - A cancelled ``to_thread`` create would still land a card we never recorded, - and the next tick would create a second one. So the slot is reserved with - an in-flight Event and the create runs in a shielded background task that - records the msg_id regardless of our own cancellation. - """ + """Post a visible 'Hermes is thinking…' marker (Chat has no typing API); ``send()`` + PATCHes it with the reply, ``on_processing_complete`` reaps it otherwise. Created in + the user's thread (patch cannot move it). ``_keep_typing`` wraps this in + ``wait_for(timeout=1.5)``: a cancelled create would still land an unrecorded card and + the next tick would create a second, so the slot is reserved with an in-flight Event + and the create runs in a shielded task that records the msg_id regardless.""" # Already have a card (real msg_id, sentinel, or in-flight) — bail. if chat_id in self._typing_messages: return @@ -1570,14 +1221,9 @@ class GoogleChatAdapter(BasePlatformAdapter): with contextlib.suppress(asyncio.TimeoutError, KeyError): await asyncio.wait_for(self._typing_card_inflight[chat_id].wait(), timeout=5.0) return - thread_id = self._resolve_thread_id(reply_to=None, metadata=metadata, chat_id=chat_id) - body: Dict[str, Any] = {"text": getattr(self.config, "typing_status_text", None) or "Hermes is thinking…"} - if thread_id: - body["thread"] = {"name": thread_id} - - completed = asyncio.Event() - self._typing_card_inflight[chat_id] = completed + body = _thread_body(getattr(self.config, "typing_status_text", None) or "Hermes is thinking…", thread_id) + self._typing_card_inflight[chat_id] = completed = asyncio.Event() async def _create_and_record() -> None: try: @@ -1586,8 +1232,8 @@ class GoogleChatAdapter(BasePlatformAdapter): if chat_id not in self._typing_messages: self._typing_messages[chat_id] = result.message_id else: - # send() or another create claimed the slot first: this card - # is an orphan; on_processing_complete cleans it up. + # send() or another create claimed the slot first: orphan; + # on_processing_complete cleans it up. self._orphan_typing_messages.setdefault(chat_id, []).append(result.message_id) except Exception: logger.debug("[GoogleChat] send_typing background create failed", exc_info=True) @@ -1595,27 +1241,21 @@ class GoogleChatAdapter(BasePlatformAdapter): self._typing_card_inflight.pop(chat_id, None) completed.set() - task = asyncio.create_task(_create_and_record()) # The shielded task keeps running if our awaiter is cancelled. - try: - await asyncio.shield(task) - except asyncio.CancelledError: - raise + await asyncio.shield(asyncio.create_task(_create_and_record())) async def stop_typing(self, chat_id: str) -> None: - """NO-OP when a live card is tracked: the marker is a real message that - ``send()`` patches in place, and upstream calls ``stop_typing`` BEFORE - ``send()`` (deleting would tombstone and force a fresh message). Only the - SENTINEL is popped so the next turn starts clean; stranded cards on - error/cancel paths are reaped by ``on_processing_complete``.""" - current = self._typing_messages.get(chat_id) - if not current: - return - if current == _TYPING_CONSUMED_SENTINEL: + """NO-OP for a live card: upstream calls ``stop_typing`` BEFORE ``send()`` patches it + (deleting would tombstone). Only the SENTINEL is popped so the next turn starts clean; + stranded cards are reaped by ``on_processing_complete``.""" + if self._typing_messages.get(chat_id) == _TYPING_CONSUMED_SENTINEL: self._typing_messages.pop(chat_id, None) - return - # Real message_name — leave it for send() to patch. Deliberate no-op. - return + + async def _patch_quietly(self, message_name: str, text: str, log_msg: str, *log_args: Any) -> None: + try: + await self._patch_message(message_name, {"text": text}) + except Exception: + logger.debug(log_msg, *log_args, exc_info=True) async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None: """Reap typing card(s) after the turn: pop the SENTINEL on success; on @@ -1628,28 +1268,17 @@ class GoogleChatAdapter(BasePlatformAdapter): current = self._typing_messages.pop(chat_id, None) if current and current != _TYPING_CONSUMED_SENTINEL: label = "(interrupted)" if outcome == ProcessingOutcome.CANCELLED else "(no reply)" - try: - await self._patch_message(current, {"text": label}) - except Exception: - logger.debug("[GoogleChat] on_processing_complete patch fallback failed", exc_info=True) + await self._patch_quietly(current, label, "[GoogleChat] on_processing_complete patch fallback failed") for orphan_id in self._orphan_typing_messages.pop(chat_id, []): - try: - await self._patch_message(orphan_id, {"text": "·"}) - except Exception: - logger.debug("[GoogleChat] orphan typing-card patch failed: %s", orphan_id, exc_info=True) + await self._patch_quietly(orphan_id, "·", "[GoogleChat] orphan typing-card patch failed: %s", orphan_id) except Exception: logger.debug("[GoogleChat] cleanup in on_processing_complete failed", exc_info=True) - # ------------------------------------------------------------------ - # Attachment send paths - # ------------------------------------------------------------------ + # -- attachments --------------------------------------------------------- async def _consume_typing_card_with_text(self, chat_id: str, text: str) -> Optional[SendResult]: - """Patch the tracked typing card with ``text`` (no tombstone). - - Returns None when there is no real card (caller creates a new message) — - the SENTINEL is left in place so ``_keep_typing`` doesn't post a fresh card - during a subsequent attachment send. Raises transient HttpErrors. - """ + """Patch the tracked typing card with ``text`` (no tombstone); None when there is no + real card (caller creates a message) — the SENTINEL stays so ``_keep_typing`` doesn't + post a fresh card during a subsequent attachment send. Raises transient HttpErrors.""" current = self._typing_messages.get(chat_id) if not current or current == _TYPING_CONSUMED_SENTINEL: return None @@ -1674,10 +1303,7 @@ class GoogleChatAdapter(BasePlatformAdapter): patched = await self._consume_typing_card_with_text(chat_id, text) if patched is not None: return patched - body: Dict[str, Any] = {"text": text} - if thread_id: - body["thread"] = {"name": thread_id} - return await self._create_message(chat_id, body) + return await self._create_message(chat_id, _thread_body(text, thread_id)) except HttpError as exc: return SendResult(success=False, error=_redact_sensitive(str(exc))) @@ -1687,12 +1313,10 @@ class GoogleChatAdapter(BasePlatformAdapter): ) -> SendResult: thread_id = self._resolve_thread_id(reply_to, kwargs.get("metadata"), chat_id=chat_id) return await self._send_file( - chat_id, path, caption, mime_hint=mime_hint, thread_id=thread_id, override_filename=override_filename - ) + chat_id, path, caption, mime_hint=mime_hint, thread_id=thread_id, override_filename=override_filename) - async def send_image_file( - self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs: Any - ) -> SendResult: + async def send_image_file(self, chat_id: str, image_path: str, caption: Optional[str] = None, + reply_to: Optional[str] = None, **kwargs: Any) -> SendResult: return await self._send_file_reply(chat_id, image_path, caption, reply_to, kwargs, "image/*") async def send_document( @@ -1701,14 +1325,12 @@ class GoogleChatAdapter(BasePlatformAdapter): ) -> SendResult: return await self._send_file_reply(chat_id, file_path, caption, reply_to, kwargs, None, file_name) - async def send_voice( - self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs: Any - ) -> SendResult: + async def send_voice(self, chat_id: str, audio_path: str, caption: Optional[str] = None, + reply_to: Optional[str] = None, **kwargs: Any) -> SendResult: return await self._send_file_reply(chat_id, audio_path, caption, reply_to, kwargs, "audio/ogg") - async def send_video( - self, chat_id: str, video_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, **kwargs: Any - ) -> SendResult: + async def send_video(self, chat_id: str, video_path: str, caption: Optional[str] = None, + reply_to: Optional[str] = None, **kwargs: Any) -> SendResult: return await self._send_file_reply(chat_id, video_path, caption, reply_to, kwargs, "video/mp4") async def send_animation( @@ -1718,44 +1340,33 @@ class GoogleChatAdapter(BasePlatformAdapter): """Google Chat has no native animation type; fall back to send_image.""" return await self.send_image(chat_id, animation_url, caption=caption, reply_to=reply_to, metadata=metadata) - # ------------------------------------------------------------------ - # Native attachment delivery via user OAuth - # - # media.upload hard-rejects SA authentication, so the user grants the bot - # chat.messages.create ONCE via ``/setup-files`` and the bot uploads AS the - # user. See https://developers.google.com/chat/api/guides/auth/users and - # ``oauth.py``. - # ------------------------------------------------------------------ - _LEGACY_USER_IDENTITY = "__legacy__" + # -- native attachment delivery via user OAuth: media.upload hard-rejects SA + # auth, so the user grants chat.messages.create ONCE via ``/setup-files`` and + # the bot uploads AS the user (see ``oauth.py``). -------------------------- + @staticmethod + async def _refresh_or_none(creds: Any, email: Optional[str], log_msg: str) -> Optional[Any]: + """``oauth.refresh_or_none`` in a thread; a raise is logged at DEBUG and treated as None.""" + from .oauth import refresh_or_none as _refresh + try: + return await asyncio.to_thread(_refresh, creds, email) + except Exception: + logger.debug(log_msg, exc_info=True) + return None async def _load_per_user_chat_api(self, email: str) -> Optional[Any]: """Get (or build + cache) a user-authed Chat client for ``email``. - - Cache hit → refresh creds (eviction on refresh failure so the next request - goes back through disk / the text-notice fallback). Miss → load token from - disk, build the client, cache both. - """ - from .oauth import ( - load_user_credentials as _load, - build_user_chat_service as _build, - refresh_or_none as _refresh, - ) - + Cache hit → refresh creds (evict on failure so the next request goes back + through disk / the text-notice fallback). Miss → load token, build, cache.""" + from .oauth import load_user_credentials as _load, build_user_chat_service as _build cached_api = self._user_chat_api_by_email.get(email) cached_creds = self._user_creds_by_email.get(email) if cached_api is not None and cached_creds is not None: - try: - refreshed = await asyncio.to_thread(_refresh, cached_creds, email) - except Exception: - logger.debug("[GoogleChat] cached per-user refresh raised", exc_info=True) - refreshed = None + refreshed = await self._refresh_or_none(cached_creds, email, "[GoogleChat] cached per-user refresh raised") if refreshed is None: - self._user_chat_api_by_email.pop(email, None) - self._user_creds_by_email.pop(email, None) + self._invalidate_user_creds(email) return None self._user_creds_by_email[email] = refreshed return cached_api - try: creds = await asyncio.to_thread(_load, email) if creds is None: @@ -1764,38 +1375,26 @@ class GoogleChatAdapter(BasePlatformAdapter): except Exception: logger.debug("[GoogleChat] per-user creds load/build failed for %s", email, exc_info=True) return None - self._user_creds_by_email[email] = creds self._user_chat_api_by_email[email] = api return api async def _acquire_user_chat_api(self, sender_email: Optional[str]) -> Tuple[Optional[Any], Optional[str]]: - """Resolve the user-authed Chat client for an outbound attachment. - - Order: per-user token for ``sender_email`` → legacy single-user fallback → - ``(None, None)`` (caller posts the setup notice). The identity label - (email or ``"__legacy__"``) tells ``_invalidate_user_creds`` which slot to evict. - """ + """User-authed Chat client for an outbound attachment: per-user token for + ``sender_email`` → legacy single-user fallback → ``(None, None)`` (caller posts the + setup notice). The identity label (email / ``"__legacy__"``) selects the slot to evict.""" if sender_email: api = await self._load_per_user_chat_api(sender_email) if api is not None: return api, sender_email - if self._user_chat_api is not None: - try: - from .oauth import refresh_or_none as _refresh - refreshed = await asyncio.to_thread(_refresh, self._user_credentials, None) - except Exception: - logger.debug("[GoogleChat] legacy creds refresh raised", exc_info=True) - refreshed = None + refreshed = await self._refresh_or_none(self._user_credentials, None, "[GoogleChat] legacy creds refresh raised") if refreshed is None: logger.warning("[GoogleChat] legacy user-OAuth refresh returned None — evicting fallback creds") - self._user_credentials = None - self._user_chat_api = None + self._invalidate_user_creds(self._LEGACY_USER_IDENTITY) return None, None self._user_credentials = refreshed return self._user_chat_api, self._LEGACY_USER_IDENTITY - return None, None def _invalidate_user_creds(self, identity: Optional[str]) -> None: @@ -1803,8 +1402,7 @@ class GoogleChatAdapter(BasePlatformAdapter): if not identity: return if identity == self._LEGACY_USER_IDENTITY: - self._user_credentials = None - self._user_chat_api = None + self._user_credentials = self._user_chat_api = None return self._user_creds_by_email.pop(identity, None) self._user_chat_api_by_email.pop(identity, None) @@ -1813,108 +1411,76 @@ class GoogleChatAdapter(BasePlatformAdapter): self, chat_id: str, path: str, caption: Optional[str], mime_hint: Optional[str], thread_id: Optional[str] = None, override_filename: Optional[str] = None, ) -> SendResult: - """Native Chat attachment: user-authed ``media.upload`` then ``messages.create``. - - Uses the most recent inbound sender's OAuth token for this chat (legacy - single-user token as fallback; text notice when neither exists). Since - ``messages.patch`` cannot add an attachment, the typing card is patched - with the caption (or a single space) so it retires without a tombstone. - """ + """Native attachment: user-authed ``media.upload`` then ``messages.create`` with the + latest inbound sender's token (legacy token as fallback; text notice when neither). + ``messages.patch`` cannot add attachments, so the typing card is patched with the + caption (or a single space) to retire it without a tombstone.""" if not os.path.exists(path): return SendResult(success=False, error=f"file not found: {path}") - filename = override_filename or os.path.basename(path) or "upload.bin" mime = mime_hint or "application/octet-stream" - sender_email = self._last_sender_by_chat.get(chat_id) - chat_api, identity = await self._acquire_user_chat_api(sender_email) + chat_api, identity = await self._acquire_user_chat_api(self._last_sender_by_chat.get(chat_id)) if chat_api is None: return await self._post_attachment_fallback(chat_id, path, filename, caption, thread_id) - try: await self._consume_typing_card_with_text(chat_id, caption or " ") except Exception: logger.debug("[GoogleChat] _send_file pre-patch typing-card failed", exc_info=True) - def _upload() -> Dict[str, Any]: - media = MediaFileUpload(path, mimetype=mime, resumable=False) - return chat_api.media().upload(parent=chat_id, body={"filename": filename}, media_body=media).execute() - try: - upload_resp = await asyncio.to_thread(_upload) + upload_resp = await asyncio.to_thread(lambda: chat_api.media().upload( + parent=chat_id, body={"filename": filename}, + media_body=MediaFileUpload(path, mimetype=mime, resumable=False), + ).execute()) except HttpError as exc: status = _http_status(exc) if status in {401, 403}: logger.warning( - "[GoogleChat] media.upload auth failure for identity=%s " - "(token revoked or scope missing) — falling back to " - "text notice. Status=%s", identity, status, + "[GoogleChat] media.upload auth failure for identity=%s (token revoked or scope missing) — falling " + "back to text notice. Status=%s", identity, status, ) self._invalidate_user_creds(identity) return await self._post_attachment_fallback(chat_id, path, filename, caption, thread_id) return SendResult(success=False, error=_redact_sensitive(str(exc))) - attachment_ref = upload_resp.get("attachmentDataRef") if not attachment_ref: return SendResult(success=False, error="upload returned no attachmentDataRef") - body: Dict[str, Any] = {"attachment": [{"attachmentDataRef": attachment_ref}]} - if caption: - body["text"] = caption - if thread_id: - body["thread"] = {"name": thread_id} + body.update({k: v for k, v in (("text", caption), ("thread", {"name": thread_id} if thread_id else None)) if v}) # The attachmentDataRef is bound to the uploading principal, so this create - # also needs user auth. messageReplyOption: see _create_message. - create_kwargs: Dict[str, Any] = {"parent": chat_id, "body": body} - if thread_id: - create_kwargs["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" - - def _create_with_attachment() -> Dict[str, Any]: - return chat_api.spaces().messages().create(**create_kwargs).execute() - + # also needs user auth. + create_kwargs = _create_kwargs(chat_id, body) try: - resp = await asyncio.to_thread(_create_with_attachment) + resp = await asyncio.to_thread(lambda: chat_api.spaces().messages().create(**create_kwargs).execute()) self._track_outbound_thread(chat_id, resp) return SendResult(success=True, message_id=resp.get("name")) except HttpError as exc: return SendResult(success=False, error=_redact_sensitive(str(exc))) - async def _post_attachment_fallback( - self, chat_id: str, path: str, filename: str, caption: Optional[str], thread_id: Optional[str] - ) -> SendResult: + async def _post_attachment_fallback(self, chat_id: str, path: str, filename: str, caption: Optional[str], + thread_id: Optional[str]) -> SendResult: """Post the ``/setup-files`` notice (plus host path) when native delivery is unavailable. Always returns ``success=False``.""" - lines = [] - if caption: - lines.append(caption) + lines = [caption] if caption else [] lines.extend([ f"⚠️ No he podido adjuntar **{filename}**.", - "Google Chat sólo permite adjuntar archivos cuando el bot tiene " - "permiso explícito tuyo (OAuth de usuario). Es un consentimiento " - "único que se hace desde este chat.", + "Google Chat sólo permite adjuntar archivos cuando el bot tiene permiso explícito tuyo (OAuth de usuario). " + "Es un consentimiento único que se hace desde este chat.", "**Para activarlo:** envía `/setup-files` y sigue las instrucciones.", f"Mientras tanto el archivo está en el host: `{path}`", ]) - body: Dict[str, Any] = {"text": "\n".join(lines)} - if thread_id: - body["thread"] = {"name": thread_id} try: - await self._create_message(chat_id, body) + await self._create_message(chat_id, _thread_body("\n".join(lines), thread_id)) except Exception: logger.debug("[GoogleChat] attachment fallback notice send failed", exc_info=True) return SendResult( - success=False, - error="google_chat: native attachment requires user OAuth — run /setup-files in chat", - ) + success=False, error="google_chat: native attachment requires user OAuth — run /setup-files in chat") - # ------------------------------------------------------------------ - # Metadata - # ------------------------------------------------------------------ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return {name, type, chat_id} for a space.""" try: info = await asyncio.to_thread( - lambda: self._chat_api.spaces().get(name=chat_id).execute(http=self._new_authed_http()) - ) + lambda: self._chat_api.spaces().get(name=chat_id).execute(http=self._new_authed_http())) except HttpError as exc: logger.debug("[GoogleChat] get_chat_info failed: %s", _redact_sensitive(str(exc))) return {"name": chat_id, "type": "group", "chat_id": chat_id} @@ -1926,11 +1492,7 @@ class GoogleChatAdapter(BasePlatformAdapter): } -# --------------------------------------------------------------------------- -# Plugin entry point -# --------------------------------------------------------------------------- - - +# -- plugin entry point ----------------------------------------------------- def _validate_config(config: PlatformConfig) -> bool: """Plugin-side config gate for HTTP callback or Pub/Sub inbound modes.""" extra = getattr(config, "extra", {}) or {} @@ -1944,13 +1506,15 @@ def _env_inbound_settings() -> Tuple[Optional[str], Optional[str], Optional[str] return project, subscription, _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_URL") +def _env_inbound_configured() -> bool: + project, subscription, http_events_url = _env_inbound_settings() + return bool(http_events_url or (project and subscription)) + + def _check_for_registry() -> bool: """``check_fn`` for the registry pass: deps installed AND minimum inbound env set, so an unconfigured user never sees ``google_chat`` auto-enabled.""" - if not check_google_chat_requirements(): - return False - project, subscription, http_events_url = _env_inbound_settings() - return bool(http_events_url or (project and subscription)) + return check_google_chat_requirements() and _env_inbound_configured() def _is_connected(config: PlatformConfig) -> bool: @@ -1958,34 +1522,26 @@ def _is_connected(config: PlatformConfig) -> bool: return bool(getattr(config, "enabled", False)) and _validate_config(config) -def _env_enablement() -> Optional[Dict[str, Any]]: - """Seed ``PlatformConfig.extra`` from env vars during ``_apply_env_overrides`` - (before the adapter exists, so ``gateway status`` reflects env-only config). +_ENV_SEED_KEYS = ( + ("http_events_audience", "GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE"), + ("http_events_service_account_email", "GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL"), + ("max_messages", "GOOGLE_CHAT_MAX_MESSAGES"), ("max_bytes", "GOOGLE_CHAT_MAX_BYTES"), + ("bootstrap_spaces", "GOOGLE_CHAT_BOOTSTRAP_SPACES"), ("debug_raw", "GOOGLE_CHAT_DEBUG_RAW"), +) - Returns None when the minimum inbound settings are absent. ``home_channel`` is - turned into a ``HomeChannel`` by the core hook rather than merged into extra. - """ - project, subscription, http_events_url = _env_inbound_settings() - if not (http_events_url or (project and subscription)): + +def _env_enablement() -> Optional[Dict[str, Any]]: + """Seed ``PlatformConfig.extra`` from env during ``_apply_env_overrides`` (before the + adapter exists, so ``gateway status`` reflects env-only config). None when the minimum + inbound settings are absent; ``home_channel`` becomes a ``HomeChannel`` in the core hook.""" + if not _env_inbound_configured(): return None - seed: Dict[str, Any] = {} - for extra_name, value in ( - ("project_id", project), - ("subscription_name", subscription), - ("http_events_url", http_events_url), - ("http_events_audience", _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE")), - ("http_events_service_account_email", _get_scoped_secret("GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL")), - ("max_messages", _get_scoped_secret("GOOGLE_CHAT_MAX_MESSAGES")), - ("max_bytes", _get_scoped_secret("GOOGLE_CHAT_MAX_BYTES")), - ("bootstrap_spaces", _get_scoped_secret("GOOGLE_CHAT_BOOTSTRAP_SPACES")), - ("debug_raw", _get_scoped_secret("GOOGLE_CHAT_DEBUG_RAW")), - ( - "service_account_json", - _get_scoped_secret("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") or _get_scoped_secret("GOOGLE_APPLICATION_CREDENTIALS"), - ), - ): - if value: - seed[extra_name] = value + project, subscription, http_events_url = _env_inbound_settings() + values = [("project_id", project), ("subscription_name", subscription), ("http_events_url", http_events_url)] + values += [(extra_name, _get_scoped_secret(env)) for extra_name, env in _ENV_SEED_KEYS] + values.append(("service_account_json", _get_scoped_secret("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") + or _get_scoped_secret("GOOGLE_APPLICATION_CREDENTIALS"))) + seed: Dict[str, Any] = {extra_name: value for extra_name, value in values if value} home = _get_scoped_secret("GOOGLE_CHAT_HOME_CHANNEL") if home: seed["home_channel"] = {"chat_id": home, "name": _get_scoped_secret("GOOGLE_CHAT_HOME_CHANNEL_NAME", "Home")} @@ -2010,11 +1566,9 @@ Full guide: website/docs/user-guide/messaging/google_chat.md def interactive_setup() -> None: - """``hermes setup`` wizard for plugin platforms: print GCP instructions, prompt - for env vars, persist them to ``~/.hermes/.env``.""" + """``hermes setup`` wizard: print GCP instructions, prompt for env vars, persist to ``~/.hermes/.env``.""" from hermes_cli.cli_output import print_info, print_success, print_warning, prompt, prompt_yes_no from hermes_cli.config import get_env_value, save_env_value - existing_sub = get_env_value("GOOGLE_CHAT_SUBSCRIPTION_NAME") if existing_sub: print_info(f"Google Chat: already configured (subscription: {existing_sub})") @@ -2022,7 +1576,6 @@ def interactive_setup() -> None: return for line in _SETUP_WALKTHROUGH.splitlines(): print_info(line) - for question, env_name, required_label, password in ( ("GCP project ID (e.g. my-project)", "GOOGLE_CHAT_PROJECT_ID", "Project ID", False), ("Pub/Sub subscription (projects//subscriptions/)", "GOOGLE_CHAT_SUBSCRIPTION_NAME", "Subscription", False), @@ -2035,7 +1588,6 @@ def interactive_setup() -> None: print_warning(f"{required_label} is required — skipping Google Chat setup") return save_env_value(env_name, value.strip()) - if prompt_yes_no("Restrict access to specific users? (recommended)", True): allowed = prompt("Allowed user emails (comma-separated)", default=get_env_value("GOOGLE_CHAT_ALLOWED_USERS") or "") if allowed: @@ -2046,133 +1598,100 @@ def interactive_setup() -> None: else: save_env_value("GOOGLE_CHAT_ALLOW_ALL_USERS", "true") print_warning("⚠️ Open access — anyone who can DM the bot can command it.") - home = prompt( "Home space for cron/notification delivery (e.g. spaces/AAAA, or empty)", default=get_env_value("GOOGLE_CHAT_HOME_CHANNEL") or "", ) if home: save_env_value("GOOGLE_CHAT_HOME_CHANNEL", home.strip()) - print() print_success("Google Chat configuration saved to ~/.hermes/.env") print_info("Restart the gateway: hermes gateway restart") -# Strict resource-name pattern: anything outside Chat's documented character set -# is a tampered chat_id trying to break out of the REST URL path. +# Strict resource-name patterns: anything outside Chat's documented character set +# is a tampered id trying to break out of the REST URL path. _GCHAT_CHAT_ID_RE = re.compile(r"^(?:spaces|users)/[A-Za-z0-9_-]+$") +_GCHAT_THREAD_ID_RE = re.compile(r"^spaces/[A-Za-z0-9_-]+/threads/[A-Za-z0-9_-]+$") +# Detail suffixes for ``_standalone_error`` keyed by ``_SACredentialError.kind``. _STANDALONE_SA_ERRORS = { - "inline_invalid": "Google Chat standalone send: inline SA JSON is invalid: {exc}", - "not_found": "Google Chat standalone send: SA JSON file not found at {path}", - "file_invalid": "Google Chat standalone send: SA JSON file is invalid: {exc}", - "adc_foreign": ( - "Google Chat standalone send: ADC skipped for this profile: service-account credentials are set in " - "the process environment but not in this profile's secret scope" - ), - "adc_no_auth": ( - "Google Chat standalone send: no SA credentials configured and google-auth is not installed for ADC fallback" - ), - "adc_failed": ( - "Google Chat standalone send: no SA credentials configured and Application Default Credentials are " - "unavailable: {exc}" - ), + "inline_invalid": "inline SA JSON is invalid: {exc}", + "not_found": "SA JSON file not found at {path}", + "file_invalid": "SA JSON file is invalid: {exc}", + "adc_foreign": ("ADC skipped for this profile: service-account credentials are set in the process environment but " + "not in this profile's secret scope"), + "adc_no_auth": "no SA credentials configured and google-auth is not installed for ADC fallback", + "adc_failed": "no SA credentials configured and Application Default Credentials are unavailable: {exc}", } +def _standalone_error(detail: str) -> Dict[str, Any]: + return {"error": f"Google Chat standalone send: {detail}"} + + async def _standalone_send( pconfig, chat_id: str, message: str, *, thread_id: Optional[str] = None, media_files: Optional[List[str]] = None, force_document: bool = False, ) -> Dict[str, Any]: - """POST a single Chat message via REST without the SDK. - - Used by ``tools/send_message_tool._send_via_adapter`` when the gateway runner - is not in this process (e.g. ``hermes cron``). Needs SA credentials - (``GOOGLE_CHAT_SERVICE_ACCOUNT_JSON`` / ``GOOGLE_APPLICATION_CREDENTIALS`` / - ADC) and a validated space resource name. ``media_files`` / ``force_document`` - are accepted for signature parity only; attachments send as text. - """ + """POST one Chat message via REST without the SDK (``send_message_tool`` when the + gateway runner is not in-process, e.g. ``hermes cron``). Needs SA credentials and a + validated space name; ``media_files`` / ``force_document`` are signature parity only.""" if not chat_id: - return {"error": "Google Chat standalone send: chat_id (space resource) is required"} + return _standalone_error("chat_id (space resource) is required") if not _GCHAT_CHAT_ID_RE.match(chat_id): - return {"error": ( - f"Google Chat standalone send: chat_id {chat_id!r} must match " - f"'spaces/' or 'users/' with only [A-Za-z0-9_-] in the id" - )} - if thread_id is not None and not re.match(r"^spaces/[A-Za-z0-9_-]+/threads/[A-Za-z0-9_-]+$", thread_id): - return {"error": ( - f"Google Chat standalone send: thread_id {thread_id!r} must match " - f"'spaces//threads/'" - )} - + return _standalone_error( + f"chat_id {chat_id!r} must match 'spaces/' or 'users/' with only [A-Za-z0-9_-] in the id") + if thread_id is not None and not _GCHAT_THREAD_ID_RE.match(thread_id): + return _standalone_error(f"thread_id {thread_id!r} must match 'spaces//threads/'") extra = getattr(pconfig, "extra", {}) or {} sa_value = ( - extra.get("service_account_json") - or _get_scoped_secret("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") + extra.get("service_account_json") or _get_scoped_secret("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") or _get_scoped_secret("GOOGLE_APPLICATION_CREDENTIALS") ) if service_account is None: - return {"error": "Google Chat standalone send: google-auth not installed"} + return _standalone_error("google-auth not installed") try: from google.auth.transport.requests import Request as _GoogleAuthRequest except Exception as e: - return {"error": f"Google Chat standalone send: google-auth import failed: {e}"} - + return _standalone_error(f"google-auth import failed: {e}") try: creds = _load_sa_credentials_from(sa_value) except _SACredentialError as err: - return {"error": _STANDALONE_SA_ERRORS[err.kind].format(exc=err.detail, path=sa_value)} - except asyncio.CancelledError: - raise + return _standalone_error(_STANDALONE_SA_ERRORS[err.kind].format(exc=err.detail, path=sa_value)) except Exception as e: - return {"error": f"Google Chat standalone send: credential load failed: {e}"} - + return _standalone_error(f"credential load failed: {e}") # Bound the synchronous token refresh so a hung STS endpoint can't stall cron. try: await asyncio.wait_for(asyncio.to_thread(creds.refresh, _GoogleAuthRequest()), timeout=10.0) except asyncio.TimeoutError: - return {"error": "Google Chat standalone send: token refresh timed out"} - except asyncio.CancelledError: - raise + return _standalone_error("token refresh timed out") except Exception as e: - return {"error": f"Google Chat standalone send: token refresh failed: {e}"} - + return _standalone_error(f"token refresh failed: {e}") token = getattr(creds, "token", None) if not token: - return {"error": "Google Chat standalone send: refreshed credentials have no token"} - - body: Dict[str, Any] = {"text": message} - if thread_id: - body["thread"] = {"name": thread_id} - url = f"https://chat.googleapis.com/v1/{chat_id}/messages" + return _standalone_error("refreshed credentials have no token") try: import aiohttp as _aiohttp except ImportError: - return {"error": "Google Chat standalone send: aiohttp not installed"} - + return _standalone_error("aiohttp not installed") try: async with _aiohttp.ClientSession(timeout=_aiohttp.ClientTimeout(total=30.0), trust_env=gateway_trust_env()) as session: async with session.post( - url, - json=body, + f"https://chat.googleapis.com/v1/{chat_id}/messages", json=_thread_body(message, thread_id), headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, ) as resp: if resp.status >= 400: - text = await resp.text() - return {"error": f"Google Chat standalone send: API returned {resp.status}: {text[:300]}"} + return _standalone_error(f"API returned {resp.status}: {(await resp.text())[:300]}") payload = await resp.json() return {"success": True, "message_id": payload.get("name")} - except asyncio.CancelledError: - raise except Exception as e: logger.debug("Google Chat standalone send raised", exc_info=True) return {"error": f"Google Chat standalone send failed: {e}"} def register(ctx) -> None: - """Plugin entry point: registers the ``google_chat`` adapter with the platform - registry (consulted by the gateway's ``_create_adapter`` before its built-ins).""" + """Plugin entry point: registers the ``google_chat`` adapter with the platform registry.""" ctx.register_platform( name="google_chat", label="Google Chat", @@ -2183,11 +1702,8 @@ def register(ctx) -> None: required_env=["GOOGLE_CHAT_SERVICE_ACCOUNT_JSON"], install_hint="Run `hermes setup` to install Google Chat support.", setup_fn=interactive_setup, - # Seeds PlatformConfig.extra + home_channel from env during _apply_env_overrides. env_enablement_fn=_env_enablement, - # Cron home-channel delivery for ``deliver=google_chat`` jobs. cron_deliver_env_var="GOOGLE_CHAT_HOME_CHANNEL", - # Out-of-process cron delivery via the Chat REST API. standalone_sender_fn=_standalone_send, allowed_users_env="GOOGLE_CHAT_ALLOWED_USERS", allow_all_env="GOOGLE_CHAT_ALLOW_ALL_USERS", @@ -2196,24 +1712,17 @@ def register(ctx) -> None: emoji="💬", allow_update_command=True, platform_hint=( - "You are on Google Chat. Limited markdown subset is rendered: " - "*bold*, _italic_, ~strike~, `code`. No headings or lists. " - "Message size limit: 4000 characters; longer responses are split " - "across multiple messages. You are in a space (DM or group). " - "Images render inline; audio, video, and document attachments " - "render as download cards (no native voice/video UI). To send " - "files, include MEDIA:/absolute/path/to/file in your response. " - "Native file attachments require the user to run /setup-files " - "once in their own DM — until they do, file requests fall back " - "to a text notice with the host path. Do NOT generate interactive " - "Card v2 buttons — Google Chat interactivity is not yet supported " - "by this gateway; ask for typed confirmations instead. While you " - "are generating a response, a 'Hermes is thinking…' marker message " - "appears in the space and is deleted once your response is ready. " - "You do NOT have access to Google Chat-specific APIs — you cannot " - "search space history, list space members, or manage spaces. Do " - "not promise to perform these actions; explain that you can only " - "read messages sent directly to you and respond in the same " - "space/thread." + "You are on Google Chat. Limited markdown subset is rendered: *bold*, _italic_, ~strike~, `code`. " + "No headings or lists. Message size limit: 4000 characters; longer responses are split across multiple " + "messages. You are in a space (DM or group). Images render inline; audio, video, and document attachments " + "render as download cards (no native voice/video UI). To send files, include MEDIA:/absolute/path/to/file " + "in your response. Native file attachments require the user to run /setup-files once in their own DM — " + "until they do, file requests fall back to a text notice with the host path. Do NOT generate interactive " + "Card v2 buttons — Google Chat interactivity is not yet supported by this gateway; ask for typed " + "confirmations instead. While you are generating a response, a 'Hermes is thinking…' marker message " + "appears in the space and is deleted once your response is ready. You do NOT have access to Google " + "Chat-specific APIs — you cannot search space history, list space members, or manage spaces. Do not " + "promise to perform these actions; explain that you can only read messages sent directly to you and " + "respond in the same space/thread." ), ) diff --git a/tests/plugins/test_a2a_phase23.py b/tests/plugins/test_a2a_phase23.py index 933ae037c9..6453eef21b 100644 --- a/tests/plugins/test_a2a_phase23.py +++ b/tests/plugins/test_a2a_phase23.py @@ -259,7 +259,7 @@ class TestPushSigning: def test_sign_push_payload_deterministic(self, monkeypatch): monkeypatch.setenv("A2A_PUSH_SECRET", "test-secret-123") payload = {"statusUpdate": {"taskId": "task-1"}} - sig = security.sign_push_payload(payload) + sig = security.A2ASecurityContext.capture().sign_push_payload(payload) assert sig import hashlib import hmac as hmac_mod @@ -273,12 +273,12 @@ class TestPushSigning: def test_no_secret_means_unsigned(self, monkeypatch): monkeypatch.delenv("A2A_PUSH_SECRET", raising=False) monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False) - assert security.sign_push_payload({"x": 1}) == "" + assert security.A2ASecurityContext.capture().sign_push_payload({"x": 1}) == "" def test_falls_back_to_bearer_token(self, monkeypatch): monkeypatch.delenv("A2A_PUSH_SECRET", raising=False) monkeypatch.setenv("A2A_BEARER_TOKEN", "bearer-as-push-secret") - assert security.sign_push_payload({"x": 1}) + assert security.A2ASecurityContext.capture().sign_push_payload({"x": 1}) # ═════════════════════════════════════════════════════════════════════════════ diff --git a/tests/plugins/test_a2a_plugin.py b/tests/plugins/test_a2a_plugin.py index e5d2668884..70751050e2 100644 --- a/tests/plugins/test_a2a_plugin.py +++ b/tests/plugins/test_a2a_plugin.py @@ -44,33 +44,33 @@ class TestBindSafety: monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False) monkeypatch.delenv("A2A_PEER_TOKENS", raising=False) assert security.localhost_only() is True - assert security.resolve_bind_host() == "127.0.0.1" + assert security.A2ASecurityContext.capture().resolve_bind_host() == "127.0.0.1" def test_host_ignored_without_token(self, monkeypatch): monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False) monkeypatch.delenv("A2A_PEER_TOKENS", raising=False) monkeypatch.setenv("A2A_HOST", "0.0.0.0") # No token => refuse to widen, stay on loopback. - assert security.resolve_bind_host() == "127.0.0.1" + assert security.A2ASecurityContext.capture().resolve_bind_host() == "127.0.0.1" def test_host_widens_with_shared_token(self, monkeypatch): monkeypatch.setenv("A2A_BEARER_TOKEN", "secret-token-123") monkeypatch.setenv("A2A_HOST", "0.0.0.0") assert security.localhost_only() is False - assert security.resolve_bind_host() == "0.0.0.0" + assert security.A2ASecurityContext.capture().resolve_bind_host() == "0.0.0.0" def test_host_widens_with_peer_tokens(self, monkeypatch): monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False) monkeypatch.setenv("A2A_PEER_TOKENS", "alice:tok1") monkeypatch.setenv("A2A_HOST", "0.0.0.0") assert security.localhost_only() is False - assert security.resolve_bind_host() == "0.0.0.0" + assert security.A2ASecurityContext.capture().resolve_bind_host() == "0.0.0.0" def test_loopback_host_allowed_without_token(self, monkeypatch): monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False) monkeypatch.delenv("A2A_PEER_TOKENS", raising=False) monkeypatch.setenv("A2A_HOST", "localhost") - assert security.resolve_bind_host() == "localhost" + assert security.A2ASecurityContext.capture().resolve_bind_host() == "localhost" class TestPeerIdentity: @@ -80,33 +80,33 @@ class TestPeerIdentity: def test_no_tokens_identity_is_client_ip(self, monkeypatch): monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False) monkeypatch.delenv("A2A_PEER_TOKENS", raising=False) - assert security.authenticate(None, "127.0.0.1") == "ip:127.0.0.1" - assert security.authenticate("Bearer anything", "127.0.0.1") == "ip:127.0.0.1" + assert security.A2ASecurityContext.capture().authenticate(None, "127.0.0.1") == "ip:127.0.0.1" + assert security.A2ASecurityContext.capture().authenticate("Bearer anything", "127.0.0.1") == "ip:127.0.0.1" def test_peer_token_maps_to_name(self, monkeypatch): monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False) monkeypatch.setenv("A2A_PEER_TOKENS", "alice:tok-a, bob:tok-b") - assert security.authenticate("Bearer tok-a", "1.2.3.4") == "alice" - assert security.authenticate("Bearer tok-b", "1.2.3.4") == "bob" + assert security.A2ASecurityContext.capture().authenticate("Bearer tok-a", "1.2.3.4") == "alice" + assert security.A2ASecurityContext.capture().authenticate("Bearer tok-b", "1.2.3.4") == "bob" def test_wrong_or_missing_token_rejected(self, monkeypatch): monkeypatch.setenv("A2A_PEER_TOKENS", "alice:tok-a") monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False) - assert security.authenticate("Bearer nope", "1.2.3.4") is None - assert security.authenticate(None, "1.2.3.4") is None - assert security.authenticate("Basic tok-a", "1.2.3.4") is None + assert security.A2ASecurityContext.capture().authenticate("Bearer nope", "1.2.3.4") is None + assert security.A2ASecurityContext.capture().authenticate(None, "1.2.3.4") is None + assert security.A2ASecurityContext.capture().authenticate("Basic tok-a", "1.2.3.4") is None def test_shared_token_identity_is_ip(self, monkeypatch): monkeypatch.setenv("A2A_BEARER_TOKEN", "shared-tok") monkeypatch.delenv("A2A_PEER_TOKENS", raising=False) - assert security.authenticate("Bearer shared-tok", "9.8.7.6") == "ip:9.8.7.6" - assert security.authenticate("Bearer wrong", "9.8.7.6") is None + assert security.A2ASecurityContext.capture().authenticate("Bearer shared-tok", "9.8.7.6") == "ip:9.8.7.6" + assert security.A2ASecurityContext.capture().authenticate("Bearer wrong", "9.8.7.6") is None def test_peer_tokens_beat_shared(self, monkeypatch): monkeypatch.setenv("A2A_BEARER_TOKEN", "shared-tok") monkeypatch.setenv("A2A_PEER_TOKENS", "carol:tok-c") - assert security.authenticate("Bearer tok-c", "1.1.1.1") == "carol" - assert security.authenticate("Bearer shared-tok", "1.1.1.1") == "ip:1.1.1.1" + assert security.A2ASecurityContext.capture().authenticate("Bearer tok-c", "1.1.1.1") == "carol" + assert security.A2ASecurityContext.capture().authenticate("Bearer shared-tok", "1.1.1.1") == "ip:1.1.1.1" class TestTrustedPeers: @@ -114,27 +114,27 @@ class TestTrustedPeers: monkeypatch.delenv("A2A_BEARER_TOKEN", raising=False) monkeypatch.delenv("A2A_PEER_TOKENS", raising=False) monkeypatch.delenv("A2A_ALLOW_ALL_USERS", raising=False) - assert security.is_trusted_peer("ip:127.0.0.1") is True + assert security.A2ASecurityContext.capture().is_trusted_peer("ip:127.0.0.1") is True def test_no_allowlist_trusts_authenticated(self, monkeypatch): monkeypatch.setenv("A2A_BEARER_TOKEN", "secret") monkeypatch.delenv("A2A_ALLOW_ALL_USERS", raising=False) monkeypatch.delenv("A2A_TRUSTED_PEERS", raising=False) - assert security.is_trusted_peer("alice") is True + assert security.A2ASecurityContext.capture().is_trusted_peer("alice") is True def test_allowlist_restricts(self, monkeypatch): monkeypatch.setenv("A2A_BEARER_TOKEN", "secret") monkeypatch.delenv("A2A_ALLOW_ALL_USERS", raising=False) monkeypatch.setenv("A2A_TRUSTED_PEERS", "alice,bob") - assert security.is_trusted_peer("alice") is True - assert security.is_trusted_peer("bob") is True - assert security.is_trusted_peer("mallory") is False + assert security.A2ASecurityContext.capture().is_trusted_peer("alice") is True + assert security.A2ASecurityContext.capture().is_trusted_peer("bob") is True + assert security.A2ASecurityContext.capture().is_trusted_peer("mallory") is False def test_allow_all_users_overrides(self, monkeypatch): monkeypatch.setenv("A2A_BEARER_TOKEN", "secret") monkeypatch.setenv("A2A_ALLOW_ALL_USERS", "true") monkeypatch.setenv("A2A_TRUSTED_PEERS", "alice") - assert security.is_trusted_peer("mallory") is True + assert security.A2ASecurityContext.capture().is_trusted_peer("mallory") is True class TestInjectionFilter: @@ -266,7 +266,6 @@ class TestV1Enums: assert protocol.STATE_CANCELED == "TASK_STATE_CANCELED" assert protocol.STATE_REJECTED == "TASK_STATE_REJECTED" assert protocol.STATE_INPUT_REQUIRED == "TASK_STATE_INPUT_REQUIRED" - assert protocol.STATE_AUTH_REQUIRED == "TASK_STATE_AUTH_REQUIRED" def test_roles_are_v1(self): assert protocol.ROLE_USER == "ROLE_USER" @@ -330,42 +329,6 @@ class TestV1Parts: assert "hello.txt" in result assert "base64" in result - def test_file_part_builder(self): - """file_part() builds a v1.0 file Part with URL or raw.""" - fp = protocol.file_part(url="https://x/f.pdf", filename="f.pdf", - media_type="application/pdf") - assert fp["url"] == "https://x/f.pdf" - assert fp["filename"] == "f.pdf" - assert fp["mediaType"] == "application/pdf" - assert "kind" not in fp - - # Raw variant - rp = protocol.file_part(raw="aGVsbG8=", filename="hello.txt", - media_type="text/plain") - assert rp["raw"] == "aGVsbG8=" - assert rp["filename"] == "hello.txt" - assert "url" not in rp - - def test_data_part_builder(self): - """data_part() builds a v1.0 data Part.""" - dp = protocol.data_part({"key": "value"}) - assert dp["data"] == {"key": "value"} - assert dp["mediaType"] == "application/json" - assert "kind" not in dp - - def test_message_with_parts(self): - """message_with_parts() builds a Message with mixed Part types.""" - msg = protocol.message_with_parts( - protocol.ROLE_USER, - [protocol.text_part("hello"), protocol.data_part({"x": 1})], - context_id="ctx-1", - ) - assert msg["role"] == "ROLE_USER" - assert len(msg["parts"]) == 2 - assert msg["parts"][0]["text"] == "hello" - assert msg["parts"][1]["data"] == {"x": 1} - assert msg["contextId"] == "ctx-1" - def test_context_id_extracted_from_message(self): params = {"message": protocol.text_message(protocol.ROLE_USER, "x", context_id="ctx-in-msg")} assert protocol.extract_context_id(params) == "ctx-in-msg" @@ -1010,16 +973,14 @@ class TestInboundRoundTrip: async def run(): assert await adapter.connect() is True - msg = protocol.message_with_parts( - protocol.ROLE_USER, - [ + msg = { + "role": protocol.ROLE_USER, "messageId": "m-mixed", "contextId": "ctx-mixed", + "parts": [ protocol.text_part("Please process these:"), - protocol.file_part(url="https://example.com/report.pdf", - filename="report.pdf", media_type="application/pdf"), - protocol.data_part({"title": "Q3", "pages": 42}, "application/json"), + {"mediaType": "application/pdf", "filename": "report.pdf", "url": "https://example.com/report.pdf"}, + {"data": {"title": "Q3", "pages": 42}, "mediaType": "application/json"}, ], - context_id="ctx-mixed", - ) + } resp = await asyncio.to_thread(_post_json, base + "/", { "jsonrpc": "2.0", "id": "1", "method": "message/send", "params": {"message": msg},