From d59a5ff3d72a63383acedf267d149c5617abfaca Mon Sep 17 00:00:00 2001 From: GEYUANwuqi <17539198883@163.com> Date: Sat, 21 Mar 2026 00:45:27 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(bilibili):=20=E4=BD=BF=E7=94=A8Danmaku?= =?UTF-8?q?=E5=8C=85=E8=A3=85text=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ncatbot/adapter/bilibili/api/danmu.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ncatbot/adapter/bilibili/api/danmu.py b/ncatbot/adapter/bilibili/api/danmu.py index 21788a53..9aed50fc 100644 --- a/ncatbot/adapter/bilibili/api/danmu.py +++ b/ncatbot/adapter/bilibili/api/danmu.py @@ -7,8 +7,10 @@ class DanmuAPIMixin: async def send_danmu(self, room_id: int, text: str) -> Any: + from bilibili_api.utils.danmaku import Danmaku + room = self._get_room(room_id) - return await room.send_danmaku(text) + return await room.send_danmaku(Danmaku(text=text)) def _get_room(self, room_id: int) -> Any: """获取或创建 LiveRoom 实例(带缓存)""" From 90951fa2996c8f7a2aa7afe0c0bb88c53d303a6d Mon Sep 17 00:00:00 2001 From: GEYUANwuqi <17539198883@163.com> Date: Sat, 21 Mar 2026 00:47:39 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(bilibili):=20=E5=9C=A8=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E7=BA=BF=E7=A8=8B=E4=B8=AD=E7=9B=91=E5=90=AC=E7=9B=B4?= =?UTF-8?q?=E6=92=AD=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/bilibili/source/live_source.py | 90 +++++++++++++++---- 1 file changed, 71 insertions(+), 19 deletions(-) diff --git a/ncatbot/adapter/bilibili/source/live_source.py b/ncatbot/adapter/bilibili/source/live_source.py index 04ac652a..475e9a0e 100644 --- a/ncatbot/adapter/bilibili/source/live_source.py +++ b/ncatbot/adapter/bilibili/source/live_source.py @@ -1,8 +1,14 @@ -"""直播间数据源 — 每个实例监听一个直播间的 WebSocket 弹幕流""" +"""直播间数据源 — 每个实例在独立线程中监听一个直播间的 WebSocket 弹幕流 + +每个 LiveSource 拥有独立的线程和事件循环,避免多房间或 +LiveDanmaku 内部阻塞操作影响主事件循环。事件通过 +``asyncio.run_coroutine_threadsafe`` 线程安全地回调到主循环。 +""" from __future__ import annotations import asyncio +import threading from typing import Any, Awaitable, Callable, Optional from ncatbot.utils import get_log @@ -31,11 +37,18 @@ def __init__( self._max_retry = max_retry self._retry_after = retry_after self._danmaku: Optional["Any"] = None - self._task: Optional[asyncio.Task] = None + self._thread: Optional[threading.Thread] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._main_loop: Optional[asyncio.AbstractEventLoop] = None + self._ready = threading.Event() async def start(self) -> None: if self._running: return + + self._main_loop = asyncio.get_running_loop() + self._ready.clear() + from bilibili_api.live import LiveDanmaku self._danmaku = LiveDanmaku( @@ -47,37 +60,76 @@ async def start(self) -> None: @self._danmaku.on("ALL") async def _on_all(event: dict) -> None: - await self._callback("live", event) + # 线程安全地将事件回调到主事件循环 + if self._main_loop is not None and self._main_loop.is_running(): + asyncio.run_coroutine_threadsafe( + self._callback("live", event), self._main_loop + ) self._running = True - self._task = asyncio.create_task( - self._run_danmaku(), name=f"live_source_{self._room_id}" + self._thread = threading.Thread( + target=self._run_thread, + name=f"LiveSource({self._room_id})", + daemon=True, ) - LOG.info("直播源 %s 已启动", self._room_id) + self._thread.start() + LOG.info("直播源 %s 已启动 (线程: %s)", self._room_id, self._thread.name) + + def _run_thread(self) -> None: + """线程入口:创建独立事件循环并运行 LiveDanmaku 连接。""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + self._loop = loop + try: + loop.run_until_complete(self._connect_danmaku()) + except Exception: + LOG.exception("直播源 %s 线程异常退出", self._room_id) + finally: + self._running = False + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + except Exception: + pass + loop.close() + self._loop = None + + async def _connect_danmaku(self) -> None: + """在独立事件循环中运行弹幕连接。""" + + @self._danmaku.on("VERIFICATION_SUCCESSFUL") + async def _on_connected(_: dict) -> None: + self._ready.set() + LOG.debug("直播源 %s WebSocket 验证成功", self._room_id) - async def _run_danmaku(self) -> None: try: await self._danmaku.connect() except asyncio.CancelledError: pass except Exception: - LOG.exception("直播源 %s 异常退出", self._room_id) + LOG.exception("直播源 %s 连接异常", self._room_id) finally: - self._running = False + self._ready.set() # 防止异常时 ready 永远阻塞 async def stop(self) -> None: - if self._danmaku is not None: + if self._danmaku is not None and self._loop is not None: + loop = self._loop try: - await self._danmaku.disconnect() + future = asyncio.run_coroutine_threadsafe( + self._danmaku.disconnect(), loop + ) + future.result(timeout=10) except Exception: - pass - if self._task is not None and not self._task.done(): - self._task.cancel() - try: - await self._task - except (asyncio.CancelledError, Exception): - pass + LOG.debug("断开直播源 %s 时异常", self._room_id, exc_info=True) + # 通知子线程事件循环停止 + loop.call_soon_threadsafe(loop.stop) + + if self._thread is not None and self._thread.is_alive(): + self._thread.join(timeout=15) + if self._thread.is_alive(): + LOG.warning("直播源 %s 线程未能在超时内退出", self._room_id) + self._running = False self._danmaku = None - self._task = None + self._thread = None + self._loop = None LOG.info("直播源 %s 已停止", self._room_id) From 5acb06edffa85657b84e28eae7e789dc03804cd7 Mon Sep 17 00:00:00 2001 From: GEYUANwuqi <17539198883@163.com> Date: Sat, 21 Mar 2026 01:25:24 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(bilibili):=20get=5Froom=5Finfo?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E4=BB=A5=E5=8F=8A=E5=BC=80=E6=92=AD=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ncatbot/adapter/bilibili/api/room_manage.py | 9 +- ncatbot/adapter/bilibili/parser.py | 14 +- .../adapter/bilibili/source/live_source.py | 25 ++++ ncatbot/api/bilibili/interface.py | 5 +- ncatbot/core/registry/platform.py | 8 ++ ncatbot/types/bilibili/__init__.py | 3 + ncatbot/types/bilibili/events.py | 4 + ncatbot/types/bilibili/models.py | 124 ++++++++++++++++++ 8 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 ncatbot/types/bilibili/models.py diff --git a/ncatbot/adapter/bilibili/api/room_manage.py b/ncatbot/adapter/bilibili/api/room_manage.py index da8fde88..1fd60a37 100644 --- a/ncatbot/adapter/bilibili/api/room_manage.py +++ b/ncatbot/adapter/bilibili/api/room_manage.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing import Any +from typing import Any, Optional + +from ncatbot.types.bilibili.models import LiveRoomInfo class RoomManageAPIMixin: @@ -23,6 +25,7 @@ async def set_room_silent(self, room_id: int, enable: bool, **kwargs: Any) -> An ) return await room.del_room_silent() - async def get_room_info(self, room_id: int) -> dict: + async def get_room_info(self, room_id: int) -> Optional[LiveRoomInfo]: room = self._get_room(room_id) - return await room.get_room_info() + raw = await room.get_room_info() + return LiveRoomInfo.from_raw(raw) diff --git a/ncatbot/adapter/bilibili/parser.py b/ncatbot/adapter/bilibili/parser.py index 0e750312..fce13229 100644 --- a/ncatbot/adapter/bilibili/parser.py +++ b/ncatbot/adapter/bilibili/parser.py @@ -34,6 +34,7 @@ WatchedChangeEventData, ) from ncatbot.types.bilibili.enums import BiliLiveEventType, BiliSessionEventType +from ncatbot.types.bilibili.models import LiveRoomInfo from ncatbot.types.bilibili.sender import BiliSender from ncatbot.types.common.base import BaseEventData from ncatbot.types.common.segment.array import MessageArray @@ -84,7 +85,13 @@ def _parse_live(self, callback_info: dict) -> Optional[BaseEventData]: parser = _LIVE_PARSERS.get(cmd) if parser is not None: - return parser(data, common) + result = parser(data, common) + # 开播事件:附加直播间信息 + if isinstance(result, LiveStatusEventData) and result.status == "live": + raw_room_info = callback_info.get("room_info") + if raw_room_info is not None: + result.room_info = LiveRoomInfo.from_raw(raw_room_info) + return result # 系统事件 if cmd in ("VERIFICATION_SUCCESSFUL", "DISCONNECT", "TIMEOUT"): @@ -302,7 +309,10 @@ def _parse_view(data: Any, common: dict) -> ViewEventData: def _parse_live_status(data: dict, common: dict, status: str) -> LiveStatusEventData: - return LiveStatusEventData(**common, status=status) + live_event_type = ( + BiliLiveEventType.LIVE if status == "live" else BiliLiveEventType.PREPARING + ) + return LiveStatusEventData(**common, live_event_type=live_event_type, status=status) def _parse_room_change(data: dict, common: dict) -> RoomChangeEventData: diff --git a/ncatbot/adapter/bilibili/source/live_source.py b/ncatbot/adapter/bilibili/source/live_source.py index 475e9a0e..9eac00f1 100644 --- a/ncatbot/adapter/bilibili/source/live_source.py +++ b/ncatbot/adapter/bilibili/source/live_source.py @@ -60,6 +60,12 @@ async def start(self) -> None: @self._danmaku.on("ALL") async def _on_all(event: dict) -> None: + # 开播事件:获取直播间信息附加到事件数据 + if event.get("type") == "LIVE": + data = event.get("data", {}) + if isinstance(data, dict) and data.get("live_time", 0): + await self._attach_room_info(event) + # 线程安全地将事件回调到主事件循环 if self._main_loop is not None and self._main_loop.is_running(): asyncio.run_coroutine_threadsafe( @@ -93,6 +99,25 @@ def _run_thread(self) -> None: loop.close() self._loop = None + async def _attach_room_info(self, event: dict) -> None: + """在子线程事件循环中获取直播间信息并附加到事件数据。""" + try: + from bilibili_api.live import LiveRoom + + room = LiveRoom( + room_display_id=self._room_id, + credential=self._credential, + ) + raw_info = await room.get_room_info() + event["room_info"] = raw_info + LOG.info( + "直播源 %s 开播,已获取直播间信息: %s", + self._room_id, + raw_info.get("room_info", {}).get("title", ""), + ) + except Exception: + LOG.warning("直播源 %s 获取直播间信息失败", self._room_id, exc_info=True) + async def _connect_danmaku(self) -> None: """在独立事件循环中运行弹幕连接。""" diff --git a/ncatbot/api/bilibili/interface.py b/ncatbot/api/bilibili/interface.py index 9619ed06..5267f4d9 100644 --- a/ncatbot/api/bilibili/interface.py +++ b/ncatbot/api/bilibili/interface.py @@ -7,9 +7,10 @@ from __future__ import annotations from abc import abstractmethod -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from ncatbot.api.base import IAPIClient +from ncatbot.types.bilibili.models import LiveRoomInfo class IBiliAPIClient(IAPIClient): @@ -58,7 +59,7 @@ async def set_room_silent(self, room_id: int, enable: bool, **kwargs: Any) -> An """全员禁言""" @abstractmethod - async def get_room_info(self, room_id: int) -> dict: + async def get_room_info(self, room_id: int) -> Optional[LiveRoomInfo]: """获取直播间信息""" # ---- 私信 ---- diff --git a/ncatbot/core/registry/platform.py b/ncatbot/core/registry/platform.py index d2c03ce4..89d70d2a 100644 --- a/ncatbot/core/registry/platform.py +++ b/ncatbot/core/registry/platform.py @@ -239,6 +239,14 @@ def on_like(self, priority: int = 0, **metadata: Any) -> Callable: """注册点赞事件 handler""" return self.on("live.like_info_v3_click", priority=priority, **metadata) + def on_live_start(self, priority: int = 0, **metadata: Any) -> Callable: + """注册开播事件 handler""" + return self.on("live.live", priority=priority, **metadata) + + def on_live_end(self, priority: int = 0, **metadata: Any) -> Callable: + """注册下播事件 handler""" + return self.on("live.preparing", priority=priority, **metadata) + # ---- 评论事件 ---- def on_comment(self, priority: int = 0, **metadata: Any) -> Callable: diff --git a/ncatbot/types/bilibili/__init__.py b/ncatbot/types/bilibili/__init__.py index 26a0e2a6..4618e7f9 100644 --- a/ncatbot/types/bilibili/__init__.py +++ b/ncatbot/types/bilibili/__init__.py @@ -7,6 +7,7 @@ BiliSessionEventType, ) from .sender import BiliSender +from .models import LiveRoomInfo from .events import ( BiliCommentEventData, BiliConnectionEventData, @@ -54,4 +55,6 @@ "BiliCommentEventData", # system events "BiliConnectionEventData", + # models + "LiveRoomInfo", ] diff --git a/ncatbot/types/bilibili/events.py b/ncatbot/types/bilibili/events.py index fb51855c..9e051d01 100644 --- a/ncatbot/types/bilibili/events.py +++ b/ncatbot/types/bilibili/events.py @@ -3,11 +3,14 @@ from __future__ import annotations +from typing import Optional + from pydantic import Field from ncatbot.types.common.base import BaseEventData from ncatbot.types.common.segment.array import MessageArray from .enums import BiliPostType, BiliLiveEventType, BiliCommentEventType +from .models import LiveRoomInfo from .sender import BiliSender __all__ = [ @@ -130,6 +133,7 @@ class LiveStatusEventData(BiliLiveEventData): """开播/下播""" status: str = "" + room_info: Optional[LiveRoomInfo] = None class RoomChangeEventData(BiliLiveEventData): diff --git a/ncatbot/types/bilibili/models.py b/ncatbot/types/bilibili/models.py new file mode 100644 index 00000000..e5b42121 --- /dev/null +++ b/ncatbot/types/bilibili/models.py @@ -0,0 +1,124 @@ +"""Bilibili 平台数据模型 — 非事件类结构化数据""" + +from __future__ import annotations + +from typing import Optional, Tuple + +from pydantic import BaseModel, Field + + +__all__ = [ + "LiveRoomInfo", + "RoomInfo", + "AnchorInfo", + "WatchedShow", +] + + +class RoomInfo(BaseModel): + """直播间信息""" + + uid: int = 0 + room_id: int = 0 + title: str = "" + cover_url: str = "" + background_url: str = "" + description: str = "" + tags: Tuple[str, ...] = () + live_status: int = 0 + live_start_time: int = 0 + parent_area_name: str = "" + parent_area_id: int = 0 + area_name: str = "" + area_id: int = 0 + keyframe_url: str = "" + online: int = 0 + + +class AnchorInfo(BaseModel): + """主播信息""" + + name: str = "" + face_url: str = "" + gender: str = "" + official_info: str = "" + fanclub_name: str = "" + fanclub_num: int = 0 + live_level: int = 0 + live_score: int = 0 + live_upgrade_score: int = 0 + + +class WatchedShow(BaseModel): + """观看榜信息""" + + num: int = 0 + text_small: str = "" + text_large: str = "" + + +class LiveRoomInfo(BaseModel): + """直播间完整信息""" + + room_info: RoomInfo = Field(default_factory=RoomInfo) + anchor_info: AnchorInfo = Field(default_factory=AnchorInfo) + watched_show: WatchedShow = Field(default_factory=WatchedShow) + + @classmethod + def from_raw(cls, data: dict) -> Optional["LiveRoomInfo"]: + """从 bilibili-api ``LiveRoom.get_room_info()`` 原始数据构造模型。""" + try: + ri = data.get("room_info", {}) + tags_str: str = ri.get("tags", "") + tags = tuple(tags_str.split(",")) if tags_str else () + + room_info = RoomInfo( + uid=ri.get("uid", 0), + room_id=ri.get("room_id", 0), + title=ri.get("title", ""), + cover_url=ri.get("cover", ""), + background_url=ri.get("background", ""), + description=ri.get("description", ""), + tags=tags, + live_status=ri.get("live_status", 0), + live_start_time=ri.get("live_start_time", 0), + parent_area_name=ri.get("parent_area_name", ""), + parent_area_id=ri.get("parent_area_id", 0), + area_name=ri.get("area_name", ""), + area_id=ri.get("area_id", 0), + keyframe_url=ri.get("keyframe", ""), + online=ri.get("online", 0), + ) + + ai = data.get("anchor_info", {}) + base = ai.get("base_info", {}) + medal = ai.get("medal_info", {}) + live_info = ai.get("live_info", {}) + official = base.get("official_info", {}) + + anchor_info = AnchorInfo( + name=base.get("uname", ""), + face_url=base.get("face", ""), + gender=base.get("gender", ""), + official_info=official.get("title", ""), + fanclub_name=medal.get("medal_name", ""), + fanclub_num=medal.get("fansclub", 0), + live_level=live_info.get("level", 0), + live_score=live_info.get("score", 0), + live_upgrade_score=live_info.get("upgrade_score", 0), + ) + + ws = data.get("watched_show", {}) + watched_show = WatchedShow( + num=ws.get("num", 0), + text_small=ws.get("text_small", ""), + text_large=ws.get("text_large", ""), + ) + + return cls( + room_info=room_info, + anchor_info=anchor_info, + watched_show=watched_show, + ) + except Exception: + return None From edd3a13ca61f8804c3c992dfc7bfd351adb7c057 Mon Sep 17 00:00:00 2001 From: GEYUANwuqi <17539198883@163.com> Date: Sat, 21 Mar 2026 02:10:29 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(bilibili):=20=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E9=83=A8=E5=88=86=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/README.md | 11 +- tests/unit/adapter/README.md | 24 +++ tests/unit/adapter/test_bilibili_parser.py | 79 ++++++++++ tests/unit/core/README.md | 2 + tests/unit/core/test_registrar.py | 39 +++++ tests/unit/types/README.md | 8 + tests/unit/types/test_bilibili_models.py | 169 +++++++++++++++++++++ 7 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 tests/unit/types/test_bilibili_models.py diff --git a/tests/README.md b/tests/README.md index 117bccb1..d9859199 100644 --- a/tests/README.md +++ b/tests/README.md @@ -7,13 +7,13 @@ ``` tests/ ├── unit/ # 单元测试 — 按模块组织 -│ ├── types/ # 类型系统 (T-01 ~ T-05, S-01 ~ S-10, CQ-01 ~ CQ-08, N-01 ~ N-05, MA-01 ~ MA-04, FW-01 ~ FW-03, SEG-01) +│ ├── types/ # 类型系统 (T-01 ~ T-05, S-01 ~ S-10, CQ-01 ~ CQ-08, N-01 ~ N-05, MA-01 ~ MA-04, FW-01 ~ FW-03, SEG-01, LR-01 ~ LR-03) │ ├── event/ # 事件工厂 (E-01 ~ E-04, GHE-01 ~ GHE-04, QMA-01 ~ QMA-03) │ ├── api/ # API 客户端 + 错误层级 + Sugar (A-01 ~ A-02, AE-01 ~ AE-07, SG-01 ~ SG-06, FL-01 ~ FL-06) -│ ├── core/ # 核心分发与注册 + 谓词 (D-01 ~ D-09, K-01 ~ K-21, H-01 ~ H-11, R-01 ~ R-07, PR-01 ~ PR-06) +│ ├── core/ # 核心分发与注册 + 谓词 (D-01 ~ D-09, K-01 ~ K-21, H-01 ~ H-11, R-01 ~ R-09, PR-01 ~ PR-06) │ ├── service/ # 服务管理 + RBAC + 调度 (SM-01 ~ SM-08, SC-01 ~ SC-12, TS-01 ~ TS-06) │ ├── plugin/ # 插件 Mixin + 导入去重 + Loader (M-01 ~ M-41, ID-01 ~ ID-02, LD-01 ~ LD-05) -│ ├── adapter/ # 适配器解析 + 注册表 + 真实数据 (P-01 ~ P-07, RF-01 ~ RF-08, AR-01 ~ AR-05, GM-01 ~ GM-05, BL-01 ~ BL-14, GH-01 ~ GH-11) +│ ├── adapter/ # 适配器解析 + 注册表 + 真实数据 (P-01 ~ P-07, RF-01 ~ RF-08, AR-01 ~ AR-05, GM-01 ~ GM-05, BL-01 ~ BL-17, GH-01 ~ GH-11) │ └── config/ # 配置迁移 + 安全 (CF-01 ~ CF-05, CS-01 ~ CS-05) ├── integration/ # 集成测试 (I-01 ~ I-21) ├── e2e/ # 端到端测试 @@ -70,7 +70,7 @@ python tests/e2e/napcat/run.py | D | AsyncEventDispatcher | D-01 ~ D-09 | | K | Hook System | K-01 ~ K-21 | | H | HandlerDispatcher | H-01 ~ H-11 | -| R | Registrar | R-01 ~ R-07 | +| R | Registrar | R-01 ~ R-09 | | ID | Import Dedup (插件导入去重) | ID-01 ~ ID-02 | | SM | ServiceManager | SM-01 ~ SM-08 | | M | Plugin Mixin | M-01 ~ M-41 | @@ -88,7 +88,8 @@ python tests/e2e/napcat/run.py | FW | Forward 转发消息 | FW-01 ~ FW-03 | | RF | 真实日志夹具事件解析 | RF-01 ~ RF-08 | | GM | 群消息批量真实数据 | GM-01 ~ GM-05 | -| BL | Bilibili 事件解析 | BL-01 ~ BL-14 | +| BL | Bilibili 事件解析 | BL-01 ~ BL-17 | +| LR | Bilibili 数据模型 (LiveRoomInfo) | LR-01 ~ LR-03 | | GH | GitHub 事件解析 | GH-01 ~ GH-11 | | SEG | 消息段附件桥接 | SEG-01 | | GHE | GitHub 事件实体 | GHE-01 ~ GHE-04 | diff --git a/tests/unit/adapter/README.md b/tests/unit/adapter/README.md index 2f2c8fc2..c7f4a835 100644 --- a/tests/unit/adapter/README.md +++ b/tests/unit/adapter/README.md @@ -36,3 +36,27 @@ python -m pytest tests/unit/adapter/ -v | AR-03 | `create()` | 根据 AdapterEntry 创建适配器实例 | | AR-04 | `create()` platform 覆盖 | `platform` 参数覆盖默认值 | | AR-05 | 未知类型 | 抛 `ValueError` | + +### BiliEventParser (`test_bilibili_parser.py`) + +测试 Bilibili 三路由解析器(直播/私信/评论)。 + +| 规范 ID | 说明 | 验证点 | +|---------|------|--------| +| BL-01 | 弹幕 (DANMU_MSG) | DanmuMsgEventData 字段正确 | +| BL-02 | 礼物 (SEND_GIFT) | GiftEventData 字段正确 | +| BL-03 | 醒目留言 (SUPER_CHAT) | SuperChatEventData 字段正确 | +| BL-04 | 大航海 (GUARD_BUY) | GuardBuyEventData 字段正确 | +| BL-05 | 互动 (INTERACT_WORD_V2) | InteractEventData 字段正确 | +| BL-06 | 点赞 (LIKE_INFO_V3_CLICK) | LikeEventData 字段正确 | +| BL-07 | 人气 (VIEW) | ViewEventData 字段正确 | +| BL-08 | 开播/下播 (LIVE/PREPARING) | LiveStatusEventData status 正确 | +| BL-09 | 房间变更 + 禁言 + 观看人数 | RoomChange/Block/Silent/Watched 正确 | +| BL-10 | 弹幕聚合 + 进场 + 连接 | Aggregation/Entry/Connection 正确 | +| BL-11 | 私信 | BiliPrivateMessageEventData 正确 | +| BL-12 | 私信撤回 | BiliPrivateMessageWithdrawEventData 正确 | +| BL-13 | 评论 | BiliCommentEventData 正确 | +| BL-14 | 全量夹具一致性 | 全部事件可解析且非 None | +| BL-15 | LIVE live_event_type | `live_event_type = BiliLiveEventType.LIVE` | +| BL-16 | PREPARING live_event_type | `live_event_type = BiliLiveEventType.PREPARING` | +| BL-17 | LIVE 附加 LiveRoomInfo | 携带 room_info 时解析为 `LiveRoomInfo` | diff --git a/tests/unit/adapter/test_bilibili_parser.py b/tests/unit/adapter/test_bilibili_parser.py index 8e4e8cb6..e657e30d 100644 --- a/tests/unit/adapter/test_bilibili_parser.py +++ b/tests/unit/adapter/test_bilibili_parser.py @@ -18,6 +18,9 @@ BL-12: 私信撤回解析 BL-13: 评论解析 BL-14: 全量夹具一致性 — 全部事件可解析且非 None + BL-15: LIVE 事件 live_event_type 设置为 LIVE + BL-16: PREPARING 事件 live_event_type 设置为 PREPARING + BL-17: LIVE 事件携带 room_info 时附加 LiveRoomInfo """ import json @@ -287,3 +290,79 @@ def test_bl14_all_fixtures_parse(self, parser, fixtures): cmd = fix["raw_data"].get("type", fix["source_type"]) failed.append((i, cmd)) assert not failed, f"解析返回 None: {failed}" + + +class TestLiveEventType: + """BL-15 / BL-16: live_event_type 正确设置""" + + def test_bl15_live_event_type_is_live(self, parser, fixtures): + """BL-15: LIVE 事件的 live_event_type 为 BiliLiveEventType.LIVE""" + from ncatbot.types.bilibili.enums import BiliLiveEventType + + fix = _get(fixtures, "live", "LIVE") + result = parser.parse(fix["source_type"], fix["raw_data"]) + assert isinstance(result, LiveStatusEventData) + assert result.live_event_type == BiliLiveEventType.LIVE + + def test_bl16_preparing_event_type_is_preparing(self, parser, fixtures): + """BL-16: PREPARING 事件的 live_event_type 为 BiliLiveEventType.PREPARING""" + from ncatbot.types.bilibili.enums import BiliLiveEventType + + fix = _get(fixtures, "live", "PREPARING") + result = parser.parse(fix["source_type"], fix["raw_data"]) + assert isinstance(result, LiveStatusEventData) + assert result.live_event_type == BiliLiveEventType.PREPARING + + +class TestLiveRoomInfoAttach: + """BL-17: LIVE 事件携带 room_info 时附加 LiveRoomInfo""" + + def test_bl17_live_with_room_info(self, parser): + """BL-17: callback_info 中携带 room_info 时,解析结果附带 LiveRoomInfo""" + from ncatbot.types.bilibili.models import LiveRoomInfo + + raw = { + "type": "LIVE", + "room_real_id": "12345", + "room_display_id": "12345", + "data": {}, + "room_info": { + "room_info": { + "uid": 100, + "room_id": 12345, + "title": "测试开播", + "area_name": "聊天", + "live_status": 1, + "online": 999, + }, + "anchor_info": { + "base_info": {"uname": "TestStreamer", "face": ""}, + }, + "watched_show": { + "num": 500, + "text_small": "500", + "text_large": "500人看过", + }, + }, + } + result = parser.parse("live", raw) + assert isinstance(result, LiveStatusEventData) + assert result.room_info is not None + assert isinstance(result.room_info, LiveRoomInfo) + assert result.room_info.room_info.title == "测试开播" + assert result.room_info.anchor_info.name == "TestStreamer" + assert result.room_info.watched_show.num == 500 + + def test_bl17_live_without_room_info(self, parser, fixtures): + """BL-17: 不携带 room_info 时,字段为 None""" + fix = _get(fixtures, "live", "LIVE") + result = parser.parse(fix["source_type"], fix["raw_data"]) + assert isinstance(result, LiveStatusEventData) + assert result.room_info is None + + def test_bl17_preparing_no_room_info(self, parser, fixtures): + """BL-17: PREPARING 事件不附加 room_info""" + fix = _get(fixtures, "live", "PREPARING") + result = parser.parse(fix["source_type"], fix["raw_data"]) + assert isinstance(result, LiveStatusEventData) + assert result.room_info is None diff --git a/tests/unit/core/README.md b/tests/unit/core/README.md index f0767840..d0b175b1 100644 --- a/tests/unit/core/README.md +++ b/tests/unit/core/README.md @@ -68,6 +68,8 @@ | R-02 | ContextVar 隔离 | 不同 `plugin_name` 的 handler 分开收集 | | R-03 | `fork()` | 创建独立 Registrar 实例 | | R-04 | `clear_pending()` | 清理残留的 pending handler | +| R-08 | `bilibili.on_live_start()` 路由 | 路由到 `live.live` 事件类型 | +| R-09 | `bilibili.on_live_end()` 路由 | 路由到 `live.preparing` 事件类型 | ### Registrar 堆叠装饰器去重 (`test_duplicate_handler.py`) diff --git a/tests/unit/core/test_registrar.py b/tests/unit/core/test_registrar.py index b3975de6..8cb92895 100644 --- a/tests/unit/core/test_registrar.py +++ b/tests/unit/core/test_registrar.py @@ -6,6 +6,8 @@ R-02: ContextVar 隔离不同 plugin_name R-03: fork() 创建独立 Registrar R-04: clear_pending() 清理残留 + R-08: registrar.bilibili.on_live_start() 路由到 live.live + R-09: registrar.bilibili.on_live_end() 路由到 live.preparing """ import pytest @@ -135,3 +137,40 @@ async def handler(event): # 再次 flush 应返回 0 hd = HandlerDispatcher(api=MockBotAPI()) assert flush_pending(hd, "cleanup_test") == 0 + + +# ---- R-08 / R-09: BilibiliRegistrar 平台装饰器路由 ---- + + +def test_bilibili_on_live_start_routes_to_live_live(): + """R-08: registrar.bilibili.on_live_start() 路由到 live.live""" + reg = Registrar() + hd = HandlerDispatcher(api=MockBotAPI()) + + @reg.bilibili.on_live_start() + async def handle_start(event): + pass + + count = flush_pending(hd, "__global__") + assert count == 1 + + handlers = hd.get_handlers("live.live") + funcs = [h.func for h in handlers] + assert handle_start in funcs + + +def test_bilibili_on_live_end_routes_to_live_preparing(): + """R-09: registrar.bilibili.on_live_end() 路由到 live.preparing""" + reg = Registrar() + hd = HandlerDispatcher(api=MockBotAPI()) + + @reg.bilibili.on_live_end() + async def handle_end(event): + pass + + count = flush_pending(hd, "__global__") + assert count == 1 + + handlers = hd.get_handlers("live.preparing") + funcs = [h.func for h in handlers] + assert handle_end in funcs diff --git a/tests/unit/types/README.md b/tests/unit/types/README.md index dd680d4e..5476f059 100644 --- a/tests/unit/types/README.md +++ b/tests/unit/types/README.md @@ -85,3 +85,11 @@ | 规范 ID | 说明 | 验证点 | |---------|------|--------| | SEG-01 | 纯文本消息返回空 | `get_attachments()` 返回空 `AttachmentList` | + +### Bilibili 数据模型 (`test_bilibili_models.py`) + +| 规范 ID | 说明 | 验证点 | +|---------|------|--------| +| LR-01 | `LiveRoomInfo.from_raw()` 完整解析 | 全字段正确映射 | +| LR-02 | `from_raw()` 空/缺失字段 | 回退默认值 | +| LR-03 | `from_raw()` 异常数据 | 返回 None | diff --git a/tests/unit/types/test_bilibili_models.py b/tests/unit/types/test_bilibili_models.py new file mode 100644 index 00000000..7b289a81 --- /dev/null +++ b/tests/unit/types/test_bilibili_models.py @@ -0,0 +1,169 @@ +""" +Bilibili 数据模型测试 + +规范: + LR-01: LiveRoomInfo.from_raw() 完整解析 — 全字段正确映射 + LR-02: from_raw() 空/缺失字段回退默认值 + LR-03: from_raw() 异常数据返回 None +""" + +from ncatbot.types.bilibili.models import ( + AnchorInfo, + LiveRoomInfo, + RoomInfo, + WatchedShow, +) + +# ---- 真实结构的测试数据 ---- + +FULL_RAW = { + "room_info": { + "uid": 12345, + "room_id": 67890, + "title": "测试直播间", + "cover": "https://example.com/cover.jpg", + "background": "https://example.com/bg.jpg", + "description": "这是一个测试直播间", + "tags": "游戏,虚拟主播,聊天", + "live_status": 1, + "live_start_time": 1700000000, + "parent_area_name": "虚拟主播", + "parent_area_id": 9, + "area_name": "聊天室", + "area_id": 371, + "keyframe": "https://example.com/keyframe.jpg", + "online": 5000, + }, + "anchor_info": { + "base_info": { + "uname": "TestAnchor", + "face": "https://example.com/face.jpg", + "gender": "女", + "official_info": {"title": "bilibili知名UP主"}, + }, + "medal_info": { + "medal_name": "测试勋章", + "fansclub": 10000, + }, + "live_info": { + "level": 40, + "score": 123456, + "upgrade_score": 200000, + }, + }, + "watched_show": { + "num": 23000, + "text_small": "2.3万", + "text_large": "2.3万人看过", + }, +} + + +class TestFromRawFull: + """LR-01: LiveRoomInfo.from_raw() 完整解析""" + + def test_lr01_room_info_fields(self): + """LR-01: room_info 字段正确映射""" + info = LiveRoomInfo.from_raw(FULL_RAW) + assert info is not None + + r = info.room_info + assert isinstance(r, RoomInfo) + assert r.uid == 12345 + assert r.room_id == 67890 + assert r.title == "测试直播间" + assert r.cover_url == "https://example.com/cover.jpg" + assert r.background_url == "https://example.com/bg.jpg" + assert r.description == "这是一个测试直播间" + assert r.tags == ("游戏", "虚拟主播", "聊天") + assert r.live_status == 1 + assert r.live_start_time == 1700000000 + assert r.parent_area_name == "虚拟主播" + assert r.parent_area_id == 9 + assert r.area_name == "聊天室" + assert r.area_id == 371 + assert r.keyframe_url == "https://example.com/keyframe.jpg" + assert r.online == 5000 + + def test_lr01_anchor_info_fields(self): + """LR-01: anchor_info 字段正确映射""" + info = LiveRoomInfo.from_raw(FULL_RAW) + assert info is not None + + a = info.anchor_info + assert isinstance(a, AnchorInfo) + assert a.name == "TestAnchor" + assert a.face_url == "https://example.com/face.jpg" + assert a.gender == "女" + assert a.official_info == "bilibili知名UP主" + assert a.fanclub_name == "测试勋章" + assert a.fanclub_num == 10000 + assert a.live_level == 40 + assert a.live_score == 123456 + assert a.live_upgrade_score == 200000 + + def test_lr01_watched_show_fields(self): + """LR-01: watched_show 字段正确映射""" + info = LiveRoomInfo.from_raw(FULL_RAW) + assert info is not None + + w = info.watched_show + assert isinstance(w, WatchedShow) + assert w.num == 23000 + assert w.text_small == "2.3万" + assert w.text_large == "2.3万人看过" + + def test_lr01_tags_empty_string(self): + """LR-01: tags 为空字符串时解析为空元组""" + raw = {"room_info": {"tags": ""}} + info = LiveRoomInfo.from_raw(raw) + assert info is not None + assert info.room_info.tags == () + + +class TestFromRawDefaults: + """LR-02: 空/缺失字段回退默认值""" + + def test_lr02_empty_dict(self): + """LR-02: 传入空 dict 返回全默认值的 LiveRoomInfo""" + info = LiveRoomInfo.from_raw({}) + assert info is not None + assert info.room_info.uid == 0 + assert info.room_info.title == "" + assert info.room_info.tags == () + assert info.anchor_info.name == "" + assert info.anchor_info.live_level == 0 + assert info.watched_show.num == 0 + + def test_lr02_partial_room_info(self): + """LR-02: room_info 只有部分字段,其余回退默认值""" + raw = {"room_info": {"title": "部分标题", "uid": 999}} + info = LiveRoomInfo.from_raw(raw) + assert info is not None + assert info.room_info.title == "部分标题" + assert info.room_info.uid == 999 + assert info.room_info.room_id == 0 + assert info.room_info.online == 0 + + def test_lr02_missing_anchor_sub_dicts(self): + """LR-02: anchor_info 缺少 base_info/medal_info/live_info""" + raw = {"anchor_info": {}} + info = LiveRoomInfo.from_raw(raw) + assert info is not None + assert info.anchor_info.name == "" + assert info.anchor_info.fanclub_name == "" + assert info.anchor_info.live_level == 0 + + +class TestFromRawError: + """LR-03: 异常数据返回 None""" + + def test_lr03_none_input(self): + """LR-03: 传入 None 时返回 None(触发 AttributeError)""" + result = LiveRoomInfo.from_raw(None) # type: ignore[arg-type] + assert result is None + + def test_lr03_non_dict_input(self): + """LR-03: 传入非 dict 类型返回 None""" + result = LiveRoomInfo.from_raw("not a dict") # type: ignore[arg-type] + assert result is None