from __future__ import annotations import logging from yuxi.channel.extensions.googlechat.api import ( delete_message, send_message, send_message_with_attachments, update_message, ) from yuxi.channel.extensions.googlechat.formatting import chunk_markdown_text, sanitize_text from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount from yuxi.channel.protocols import ( OutboundDeliveryCapabilities, OutboundDeliveryMode, OutboundPresentationCapabilities, ) logger = logging.getLogger(__name__) class GoogleChatOutboundAdapter: delivery_mode = OutboundDeliveryMode.DIRECT chunker_mode = "markdown" text_chunk_limit: int = 4000 poll_max_options = None supports_poll_duration_seconds = False supports_anonymous_polls = False extract_markdown_images = False presentation_capabilities = OutboundPresentationCapabilities( supported=False, ) delivery_capabilities = OutboundDeliveryCapabilities( durable_final_text=True, durable_final_media=False, durable_final_thread=True, ) async def _get_account(self, account_id: str | None) -> ResolvedGoogleChatAccount | None: if account_id: from yuxi.channel.plugins.registry import ChannelPluginRegistry plugin = ChannelPluginRegistry.get("googlechat") if plugin and hasattr(plugin, "_config"): account_dict = await plugin._config.resolve_account(account_id) return account_dict.get("resolved") return None async def send_text( self, target_id: str, content: str, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> None: account = await self._get_account(account_id) if not account: raise RuntimeError(f"Google Chat account not found: {account_id}") safe = sanitize_text(content) if not safe: return reply_mode = getattr(account, "reply_to_mode", "off") effective_thread = thread_id if reply_mode != "off" else None chunks = chunk_markdown_text(safe, self.text_chunk_limit) for i, chunk in enumerate(chunks): chunk_thread = effective_thread if reply_mode == "all" or (reply_mode == "first" and i == 0) else None quoted = reply_to_id if i == 0 else None await send_message( account, target_id, chunk, thread_name=chunk_thread, quoted_message_name=quoted, ) async def send_media( self, target_id: str, media_url: str, media_type: str, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> None: import httpx account = await self._get_account(account_id) if not account: raise RuntimeError(f"Google Chat account not found: {account_id}") async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client: resp = await client.get(media_url) resp.raise_for_status() media_data = resp.content from yuxi.channel.extensions.googlechat.api import upload_attachment upload_result = await upload_attachment( account, target_id, "attachment", media_data, media_type ) attachment_token = upload_result.get("attachmentUploadToken") if attachment_token: attachment = {"attachmentUploadToken": attachment_token} await send_message_with_attachments( account, target_id, "", [attachment], thread_name=thread_id, quoted_message_name=reply_to_id, ) async def edit_message( self, target_id: str, message_id: str, content: str, *, thread_id: str | None = None, account_id: str | None = None, ) -> str | None: account = await self._get_account(account_id) if not account: return None safe = sanitize_text(content) await update_message(account, message_id, safe) return message_id async def send_card( self, target_id: str, card_content: dict, *, reply_to_id: str | None = None, thread_id: str | None = None, account_id: str | None = None, ) -> str | None: account = await self._get_account(account_id) if not account: return None cards_v2 = card_content.get("cardsV2", card_content.get("cards", [])) if isinstance(cards_v2, dict): cards_v2 = [cards_v2] if not cards_v2: return None fallback = card_content.get("fallbackText", card_content.get("text", "")) from yuxi.channel.extensions.googlechat.api import send_card_message result = await send_card_message( account, target_id, cards_v2, thread_name=thread_id, fallback_text=fallback, ) return result.get("name") async def edit_card( self, target_id: str, message_id: str, card_content: dict, *, thread_id: str | None = None, account_id: str | None = None, ) -> str | None: account = await self._get_account(account_id) if not account: return None cards_v2 = card_content.get("cardsV2", card_content.get("cards", [])) if isinstance(cards_v2, dict): cards_v2 = [cards_v2] if not cards_v2: return None import httpx from yuxi.channel.extensions.googlechat.api import ( CHAT_API_BASE, get_access_token, ) url = f"{CHAT_API_BASE}/{message_id}?updateMask=cardsV2" token = await get_access_token(account) if not token: return None body = {"cardsV2": cards_v2} async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client: resp = await client.patch( url, json=body, headers={ "Authorization": f"Bearer {token}", "Content-Type": "application/json", }, ) resp.raise_for_status() return message_id async def send_payload(self, ctx: object) -> object: return None async def send_poll(self, ctx: object) -> object: return None async def render_presentation( self, payload: object, presentation: object, ctx: object ) -> object | None: return None async def pin_delivered_message( self, config: dict, target_ref: object, message_id: str, pin: object ) -> None: pass async def before_deliver_payload( self, config: dict, target_ref: object, payload: object, hint: object | None = None ) -> None: pass async def after_deliver_payload( self, config: dict, target_ref: object, payload: object, results: list ) -> None: pass def resolve_target( self, to: str | None = None, *, config: dict | None = None, allow_from: list[str] | None = None, account_id: str | None = None, mode: str | None = None, ) -> tuple[bool, str]: if not to: return False, "Target is required" from yuxi.channel.extensions.googlechat.targets import normalize_googlechat_target normalized = normalize_googlechat_target(to) if not normalized: return False, f"Invalid target: {to}" return True, normalized def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]: return chunk_markdown_text(text, limit) def sanitize_text(self, text: str, payload: object) -> str: return sanitize_text(text) def should_skip_plain_text_sanitization(self, payload: object) -> bool: return False def normalize_payload( self, payload: object, config: dict, account_id: str | None = None ) -> object | None: return payload def resolve_effective_text_chunk_limit( self, config: dict, account_id: str | None = None, fallback_limit: int | None = None ) -> int | None: return self.text_chunk_limit def should_treat_delivered_text_as_visible( self, kind: str, text: str | None = None ) -> bool: return True