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,367 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from logging import getLogger
|
|
3
|
+
from typing import TYPE_CHECKING, Callable, ParamSpec, overload
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from butterbot.core.exceptions import LifecycleError, SourceError, SourceStartError
|
|
7
|
+
from butterbot.core.source import BaseSource, BaseSourceT
|
|
8
|
+
|
|
9
|
+
_SourceP = ParamSpec("_SourceP")
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from butterbot.core.context import AppContext
|
|
13
|
+
|
|
14
|
+
_log = getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SourceManager:
|
|
18
|
+
"""SourceManager 类,专注于事件源的生命周期管理.
|
|
19
|
+
|
|
20
|
+
负责添加/移除事件源,并统一管理其启动、停止流程和异步任务。
|
|
21
|
+
高层概念(EventBus、ApiRegistry、AppContext)由 BotApp 持有并通过
|
|
22
|
+
AppContext 注入——因此本类只负责事件源自身及其在 EventBus 上的订阅,
|
|
23
|
+
不负责关闭 EventBus / ApiRegistry(那是 :meth:`BotApp.close` 的职责)。
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
sources: 事件源集合(UUID -> Source)
|
|
27
|
+
running: 是否正在运行
|
|
28
|
+
closed: 是否已关闭
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, ctx: "AppContext"):
|
|
32
|
+
"""初始化 SourceManager.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
ctx: 由 BotApp 构建并传入的应用上下文
|
|
36
|
+
"""
|
|
37
|
+
# 注入应用上下文(只读引用,不持有所有权)
|
|
38
|
+
self._ctx = ctx
|
|
39
|
+
|
|
40
|
+
# 事件源集合:UUID -> Source
|
|
41
|
+
self._sources: dict[UUID, BaseSource] = {}
|
|
42
|
+
|
|
43
|
+
# 生命周期状态
|
|
44
|
+
self._running = False
|
|
45
|
+
self._closed = False
|
|
46
|
+
|
|
47
|
+
# ============ 属性 ============ #
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def ctx(self) -> "AppContext":
|
|
51
|
+
"""获取应用上下文."""
|
|
52
|
+
return self._ctx
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def running(self) -> bool:
|
|
56
|
+
"""检查是否正在运行."""
|
|
57
|
+
return self._running
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def closed(self) -> bool:
|
|
61
|
+
"""检查是否已关闭."""
|
|
62
|
+
return self._closed
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def sources(self) -> dict[UUID, BaseSource]:
|
|
66
|
+
"""获取所有事件源的副本."""
|
|
67
|
+
return dict(self._sources)
|
|
68
|
+
|
|
69
|
+
# ============ Source 管理 ============ #
|
|
70
|
+
|
|
71
|
+
def add_source(
|
|
72
|
+
self,
|
|
73
|
+
source_cls: Callable[_SourceP, BaseSourceT],
|
|
74
|
+
*args: _SourceP.args,
|
|
75
|
+
**kwargs: _SourceP.kwargs,
|
|
76
|
+
) -> BaseSourceT:
|
|
77
|
+
"""添加事件源.
|
|
78
|
+
|
|
79
|
+
本方法只做注册,不启动。应用已在运行时会立即注入上下文,
|
|
80
|
+
以便按"注册 → 订阅 → :meth:`start_source`"的顺序接入新事件源;
|
|
81
|
+
之所以不自动启动,是因为自动启动会让事件在 ``subscribe`` 注册完成前
|
|
82
|
+
就开始产生,从而丢事件。
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
source_cls: 事件源类
|
|
86
|
+
*args: 事件源初始化位置参数
|
|
87
|
+
**kwargs: 事件源初始化关键字参数
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
事件源实例
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
LifecycleError: 如果 SourceManager 已关闭
|
|
94
|
+
"""
|
|
95
|
+
if self._closed:
|
|
96
|
+
raise LifecycleError("SourceManager 已关闭,无法添加事件源")
|
|
97
|
+
|
|
98
|
+
source = source_cls(*args, **kwargs)
|
|
99
|
+
|
|
100
|
+
self._sources[source.uuid] = source
|
|
101
|
+
if self._running:
|
|
102
|
+
# 运行中新增:先注入上下文,让用户可以立刻订阅,再显式启动
|
|
103
|
+
source.bind(self._ctx)
|
|
104
|
+
_log.info(
|
|
105
|
+
"运行中添加事件源: %s (uuid=%s),"
|
|
106
|
+
"请在注册订阅后调用 await start_source(...) 启动",
|
|
107
|
+
source.__class__.__name__,
|
|
108
|
+
source.uuid,
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
_log.info(
|
|
112
|
+
"添加事件源: %s (uuid=%s)", source.__class__.__name__, source.uuid
|
|
113
|
+
)
|
|
114
|
+
return source
|
|
115
|
+
|
|
116
|
+
async def remove_source(self, source_id: UUID) -> BaseSource | None:
|
|
117
|
+
"""移除事件源.
|
|
118
|
+
|
|
119
|
+
完整的移除序列:停止事件源 → 清理它在 EventBus 上的全部订阅 →
|
|
120
|
+
从集合中摘除。只 pop 不做前两步会留下"幽灵源":
|
|
121
|
+
后台任务仍在跑,且派发表里的回调永久残留。
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
source_id: 事件源的 UUID
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
被移除的事件源,如果不存在则返回 None
|
|
128
|
+
"""
|
|
129
|
+
source = self._sources.get(source_id)
|
|
130
|
+
if source is None:
|
|
131
|
+
_log.warning("事件源 %s 不存在", source_id)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
await source.stop()
|
|
136
|
+
except Exception:
|
|
137
|
+
_log.exception("移除前停止 %s 失败", source.__class__.__name__)
|
|
138
|
+
|
|
139
|
+
removed_callbacks = self._ctx.bus.remove_subscribers(source_id)
|
|
140
|
+
self._sources.pop(source_id, None)
|
|
141
|
+
_log.info(
|
|
142
|
+
"移除事件源: %s (uuid=%s),同时清理 %s 个订阅回调",
|
|
143
|
+
source.__class__.__name__,
|
|
144
|
+
source_id,
|
|
145
|
+
removed_callbacks,
|
|
146
|
+
)
|
|
147
|
+
return source
|
|
148
|
+
|
|
149
|
+
@overload
|
|
150
|
+
def get_source(self, source: UUID) -> BaseSource | None: ...
|
|
151
|
+
|
|
152
|
+
@overload
|
|
153
|
+
def get_source(
|
|
154
|
+
self, source: type[BaseSourceT], config_key: str | None = None
|
|
155
|
+
) -> BaseSourceT | None: ...
|
|
156
|
+
|
|
157
|
+
def get_source(
|
|
158
|
+
self,
|
|
159
|
+
source: type[BaseSource] | UUID,
|
|
160
|
+
config_key: str | None = None,
|
|
161
|
+
) -> BaseSource | None:
|
|
162
|
+
"""获取事件源.
|
|
163
|
+
|
|
164
|
+
支持三种查找方式:
|
|
165
|
+
|
|
166
|
+
- ``get_source(source_id)`` — 按 UUID 查找
|
|
167
|
+
- ``get_source(source_cls)`` — 按类型查找(单一实例时最常用)
|
|
168
|
+
- ``get_source(source_cls, config_key)`` — 按类型 + 配置键查找(同源多实例时区分)
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
source: 事件源类或 UUID
|
|
172
|
+
config_key: 配置键,可选。传入时做精确匹配,否则返回该类型的第一个实例
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
事件源实例,如果不存在则返回 None
|
|
176
|
+
"""
|
|
177
|
+
# 按 UUID 查找
|
|
178
|
+
if isinstance(source, UUID):
|
|
179
|
+
return self._sources.get(source)
|
|
180
|
+
|
|
181
|
+
# 此时 source 一定是 type[BaseSource]
|
|
182
|
+
source_cls: type[BaseSource] = source
|
|
183
|
+
|
|
184
|
+
# 按类型查找(可选精确匹配 config_key)
|
|
185
|
+
for inst in self._sources.values():
|
|
186
|
+
if isinstance(inst, source_cls):
|
|
187
|
+
if config_key is None or inst.config_key == config_key:
|
|
188
|
+
return inst
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def _require_source(self, source: BaseSource | UUID) -> BaseSource:
|
|
192
|
+
"""解析并校验事件源必须已注册.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
source: 事件源实例或其 UUID
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
已注册的事件源实例
|
|
199
|
+
|
|
200
|
+
Raises:
|
|
201
|
+
SourceError: 事件源未注册
|
|
202
|
+
"""
|
|
203
|
+
source_id = source if isinstance(source, UUID) else source.uuid
|
|
204
|
+
found = self._sources.get(source_id)
|
|
205
|
+
if found is None:
|
|
206
|
+
raise SourceError("事件源 %s 不存在,请先通过 add_source 添加" % source_id)
|
|
207
|
+
return found
|
|
208
|
+
|
|
209
|
+
# ============ 单个 Source 的运行时启停 ============ #
|
|
210
|
+
|
|
211
|
+
async def start_source(self, source: BaseSource | UUID) -> BaseSource:
|
|
212
|
+
"""启动单个事件源(可在应用运行中调用).
|
|
213
|
+
|
|
214
|
+
用于运行期动态接入新事件源:``add_source`` → ``subscribe`` →
|
|
215
|
+
``await start_source(...)``。重复调用是安全的
|
|
216
|
+
(:meth:`BaseSource.start` 对已运行的源直接返回)。
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
source: 事件源实例或其 UUID
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
被启动的事件源
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
LifecycleError: SourceManager 已关闭
|
|
226
|
+
SourceError: 事件源未注册
|
|
227
|
+
"""
|
|
228
|
+
if self._closed:
|
|
229
|
+
raise LifecycleError("SourceManager 已关闭,无法启动事件源")
|
|
230
|
+
|
|
231
|
+
resolved = self._require_source(source)
|
|
232
|
+
resolved.bind(self._ctx)
|
|
233
|
+
await resolved.start()
|
|
234
|
+
_log.info("启动事件源: %s (uuid=%s)", type(resolved).__name__, resolved.uuid)
|
|
235
|
+
return resolved
|
|
236
|
+
|
|
237
|
+
async def stop_source(self, source: BaseSource | UUID) -> BaseSource:
|
|
238
|
+
"""停止单个事件源,但保留注册与订阅(可再次 :meth:`start_source`).
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
source: 事件源实例或其 UUID
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
被停止的事件源
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
SourceError: 事件源未注册
|
|
248
|
+
"""
|
|
249
|
+
resolved = self._require_source(source)
|
|
250
|
+
await resolved.stop()
|
|
251
|
+
_log.info("停止事件源: %s (uuid=%s)", type(resolved).__name__, resolved.uuid)
|
|
252
|
+
return resolved
|
|
253
|
+
|
|
254
|
+
# ============ 生命周期 ============ #
|
|
255
|
+
|
|
256
|
+
async def start(self) -> None:
|
|
257
|
+
"""启动 SourceManager.
|
|
258
|
+
|
|
259
|
+
生命周期顺序:
|
|
260
|
+
|
|
261
|
+
1. 检查状态
|
|
262
|
+
2. 为每个 source 注入上下文 (bind)
|
|
263
|
+
3. 启动每个 source
|
|
264
|
+
|
|
265
|
+
任一事件源启动失败时不留半启动状态:已成功启动的会被回滚(stop),
|
|
266
|
+
然后把全部失败聚合成 :class:`SourceStartError` 上抛,``running`` 保持 ``False``。
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
LifecycleError: SourceManager 已关闭
|
|
270
|
+
SourceStartError: 一个或多个事件源启动失败
|
|
271
|
+
"""
|
|
272
|
+
if self._closed:
|
|
273
|
+
raise LifecycleError("SourceManager 已关闭,无法启动")
|
|
274
|
+
|
|
275
|
+
if self._running:
|
|
276
|
+
_log.warning("SourceManager 已在运行中")
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
_log.info("正在启动 SourceManager...")
|
|
280
|
+
|
|
281
|
+
# 为每个 source 注入上下文
|
|
282
|
+
for source in self._sources.values():
|
|
283
|
+
source.bind(self._ctx)
|
|
284
|
+
_log.debug("绑定上下文到 %s", source.__class__.__name__)
|
|
285
|
+
|
|
286
|
+
# 启动所有 source
|
|
287
|
+
started: list[BaseSource] = []
|
|
288
|
+
failures: dict[str, BaseException] = {}
|
|
289
|
+
for source in self._sources.values():
|
|
290
|
+
try:
|
|
291
|
+
await source.start()
|
|
292
|
+
except Exception as e:
|
|
293
|
+
_log.exception("启动 %s 失败", source.__class__.__name__)
|
|
294
|
+
failures["%s(uuid=%s)" % (source.__class__.__name__, source.uuid)] = e
|
|
295
|
+
else:
|
|
296
|
+
started.append(source)
|
|
297
|
+
_log.debug("启动 %s", source.__class__.__name__)
|
|
298
|
+
|
|
299
|
+
if failures:
|
|
300
|
+
for source in started:
|
|
301
|
+
try:
|
|
302
|
+
await source.stop()
|
|
303
|
+
except Exception:
|
|
304
|
+
_log.exception("回滚 %s 时出错", source.__class__.__name__)
|
|
305
|
+
raise SourceStartError(failures)
|
|
306
|
+
|
|
307
|
+
self._running = True
|
|
308
|
+
_log.info("SourceManager 已启动")
|
|
309
|
+
|
|
310
|
+
async def stop(self) -> None:
|
|
311
|
+
"""停止 SourceManager.
|
|
312
|
+
|
|
313
|
+
依次停止所有事件源;单个事件源停止失败只记录日志,不影响其余事件源。
|
|
314
|
+
|
|
315
|
+
``CancelledError`` 被单独接住:Ctrl+C 路径下 ``await source.stop()``
|
|
316
|
+
会在取消态抛出它,而它不是 ``Exception`` 的子类——不单独处理就会
|
|
317
|
+
中断后续事件源的清理。这里先清完所有事件源,最后再把取消向上重抛。
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
asyncio.CancelledError: 停止过程中被取消(在清理完成后重抛)
|
|
321
|
+
"""
|
|
322
|
+
if not self._running:
|
|
323
|
+
_log.warning("SourceManager 未在运行")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
_log.info("正在停止 SourceManager...")
|
|
327
|
+
|
|
328
|
+
cancelled: asyncio.CancelledError | None = None
|
|
329
|
+
|
|
330
|
+
# 停止所有 source
|
|
331
|
+
for source in self._sources.values():
|
|
332
|
+
try:
|
|
333
|
+
await source.stop()
|
|
334
|
+
_log.debug("停止 %s", source.__class__.__name__)
|
|
335
|
+
except asyncio.CancelledError as e:
|
|
336
|
+
cancelled = e
|
|
337
|
+
_log.warning(
|
|
338
|
+
"停止 %s 时被取消,继续清理其余事件源",
|
|
339
|
+
source.__class__.__name__,
|
|
340
|
+
)
|
|
341
|
+
except Exception:
|
|
342
|
+
_log.exception("停止 %s 失败", source.__class__.__name__)
|
|
343
|
+
|
|
344
|
+
self._running = False
|
|
345
|
+
_log.info("SourceManager 已停止")
|
|
346
|
+
|
|
347
|
+
if cancelled is not None:
|
|
348
|
+
raise cancelled
|
|
349
|
+
|
|
350
|
+
async def close(self) -> None:
|
|
351
|
+
"""关闭 SourceManager.
|
|
352
|
+
|
|
353
|
+
关闭后无法再使用。即使 :meth:`stop` 因取消而抛出,
|
|
354
|
+
集合清理与状态置位仍会完成(在 ``finally`` 中)。
|
|
355
|
+
"""
|
|
356
|
+
if self._closed:
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
# 先停止
|
|
361
|
+
if self._running:
|
|
362
|
+
await self.stop()
|
|
363
|
+
finally:
|
|
364
|
+
# 清理资源
|
|
365
|
+
self._sources.clear()
|
|
366
|
+
self._closed = True
|
|
367
|
+
_log.info("SourceManager 已关闭")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from .api import BaseApi, BaseApiT
|
|
2
|
+
from .context import ApiRegistry, AppContext, ConfigProvider
|
|
3
|
+
from .data import AutoDispatchList, BaseDataMixin, BaseDataModel, BaseDataT
|
|
4
|
+
from .event import Event, EventBus
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
ApiError,
|
|
7
|
+
ButterError,
|
|
8
|
+
ConfigError,
|
|
9
|
+
LifecycleError,
|
|
10
|
+
SourceError,
|
|
11
|
+
SourceStartError,
|
|
12
|
+
SubscriptionError,
|
|
13
|
+
)
|
|
14
|
+
from .filter import AndFilter, BaseFilter, OrFilter
|
|
15
|
+
from .source import BaseSource, BaseSourceT
|
|
16
|
+
from .types import BaseType, BaseTypeT
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AndFilter",
|
|
20
|
+
"ApiError",
|
|
21
|
+
"ApiRegistry",
|
|
22
|
+
"AppContext",
|
|
23
|
+
"AutoDispatchList",
|
|
24
|
+
"BaseApi",
|
|
25
|
+
"BaseApiT",
|
|
26
|
+
"BaseDataMixin",
|
|
27
|
+
"BaseDataModel",
|
|
28
|
+
"BaseDataT",
|
|
29
|
+
"BaseFilter",
|
|
30
|
+
# Base classes
|
|
31
|
+
"BaseSource",
|
|
32
|
+
"BaseSourceT",
|
|
33
|
+
"BaseType",
|
|
34
|
+
"BaseTypeT",
|
|
35
|
+
# 异常层级
|
|
36
|
+
"ButterError",
|
|
37
|
+
"ConfigError",
|
|
38
|
+
"ConfigProvider",
|
|
39
|
+
"Event",
|
|
40
|
+
"EventBus",
|
|
41
|
+
"LifecycleError",
|
|
42
|
+
"OrFilter",
|
|
43
|
+
"SourceError",
|
|
44
|
+
"SourceStartError",
|
|
45
|
+
"SubscriptionError",
|
|
46
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import TYPE_CHECKING, Self, TypeVar
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from butterbot.core.context import ApiRegistry
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseApi(ABC):
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def __init__(self):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def create(cls, ctx: "ApiRegistry", config_key: str) -> Self:
|
|
16
|
+
"""API实例工厂方法"""
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
async def aclose(self) -> None:
|
|
20
|
+
"""释放该 API 持有的资源(可选覆写).
|
|
21
|
+
|
|
22
|
+
由 :meth:`ApiRegistry.aclose_all` 在应用关闭时调用,
|
|
23
|
+
用于关闭长连接、取消后台任务等。默认实现什么都不做——
|
|
24
|
+
无外部资源的 API 无需覆写。
|
|
25
|
+
|
|
26
|
+
实现要求:必须幂等(可能被重复调用),且不应抛出异常
|
|
27
|
+
(抛出的异常会被 ApiRegistry 记录后忽略,不影响其他 API 的关闭)。
|
|
28
|
+
"""
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
BaseApiT = TypeVar("BaseApiT", bound=BaseApi)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
--- 待施工 ---
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from logging import getLogger
|
|
5
|
+
from threading import RLock
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from butterbot.core.api import BaseApi, BaseApiT
|
|
9
|
+
from butterbot.core.exceptions import ConfigError
|
|
10
|
+
|
|
11
|
+
from .config_provider import ConfigProvider
|
|
12
|
+
|
|
13
|
+
_log = getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiRegistry:
|
|
17
|
+
"""API 注册器,负责管理 API 单例和配置."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, config: ConfigProvider):
|
|
20
|
+
"""初始化 ApiRegistry 实例.
|
|
21
|
+
Args:
|
|
22
|
+
config: 实现 :class:`ConfigProvider` 的运行时配置
|
|
23
|
+
"""
|
|
24
|
+
self.config = config
|
|
25
|
+
# 用可重入锁:API 的 create() 是同步方法,允许在内部再取用别的 API
|
|
26
|
+
# (create → get_api → 同一把锁),非重入锁会在这条路径上自死锁。
|
|
27
|
+
self._lock = RLock()
|
|
28
|
+
self._instances: dict[type, dict[str, Any]] = defaultdict(dict)
|
|
29
|
+
# Type[BaseApiT] - {config_key - BaseApiT}
|
|
30
|
+
|
|
31
|
+
def get_api(
|
|
32
|
+
self,
|
|
33
|
+
cls: type[BaseApiT],
|
|
34
|
+
config_key: str,
|
|
35
|
+
) -> BaseApiT:
|
|
36
|
+
"""
|
|
37
|
+
获取API实例
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
cls: API类
|
|
41
|
+
config_key: 配置键
|
|
42
|
+
"""
|
|
43
|
+
_log.debug("读取 %s 的 %s 实例", config_key, cls.__name__)
|
|
44
|
+
with self._lock:
|
|
45
|
+
if config_key not in self._instances[cls]:
|
|
46
|
+
self._instances[cls][config_key] = cls.create(
|
|
47
|
+
self, config_key=config_key
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return self._instances[cls][config_key]
|
|
51
|
+
|
|
52
|
+
def require_config(self, config_key: str) -> Any:
|
|
53
|
+
"""读取必需的配置项,缺失时抛出明确错误.
|
|
54
|
+
|
|
55
|
+
供 API 的 :meth:`BaseApi.create` 使用。直接用
|
|
56
|
+
``ctx.config.get_config(key)`` 时缺失的键会静默返回 ``None``,
|
|
57
|
+
错误会推迟到深处变成一句与配置无关的 ``AttributeError``
|
|
58
|
+
(例如 ``'NoneType' object has no attribute 'token'``)。
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
config_key: 配置键
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
配置对象
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ConfigError: 配置键不存在或值为 ``None``
|
|
68
|
+
"""
|
|
69
|
+
value = self.config.get_config(config_key)
|
|
70
|
+
if value is None:
|
|
71
|
+
raise ConfigError(
|
|
72
|
+
"缺少配置键 '%s'。请在 config.yaml 中添加该键,"
|
|
73
|
+
"或构造 RuntimeConfig(%s=...) 时显式传入。" % (config_key, config_key)
|
|
74
|
+
)
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
async def aclose_all(self) -> None:
|
|
78
|
+
"""关闭并清空所有缓存的 API 单例.
|
|
79
|
+
|
|
80
|
+
依次 ``await`` 每个实例的 :meth:`BaseApi.aclose`,单个实例关闭失败
|
|
81
|
+
只记录日志、不影响其余实例,最后统一清空缓存。
|
|
82
|
+
"""
|
|
83
|
+
with self._lock:
|
|
84
|
+
instances = [
|
|
85
|
+
inst for by_key in self._instances.values() for inst in by_key.values()
|
|
86
|
+
]
|
|
87
|
+
self._instances.clear()
|
|
88
|
+
|
|
89
|
+
for inst in instances:
|
|
90
|
+
if not isinstance(inst, BaseApi):
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
await inst.aclose()
|
|
94
|
+
except Exception:
|
|
95
|
+
_log.exception("关闭 API %s 时出错", type(inst).__name__)
|
|
96
|
+
|
|
97
|
+
if instances:
|
|
98
|
+
_log.debug("已关闭 %s 个 API 实例", len(instances))
|
|
99
|
+
|
|
100
|
+
def clear(self) -> None:
|
|
101
|
+
"""清空所有缓存的 API 单例实例,主要用于测试隔离.
|
|
102
|
+
|
|
103
|
+
只丢弃引用,不会调用 :meth:`BaseApi.aclose`。
|
|
104
|
+
需要释放连接等资源时请用 :meth:`aclose_all`。
|
|
105
|
+
"""
|
|
106
|
+
with self._lock:
|
|
107
|
+
self._instances.clear()
|
|
108
|
+
|
|
109
|
+
get = get_api
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from butterbot.core.event import EventBus
|
|
4
|
+
|
|
5
|
+
from .api_registry import ApiRegistry
|
|
6
|
+
from .config_provider import ConfigProvider
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AppContext:
|
|
10
|
+
"""应用上下文,统一注入对象.
|
|
11
|
+
|
|
12
|
+
用于将分散的依赖注入整合为一个对象,
|
|
13
|
+
供 Source 和其他组件使用。
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
config: 运行时配置(只读)
|
|
17
|
+
api_ctx: API 单例容器
|
|
18
|
+
bus: 事件总线
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
config: ConfigProvider,
|
|
24
|
+
event_bus: EventBus | None = None,
|
|
25
|
+
api_ctx: ApiRegistry | None = None,
|
|
26
|
+
):
|
|
27
|
+
"""初始化 AppContext.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
config: 运行时配置
|
|
31
|
+
event_bus: 可选,注入自定义 EventBus,默认自动创建
|
|
32
|
+
api_ctx: 可选,注入自定义 ApiRegistry,默认自动创建
|
|
33
|
+
"""
|
|
34
|
+
self._config = config
|
|
35
|
+
self._api_ctx = api_ctx or ApiRegistry(config)
|
|
36
|
+
self._bus = event_bus or EventBus()
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def config(self) -> ConfigProvider:
|
|
40
|
+
"""获取运行时配置(只读)."""
|
|
41
|
+
return self._config
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def api_ctx(self) -> ApiRegistry:
|
|
45
|
+
"""获取 API 上下文."""
|
|
46
|
+
return self._api_ctx
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def bus(self) -> EventBus:
|
|
50
|
+
"""获取事件总线."""
|
|
51
|
+
return self._bus
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""core 层使用的配置契约."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConfigProvider(Protocol):
|
|
7
|
+
"""core 组件所需的最小配置接口.
|
|
8
|
+
|
|
9
|
+
core 只依赖按键读取配置;具体加载方式、YAML 解析和构建器注册均属于
|
|
10
|
+
app 层职责。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def get_config(self, key: str, default: Any = None) -> Any:
|
|
14
|
+
"""返回配置值;键不存在时返回 ``default``."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import TypeVar
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BaseDataMixin:
|
|
5
|
+
"""
|
|
6
|
+
框架内约束的数据类混入类, 用于标记框架内数据
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
_repr_exclude = {"raw_data"} # 排除在repr中的属性集合
|
|
10
|
+
|
|
11
|
+
def __repr__(self):
|
|
12
|
+
core_properties_str: str = self._get_core_properties_str()
|
|
13
|
+
return "%s(%s)" % (self.__class__.__name__, core_properties_str)
|
|
14
|
+
|
|
15
|
+
def __str__(self):
|
|
16
|
+
return self.__repr__()
|
|
17
|
+
|
|
18
|
+
def _get_core_properties_str(self) -> str:
|
|
19
|
+
excludes = set(getattr(self, "_repr_exclude", ()))
|
|
20
|
+
props = {
|
|
21
|
+
k: v
|
|
22
|
+
for k, v in vars(self).items()
|
|
23
|
+
if not k.startswith("_") and k not in excludes
|
|
24
|
+
}
|
|
25
|
+
parts = ["%s=%r" % (k, v) for k, v in props.items()]
|
|
26
|
+
return ", ".join(parts)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
BaseDataT = TypeVar("BaseDataT", bound=BaseDataMixin)
|