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.
- butterbot/__init__.py +15 -0
- butterbot/app/__init__.py +41 -0
- butterbot/app/bot_app.py +374 -0
- butterbot/app/config.py +156 -0
- butterbot/app/source_manager.py +367 -0
- butterbot/core/__init__.py +46 -0
- butterbot/core/api/__init__.py +9 -0
- butterbot/core/api/base_api.py +32 -0
- butterbot/core/context/README.md +1 -0
- butterbot/core/context/__init__.py +9 -0
- butterbot/core/context/api_registry.py +109 -0
- butterbot/core/context/app_context.py +51 -0
- butterbot/core/context/config_provider.py +14 -0
- butterbot/core/data/__init__.py +15 -0
- butterbot/core/data/base_data.py +29 -0
- butterbot/core/data/base_model.py +197 -0
- butterbot/core/event/README.md +1 -0
- butterbot/core/event/__init__.py +10 -0
- butterbot/core/event/event.py +27 -0
- butterbot/core/event/event_bus.py +276 -0
- butterbot/core/event/subscriber.py +130 -0
- butterbot/core/exceptions.py +90 -0
- butterbot/core/filter/__init__.py +11 -0
- butterbot/core/filter/base_filter.py +74 -0
- butterbot/core/source/README.md +1 -0
- butterbot/core/source/__init__.py +9 -0
- butterbot/core/source/base_source.py +134 -0
- butterbot/core/types/__init__.py +9 -0
- butterbot/core/types/base_type.py +74 -0
- butterbot/sources/__init__.py +1 -0
- butterbot/sources/bilibili/README.md +10 -0
- butterbot/sources/bilibili/__init__.py +20 -0
- butterbot/sources/bilibili/api/__init__.py +5 -0
- butterbot/sources/bilibili/api/bili_api.py +123 -0
- butterbot/sources/bilibili/data/__init__.py +52 -0
- butterbot/sources/bilibili/data/danmaku_gift_data.py +135 -0
- butterbot/sources/bilibili/data/danmaku_guard_data.py +54 -0
- butterbot/sources/bilibili/data/danmaku_msg_data.py +71 -0
- butterbot/sources/bilibili/data/dto/__init__.py +59 -0
- butterbot/sources/bilibili/data/dto/danmaku_gift_dto.py +193 -0
- butterbot/sources/bilibili/data/dto/danmaku_guard_buy_dto.py +54 -0
- butterbot/sources/bilibili/data/dto/danmaku_msg_dto.py +123 -0
- butterbot/sources/bilibili/data/dto/dynamic_dto.py +276 -0
- butterbot/sources/bilibili/data/dto/live_room_dto.py +169 -0
- butterbot/sources/bilibili/data/dto/video_part_dto.py +18 -0
- butterbot/sources/bilibili/data/dynamic_data.py +362 -0
- butterbot/sources/bilibili/data/live_room_data.py +162 -0
- butterbot/sources/bilibili/data/video_part.py +46 -0
- butterbot/sources/bilibili/source/__init__.py +15 -0
- butterbot/sources/bilibili/source/base_polling_source.py +130 -0
- butterbot/sources/bilibili/source/bili_danmaku_source.py +230 -0
- butterbot/sources/bilibili/source/bili_dynamic_source.py +135 -0
- butterbot/sources/bilibili/source/bili_live_source.py +137 -0
- butterbot/sources/bilibili/types/__init__.py +11 -0
- butterbot/sources/bilibili/types/bili_type.py +32 -0
- butterbot/sources/napcat/README.md +10 -0
- butterbot/sources/napcat/__init__.py +20 -0
- butterbot/sources/napcat/api/__init__.py +3 -0
- butterbot/sources/napcat/api/napcat_api.py +316 -0
- butterbot/sources/napcat/data/__init__.py +179 -0
- butterbot/sources/napcat/data/event_data.py +441 -0
- butterbot/sources/napcat/data/segment_data.py +432 -0
- butterbot/sources/napcat/events.py +91 -0
- butterbot/sources/napcat/filters/__init__.py +19 -0
- butterbot/sources/napcat/filters/filters.py +202 -0
- butterbot/sources/napcat/source/__init__.py +5 -0
- butterbot/sources/napcat/source/napcat_source.py +58 -0
- butterbot/sources/napcat/types/__init__.py +5 -0
- butterbot/sources/napcat/types/napcat_type.py +61 -0
- butterbot/utils/README.md +15 -0
- butterbot/utils/__init__.py +11 -0
- butterbot/utils/data_pair.py +27 -0
- butterbot/utils/logging_config.py +521 -0
- butterbot/utils/terminal.py +308 -0
- butterbot/utils/websocket.py +1270 -0
- butterbot_python-3.1.0.dev1.dist-info/METADATA +769 -0
- butterbot_python-3.1.0.dev1.dist-info/RECORD +79 -0
- butterbot_python-3.1.0.dev1.dist-info/WHEEL +4 -0
- 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,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`、测试和示例为准。
|