feat(googlechat): 新增Google Chat渠道完整实现

新增Google Chat插件的所有核心功能模块,包括账户配置、消息收发、权限控制、媒体处理、会话管理等完整能力
This commit is contained in:
Kris 2026-05-21 10:49:33 +08:00
parent 4a4e256955
commit c4e301a5e0
25 changed files with 3572 additions and 0 deletions

View File

@ -0,0 +1,344 @@
from __future__ import annotations
from yuxi.channel.capabilities import ChannelCapabilities
from yuxi.channel.extensions.base import BaseChannelPlugin
from yuxi.channel.extensions.googlechat.access_policy import apply_inbound_access_policy
from yuxi.channel.extensions.googlechat.config import GoogleChatConfigAdapter
from yuxi.channel.extensions.googlechat.dedupe import GoogleChatDedupeStore
from yuxi.channel.extensions.googlechat.gateway import GoogleChatGatewayAdapter
from yuxi.channel.extensions.googlechat.monitor import GoogleChatMonitor
from yuxi.channel.extensions.googlechat.outbound import GoogleChatOutboundAdapter
from yuxi.channel.extensions.googlechat.pairing import GoogleChatPairingAdapter
from yuxi.channel.extensions.googlechat.security import GoogleChatSecurityAdapter
from yuxi.channel.extensions.googlechat.session import GoogleChatSessionAdapter
from yuxi.channel.extensions.googlechat.status import GoogleChatStatusAdapter
from yuxi.channel.plugins.registry import ChannelPluginRegistry
class GoogleChatPlugin(BaseChannelPlugin):
id = "googlechat"
name = "Google Chat"
order = 55
label = "Google Chat (Chat API)"
aliases = ["gchat", "google-chat"]
resolve_reply_to_mode = "off"
def __init__(self):
self._config = GoogleChatConfigAdapter()
self._gateway = GoogleChatGatewayAdapter()
self._outbound = GoogleChatOutboundAdapter()
self._monitor = GoogleChatMonitor()
self._status = GoogleChatStatusAdapter()
self._security = GoogleChatSecurityAdapter()
self._session = GoogleChatSessionAdapter()
self._pairing = GoogleChatPairingAdapter()
self._dedupe = GoogleChatDedupeStore()
@property
def capabilities(self) -> ChannelCapabilities:
return ChannelCapabilities(
chat_types=["direct", "group", "thread"],
message_types=["text", "image", "file", "video"],
interactions=[],
reactions=True,
typing_indicator=True,
threads=True,
edit=True,
unsend=True,
reply=True,
media=True,
native_commands=False,
polls=False,
streaming=False,
streaming_mode=None,
block_streaming=True,
block_streaming_chunk_min_chars=4000,
block_streaming_chunk_max_chars=4000,
block_streaming_coalesce_min_chars=1500,
block_streaming_coalesce_max_chars=4000,
)
# ── ConfigProtocol ───────────────────────────────────
def list_account_ids(self, config: dict) -> list[str]:
return self._config.list_account_ids(config)
async def resolve_account(self, account_id: str) -> dict:
return await self._config.resolve_account(account_id)
def is_configured(self, account: dict) -> bool:
return self._config.is_configured(account)
def is_enabled(self, account: dict) -> bool:
return self._config.is_enabled(account)
def disabled_reason(self, account: dict) -> str:
return self._config.disabled_reason(account)
async def resolve_allow_from(self, config: dict, account_id: str) -> list[str] | None:
return await self._config.resolve_allow_from(config, account_id)
def describe_account(self, account: dict) -> dict:
return self._config.describe_account(account)
# ── GatewayProtocol ───────────────────────────────────
async def start(self, ctx) -> object:
return await self._gateway.start(ctx)
async def stop(self, ctx) -> None:
await self._gateway.stop(ctx)
# ── OutboundProtocol ─────────────────────────────────
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:
await self._outbound.send_text(
target_id, content,
reply_to_id=reply_to_id, thread_id=thread_id, account_id=account_id,
)
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:
await self._outbound.send_media(
target_id, media_url, media_type,
reply_to_id=reply_to_id, thread_id=thread_id, account_id=account_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:
return await self._outbound.send_card(
target_id, card_content,
reply_to_id=reply_to_id, thread_id=thread_id, account_id=account_id,
)
# ── StatusProtocol ────────────────────────────────────
async def probe(self, account: dict) -> bool:
return await self._status.probe(account)
def build_summary(self, snapshot: object) -> dict:
return self._status.build_summary(snapshot)
# ── SecurityProtocol ─────────────────────────────────
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
from yuxi.channel.runtime.manager import gateway
config = gateway.global_config if gateway is not None else {}
account_ids = self._config.list_account_ids(config)
account_id = account_ids[0] if account_ids else "default"
account = await self._config.resolve_account(account_id)
account_dict = account.get("resolved")
if not account_dict:
return True
is_dm = channel_type == "direct"
space_name = peer_id if not is_dm else ""
space_type = "GROUP_CHAT" if not is_dm else "DM"
result = apply_inbound_access_policy(
space_name=space_name,
space_type=space_type,
sender_name=peer_id,
is_dm=is_dm,
account=account,
)
return result.get("allowed", False)
def resolve_dm_policy(self) -> dict:
return self._security.resolve_dm_policy()
def collect_warnings(self, config: dict, account_id: str | None = None) -> list[str]:
return self._security.collect_warnings(config, account_id)
# ── PairingProtocol ───────────────────────────────────
async def generate_code(self, peer_id: str) -> str:
return await self._pairing.generate_code(peer_id)
async def verify_code(self, peer_id: str, code: str) -> bool:
return await self._pairing.verify_code(peer_id, code)
# ── ThreadingProtocol ─────────────────────────────────
def extract_thread_id(self, msg: object) -> str | None:
metadata = getattr(msg, "metadata", {}) or {}
thread_name = metadata.get("thread_name", "")
if thread_name:
return thread_name
return getattr(msg, "message_thread_id", None)
def resolve_reply_transport(self, msg: object, thread_id: str | None):
from yuxi.channel.protocols import ReplyTransport
return ReplyTransport(
thread_id=thread_id,
reply_mode=getattr(self, "resolve_reply_to_mode", "off"),
)
# ── MessagingProtocol ─────────────────────────────────
def resolve_session(self, msg):
return self._session.resolve_session(msg)
# ── ReactionProtocol ──────────────────────────────────
async def send_reaction(
self,
target_id: str,
message_id: str,
emoji: str,
*,
account_id: str | None = None,
) -> None:
from yuxi.channel.extensions.googlechat.reactions import add_reaction
account = await self._config.resolve_account(account_id or "default")
resolved = account.get("resolved")
if resolved:
await add_reaction(resolved, message_id, emoji)
async def remove_reaction(
self,
target_id: str,
message_id: str,
emoji: str,
*,
account_id: str | None = None,
) -> None:
from yuxi.channel.extensions.googlechat.reactions import remove_reaction
account = await self._config.resolve_account(account_id or "default")
resolved = account.get("resolved")
if resolved:
bot_user = resolved.bot_user if hasattr(resolved, "bot_user") else None
await remove_reaction(resolved, message_id, emoji, bot_user)
async def fetch_reactions(
self,
target_id: str,
message_id: str,
*,
account_id: str | None = None,
) -> list[dict]:
from yuxi.channel.extensions.googlechat.reactions import get_reactions
account = await self._config.resolve_account(account_id or "default")
resolved = account.get("resolved")
if resolved:
return await get_reactions(resolved, message_id)
return []
# ── FormatProtocol ────────────────────────────────────
def markdown_to_native(self, md_text: str) -> dict | str:
from yuxi.channel.extensions.googlechat.formatting import sanitize_text
return {"text": sanitize_text(md_text)}
def native_to_markdown(self, native_content: dict | str) -> str:
if isinstance(native_content, dict):
return native_content.get("text", "")
return str(native_content)
# ── DedupeProtocol ────────────────────────────────────
def is_duplicate(self, key: str) -> bool:
return self._dedupe.is_duplicate(key)
def mark_seen(self, key: str) -> None:
self._dedupe.mark_seen(key)
@property
def ttl_seconds(self) -> int:
return 300
@property
def max_entries(self) -> int:
return 10000
# ── ErrorHandlingProtocol ─────────────────────────────
def classify_error(self, error: BaseException) -> object:
import httpx
from yuxi.channel.errors import classify_error
from yuxi.channel.protocols import ClassifiedError, ErrorSeverity
if isinstance(error, httpx.HTTPStatusError):
status = error.response.status_code
if status == 429:
return ClassifiedError(severity=ErrorSeverity.RATE_LIMITED)
if status in (401, 403):
return ClassifiedError(severity=ErrorSeverity.FORBIDDEN)
if 500 <= status < 600:
return ClassifiedError(severity=ErrorSeverity.NETWORK)
return ClassifiedError(severity=ErrorSeverity.RETRYABLE)
if isinstance(error, httpx.TimeoutException):
return ClassifiedError(severity=ErrorSeverity.NETWORK)
return classify_error(error)
def is_retryable(self, error: BaseException) -> bool:
import httpx
from yuxi.channel.errors import is_retryable
if isinstance(error, httpx.HTTPStatusError):
status = error.response.status_code
if status == 429:
return True
if status in (401, 403):
return False
if 500 <= status < 600:
return True
return True
if isinstance(error, httpx.TimeoutException):
return True
return is_retryable(error)
# ── WebhookProtocol ───────────────────────────────────
def verify_signature(self, body: bytes, signature: str, secret: str) -> bool:
return False
# ── ConfigSchemaProtocol ──────────────────────────────
def config_schema(self) -> dict:
return self._config.config_schema()
# ── AgentPromptProtocol ──────────────────────────────
@property
def channel_format_instructions(self) -> str | None:
return (
"You are responding via Google Chat. Messages support basic Markdown (bold, italic, "
"links, code blocks, lists). "
"Messages are limited to 4000 characters and will be automatically chunked if longer. "
"You can use reactions (emoji) and thread replies. "
"Be concise and avoid excessive formatting."
)
googlechat_plugin = GoogleChatPlugin()
ChannelPluginRegistry.register(googlechat_plugin)

View File

@ -0,0 +1,145 @@
from __future__ import annotations
import re
import logging
logger = logging.getLogger(__name__)
_GROUP_KEY_PATTERN = re.compile(r"^spaces/.+$")
def apply_inbound_access_policy(
space_name: str,
space_type: str,
sender_name: str,
is_dm: bool,
account: dict,
) -> dict:
"""
Returns {"allowed": bool, "reason": str, "requires_pairing": bool}
"""
if is_dm:
return _check_dm_policy(sender_name, account)
else:
return _check_group_policy(space_name, space_type, account)
def _check_dm_policy(sender_name: str, account: dict) -> dict:
dm_policy = account.get("dm_policy", "pairing")
dm_enabled = account.get("dm_enabled", True)
if not dm_enabled:
return {"allowed": False, "reason": "dm_disabled"}
if dm_policy == "open":
return {"allowed": True, "reason": "open_dm"}
if dm_policy == "allowlist":
allow_from = account.get("dm_allow_from", [])
if "*" in allow_from:
return {"allowed": True, "reason": "wildcard_allow"}
if sender_name in allow_from:
return {"allowed": True, "reason": "in_allowlist"}
normalized = _normalize_sender_for_allowlist_check(sender_name, allow_from)
if normalized:
return {"allowed": True, "reason": "in_allowlist"}
return {"allowed": False, "reason": "not_in_allowlist"}
if dm_policy == "pairing":
allow_from = account.get("dm_allow_from", [])
resolved = account.get("resolved")
if resolved and hasattr(resolved, "dm_allow_from"):
allow_from = resolved.dm_allow_from
if sender_name in allow_from:
return {"allowed": True, "reason": "already_paired"}
normalized = _normalize_sender_for_allowlist_check(sender_name, allow_from)
if normalized:
return {"allowed": True, "reason": "already_paired"}
return {"allowed": False, "reason": "pairing_required", "requires_pairing": True}
return {"allowed": False, "reason": f"unknown_policy: {dm_policy}"}
def _check_group_policy(space_name: str, space_type: str, account: dict) -> dict:
group_policy = account.get("group_policy", "allowlist")
groups = account.get("groups", {})
if group_policy == "disabled":
return {"allowed": False, "reason": "groups_disabled"}
if group_policy == "open":
return {"allowed": True, "reason": "open_group"}
if group_policy == "allowlist":
if "*" in groups:
return {"allowed": True, "reason": "wildcard_group"}
if space_name in groups:
entry = groups[space_name]
if entry.get("enabled") is False:
return {"allowed": False, "reason": "group_disabled"}
return {"allowed": True, "reason": "in_group_list"}
if not _GROUP_KEY_PATTERN.match(space_name):
return {"allowed": False, "reason": "deprecated_group_key"}
return {"allowed": False, "reason": "not_in_group_list"}
return {"allowed": False, "reason": f"unknown_policy: {group_policy}"}
def _normalize_sender_for_allowlist_check(sender_name: str, allow_from: list[str]) -> str | None:
for entry in allow_from:
if entry.startswith("googlechat:"):
if entry[len("googlechat:"):] == sender_name:
return sender_name
if entry.startswith("users/"):
if entry == sender_name:
return sender_name
return None
def is_control_command_message(text: str) -> bool:
text = text.strip()
return text.startswith("/config") or text.startswith("/status") or text.startswith("/admin")
def should_require_command_authorization(
is_group: bool, text: str, command_authorized: bool
) -> bool:
if not is_group:
return False
if not is_control_command_message(text):
return False
return not command_authorized
def detect_mention_for_access(annotations: list[dict]) -> bool:
if not annotations:
return False
for ann in annotations:
if ann.get("type") == "USER_MENTION":
um = ann.get("userMention", {})
if um.get("user", {}).get("name") == "users/app":
return True
return False
def resolve_group_require_mention(space_name: str, account: dict) -> bool:
groups = account.get("groups", {})
default_require = account.get("require_mention", True)
if "*" in groups:
entry = groups["*"]
if "requireMention" in entry:
return entry["requireMention"]
if space_name in groups:
entry = groups[space_name]
if "requireMention" in entry:
return entry["requireMention"]
return default_require
def should_bypass_mention_for_text_commands(text: str, account: dict) -> bool:
if not account.get("allow_text_commands", False):
return False
return is_control_command_message(text)

View File

@ -0,0 +1,30 @@
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
def resolve_merged_config(cfg: dict, account_id: str) -> dict:
gc_config = cfg.get("channels", {}).get("googlechat", {}) if cfg else {}
defaults = gc_config.get("accounts", {}).get("default", {})
account_cfg = gc_config.get("accounts", {}).get(account_id, {})
merged = {**gc_config, **defaults, **account_cfg}
clear_base = {
"serviceAccount",
"serviceAccountFile",
"audienceType",
"audience",
"webhookPath",
"webhookUrl",
"botUser",
"name",
}
for field in clear_base:
if field not in account_cfg and field not in defaults:
merged.pop(field, None)
return merged

View File

@ -0,0 +1,42 @@
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
async def execute_send_action(account, space_name: str, text: str, thread_name: str | None = None) -> dict:
from yuxi.channel.extensions.googlechat.api import send_message
result = await send_message(account, space_name, text, thread_name=thread_name)
return {"success": True, "action": "send", "result": result}
async def execute_upload_file_action(
account, space_name: str, file_path: str, content_type: str | None = None
) -> dict:
from yuxi.channel.extensions.googlechat.media import upload_media_file
result = await upload_media_file(account, space_name, file_path, content_type)
token = result.get("attachmentUploadToken")
if token:
from yuxi.channel.extensions.googlechat.api import send_message_with_attachments
msg = await send_message_with_attachments(
account, space_name, "", [{"attachmentUploadToken": token}]
)
return {"success": True, "action": "upload-file", "result": msg}
return {"success": True, "action": "upload-file", "result": result}
async def execute_react_action(account, message_name: str, emoji: str, remove: bool = False) -> dict:
from yuxi.channel.extensions.googlechat.reactions import execute_react_action as _execute
return await _execute(account, message_name, emoji, remove)
async def execute_reactions_action(account, message_name: str) -> dict:
from yuxi.channel.extensions.googlechat.reactions import get_reactions
reactions = await get_reactions(account, message_name)
return {"success": True, "action": "reactions", "result": reactions}

View File

@ -0,0 +1,454 @@
from __future__ import annotations
import logging
import httpx
from yuxi.channel.extensions.googlechat.auth import get_access_token
from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount
logger = logging.getLogger(__name__)
CHAT_API_BASE = "https://chat.googleapis.com/v1"
CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1"
CHAT_MEDIA_BASE = "https://chat.googleapis.com/v1/media"
async def _get_http_for_account(
account: ResolvedGoogleChatAccount,
) -> tuple[httpx.AsyncClient, str]:
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain Google Chat access token")
client = httpx.AsyncClient(
base_url=CHAT_API_BASE,
timeout=httpx.Timeout(30.0),
headers={"Authorization": f"Bearer {token}"},
)
return client, token
async def send_message(
account: ResolvedGoogleChatAccount,
space_name: str,
text: str,
*,
thread_name: str | None = None,
quoted_message_name: str | None = None,
private_message_viewer: str | None = None,
accessory_widgets: list[dict] | None = None,
client_message_id: str | None = None,
message_reply_option: str = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD",
) -> dict:
url = f"{CHAT_API_BASE}/{space_name}/messages"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
body: dict = {"text": text}
if thread_name:
body["messageReplyOption"] = message_reply_option
body["thread"] = {"name": thread_name}
if quoted_message_name:
body["quotedMessageMetadata"] = {"name": quoted_message_name}
if private_message_viewer:
body["privateMessageViewer"] = {"name": private_message_viewer}
if accessory_widgets:
body["accessoryWidgets"] = accessory_widgets
params: dict = {}
if client_message_id:
params["messageId"] = client_message_id
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
resp = await client.post(
url,
json=body,
params=params,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
resp.raise_for_status()
return resp.json()
async def send_message_with_attachments(
account: ResolvedGoogleChatAccount,
space_name: str,
text: str,
attachments: list[dict],
*,
thread_name: str | None = None,
quoted_message_name: str | None = None,
private_message_viewer: str | None = None,
client_message_id: str | None = None,
message_reply_option: str = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD",
) -> dict:
url = f"{CHAT_API_BASE}/{space_name}/messages"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
body: dict = {"text": text, "attachment": attachments}
if thread_name:
body["messageReplyOption"] = message_reply_option
body["thread"] = {"name": thread_name}
if quoted_message_name:
body["quotedMessageMetadata"] = {"name": quoted_message_name}
if private_message_viewer:
body["privateMessageViewer"] = {"name": private_message_viewer}
params: dict = {}
if client_message_id:
params["messageId"] = client_message_id
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
resp = await client.post(
url,
json=body,
params=params,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
resp.raise_for_status()
return resp.json()
async def send_card_message(
account: ResolvedGoogleChatAccount,
space_name: str,
cards_v2: list[dict],
*,
thread_name: str | None = None,
fallback_text: str = "",
message_reply_option: str = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD",
) -> dict:
url = f"{CHAT_API_BASE}/{space_name}/messages"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
body: dict = {
"cardsV2": cards_v2,
"fallbackText": fallback_text,
}
if thread_name:
body["messageReplyOption"] = message_reply_option
body["thread"] = {"name": thread_name}
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
resp = await client.post(
url,
json=body,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
resp.raise_for_status()
return resp.json()
async def update_message(
account: ResolvedGoogleChatAccount,
message_name: str,
text: str,
) -> dict:
url = f"{CHAT_API_BASE}/{message_name}?updateMask=text"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
body = {"text": text}
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 resp.json()
async def delete_message(account: ResolvedGoogleChatAccount, message_name: str) -> None:
url = f"{CHAT_API_BASE}/{message_name}"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
resp = await client.delete(
url,
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
async def get_message(
account: ResolvedGoogleChatAccount,
message_name: str,
) -> dict | None:
url = f"{CHAT_API_BASE}/{message_name}"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client:
resp = await client.get(
url,
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code == 404:
return None
resp.raise_for_status()
return resp.json()
async def upload_attachment(
account: ResolvedGoogleChatAccount,
space_name: str,
filename: str,
content: bytes,
content_type: str,
) -> dict:
url = f"{CHAT_UPLOAD_BASE}/{space_name}/attachments:upload"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
resp = await client.post(
url,
data={"filename": filename},
files={"data": (filename, content, content_type)},
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
return resp.json()
async def download_media_content(
account: ResolvedGoogleChatAccount,
resource_name: str,
max_bytes: int = 20 * 1024 * 1024,
) -> bytes:
url = f"{CHAT_MEDIA_BASE}/{resource_name}"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
resp = await client.get(
url,
headers={"Authorization": f"Bearer {token}"},
params={"alt": "media"},
)
resp.raise_for_status()
content = resp.content
if len(content) > max_bytes:
raise ValueError(f"Media size {len(content)} exceeds limit {max_bytes}")
return content
async def create_reaction(
account: ResolvedGoogleChatAccount,
message_name: str,
emoji: str,
custom_emoji_url: str | None = None,
) -> dict:
url = f"{CHAT_API_BASE}/{message_name}/reactions"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
body: dict = {"emoji": {"unicode": emoji}}
if custom_emoji_url:
body["emoji"]["customEmojiUrl"] = custom_emoji_url
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
resp = await client.post(
url,
json=body,
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
resp.raise_for_status()
return resp.json()
async def list_reactions(
account: ResolvedGoogleChatAccount,
message_name: str,
) -> list[dict]:
url = f"{CHAT_API_BASE}/{message_name}/reactions"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
resp = await client.get(
url,
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
data = resp.json()
return data.get("reactions", [])
async def delete_reaction(
account: ResolvedGoogleChatAccount,
reaction_name: str,
) -> None:
url = f"{CHAT_API_BASE}/{reaction_name}"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
resp = await client.delete(
url,
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
async def list_thread_messages(
account: ResolvedGoogleChatAccount,
space_name: str,
thread_name: str,
*,
page_size: int = 20,
order_by: str = "createTime desc",
) -> list[dict]:
url = f"{CHAT_API_BASE}/{space_name}/messages"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
params = {
"thread.name": thread_name,
"pageSize": page_size,
"orderBy": order_by,
}
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
resp = await client.get(
url,
params=params,
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
data = resp.json()
return data.get("messages", [])
async def list_messages(
account: ResolvedGoogleChatAccount,
space_name: str,
*,
page_size: int = 50,
page_token: str | None = None,
filter_str: str | None = None,
order_by: str = "createTime desc",
) -> dict:
url = f"{CHAT_API_BASE}/{space_name}/messages"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
params: dict = {"pageSize": page_size, "orderBy": order_by}
if page_token:
params["pageToken"] = page_token
if filter_str:
params["filter"] = filter_str
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
resp = await client.get(
url, params=params,
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
return resp.json()
async def list_spaces(
account: ResolvedGoogleChatAccount,
*,
page_size: int = 100,
filter_str: str | None = None,
) -> list[dict]:
url = f"{CHAT_API_BASE}/spaces"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
params: dict = {"pageSize": page_size}
if filter_str:
params["filter"] = filter_str
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client:
resp = await client.get(
url, params=params,
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
data = resp.json()
return data.get("spaces", [])
async def find_direct_message(
account: ResolvedGoogleChatAccount,
user_name: str,
) -> str | None:
url = f"{CHAT_API_BASE}/spaces:findDirectMessage"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
resp = await client.get(
url,
params={"name": user_name},
headers={"Authorization": f"Bearer {token}"},
)
if resp.status_code == 404:
return None
resp.raise_for_status()
data = resp.json()
return data.get("name")
async def list_members(
account: ResolvedGoogleChatAccount,
space_name: str,
*,
page_size: int = 100,
page_token: str | None = None,
) -> dict:
url = f"{CHAT_API_BASE}/{space_name}/members"
token = await get_access_token(account)
if not token:
raise RuntimeError("Failed to obtain access token")
params: dict = {"pageSize": page_size}
if page_token:
params["pageToken"] = page_token
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
resp = await client.get(
url, params=params,
headers={"Authorization": f"Bearer {token}"},
)
resp.raise_for_status()
return resp.json()

View File

@ -0,0 +1,36 @@
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
class GoogleChatApprovalAuth:
channel_label = "Google Chat"
def resolve_approvers(self, cfg: dict, account_id: str) -> list[str]:
gc_config = cfg.get("channels", {}).get("googlechat", {}) if cfg else {}
allow_from = gc_config.get("dm", {}).get("allowFrom", [])
if not allow_from:
account_cfg = gc_config.get("accounts", {}).get(account_id, {})
allow_from = account_cfg.get("dm", {}).get("allowFrom", [])
else:
account_cfg = gc_config.get("accounts", {}).get(account_id, {})
account_allow_from = account_cfg.get("dm", {}).get("allowFrom", [])
if account_allow_from:
allow_from = account_allow_from
return [a for a in (self._normalize_approver_id(entry) for entry in allow_from) if a]
def _normalize_approver_id(self, value) -> str | None:
if not value:
return None
for prefix in ("googlechat:",):
if value.startswith(prefix):
value = value[len(prefix):]
if value.startswith("users/") and "@" in value:
return None
if value.startswith("users/"):
return value.lower()
return f"users/{value.lower()}"

View File

@ -0,0 +1,195 @@
from __future__ import annotations
import json
import logging
import re
import time
import hashlib
from google.oauth2 import service_account
from google.oauth2 import id_token as google_id_token
from google.auth.transport import requests as google_requests
import httpx
import jwt
from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount
logger = logging.getLogger(__name__)
CHAT_API_SCOPE = "https://www.googleapis.com/auth/chat.bot"
CERTS_URL = "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com"
CERTS_TTL = 600
_STANDARD_CHAT_ISSUER = "chat@system.gserviceaccount.com"
_ADDON_ISSUER_PATTERN = re.compile(r"^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceaccount\.com$")
_auth_cache: dict[str, tuple[str, service_account.Credentials]] = {}
_MAX_AUTH_CACHE = 32
_cert_cache: tuple[float, dict[str, str]] | None = None
_http_client: httpx.AsyncClient | None = None
async def _get_http() -> httpx.AsyncClient:
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
return _http_client
def _normalize_private_key(key: str) -> str:
return key.replace("\\n", "\n")
def _build_auth_key(sa_info: dict, sa_file: str | None) -> str:
if sa_file:
return f"file:{sa_file}"
if sa_info:
key_hash = hashlib.sha256(
json.dumps({"client_email": sa_info.get("client_email"), "project_id": sa_info.get("project_id")}).encode()
).hexdigest()[:16]
return f"inline:{key_hash}"
return "none"
def get_or_create_credentials(
account_id: str, sa_info: dict, sa_file: str | None
) -> service_account.Credentials:
key = _build_auth_key(sa_info, sa_file)
cached = _auth_cache.get(account_id)
if cached and cached[0] == key:
return cached[1]
private_key = _normalize_private_key(sa_info.get("private_key", ""))
creds = service_account.Credentials.from_service_account_info(
{
**sa_info,
"private_key": private_key,
},
scopes=[CHAT_API_SCOPE],
)
if len(_auth_cache) >= _MAX_AUTH_CACHE:
oldest = next(iter(_auth_cache))
_auth_cache.pop(oldest)
_auth_cache[account_id] = (key, creds)
return creds
async def get_access_token(account: ResolvedGoogleChatAccount) -> str | None:
if not account.service_account_info:
return None
creds = get_or_create_credentials(
account.account_id,
account.service_account_info,
account.service_account_file,
)
request = google_requests.Request()
creds.refresh(request)
return creds.token
def decode_unverified_jwt_payload(token: str) -> dict:
import base64
parts = token.split(".")
if len(parts) < 2:
return {}
try:
padded = parts[1] + "=" * (4 - len(parts[1]) % 4)
decoded = base64.b64decode(padded.replace("-", "+").replace("_", "/"), validate=False)
return json.loads(decoded)
except Exception:
return {}
async def _google_verify_id_token(token: str, audience: str) -> dict:
request = google_requests.Request()
return google_id_token.verify_oauth2_token(token, request, audience)
async def _fetch_chat_certs() -> dict[str, str]:
global _cert_cache
now = time.monotonic()
if _cert_cache and now - _cert_cache[0] < CERTS_TTL:
return _cert_cache[1]
http = await _get_http()
resp = await http.get(CERTS_URL)
resp.raise_for_status()
certs = resp.json()
_cert_cache = (now, certs)
return certs
async def _verify_signed_jwt_with_certs(token: str, audience: str, certs: dict[str, str]) -> dict:
header = jwt.get_unverified_header(token)
kid = header.get("kid", "")
cert = certs.get(kid)
if not cert:
raise jwt.InvalidTokenError(f"No matching cert for kid: {kid}")
return jwt.decode(token, cert, algorithms=["RS256"], audience=audience)
async def verify_webhook_token(
token: str, audience_type: str, audience: str, app_principal: str | None
) -> tuple[bool, dict, str | None]:
payload = decode_unverified_jwt_payload(token)
iss = payload.get("iss", "")
if iss == _STANDARD_CHAT_ISSUER:
try:
if audience_type == "app-url":
verified = await _google_verify_id_token(token, audience)
elif audience_type == "project-number":
certs = await _fetch_chat_certs()
verified = await _verify_signed_jwt_with_certs(token, audience, certs)
else:
return False, payload, f"unsupported audience_type: {audience_type}"
return True, verified, None
except Exception as e:
return False, payload, f"token verification failed: {e}"
if _ADDON_ISSUER_PATTERN.match(iss):
if app_principal and payload.get("sub") != app_principal:
return False, payload, "token sub does not match appPrincipal"
try:
certs = await _fetch_chat_certs()
verified = await _verify_signed_jwt_with_certs(token, audience, certs)
return True, verified, None
except Exception as e:
return False, payload, f"add-on token verification failed: {e}"
return False, payload, f"invalid issuer: {iss}"
async def probe_account(account: ResolvedGoogleChatAccount) -> dict:
token = await get_access_token(account)
if not token:
return {"ok": False, "status": "no_token", "error": "Failed to obtain access token"}
http = await _get_http()
try:
resp = await http.get(
"https://chat.googleapis.com/v1/spaces?pageSize=1",
headers={"Authorization": f"Bearer {token}"},
timeout=10.0,
)
resp.raise_for_status()
return {"ok": True, "status": "connected"}
except httpx.HTTPStatusError as e:
return {"ok": False, "status": f"http_{e.response.status_code}", "error": str(e)}
except Exception as e:
return {"ok": False, "status": "error", "error": str(e)}
async def close_auth_http() -> None:
global _http_client
if _http_client:
await _http_client.aclose()
_http_client = None

View File

@ -0,0 +1,313 @@
from __future__ import annotations
import json
import logging
import os
import re
from pathlib import Path
from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount
logger = logging.getLogger(__name__)
_AUDIENCE_VARIANTS: dict[str, set[str]] = {
"app-url": {"app-url", "app_url", "app"},
"project-number": {"project-number", "project_number", "project"},
}
_DEFAULT_WEBHOOK_PATH = "/api/webhook/googlechat"
_REQUIRED_SERVICE_ACCOUNT_FIELDS = {"client_email", "private_key"}
class GoogleChatConfigAdapter:
def list_account_ids(self, config: dict) -> list[str]:
gc_config = config.get("channels", {}).get("googlechat", {})
accounts = gc_config.get("accounts", {})
default_account = gc_config.get("defaultAccount", "default")
if not accounts:
return [default_account]
return list(accounts.keys())
async def resolve_account(self, account_id: str) -> dict:
from yuxi.channel.runtime.manager import gateway
config = gateway.global_config if gateway is not None else {}
gc_config = config.get("channels", {}).get("googlechat", {}) if config else {}
defaults = gc_config.get("accounts", {}).get("default", {})
account_cfg = gc_config.get("accounts", {}).get(account_id, {})
merged = {**gc_config, **defaults, **account_cfg}
credential_source, sa_info, sa_file = self._resolve_credential(merged, account_id, account_id == "default")
resolved = ResolvedGoogleChatAccount(
account_id=account_id,
name=merged.get("name", account_id),
credential_source=credential_source,
service_account_info=sa_info,
service_account_file=sa_file,
audience_type=self.normalize_audience_type(merged.get("audienceType")),
audience=merged.get("audience"),
webhook_path=merged.get("webhookPath", _DEFAULT_WEBHOOK_PATH),
webhook_url=merged.get("webhookUrl"),
bot_user=merged.get("botUser"),
app_principal=merged.get("appPrincipal"),
group_policy=merged.get("groupPolicy", "allowlist"),
require_mention=merged.get("requireMention", True),
allow_text_commands=merged.get("allowTextCommands", False),
reply_to_mode=merged.get("replyToMode", "off"),
text_chunk_limit=merged.get("textChunkLimit", 4000),
media_max_mb=merged.get("mediaMaxMb", 20),
typing_indicator=merged.get("typingIndicator", "message"),
allow_bots=merged.get("allowBots", False),
dangerously_allow_name_matching=merged.get("dangerouslyAllowNameMatching", False),
actions_reactions=merged.get("actions", {}).get("reactions", True),
dm_policy=merged.get("dm", {}).get("policy", "pairing"),
dm_enabled=merged.get("dm", {}).get("enabled", True),
dm_allow_from=merged.get("dm", {}).get("allowFrom", []),
groups=merged.get("groups", {}),
block_streaming_coalesce_min_chars=merged.get("blockStreamingCoalesce", {}).get("minChars", 1500),
block_streaming_coalesce_idle_ms=merged.get("blockStreamingCoalesce", {}).get("idleMs", 1000),
enabled=merged.get("enabled", True),
config=merged,
)
return {
"account_id": account_id,
"name": resolved.name,
"credential_source": resolved.credential_source,
"service_account_info": resolved.service_account_info,
"service_account_file": resolved.service_account_file,
"audience_type": resolved.audience_type,
"audience": resolved.audience,
"webhook_path": resolved.webhook_path,
"webhook_url": resolved.webhook_url,
"bot_user": resolved.bot_user,
"app_principal": resolved.app_principal,
"group_policy": resolved.group_policy,
"require_mention": resolved.require_mention,
"allow_text_commands": resolved.allow_text_commands,
"reply_to_mode": resolved.reply_to_mode,
"text_chunk_limit": resolved.text_chunk_limit,
"media_max_mb": resolved.media_max_mb,
"typing_indicator": resolved.typing_indicator,
"allow_bots": resolved.allow_bots,
"dangerously_allow_name_matching": resolved.dangerously_allow_name_matching,
"actions_reactions": resolved.actions_reactions,
"dm_policy": resolved.dm_policy,
"dm_enabled": resolved.dm_enabled,
"dm_allow_from": resolved.dm_allow_from,
"groups": resolved.groups,
"block_streaming_coalesce_min_chars": resolved.block_streaming_coalesce_min_chars,
"block_streaming_coalesce_idle_ms": resolved.block_streaming_coalesce_idle_ms,
"enabled": resolved.enabled,
"config": resolved.config,
"resolved": resolved,
}
def is_configured(self, account: dict) -> bool:
resolved = account.get("resolved")
if isinstance(resolved, ResolvedGoogleChatAccount):
return resolved.is_configured
return bool(account.get("credential_source") != "none")
def is_enabled(self, account: dict) -> bool:
return bool(account.get("enabled", True))
def disabled_reason(self, account: dict) -> str:
if not self.is_configured(account):
return "Service Account credentials are required"
return ""
async def resolve_allow_from(self, config: dict, account_id: str) -> list[str] | None:
account = await self.resolve_account(account_id)
return account.get("dm_allow_from", [])
def describe_account(self, account: dict) -> dict:
return {
"account_id": account.get("account_id", ""),
"name": account.get("name", ""),
"configured": self.is_configured(account),
"credential_source": account.get("credential_source", "none"),
}
def config_schema(self) -> dict:
return {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": True},
"name": {"type": "string"},
"serviceAccount": {
"type": "string",
"description": "Google Cloud Service Account JSON (inline string)",
},
"serviceAccountFile": {
"type": "string",
"description": "Path to Service Account JSON file",
},
"audienceType": {
"type": "string",
"enum": ["app-url", "project-number"],
},
"audience": {"type": "string"},
"webhookPath": {"type": "string", "default": "/api/webhook/googlechat"},
"webhookUrl": {"type": "string"},
"botUser": {"type": "string"},
"appPrincipal": {"type": "string"},
"groupPolicy": {
"type": "string",
"enum": ["open", "allowlist", "disabled"],
"default": "allowlist",
},
"requireMention": {"type": "boolean", "default": True},
"allowTextCommands": {"type": "boolean", "default": False},
"replyToMode": {
"type": "string",
"enum": ["off", "first", "all", "batched"],
"default": "off",
},
"textChunkLimit": {"type": "integer", "default": 4000},
"mediaMaxMb": {"type": "integer", "default": 20},
"typingIndicator": {
"type": "string",
"enum": ["message", "reaction", "off"],
"default": "message",
},
"allowBots": {"type": "boolean", "default": False},
"dangerouslyAllowNameMatching": {"type": "boolean", "default": False},
"dm": {
"type": "object",
"properties": {
"policy": {
"type": "string",
"enum": ["open", "pairing", "allowlist"],
"default": "pairing",
},
"allowFrom": {"type": "array", "items": {"type": "string"}},
"enabled": {"type": "boolean", "default": True},
},
},
"groups": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"requireMention": {"type": "boolean"},
"enabled": {"type": "boolean"},
"users": {"type": "array", "items": {"type": "string"}},
"systemPrompt": {"type": "string"},
},
},
},
"actions": {
"type": "object",
"properties": {
"reactions": {"type": "boolean", "default": True},
},
},
"accounts": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"name": {"type": "string"},
"serviceAccount": {"type": "string"},
"serviceAccountFile": {"type": "string"},
"audienceType": {"type": "string", "enum": ["app-url", "project-number"]},
"audience": {"type": "string"},
"webhookPath": {"type": "string"},
"webhookUrl": {"type": "string"},
"botUser": {"type": "string"},
"appPrincipal": {"type": "string"},
"dm": {
"type": "object",
"properties": {
"policy": {"type": "string", "enum": ["open", "pairing", "allowlist"]},
"allowFrom": {"type": "array", "items": {"type": "string"}},
"enabled": {"type": "boolean"},
},
},
},
},
},
"defaultAccount": {"type": "string", "default": "default"},
},
}
@staticmethod
def normalize_audience_type(value: str | None) -> str | None:
if not value:
return None
normalized = value.strip().lower()
for canonical, variants in _AUDIENCE_VARIANTS.items():
if normalized in variants:
return canonical
return None
@staticmethod
def _resolve_credential(
merged: dict, account_id: str, is_default: bool
) -> tuple[str, dict | None, str | None]:
sa_file = merged.get("serviceAccountFile")
if sa_file:
try:
with open(sa_file, encoding="utf-8") as f:
sa_info = json.load(f)
return "file", sa_info, sa_file
except Exception:
logger.warning("Failed to read serviceAccountFile: %s", sa_file)
sa_inline = merged.get("serviceAccount")
if sa_inline:
try:
sa_info = json.loads(sa_inline) if isinstance(sa_inline, str) else sa_inline
return "inline", sa_info, None
except Exception:
logger.warning("Failed to parse inline serviceAccount")
if is_default:
sa_env = os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT")
if sa_env:
try:
sa_info = json.loads(sa_env)
return "env", sa_info, None
except Exception:
logger.warning("Failed to parse GOOGLE_CHAT_SERVICE_ACCOUNT env var")
sa_file_env = os.getenv("GOOGLE_CHAT_SERVICE_ACCOUNT_FILE")
if sa_file_env:
try:
with open(sa_file_env, encoding="utf-8") as f:
sa_info = json.load(f)
return "env_file", sa_info, sa_file_env
except Exception:
logger.warning("Failed to read GOOGLE_CHAT_SERVICE_ACCOUNT_FILE: %s", sa_file_env)
return "none", None, None
@staticmethod
def validate_service_account(sa_info: dict) -> list[str]:
errors = []
if sa_info.get("type") and sa_info["type"] != "service_account":
errors.append(f"invalid type: {sa_info['type']}, expected service_account")
if not sa_info.get("client_email"):
errors.append("missing client_email")
if not sa_info.get("private_key"):
errors.append("missing private_key")
universe = sa_info.get("universe_domain")
if universe and universe != "googleapis.com":
errors.append(f"unexpected universe_domain: {universe}, expected googleapis.com")
auth_uri = sa_info.get("auth_uri")
if auth_uri and auth_uri != "https://accounts.google.com/o/oauth2/auth":
errors.append(f"unexpected auth_uri: {auth_uri}")
token_uri = sa_info.get("token_uri")
if token_uri and token_uri != "https://oauth2.googleapis.com/token":
errors.append(f"unexpected token_uri: {token_uri}")
cert_url = sa_info.get("auth_provider_x509_cert_url")
if cert_url and cert_url != "https://www.googleapis.com/oauth2/v1/certs":
errors.append(f"unexpected auth_provider_x509_cert_url: {cert_url}")
client_cert = sa_info.get("client_x509_cert_url")
if client_cert and not client_cert.startswith("https://www.googleapis.com/robot/v1/metadata/x509/"):
errors.append(f"unexpected client_x509_cert_url: {client_cert}")
return errors

View File

@ -0,0 +1,39 @@
from __future__ import annotations
import time
from collections import OrderedDict
class GoogleChatDedupeStore:
def __init__(self, ttl_seconds: int = 300, max_entries: int = 10000):
self._store: OrderedDict[str, float] = OrderedDict()
self._ttl_seconds = ttl_seconds
self._max_entries = max_entries
def is_duplicate(self, key: str) -> bool:
self._evict_expired()
return key in self._store
def mark_seen(self, key: str) -> None:
self._evict_expired()
self._store[key] = time.monotonic()
if len(self._store) > self._max_entries:
self._store.popitem(last=False)
def reset(self) -> None:
self._store.clear()
@property
def ttl_seconds(self) -> int:
return self._ttl_seconds
@property
def max_entries(self) -> int:
return self._max_entries
def _evict_expired(self) -> None:
now = time.monotonic()
cutoff = now - self._ttl_seconds
stale = [k for k, v in self._store.items() if v < cutoff]
for k in stale:
del self._store[k]

View File

@ -0,0 +1,96 @@
from __future__ import annotations
import re
_CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
_MULTI_NEWLINE_PATTERN = re.compile(r"\n{3,}")
def sanitize_text(text: str) -> str:
cleaned = _CONTROL_CHAR_PATTERN.sub("", text)
cleaned = _MULTI_NEWLINE_PATTERN.sub("\n\n", cleaned)
return cleaned.strip()
def chunk_markdown_text(text: str, limit: int = 4000) -> list[str]:
if len(text) <= limit:
return [text]
chunks = []
lines = text.split("\n")
current = ""
for line in lines:
if len(current) + len(line) + 1 > limit:
if current:
chunks.append(current.strip())
current = ""
if len(line) > limit:
while len(line) > limit:
split_at = _find_code_block_split(line, limit)
chunks.append(line[:split_at].strip())
line = line[split_at:].strip()
if line:
current = line
else:
current = line
else:
if current:
current += "\n" + line
else:
current = line
if current.strip():
chunks.append(current.strip())
return chunks
def _find_code_block_split(line: str, limit: int) -> int:
in_block = False
chars = 0
for i, ch in enumerate(line):
if ch == "`":
if line[i:i+3] == "```":
in_block = not in_block
if not in_block and chars >= limit:
return i
chars += 1
return limit
def format_conversation_label(user_name: str, space_name: str) -> str:
if space_name:
return f"googlechat::{space_name}"
return f"googlechat::{user_name}"
def build_typing_indicator_text(bot_name: str) -> str:
return f"_{bot_name} is typing..._"
def resolve_bot_display_name(account: dict) -> str:
resolved = account.get("resolved")
if resolved and resolved.name:
return resolved.name
return account.get("name", "Bot")
def format_thread_context(recent_messages: list[dict]) -> str:
if not recent_messages:
return ""
lines = [
"[Thread conversation - previous replies. You are participating in this thread.]",
"",
"[Previous messages]",
]
for msg in recent_messages[-10:]:
author = msg.get("author", "Unknown")
content = msg.get("content", "")
if content:
lines.append(f"{author}: {content}")
lines.append("")
lines.append("[Current message]")
return "\n".join(lines)

View File

@ -0,0 +1,109 @@
from __future__ import annotations
import asyncio
import logging
import time
from yuxi.channel.extensions.googlechat.auth import probe_account
from yuxi.channel.extensions.googlechat.config import GoogleChatConfigAdapter
from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount
from yuxi.channel.extensions.googlechat.webhook import GoogleChatWebhookHandler
from yuxi.channel.gateway.routes import webhook_registry
logger = logging.getLogger(__name__)
_STATUS_OK = "ok"
_STATUS_ERROR = "error"
class GoogleChatGatewayAdapter:
def __init__(self):
self._config_adapter = GoogleChatConfigAdapter()
self._webhook_handler = GoogleChatWebhookHandler()
self._running: dict[str, bool] = {}
self._start_times: dict[str, float] = {}
self._status_snapshots: dict[str, dict] = {}
async def start(self, ctx) -> object:
account_dict = getattr(ctx, "account", {}) or {}
account = account_dict.get("resolved")
if not isinstance(account, ResolvedGoogleChatAccount):
account_id = account_dict.get("account_id", "default")
resolved_dict = await self._config_adapter.resolve_account(account_id)
account = resolved_dict.get("resolved")
if not account or not account.is_configured:
logger.warning("Google Chat account not configured: %s", account.account_id if account else "unknown")
return asyncio.Queue()
account_id = account.account_id
webhook_path = account.webhook_path
self._webhook_handler.register_target(webhook_path, account)
webhook_registry.register(
"googlechat",
self._webhook_handler.handle_webhook,
guard_config=None,
)
self._running[account_id] = True
self._start_times[account_id] = time.time()
self._status_snapshots[account_id] = {
"running": True,
"webhook_path": webhook_path,
"audience_type": account.audience_type,
"audience": account.audience,
"credential_source": account.credential_source,
"start_time": time.time(),
}
self._warn_app_principal_misconfiguration(account)
logger.info("Google Chat gateway started for account: %s, webhook: %s", account_id, webhook_path)
queue = getattr(ctx, "queue", asyncio.Queue())
return queue
async def stop(self, ctx) -> None:
account_dict = getattr(ctx, "account", {}) or {}
account_id = account_dict.get("account_id", "default")
account = account_dict.get("resolved")
if isinstance(account, ResolvedGoogleChatAccount):
self._webhook_handler.unregister_target(account.webhook_path)
self._running.pop(account_id, None)
self._status_snapshots[account_id] = {
"running": False,
"stop_time": time.time(),
}
logger.info("Google Chat gateway stopped for account: %s", account_id)
def get_status(self, account_id: str) -> dict:
return self._status_snapshots.get(account_id, {})
async def probe(self, account_dict: dict) -> dict:
account = account_dict.get("resolved")
if not isinstance(account, ResolvedGoogleChatAccount):
return {"ok": False, "status": "no_account"}
return await probe_account(account)
@staticmethod
def _warn_app_principal_misconfiguration(account: ResolvedGoogleChatAccount) -> None:
if account.audience_type != "app-url":
return
if not account.app_principal:
logger.error(
'appPrincipal is missing for audienceType "app-url"; '
"add-on token verification will fail. "
"Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email."
)
elif "@" in account.app_principal:
logger.error(
'appPrincipal "%s" looks like an email address. '
"Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email.",
account.app_principal,
)

View File

@ -0,0 +1,122 @@
from __future__ import annotations
import logging
import os
import tempfile
import httpx
from yuxi.channel.extensions.googlechat.api import (
download_media_content,
upload_attachment,
)
from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount
logger = logging.getLogger(__name__)
_DEFAULT_MEDIA_MAX_MB = 20
async def download_attachment(
account: ResolvedGoogleChatAccount,
resource_name: str,
max_mb: int = _DEFAULT_MEDIA_MAX_MB,
) -> tuple[bytes, str, str]:
max_bytes = max_mb * 1024 * 1024
content = await download_media_content(account, resource_name, max_bytes)
content_type = "application/octet-stream"
try:
import magic
content_type = magic.Magic(mime=True).from_buffer(content)
except ImportError:
if content[:4] == b"\xff\xd8\xff":
content_type = "image/jpeg"
elif content[:4] == b"\x89PNG":
content_type = "image/png"
elif content[:6] in (b"GIF87a", b"GIF89a"):
content_type = "image/gif"
ext = _mime_to_ext(content_type)
filename = f"attachment{ext}"
return content, filename, content_type
async def upload_media_file(
account: ResolvedGoogleChatAccount,
space_name: str,
file_path: str,
content_type: str | None = None,
) -> dict:
if not content_type:
try:
import magic
content_type = magic.Magic(mime=True).from_file(file_path)
except ImportError:
content_type = "application/octet-stream"
filename = os.path.basename(file_path)
with open(file_path, "rb") as f:
content = f.read()
return await upload_attachment(account, space_name, filename, content, content_type)
async def fetch_remote_media(url: str, max_mb: int = _DEFAULT_MEDIA_MAX_MB) -> tuple[bytes, str, str]:
max_bytes = max_mb * 1024 * 1024
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
resp = await client.get(url)
resp.raise_for_status()
content = resp.content
if len(content) > max_bytes:
raise ValueError(f"Media size {len(content)} exceeds limit {max_bytes}")
content_type = resp.headers.get("content-type", "application/octet-stream")
ext = _mime_to_ext(content_type)
filename = f"downloaded{ext}"
return content, filename, content_type
def _mime_to_ext(mime: str) -> str:
ext_map = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"video/mp4": ".mp4",
"video/webm": ".webm",
"audio/mpeg": ".mp3",
"audio/wav": ".wav",
"application/pdf": ".pdf",
"text/plain": ".txt",
"text/html": ".html",
}
return ext_map.get(mime, ".bin")
def build_attachment_placeholder(attachments: list[dict]) -> str:
if not attachments:
return ""
types: dict[str, int] = {}
for att in attachments:
ct = att.get("contentType", "unknown")
main_type = ct.split("/")[0]
types[main_type] = types.get(main_type, 0) + 1
parts = []
for t, count in types.items():
parts.append(f"{count} {t}")
if len(attachments) == 1:
ct = attachments[0].get("contentType", "")
main = ct.split("/")[0]
return f"<media:{main}>"
joined = " + ".join(parts)
return f"[{joined} attached]"

View File

@ -0,0 +1,169 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from yuxi.channel.extensions.googlechat.formatting import sanitize_text
from yuxi.channel.extensions.googlechat.media import build_attachment_placeholder
from yuxi.channel.extensions.googlechat.types import GoogleChatEvent, ResolvedGoogleChatAccount
from yuxi.channel.message.models import (
GroupContext,
MessageType,
PeerInfo,
UnifiedMessage,
)
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
class GoogleChatMonitor:
def __init__(self):
self._last_event_time: float = 0
def event_to_unified_message(
self,
raw_payload: dict,
event: GoogleChatEvent,
text: str | None = None,
account: ResolvedGoogleChatAccount | None = None,
) -> UnifiedMessage | None:
if not event.message:
return None
msg = event.message
sender = msg.sender
space_name = event.effective_space_name
space_type = event.space_type
safe_text = text if text is not None else sanitize_text(msg.argument_text or msg.text)
if not safe_text:
return None
is_dm = space_type == "DM" or event.effective_space.single_user_bot_dm if event.effective_space else False
is_group = space_type in ("GROUP_CHAT", "SPACE") and not is_dm
sender_kind = PeerKind.DIRECT if is_dm else PeerKind.GROUP
sender_info = PeerInfo(
kind=sender_kind,
id=sender.name or "unknown",
display_name=sender.display_name,
username=sender.email,
is_bot=sender.type == "BOT",
)
group = None
if is_group:
group = GroupContext(
id=space_name,
name=event.effective_space.display_name if event.effective_space else None,
kind="group",
)
thread_id = None
if msg.thread:
thread_id = msg.thread.get("name")
timestamp = None
if event.event_time:
try:
from datetime import datetime as dt
timestamp = dt.fromisoformat(event.event_time.replace("Z", "+00:00"))
except (ValueError, TypeError):
pass
media_urls = []
media_types = []
for att in msg.attachment:
dr = att.get("driveDataRef") or att.get("attachmentDataRef")
if dr:
media_urls.append(dr.get("resourceName", ""))
media_types.append(att.get("contentType", ""))
if msg.attachment:
placeholder = build_attachment_placeholder(msg.attachment)
if placeholder:
safe_text = f"{placeholder}\n{safe_text}"
was_mentioned = _detect_mention(msg.annotations)
slash_cmd = _detect_slash_command(msg.annotations)
metadata = {
"ChatType": "direct" if is_dm else "group",
"WasMentioned": was_mentioned,
"CommandAuthorized": True,
"space_type": space_type,
"space_name": space_name,
}
if slash_cmd:
metadata["SlashCommand"] = slash_cmd
if is_group and event.effective_space:
metadata["GroupSpace"] = event.effective_space.display_name
return UnifiedMessage(
msg_id=msg.name or f"gc:{int(datetime.now(tz=timezone.utc).timestamp())}",
channel_type="googlechat",
account_id=account.account_id if account else "default",
content=safe_text,
sender=sender_info,
message_type=MessageType.TEXT,
media_urls=media_urls,
media_types=media_types,
group=group,
timestamp=timestamp,
raw_payload=raw_payload,
message_thread_id=thread_id,
metadata=metadata,
was_mentioned=was_mentioned,
)
async def inject_thread_context(
self,
account: ResolvedGoogleChatAccount,
space_name: str,
thread_name: str,
) -> str | None:
try:
from yuxi.channel.extensions.googlechat.api import list_thread_messages
from yuxi.channel.extensions.googlechat.formatting import format_thread_context
messages = await list_thread_messages(account, space_name, thread_name, page_size=20)
if not messages:
return None
history = []
for msg in messages:
sender = msg.get("sender", {})
history.append({
"author": sender.get("displayName", sender.get("name", "Unknown")),
"content": msg.get("text", msg.get("argumentText", "")),
})
return format_thread_context(history)
except Exception:
logger.debug("Failed to fetch thread history for %s in %s", thread_name, space_name)
return None
def _detect_mention(annotations: list[dict]) -> bool:
if not annotations:
return False
for ann in annotations:
if ann.get("type") == "USER_MENTION":
um = ann.get("userMention", {})
mention_user = um.get("user", {})
if mention_user.get("name") == "users/app":
return True
return False
def _detect_slash_command(annotations: list[dict]) -> str | None:
if not annotations:
return None
for ann in annotations:
if ann.get("type") == "SLASH_COMMAND":
sc = ann.get("slashCommand", {})
return sc.get("commandId")
return None

View File

@ -0,0 +1,272 @@
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

View File

@ -0,0 +1,62 @@
from __future__ import annotations
import logging
from yuxi.channel.extensions.googlechat.api import send_message
logger = logging.getLogger(__name__)
_PARING_CODE_LENGTH = 6
_pairing_store: dict[str, dict] = {}
class GoogleChatPairingAdapter:
id_label = "Google Chat User ID"
def normalize_allow_entry(self, entry: str) -> str:
for prefix in ("googlechat:", "google-chat:", "gchat:"):
if entry.lower().startswith(prefix):
entry = entry[len(prefix):]
break
return entry.lower()
async def generate_code(self, peer_id: str) -> str:
import random
import string
code = "".join(random.choices(string.ascii_uppercase + string.digits, k=_PARING_CODE_LENGTH))
_pairing_store[peer_id] = {
"code": code,
"status": "pending",
}
return code
async def verify_code(self, peer_id: str, code: str) -> bool:
stored = _pairing_store.get(peer_id)
if stored and stored.get("code") == code:
stored["status"] = "verified"
return True
return False
async def notify_approval(
self, config: dict, peer_id: str, account_id: str | None = None
) -> None:
logger.info("Pairing approved for peer: %s (account: %s)", peer_id, account_id)
async def send_pairing_challenge(
self, account, space_name: str, peer_id: str
) -> str:
code = await self.generate_code(peer_id)
text = (
f"🔒 *Access Request*\n\n"
f"Your user ID: `{peer_id}`\n"
f"Pairing code: `{code}`\n\n"
f"An administrator must approve this pairing request before you can interact with this bot."
)
result = await send_message(account, space_name, text)
return result.get("name", "")
def is_paired(self, peer_id: str) -> bool:
stored = _pairing_store.get(peer_id)
return stored is not None and stored.get("status") == "verified"

View File

@ -0,0 +1,183 @@
{
"id": "googlechat",
"name": "Google Chat",
"version": "1.0.0",
"description": "Google Chat 渠道插件,支持通过 Google Chat API 收发消息、卡片交互和线程对话。",
"author": "Yuxi Team",
"license": "MIT",
"entry_point": "yuxi.channel.extensions.googlechat",
"capabilities": {
"chat_types": ["direct", "group", "thread"],
"message_types": ["text", "image", "file", "video"],
"interactions": [],
"reactions": true,
"typing_indicator": true,
"threads": true,
"edit": true,
"unsend": true,
"reply": true,
"media": true,
"native_commands": false,
"polls": false,
"group_management": false,
"streaming": false,
"streaming_mode": null,
"block_streaming": true,
"block_streaming_chunk_min_chars": 4000,
"block_streaming_chunk_max_chars": 4000,
"block_streaming_coalesce_min_chars": 1500,
"block_streaming_coalesce_max_chars": 4000,
"adaptive_cards": true,
"sse_support": false
},
"config_schema": {
"type": "object",
"properties": {
"service_account_json": {
"type": "string",
"title": "Service Account JSON",
"description": "Google Cloud Service Account 凭证 JSON 字符串或文件路径",
"format": "textarea"
},
"space_allowlist": {
"type": "array",
"title": "Space Allowlist",
"description": "允许访问的 Google Chat Space ID 列表",
"items": {
"type": "string"
}
},
"dm_policy": {
"type": "string",
"title": "DM Policy",
"description": "私信访问控制策略",
"enum": ["open", "pairing", "closed"],
"default": "pairing"
},
"group_policy": {
"type": "string",
"title": "Group Policy",
"description": "群组访问控制策略",
"enum": ["open", "allowlist", "closed"],
"default": "allowlist"
},
"webhook_secret": {
"type": "string",
"title": "Webhook Secret",
"description": "Google Chat webhook 验证密钥",
"format": "password"
},
"bot_name": {
"type": "string",
"title": "Bot Name",
"description": "机器人在 Google Chat 中显示的名称",
"default": "Yuxi Bot"
},
"avatar_url": {
"type": "string",
"title": "Avatar URL",
"description": "机器人头像 URL",
"format": "uri"
},
"auto_join_spaces": {
"type": "boolean",
"title": "Auto Join Spaces",
"description": "是否自动加入邀请的 Space",
"default": true
},
"card_response_enabled": {
"type": "boolean",
"title": "Card Response Enabled",
"description": "是否启用卡片消息响应",
"default": true
},
"threading_enabled": {
"type": "boolean",
"title": "Threading Enabled",
"description": "是否启用线程对话支持",
"default": true
},
"mention_required_in_groups": {
"type": "boolean",
"title": "Mention Required in Groups",
"description": "群组中是否需要 @提及 机器人才响应",
"default": true
},
"max_message_length": {
"type": "integer",
"title": "Max Message Length",
"description": "单条消息最大字符数",
"default": 4096,
"minimum": 100,
"maximum": 4096
},
"rate_limit_per_minute": {
"type": "integer",
"title": "Rate Limit Per Minute",
"description": "每分钟最大请求数",
"default": 60,
"minimum": 10,
"maximum": 300
}
},
"required": ["service_account_json"]
},
"webhook": {
"enabled": true,
"path": "/webhooks/googlechat",
"methods": ["POST"],
"auth_type": "hmac",
"secret_header": "X-Goog-Signature"
},
"dependencies": ["httpx", "google-auth"],
"min_api_version": "1.0.0",
"documentation_url": "https://developers.google.com/chat",
"icon": "googlechat",
"color": "#00832d",
"tags": ["google", "chat", "workspace", "enterprise"],
"features": {
"rich_text": false,
"markdown_support": true,
"html_support": true,
"emoji_support": true,
"file_sharing": true,
"voice_messages": false,
"video_messages": false,
"location_sharing": false,
"contact_cards": false,
"message_search": true,
"threaded_replies": true,
"message_editing": true,
"message_deletion": true,
"typing_indicators": true,
"read_receipts": false,
"delivery_receipts": true,
"bot_commands": true,
"slash_commands": true,
"interactive_cards": true,
"buttons": true,
"quick_replies": false,
"menus": false,
"forms": true,
"polls": false,
"scheduling": false,
"reminders": false,
"notifications": true,
"mentions": true,
"hashtags": false,
"custom_status": false,
"presence_indicators": false,
"user_profiles": true,
"group_profiles": true,
"channel_directory": false,
"guest_access": true,
"single_sign_on": true,
"two_factor_auth": true,
"end_to_end_encryption": false,
"data_residency": true,
"gdpr_compliant": true,
"hipaa_compliant": false,
"soc2_compliant": true,
"iso27001_compliant": true
}
}

View File

@ -0,0 +1,66 @@
from __future__ import annotations
import logging
from yuxi.channel.extensions.googlechat.api import (
create_reaction,
delete_reaction,
list_reactions,
)
from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount
logger = logging.getLogger(__name__)
async def add_reaction(
account: ResolvedGoogleChatAccount,
message_name: str,
emoji: str,
) -> dict:
return await create_reaction(account, message_name, emoji)
async def remove_reaction(
account: ResolvedGoogleChatAccount,
message_name: str,
emoji: str,
bot_user: str | None = None,
) -> bool:
reactions = await list_reactions(account, message_name)
target = None
for r in reactions:
user = r.get("user", {})
user_name = user.get("name", "")
if user_name == "users/app" or (bot_user and user_name == bot_user):
if r.get("emoji", {}).get("unicode") == emoji:
target = r
break
if not target:
return False
await delete_reaction(account, target["name"])
return True
async def get_reactions(
account: ResolvedGoogleChatAccount,
message_name: str,
) -> list[dict]:
return await list_reactions(account, message_name)
async def execute_react_action(
account: ResolvedGoogleChatAccount,
message_name: str,
emoji: str,
remove: bool = False,
bot_user: str | None = None,
) -> dict:
if remove:
removed = await remove_reaction(account, message_name, emoji, bot_user)
return {"success": removed, "action": "remove", "emoji": emoji}
else:
result = await add_reaction(account, message_name, emoji)
return {"success": True, "action": "add", "emoji": emoji, "result": result}

View File

@ -0,0 +1,97 @@
from __future__ import annotations
import logging
from yuxi.channel.extensions.googlechat.api import (
delete_message,
send_message,
update_message,
)
from yuxi.channel.extensions.googlechat.formatting import (
build_typing_indicator_text,
chunk_markdown_text,
resolve_bot_display_name,
)
from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount
logger = logging.getLogger(__name__)
class TypingPlaceholder:
def __init__(self, message_name: str, space_name: str, consumed: bool = False):
self.message_name = message_name
self.space_name = space_name
self.consumed = consumed
async def send_typing_placeholder(
account: ResolvedGoogleChatAccount,
space_name: str,
bot_name: str | None = None,
) -> TypingPlaceholder:
name = bot_name or resolve_bot_display_name(account.config if hasattr(account, "config") else {})
text = build_typing_indicator_text(name)
result = await send_message(account, space_name, text)
message_name = result.get("name", "")
return TypingPlaceholder(message_name=message_name, space_name=space_name)
async def deliver_text_chunk(
account: ResolvedGoogleChatAccount,
space_name: str,
text: str,
placeholder: TypingPlaceholder | None = None,
thread_name: str | None = None,
) -> str:
if placeholder and not placeholder.consumed:
await update_message(account, placeholder.message_name, text)
placeholder.consumed = True
return placeholder.message_name
if placeholder and placeholder.consumed:
result = await send_message(account, space_name, text, thread_name=thread_name)
return result.get("name", "")
result = await send_message(account, space_name, text, thread_name=thread_name)
return result.get("name", "")
async def deliver_text_with_streaming(
account: ResolvedGoogleChatAccount,
space_name: str,
full_text: str,
bot_name: str | None = None,
thread_name: str | None = None,
text_chunk_limit: int = 4000,
) -> list[str]:
chunks = chunk_markdown_text(full_text, text_chunk_limit)
if not chunks:
return []
placeholder = await send_typing_placeholder(account, space_name, bot_name)
message_names = []
for i, chunk in enumerate(chunks):
try:
name = await deliver_text_chunk(
account, space_name, chunk, placeholder if i == 0 else None, thread_name
)
if name:
message_names.append(name)
except Exception as e:
logger.warning("Failed to deliver chunk %d: %s", i, e)
return message_names
async def clear_typing_placeholder(
account: ResolvedGoogleChatAccount,
placeholder: TypingPlaceholder,
) -> None:
try:
await delete_message(account, placeholder.message_name)
except Exception:
try:
await update_message(account, placeholder.message_name, "")
except Exception:
pass

View File

@ -0,0 +1,87 @@
from __future__ import annotations
import logging
import re
logger = logging.getLogger(__name__)
_EMAIL_PATTERN = re.compile(r".+@.+\..+")
class GoogleChatSecurityAdapter:
def __init__(self):
self._dm_policy: str = "pairing"
def resolve_dm_policy(self) -> dict:
return {"mode": self._dm_policy, "allow_from": []}
def check_allow_entry(self, entry: str) -> bool:
if not entry:
return False
if entry.startswith("googlechat:"):
entry = entry[len("googlechat:"):]
if entry.startswith("users/") and _EMAIL_PATTERN.match(entry[6:]):
return False
return bool(entry)
def normalize_allow_entry(self, entry: str) -> str:
if not entry:
return entry
for prefix in ("googlechat:", "google-chat:", "gchat:"):
if entry.lower().startswith(prefix):
entry = entry[len(prefix):]
break
if entry.startswith("user:users/"):
entry = entry[5:]
elif entry.startswith("space:spaces/"):
entry = entry[6:]
return entry.lower()
def collect_warnings(self, config: dict, account_id: str | None = None) -> list[str]:
warnings = []
gc_config = config.get("channels", {}).get("googlechat", {})
group_policy = gc_config.get("groupPolicy", "allowlist")
if group_policy == "open":
warnings.append(
"Group policy is 'open' - any Google Chat Space can trigger the bot. Consider using 'allowlist' for production."
)
dm_config = gc_config.get("dm", {})
dm_policy = dm_config.get("policy", "pairing")
if dm_policy == "open":
warnings.append(
"DM policy is 'open' - anyone can DM the bot. Consider using 'pairing' or 'allowlist' for production."
)
allow_from = dm_config.get("allowFrom", [])
for entry in allow_from:
normalized = self.normalize_allow_entry(entry)
if normalized.startswith("users/") and "@" in normalized:
warnings.append(
f"DM allowFrom entry '{entry}' uses email format. Email addresses are mutable; use users/<numeric_id> instead."
)
groups = gc_config.get("groups", {})
for group_key in groups:
if not group_key.startswith("spaces/") and group_key != "*":
warnings.append(
f"Group key '{group_key}' is not a stable spaces/<id> name. This may break routing."
)
return warnings
def check_dm_allowlist(
self, sender_name: str, allow_from: list[str], dangerously_allow_name_matching: bool = False
) -> bool:
if "*" in allow_from:
return True
for entry in allow_from:
if "@" in entry and not dangerously_allow_name_matching:
continue
normalized = self.normalize_allow_entry(entry)
if normalized == self.normalize_allow_entry(sender_name):
return True
return False

View File

@ -0,0 +1,48 @@
from __future__ import annotations
from yuxi.channel.protocols import SessionResolution
class GoogleChatSessionAdapter:
def resolve_session(self, msg) -> SessionResolution:
from yuxi.channel.routing.models import PeerKind
if hasattr(msg, "sender") and hasattr(msg.sender, "kind"):
if msg.sender.kind == PeerKind.DIRECT:
space_id = _extract_space_id(msg)
return SessionResolution(
kind="direct",
conversation_id=space_id or msg.sender.id,
)
space_id = _extract_space_id(msg)
if space_id:
return SessionResolution(kind="group", conversation_id=space_id)
if msg.group and msg.group.id:
return SessionResolution(kind="group", conversation_id=msg.group.id)
return SessionResolution(kind="group", conversation_id="unknown")
@staticmethod
def build_dm_session_key(agent_id: str, account_id: str, space_id: str) -> str:
return f"agent:{agent_id}:googlechat:direct:{space_id}"
@staticmethod
def build_group_session_key(agent_id: str, account_id: str, space_id: str) -> str:
return f"agent:{agent_id}:googlechat:group:{space_id}"
def _extract_space_id(msg) -> str | None:
metadata = getattr(msg, "metadata", {}) or {}
space_name = metadata.get("space_name", "")
if space_name:
return space_name
raw = getattr(msg, "raw_payload", {}) or {}
event = raw.get("event", raw)
space = event.get("space", {})
if space:
return space.get("name")
return None

View File

@ -0,0 +1,82 @@
from __future__ import annotations
import logging
from yuxi.channel.extensions.googlechat.auth import probe_account
from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount
logger = logging.getLogger(__name__)
class GoogleChatStatusAdapter:
async def probe(self, account_dict: dict) -> bool:
account = account_dict.get("resolved")
if not isinstance(account, ResolvedGoogleChatAccount):
return False
result = await probe_account(account)
return result.get("ok", False)
def build_summary(self, snapshot) -> dict:
return {
"channel": "googlechat",
"mode": "webhook",
}
def build_account_snapshot(self, account_dict: dict) -> dict:
return {
"account_id": account_dict.get("account_id", "default"),
"name": account_dict.get("name", ""),
"configured": account_dict.get("credential_source", "none") != "none",
"enabled": account_dict.get("enabled", True),
}
def collect_status_issues(self, accounts: list[dict]) -> list[dict]:
issues = []
for account in accounts:
if not account:
continue
if not account.get("audience"):
issues.append({
"channel": "googlechat",
"account_id": account.get("account_id", "default"),
"kind": "audience_missing",
"message": "Audience is not configured. Webhook token verification will fail.",
"fix": "Set channels.googlechat.audience and channels.googlechat.audienceType.",
})
if not account.get("audience_type"):
issues.append({
"channel": "googlechat",
"account_id": account.get("account_id", "default"),
"kind": "audience_type_missing",
"message": "audienceType is not configured.",
"fix": "Set channels.googlechat.audienceType to 'app-url' or 'project-number'.",
})
resolved = account.get("resolved")
if isinstance(resolved, ResolvedGoogleChatAccount):
if resolved.audience_type == "app-url" and not resolved.app_principal:
issues.append({
"channel": "googlechat",
"account_id": account.get("account_id", "default"),
"kind": "app_principal_missing",
"message": (
'appPrincipal is missing for audienceType "app-url"; '
"add-on token verification will fail. "
"Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email."
),
})
if resolved.app_principal and "@" in resolved.app_principal:
issues.append({
"channel": "googlechat",
"account_id": account.get("account_id", "default"),
"kind": "app_principal_is_email",
"message": (
f'appPrincipal "{resolved.app_principal}" looks like an email address. '
"Set appPrincipal to the numeric OAuth 2.0 client ID (uniqueId, 21 digits), not an email."
),
})
return issues

View File

@ -0,0 +1,38 @@
from __future__ import annotations
import logging
from yuxi.channel.extensions.googlechat.api import send_message
from yuxi.channel.extensions.googlechat.formatting import chunk_markdown_text
from yuxi.channel.extensions.googlechat.types import ResolvedGoogleChatAccount
logger = logging.getLogger(__name__)
async def stream_text_by_chunks(
account: ResolvedGoogleChatAccount,
space_name: str,
full_text: str,
*,
thread_name: str | None = None,
chunk_limit: int = 4000,
) -> list[str]:
chunks = chunk_markdown_text(full_text, chunk_limit)
message_names: list[str] = []
for chunk in chunks:
result = await send_message(account, space_name, chunk, thread_name=thread_name)
name = result.get("name", "")
if name:
message_names.append(name)
return message_names
async def send_coalesced_text(
account: ResolvedGoogleChatAccount,
space_name: str,
text: str,
*,
thread_name: str | None = None,
) -> str:
result = await send_message(account, space_name, text, thread_name=thread_name)
return result.get("name", "")

View File

@ -0,0 +1,46 @@
from __future__ import annotations
def normalize_googlechat_target(raw: str) -> str | None:
if not raw:
return None
t = raw.strip()
for prefix in ("googlechat:", "google-chat:", "gchat:"):
if t.lower().startswith(prefix):
t = t[len(prefix):]
t = t.strip()
break
if t.lower().startswith("user:users/"):
t = t[5:]
elif t.lower().startswith("space:spaces/"):
t = t[6:]
if "@" in t and not t.startswith("users/") and not t.startswith("spaces/"):
t = f"users/{t.lower()}"
return t
def strip_message_suffix(target: str) -> str:
idx = target.find("/messages/")
return target[:idx] if idx != -1 else target
async def resolve_outbound_space(account, target: str) -> str | None:
from yuxi.channel.extensions.googlechat.api import find_direct_message
normalized = normalize_googlechat_target(target)
if not normalized:
return None
base = strip_message_suffix(normalized)
if base.startswith("spaces/"):
return base
if base.startswith("users/"):
return await find_direct_message(account, base)
return None
def is_space_target(target: str) -> bool:
return target.startswith("spaces/")
def is_user_target(target: str) -> bool:
return target.startswith("users/")

View File

@ -0,0 +1,189 @@
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class GoogleChatUser:
name: str = ""
display_name: str | None = None
email: str | None = None
type: str = "HUMAN"
avatar_url: str | None = None
@dataclass
class GoogleChatSpace:
name: str = ""
type: str = "GROUP_CHAT"
display_name: str | None = None
single_user_bot_dm: bool = False
@dataclass
class GoogleChatAttachment:
name: str | None = None
content_name: str | None = None
content_type: str | None = None
attachment_upload_token: str | None = None
drive_data_ref: dict | None = None
thumbnail_uri: str | None = None
download_uri: str | None = None
source: str = "UPLOAD_CONTENT"
@dataclass
class GoogleChatAnnotation:
type: str = ""
user_mention: dict | None = None
slash_command: dict | None = None
@dataclass
class GoogleChatMessage:
name: str = ""
sender: GoogleChatUser = field(default_factory=GoogleChatUser)
space: GoogleChatSpace = field(default_factory=GoogleChatSpace)
text: str = ""
argument_text: str = ""
thread: dict | None = None
attachment: list[dict] = field(default_factory=list)
annotations: list[dict] = field(default_factory=list)
create_time: str = ""
fallback_text: str = ""
formatted_text: str = ""
last_update_time: str = ""
@dataclass
class GoogleChatEvent:
type: str = ""
event_time: str = ""
message: GoogleChatMessage | None = None
space: GoogleChatSpace | None = None
user: GoogleChatUser | None = None
thread: dict | None = None
reaction: dict | None = None
config_complete_redirect_url: str | None = None
is_dialog_event: bool = False
dialog_action_type: str | None = None
common: dict | None = None
@classmethod
def from_payload(cls, payload: dict) -> GoogleChatEvent:
event_data = payload.get("event", payload)
event = cls(
type=event_data.get("type", ""),
event_time=event_data.get("eventTime", ""),
config_complete_redirect_url=payload.get("configCompleteRedirectUrl"),
is_dialog_event=payload.get("isDialogEvent", False),
dialog_action_type=payload.get("dialogActionType"),
common=payload.get("common"),
)
space_data = event_data.get("space") or payload.get("space")
if space_data:
event.space = GoogleChatSpace(
name=space_data.get("name", ""),
type=space_data.get("type", "GROUP_CHAT"),
display_name=space_data.get("displayName"),
single_user_bot_dm=space_data.get("singleUserBotDm", False),
)
user_data = event_data.get("user") or payload.get("user")
if user_data:
event.user = GoogleChatUser(
name=user_data.get("name", ""),
display_name=user_data.get("displayName"),
email=user_data.get("email"),
type=user_data.get("type", "HUMAN"),
avatar_url=user_data.get("avatarUrl"),
)
msg_data = event_data.get("message")
if msg_data:
sender_data = msg_data.get("sender", {})
event.message = GoogleChatMessage(
name=msg_data.get("name", ""),
sender=GoogleChatUser(
name=sender_data.get("name", ""),
display_name=sender_data.get("displayName"),
email=sender_data.get("email"),
type=sender_data.get("type", "HUMAN"),
avatar_url=sender_data.get("avatarUrl"),
),
text=msg_data.get("text", ""),
argument_text=msg_data.get("argumentText", ""),
thread=msg_data.get("thread"),
attachment=msg_data.get("attachment", []),
annotations=msg_data.get("annotations", []),
create_time=msg_data.get("createTime", ""),
fallback_text=msg_data.get("fallbackText", ""),
formatted_text=msg_data.get("formattedText", ""),
last_update_time=msg_data.get("lastUpdateTime", ""),
)
if msg_data.get("space"):
msg_space = msg_data["space"]
event.message.space = GoogleChatSpace(
name=msg_space.get("name", ""),
type=msg_space.get("type", "GROUP_CHAT"),
display_name=msg_space.get("displayName"),
single_user_bot_dm=msg_space.get("singleUserBotDm", False),
)
return event
@property
def effective_space(self) -> GoogleChatSpace | None:
if self.space:
return self.space
if self.message and self.message.space:
return self.message.space
return None
@property
def effective_space_name(self) -> str:
s = self.effective_space
return s.name if s else ""
@property
def space_type(self) -> str:
s = self.effective_space
return s.type if s else "GROUP_CHAT"
@dataclass
class ResolvedGoogleChatAccount:
account_id: str
name: str = ""
credential_source: str = "none"
service_account_info: dict | None = None
service_account_file: str | None = None
audience_type: str | None = None
audience: str | None = None
webhook_path: str = "/api/webhook/googlechat"
webhook_url: str | None = None
bot_user: str | None = None
app_principal: str | None = None
group_policy: str = "allowlist"
require_mention: bool = True
allow_text_commands: bool = False
reply_to_mode: str = "off"
text_chunk_limit: int = 4000
media_max_mb: int = 20
typing_indicator: str = "message"
allow_bots: bool = False
dangerously_allow_name_matching: bool = False
actions_reactions: bool = True
dm_policy: str = "pairing"
dm_enabled: bool = True
dm_allow_from: list[str] = field(default_factory=list)
groups: dict = field(default_factory=dict)
block_streaming_coalesce_min_chars: int = 1500
block_streaming_coalesce_idle_ms: int = 1000
enabled: bool = True
config: dict = field(default_factory=dict)
@property
def is_configured(self) -> bool:
return self.credential_source != "none"

View File

@ -0,0 +1,308 @@
from __future__ import annotations
import asyncio
import logging
from yuxi.channel.extensions.googlechat.auth import verify_webhook_token
from yuxi.channel.extensions.googlechat.formatting import sanitize_text
from yuxi.channel.extensions.googlechat.monitor import GoogleChatMonitor
from yuxi.channel.extensions.googlechat.types import GoogleChatEvent, ResolvedGoogleChatAccount
logger = logging.getLogger(__name__)
_PRE_AUTH_BODY_LIMIT = 16 * 1024
_PRE_AUTH_TIMEOUT = 3.0
class GoogleChatWebhookHandler:
def __init__(self, monitor: GoogleChatMonitor | None = None):
self._monitor = monitor or GoogleChatMonitor()
self._targets: dict[str, ResolvedGoogleChatAccount] = {}
def register_target(self, webhook_path: str, account: ResolvedGoogleChatAccount) -> None:
self._targets[webhook_path] = account
def unregister_target(self, webhook_path: str) -> None:
self._targets.pop(webhook_path, None)
def _resolve_account(
self, token: str = "", webhook_path: str = ""
) -> ResolvedGoogleChatAccount | None:
if webhook_path and webhook_path in self._targets:
return self._targets[webhook_path]
if token and self._targets:
from yuxi.channel.extensions.googlechat.auth import decode_unverified_jwt_payload
payload = decode_unverified_jwt_payload(token)
target_audience = payload.get("aud", "")
for path, account in self._targets.items():
if account.audience and account.audience == target_audience:
return account
for account in self._targets.values():
return account
return None
async def handle_webhook(self, payload: dict) -> dict:
token = payload.get("_bearer_token", "")
raw_body = payload.get("_raw_body")
account = self._resolve_account(token=token)
audience_type = account.audience_type or "app-url" if account else "app-url"
audience = account.audience or "" if account else ""
app_principal = account.app_principal if account else None
if token:
ok, verified_payload, error = await verify_webhook_token(
token, audience_type, audience, app_principal
)
if not ok:
logger.warning("Webhook auth failed: %s", error)
return {"error": "unauthorized", "detail": error}
body = raw_body or payload
if not token:
if isinstance(body, bytes) and len(body) > _PRE_AUTH_BODY_LIMIT:
return {"error": "payload too large"}
event = GoogleChatEvent.from_payload(body)
return await self._process_event(body, event, account)
async def _process_event(
self, raw_payload: dict, event: GoogleChatEvent, account: ResolvedGoogleChatAccount | None = None
) -> dict:
if event.type == "ADDED_TO_SPACE":
return await self._handle_added_to_space(event, account)
if event.type == "REMOVED_FROM_SPACE":
return await self._handle_removed_from_space(event, account)
if event.type == "CARD_CLICKED":
return await self._handle_card_clicked(event, account)
if event.type == "APP_HOME":
return await self._handle_app_home(event, account)
if event.type != "MESSAGE":
return {"status": "ignored", "reason": f"event type: {event.type}"}
if not event.message:
return {"status": "ignored", "reason": "no message in event"}
msg = event.message
sender = msg.sender
allow_bots = account.allow_bots if account else False
if sender.type == "BOT" and not allow_bots:
return {"status": "ignored", "reason": "bot message"}
if sender.name == "users/app":
return {"status": "ignored", "reason": "self message"}
text = msg.argument_text or msg.text
if not text:
return {"status": "ignored", "reason": "empty text"}
safe_text = sanitize_text(text)
if msg.thread and account:
thread_name = msg.thread.get("name", "")
if thread_name:
try:
thread_ctx = await asyncio.wait_for(
self._monitor.inject_thread_context(
account, event.effective_space_name, thread_name
),
timeout=5.0,
)
if thread_ctx:
safe_text = f"{thread_ctx}\n{safe_text}"
except (asyncio.TimeoutError, Exception):
pass
try:
unified_msg = self._monitor.event_to_unified_message(
raw_payload, event, safe_text, account=account
)
if unified_msg is None:
return {"status": "filtered"}
from yuxi.channel.runtime.manager import gateway
processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None)
if processor:
await asyncio.wait_for(processor.process(unified_msg), timeout=120.0)
return {"status": "processed"}
except asyncio.TimeoutError:
logger.error("Agent response timeout for Google Chat message")
return {"status": "timeout"}
except Exception:
logger.exception("Failed to process Google Chat message")
return {"status": "error"}
async def _handle_added_to_space(
self, event: GoogleChatEvent, account: ResolvedGoogleChatAccount | None = None
) -> dict:
space_name = event.effective_space_name
if not space_name or not account:
return {"status": "skipped", "reason": "no space or account"}
space_display = (
event.effective_space.display_name if event.effective_space and event.effective_space.display_name
else "this space"
)
welcome_text = (
f"Hi! I'm a bot powered by ForcePilot. "
f"I've been added to *{space_display}*.\n\n"
f"• Mention me with @ to ask questions\n"
f"• I can participate in threads\n"
f"• Use /help to see available commands"
)
try:
from yuxi.channel.extensions.googlechat.api import send_message
await send_message(account, space_name, welcome_text)
logger.info("Sent welcome message to space: %s", space_name)
return {"status": "processed", "action": "welcome"}
except Exception:
logger.exception("Failed to send welcome message to space: %s", space_name)
return {"status": "error", "action": "welcome"}
async def _handle_removed_from_space(
self, event: GoogleChatEvent, account: ResolvedGoogleChatAccount | None = None
) -> dict:
space_name = event.effective_space_name
if not space_name:
return {"status": "skipped", "reason": "no space"}
logger.info(
"Bot removed from space: %s, user: %s",
space_name,
event.user.name if event.user else "unknown",
)
if account:
from yuxi.channel.runtime.manager import gateway
channel_ctx = getattr(gateway, "_channel_contexts", {}).get(account.account_id)
if channel_ctx and hasattr(channel_ctx, "webhook_handler"):
channel_ctx.webhook_handler.unregister_target(account.webhook_path)
return {"status": "processed", "action": "cleanup"}
async def _handle_card_clicked(
self, event: GoogleChatEvent, account: ResolvedGoogleChatAccount | None = None
) -> dict:
import time
common = event.common or {}
action = common.get("invokedFunction", "")
parameters = common.get("parameters", {})
user = event.user
user_id = user.name if user else "unknown"
space_name = event.effective_space_name
logger.info("CARD_CLICKED: action=%s user=%s space=%s", action, user_id, space_name)
from yuxi.channel.message.models import (
MessageType,
PeerInfo,
UnifiedMessage,
)
from yuxi.channel.routing.models import PeerKind
unified_msg = UnifiedMessage(
msg_id=f"card_clicked:{user_id}:{int(time.time())}",
channel_type="googlechat",
account_id=account.account_id if account else "default",
content=f"[card_action:{action}]",
sender=PeerInfo(
kind=PeerKind.GROUP if space_name else PeerKind.DIRECT,
id=user_id,
display_name=user.display_name if user else None,
),
message_type=MessageType.COMMAND,
metadata={
"ChatType": "direct" if not space_name else "group",
"InteractionType": "card_clicked",
"InvokedFunction": action,
"Parameters": parameters,
"space_name": space_name,
},
raw_payload=event.__dict__ if hasattr(event, "__dict__") else {},
)
try:
from yuxi.channel.runtime.manager import gateway
processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None)
if processor:
await asyncio.wait_for(processor.process(unified_msg), timeout=120.0)
return {"status": "processed", "action": "card_clicked"}
except asyncio.TimeoutError:
logger.error("Agent response timeout for CARD_CLICKED event")
return {"status": "timeout", "action": "card_clicked"}
except Exception:
logger.exception("Failed to process CARD_CLICKED event")
return {"status": "error", "action": "card_clicked"}
async def _handle_app_home(
self, event: GoogleChatEvent, account: ResolvedGoogleChatAccount | None = None
) -> dict:
space_name = event.effective_space_name
if not space_name or not account:
return {"status": "skipped", "reason": "no space or account"}
cards_v2 = [{
"cardId": "app_home",
"card": {
"header": {"title": "ForcePilot AI Assistant"},
"sections": [{
"widgets": [
{
"textParagraph": {
"text": "Ask me anything! I'm here to help you with tasks, research, and more."
}
},
{
"buttonList": {
"buttons": [
{
"text": "Start a Conversation",
"onClick": {"action": {"function": "start_conversation"}},
},
{
"text": "Help",
"onClick": {"action": {"function": "show_help"}},
},
]
}
},
],
}],
},
}]
try:
from yuxi.channel.extensions.googlechat.api import send_card_message
await send_card_message(
account, space_name, cards_v2, fallback_text="ForcePilot AI Assistant"
)
return {"status": "processed", "action": "app_home"}
except Exception:
logger.exception("Failed to render APP_HOME")
return {"status": "error", "action": "app_home"}
async def handle_webhook_request(self, raw_body: dict) -> dict:
if not raw_body or not raw_body.get("event"):
return {"error": "invalid request", "detail": "No valid event in payload"}
return await self.handle_webhook(raw_body)