from __future__ import annotations import json import logging import time import httpx from yuxi.channel.extensions.bilibili.types import BilibiliConfig logger = logging.getLogger(__name__) class BilibiliOutbound: SEND_DM_URL = "https://api.vc.bilibili.com/web_im/v1/web_im/send_msg" def __init__(self, credential, anti_risk, config: BilibiliConfig): self._credential = credential self._anti_risk = anti_risk self._config = config self._client: httpx.AsyncClient | None = None async def _ensure_client(self) -> httpx.AsyncClient: if self._client is None: self._client = httpx.AsyncClient() return self._client async def close(self) -> None: if self._client: await self._client.aclose() self._client = None async def send_text( self, *, to: str, text: str, account_id: str = "default", **kwargs, ) -> dict: prefix, destination = self._parse_target(to) if prefix == "dm": return await self._send_dm(destination, text) elif prefix == "danmaku": return await self._send_danmaku(destination, text) elif prefix == "comment": return await self._send_comment(destination, text) else: return {"ok": False, "error": f"Unknown target type: {prefix}"} async def _send_dm(self, receiver_uid: str, text: str) -> dict: await self._anti_risk.throttle("dm_send") client = await self._ensure_client() content_json = json.dumps({"content": text}) form_data = { "msg[sender_uid]": self._credential.dedeuserid, "msg[receiver_id]": receiver_uid, "msg[receiver_type]": 1, "msg[msg_type]": 1, "msg[content]": content_json, "msg[timestamp]": int(time.time()), "csrf": self._credential.bili_jct, } resp = await client.post( self.SEND_DM_URL, cookies=self._credential.get_cookies(), data=form_data, ) result = resp.json() return { "ok": result.get("code") == 0, "message_id": result.get("data", {}).get("msg_key", ""), "error": result.get("message") if result.get("code") != 0 else None, } async def _send_danmaku(self, room_id: str, text: str) -> dict: account = self._config.accounts.get("default") if account is None or not account.danmaku_reply_enabled: return {"ok": False, "error": "Danmaku reply is disabled"} from bilibili_api.live import LiveDanmaku lm = LiveDanmaku(int(room_id), credential=self._credential) result = await lm.send_danmaku(text[:50]) return {"ok": result, "message_id": ""} async def _send_comment(self, oid: str, text: str) -> dict: await self._anti_risk.throttle("comment_reply") client = await self._ensure_client() resp = await client.post( "https://api.bilibili.com/x/v2/reply/add", cookies=self._credential.get_cookies(), data={ "oid": oid, "type": 1, "message": text, "plat": 1, "csrf": self._credential.bili_jct, }, ) result = resp.json() return { "ok": result.get("code") == 0, "message_id": str(result.get("data", {}).get("rpid", "")), "error": result.get("message") if result.get("code") != 0 else None, } @staticmethod def _parse_target(to: str) -> tuple[str, str]: if ":" not in to: raise ValueError(f"Invalid target format: {to}") prefix, _, destination = to.partition(":") return prefix, destination async def upload_image(self, source: str) -> str | None: if source.startswith(("http://", "https://")): client = await self._ensure_client() resp = await client.get(source) resp.raise_for_status() file_data = resp.content else: import aiofiles async with aiofiles.open(source, "rb") as f: file_data = await f.read() client = await self._ensure_client() resp = await client.post( "https://api.vc.bilibili.com/api/v1/image/upload", cookies=self._credential.get_cookies(), files={"file_up": ("image.png", file_data, "image/png")}, data={"csrf": self._credential.bili_jct}, ) result = resp.json() if result.get("code") == 0: return result.get("data", {}).get("image_url") return None async def follow_user(self, uid: str) -> bool: client = await self._ensure_client() resp = await client.post( "https://api.bilibili.com/x/relation/modify", cookies=self._credential.get_cookies(), data={ "fid": uid, "act": 1, "re_src": 11, "csrf": self._credential.bili_jct, }, ) result = resp.json() return result.get("code") == 0