From 70a3cb466ef1958de5c65054a8df18c55ed11ed5 Mon Sep 17 00:00:00 2001 From: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com> Date: Sun, 31 May 2026 18:57:44 +0000 Subject: [PATCH 1/8] fix(google-chat): allow http inbound without pubsub --- plugins/platforms/google_chat/adapter.py | 161 ++++++++++++---------- plugins/platforms/google_chat/plugin.yaml | 27 ++-- tests/gateway/test_google_chat.py | 49 ++++++- 3 files changed, 152 insertions(+), 85 deletions(-) diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index f63efeabebde..29163b8aca37 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -1,9 +1,9 @@ """ Google Chat platform adapter. -Uses Google Cloud Pub/Sub (pull subscription) for inbound events and the -Google Chat REST API for outbound messages. Pattern parallels Slack Socket -Mode and Telegram long-polling: no public endpoint required. +Uses authenticated HTTP callbacks or Google Cloud Pub/Sub for inbound +events and the Google Chat REST API for outbound messages. Pub/Sub remains +available for no-public-URL deployments. Concurrency model ----------------- @@ -625,33 +625,42 @@ def _load_sa_credentials(self) -> Any: ) return credentials - def _validate_config(self) -> Tuple[str, str]: + def _validate_config(self) -> Tuple[str, Optional[str]]: """Return (project_id, subscription_path) after validation. - Raises ValueError with a sanitized message on any config problem. + ``subscription_path`` is ``None`` for HTTP-inbound deployments. Raises + ValueError with a sanitized message on any config problem. """ - project_id = self.config.extra.get("project_id") - subscription = self.config.extra.get("subscription_name") + 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: + raise ValueError( + "GOOGLE_CHAT_SUBSCRIPTION_NAME must match " + "'projects//subscriptions/'." + ) + 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." + ) + return project_id or subscription_project, subscription + + if http_events_url: + return project_id, None + if not project_id: raise ValueError( "GOOGLE_CHAT_PROJECT_ID (or GOOGLE_CLOUD_PROJECT) is not set." ) - if not subscription: - raise ValueError( - "GOOGLE_CHAT_SUBSCRIPTION_NAME (or GOOGLE_CHAT_SUBSCRIPTION) is not set." - ) - match = _SUBSCRIPTION_PATH_RE.match(subscription) - if not match: - raise ValueError( - "GOOGLE_CHAT_SUBSCRIPTION_NAME must match " - "'projects//subscriptions/'." - ) - if match.group("project") != project_id: - raise ValueError( - "project_id in GOOGLE_CHAT_PROJECT_ID does not match the " - "project embedded in GOOGLE_CHAT_SUBSCRIPTION_NAME." - ) - return project_id, subscription + raise ValueError( + "GOOGLE_CHAT_SUBSCRIPTION_NAME (or GOOGLE_CHAT_SUBSCRIPTION) is not set. " + "Set GOOGLE_CHAT_HTTP_EVENTS_URL for HTTP callback mode." + ) # ------------------------------------------------------------------ # Loop bridge helpers (thread -> asyncio loop) @@ -862,36 +871,37 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: "all threads as fresh)", exc_info=True, ) - # Sanity check: subscription exists / SA has access. - self._subscriber = pubsub_v1.SubscriberClient(credentials=credentials) - try: - await asyncio.to_thread( - lambda: self._subscriber.get_subscription( - request={"subscription": subscription_path} + if subscription_path is not None: + # Sanity check: subscription exists / SA has access. + 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: - self._set_fatal_error( - code="subscription_not_found", - message="Pub/Sub subscription not found at configured path", - retryable=False, - ) - return False - except gax_exceptions.PermissionDenied: - self._set_fatal_error( - code="subscription_permission", - message=( - "Service Account lacks roles/pubsub.subscriber on the " - "subscription" - ), - 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 + except gax_exceptions.NotFound: + self._set_fatal_error( + code="subscription_not_found", + message="Pub/Sub subscription not found at configured path", + retryable=False, + ) + return False + except gax_exceptions.PermissionDenied: + self._set_fatal_error( + code="subscription_permission", + message=( + "Service Account lacks roles/pubsub.subscriber on the " + "subscription" + ), + 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 # Resolve bot user_id (eager): cache first, then members.list. self._bot_user_id = self._load_cached_bot_id() @@ -905,14 +915,22 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: "will resolve on first addedToSpace or member lookup" ) - # Start the supervisor task that runs the Pub/Sub pull with exponential - # backoff + jitter on transient errors, bails out after N retries. - self._supervisor_task = asyncio.create_task(self._run_supervisor()) + if subscription_path is not None: + # Start the supervisor task that runs the Pub/Sub pull with exponential + # backoff + jitter on transient errors, bails out after N retries. + self._supervisor_task = asyncio.create_task(self._run_supervisor()) + inbound = "pubsub" + else: + self._supervisor_task = None + inbound = "http" + self._mark_connected() logger.info( - "[GoogleChat] Connected; project=%s, subscription=, " + "[GoogleChat] Connected; project=%s, inbound=%s, subscription=%s, " "bot_user_id=%s, flow_control(msgs=%s, bytes=%s)", - project_id, + project_id or "", + inbound, + "" if subscription_path else "", self._bot_user_id or "", self._max_messages, self._max_bytes, @@ -2944,15 +2962,11 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: def _validate_config(config: PlatformConfig) -> bool: - """Plugin-side config gate: require both Pub/Sub project and subscription. - - Mirrors the legacy dispatch entry in ``gateway/config.py`` so the - registry can decide whether the platform is configured without - importing the legacy table. - """ + """Plugin-side config gate for HTTP callback or Pub/Sub inbound modes.""" extra = getattr(config, "extra", {}) or {} return bool( - extra.get("project_id") and extra.get("subscription_name") + extra.get("http_events_url") + or (extra.get("project_id") and extra.get("subscription_name")) ) @@ -2977,7 +2991,8 @@ def _check_for_registry() -> bool: os.getenv("GOOGLE_CHAT_SUBSCRIPTION_NAME") or os.getenv("GOOGLE_CHAT_SUBSCRIPTION") ) - return bool(project and subscription) + http_events_url = os.getenv("GOOGLE_CHAT_HTTP_EVENTS_URL") + return bool(http_events_url or (project and subscription)) def _is_connected(config: PlatformConfig) -> bool: @@ -3007,12 +3022,16 @@ def _env_enablement() -> Optional[Dict[str, Any]]: os.getenv("GOOGLE_CHAT_SUBSCRIPTION_NAME") or os.getenv("GOOGLE_CHAT_SUBSCRIPTION") ) - if not (project and subscription): + http_events_url = os.getenv("GOOGLE_CHAT_HTTP_EVENTS_URL") + if not (http_events_url or (project and subscription)): return None - seed: Dict[str, Any] = { - "project_id": project, - "subscription_name": subscription, - } + seed: Dict[str, Any] = {} + if project: + seed["project_id"] = project + if subscription: + seed["subscription_name"] = subscription + if http_events_url: + seed["http_events_url"] = http_events_url sa_json = ( os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") or os.getenv("GOOGLE_APPLICATION_CREDENTIALS") @@ -3295,8 +3314,6 @@ def register(ctx) -> None: validate_config=_validate_config, is_connected=_is_connected, required_env=[ - "GOOGLE_CHAT_PROJECT_ID", - "GOOGLE_CHAT_SUBSCRIPTION_NAME", "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", ], install_hint="pip install 'hermes-agent[google_chat]'", diff --git a/plugins/platforms/google_chat/plugin.yaml b/plugins/platforms/google_chat/plugin.yaml index 1a8b90c43a70..6cf99cb8a0a0 100644 --- a/plugins/platforms/google_chat/plugin.yaml +++ b/plugins/platforms/google_chat/plugin.yaml @@ -4,31 +4,34 @@ kind: platform version: 1.0.0 description: > Google Chat gateway adapter for Hermes Agent. - Connects via Cloud Pub/Sub pull subscription for inbound events and the - Google Chat REST API for outbound messages — same ergonomics as Slack - Socket Mode or Telegram long-polling, no public URL required. Native - file attachments are delivered via per-user OAuth (each user runs - /setup-files once in their own DM). + Connects through authenticated HTTP callbacks or an optional Cloud Pub/Sub + pull subscription for inbound events, and uses the Google Chat REST API for + outbound messages. Native file attachments are delivered via per-user OAuth + (each user runs /setup-files once in their own DM). author: Ramón Fernández # ``requires_env`` entries are surfaced in ``hermes config`` UI via the # platform-plugin env var injector in ``hermes_cli/config.py``. Using the # rich-dict form lets us contribute description/url/prompt metadata so users # see helpful guidance instead of the auto-generated fallback text. requires_env: + - name: GOOGLE_CHAT_SERVICE_ACCOUNT_JSON + description: "Path to Service Account JSON key (or inline JSON). Leave empty to use Application Default Credentials on Cloud Run / GCE. Falls back to GOOGLE_APPLICATION_CREDENTIALS." + prompt: "Path to SA JSON (or empty for ADC)" + password: true +optional_env: + - name: GOOGLE_CHAT_HTTP_EVENTS_URL + description: "Authenticated HTTP endpoint for Chat message events." + prompt: "HTTP events callback URL" + password: false - name: GOOGLE_CHAT_PROJECT_ID - description: "GCP project ID hosting the Pub/Sub topic for Chat events. Falls back to GOOGLE_CLOUD_PROJECT." + description: "GCP project ID for optional Pub/Sub inbound mode. Falls back to GOOGLE_CLOUD_PROJECT." prompt: "GCP project ID" url: "https://console.cloud.google.com/" password: false - name: GOOGLE_CHAT_SUBSCRIPTION_NAME - description: "Full Pub/Sub subscription path: projects//subscriptions/. Legacy alias: GOOGLE_CHAT_SUBSCRIPTION." + description: "Optional Pub/Sub subscription path for pull-mode inbound events." prompt: "Pub/Sub subscription name" password: false - - name: GOOGLE_CHAT_SERVICE_ACCOUNT_JSON - description: "Path to Service Account JSON key (or inline JSON). Leave empty to use Application Default Credentials on Cloud Run / GCE. Falls back to GOOGLE_APPLICATION_CREDENTIALS." - prompt: "Path to SA JSON (or empty for ADC)" - password: true -optional_env: - name: GOOGLE_CHAT_ALLOWED_USERS description: "Comma-separated user emails allowed to interact with the bot." prompt: "Allowed user emails (comma-separated)" diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index b75902785038..a0a7dc16217c 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -252,6 +252,7 @@ class TestEnvConfigLoading: "GOOGLE_CLOUD_PROJECT", "GOOGLE_CHAT_SUBSCRIPTION_NAME", "GOOGLE_CHAT_SUBSCRIPTION", + "GOOGLE_CHAT_HTTP_EVENTS_URL", "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CHAT_HOME_CHANNEL", @@ -280,7 +281,12 @@ def test_missing_project_does_not_enable(self, monkeypatch): cfg = load_gateway_config() assert _GC not in cfg.platforms - + def test_http_events_enable_without_pubsub(self, monkeypatch): + self._clean_env(monkeypatch) + monkeypatch.setenv("GOOGLE_CHAT_HTTP_EVENTS_URL", "https://example.test/google-chat/events") + cfg = load_gateway_config() + assert _GC in cfg.platforms + assert cfg.platforms[_GC].extra["http_events_url"] == "https://example.test/google-chat/events" # =========================================================================== @@ -390,6 +396,47 @@ def test_validate_config_happy(self): assert project == "test-project" assert sub == "projects/test-project/subscriptions/test-sub" + def test_http_events_mode_does_not_require_pubsub(self): + cfg = PlatformConfig(enabled=True) + cfg.extra["http_events_url"] = "https://example.test/google-chat/events" + a = GoogleChatAdapter(cfg) + project, sub = a._validate_config() + assert project == "" + assert sub is None + + def test_full_subscription_can_infer_project(self): + cfg = PlatformConfig(enabled=True) + cfg.extra["subscription_name"] = "projects/inferred/subscriptions/sub" + a = GoogleChatAdapter(cfg) + project, sub = a._validate_config() + assert project == "inferred" + assert sub == "projects/inferred/subscriptions/sub" + + +class TestConnectModes: + @pytest.mark.asyncio + async def test_connect_http_mode_skips_pubsub_subscriber(self, tmp_path, monkeypatch): + cfg = PlatformConfig(enabled=True) + cfg.extra.update({ + "http_events_url": "https://example.test/google-chat/events", + "service_account_json": "{}", + }) + a = GoogleChatAdapter(cfg) + a._thread_count_store._path = tmp_path / "google_chat_thread_counts.json" + monkeypatch.setattr(_gc_mod, "_load_google_modules", lambda: True) + monkeypatch.setattr(a, "_load_sa_credentials", MagicMock(return_value=MagicMock())) + monkeypatch.setattr(_gc_mod, "build_service", MagicMock(return_value=MagicMock())) + subscriber_client = MagicMock() + monkeypatch.setattr(_gc_mod, "pubsub_v1", MagicMock(SubscriberClient=subscriber_client)) + a._resolve_bot_user_id = AsyncMock(return_value=None) + + assert await a.connect() is True + subscriber_client.assert_not_called() + assert a._subscription_path is None + assert a._supervisor_task is None + assert a.is_connected is True + await a.disconnect() + # =========================================================================== # _chunk_text From c615a76092c715a18895c512537584dafdc80ad5 Mon Sep 17 00:00:00 2001 From: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com> Date: Sun, 31 May 2026 19:36:32 +0000 Subject: [PATCH 2/8] feat(gateway): route platform HTTP event callbacks --- gateway/platforms/api_server.py | 119 ++++++++++++++++++++++ gateway/run.py | 4 +- plugins/platforms/google_chat/adapter.py | 91 +++++++++++++++++ plugins/platforms/google_chat/plugin.yaml | 8 ++ tests/gateway/test_api_server.py | 94 +++++++++++++++++ tests/gateway/test_google_chat.py | 104 ++++++++++++++++++- 6 files changed, 417 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 5ba09d67492e..9776439e3f61 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -900,6 +900,7 @@ def __init__(self, config: PlatformConfig): # Number of in-flight runs on the non-streaming chat/responses paths # (the /v1/runs path tracks its own in-flight set via _run_streams). self._inflight_agent_runs: int = 0 + self.gateway_runner: Optional[Any] = None @staticmethod def _parse_cors_origins(value: Any) -> tuple[str, ...]: @@ -1072,6 +1073,118 @@ def _check_auth(self, request: "web.Request") -> Optional["web.Response"]: status=401, ) + @staticmethod + def _normalize_callback_platform(value: str) -> str: + normalized = (value or "").strip().lower().replace("-", "_") + if not re.fullmatch(r"[a-z0-9_]+", normalized): + return "" + return normalized + + def _get_platform_callback_adapter( + self, + request: "web.Request", + platform_name: str, + ) -> Optional[Any]: + injected = request.app.get("platform_event_adapters") + if isinstance(injected, dict): + adapter = injected.get(platform_name) + if adapter is not None: + return adapter + + adapter = request.app.get(f"{platform_name}_adapter") + if adapter is not None: + return adapter + + runner = self.gateway_runner or request.app.get("gateway_runner") + adapters = getattr(runner, "adapters", None) + if not adapters: + return None + + try: + from gateway.config import Platform as _Platform + return adapters.get(_Platform(platform_name)) + except Exception: + for platform, candidate in adapters.items(): + if getattr(platform, "value", platform) == platform_name: + return candidate + return None + + async def _handle_platform_event_callback(self, request: "web.Request") -> "web.Response": + platform_name = self._normalize_callback_platform( + request.match_info.get("platform", "") + ) + if not platform_name: + return web.json_response( + _openai_error( + "Invalid platform name", + code="invalid_platform", + ), + status=400, + ) + + adapter = self._get_platform_callback_adapter(request, platform_name) + if adapter is None: + return web.json_response( + _openai_error( + "Platform adapter is not connected", + code="platform_unavailable", + ), + status=503, + ) + + verifier = getattr(adapter, "verify_http_event_request", None) + dispatcher = getattr(adapter, "dispatch_http_event", None) + if verifier is None or dispatcher is None: + return web.json_response( + _openai_error( + "Platform adapter does not support HTTP events", + code="platform_http_events_unsupported", + ), + status=503, + ) + + ok, code = verifier(request.headers.get("Authorization", "")) + if not ok: + return web.json_response( + _openai_error( + "Invalid platform event authorization", + code=code or "invalid_platform_event_authorization", + ), + status=401, + ) + + try: + payload = await request.json() + except Exception: + return web.json_response( + _openai_error("Invalid JSON in platform event", code="invalid_json"), + status=400, + ) + + if not isinstance(payload, dict): + return web.json_response( + _openai_error( + "Platform event must be a JSON object", + code="invalid_request", + ), + status=400, + ) + + try: + result = await dispatcher(payload) + except Exception: + logger.exception("Platform HTTP event dispatch failed for %s", platform_name) + return web.json_response( + _openai_error( + "Platform event dispatch failed", + err_type="server_error", + code="platform_event_dispatch_failed", + ), + status=500, + ) + + return web.json_response(result if isinstance(result, dict) else {}) + # ------------------------------------------------------------------ # Session header helpers # ------------------------------------------------------------------ @@ -4787,6 +4900,10 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: self._app.router.add_post("/v1/responses", self._handle_responses) self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response) self._app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response) + self._app.router.add_post( + "/api/platforms/{platform}/events", + self._handle_platform_event_callback, + ) # Cron jobs management API self._app.router.add_get("/api/jobs", self._handle_list_jobs) self._app.router.add_post("/api/jobs", self._handle_create_job) @@ -4812,6 +4929,8 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: # native routes first lets those shims no-op instead of shadowing the # upstream session-control handlers. self._app["api_server_adapter"] = self + if self.gateway_runner is not None: + self._app["gateway_runner"] = self.gateway_runner # Start background sweep to clean up orphaned (unconsumed) run streams sweep_task = asyncio.create_task(self._sweep_orphaned_runs()) diff --git a/gateway/run.py b/gateway/run.py index b81e87dba641..1882cfd290ee 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8512,7 +8512,9 @@ def _create_adapter( if not check_api_server_requirements(): logger.warning("API Server: aiohttp not installed") return None - return APIServerAdapter(config) + adapter = APIServerAdapter(config) + adapter.gateway_runner = self + return adapter elif platform == Platform.WEBHOOK: from gateway.platforms.webhook import WebhookAdapter, check_webhook_requirements diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 29163b8aca37..8fc540894989 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -73,6 +73,20 @@ _google_modules_loaded: bool = False +def _verify_google_id_token(token: str, audience: str) -> Dict[str, Any]: + try: + from google.auth.transport import requests as google_requests + from google.oauth2 import id_token + except ImportError as exc: + raise RuntimeError("google-auth is required for Google Chat HTTP callbacks") from exc + + return id_token.verify_oauth2_token( + token, + google_requests.Request(), + audience, + ) + + def _load_google_modules() -> bool: """Lazily import the heavy google-cloud + googleapiclient stack. @@ -548,6 +562,21 @@ def __init__(self, config: PlatformConfig): self._max_bytes = int(os.getenv("GOOGLE_CHAT_MAX_BYTES", str(16 * 1024 * 1024))) except (ValueError, TypeError): self._max_bytes = 16 * 1024 * 1024 + self._http_events_url = ( + self.config.extra.get("http_events_url") + or os.getenv("GOOGLE_CHAT_HTTP_EVENTS_URL", "") + or "" + ).strip() + self._http_events_audience = ( + self.config.extra.get("http_events_audience") + or os.getenv("GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE", "") + or self._http_events_url + ).strip() + self._http_events_service_account_email = ( + self.config.extra.get("http_events_service_account_email") + or os.getenv("GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL", "") + or "" + ).strip().lower() # ------------------------------------------------------------------ # Configuration loading and validation @@ -1280,6 +1309,62 @@ def _on_pubsub_message(self, message: Any) -> None: except Exception: pass + async def dispatch_http_event(self, envelope: Dict[str, Any]) -> Dict[str, Any]: + extracted = self._extract_message_payload(envelope) + if extracted is None: + return {} + + msg, space, _fmt = extracted + sender = msg.get("sender") or {} + if sender.get("type") == "BOT": + return {} + + msg_name = msg.get("name") or "" + if msg_name and self._dedup.is_duplicate(msg_name): + return {} + + 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 + + await self._dispatch_message(msg_with_space, enriched_env) + return {} + + 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() + if not token: + return False, "missing_google_bearer" + + try: + claims = _verify_google_id_token(token, self._http_events_audience) + except Exception as exc: + 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: + 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 payload to a MessageEvent and hand off. @@ -3032,6 +3117,12 @@ def _env_enablement() -> Optional[Dict[str, Any]]: seed["subscription_name"] = subscription if http_events_url: seed["http_events_url"] = http_events_url + http_events_audience = os.getenv("GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE") + if http_events_audience: + seed["http_events_audience"] = http_events_audience + http_events_sa_email = os.getenv("GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL") + if http_events_sa_email: + seed["http_events_service_account_email"] = http_events_sa_email sa_json = ( os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_JSON") or os.getenv("GOOGLE_APPLICATION_CREDENTIALS") diff --git a/plugins/platforms/google_chat/plugin.yaml b/plugins/platforms/google_chat/plugin.yaml index 6cf99cb8a0a0..d178a53085d4 100644 --- a/plugins/platforms/google_chat/plugin.yaml +++ b/plugins/platforms/google_chat/plugin.yaml @@ -23,6 +23,14 @@ optional_env: description: "Authenticated HTTP endpoint for Chat message events." prompt: "HTTP events callback URL" password: false + - name: GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE + description: "Expected audience for Google-signed HTTP event bearer tokens. Defaults to GOOGLE_CHAT_HTTP_EVENTS_URL." + prompt: "HTTP events token audience" + password: false + - name: GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL + description: "Expected Google service account email for HTTP event bearer tokens." + prompt: "HTTP events service account email" + password: false - name: GOOGLE_CHAT_PROJECT_ID description: "GCP project ID for optional Pub/Sub inbound mode. Falls back to GOOGLE_CLOUD_PROJECT." prompt: "GCP project ID" diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 1aed7455eef7..7341d12a4a3b 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -616,9 +616,28 @@ def _create_app(adapter: APIServerAdapter) -> web.Application: app.router.add_post("/v1/responses", adapter._handle_responses) app.router.add_get("/v1/responses/{response_id}", adapter._handle_get_response) app.router.add_delete("/v1/responses/{response_id}", adapter._handle_delete_response) + app.router.add_post( + "/api/platforms/{platform}/events", + adapter._handle_platform_event_callback, + ) return app +class _FakeGoogleChatAdapter: + def __init__(self, *, verify_ok: bool = True, verify_code: str = ""): + self.verify_ok = verify_ok + self.verify_code = verify_code + self.dispatched = [] + + def verify_http_event_request(self, auth_header: str): + self.auth_header = auth_header + return self.verify_ok, self.verify_code + + async def dispatch_http_event(self, payload): + self.dispatched.append(payload) + return {"ok": True} + + @pytest.fixture def adapter(): return _make_adapter() @@ -2768,6 +2787,81 @@ async def test_send_returns_not_supported(self): assert "HTTP request/response" in result.error +class TestPlatformEventCallbackEndpoint: + @pytest.mark.asyncio + async def test_dispatches_authorized_google_chat_event(self, adapter): + app = _create_app(adapter) + google_adapter = _FakeGoogleChatAdapter() + app["platform_event_adapters"] = {"google_chat": google_adapter} + + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/api/platforms/google_chat/events", + headers={"Authorization": "Bearer google-token"}, + json={"type": "MESSAGE", "message": {"text": "hi"}}, + ) + body = await resp.json() + + assert resp.status == 200 + assert body == {"ok": True} + assert google_adapter.auth_header == "Bearer google-token" + assert google_adapter.dispatched == [ + {"type": "MESSAGE", "message": {"text": "hi"}} + ] + + @pytest.mark.asyncio + async def test_rejects_invalid_google_chat_auth(self, adapter): + app = _create_app(adapter) + app["platform_event_adapters"] = { + "google_chat": _FakeGoogleChatAdapter( + verify_ok=False, + verify_code="invalid_google_bearer", + ) + } + + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/api/platforms/google_chat/events", + headers={"Authorization": "Bearer bad"}, + json={"type": "MESSAGE"}, + ) + body = await resp.json() + + assert resp.status == 401 + assert body["error"]["code"] == "invalid_google_bearer" + + @pytest.mark.asyncio + async def test_requires_connected_google_chat_adapter(self, adapter): + app = _create_app(adapter) + + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/api/platforms/google_chat/events", + headers={"Authorization": "Bearer google-token"}, + json={"type": "MESSAGE"}, + ) + body = await resp.json() + + assert resp.status == 503 + assert body["error"]["code"] == "platform_unavailable" + + @pytest.mark.asyncio + async def test_rejects_malformed_platform_event_json(self, adapter): + app = _create_app(adapter) + app["platform_event_adapters"] = {"google_chat": _FakeGoogleChatAdapter()} + + async with TestClient(TestServer(app)) as cli: + resp = await cli.post( + "/api/platforms/google_chat/events", + headers={"Authorization": "Bearer google-token"}, + data="{", + ) + body = await resp.json() + + assert resp.status == 400 + assert body["error"]["code"] == "invalid_json" + + # --------------------------------------------------------------------------- # GET /v1/responses/{response_id} # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index a0a7dc16217c..8c53b37d43da 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -253,6 +253,8 @@ class TestEnvConfigLoading: "GOOGLE_CHAT_SUBSCRIPTION_NAME", "GOOGLE_CHAT_SUBSCRIPTION", "GOOGLE_CHAT_HTTP_EVENTS_URL", + "GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE", + "GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL", "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CHAT_HOME_CHANNEL", @@ -283,10 +285,32 @@ def test_missing_project_does_not_enable(self, monkeypatch): def test_http_events_enable_without_pubsub(self, monkeypatch): self._clean_env(monkeypatch) - monkeypatch.setenv("GOOGLE_CHAT_HTTP_EVENTS_URL", "https://example.test/google-chat/events") + monkeypatch.setenv( + "GOOGLE_CHAT_HTTP_EVENTS_URL", + "https://example.test/google-chat/events", + ) + monkeypatch.setenv( + "GOOGLE_CHAT_HTTP_EVENTS_AUDIENCE", + "https://callback.example.test/events", + ) + monkeypatch.setenv( + "GOOGLE_CHAT_HTTP_EVENTS_SERVICE_ACCOUNT_EMAIL", + "chat-callback@example.iam.gserviceaccount.com", + ) cfg = load_gateway_config() assert _GC in cfg.platforms - assert cfg.platforms[_GC].extra["http_events_url"] == "https://example.test/google-chat/events" + assert ( + cfg.platforms[_GC].extra["http_events_url"] + == "https://example.test/google-chat/events" + ) + assert ( + cfg.platforms[_GC].extra["http_events_audience"] + == "https://callback.example.test/events" + ) + assert ( + cfg.platforms[_GC].extra["http_events_service_account_email"] + == "chat-callback@example.iam.gserviceaccount.com" + ) # =========================================================================== @@ -413,6 +437,82 @@ def test_full_subscription_can_infer_project(self): assert sub == "projects/inferred/subscriptions/sub" +class TestHttpEventIngress: + def test_verify_http_event_request_accepts_expected_google_identity(self, monkeypatch): + cfg = PlatformConfig(enabled=True) + cfg.extra.update( + { + "http_events_url": "https://example.test/google-chat/events", + "http_events_service_account_email": ( + "chat-callback@example.iam.gserviceaccount.com" + ), + } + ) + a = GoogleChatAdapter(cfg) + + def fake_verify(token, audience): + assert token == "signed-token" + assert audience == "https://example.test/google-chat/events" + return {"email": "chat-callback@example.iam.gserviceaccount.com"} + + monkeypatch.setattr(_gc_mod, "_verify_google_id_token", fake_verify) + + assert a.verify_http_event_request("Bearer signed-token") == (True, "") + + def test_verify_http_event_request_rejects_unexpected_identity(self, monkeypatch): + cfg = PlatformConfig(enabled=True) + cfg.extra.update( + { + "http_events_url": "https://example.test/google-chat/events", + "http_events_service_account_email": ( + "expected@example.iam.gserviceaccount.com" + ), + } + ) + a = GoogleChatAdapter(cfg) + monkeypatch.setattr( + _gc_mod, + "_verify_google_id_token", + lambda _token, _audience: { + "email": "other@example.iam.gserviceaccount.com" + }, + ) + + ok, code = a.verify_http_event_request("Bearer signed-token") + + assert ok is False + assert code == "unexpected_google_bearer_identity" + + def test_verify_http_event_request_requires_callback_identity_config(self): + cfg = PlatformConfig(enabled=True) + cfg.extra["http_events_url"] = "https://example.test/google-chat/events" + a = GoogleChatAdapter(cfg) + + assert a.verify_http_event_request("Bearer signed-token") == ( + False, + "google_chat_http_events_not_configured", + ) + + @pytest.mark.asyncio + async def test_dispatch_http_event_routes_message_payload(self, adapter): + envelope = _make_chat_envelope(text="hello from http") + + result = await adapter.dispatch_http_event(envelope) + + assert result == {} + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "hello from http" + assert event.source.chat_id == "spaces/S" + + @pytest.mark.asyncio + async def test_dispatch_http_event_ignores_bot_messages(self, adapter): + envelope = _make_chat_envelope(text="bot echo", sender_type="BOT") + + assert await adapter.dispatch_http_event(envelope) == {} + adapter.handle_message.assert_not_awaited() + + class TestConnectModes: @pytest.mark.asyncio async def test_connect_http_mode_skips_pubsub_subscriber(self, tmp_path, monkeypatch): From e724b53286bc435ac542b43c79aee143f1f9bfdf Mon Sep 17 00:00:00 2001 From: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com> Date: Sun, 31 May 2026 20:31:12 +0000 Subject: [PATCH 3/8] feat(google-chat): render clarify prompts as cards --- plugins/platforms/google_chat/adapter.py | 244 +++++++++++++++++++++++ tests/gateway/test_google_chat.py | 83 ++++++++ 2 files changed, 327 insertions(+) diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 8fc540894989..fbd6e9853419 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -200,6 +200,17 @@ def _load_google_modules() -> bool: _RETRY_MAX_DELAY = 8.0 _RETRY_JITTER = 0.3 _RETRYABLE_HTTP_STATUSES = frozenset({429, 500, 502, 503, 504}) +_CARD_WIDGET_TYPES = frozenset({ + "text", + "text_paragraph", + "decorated_text", + "buttons", + "button_list", + "selection", + "selection_input", + "image", + "divider", +}) def _is_retryable_error(exc: BaseException) -> bool: @@ -330,6 +341,136 @@ def _mime_for_message_type(mime: str) -> MessageType: return MessageType.DOCUMENT +def _required_str(mapping: Dict[str, Any], key: str, context: str) -> str: + value = mapping.get(key) + if value is None: + raise ValueError(f"{context}.{key} is required") + value = str(value).strip() + if not value: + raise ValueError(f"{context}.{key} is required") + return value + + +def _button_to_chat(button: Dict[str, Any]) -> Dict[str, Any]: + text = _required_str(button, "text", "button") + action = _required_str(button, "action", "button") + raw_params = button.get("parameters") or {} + if not isinstance(raw_params, dict): + raise ValueError("button.parameters must be an object") + parameters = [ + {"key": str(key), "value": str(value)} + for key, value in sorted(raw_params.items()) + ] + return { + "text": text, + "onClick": {"action": {"function": action, "parameters": parameters}}, + } + + +def _widget_to_chat(widget: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(widget, dict): + raise ValueError("card widgets must be objects") + widget_type = str(widget.get("type") or "").strip() + if widget_type not in _CARD_WIDGET_TYPES: + raise ValueError(f"unsupported widget type: {widget_type or ''}") + + if widget_type in {"text", "text_paragraph"}: + return { + "textParagraph": { + "text": GoogleChatAdapter.format_message( + _required_str(widget, "text", "widget") + ) + } + } + if widget_type == "decorated_text": + decorated: Dict[str, Any] = { + "text": GoogleChatAdapter.format_message( + _required_str(widget, "text", "widget") + ), + "wrapText": bool(widget.get("wrap_text", True)), + } + if widget.get("top_label"): + decorated["topLabel"] = str(widget["top_label"]) + if widget.get("bottom_label"): + decorated["bottomLabel"] = str(widget["bottom_label"]) + return {"decoratedText": decorated} + if widget_type == "divider": + return {"divider": {}} + if widget_type == "image": + image = {"imageUrl": _required_str(widget, "image_url", "widget")} + if widget.get("alt_text"): + image["altText"] = str(widget["alt_text"]) + return {"image": image} + if widget_type in {"buttons", "button_list"}: + raw_buttons = widget.get("buttons") or [] + if not isinstance(raw_buttons, list) or not raw_buttons: + raise ValueError("button widgets require at least one button") + return {"buttonList": {"buttons": [_button_to_chat(btn) for btn in raw_buttons]}} + if widget_type in {"selection", "selection_input"}: + name = _required_str(widget, "name", "widget") + raw_items = widget.get("items") or [] + if not isinstance(raw_items, list) or not raw_items: + raise ValueError("selection widgets require at least one item") + items: List[Dict[str, Any]] = [] + for item in raw_items: + if not isinstance(item, dict): + raise ValueError("selection items must be objects") + items.append({ + "text": _required_str(item, "text", "selection item"), + "value": _required_str(item, "value", "selection item"), + "selected": bool(item.get("selected", False)), + }) + return { + "selectionInput": { + "name": name, + "label": str(widget.get("label") or name), + "type": str(widget.get("selection_type") or "CHECK_BOX"), + "items": items, + } + } + raise ValueError(f"unsupported widget type: {widget_type}") + + +def card_spec_to_cards_v2(card_spec: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(card_spec, dict): + raise ValueError("card must be an object") + + raw_sections = card_spec.get("sections") or [] + if not isinstance(raw_sections, list) or not raw_sections: + raise ValueError("card.sections must contain at least one section") + + sections: List[Dict[str, Any]] = [] + for section in raw_sections: + if not isinstance(section, dict): + raise ValueError("card sections must be objects") + widgets = section.get("widgets") or [] + if not isinstance(widgets, list) or not widgets: + raise ValueError("card section widgets must contain at least one widget") + rendered: Dict[str, Any] = {"widgets": [_widget_to_chat(w) for w in widgets]} + if section.get("header"): + rendered["header"] = str(section["header"]) + sections.append(rendered) + + card: Dict[str, Any] = {"sections": sections} + header = card_spec.get("header") + if header: + if not isinstance(header, dict): + raise ValueError("card.header must be an object") + rendered_header: Dict[str, Any] = { + "title": _required_str(header, "title", "card.header") + } + if header.get("subtitle"): + rendered_header["subtitle"] = str(header["subtitle"]) + if header.get("image_url"): + rendered_header["imageUrl"] = str(header["image_url"]) + rendered_header["imageType"] = str(header.get("image_type") or "SQUARE") + if header.get("image_alt_text"): + rendered_header["imageAltText"] = str(header["image_alt_text"]) + card["header"] = rendered_header + + return {"cardId": str(card_spec.get("card_id") or "hermes-card"), "card": card} + + class _ThreadCountStore: """Per-(chat_id, thread_name) inbound message counter, persisted to disk. @@ -516,6 +657,7 @@ def __init__(self, config: PlatformConfig): 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-seen inbound thread name per chat_id (space). Google Chat @@ -1973,6 +2115,108 @@ async def send( finally: self.resume_typing_for_chat(chat_id) + async def send_card( + self, + chat_id: str, + card: Dict[str, Any], + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + body: Dict[str, Any] = {"cardsV2": [card]} + thread_id = self._resolve_thread_id(None, metadata, chat_id=chat_id) + if thread_id: + body["thread"] = {"name": thread_id} + try: + result = await self._create_message(chat_id, body) + result.raw_response = result.raw_response or {"cardsV2": body["cardsV2"]} + return result + except HttpError as exc: + status = getattr(getattr(exc, "resp", None), "status", None) + return SendResult( + success=False, + error=_redact_sensitive(str(exc)), + retryable=status 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), + ) + + async def send_clarify( + self, + chat_id: str, + question: str, + choices: Optional[list], + clarify_id: str, + session_key: str, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + if not choices: + return await super().send_clarify( + chat_id, question, choices, clarify_id, session_key, metadata + ) + + 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 not buttons: + return await super().send_clarify( + chat_id, question, choices, clarify_id, session_key, metadata + ) + + 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}, + ] + } + ], + } + ) + result = await self.send_card(chat_id, card, metadata=metadata) + if result.success: + self._clarify_state[clarify_id] = session_key + try: + from tools.clarify_gateway import mark_awaiting_text + + mark_awaiting_text(clarify_id) + except Exception as exc: + logger.warning("[GoogleChat] mark_awaiting_text failed: %s", exc) + return result + return await super().send_clarify( + chat_id, question, choices, clarify_id, session_key, metadata + ) + async def edit_message( self, chat_id: str, diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index 8c53b37d43da..14d8726146f7 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -135,6 +135,7 @@ def _ensure_google_mocks(): _is_google_owned_host, _mime_for_message_type, _redact_sensitive, + card_spec_to_cards_v2, check_google_chat_requirements, ) @@ -1151,6 +1152,88 @@ async def test_429_increments_rate_limit_counter_and_raises(self, adapter): await adapter.send("spaces/S", "hola") assert adapter._rate_limit_hits.get("spaces/S") == 1 + def test_card_spec_to_cards_v2_builds_button_card(self): + card = card_spec_to_cards_v2( + { + "card_id": "approval", + "header": {"title": "Approve request"}, + "sections": [ + { + "widgets": [ + {"type": "text", "text": "Pick one"}, + { + "type": "buttons", + "buttons": [ + { + "text": "Yes", + "action": "approve", + "parameters": {"choice": "yes"}, + } + ], + }, + ] + } + ], + } + ) + + assert card["cardId"] == "approval" + assert card["card"]["header"]["title"] == "Approve request" + button = card["card"]["sections"][0]["widgets"][1]["buttonList"]["buttons"][0] + assert button["text"] == "Yes" + assert button["onClick"]["action"]["function"] == "approve" + assert {"key": "choice", "value": "yes"} in button["onClick"]["action"]["parameters"] + + @pytest.mark.asyncio + async def test_send_card_posts_cards_v2_with_thread(self, adapter): + adapter._create_message = AsyncMock( + return_value=type( + "R", + (), + {"success": True, "message_id": "m/1", "error": None, "raw_response": None}, + )() + ) + + result = await adapter.send_card( + "spaces/S", + {"cardId": "c1", "card": {"sections": [{"widgets": []}]}}, + metadata={"thread_id": "spaces/S/threads/T"}, + ) + + assert result.success is True + body = adapter._create_message.await_args.args[1] + assert body["cardsV2"][0]["cardId"] == "c1" + assert body["thread"] == {"name": "spaces/S/threads/T"} + + @pytest.mark.asyncio + async def test_send_clarify_posts_choice_card(self, adapter): + adapter._create_message = AsyncMock( + return_value=type( + "R", + (), + {"success": True, "message_id": "m/1", "error": None, "raw_response": None}, + )() + ) + + result = await adapter.send_clarify( + "spaces/S", + "Pick a demo", + ["Simple", "Capability test"], + "clarify123", + "session-key", + ) + + assert result.success is True + body = adapter._create_message.await_args.args[1] + card = body["cardsV2"][0] + assert card["cardId"] == "clarify-clarify123" + buttons = card["card"]["sections"][0]["widgets"][1]["buttonList"]["buttons"] + assert buttons[0]["text"] == "Simple" + assert buttons[0]["onClick"]["action"]["function"] == "hermes_clarify" + assert {"key": "choice", "value": "Simple"} in buttons[0]["onClick"]["action"]["parameters"] + assert buttons[-1]["text"] == "Other / type answer" + assert adapter._clarify_state["clarify123"] == "session-key" + # =========================================================================== # send_typing / stop_typing From 65e2a1d7df261ffadfa4212e026375b794c146e1 Mon Sep 17 00:00:00 2001 From: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com> Date: Sun, 31 May 2026 20:34:19 +0000 Subject: [PATCH 4/8] feat(google-chat): handle card click callbacks --- plugins/platforms/google_chat/adapter.py | 305 ++++++++++++++++++++++- tests/gateway/test_google_chat.py | 162 ++++++++++++ 2 files changed, 460 insertions(+), 7 deletions(-) diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index fbd6e9853419..62db02ecbf41 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -30,9 +30,10 @@ Event type routing ------------------ Inbound envelope carries ``type`` in [MESSAGE, ADDED_TO_SPACE, REMOVED_FROM_SPACE, -CARD_CLICKED]. Only MESSAGE dispatches to the agent. ADDED_TO_SPACE caches the +CARD_CLICKED]. MESSAGE dispatches to the agent. ADDED_TO_SPACE caches the bot's resource name (belt-and-suspenders on top of eager resolution in connect()). -CARD_CLICKED is ACK'd only in v1 (follow-up PR implements interactivity). +CARD_CLICKED resolves built-in clarify prompts or dispatches a synthesized action +event to the agent. """ from __future__ import annotations @@ -341,6 +342,155 @@ def _mime_for_message_type(mime: str) -> MessageType: return MessageType.DOCUMENT +def _card_click_action_name(payload: Dict[str, Any]) -> str: + action = payload.get("action") or {} + common = payload.get("common") or {} + params = _card_click_parameters(payload) + return str( + action.get("actionMethodName") + or common.get("invokedFunction") + or params.get("__action_method_name__") + or params.get("action") + or params.get("method") + or params.get("action_method_name") + or action.get("methodName") + or action.get("function") + or "" + ).strip() + + +def _card_click_parameters(payload: Dict[str, Any]) -> Dict[str, str]: + action = payload.get("action") or {} + raw_params = action.get("parameters") or [] + if isinstance(raw_params, dict): + raw_params = [{"key": key, "value": value} for key, value in raw_params.items()] + + params: Dict[str, str] = {} + common = payload.get("common") or {} + common_params = common.get("parameters") or {} + if isinstance(common_params, dict): + for key, value in common_params.items(): + key = str(key).strip() + if key: + params[key] = str(value) + + for item in raw_params: + if not isinstance(item, dict): + continue + key = str(item.get("key") or item.get("name") or "").strip() + if key: + params[key] = str(item.get("value", "")) + return params + + +def _card_click_form_inputs(payload: Dict[str, Any]) -> Dict[str, List[str]]: + common = payload.get("common") or {} + raw_inputs = common.get("formInputs") or {} + if not isinstance(raw_inputs, dict): + return {} + + selections: Dict[str, List[str]] = {} + for name, value in raw_inputs.items(): + if not isinstance(value, dict): + continue + selected: List[str] = [] + for input_key in ("stringInputs", "dateTimeInput", "dateInput", "timeInput"): + input_value = value.get(input_key) + if not isinstance(input_value, dict): + continue + raw_values = input_value.get("value") + if raw_values is None: + raw_values = [ + input_value.get("msSinceEpoch"), + input_value.get("hours"), + input_value.get("minutes"), + ] + if not isinstance(raw_values, list): + raw_values = [raw_values] + selected.extend(str(item) for item in raw_values if item is not None) + if selected: + selections[str(name)] = selected + return selections + + +def _synthesize_card_click_text(payload: Dict[str, Any]) -> str: + action_name = _card_click_action_name(payload) + params = _card_click_parameters(payload) + selections = _card_click_form_inputs(payload) + if not action_name and not params and not selections: + return "" + + lines = ["Google Chat card click"] + if action_name: + lines.append(f"action: {action_name}") + if params: + lines.append("parameters:") + for key in sorted(params): + lines.append(f"- {key}: {params[key]}") + if selections: + lines.append("selections:") + for key in sorted(selections): + lines.append(f"- {key}: {', '.join(selections[key])}") + return "\n".join(lines) + + +def _extract_card_clicked_payload( + envelope: Dict[str, Any], ce_type: str = "" +) -> Optional[Dict[str, Any]]: + chat_payload = (envelope.get("chat") or {}).get("cardClickedPayload") + if isinstance(chat_payload, dict): + return chat_payload + event_type = str(envelope.get("type") or "").upper() + at_type = str(envelope.get("@type") or "") + if ( + event_type == "CARD_CLICKED" + or "card" in ce_type.lower() + or "widget" in ce_type.lower() + or "CardClicked" in at_type + ): + return envelope + return None + + +def _addon_event_to_card_click_payload(event: Dict[str, Any]) -> Dict[str, Any]: + common = event.get("commonEventObject") or event.get("common") or {} + chat = event.get("chat") or {} + payload = chat.get("buttonClickedPayload") or event.get("buttonClickedPayload") or {} + if not isinstance(payload, dict): + payload = {} + + normalized: Dict[str, Any] = dict(payload) + normalized["type"] = "CARD_CLICKED" + + common_params = common.get("parameters") if isinstance(common, dict) else {} + if isinstance(common_params, dict): + existing_common = ( + normalized.get("common") if isinstance(normalized.get("common"), dict) else {} + ) + merged_common = dict(existing_common) + merged_params = dict(merged_common.get("parameters") or {}) + merged_params.update(common_params) + merged_common["parameters"] = merged_params + if common.get("invokedFunction"): + merged_common["invokedFunction"] = common.get("invokedFunction") + normalized["common"] = merged_common + + for key in ("space", "message", "user"): + if normalized.get(key): + continue + value = chat.get(key) or event.get(key) + if isinstance(value, dict): + normalized[key] = value + + message_payload = chat.get("messagePayload") + if not normalized.get("message") and isinstance(message_payload, dict): + message = message_payload.get("message") + if isinstance(message, dict): + normalized["message"] = message + + return normalized + + def _required_str(mapping: Dict[str, Any], key: str, context: str) -> str: value = mapping.get(key) if value is None: @@ -1397,11 +1547,10 @@ def _on_pubsub_message(self, message: Any) -> None: message.ack() return - # --- Card-click events (v2 follow-up) --- - if "widget" in ce_type or "card" in ce_type.lower(): - logger.info( - "[GoogleChat] Card/widget event ack'd (v2 feature, deferred)" - ) + # --- Card-click events --- + card_payload = _extract_card_clicked_payload(envelope, ce_type) + if card_payload is not None: + self._submit_on_loop(self._dispatch_card_click(card_payload)) message.ack() return @@ -1452,6 +1601,15 @@ def _on_pubsub_message(self, message: Any) -> None: pass async def dispatch_http_event(self, envelope: Dict[str, Any]) -> Dict[str, Any]: + if isinstance(envelope.get("commonEventObject"), dict): + await self._dispatch_card_click(_addon_event_to_card_click_payload(envelope)) + return {} + + card_payload = _extract_card_clicked_payload(envelope) + if card_payload is not None: + await self._dispatch_card_click(card_payload) + return {} + extracted = self._extract_message_payload(envelope) if extracted is None: return {} @@ -1507,6 +1665,139 @@ def verify_http_event_request(self, auth_header: str) -> Tuple[bool, str]: return True, "" + async def _dispatch_card_click(self, payload: Dict[str, Any]) -> None: + synthesized = _synthesize_card_click_text(payload) + if not synthesized: + logger.debug("[GoogleChat] CARD_CLICKED ignored without action context") + return + + message = payload.get("message") or {} + message_name = message.get("name", "") or "" + user = payload.get("user") or message.get("sender") or {} + user_key = user.get("email") or user.get("name") or "" + dedup_key = ( + f"{message_name}:card_click:{user_key}:{synthesized}" + if message_name + else "" + ) + if dedup_key and self._dedup.is_duplicate(dedup_key): + return + + space = payload.get("space") or message.get("space") or {} + thread = message.get("thread") or {} + space_name = space.get("name") or "" + space_type = (space.get("type") or space.get("spaceType") or "").upper() + chat_type = "dm" if space_type in {"DIRECT_MESSAGE", "DM"} else "group" + thread_name = thread.get("name") or None + if chat_type == "dm": + is_side_thread = ( + thread_name is not None + and self._thread_count_store.get(space_name, thread_name) > 1 + ) + session_thread_id = thread_name if is_side_thread else None + if thread_name and is_side_thread: + self._last_inbound_thread[space_name] = thread_name + elif space_name: + self._last_inbound_thread.pop(space_name, None) + else: + session_thread_id = None + if space_name: + self._last_inbound_thread.pop(space_name, None) + + source = self.build_source( + chat_id=space_name, + chat_name=space.get("displayName") or space.get("name") or "", + chat_type=chat_type, + user_id=(user.get("email") or user.get("name") or ""), + user_name=( + user.get("displayName") + or user.get("email") + or user.get("name") + or "" + ), + thread_id=session_thread_id, + user_id_alt=(user.get("name") or None), + ) + params = _card_click_parameters(payload) + if _card_click_action_name(payload) == "hermes_clarify": + await self._dispatch_clarify_card_click( + source=source, + clarify_id=params.get("clarify_id", ""), + choice=params.get("choice", ""), + ) + return + + event = MessageEvent( + message_id=message_name or f"card_click:{user_key}:{hash(synthesized)}", + source=source, + message_type=MessageType.COMMAND, + text=synthesized, + raw_message={"google_chat_card_click": payload}, + ) + await self.handle_message(event) + + async def _dispatch_clarify_card_click( + self, + *, + source: Any, + clarify_id: str, + choice: str, + ) -> None: + if not clarify_id: + return + runner = getattr(getattr(self, "_message_handler", None), "__self__", None) + auth_fn = getattr(runner, "_is_user_authorized", None) + if callable(auth_fn) and not auth_fn(source): + await self.send( + source.chat_id, + "⛔ You are not authorized to answer this prompt.", + metadata={"thread_id": source.thread_id} if source.thread_id else None, + ) + return + + if clarify_id not in self._clarify_state: + await self.send( + source.chat_id, + "This prompt has already been resolved.", + metadata={"thread_id": source.thread_id} if source.thread_id else None, + ) + return + + if choice == "__other__": + try: + from tools.clarify_gateway import mark_awaiting_text + + mark_awaiting_text(clarify_id) + except Exception as exc: + logger.warning("[GoogleChat] mark_awaiting_text failed: %s", exc) + await self.send( + source.chat_id, + "✏️ Type your answer in the chat.", + metadata={"thread_id": source.thread_id} if source.thread_id else None, + ) + return + + try: + from tools.clarify_gateway import resolve_gateway_clarify + + resolved = resolve_gateway_clarify(clarify_id, choice) + except Exception as exc: + logger.error("[GoogleChat] resolve_gateway_clarify failed: %s", exc) + resolved = False + self._clarify_state.pop(clarify_id, None) + if resolved: + await self.send( + source.chat_id, + f"✓ {choice[:80]}", + metadata={"thread_id": source.thread_id} if source.thread_id else None, + ) + else: + await self.send( + source.chat_id, + "This prompt has already been resolved.", + metadata={"thread_id": source.thread_id} if source.thread_id else None, + ) + async def _dispatch_message(self, msg: Dict[str, Any], envelope: Dict[str, Any]) -> None: """Translate a Chat message payload to a MessageEvent and hand off. diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index 14d8726146f7..a789f6dd8979 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -132,9 +132,13 @@ def _ensure_google_mocks(): from gateway.platforms.base import MessageEvent, MessageType, ProcessingOutcome # noqa: E402 from plugins.platforms.google_chat.adapter import ( # noqa: E402 GoogleChatAdapter, + _addon_event_to_card_click_payload, + _card_click_action_name, + _card_click_parameters, _is_google_owned_host, _mime_for_message_type, _redact_sensitive, + _synthesize_card_click_text, card_spec_to_cards_v2, check_google_chat_requirements, ) @@ -514,6 +518,164 @@ async def test_dispatch_http_event_ignores_bot_messages(self, adapter): adapter.handle_message.assert_not_awaited() +class TestCardClickCallbacks: + def test_card_click_helpers_extract_action_and_parameters(self): + payload = { + "action": { + "function": "custom_action", + "parameters": [ + {"key": "choice", "value": "A"}, + {"name": "extra", "value": "B"}, + ], + }, + "common": { + "formInputs": { + "field": {"stringInputs": {"value": ["x", "y"]}}, + } + }, + } + + assert _card_click_action_name(payload) == "custom_action" + assert _card_click_parameters(payload)["choice"] == "A" + text = _synthesize_card_click_text(payload) + assert "Google Chat card click" in text + assert "action: custom_action" in text + assert "- field: x, y" in text + + def test_addon_event_normalizes_button_payload(self): + payload = _addon_event_to_card_click_payload( + { + "commonEventObject": { + "invokedFunction": "hermes_clarify", + "parameters": {"clarify_id": "c1", "choice": "A"}, + }, + "chat": { + "space": {"name": "spaces/S", "spaceType": "DIRECT_MESSAGE"}, + "messagePayload": { + "message": {"name": "spaces/S/messages/M.M"} + }, + "user": {"name": "users/123", "email": "u@example.com"}, + }, + } + ) + + assert payload["type"] == "CARD_CLICKED" + assert payload["common"]["invokedFunction"] == "hermes_clarify" + assert payload["common"]["parameters"]["choice"] == "A" + assert payload["space"]["name"] == "spaces/S" + assert payload["message"]["name"] == "spaces/S/messages/M.M" + + @pytest.mark.asyncio + async def test_dispatch_card_click_as_message_event(self, adapter): + payload = { + "space": {"name": "spaces/S", "spaceType": "SPACE", "displayName": "Room"}, + "user": { + "name": "users/123", + "email": "u@example.com", + "displayName": "User", + }, + "message": {"name": "spaces/S/messages/CARD.CARD"}, + "action": { + "function": "custom_action", + "parameters": [{"key": "choice", "value": "A"}], + }, + } + + await adapter._dispatch_card_click(payload) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text.startswith("Google Chat card click") + assert "custom_action" in event.text + assert event.source.chat_id == "spaces/S" + assert event.source.user_id == "u@example.com" + + @pytest.mark.asyncio + async def test_dispatch_http_event_resolves_clarify_card_click(self, adapter): + from tools.clarify_gateway import register, wait_for_response + + clarify_id = "clarify123" + session_key = "agent:main:google_chat:dm:spaces/S" + register( + clarify_id=clarify_id, + session_key=session_key, + question="Pick one", + choices=["A", "B"], + ) + adapter._clarify_state[clarify_id] = session_key + adapter._create_message = AsyncMock( + return_value=type( + "R", + (), + {"success": True, "message_id": "m/ack", "error": None}, + )() + ) + + await adapter.dispatch_http_event( + { + "commonEventObject": { + "invokedFunction": "hermes_clarify", + "parameters": {"clarify_id": clarify_id, "choice": "B"}, + }, + "chat": { + "space": {"name": "spaces/S", "spaceType": "DIRECT_MESSAGE"}, + "messagePayload": { + "message": {"name": "spaces/S/messages/CARD.CARD"} + }, + "user": {"name": "users/123", "email": "u@example.com"}, + }, + } + ) + + assert wait_for_response(clarify_id, timeout=0.1) == "B" + assert clarify_id not in adapter._clarify_state + adapter.handle_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_unauthorized_clarify_card_click_does_not_resolve(self, adapter): + from tools.clarify_gateway import register, wait_for_response + + class Runner: + def _is_user_authorized(self, _source): + return False + + async def handle(self, _event): + return None + + clarify_id = "clarify-denied" + register( + clarify_id=clarify_id, + session_key="agent:main:google_chat:dm:spaces/S", + question="Pick one", + choices=["A", "B"], + ) + adapter._clarify_state[clarify_id] = "agent:main:google_chat:dm:spaces/S" + adapter._message_handler = Runner().handle + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m/deny", "error": None})() + ) + + await adapter._dispatch_card_click( + { + "space": {"name": "spaces/S", "spaceType": "DIRECT_MESSAGE"}, + "user": {"name": "users/999", "email": "blocked@example.com"}, + "message": {"name": "spaces/S/messages/CARD.CARD"}, + "action": { + "function": "hermes_clarify", + "parameters": [ + {"key": "clarify_id", "value": clarify_id}, + {"key": "choice", "value": "A"}, + ], + }, + } + ) + + assert wait_for_response(clarify_id, timeout=0.1) is None + assert clarify_id in adapter._clarify_state + sent = adapter._create_message.await_args.args[1]["text"] + assert "not authorized" in sent + + class TestConnectModes: @pytest.mark.asyncio async def test_connect_http_mode_skips_pubsub_subscriber(self, tmp_path, monkeypatch): From 093b05dfb300a767eba98e2e8101af971e306a42 Mon Sep 17 00:00:00 2001 From: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:49:19 -0400 Subject: [PATCH 5/8] fix(google-chat): fail-closed card-click authorization Add _is_callback_user_authorized mirroring Telegram's contract: when the runner _is_user_authorized path is unavailable, fall back to the GOOGLE_CHAT_ALLOWED_USERS allowlist (comma-separated emails, '*' wildcard) and GATEWAY_ALLOW_ALL_USERS, denying by default instead of silently letting any card click resolve a clarify/approval. Closes the fail-open gap where a missing auth_fn let unauthorized clicks through. --- c1.html | 4328 +++++++++++++++++ ..._019f181c-fbf2-7f66-bc3a-9f292111fef7.html | 4328 +++++++++++++++++ plugins/platforms/google_chat/adapter.py | 35 +- tests/gateway/test_google_chat.py | 45 + 4 files changed, 8733 insertions(+), 3 deletions(-) create mode 100644 c1.html create mode 100644 pi-session-2026-06-30T10-39-40-530Z_019f181c-fbf2-7f66-bc3a-9f292111fef7.html diff --git a/c1.html b/c1.html new file mode 100644 index 000000000000..e8de5ad3ed2f --- /dev/null +++ b/c1.html @@ -0,0 +1,4328 @@ + + + + + + Session Export + + + + + +
+ + +
+
+
+
+
+ +
+
+ + + + + + + + + + + + + diff --git a/pi-session-2026-06-30T10-39-40-530Z_019f181c-fbf2-7f66-bc3a-9f292111fef7.html b/pi-session-2026-06-30T10-39-40-530Z_019f181c-fbf2-7f66-bc3a-9f292111fef7.html new file mode 100644 index 000000000000..e8de5ad3ed2f --- /dev/null +++ b/pi-session-2026-06-30T10-39-40-530Z_019f181c-fbf2-7f66-bc3a-9f292111fef7.html @@ -0,0 +1,4328 @@ + + + + + + Session Export + + + + + +
+ + +
+
+
+
+
+ +
+
+ + + + + + + + + + + + + diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 62db02ecbf41..f68735e6c768 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -1736,6 +1736,37 @@ async def _dispatch_card_click(self, payload: Dict[str, Any]) -> None: ) await self.handle_message(event) + def _is_callback_user_authorized(self, source: Any) -> bool: + """Return whether a card-click caller may resolve a clarify/approval. + + Fail-closed: if the runner auth path is unavailable, fall back to the + GOOGLE_CHAT_ALLOWED_USERS allowlist and GATEWAY_ALLOW_ALL_USERS. With + no allowlist and no allow-all flag, deny by default — never silently + let an unconfigured adapter authorize a gated action (cf. #24457). + """ + caller_id = str(getattr(source, "user_id", "") or "").strip() + if not caller_id: + return False + + runner = getattr(getattr(self, "_message_handler", None), "__self__", None) + auth_fn = getattr(runner, "_is_user_authorized", None) + if callable(auth_fn): + try: + return bool(auth_fn(source)) + except Exception: + logger.debug( + "[GoogleChat] Falling back to env-only callback auth for %s", + caller_id, + exc_info=True, + ) + + allowed_csv = os.getenv("GOOGLE_CHAT_ALLOWED_USERS", "").strip() + if not allowed_csv: + # Fail-closed: no allowlist means deny by default. + return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} + allowed = {uid.strip().lower() for uid in allowed_csv.split(",") if uid.strip()} + return "*" in allowed or caller_id.lower() in allowed + async def _dispatch_clarify_card_click( self, *, @@ -1745,9 +1776,7 @@ async def _dispatch_clarify_card_click( ) -> None: if not clarify_id: return - runner = getattr(getattr(self, "_message_handler", None), "__self__", None) - auth_fn = getattr(runner, "_is_user_authorized", None) - if callable(auth_fn) and not auth_fn(source): + if not self._is_callback_user_authorized(source): await self.send( source.chat_id, "⛔ You are not authorized to answer this prompt.", diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index a789f6dd8979..78762d6884b9 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -675,6 +675,51 @@ async def handle(self, _event): sent = adapter._create_message.await_args.args[1]["text"] assert "not authorized" in sent + @pytest.mark.asyncio + async def test_clarify_card_click_fails_closed_without_runner(self, adapter, monkeypatch): + # No runner wired → _is_callback_user_authorized must fall back to the + # env allowlist and deny by default (no silent authorize on a gated + # action). Regression for the fail-open bug where a missing auth_fn + # let any card click through. + from tools.clarify_gateway import register, wait_for_response + + monkeypatch.delenv("GOOGLE_CHAT_ALLOWED_USERS", raising=False) + monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False) + + clarify_id = "clarify-no-runner" + register( + clarify_id=clarify_id, + session_key="agent:main:google_chat:dm:spaces/S", + question="Pick one", + choices=["A", "B"], + ) + adapter._clarify_state[clarify_id] = "agent:main:google_chat:dm:spaces/S" + # No _message_handler → no runner → auth_fn absent. + adapter._message_handler = None + adapter._create_message = AsyncMock( + return_value=type("R", (), {"success": True, "message_id": "m/deny", "error": None})() + ) + + await adapter._dispatch_card_click( + { + "space": {"name": "spaces/S", "spaceType": "DIRECT_MESSAGE"}, + "user": {"name": "users/999", "email": "sneaky@example.com"}, + "message": {"name": "spaces/S/messages/CARD.CARD"}, + "action": { + "function": "hermes_clarify", + "parameters": [ + {"key": "clarify_id", "value": clarify_id}, + {"key": "choice", "value": "A"}, + ], + }, + } + ) + + assert wait_for_response(clarify_id, timeout=0.1) is None + assert clarify_id in adapter._clarify_state + sent = adapter._create_message.await_args.args[1]["text"] + assert "not authorized" in sent + class TestConnectModes: @pytest.mark.asyncio From c2636f68c3a06b397f5d84576fa9bbc014063e17 Mon Sep 17 00:00:00 2001 From: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:50:20 -0400 Subject: [PATCH 6/8] chore: drop stray session scratch files from PR branch --- c1.html | 4328 ----------------- ..._019f181c-fbf2-7f66-bc3a-9f292111fef7.html | 4328 ----------------- 2 files changed, 8656 deletions(-) delete mode 100644 c1.html delete mode 100644 pi-session-2026-06-30T10-39-40-530Z_019f181c-fbf2-7f66-bc3a-9f292111fef7.html diff --git a/c1.html b/c1.html deleted file mode 100644 index e8de5ad3ed2f..000000000000 --- a/c1.html +++ /dev/null @@ -1,4328 +0,0 @@ - - - - - - Session Export - - - - - -
- - -
-
-
-
-
- -
-
- - - - - - - - - - - - - diff --git a/pi-session-2026-06-30T10-39-40-530Z_019f181c-fbf2-7f66-bc3a-9f292111fef7.html b/pi-session-2026-06-30T10-39-40-530Z_019f181c-fbf2-7f66-bc3a-9f292111fef7.html deleted file mode 100644 index e8de5ad3ed2f..000000000000 --- a/pi-session-2026-06-30T10-39-40-530Z_019f181c-fbf2-7f66-bc3a-9f292111fef7.html +++ /dev/null @@ -1,4328 +0,0 @@ - - - - - - Session Export - - - - - -
- - -
-
-
-
-
- -
-
- - - - - - - - - - - - - From ec76ec7a0a3fcbd0041bc361c140f70f88b7b10e Mon Sep 17 00:00:00 2001 From: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:53:58 -0400 Subject: [PATCH 7/8] fix(google-chat): don't flip clarify to text-capture at send time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mark_awaiting_text is the 'Other (type answer)' mode-flip; calling it in send_clarify captures the user's next message as the clarify response, racing the button-click path. It is already called in the __other__ branch of _dispatch_clarify_card_click — drop the send-time duplicate. --- plugins/platforms/google_chat/adapter.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index f68735e6c768..016365fc53c5 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -2526,12 +2526,6 @@ async def send_clarify( result = await self.send_card(chat_id, card, metadata=metadata) if result.success: self._clarify_state[clarify_id] = session_key - try: - from tools.clarify_gateway import mark_awaiting_text - - mark_awaiting_text(clarify_id) - except Exception as exc: - logger.warning("[GoogleChat] mark_awaiting_text failed: %s", exc) return result return await super().send_clarify( chat_id, question, choices, clarify_id, session_key, metadata From e34ac93065c6a21063164e2e2a801f2c6ba071cb Mon Sep 17 00:00:00 2001 From: aeyeopsdev <275853971+aeyeopsdev@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:34:55 -0400 Subject: [PATCH 8/8] test(google-chat): authorize HTTP clarify callback path --- tests/gateway/test_google_chat.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index 78762d6884b9..87ba5633e411 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -610,6 +610,14 @@ async def test_dispatch_http_event_resolves_clarify_card_click(self, adapter): {"success": True, "message_id": "m/ack", "error": None}, )() ) + class Runner: + def _is_user_authorized(self, _source): + return True + + async def handle(self, _event): + return None + + adapter._message_handler = Runner().handle await adapter.dispatch_http_event( {