ForcePilot/backend/package/yuxi/channel/extensions/googlechat/outbound.py
Kris c4e301a5e0 feat(googlechat): 新增Google Chat渠道完整实现
新增Google Chat插件的所有核心功能模块,包括账户配置、消息收发、权限控制、媒体处理、会话管理等完整能力
2026-05-21 10:49:33 +08:00

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