feat(google-chat): render clarify prompts as cards

This commit is contained in:
aeyeopsdev 2026-05-31 20:31:12 +00:00 committed by Teknium
parent 6803519aa5
commit 09505a393e
2 changed files with 327 additions and 0 deletions

View file

@ -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 '<missing>'}")
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,

View file

@ -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