butterbot-python 3.1.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. butterbot/__init__.py +15 -0
  2. butterbot/app/__init__.py +41 -0
  3. butterbot/app/bot_app.py +374 -0
  4. butterbot/app/config.py +156 -0
  5. butterbot/app/source_manager.py +367 -0
  6. butterbot/core/__init__.py +46 -0
  7. butterbot/core/api/__init__.py +9 -0
  8. butterbot/core/api/base_api.py +32 -0
  9. butterbot/core/context/README.md +1 -0
  10. butterbot/core/context/__init__.py +9 -0
  11. butterbot/core/context/api_registry.py +109 -0
  12. butterbot/core/context/app_context.py +51 -0
  13. butterbot/core/context/config_provider.py +14 -0
  14. butterbot/core/data/__init__.py +15 -0
  15. butterbot/core/data/base_data.py +29 -0
  16. butterbot/core/data/base_model.py +197 -0
  17. butterbot/core/event/README.md +1 -0
  18. butterbot/core/event/__init__.py +10 -0
  19. butterbot/core/event/event.py +27 -0
  20. butterbot/core/event/event_bus.py +276 -0
  21. butterbot/core/event/subscriber.py +130 -0
  22. butterbot/core/exceptions.py +90 -0
  23. butterbot/core/filter/__init__.py +11 -0
  24. butterbot/core/filter/base_filter.py +74 -0
  25. butterbot/core/source/README.md +1 -0
  26. butterbot/core/source/__init__.py +9 -0
  27. butterbot/core/source/base_source.py +134 -0
  28. butterbot/core/types/__init__.py +9 -0
  29. butterbot/core/types/base_type.py +74 -0
  30. butterbot/sources/__init__.py +1 -0
  31. butterbot/sources/bilibili/README.md +10 -0
  32. butterbot/sources/bilibili/__init__.py +20 -0
  33. butterbot/sources/bilibili/api/__init__.py +5 -0
  34. butterbot/sources/bilibili/api/bili_api.py +123 -0
  35. butterbot/sources/bilibili/data/__init__.py +52 -0
  36. butterbot/sources/bilibili/data/danmaku_gift_data.py +135 -0
  37. butterbot/sources/bilibili/data/danmaku_guard_data.py +54 -0
  38. butterbot/sources/bilibili/data/danmaku_msg_data.py +71 -0
  39. butterbot/sources/bilibili/data/dto/__init__.py +59 -0
  40. butterbot/sources/bilibili/data/dto/danmaku_gift_dto.py +193 -0
  41. butterbot/sources/bilibili/data/dto/danmaku_guard_buy_dto.py +54 -0
  42. butterbot/sources/bilibili/data/dto/danmaku_msg_dto.py +123 -0
  43. butterbot/sources/bilibili/data/dto/dynamic_dto.py +276 -0
  44. butterbot/sources/bilibili/data/dto/live_room_dto.py +169 -0
  45. butterbot/sources/bilibili/data/dto/video_part_dto.py +18 -0
  46. butterbot/sources/bilibili/data/dynamic_data.py +362 -0
  47. butterbot/sources/bilibili/data/live_room_data.py +162 -0
  48. butterbot/sources/bilibili/data/video_part.py +46 -0
  49. butterbot/sources/bilibili/source/__init__.py +15 -0
  50. butterbot/sources/bilibili/source/base_polling_source.py +130 -0
  51. butterbot/sources/bilibili/source/bili_danmaku_source.py +230 -0
  52. butterbot/sources/bilibili/source/bili_dynamic_source.py +135 -0
  53. butterbot/sources/bilibili/source/bili_live_source.py +137 -0
  54. butterbot/sources/bilibili/types/__init__.py +11 -0
  55. butterbot/sources/bilibili/types/bili_type.py +32 -0
  56. butterbot/sources/napcat/README.md +10 -0
  57. butterbot/sources/napcat/__init__.py +20 -0
  58. butterbot/sources/napcat/api/__init__.py +3 -0
  59. butterbot/sources/napcat/api/napcat_api.py +316 -0
  60. butterbot/sources/napcat/data/__init__.py +179 -0
  61. butterbot/sources/napcat/data/event_data.py +441 -0
  62. butterbot/sources/napcat/data/segment_data.py +432 -0
  63. butterbot/sources/napcat/events.py +91 -0
  64. butterbot/sources/napcat/filters/__init__.py +19 -0
  65. butterbot/sources/napcat/filters/filters.py +202 -0
  66. butterbot/sources/napcat/source/__init__.py +5 -0
  67. butterbot/sources/napcat/source/napcat_source.py +58 -0
  68. butterbot/sources/napcat/types/__init__.py +5 -0
  69. butterbot/sources/napcat/types/napcat_type.py +61 -0
  70. butterbot/utils/README.md +15 -0
  71. butterbot/utils/__init__.py +11 -0
  72. butterbot/utils/data_pair.py +27 -0
  73. butterbot/utils/logging_config.py +521 -0
  74. butterbot/utils/terminal.py +308 -0
  75. butterbot/utils/websocket.py +1270 -0
  76. butterbot_python-3.1.0.dev1.dist-info/METADATA +769 -0
  77. butterbot_python-3.1.0.dev1.dist-info/RECORD +79 -0
  78. butterbot_python-3.1.0.dev1.dist-info/WHEEL +4 -0
  79. butterbot_python-3.1.0.dev1.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,130 @@
1
+ """Bilibili 轮询事件源的共享生命周期与调度实现."""
2
+
3
+ import asyncio
4
+ from abc import ABC, abstractmethod
5
+ from logging import Logger, getLogger
6
+ from uuid import UUID
7
+
8
+ from butterbot.core.source import BaseSource
9
+
10
+
11
+ class BasePollingSource(BaseSource, ABC):
12
+ """按轮次轮询整数目标列表的内部基类.
13
+
14
+ 领域数据获取与事件构造仍由子类负责;本类只持有生命周期任务、目标集合、
15
+ 间隔更新,以及“每轮只等待一次”的调度契约。
16
+ """
17
+
18
+ _log: Logger = getLogger(__name__)
19
+ _source_name = "轮询监控"
20
+ _target_name = "目标"
21
+ _empty_wait = 5.0
22
+
23
+ def __init__(
24
+ self,
25
+ poll_interval: float | int,
26
+ watch_targets: list[int] | None = None,
27
+ *,
28
+ uuid: UUID | None = None,
29
+ config_key: str | None = None,
30
+ ) -> None:
31
+ super().__init__(uuid=uuid, config_key=config_key)
32
+ self.poll_interval: float | int = poll_interval
33
+ self._watch_targets: list[int] = []
34
+ self._poll_num = 0
35
+ self._task: asyncio.Task[None] | None = None
36
+ if watch_targets is not None:
37
+ self.add_members(watch_targets)
38
+
39
+ async def on_start(self) -> None:
40
+ """启动本事件源持有的监控任务."""
41
+ self._task = asyncio.create_task(self._monitor_loop())
42
+ self._log.info("%s已启动", self._source_name)
43
+
44
+ async def on_stop(self) -> None:
45
+ """取消并等待本事件源持有的监控任务."""
46
+ task = self._task
47
+ self._task = None
48
+ if task is not None and not task.done():
49
+ task.cancel()
50
+ try:
51
+ await task
52
+ except asyncio.CancelledError:
53
+ pass
54
+ self._log.info("%s已停止", self._source_name)
55
+
56
+ def add_members(self, keys: list[int]) -> None:
57
+ """按插入顺序添加不重复的轮询目标."""
58
+ for key in keys:
59
+ if key not in self._watch_targets:
60
+ self._watch_targets.append(key)
61
+ self._log.debug("添加%s '%s' 到监控列表", self._target_name, key)
62
+ else:
63
+ self._log.warning("%s '%s' 已存在于监控列表中", self._target_name, key)
64
+
65
+ def remove_members(self, keys: list[int]) -> None:
66
+ """移除轮询目标."""
67
+ for key in keys:
68
+ if key in self._watch_targets:
69
+ self._watch_targets.remove(key)
70
+ self._log.debug("从监控列表移除%s '%s'", self._target_name, key)
71
+ else:
72
+ self._log.warning("%s '%s' 不存在于监控列表中", self._target_name, key)
73
+
74
+ def set_poll_interval(self, interval: float | int) -> None:
75
+ """更新每轮轮询间隔."""
76
+ if interval <= 0:
77
+ self._log.error("非法参数,轮询间隔时间不可小于或等于0")
78
+ return
79
+ if interval <= 30:
80
+ self._log.warning("将轮询间隔时间设置为30s及以下,可能导致请求频率过高")
81
+ self.poll_interval = interval
82
+ self._log.info("轮询间隔时间已设置为 %s 秒", self.poll_interval)
83
+
84
+ @property
85
+ def watch_targets(self) -> list[int]:
86
+ """返回当前监控目标的快照."""
87
+ return list(self._watch_targets)
88
+
89
+ @property
90
+ def poll_num(self) -> int:
91
+ """已完成的轮询轮数."""
92
+ return self._poll_num
93
+
94
+ @abstractmethod
95
+ async def _poll_target(self, target: int) -> None:
96
+ """轮询单个目标并发布产生的事件."""
97
+
98
+ async def _monitor_loop(self) -> None:
99
+ """轮询当前全部目标,然后只等待一次再进入下一轮."""
100
+ self._log.info("%s循环已启动", self._source_name)
101
+ try:
102
+ while self.running:
103
+ targets = list(self._watch_targets)
104
+ if not targets:
105
+ await asyncio.sleep(self._empty_wait)
106
+ continue
107
+
108
+ for target in targets:
109
+ if not self.running:
110
+ break
111
+ try:
112
+ await self._poll_target(target)
113
+ except asyncio.CancelledError:
114
+ raise
115
+ except Exception:
116
+ self._log.exception(
117
+ "轮询%s '%s' 时出错", self._target_name, target
118
+ )
119
+
120
+ self._poll_num += 1
121
+ self._log.debug("完成第 %s 轮%s", self._poll_num, self._source_name)
122
+ if not self.running:
123
+ break
124
+ await asyncio.sleep(self.poll_interval)
125
+ except asyncio.CancelledError:
126
+ self._log.debug("%s循环被取消", self._source_name)
127
+ except Exception:
128
+ self._log.exception("%s循环异常", self._source_name)
129
+ finally:
130
+ self._log.info("%s循环已停止", self._source_name)
@@ -0,0 +1,230 @@
1
+ import asyncio
2
+ import threading
3
+ from logging import DEBUG, INFO, getLogger
4
+ from typing import TYPE_CHECKING, TypeVar
5
+ from uuid import UUID
6
+
7
+ from butterbot.core.data import BaseDataMixin
8
+ from butterbot.core.event import Event
9
+ from butterbot.core.source import BaseSource
10
+
11
+ from ..api import BilibiliApi
12
+ from ..data import DanmakuGiftData, DanmakuGuardData, DanmakuMsgData
13
+ from ..data.dto import DanmakuGiftDTO, DanmakuGuardDTO, DanmakuMsgDTO
14
+ from ..types import DanmakuType
15
+
16
+ if TYPE_CHECKING:
17
+ from bilibili_api.live import LiveDanmaku
18
+
19
+ _log = getLogger("BiliDanmakuSource")
20
+ _EventDataT = TypeVar("_EventDataT", bound=BaseDataMixin)
21
+
22
+
23
+ class BiliDanmakuSource(BaseSource):
24
+ """B站直播弹幕事件源.
25
+
26
+ 负责轮询B站直播间弹幕并发布事件。
27
+ """
28
+
29
+ supported_types = DanmakuType
30
+ config_key = "bilibili"
31
+
32
+ def __init__(
33
+ self,
34
+ room_id: list[int] | None = None,
35
+ debug: bool = False,
36
+ *,
37
+ watch_targets: list[int] | None = None,
38
+ uuid: UUID | None = None,
39
+ config_key: str | None = None,
40
+ ) -> None:
41
+ """初始化B站弹幕源
42
+
43
+ Args:
44
+ room_id: 房间号列表(兼容旧参数)
45
+ debug: 是否开启调试
46
+ watch_targets: 房间号列表,与另外两个 Bilibili source 的命名一致
47
+ uuid: 可选事件源 UUID
48
+ config_key: 可选配置键
49
+ """
50
+ if room_id is not None and watch_targets is not None:
51
+ raise TypeError("room_id 与 watch_targets 不能同时提供")
52
+ targets = room_id if room_id is not None else watch_targets
53
+ if targets is None:
54
+ raise TypeError("必须提供 room_id 或 watch_targets")
55
+
56
+ super().__init__(uuid=uuid, config_key=config_key)
57
+ self.room_id: list[int] = list(targets)
58
+ self.danmaku_list: dict[int, LiveDanmaku] = {}
59
+ self.debug = debug
60
+ self._loops: dict[int, asyncio.AbstractEventLoop] = {}
61
+ self._threads: dict[int, threading.Thread] = {}
62
+ self._main_loop: asyncio.AbstractEventLoop | None = None # 主事件循环引用
63
+
64
+ def _run_room_thread(
65
+ self, room_id: int, danmaku: "LiveDanmaku", ready: threading.Event
66
+ ) -> None:
67
+ """线程入口:为该房间创建独立事件循环,以 task 方式运行弹幕连接,loop.run_forever() 驱动。"""
68
+ loop = asyncio.new_event_loop()
69
+ asyncio.set_event_loop(loop)
70
+
71
+ self._loops[room_id] = loop
72
+
73
+ async def _connect():
74
+ @danmaku.on("VERIFICATION_SUCCESSFUL")
75
+ async def _on_connected(_):
76
+ ready.set()
77
+ if danmaku.get_status() == 2:
78
+ _log.info("房间 %s 的弹幕姬已连接", room_id)
79
+
80
+ try:
81
+ asyncio.get_event_loop().create_task(danmaku.connect())
82
+ _log.debug("等待房间 %d 的弹幕姬连接成功...", room_id)
83
+ except Exception as e:
84
+ _log.error("房间 %s 弹幕姬异常退出: %s", room_id, e)
85
+ finally:
86
+ ready.set() # 防止异常时 ready 永远不被 set
87
+
88
+ loop.create_task(_connect())
89
+ loop.run_forever()
90
+
91
+ def _start_room_thread(self, room_id: int, danmaku: "LiveDanmaku") -> None:
92
+ """为已有的 danmaku 对象启动独立线程并等待连接建立."""
93
+ if room_id in self._threads and self._threads[room_id].is_alive():
94
+ _log.warning("房间 %s 的弹幕姬已在运行中", room_id)
95
+ return
96
+ name = "LiveDanmaku(%s)" % room_id
97
+ danmaku.logger = getLogger(name)
98
+ danmaku.logger.setLevel(DEBUG if self.debug else INFO)
99
+ ready = threading.Event()
100
+ t = threading.Thread(
101
+ target=self._run_room_thread,
102
+ args=(room_id, danmaku, ready),
103
+ name=name,
104
+ daemon=True,
105
+ )
106
+ self._threads[room_id] = t
107
+ _log.debug("正在为房间 %s 启动弹幕姬线程...", room_id)
108
+ t.start()
109
+
110
+ def start_room(self, room_id: int) -> None:
111
+ """启动已有的房间监控(stop_room 后可重新启动)."""
112
+ danmaku = self.danmaku_list.get(room_id)
113
+ if danmaku is None:
114
+ _log.warning("房间 %s 不存在,请先调用 add_new_room", room_id)
115
+ return
116
+ self._start_room_thread(room_id, danmaku)
117
+
118
+ def add_new_room(self, room_id: int) -> None:
119
+ """新建房间监控并在独立线程中启动."""
120
+ if room_id in self.danmaku_list:
121
+ _log.warning("房间 %s 已存在,如需重启请调用 start_room", room_id)
122
+ return
123
+ danmaku = self.api.get_live_danmaku(room_id)
124
+ danmaku.add_event_listener("LIVE", self.on_live)
125
+ danmaku.add_event_listener("DANMU_MSG", self.on_danmaku)
126
+ danmaku.add_event_listener("SEND_GIFT", self.on_gift)
127
+ danmaku.add_event_listener("GUARD_BUY", self.on_guard)
128
+ self.danmaku_list[room_id] = danmaku
129
+ _log.debug("新建了房间 %s 的弹幕姬对象,正在启动线程...", room_id)
130
+ self._start_room_thread(room_id, danmaku)
131
+
132
+ def stop_room(self, room_id: int) -> None:
133
+ """断开房间连接并等待线程真正退出(保留 danmaku 对象,可通过 start_room 重启)."""
134
+ danmaku = self.danmaku_list.get(room_id)
135
+ loop = self._loops.get(room_id)
136
+ if danmaku is None or loop is None:
137
+ _log.warning("未找到房间 %s 的弹幕姬或其事件循环", room_id)
138
+ return
139
+ # 先在房间线程的事件循环中调用 disconnect,等待其完成
140
+ future = asyncio.run_coroutine_threadsafe(danmaku.disconnect(), loop)
141
+ try:
142
+ future.result(timeout=10)
143
+ except Exception as e:
144
+ _log.error("停止房间 %s 时出错: %s", room_id, e)
145
+ # disconnect 完成后,通知 loop.run_forever() 退出,线程自然结束
146
+ loop.call_soon_threadsafe(loop.stop)
147
+ # 等待线程真正退出,确保连接已完全断开
148
+ t = self._threads.get(room_id)
149
+ if t and t.is_alive():
150
+ t.join(timeout=15)
151
+ if t.is_alive():
152
+ _log.warning(
153
+ "房间 %s 的线程未能在超时内退出,连接可能未完全断开", room_id
154
+ )
155
+ else:
156
+ _log.info("房间 %s 的弹幕姬已暂停", room_id)
157
+ self._threads.pop(room_id, None)
158
+
159
+ def remove_room(self, room_id: int) -> None:
160
+ """停止并彻底移除房间监控(同时清理 danmaku 对象)."""
161
+ self.stop_room(room_id) # 断开连接并等待线程退出
162
+ self.danmaku_list.pop(room_id, None)
163
+ _log.info("房间 %s 的弹幕姬已移除", room_id)
164
+
165
+ async def on_start(self) -> None:
166
+ """启动弹幕监控."""
167
+ if not self.ctx.config.get_config(self.config_key):
168
+ raise RuntimeError("未找到Credential配置")
169
+ self._main_loop = asyncio.get_running_loop() # 保存主事件循环
170
+ for rid in self.room_id:
171
+ self.add_new_room(rid)
172
+ _log.info("B站弹幕姬已启动")
173
+
174
+ async def on_stop(self) -> None:
175
+ """停止所有房间监控."""
176
+ for rid in list(self._threads.keys()):
177
+ self.remove_room(rid)
178
+
179
+ def _publish_to_main(self, event: Event[_EventDataT]) -> None:
180
+ """将事件发布调度到主事件循环(线程安全)."""
181
+ if self._main_loop is None:
182
+ _log.error("主事件循环未初始化,无法发布事件")
183
+ raise RuntimeError("主事件循环未初始化")
184
+ asyncio.run_coroutine_threadsafe(
185
+ self.ctx.bus.publish(self.uuid, event), self._main_loop
186
+ )
187
+
188
+ @property
189
+ def api(self) -> BilibiliApi:
190
+ """获取 Bilibili API 实例."""
191
+ return self.ctx.api_ctx.get(BilibiliApi, self.config_key)
192
+
193
+ async def on_live(self, msg: dict) -> None:
194
+ # 开播事件
195
+ if not msg.get("data", {}).get("live_time", 0):
196
+ # data.live_time不存在时不认为是开播事件
197
+ _log.debug("跳过不存在live_time字段的LIVE事件")
198
+ return
199
+ raw_room_id = msg.get("room_display_id")
200
+ if raw_room_id is None:
201
+ _log.warning("消息中缺少 room_display_id,跳过开播事件")
202
+ return
203
+ room_id = int(raw_room_id)
204
+ info = await self.api.get_room_info(room_id)
205
+ event = Event(data=info, status=DanmakuType.OPEN)
206
+ self._publish_to_main(event)
207
+
208
+ async def on_danmaku(self, msg: dict) -> None:
209
+ # 弹幕事件
210
+ dto_data = DanmakuMsgDTO.from_raw(msg)
211
+ if dto_data is not None:
212
+ danmaku_data = DanmakuMsgData.from_dto(dto_data)
213
+ event = Event(data=danmaku_data, status=DanmakuType.DANMAKU)
214
+ self._publish_to_main(event)
215
+
216
+ async def on_gift(self, msg: dict) -> None:
217
+ # 礼物事件
218
+ dto_data = DanmakuGiftDTO.from_raw(msg)
219
+ if dto_data is not None:
220
+ danmaku_data = DanmakuGiftData.from_dto(dto_data)
221
+ event = Event(data=danmaku_data, status=DanmakuType.GIFT)
222
+ self._publish_to_main(event)
223
+
224
+ async def on_guard(self, msg: dict) -> None:
225
+ # 上舰事件
226
+ dto_data = DanmakuGuardDTO.from_raw(msg)
227
+ if dto_data is not None:
228
+ danmaku_data = DanmakuGuardData.from_dto(dto_data)
229
+ event = Event(data=danmaku_data, status=DanmakuType.GUARD)
230
+ self._publish_to_main(event)
@@ -0,0 +1,135 @@
1
+ import traceback
2
+ from logging import getLogger
3
+ from uuid import UUID
4
+
5
+ from butterbot.core.event import Event
6
+ from butterbot.utils import DataPair
7
+
8
+ from ..api import BilibiliApi
9
+ from ..data import DynamicData
10
+ from ..types import DynamicType
11
+ from .base_polling_source import BasePollingSource
12
+
13
+ _log = getLogger("BiliDynamicSource")
14
+
15
+
16
+ class BiliDynamicSource(BasePollingSource):
17
+ """B站动态事件源.
18
+
19
+ 负责轮询B站动态并发布事件。
20
+ """
21
+
22
+ supported_types = DynamicType
23
+ config_key = "bilibili"
24
+ _log = _log
25
+ _source_name = "B站动态监控"
26
+ _target_name = "UID"
27
+
28
+ def __init__(
29
+ self,
30
+ poll_interval: float | int = 60,
31
+ watch_targets: list[int] | None = None,
32
+ *,
33
+ uuid: UUID | None = None,
34
+ config_key: str | None = None,
35
+ ) -> None:
36
+ """初始化动态事件源.
37
+ Args:
38
+ poll_interval: 轮询间隔时间(秒)
39
+ watch_targets: 监听用户列表
40
+ """
41
+ super().__init__(
42
+ poll_interval,
43
+ watch_targets,
44
+ uuid=uuid,
45
+ config_key=config_key,
46
+ )
47
+ self._dynamic_data: dict[int, DataPair[DynamicData]] = {}
48
+
49
+ @property
50
+ def api(self) -> BilibiliApi:
51
+ """获取 Bilibili API 实例."""
52
+ return self.ctx.api_ctx.get(BilibiliApi, self.config_key)
53
+
54
+ @property
55
+ def members(self) -> list[int]:
56
+ """获取监控成员列表."""
57
+ return self.watch_targets
58
+
59
+ async def _poll_data(self, uid: int) -> DynamicData | None:
60
+ """获取并更新动态数据.
61
+
62
+ Args:
63
+ uid: 用户UID
64
+
65
+ Returns:
66
+ 新获取的动态数据
67
+ """
68
+ try:
69
+ new_data = await self.api.get_new_dynamic(uid)
70
+
71
+ if uid not in self._dynamic_data:
72
+ self._dynamic_data[uid] = DataPair()
73
+ _log.info("初始化 '%s' 的动态数据", uid)
74
+
75
+ self._dynamic_data[uid].update(new_data)
76
+ return new_data
77
+
78
+ except Exception as e:
79
+ _log.error("获取 '%s' 动态数据时出错: %s", uid, e)
80
+ raise
81
+
82
+ def _get_dynamic_status(self, uid: int) -> DynamicType:
83
+ """判断动态状态.
84
+
85
+ Args:
86
+ uid: 用户UID
87
+
88
+ Returns:
89
+ 当前的动态状态
90
+ """
91
+ old_data = self._dynamic_data[uid].old
92
+ new_data = self._dynamic_data[uid].new
93
+ if old_data is None or new_data is None:
94
+ return DynamicType.NULL
95
+
96
+ old_timestamp = old_data.pub_ts
97
+ new_timestamp = new_data.pub_ts
98
+
99
+ if new_timestamp > old_timestamp:
100
+ return DynamicType.NEW
101
+ elif new_timestamp < old_timestamp:
102
+ return DynamicType.DELETED
103
+ else:
104
+ return DynamicType.NULL
105
+
106
+ async def _poll_dynamic(self, uid: int) -> None:
107
+ """轮询单个用户的动态.
108
+
109
+ Args:
110
+ uid: 用户UID
111
+ """
112
+ try:
113
+ new_data = await self._poll_data(uid=uid)
114
+
115
+ if new_data is None:
116
+ _log.warning("获取 %s 动态数据失败", uid)
117
+ return
118
+
119
+ status = self._get_dynamic_status(uid)
120
+
121
+ # 根据状态决定传递哪个数据
122
+ if status == DynamicType.DELETED:
123
+ data = self._dynamic_data[uid].get_data("old")
124
+ else:
125
+ data = new_data
126
+
127
+ event: Event[DynamicData] = Event(data=data, status=status)
128
+ await self.ctx.bus.publish(self.uuid, event)
129
+
130
+ except Exception as e:
131
+ _log.error("轮询 UID '%s' 动态数据时出错: %s", uid, e)
132
+ _log.error(traceback.format_exc())
133
+
134
+ async def _poll_target(self, target: int) -> None:
135
+ await self._poll_dynamic(target)
@@ -0,0 +1,137 @@
1
+ import traceback
2
+ from logging import getLogger
3
+ from uuid import UUID
4
+
5
+ from butterbot.core.event import Event
6
+ from butterbot.utils import DataPair
7
+
8
+ from ..api import BilibiliApi
9
+ from ..data import LiveRoomData
10
+ from ..types import LiveType
11
+ from .base_polling_source import BasePollingSource
12
+
13
+ _log = getLogger("BiliLiveSource")
14
+
15
+
16
+ class BiliLiveSource(BasePollingSource):
17
+ """B站直播事件源.
18
+
19
+ 负责轮询B站直播状态并发布事件。
20
+ """
21
+
22
+ supported_types = LiveType
23
+ config_key = "bilibili"
24
+ _log = _log
25
+ _source_name = "B站直播监控"
26
+ _target_name = "房间"
27
+
28
+ def __init__(
29
+ self,
30
+ poll_interval: float | int = 20,
31
+ watch_targets: list[int] | None = None,
32
+ *,
33
+ uuid: UUID | None = None,
34
+ config_key: str | None = None,
35
+ ) -> None:
36
+ """初始化直播事件源.
37
+ Args:
38
+ poll_interval: 轮询间隔时间(秒)
39
+ watch_targets: 监听用户列表
40
+ """
41
+ super().__init__(
42
+ poll_interval,
43
+ watch_targets,
44
+ uuid=uuid,
45
+ config_key=config_key,
46
+ )
47
+ self._live_data: dict[int, DataPair[LiveRoomData]] = {}
48
+
49
+ @property
50
+ def api(self) -> BilibiliApi:
51
+ """获取 Bilibili API 实例."""
52
+ return self.ctx.api_ctx.get(BilibiliApi, self.config_key)
53
+
54
+ @property
55
+ def rooms(self) -> list[int]:
56
+ """获取监控房间列表."""
57
+ return self.watch_targets
58
+
59
+ async def _poll_data(self, room_id: int) -> LiveRoomData | None:
60
+ """获取并更新直播数据.
61
+
62
+ Args:
63
+ room_id: 直播间ID
64
+
65
+ Returns:
66
+ 新获取的直播数据
67
+ """
68
+ try:
69
+ new_data = await self.api.get_room_info(room_id)
70
+
71
+ if room_id not in self._live_data:
72
+ self._live_data[room_id] = DataPair()
73
+ _log.info("初始化房间 '%s' 的直播数据", room_id)
74
+
75
+ self._live_data[room_id].update(new_data)
76
+ return new_data
77
+
78
+ except Exception as e:
79
+ _log.error("获取房间 '%s' 直播数据时出错: %s", room_id, e)
80
+ raise
81
+
82
+ async def _poll_live(self, room_id: int) -> None:
83
+ """轮询单个房间的直播状态.
84
+
85
+ Args:
86
+ room_id: 直播间ID
87
+ """
88
+ try:
89
+ new_data = await self._poll_data(room_id=room_id)
90
+
91
+ if new_data is None:
92
+ _log.warning("获取房间 %s 直播数据失败", room_id)
93
+ return
94
+
95
+ status = self._get_live_status(room_id)
96
+
97
+ event: Event[LiveRoomData] = Event(data=new_data, status=status)
98
+ await self.ctx.bus.publish(self.uuid, event)
99
+
100
+ except Exception as e:
101
+ _log.error("轮询房间 '%s' 直播数据时出错: %s", room_id, e)
102
+ _log.error(traceback.format_exc())
103
+
104
+ def _get_live_status(
105
+ self,
106
+ room_id: int,
107
+ ) -> LiveType:
108
+ """判断直播状态.
109
+ Args:
110
+ room_id: 直播间ID
111
+
112
+ Returns:
113
+ LiveType: 当前的直播状态
114
+ """
115
+ data_pair = self._live_data[room_id]
116
+ old_data = data_pair.old
117
+ new_data = data_pair.new
118
+ if old_data is None or new_data is None:
119
+ return LiveType.NULL
120
+
121
+ old_status = old_data.room_info.live_status
122
+ new_status = new_data.room_info.live_status
123
+ # 刚开播:旧状态不是直播中(0或2),新状态是直播中(1)
124
+ if old_status != 1 and new_status == 1:
125
+ return LiveType.OPEN
126
+ # 刚下播:旧状态是直播中(1),新状态不是直播中(0或2)
127
+ elif old_status == 1 and new_status != 1:
128
+ return LiveType.CLOSE
129
+ # 直播中:新旧状态都是直播中(1)
130
+ elif old_status == 1 and new_status == 1:
131
+ return LiveType.ONLINE
132
+ # 未开播:其他情况(包括一直未开播、轮播等状态)
133
+ else:
134
+ return LiveType.OFFLINE
135
+
136
+ async def _poll_target(self, target: int) -> None:
137
+ await self._poll_live(target)
@@ -0,0 +1,11 @@
1
+ from .bili_type import (
2
+ DanmakuType,
3
+ DynamicType,
4
+ LiveType,
5
+ )
6
+
7
+ __all__ = [
8
+ "DanmakuType",
9
+ "DynamicType",
10
+ "LiveType",
11
+ ]
@@ -0,0 +1,32 @@
1
+ from butterbot.core.types import BaseType
2
+
3
+
4
+ class DynamicType(BaseType):
5
+ """动态状态枚举."""
6
+
7
+ ALL = "dynamic.all" # 表示所有状态,用作通配符
8
+ NEW = "dynamic.new" # 新动态
9
+ DELETED = "dynamic.deleted" # 删除的动态
10
+ NULL = "dynamic.null" # 无变化
11
+
12
+
13
+ class LiveType(BaseType):
14
+ """直播状态枚举."""
15
+
16
+ ALL = "live.all" # 表示所有状态,用作通配符
17
+ ONLINE = "live.online" # 在线
18
+ OFFLINE = "live.offline" # 离线
19
+ OPEN = "live.open" # 开播
20
+ CLOSE = "live.close" # 下播
21
+ NULL = "live.null" # 无变化
22
+
23
+
24
+ class DanmakuType(BaseType):
25
+ """弹幕状态枚举."""
26
+
27
+ ALL = "danmaku.all" # 表示所有状态,用作通配符
28
+ DANMAKU = "danmaku.msg" # 新弹幕
29
+ GIFT = "danmaku.gift" # 礼物
30
+ GUARD = "danmaku.guard" # 舰长
31
+ OPEN = "danmaku.live_open" # 开播
32
+ OFFLINE = "danmaku.offline" # 下播
@@ -0,0 +1,10 @@
1
+ # NapCat 适配文档
2
+
3
+ NapCat 的使用与 API 文档已迁移到统一文档站:
4
+
5
+ - [NapCat 功能指南](../../../docs/features/napcat.md)
6
+ - [内置事件源 API](../../../docs/api/builtin-sources.md)
7
+ - [NapCat 示例](../../../docs/examples/napcat.md)
8
+
9
+ 本文件保留用于兼容仓库旧链接。新文档以当前 `NapcatSource`、`NapcatApi`、
10
+ `NapcatType`、测试和示例为准。