diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index f63efeabebde..c68a6646b0e6 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -186,6 +186,17 @@ _RETRY_BASE_DELAY = 1.0 _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: @@ -316,6 +327,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. @@ -502,6 +643,7 @@ class GoogleChatAdapter(BasePlatformAdapter): 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 @@ -1870,6 +2012,108 @@ class GoogleChatAdapter(BasePlatformAdapter): 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 b75902785038..b475b48be558 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -135,6 +135,7 @@ from plugins.platforms.google_chat.adapter import ( # noqa: E402 _is_google_owned_host, _mime_for_message_type, _redact_sensitive, + card_spec_to_cards_v2, check_google_chat_requirements, ) @@ -1004,6 +1005,88 @@ class TestSend: 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