ForcePilot/backend/package/yuxi/channel/extensions/bilibili/outbound.py
Kris 415d66d1c6 feat(bilibili): 新增B站渠道插件,支持直播弹幕和私信交互
该提交实现了完整的B站渠道插件,包含以下核心功能:
1.  支持B站直播弹幕监听与处理,包含弹幕、SC、礼物等多种直播间事件
2.  支持B站私信的轮询接收与发送
3.  内置WBI签名算法,适配B站API鉴权要求
4.  提供账号配对、黑白名单等弹幕私信权限控制
5.  集成速率限制与防风险机制,降低账号封禁风险
6.  完善的配置管理与状态监控能力
2026-05-21 10:40:04 +08:00

158 lines
5.1 KiB
Python

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