272 lines
8.4 KiB
Python
272 lines
8.4 KiB
Python
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 |