from __future__ import annotations import logging from typing import Any import httpx from yuxi.channel.extensions.qqbot.api_routes import ( API_BASE_URL, GATEWAY_URL, c2c_input_notify_url, c2c_message_url, c2c_messages_url, c2c_stream_messages_url, channel_message_url, channel_messages_url, channel_url, dm_messages_url, generate_url_link_url, group_files_url, group_message_url, group_messages_url, interaction_url, resource_url, ) from yuxi.channel.extensions.qqbot.errors import QQBotError, QQBotErrorCode from yuxi.channel.extensions.qqbot.token import TokenManager from yuxi.channel.extensions.qqbot.types import ( QQBotAttachment, QQBotChatType, ) logger = logging.getLogger(__name__) class QQBotApiClient: def __init__(self, token_manager: TokenManager): self._token_manager = token_manager self._http_client: httpx.AsyncClient | None = None def _get_client(self) -> httpx.AsyncClient: if self._http_client is None: self._http_client = httpx.AsyncClient(timeout=httpx.Timeout(30.0)) return self._http_client async def _auth_headers(self) -> dict[str, str]: token = await self._token_manager.get_token() return { "Authorization": f"QQBot {token}", "Content-Type": "application/json", "X-Union-Appid": self._token_manager.app_id, } async def _request( self, method: str, url: str, json_data: dict | None = None, files: dict | None = None ) -> dict[str, Any]: client = self._get_client() headers = await self._auth_headers() if files: headers.pop("Content-Type", None) try: if files: resp = await client.request(method, url, headers=headers, files=files) else: resp = await client.request(method, url, headers=headers, json=json_data) resp.raise_for_status() return resp.json() if resp.content else {} except httpx.HTTPStatusError as e: body = {} try: body = e.response.json() except Exception: pass code = body.get("code", e.response.status_code) message = body.get("message", str(e)) raise QQBotError( QQBotErrorCode.SEND_FAILED, f"API error [{code}]: {message}", retryable=e.response.status_code >= 500 ) from e except httpx.RequestError as e: raise QQBotError(QQBotErrorCode.NETWORK_ERROR, str(e), retryable=True) from e async def get_gateway_url(self) -> str: data = await self._request("GET", GATEWAY_URL) return data.get("url", "") async def send_c2c_message( self, openid: str, content: str, msg_type: int = 0, msg_seq: int | None = None, msg_id: str | None = None, markdown: dict | None = None, keyboard: dict | None = None, media: dict | None = None, ark: dict | None = None, event_id: str | None = None, message_reference: dict | None = None, is_wakeup: bool = False, ) -> dict: payload: dict[str, Any] = {} if content: payload["content"] = content if msg_type is not None: payload["msg_type"] = msg_type if msg_seq is not None: payload["msg_seq"] = msg_seq if msg_id: payload["msg_id"] = msg_id if markdown: payload["markdown"] = markdown if keyboard: payload["keyboard"] = keyboard if media: payload["media"] = media if ark: payload["ark"] = ark if event_id: payload["event_id"] = event_id if message_reference: payload["message_reference"] = message_reference if is_wakeup: payload["is_wakeup"] = True return await self._request("POST", c2c_messages_url(openid), json_data=payload) async def send_group_message( self, group_openid: str, content: str, msg_type: int = 0, msg_seq: int | None = None, msg_id: str | None = None, markdown: dict | None = None, keyboard: dict | None = None, media: dict | None = None, ark: dict | None = None, event_id: str | None = None, message_reference: dict | None = None, ) -> dict: payload: dict[str, Any] = {"content": content, "msg_type": msg_type} if msg_seq is not None: payload["msg_seq"] = msg_seq if msg_id: payload["msg_id"] = msg_id if markdown: payload["markdown"] = markdown if keyboard: payload["keyboard"] = keyboard if media: payload["media"] = media if ark: payload["ark"] = ark if event_id: payload["event_id"] = event_id if message_reference: payload["message_reference"] = message_reference return await self._request("POST", group_messages_url(group_openid), json_data=payload) async def send_c2c_markdown( self, openid: str, markdown_content: str = "", msg_seq: int | None = None, msg_id: str | None = None, keyboard: dict | None = None, custom_template_id: str | None = None, params: list[str] | None = None, message_reference: dict | None = None, ) -> dict: payload: dict[str, Any] = {"msg_type": 2} if custom_template_id and params: payload["markdown"] = {"custom_template_id": custom_template_id, "params": params} elif markdown_content: payload["markdown"] = {"content": markdown_content} if msg_seq is not None: payload["msg_seq"] = msg_seq if msg_id: payload["msg_id"] = msg_id if keyboard: payload["keyboard"] = keyboard if message_reference: payload["message_reference"] = message_reference return await self._request("POST", c2c_messages_url(openid), json_data=payload) async def send_group_markdown( self, group_openid: str, markdown_content: str = "", msg_seq: int | None = None, msg_id: str | None = None, keyboard: dict | None = None, custom_template_id: str | None = None, params: list[str] | None = None, message_reference: dict | None = None, ) -> dict: payload: dict[str, Any] = {"msg_type": 2} if custom_template_id and params: payload["markdown"] = {"custom_template_id": custom_template_id, "params": params} elif markdown_content: payload["markdown"] = {"content": markdown_content} if msg_seq is not None: payload["msg_seq"] = msg_seq if msg_id: payload["msg_id"] = msg_id if keyboard: payload["keyboard"] = keyboard if message_reference: payload["message_reference"] = message_reference return await self._request("POST", group_messages_url(group_openid), json_data=payload) async def start_c2c_stream(self, openid: str) -> dict: payload: dict[str, Any] = {"content": "", "msg_type": 0} data = await self._request("POST", c2c_stream_messages_url(openid), json_data=payload) return data async def send_c2c_stream_chunk( self, openid: str, stream_id: str, text: str, msg_seq: int | None = None ) -> dict: payload: dict[str, Any] = { "stream": {"state": 1, "id": stream_id, "content": text}, } if msg_seq is not None: payload["msg_seq"] = msg_seq return await self._request("POST", c2c_stream_messages_url(openid), json_data=payload) async def complete_c2c_stream(self, openid: str, stream_id: str) -> dict: payload = {"stream": {"state": 10, "id": stream_id}} return await self._request("POST", c2c_stream_messages_url(openid), json_data=payload) async def abort_c2c_stream(self, openid: str, stream_id: str) -> dict: payload = {"stream": {"state": 12, "id": stream_id}} return await self._request("POST", c2c_stream_messages_url(openid), json_data=payload) async def send_input_notify(self, openid: str) -> dict: payload = {"msg_type": 6} return await self._request("POST", c2c_input_notify_url(openid), json_data=payload) async def ack_interaction(self, interaction_id: str, code: int = 0) -> dict: return await self._request("PUT", interaction_url(interaction_id), json_data={"code": code}) async def delete_c2c_message(self, openid: str, message_id: str) -> dict: return await self._request("DELETE", c2c_message_url(openid, message_id)) async def delete_group_message(self, group_openid: str, message_id: str) -> dict: return await self._request("DELETE", group_message_url(group_openid, message_id)) async def delete_channel_message(self, channel_id: str, message_id: str) -> dict: return await self._request("DELETE", channel_message_url(channel_id, message_id)) async def update_channel(self, channel_id: str, channel_data: dict) -> dict: return await self._request("PATCH", channel_url(channel_id), json_data=channel_data) async def delete_channel(self, channel_id: str) -> dict: return await self._request("DELETE", channel_url(channel_id)) async def get_guild_announces(self, guild_id: str) -> dict: from yuxi.channel.extensions.qqbot.api_routes import guild_announces_url return await self._request("GET", guild_announces_url(guild_id)) async def create_guild_announce(self, guild_id: str, announce_data: dict) -> dict: from yuxi.channel.extensions.qqbot.api_routes import guild_announces_url return await self._request("POST", guild_announces_url(guild_id), json_data=announce_data) async def get_guild_schedules(self, guild_id: str) -> dict: from yuxi.channel.extensions.qqbot.api_routes import guild_schedules_url return await self._request("GET", guild_schedules_url(guild_id)) async def create_guild_schedule(self, guild_id: str, schedule_data: dict) -> dict: from yuxi.channel.extensions.qqbot.api_routes import guild_schedules_url return await self._request("POST", guild_schedules_url(guild_id), json_data=schedule_data) async def send_c2c_ark(self, openid: str, ark: dict, msg_seq: int | None = None) -> dict: payload: dict[str, Any] = {"msg_type": 3, "ark": ark} if msg_seq is not None: payload["msg_seq"] = msg_seq return await self._request("POST", c2c_messages_url(openid), json_data=payload) async def send_group_ark(self, group_openid: str, ark: dict, msg_seq: int | None = None) -> dict: payload: dict[str, Any] = {"msg_type": 3, "ark": ark} if msg_seq is not None: payload["msg_seq"] = msg_seq return await self._request("POST", group_messages_url(group_openid), json_data=payload) async def send_channel_message( self, channel_id: str, content: str, msg_type: int = 0, markdown: dict | None = None, keyboard: dict | None = None, ) -> dict: payload: dict[str, Any] = {"content": content, "msg_type": msg_type} if markdown: payload["markdown"] = markdown if keyboard: payload["keyboard"] = keyboard return await self._request("POST", channel_messages_url(channel_id), json_data=payload) async def send_dm_message( self, guild_id: str, content: str, msg_type: int = 0, msg_id: str | None = None, markdown: dict | None = None, ) -> dict: payload: dict[str, Any] = {"content": content, "msg_type": msg_type} if msg_id: payload["msg_id"] = msg_id if markdown: payload["markdown"] = markdown return await self._request("POST", dm_messages_url(guild_id), json_data=payload) async def send_c2c_embed(self, openid: str, embed: dict, msg_id: str | None = None) -> dict: payload: dict[str, Any] = {"msg_type": 4, "embed": embed} if msg_id: payload["msg_id"] = msg_id return await self._request("POST", c2c_messages_url(openid), json_data=payload) async def send_group_embed(self, group_openid: str, embed: dict, msg_id: str | None = None) -> dict: payload: dict[str, Any] = {"msg_type": 4, "embed": embed} if msg_id: payload["msg_id"] = msg_id return await self._request("POST", group_messages_url(group_openid), json_data=payload) async def generate_url_link(self) -> dict: return await self._request("POST", generate_url_link_url()) async def get_me(self) -> dict: return await self._request("GET", f"{API_BASE_URL}/users/@me") async def get_my_guilds(self) -> dict: return await self._request("GET", f"{API_BASE_URL}/users/@me/guilds") async def get_guild(self, guild_id: str) -> dict: return await self._request("GET", f"{API_BASE_URL}/guilds/{guild_id}") async def get_guild_channels(self, guild_id: str) -> dict: return await self._request("GET", f"{API_BASE_URL}/guilds/{guild_id}/channels") async def get_guild_members(self, guild_id: str, after: str | None = None, limit: int = 100) -> dict: url = f"{API_BASE_URL}/guilds/{guild_id}/members" if after: url += f"?after={after}" if limit != 100: sep = "&" if after else "?" url += f"{sep}limit={limit}" return await self._request("GET", url) async def create_guild_channel(self, guild_id: str, channel_data: dict) -> dict: from yuxi.channel.extensions.qqbot.api_routes import guild_channels_url return await self._request("POST", guild_channels_url(guild_id), json_data=channel_data) async def upload_media( self, target_id: str, file_data: bytes, filename: str, file_type: int, chat_type: QQBotChatType ) -> QQBotAttachment: client = self._get_client() headers = await self._auth_headers() headers.pop("Content-Type", None) if chat_type == QQBotChatType.C2C: url = resource_url(target_id, file_type, url_type=0, srv_send_msg=False) else: url = group_files_url(target_id, file_type) try: resp = await client.post( url, headers=headers, files={"file": (filename, file_data)}, ) resp.raise_for_status() data = resp.json() file_info = data.get("file_info", "") return QQBotAttachment( url=data.get("url", file_info), content_type=data.get("content_type", ""), filename=filename, size=len(file_data), file_info=file_info, ttl=data.get("ttl"), ) except httpx.HTTPStatusError as e: raise QQBotError( QQBotErrorCode.MEDIA_UPLOAD_FAILED, f"Media upload failed: {e}", retryable=True ) from e async def close(self) -> None: if self._http_client: await self._http_client.aclose() self._http_client = None