From 7c2c636234a4690d6103eb57e9889f85d28e5e6c Mon Sep 17 00:00:00 2001 From: GEYUANwuqi <17539198883@163.com> Date: Sat, 21 Mar 2026 15:41:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(bilibili):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E8=AE=A2=E9=98=85=E6=BA=90=20-=20=E5=B7=B2?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=9C=AC=E6=AC=A1=E6=8F=90=E4=BA=A4=E9=99=84?= =?UTF-8?q?=E5=B8=A6=E7=9A=84test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ncatbot/adapter/bilibili/adapter.py | 16 +- ncatbot/adapter/bilibili/api/bot_api.py | 2 + ncatbot/adapter/bilibili/api/dynamic.py | 35 ++++ ncatbot/adapter/bilibili/config.py | 8 + ncatbot/adapter/bilibili/parser.py | 176 ++++++++++++++++- ncatbot/adapter/bilibili/source/__init__.py | 2 + .../adapter/bilibili/source/dynamic_source.py | 178 ++++++++++++++++++ ncatbot/adapter/bilibili/source/manager.py | 25 +++ ncatbot/api/bilibili/interface.py | 18 ++ ncatbot/core/registry/platform.py | 14 ++ ncatbot/event/bilibili/__init__.py | 4 + ncatbot/event/bilibili/dynamic.py | 47 +++++ ncatbot/event/bilibili/factory.py | 4 + ncatbot/types/bilibili/__init__.py | 19 +- ncatbot/types/bilibili/enums.py | 9 + ncatbot/types/bilibili/events.py | 49 ++++- ncatbot/types/bilibili/models.py | 64 +++++++ tests/README.md | 2 +- tests/fixtures/bilibili_events.json | 163 ++++++++++++++++ tests/unit/adapter/README.md | 5 + tests/unit/adapter/test_bilibili_parser.py | 149 +++++++++++++++ 21 files changed, 981 insertions(+), 8 deletions(-) create mode 100644 ncatbot/adapter/bilibili/api/dynamic.py create mode 100644 ncatbot/adapter/bilibili/source/dynamic_source.py create mode 100644 ncatbot/event/bilibili/dynamic.py diff --git a/ncatbot/adapter/bilibili/adapter.py b/ncatbot/adapter/bilibili/adapter.py index ddbe1e8b..ca0e6aa5 100644 --- a/ncatbot/adapter/bilibili/adapter.py +++ b/ncatbot/adapter/bilibili/adapter.py @@ -20,8 +20,13 @@ class BilibiliAdapter(BaseAdapter): name = "bilibili" - description = "Bilibili 适配器 (直播/私信/评论)" - supported_protocols = ["bilibili_live", "bilibili_session", "bilibili_comment"] + description = "Bilibili 适配器 (直播/私信/评论/动态)" + supported_protocols = [ + "bilibili_live", + "bilibili_session", + "bilibili_comment", + "bilibili_dynamic", + ] platform = "bilibili" pip_dependencies = {"bilibili-api-python": ">=17.0.0"} @@ -90,6 +95,7 @@ async def connect(self) -> None: retry_after=self._config.retry_after, session_poll_interval=self._config.session_poll_interval, comment_poll_interval=self._config.comment_poll_interval, + dynamic_poll_interval=self._config.dynamic_poll_interval, ) self._api = BiliBotAPI(self._credential, self._source_manager) @@ -106,12 +112,16 @@ async def connect(self) -> None: watch.id, watch.type, self._credential ) + for watch in self._config.dynamic_watches: + await self._source_manager.add_dynamic_watch(watch.uid, self._credential) + self._connected = True LOG.info( - "Bilibili 适配器已连接 (直播间: %d, 私信: %s, 评论: %d)", + "Bilibili 适配器已连接 (直播间: %d, 私信: %s, 评论: %d, 动态: %d)", len(self._config.live_rooms), self._config.enable_session, len(self._config.comment_watches), + len(self._config.dynamic_watches), ) async def listen(self) -> None: diff --git a/ncatbot/adapter/bilibili/api/bot_api.py b/ncatbot/adapter/bilibili/api/bot_api.py index 6506fd58..0043b9ec 100644 --- a/ncatbot/adapter/bilibili/api/bot_api.py +++ b/ncatbot/adapter/bilibili/api/bot_api.py @@ -15,6 +15,7 @@ from .room_manage import RoomManageAPIMixin from .session import SessionAPIMixin from .comment import CommentAPIMixin +from .dynamic import DynamicAPIMixin from .query import QueryAPIMixin if TYPE_CHECKING: @@ -30,6 +31,7 @@ class BiliBotAPI( RoomManageAPIMixin, SessionAPIMixin, CommentAPIMixin, + DynamicAPIMixin, QueryAPIMixin, IBiliAPIClient, ): diff --git a/ncatbot/adapter/bilibili/api/dynamic.py b/ncatbot/adapter/bilibili/api/dynamic.py new file mode 100644 index 00000000..29a69d55 --- /dev/null +++ b/ncatbot/adapter/bilibili/api/dynamic.py @@ -0,0 +1,35 @@ +"""动态操作 API Mixin""" + +from __future__ import annotations + +from typing import Optional + + +class DynamicAPIMixin: + async def get_user_dynamics(self, uid: int, offset: str = "") -> dict: + """获取用户动态列表(新版接口)""" + from bilibili_api.user import User + + user = User(uid=uid, credential=self._credential) + return await user.get_dynamics_new(offset=offset) + + async def get_user_latest_dynamic(self, uid: int) -> Optional[dict]: + """获取用户最新一条动态""" + resp = await self.get_user_dynamics(uid) + items = resp.get("items") or [] + if not items: + return None + return max( + items, + key=lambda it: int( + ((it.get("modules") or {}).get("module_author") or {}).get("pub_ts", 0) + ), + ) + + async def add_dynamic_watch(self, uid: int) -> None: + """添加动态监听""" + await self._source_manager.add_dynamic_watch(uid, self._credential) + + async def remove_dynamic_watch(self, uid: int) -> None: + """移除动态监听""" + await self._source_manager.remove_dynamic_watch(uid) diff --git a/ncatbot/adapter/bilibili/config.py b/ncatbot/adapter/bilibili/config.py index 9885755e..63306bf8 100644 --- a/ncatbot/adapter/bilibili/config.py +++ b/ncatbot/adapter/bilibili/config.py @@ -14,6 +14,12 @@ class CommentWatch(BaseModel): type: str = "video" +class DynamicWatch(BaseModel): + """动态监听条目""" + + uid: int + + class BilibiliConfig(BaseModel): """Bilibili 适配器专属配置""" @@ -28,10 +34,12 @@ class BilibiliConfig(BaseModel): live_rooms: List[int] = Field(default_factory=list) enable_session: bool = False comment_watches: List[CommentWatch] = Field(default_factory=list) + dynamic_watches: List[DynamicWatch] = Field(default_factory=list) # 轮询配置 session_poll_interval: float = 6.0 comment_poll_interval: float = 30.0 + dynamic_poll_interval: float = 600.0 # 连接 max_retry: int = 5 diff --git a/ncatbot/adapter/bilibili/parser.py b/ncatbot/adapter/bilibili/parser.py index fce13229..ef8f85e0 100644 --- a/ncatbot/adapter/bilibili/parser.py +++ b/ncatbot/adapter/bilibili/parser.py @@ -12,6 +12,7 @@ from ncatbot.types.bilibili.events import ( BiliCommentEventData, BiliConnectionEventData, + BiliDynamicEventData, BiliPrivateMessageEventData, BiliPrivateMessageWithdrawEventData, DanmuAggregationEventData, @@ -34,7 +35,14 @@ WatchedChangeEventData, ) from ncatbot.types.bilibili.enums import BiliLiveEventType, BiliSessionEventType -from ncatbot.types.bilibili.models import LiveRoomInfo +from ncatbot.types.bilibili.models import ( + LiveRoomInfo, + DynamicStatInfo, + DynamicVideoInfo, + DynamicMusicInfo, + DynamicArticleInfo, + DynamicLiveRcmdInfo, +) from ncatbot.types.bilibili.sender import BiliSender from ncatbot.types.common.base import BaseEventData from ncatbot.types.common.segment.array import MessageArray @@ -57,6 +65,8 @@ def parse(self, source_type: str, raw_data: dict) -> Optional[BaseEventData]: return self._parse_session(raw_data) if source_type == "comment": return self._parse_comment(raw_data) + if source_type == "dynamic": + return self._parse_dynamic(raw_data) except Exception: LOG.debug( "解析 %s 事件失败: %s", @@ -177,6 +187,170 @@ def _parse_comment(self, raw: dict) -> Optional[BaseEventData]: sender=sender, ) + # ==================== 动态解析 ==================== + + def _parse_dynamic(self, raw: dict) -> Optional[BaseEventData]: + import json as _json + from ncatbot.types.bilibili.enums import BiliDynamicEventType + + item = raw.get("dynamic") or {} + dynamic_status = raw.get("status", "new") # "new" | "deleted" + now = int(time.time()) + + # 基础信息 — 与 DTO.from_raw 保持一致 + dynamic_id = item.get("id_str", "") + dynamic_type = item.get("type", "") + modules = item.get("modules") or {} + author_info = modules.get("module_author") or {} + pub_ts = author_info.get("pub_ts", 0) + pub_time = author_info.get("pub_time", "") + + uid = str(author_info.get("mid", raw.get("uid", ""))) + uname = author_info.get("name", "") + face = author_info.get("face", "") + + # 解析动态内容 + text = None + pics_url = None + video = None + music = None + article = None + live_rcmd = None + tag = None + stat = None + + module_dynamic = modules.get("module_dynamic") or {} + major = module_dynamic.get("major") or {} + + # 统计信息 + stat_info = modules.get("module_stat") + if stat_info: + stat = DynamicStatInfo( + comment_count=(stat_info.get("comment") or {}).get("count", 0), + like_count=(stat_info.get("like") or {}).get("count", 0), + forward_count=(stat_info.get("forward") or {}).get("count", 0), + ) + + # 标签(如置顶) + if modules.get("module_tag"): + tag = modules["module_tag"].get("text") + + # 文字/图片动态 + if dynamic_type in ("DYNAMIC_TYPE_WORD", "DYNAMIC_TYPE_DRAW"): + opus = major.get("opus") or {} + summary = opus.get("summary") or {} + text = summary.get("text", "") + pics = opus.get("pics") or [] + if pics: + pics_url = [pic.get("url", "") for pic in pics] + + # 视频动态 + elif dynamic_type == "DYNAMIC_TYPE_AV": + archive = major.get("archive") or {} + desc_info = module_dynamic.get("desc") + stat_v = archive.get("stat") or {} + video = DynamicVideoInfo( + av_id=str(archive.get("aid", "")), + bv_id=archive.get("bvid", ""), + title=archive.get("title", ""), + cover=archive.get("cover", ""), + desc=archive.get("desc", ""), + duration_text=archive.get("duration_text", ""), + dynamic_text=desc_info.get("text", "") if desc_info else "", + play_count=str(stat_v.get("play", "")), + danmaku_count=str(stat_v.get("danmaku", "")), + ) + + # 音乐动态 + elif dynamic_type == "DYNAMIC_TYPE_MUSIC": + music_info = major.get("music") or {} + desc_info = module_dynamic.get("desc") + music = DynamicMusicInfo( + music_id=str(music_info.get("id", "")), + title=music_info.get("title", ""), + cover=music_info.get("cover", ""), + label=music_info.get("label", ""), + dynamic_text=desc_info.get("text", "") if desc_info else "", + ) + + # 专栏动态 + elif dynamic_type == "DYNAMIC_TYPE_ARTICLE": + opus = major.get("opus") or {} + summary_info = opus.get("summary") or {} + article = DynamicArticleInfo( + title=opus.get("title", ""), + summary=summary_info.get("text", ""), + has_more=summary_info.get("has_more", False), + article_id=int(dynamic_id) if dynamic_id.isdigit() else 0, + ) + + # 直播推荐动态 + elif dynamic_type == "DYNAMIC_TYPE_LIVE_RCMD": + live_rcmd_obj = major.get("live_rcmd") or {} + live_rcmd_content = live_rcmd_obj.get("content", "{}") + try: + live_data = _json.loads(live_rcmd_content) + except Exception: + live_data = {} + live_play_info = live_data.get("live_play_info") or {} + live_rcmd = DynamicLiveRcmdInfo( + room_id=live_play_info.get("room_id", 0), + live_status=live_play_info.get("live_status", 0), + title=live_play_info.get("title", ""), + cover=live_play_info.get("cover", ""), + online=live_play_info.get("online", 0), + area_id=live_play_info.get("area_id", 0), + area_name=live_play_info.get("area_name", ""), + parent_area_id=live_play_info.get("parent_area_id", 0), + parent_area_name=live_play_info.get("parent_area_name", ""), + live_start_time=live_play_info.get("live_start_time", 0), + ) + + # 转发动态 + forward_dynamic_id = None + if dynamic_type == "DYNAMIC_TYPE_FORWARD": + desc_info = module_dynamic.get("desc") + text = desc_info.get("text", "") if desc_info else "" + orig = item.get("orig") or {} + forward_dynamic_id = orig.get("id_str", "") + + # 映射 status → dynamic_event_type + if dynamic_status == "deleted": + dyn_event_type = BiliDynamicEventType.DELETED_DYNAMIC + else: + dyn_event_type = BiliDynamicEventType.NEW_DYNAMIC + + sender = BiliSender( + user_id=uid, + nickname=uname, + face_url=face, + ) + + return BiliDynamicEventData( + time=pub_ts or now, + self_id=self._self_id, + platform="bilibili", + dynamic_event_type=dyn_event_type, + dynamic_status=dynamic_status, + dynamic_id=dynamic_id, + dynamic_type=dynamic_type, + uid=uid, + user_name=uname, + face_url=face, + pub_ts=pub_ts, + pub_time=pub_time, + text=text, + pics_url=pics_url, + tag=tag, + stat=stat, + video=video, + music=music, + article=article, + live_rcmd=live_rcmd, + forward_dynamic_id=forward_dynamic_id, + sender=sender, + ) + # ==================== 直播事件解析器注册表 ==================== diff --git a/ncatbot/adapter/bilibili/source/__init__.py b/ncatbot/adapter/bilibili/source/__init__.py index d3e5f706..d746e7f3 100644 --- a/ncatbot/adapter/bilibili/source/__init__.py +++ b/ncatbot/adapter/bilibili/source/__init__.py @@ -2,6 +2,7 @@ from .live_source import LiveSource from .session_source import SessionSource from .comment_source import CommentSource +from .dynamic_source import DynamicSource from .manager import SourceManager __all__ = [ @@ -9,5 +10,6 @@ "LiveSource", "SessionSource", "CommentSource", + "DynamicSource", "SourceManager", ] diff --git a/ncatbot/adapter/bilibili/source/dynamic_source.py b/ncatbot/adapter/bilibili/source/dynamic_source.py new file mode 100644 index 00000000..995adc2e --- /dev/null +++ b/ncatbot/adapter/bilibili/source/dynamic_source.py @@ -0,0 +1,178 @@ +"""动态数据源 — 轮询指定用户的动态列表,基于时间戳增量检测新/删动态 + +采用 DataPair 模式缓存每个用户的最新动态时间戳: +- new_ts > old_ts → 发布了新动态 +- new_ts < old_ts → 删除了动态(推送深拷贝的 old_data) +""" + +from __future__ import annotations + +import asyncio +from copy import deepcopy +from typing import Any, Awaitable, Callable, Optional + +from ncatbot.utils import get_log + +from .base import BaseSource + +LOG = get_log("DynamicSource") + + +class _DynamicDataPair: + """缓存单个用户的新旧动态数据""" + + __slots__ = ("old_data", "new_data", "old_ts", "new_ts") + + def __init__(self) -> None: + self.old_data: Optional[dict] = None + self.new_data: Optional[dict] = None + self.old_ts: int = 0 + self.new_ts: int = 0 + + def update(self, latest_item: dict, pub_ts: int) -> None: + """更新数据对:将当前 new 移到 old,新数据放入 new""" + if self.old_data is None: + # 首次写入,初始化双端 + self.old_data = deepcopy(latest_item) + self.new_data = latest_item + self.old_ts = pub_ts + self.new_ts = pub_ts + else: + self.old_data = deepcopy(self.new_data) + self.old_ts = self.new_ts + self.new_data = latest_item + self.new_ts = pub_ts + + +class DynamicSource(BaseSource): + source_type = "dynamic" + + def __init__( + self, + uid: int, + credential: Any, + callback: Callable[[str, dict], Awaitable[None]], + *, + poll_interval: float = 600.0, + ) -> None: + super().__init__(callback) + self.source_id = str(uid) + self._uid = uid + self._credential = credential + self._poll_interval = poll_interval + self._pair: _DynamicDataPair = _DynamicDataPair() + self._task: Optional[asyncio.Task] = None + self._stop_event = asyncio.Event() + + async def start(self) -> None: + if self._running: + return + self._stop_event.clear() + self._running = True + self._task = asyncio.create_task( + self._poll_loop(), name=f"dynamic_source_{self._uid}" + ) + LOG.info("动态源 %s 已启动 (轮询间隔 %.0fs)", self._uid, self._poll_interval) + + @staticmethod + def _extract_latest(resp: dict) -> Optional[dict]: + """从接口响应中取时间戳最大的一条动态(仅支持新版 items 格式)""" + items = resp.get("items") or [] + if not items: + return None + return max( + items, + key=lambda it: int( + ((it.get("modules") or {}).get("module_author") or {}).get("pub_ts", 0) + ), + ) + + @staticmethod + def _extract_pub_ts(item: dict) -> int: + """提取动态发布时间戳(仅新版 API items 格式)""" + modules = item.get("modules") or {} + author = modules.get("module_author") or {} + return int(author.get("pub_ts", 0)) + + async def _poll_loop(self) -> None: + from bilibili_api.user import User + + user = User(uid=self._uid, credential=self._credential) + + # 初次拉取,填充 DataPair 基线 + try: + resp = await user.get_dynamics_new() + latest = self._extract_latest(resp) + if latest: + ts = self._extract_pub_ts(latest) + self._pair.update(latest, ts) + LOG.debug("动态源 %s 初始时间戳: %d", self._uid, ts) + except Exception: + LOG.warning("动态源 %s 初次拉取失败", self._uid) + + while not self._stop_event.is_set(): + try: + await asyncio.wait_for( + self._stop_event.wait(), timeout=self._poll_interval + ) + break + except asyncio.TimeoutError: + pass + + try: + resp = await user.get_dynamics_new() + latest = self._extract_latest(resp) + if latest is None: + continue + + new_ts = self._extract_pub_ts(latest) + old_ts = self._pair.new_ts + + if old_ts == 0: + # 首次有数据 + self._pair.update(latest, new_ts) + continue + + if new_ts > old_ts: + # 新动态发布 + self._pair.update(latest, new_ts) + raw = { + "source": "dynamic", + "uid": self._uid, + "status": "new", + "dynamic": self._pair.new_data, + } + await self._callback("dynamic", raw) + + elif new_ts < old_ts: + # 动态被删除 — 推送被删动态的缓存(即上次轮询的 new_data) + deleted_data = deepcopy(self._pair.new_data) + self._pair.update(latest, new_ts) + raw = { + "source": "dynamic", + "uid": self._uid, + "status": "deleted", + "dynamic": deleted_data, + } + await self._callback("dynamic", raw) + + # new_ts == old_ts → 无变化,跳过 + + except asyncio.CancelledError: + break + except Exception: + LOG.debug("动态源 %s 轮询异常", self._uid, exc_info=True) + + self._running = False + + async def stop(self) -> None: + self._stop_event.set() + if self._task is not None and not self._task.done(): + self._task.cancel() + try: + await self._task + except (asyncio.CancelledError, Exception): + pass + self._running = False + self._task = None + LOG.debug("动态源 %s 已停止", self._uid) diff --git a/ncatbot/adapter/bilibili/source/manager.py b/ncatbot/adapter/bilibili/source/manager.py index 78d48628..2398112c 100644 --- a/ncatbot/adapter/bilibili/source/manager.py +++ b/ncatbot/adapter/bilibili/source/manager.py @@ -15,6 +15,7 @@ from .live_source import LiveSource from .session_source import SessionSource from .comment_source import CommentSource +from .dynamic_source import DynamicSource LOG = get_log("SourceManager") @@ -30,6 +31,7 @@ def __init__( retry_after: float = 1.0, session_poll_interval: float = 6.0, comment_poll_interval: float = 30.0, + dynamic_poll_interval: float = 600.0, ) -> None: self._callback = callback self._sources: Dict[str, BaseSource] = {} @@ -37,6 +39,7 @@ def __init__( self._retry_after = retry_after self._session_poll_interval = session_poll_interval self._comment_poll_interval = comment_poll_interval + self._dynamic_poll_interval = dynamic_poll_interval self._stop_event = asyncio.Event() # ---- 直播间 ---- @@ -82,6 +85,28 @@ async def stop_session(self) -> None: if source is not None: await source.stop() + # ---- 动态 ---- + + async def add_dynamic_watch(self, uid: int, credential: Any) -> None: + key = f"dynamic:{uid}" + if key in self._sources: + LOG.warning("动态源 %s 已存在,跳过", uid) + return + source = DynamicSource( + uid=uid, + credential=credential, + callback=self._callback, + poll_interval=self._dynamic_poll_interval, + ) + self._sources[key] = source + await source.start() + + async def remove_dynamic_watch(self, uid: int) -> None: + key = f"dynamic:{uid}" + source = self._sources.pop(key, None) + if source is not None: + await source.stop() + # ---- 评论 ---- async def add_comment_watch( diff --git a/ncatbot/api/bilibili/interface.py b/ncatbot/api/bilibili/interface.py index 5267f4d9..8069d1bd 100644 --- a/ncatbot/api/bilibili/interface.py +++ b/ncatbot/api/bilibili/interface.py @@ -113,6 +113,24 @@ async def get_comments( ) -> list: """获取评论列表""" + # ---- 动态 ---- + + @abstractmethod + async def get_user_dynamics(self, uid: int, offset: str = "") -> dict: + """获取用户动态列表""" + + @abstractmethod + async def get_user_latest_dynamic(self, uid: int) -> Optional[dict]: + """获取用户最新一条动态""" + + @abstractmethod + async def add_dynamic_watch(self, uid: int) -> None: + """添加动态监听""" + + @abstractmethod + async def remove_dynamic_watch(self, uid: int) -> None: + """移除动态监听""" + # ---- 用户查询 ---- @abstractmethod diff --git a/ncatbot/core/registry/platform.py b/ncatbot/core/registry/platform.py index 89d70d2a..23c926c0 100644 --- a/ncatbot/core/registry/platform.py +++ b/ncatbot/core/registry/platform.py @@ -253,6 +253,20 @@ def on_comment(self, priority: int = 0, **metadata: Any) -> Callable: """注册所有评论事件 handler""" return self.on("comment", priority=priority, **metadata) + # ---- 动态事件 ---- + + def on_dynamic(self, priority: int = 0, **metadata: Any) -> Callable: + """注册所有动态事件 handler(新动态 + 删动态)""" + return self.on("dynamic", priority=priority, **metadata) + + def on_dynamic_new(self, priority: int = 0, **metadata: Any) -> Callable: + """注册新动态事件 handler""" + return self.on("dynamic.new_dynamic", priority=priority, **metadata) + + def on_dynamic_deleted(self, priority: int = 0, **metadata: Any) -> Callable: + """注册删除动态事件 handler""" + return self.on("dynamic.deleted_dynamic", priority=priority, **metadata) + class GitHubRegistrar(PlatformRegistrar): """GitHub 平台子注册器 diff --git a/ncatbot/event/bilibili/__init__.py b/ncatbot/event/bilibili/__init__.py index 1b2e676e..5a354e60 100644 --- a/ncatbot/event/bilibili/__init__.py +++ b/ncatbot/event/bilibili/__init__.py @@ -12,6 +12,7 @@ ) from .session import BiliPrivateMessageEvent, BiliPrivateMessageWithdrawEvent from .comment import BiliCommentEvent +from .dynamic import BiliDynamicEvent from .factory import create_bili_entity # 自动注册 Bilibili 平台工厂和 secondary keys 到通用工厂 @@ -26,6 +27,7 @@ { "live": "live_event_type", "comment": "comment_event_type", + "dynamic": "dynamic_event_type", }, ) del _register, _register_keys @@ -45,6 +47,8 @@ "BiliPrivateMessageWithdrawEvent", # comment "BiliCommentEvent", + # dynamic + "BiliDynamicEvent", # factory "create_bili_entity", ] diff --git a/ncatbot/event/bilibili/dynamic.py b/ncatbot/event/bilibili/dynamic.py new file mode 100644 index 00000000..2cbe4171 --- /dev/null +++ b/ncatbot/event/bilibili/dynamic.py @@ -0,0 +1,47 @@ +"""动态事件实体""" + +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +from ncatbot.event.common.base import BaseEvent +from ncatbot.event.common.mixins import HasSender + +if TYPE_CHECKING: + from ncatbot.api.bilibili import IBiliAPIClient + from ncatbot.types.bilibili.events import BiliDynamicEventData + +__all__ = [ + "BiliDynamicEvent", +] + + +class BiliDynamicEvent(BaseEvent, HasSender): + """B 站动态事件""" + + _data: "BiliDynamicEventData" + _api: "IBiliAPIClient" + + @property + def api(self) -> "IBiliAPIClient": + return self._api + + @property + def user_id(self) -> str: + return self._data.uid + + @property + def sender(self) -> Any: + return self._data.sender + + @property + def dynamic_id(self) -> str: + return self._data.dynamic_id + + @property + def dynamic_type(self) -> str: + return self._data.dynamic_type + + @property + def text(self) -> str | None: + return self._data.text diff --git a/ncatbot/event/bilibili/factory.py b/ncatbot/event/bilibili/factory.py index e28b1b25..93d8a8b9 100644 --- a/ncatbot/event/bilibili/factory.py +++ b/ncatbot/event/bilibili/factory.py @@ -16,6 +16,7 @@ BiliPrivateMessageEventData, BiliPrivateMessageWithdrawEventData, BiliCommentEventData, + BiliDynamicEventData, ) from ncatbot.event.common.base import BaseEvent from .live import ( @@ -29,6 +30,7 @@ ) from .session import BiliPrivateMessageEvent, BiliPrivateMessageWithdrawEvent from .comment import BiliCommentEvent +from .dynamic import BiliDynamicEvent if TYPE_CHECKING: from ncatbot.api import IAPIClient @@ -48,6 +50,7 @@ BiliPrivateMessageEventData: BiliPrivateMessageEvent, BiliPrivateMessageWithdrawEventData: BiliPrivateMessageWithdrawEvent, BiliCommentEventData: BiliCommentEvent, + BiliDynamicEventData: BiliDynamicEvent, } # post_type → 降级实体基类 @@ -55,6 +58,7 @@ BiliPostType.LIVE: BiliLiveEvent, BiliPostType.MESSAGE: BaseEvent, BiliPostType.COMMENT: BaseEvent, + BiliPostType.DYNAMIC: BaseEvent, BiliPostType.SYSTEM: BaseEvent, } diff --git a/ncatbot/types/bilibili/__init__.py b/ncatbot/types/bilibili/__init__.py index 4618e7f9..d15fc20c 100644 --- a/ncatbot/types/bilibili/__init__.py +++ b/ncatbot/types/bilibili/__init__.py @@ -2,15 +2,24 @@ from .enums import ( BiliCommentEventType, + BiliDynamicEventType, BiliLiveEventType, BiliPostType, BiliSessionEventType, ) from .sender import BiliSender -from .models import LiveRoomInfo +from .models import ( + LiveRoomInfo, + DynamicStatInfo, + DynamicVideoInfo, + DynamicMusicInfo, + DynamicArticleInfo, + DynamicLiveRcmdInfo, +) from .events import ( BiliCommentEventData, BiliConnectionEventData, + BiliDynamicEventData, BiliLiveEventData, BiliPrivateMessageEventData, BiliPrivateMessageWithdrawEventData, @@ -33,6 +42,7 @@ "BiliLiveEventType", "BiliSessionEventType", "BiliCommentEventType", + "BiliDynamicEventType", # sender "BiliSender", # live events @@ -53,8 +63,15 @@ "BiliPrivateMessageWithdrawEventData", # comment events "BiliCommentEventData", + # dynamic events + "BiliDynamicEventData", # system events "BiliConnectionEventData", # models "LiveRoomInfo", + "DynamicStatInfo", + "DynamicVideoInfo", + "DynamicMusicInfo", + "DynamicArticleInfo", + "DynamicLiveRcmdInfo", ] diff --git a/ncatbot/types/bilibili/enums.py b/ncatbot/types/bilibili/enums.py index 81c9d200..00e13163 100644 --- a/ncatbot/types/bilibili/enums.py +++ b/ncatbot/types/bilibili/enums.py @@ -7,6 +7,7 @@ "BiliLiveEventType", "BiliSessionEventType", "BiliCommentEventType", + "BiliDynamicEventType", ] @@ -14,6 +15,7 @@ class BiliPostType(str, Enum): LIVE = "live" MESSAGE = "message" COMMENT = "comment" + DYNAMIC = "dynamic" SYSTEM = "system" @@ -70,3 +72,10 @@ class BiliCommentEventType(str, Enum): NEW_REPLY = "new_reply" NEW_SUB_REPLY = "new_sub_reply" + + +class BiliDynamicEventType(str, Enum): + """动态事件类型""" + + NEW_DYNAMIC = "new_dynamic" + DELETED_DYNAMIC = "deleted_dynamic" diff --git a/ncatbot/types/bilibili/events.py b/ncatbot/types/bilibili/events.py index 9e051d01..af0ee8c4 100644 --- a/ncatbot/types/bilibili/events.py +++ b/ncatbot/types/bilibili/events.py @@ -9,8 +9,20 @@ 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 .enums import ( + BiliPostType, + BiliLiveEventType, + BiliCommentEventType, + BiliDynamicEventType, +) +from .models import ( + LiveRoomInfo, + DynamicVideoInfo, + DynamicMusicInfo, + DynamicArticleInfo, + DynamicLiveRcmdInfo, + DynamicStatInfo, +) from .sender import BiliSender __all__ = [ @@ -39,6 +51,8 @@ "BiliPrivateMessageWithdrawEventData", # comment "BiliCommentEventData", + # dynamic + "BiliDynamicEventData", # system "BiliConnectionEventData", ] @@ -264,6 +278,37 @@ class BiliCommentEventData(BaseEventData): sender: BiliSender = Field(default_factory=BiliSender) +# ==================== 动态事件 ==================== + + +class BiliDynamicEventData(BaseEventData): + """动态事件""" + + post_type: str = Field(default=BiliPostType.DYNAMIC) + dynamic_event_type: str = Field(default=BiliDynamicEventType.NEW_DYNAMIC) + dynamic_status: str = "new" # "new" | "deleted" + dynamic_id: str = "" + dynamic_type: str = "" + uid: str = "" + user_name: str = "" + face_url: str = "" + pub_ts: int = 0 + pub_time: str = "" + text: Optional[str] = None + pics_url: Optional[list] = None + tag: Optional[str] = None + # 统计 + stat: Optional[DynamicStatInfo] = None + # 各类型动态内容 + video: Optional[DynamicVideoInfo] = None + music: Optional[DynamicMusicInfo] = None + article: Optional[DynamicArticleInfo] = None + live_rcmd: Optional[DynamicLiveRcmdInfo] = None + # 转发原动态 ID + forward_dynamic_id: Optional[str] = None + sender: BiliSender = Field(default_factory=BiliSender) + + # ==================== 系统事件 ==================== diff --git a/ncatbot/types/bilibili/models.py b/ncatbot/types/bilibili/models.py index e5b42121..e0e9bb9a 100644 --- a/ncatbot/types/bilibili/models.py +++ b/ncatbot/types/bilibili/models.py @@ -12,6 +12,11 @@ "RoomInfo", "AnchorInfo", "WatchedShow", + "DynamicStatInfo", + "DynamicVideoInfo", + "DynamicMusicInfo", + "DynamicArticleInfo", + "DynamicLiveRcmdInfo", ] @@ -122,3 +127,62 @@ def from_raw(cls, data: dict) -> Optional["LiveRoomInfo"]: ) except Exception: return None + + +# ==================== 动态相关模型 ==================== + + +class DynamicStatInfo(BaseModel): + """动态统计信息""" + + comment_count: int = 0 + like_count: int = 0 + forward_count: int = 0 + + +class DynamicVideoInfo(BaseModel): + """动态中的视频信息""" + + av_id: str = "" + bv_id: str = "" + title: str = "" + cover: str = "" + desc: str = "" + duration_text: str = "" + play_count: str = "" + danmaku_count: str = "" + dynamic_text: str = "" + + +class DynamicMusicInfo(BaseModel): + """动态中的音乐信息""" + + music_id: str = "" + title: str = "" + cover: str = "" + label: str = "" + dynamic_text: str = "" + + +class DynamicArticleInfo(BaseModel): + """动态中的专栏信息""" + + title: str = "" + summary: str = "" + has_more: bool = False + article_id: int = 0 + + +class DynamicLiveRcmdInfo(BaseModel): + """动态中的直播推荐信息""" + + room_id: int = 0 + live_status: int = 0 + title: str = "" + cover: str = "" + online: int = 0 + area_id: int = 0 + area_name: str = "" + parent_area_id: int = 0 + parent_area_name: str = "" + live_start_time: int = 0 diff --git a/tests/README.md b/tests/README.md index d9859199..e487fb94 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,7 +13,7 @@ tests/ │ ├── 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-17, GH-01 ~ GH-11) +│ ├── adapter/ # 适配器解析 + 注册表 + 真实数据 (P-01 ~ P-07, RF-01 ~ RF-08, AR-01 ~ AR-05, GM-01 ~ GM-05, BL-01 ~ BL-22, GH-01 ~ GH-11) │ └── config/ # 配置迁移 + 安全 (CF-01 ~ CF-05, CS-01 ~ CS-05) ├── integration/ # 集成测试 (I-01 ~ I-21) ├── e2e/ # 端到端测试 diff --git a/tests/fixtures/bilibili_events.json b/tests/fixtures/bilibili_events.json index 1a7f3bd6..699fb720 100644 --- a/tests/fixtures/bilibili_events.json +++ b/tests/fixtures/bilibili_events.json @@ -320,5 +320,168 @@ } } } + }, + { + "source_type": "dynamic", + "raw_data": { + "source": "dynamic", + "uid": 621240130, + "status": "new", + "dynamic": { + "id_str": "999000111222333", + "type": "DYNAMIC_TYPE_DRAW", + "visible": true, + "modules": { + "module_author": { + "mid": 621240130, + "name": "TestDynUser", + "face": "https://example.com/face.jpg", + "pub_ts": 1700000100, + "pub_time": "2023-11-15 08:00" + }, + "module_dynamic": { + "major": { + "opus": { + "summary": { + "text": "这是一条图文动态测试" + }, + "pics": [ + {"url": "https://example.com/pic1.jpg"}, + {"url": "https://example.com/pic2.jpg"} + ] + } + }, + "desc": null + }, + "module_stat": { + "comment": {"count": 10}, + "like": {"count": 50}, + "forward": {"count": 3} + }, + "module_tag": { + "text": "置顶" + } + } + } + } + }, + { + "source_type": "dynamic", + "raw_data": { + "source": "dynamic", + "uid": 621240130, + "status": "new", + "dynamic": { + "id_str": "999000111222444", + "type": "DYNAMIC_TYPE_AV", + "visible": true, + "modules": { + "module_author": { + "mid": 621240130, + "name": "TestDynUser", + "face": "https://example.com/face.jpg", + "pub_ts": 1700000200, + "pub_time": "2023-11-15 09:00" + }, + "module_dynamic": { + "major": { + "archive": { + "aid": "100200300", + "bvid": "BV1test123", + "title": "测试视频标题", + "cover": "https://example.com/cover.jpg", + "desc": "视频简介", + "duration_text": "10:30", + "stat": { + "play": 12345, + "danmaku": 678 + } + } + }, + "desc": { + "text": "视频动态文本" + } + }, + "module_stat": { + "comment": {"count": 20}, + "like": {"count": 100}, + "forward": {"count": 5} + } + } + } + } + }, + { + "source_type": "dynamic", + "raw_data": { + "source": "dynamic", + "uid": 621240130, + "status": "deleted", + "dynamic": { + "id_str": "999000111222555", + "type": "DYNAMIC_TYPE_WORD", + "visible": true, + "modules": { + "module_author": { + "mid": 621240130, + "name": "TestDynUser", + "face": "https://example.com/face.jpg", + "pub_ts": 1700000050, + "pub_time": "2023-11-15 07:30" + }, + "module_dynamic": { + "major": { + "opus": { + "summary": { + "text": "这条动态被删除了" + } + } + }, + "desc": null + }, + "module_stat": { + "comment": {"count": 0}, + "like": {"count": 2}, + "forward": {"count": 0} + } + } + } + } + }, + { + "source_type": "dynamic", + "raw_data": { + "source": "dynamic", + "uid": 621240130, + "status": "new", + "dynamic": { + "id_str": "999000111222666", + "type": "DYNAMIC_TYPE_FORWARD", + "visible": true, + "modules": { + "module_author": { + "mid": 621240130, + "name": "TestDynUser", + "face": "https://example.com/face.jpg", + "pub_ts": 1700000300, + "pub_time": "2023-11-15 10:00" + }, + "module_dynamic": { + "major": null, + "desc": { + "text": "转发一下" + } + }, + "module_stat": { + "comment": {"count": 1}, + "like": {"count": 5}, + "forward": {"count": 0} + } + }, + "orig": { + "id_str": "888000111222333" + } + } + } } ] diff --git a/tests/unit/adapter/README.md b/tests/unit/adapter/README.md index c7f4a835..2e1a343b 100644 --- a/tests/unit/adapter/README.md +++ b/tests/unit/adapter/README.md @@ -60,3 +60,8 @@ python -m pytest tests/unit/adapter/ -v | 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` | +| BL-18 | 动态图文 (DYNAMIC_TYPE_DRAW) | BiliDynamicEventData 字段正确、tag/stat/pics 正确 | +| BL-19 | 动态视频 (DYNAMIC_TYPE_AV) | DynamicVideoInfo 字段正确 | +| BL-20 | 删除动态 | dynamic_event_type 为 DELETED_DYNAMIC | +| BL-21 | 转发动态 (DYNAMIC_TYPE_FORWARD) | text 和 forward_dynamic_id 正确 | +| BL-22 | DataPair 时间戳缓存 | 首次/后续 update 与深拷贝隔离 | diff --git a/tests/unit/adapter/test_bilibili_parser.py b/tests/unit/adapter/test_bilibili_parser.py index e657e30d..21630f73 100644 --- a/tests/unit/adapter/test_bilibili_parser.py +++ b/tests/unit/adapter/test_bilibili_parser.py @@ -21,6 +21,11 @@ BL-15: LIVE 事件 live_event_type 设置为 LIVE BL-16: PREPARING 事件 live_event_type 设置为 PREPARING BL-17: LIVE 事件携带 room_info 时附加 LiveRoomInfo + BL-18: 动态图文 (DYNAMIC_TYPE_DRAW) 解析 + BL-19: 动态视频 (DYNAMIC_TYPE_AV) 解析 + BL-20: 删除动态解析 — dynamic_event_type 为 DELETED_DYNAMIC + BL-21: 转发动态 (DYNAMIC_TYPE_FORWARD) 解析 + BL-22: DataPair 时间戳缓存与深拷贝 """ import json @@ -33,6 +38,7 @@ from ncatbot.types.bilibili.events import ( BiliCommentEventData, BiliConnectionEventData, + BiliDynamicEventData, BiliPrivateMessageEventData, BiliPrivateMessageWithdrawEventData, DanmuAggregationEventData, @@ -366,3 +372,146 @@ def test_bl17_preparing_no_room_info(self, parser, fixtures): result = parser.parse(fix["source_type"], fix["raw_data"]) assert isinstance(result, LiveStatusEventData) assert result.room_info is None + + +# ==================== 动态解析 ==================== + + +def _get_dynamic(fixtures, dynamic_type=None, status=None): + """按 dynamic_type + status 查找动态夹具""" + for fix in fixtures: + if fix["source_type"] != "dynamic": + continue + raw = fix["raw_data"] + dyn = raw.get("dynamic", {}) + if dynamic_type and dyn.get("type") != dynamic_type: + continue + if status and raw.get("status") != status: + continue + return fix + pytest.skip(f"夹具中不存在 dynamic_type={dynamic_type}, status={status}") + + +class TestDynamicDraw: + """BL-18: 动态图文 (DYNAMIC_TYPE_DRAW)""" + + def test_bl18_draw_dynamic(self, parser, fixtures): + """BL-18: 图文动态解析字段正确""" + fix = _get_dynamic(fixtures, "DYNAMIC_TYPE_DRAW") + result = parser.parse(fix["source_type"], fix["raw_data"]) + assert isinstance(result, BiliDynamicEventData) + assert result.dynamic_id == "999000111222333" + assert result.dynamic_type == "DYNAMIC_TYPE_DRAW" + assert result.uid == "621240130" + assert result.user_name == "TestDynUser" + assert result.text == "这是一条图文动态测试" + assert result.pics_url == [ + "https://example.com/pic1.jpg", + "https://example.com/pic2.jpg", + ] + assert result.pub_ts == 1700000100 + assert result.tag == "置顶" + assert result.stat is not None + assert result.stat.comment_count == 10 + assert result.stat.like_count == 50 + assert result.stat.forward_count == 3 + assert result.dynamic_status == "new" + + +class TestDynamicVideo: + """BL-19: 动态视频 (DYNAMIC_TYPE_AV)""" + + def test_bl19_video_dynamic(self, parser, fixtures): + """BL-19: 视频动态解析字段正确""" + fix = _get_dynamic(fixtures, "DYNAMIC_TYPE_AV") + result = parser.parse(fix["source_type"], fix["raw_data"]) + assert isinstance(result, BiliDynamicEventData) + assert result.dynamic_id == "999000111222444" + assert result.dynamic_type == "DYNAMIC_TYPE_AV" + assert result.video is not None + assert result.video.title == "测试视频标题" + assert result.video.bv_id == "BV1test123" + assert result.video.av_id == "100200300" + assert result.video.duration_text == "10:30" + assert result.video.dynamic_text == "视频动态文本" + assert result.video.play_count == "12345" + assert result.video.danmaku_count == "678" + + +class TestDynamicDeleted: + """BL-20: 删除动态""" + + def test_bl20_deleted_dynamic(self, parser, fixtures): + """BL-20: 删除动态的 dynamic_event_type 为 DELETED_DYNAMIC""" + from ncatbot.types.bilibili.enums import BiliDynamicEventType + + fix = _get_dynamic(fixtures, status="deleted") + result = parser.parse(fix["source_type"], fix["raw_data"]) + assert isinstance(result, BiliDynamicEventData) + assert result.dynamic_event_type == BiliDynamicEventType.DELETED_DYNAMIC + assert result.dynamic_status == "deleted" + assert result.text == "这条动态被删除了" + + +class TestDynamicForward: + """BL-21: 转发动态 (DYNAMIC_TYPE_FORWARD)""" + + def test_bl21_forward_dynamic(self, parser, fixtures): + """BL-21: 转发动态解析字段正确""" + fix = _get_dynamic(fixtures, "DYNAMIC_TYPE_FORWARD") + result = parser.parse(fix["source_type"], fix["raw_data"]) + assert isinstance(result, BiliDynamicEventData) + assert result.dynamic_type == "DYNAMIC_TYPE_FORWARD" + assert result.text == "转发一下" + assert result.forward_dynamic_id == "888000111222333" + + +class TestDynamicDataPair: + """BL-22: DataPair 时间戳缓存与深拷贝""" + + def test_bl22_initial_update(self): + """BL-22: 首次 update 初始化 old/new 双端""" + from ncatbot.adapter.bilibili.source.dynamic_source import _DynamicDataPair + + pair = _DynamicDataPair() + item = {"id_str": "111", "modules": {"module_author": {"pub_ts": 100}}} + pair.update(item, 100) + + assert pair.old_ts == 100 + assert pair.new_ts == 100 + assert pair.old_data["id_str"] == "111" + assert pair.new_data["id_str"] == "111" + # old_data 是深拷贝,修改 new_data 不影响 old_data + pair.new_data["id_str"] = "modified" + assert pair.old_data["id_str"] == "111" + + def test_bl22_subsequent_update(self): + """BL-22: 后续 update 将 new 移到 old,新数据放入 new""" + from ncatbot.adapter.bilibili.source.dynamic_source import _DynamicDataPair + + pair = _DynamicDataPair() + item1 = {"id_str": "aaa"} + pair.update(item1, 100) + + item2 = {"id_str": "bbb"} + pair.update(item2, 200) + + assert pair.old_ts == 100 + assert pair.new_ts == 200 + assert pair.old_data["id_str"] == "aaa" + assert pair.new_data["id_str"] == "bbb" + + def test_bl22_deep_copy_isolation(self): + """BL-22: old_data 深拷贝与 new_data 相互独立""" + from ncatbot.adapter.bilibili.source.dynamic_source import _DynamicDataPair + + pair = _DynamicDataPair() + item1 = {"id_str": "first", "nested": {"key": "val"}} + pair.update(item1, 100) + + item2 = {"id_str": "second", "nested": {"key": "val2"}} + pair.update(item2, 200) + + # old_data 应是 item1 的深拷贝,修改 old_data 不影响 new_data + pair.old_data["nested"]["key"] = "changed" + assert pair.new_data["nested"]["key"] == "val2" From 4af0380c07b78cd3cb94a25bd8d15cd9679ac445 Mon Sep 17 00:00:00 2001 From: GEYUANwuqi <17539198883@163.com> Date: Sat, 21 Mar 2026 15:45:48 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(bilibili):=20=E7=9B=B4=E6=92=AD?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E5=BC=82=E5=B8=B8=E9=80=80=E5=87=BA=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ncatbot/adapter/bilibili/source/live_source.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/ncatbot/adapter/bilibili/source/live_source.py b/ncatbot/adapter/bilibili/source/live_source.py index 9eac00f1..09173538 100644 --- a/ncatbot/adapter/bilibili/source/live_source.py +++ b/ncatbot/adapter/bilibili/source/live_source.py @@ -88,6 +88,14 @@ def _run_thread(self) -> None: self._loop = loop try: loop.run_until_complete(self._connect_danmaku()) + except RuntimeError as exc: + # stop() 在 connect() 自然返回前调用了 loop.stop(),属于正常关闭路径 + if "Event loop stopped before Future completed" in str(exc): + LOG.debug( + "直播源 %s 事件循环在连接完成前被停止(正常关闭)", self._room_id + ) + else: + LOG.exception("直播源 %s 线程异常退出", self._room_id) except Exception: LOG.exception("直播源 %s 线程异常退出", self._room_id) finally: @@ -145,8 +153,8 @@ async def stop(self) -> None: future.result(timeout=10) except Exception: LOG.debug("断开直播源 %s 时异常", self._room_id, exc_info=True) - # 通知子线程事件循环停止 - loop.call_soon_threadsafe(loop.stop) + # disconnect() 完成后 connect() 会自然返回,不需要手动 stop loop + # 直接等待线程退出即可(join 已有超时保护) if self._thread is not None and self._thread.is_alive(): self._thread.join(timeout=15)