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

455 lines
13 KiB
Python

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()