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
butterbot/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""butterbot — 事件驱动的机器人框架.
|
|
2
|
+
|
|
3
|
+
用户代码请从 :mod:`butterbot.app` 导入公开 API::
|
|
4
|
+
|
|
5
|
+
from butterbot.app import BotApp, Event, RuntimeConfig
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("butterbot-python")
|
|
12
|
+
except PackageNotFoundError: # 源码树内直接运行且未安装时
|
|
13
|
+
__version__ = "0.0.0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""butterbot 应用入口模块.
|
|
2
|
+
|
|
3
|
+
用户应该从这里导入需要的类,而不是直接从 core 导入。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from butterbot.core.event import Event
|
|
7
|
+
from butterbot.core.exceptions import (
|
|
8
|
+
ApiError,
|
|
9
|
+
ButterError,
|
|
10
|
+
ConfigError,
|
|
11
|
+
LifecycleError,
|
|
12
|
+
SourceError,
|
|
13
|
+
SourceStartError,
|
|
14
|
+
SubscriptionError,
|
|
15
|
+
)
|
|
16
|
+
from butterbot.core.filter import AndFilter, BaseFilter, OrFilter
|
|
17
|
+
|
|
18
|
+
from .bot_app import BotApp
|
|
19
|
+
from .config import RuntimeConfig, register_builder
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
# 应用主入口
|
|
23
|
+
"BotApp",
|
|
24
|
+
# 事件
|
|
25
|
+
"Event",
|
|
26
|
+
# 配置
|
|
27
|
+
"RuntimeConfig",
|
|
28
|
+
"register_builder",
|
|
29
|
+
# 过滤器
|
|
30
|
+
"AndFilter",
|
|
31
|
+
"BaseFilter",
|
|
32
|
+
"OrFilter",
|
|
33
|
+
# 异常层级
|
|
34
|
+
"ApiError",
|
|
35
|
+
"ButterError",
|
|
36
|
+
"ConfigError",
|
|
37
|
+
"LifecycleError",
|
|
38
|
+
"SourceError",
|
|
39
|
+
"SourceStartError",
|
|
40
|
+
"SubscriptionError",
|
|
41
|
+
]
|
butterbot/app/bot_app.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import re
|
|
3
|
+
import signal
|
|
4
|
+
from collections.abc import Coroutine
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable, ParamSpec, overload
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from butterbot.core.api import BaseApiT
|
|
10
|
+
from butterbot.core.context import ApiRegistry, AppContext
|
|
11
|
+
from butterbot.core.event import Event, EventBus
|
|
12
|
+
from butterbot.core.source import BaseSource, BaseSourceT
|
|
13
|
+
from butterbot.core.types import BaseType
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from butterbot.core.filter import BaseFilter
|
|
17
|
+
|
|
18
|
+
from .config import RuntimeConfig
|
|
19
|
+
from .source_manager import SourceManager
|
|
20
|
+
|
|
21
|
+
_BotSourceP = ParamSpec("_BotSourceP")
|
|
22
|
+
|
|
23
|
+
_log = getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BotApp:
|
|
27
|
+
"""Bot 应用主入口,持有所有高层基础设施.
|
|
28
|
+
|
|
29
|
+
BotApp 负责:
|
|
30
|
+
- 持有并构建 RuntimeConfig、EventBus、AppContext
|
|
31
|
+
- 创建并持有 SourceManager(专注于事件源生命周期)
|
|
32
|
+
- 对外暴露订阅、事件源管理、API 访问和生命周期接口
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
config: 运行时配置(只读)
|
|
36
|
+
ctx: 应用上下文(注入给各 Source)
|
|
37
|
+
manager: 事件源生命周期管理器
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
config: RuntimeConfig | None = None,
|
|
43
|
+
ctx: AppContext | None = None,
|
|
44
|
+
*,
|
|
45
|
+
close_timeout: float = 5.0,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""初始化 BotApp.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
config: 运行时配置,可选,默认从 ``config.yaml`` 自动加载
|
|
51
|
+
ctx: 可选,注入自定义 AppContext,默认自动创建
|
|
52
|
+
close_timeout: 关闭时等待 in-flight 回调完成的秒数,超时后强制取消
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
FileNotFoundError: 自动加载时 ``config.yaml`` 不存在
|
|
56
|
+
"""
|
|
57
|
+
self._config = config or RuntimeConfig.from_yaml()
|
|
58
|
+
|
|
59
|
+
# 统一注入对象,传递给各 Source
|
|
60
|
+
self._ctx = ctx or AppContext(
|
|
61
|
+
config=self._config,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# 事件源生命周期管理器
|
|
65
|
+
self._manager = SourceManager(self._ctx)
|
|
66
|
+
|
|
67
|
+
self._close_timeout = close_timeout
|
|
68
|
+
|
|
69
|
+
# ============ 属性 ============ #
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def config(self) -> "RuntimeConfig":
|
|
73
|
+
"""获取运行时配置(只读)."""
|
|
74
|
+
return self._config
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def bus(self) -> EventBus:
|
|
78
|
+
"""获取事件总线."""
|
|
79
|
+
return self._ctx.bus
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def api_ctx(self) -> ApiRegistry:
|
|
83
|
+
return self._ctx.api_ctx
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def ctx(self) -> AppContext:
|
|
87
|
+
"""获取应用上下文."""
|
|
88
|
+
return self._ctx
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def manager(self) -> SourceManager:
|
|
92
|
+
"""获取事件源管理器."""
|
|
93
|
+
return self._manager
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def running(self) -> bool:
|
|
97
|
+
"""检查是否正在运行."""
|
|
98
|
+
return self._manager.running
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def closed(self) -> bool:
|
|
102
|
+
"""检查是否已关闭."""
|
|
103
|
+
return self._manager.closed
|
|
104
|
+
|
|
105
|
+
# ============ Source 管理(委托 SourceManager)============ #
|
|
106
|
+
|
|
107
|
+
def add_source(
|
|
108
|
+
self,
|
|
109
|
+
source_cls: Callable[_BotSourceP, BaseSourceT],
|
|
110
|
+
*args: _BotSourceP.args,
|
|
111
|
+
**kwargs: _BotSourceP.kwargs,
|
|
112
|
+
) -> BaseSourceT:
|
|
113
|
+
"""添加事件源.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
source_cls: 事件源类
|
|
117
|
+
*args: 事件源初始化位置参数
|
|
118
|
+
**kwargs: 事件源初始化关键字参数
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
事件源实例
|
|
122
|
+
|
|
123
|
+
Note:
|
|
124
|
+
只做注册,不启动。应用已在运行中时,接入顺序为
|
|
125
|
+
``add_source`` → ``subscribe`` → ``await start_source(...)``。
|
|
126
|
+
"""
|
|
127
|
+
return self._manager.add_source(source_cls, *args, **kwargs)
|
|
128
|
+
|
|
129
|
+
async def remove_source(self, source_id: UUID) -> BaseSource | None:
|
|
130
|
+
"""移除事件源.
|
|
131
|
+
|
|
132
|
+
会先停止该事件源,再清理它在 EventBus 上的全部订阅,最后摘除。
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
source_id: 事件源的 UUID
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
被移除的事件源,不存在时返回 ``None``
|
|
139
|
+
"""
|
|
140
|
+
return await self._manager.remove_source(source_id)
|
|
141
|
+
|
|
142
|
+
async def start_source(self, source: BaseSource | UUID) -> BaseSource:
|
|
143
|
+
"""启动单个事件源(用于运行期动态接入).
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
source: 事件源实例或其 UUID
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
被启动的事件源
|
|
150
|
+
"""
|
|
151
|
+
return await self._manager.start_source(source)
|
|
152
|
+
|
|
153
|
+
async def stop_source(self, source: BaseSource | UUID) -> BaseSource:
|
|
154
|
+
"""停止单个事件源,保留注册与订阅(可再次 :meth:`start_source`).
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
source: 事件源实例或其 UUID
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
被停止的事件源
|
|
161
|
+
"""
|
|
162
|
+
return await self._manager.stop_source(source)
|
|
163
|
+
|
|
164
|
+
@overload
|
|
165
|
+
def get_source(self, source: UUID) -> BaseSource | None: ...
|
|
166
|
+
|
|
167
|
+
@overload
|
|
168
|
+
def get_source(
|
|
169
|
+
self, source: type[BaseSourceT], config_key: str | None = None
|
|
170
|
+
) -> BaseSourceT | None: ...
|
|
171
|
+
|
|
172
|
+
def get_source(
|
|
173
|
+
self,
|
|
174
|
+
source: type[BaseSource] | UUID,
|
|
175
|
+
config_key: str | None = None,
|
|
176
|
+
) -> BaseSource | None:
|
|
177
|
+
"""获取事件源.
|
|
178
|
+
|
|
179
|
+
支持三种查找方式:
|
|
180
|
+
|
|
181
|
+
- ``app.get_source(source_id)`` — 按 UUID 查找
|
|
182
|
+
- ``app.get_source(source_cls)`` — 按类型查找(单一实例时最常用)
|
|
183
|
+
- ``app.get_source(source_cls, config_key)`` — 按类型 + 配置键查找(同源多实例时区分)
|
|
184
|
+
"""
|
|
185
|
+
if isinstance(source, UUID):
|
|
186
|
+
return self._manager.get_source(source)
|
|
187
|
+
return self._manager.get_source(source, config_key)
|
|
188
|
+
|
|
189
|
+
# ============ API 访问(委托 ApiRegistry)============ #
|
|
190
|
+
|
|
191
|
+
def get_api(self, api_cls: type[BaseApiT], config_key: str) -> BaseApiT:
|
|
192
|
+
"""获取 API 单例实例.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
api_cls: API 类类型
|
|
196
|
+
config_key: 配置键
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
API 单例实例
|
|
200
|
+
"""
|
|
201
|
+
return self.api_ctx.get_api(api_cls, config_key)
|
|
202
|
+
|
|
203
|
+
# ============ 订阅接口(委托 EventBus)============ #
|
|
204
|
+
|
|
205
|
+
def subscribe(
|
|
206
|
+
self,
|
|
207
|
+
source_id: UUID,
|
|
208
|
+
status: str | re.Pattern[str] | BaseType,
|
|
209
|
+
*,
|
|
210
|
+
event_filter: "BaseFilter | None" = None,
|
|
211
|
+
) -> Callable:
|
|
212
|
+
"""装饰器:订阅事件.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
source_id: 事件源的 UUID
|
|
216
|
+
status: 状态过滤器(``BaseType`` 枚举、``str`` 或 ``re.Pattern`` 正则)
|
|
217
|
+
event_filter: 可选的事件内容过滤器,只有通过过滤器的事件才触发回调
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
装饰器函数
|
|
221
|
+
|
|
222
|
+
Usage:
|
|
223
|
+
@app.subscribe(source.uuid, LiveType.OPEN)
|
|
224
|
+
async def on_open(event: Event):
|
|
225
|
+
...
|
|
226
|
+
|
|
227
|
+
@app.subscribe(source.uuid, r".*\\.message")
|
|
228
|
+
async def on_message(event: Event):
|
|
229
|
+
...
|
|
230
|
+
|
|
231
|
+
pattern = re.compile(r"danmaku\\.(msg|gift)")
|
|
232
|
+
@app.subscribe(source.uuid, pattern)
|
|
233
|
+
async def on_danmaku(event: Event):
|
|
234
|
+
...
|
|
235
|
+
"""
|
|
236
|
+
source = self._manager.get_source(source_id)
|
|
237
|
+
if source is None:
|
|
238
|
+
raise ValueError("事件源 %s 不存在,请先通过 add_source 添加" % source_id)
|
|
239
|
+
return self.bus.subscribe(
|
|
240
|
+
source_id, status, source.supported_types, event_filter=event_filter
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def add_subscriber(
|
|
244
|
+
self,
|
|
245
|
+
source_id: UUID,
|
|
246
|
+
callback: Callable[[Event], Coroutine[Any, Any, None]],
|
|
247
|
+
status: str | re.Pattern[str] | BaseType,
|
|
248
|
+
*,
|
|
249
|
+
event_filter: "BaseFilter | None" = None,
|
|
250
|
+
) -> None:
|
|
251
|
+
"""手动注册订阅者.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
source_id: 事件源的 UUID
|
|
255
|
+
callback: 异步回调函数
|
|
256
|
+
status: 状态过滤器(``BaseType`` 枚举、``str`` 或 ``re.Pattern`` 正则)
|
|
257
|
+
event_filter: 可选的事件内容过滤器,只有通过过滤器的事件才触发回调
|
|
258
|
+
"""
|
|
259
|
+
source = self._manager.get_source(source_id)
|
|
260
|
+
if source is None:
|
|
261
|
+
raise ValueError("事件源 %s 不存在,请先通过 add_source 添加" % source_id)
|
|
262
|
+
self.bus.add_subscriber(
|
|
263
|
+
source_id,
|
|
264
|
+
callback,
|
|
265
|
+
status,
|
|
266
|
+
source.supported_types,
|
|
267
|
+
event_filter=event_filter,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def unsubscribe(self, source_id: UUID) -> int:
|
|
271
|
+
"""取消某个事件源的全部订阅.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
source_id: 事件源的 UUID
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
被移除的回调数量
|
|
278
|
+
"""
|
|
279
|
+
return self.bus.remove_subscribers(source_id)
|
|
280
|
+
|
|
281
|
+
# ============ 生命周期 ============ #
|
|
282
|
+
|
|
283
|
+
async def start(self) -> None:
|
|
284
|
+
"""启动应用(启动所有事件源).
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
SourceStartError: 一个或多个事件源启动失败
|
|
288
|
+
(抛出前已回滚成功启动的事件源)
|
|
289
|
+
"""
|
|
290
|
+
await self._manager.start()
|
|
291
|
+
|
|
292
|
+
async def stop(self) -> None:
|
|
293
|
+
"""停止应用(停止所有事件源)."""
|
|
294
|
+
await self._manager.stop()
|
|
295
|
+
|
|
296
|
+
async def close(self) -> None:
|
|
297
|
+
"""关闭应用,释放所有资源.
|
|
298
|
+
|
|
299
|
+
关闭顺序是固定的,且不能调换:
|
|
300
|
+
|
|
301
|
+
1. ``SourceManager.close()`` — 停止全部事件源并清空注册;
|
|
302
|
+
先停源,总线才不会在排空期间又收到新事件。
|
|
303
|
+
2. ``EventBus.close()`` — 排空正在执行的订阅回调(``close_timeout`` 超时后取消);
|
|
304
|
+
先排空回调,回调里才不会用到下一步已经关掉的 API。
|
|
305
|
+
3. ``ApiRegistry.aclose_all()`` — 释放各 API 持有的连接与后台任务。
|
|
306
|
+
|
|
307
|
+
即使第 1 步因取消而抛出,后两步仍会在 ``finally`` 中完成。
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
await self._manager.close()
|
|
311
|
+
finally:
|
|
312
|
+
await self.bus.close(timeout=self._close_timeout)
|
|
313
|
+
await self.api_ctx.aclose_all()
|
|
314
|
+
|
|
315
|
+
# ============ 阻塞式入口 ============ #
|
|
316
|
+
|
|
317
|
+
def run(self, duration: float | None = None) -> None:
|
|
318
|
+
"""阻塞运行 BotApp,直到被中断或达到指定时长.
|
|
319
|
+
|
|
320
|
+
这是最简使用方式,适合大多数场景。
|
|
321
|
+
高级用户仍可使用 ``async with`` 或 ``await start/stop`` 进行精细控制。
|
|
322
|
+
|
|
323
|
+
``SIGINT``(Ctrl+C)与 ``SIGTERM``(容器 ``docker stop`` / k8s 缩容)
|
|
324
|
+
都会触发**正常退出路径**:先跳出等待,再走完整的 :meth:`close`。
|
|
325
|
+
只依赖 ``KeyboardInterrupt`` 的话,容器里收到 SIGTERM 会直接被杀,
|
|
326
|
+
清理代码根本不会执行。不支持 ``add_signal_handler`` 的平台
|
|
327
|
+
(如 Windows 的 ProactorEventLoop)自动回退到 ``KeyboardInterrupt``。
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
duration: 可选,运行时长(秒)。为 ``None`` 则持续运行直到收到信号。
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
async def _run() -> None:
|
|
334
|
+
loop = asyncio.get_running_loop()
|
|
335
|
+
stop_event = asyncio.Event()
|
|
336
|
+
installed: list[signal.Signals] = []
|
|
337
|
+
|
|
338
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
339
|
+
try:
|
|
340
|
+
loop.add_signal_handler(sig, stop_event.set)
|
|
341
|
+
except (NotImplementedError, RuntimeError, ValueError, AttributeError):
|
|
342
|
+
_log.debug("当前平台不支持处理信号 %s,回退到默认行为", sig)
|
|
343
|
+
else:
|
|
344
|
+
installed.append(sig)
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
async with self:
|
|
348
|
+
if duration is not None:
|
|
349
|
+
try:
|
|
350
|
+
await asyncio.wait_for(stop_event.wait(), timeout=duration)
|
|
351
|
+
except TimeoutError:
|
|
352
|
+
_log.info("BotApp 运行 %s 秒,自动停止", duration)
|
|
353
|
+
else:
|
|
354
|
+
_log.info("BotApp 收到停止信号,正在关闭")
|
|
355
|
+
else:
|
|
356
|
+
await stop_event.wait()
|
|
357
|
+
_log.info("BotApp 收到停止信号,正在关闭")
|
|
358
|
+
finally:
|
|
359
|
+
for sig in installed:
|
|
360
|
+
loop.remove_signal_handler(sig)
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
asyncio.run(_run())
|
|
364
|
+
except KeyboardInterrupt:
|
|
365
|
+
_log.info("BotApp 被用户中断")
|
|
366
|
+
|
|
367
|
+
# ============ 异步上下文管理器 ============ #
|
|
368
|
+
|
|
369
|
+
async def __aenter__(self) -> "BotApp":
|
|
370
|
+
await self.start()
|
|
371
|
+
return self
|
|
372
|
+
|
|
373
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
374
|
+
await self.close()
|
butterbot/app/config.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from butterbot.core.exceptions import ConfigError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RuntimeConfig:
|
|
12
|
+
"""运行时 API 配置类,存储和管理 API 配置信息.
|
|
13
|
+
|
|
14
|
+
以键值对形式存储 API 配置,支持通过方法访问 API 配置项.
|
|
15
|
+
|
|
16
|
+
支持从 YAML 配置文件加载:
|
|
17
|
+
>>> config = RuntimeConfig.from_yaml("config.yaml")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, **configs: Any):
|
|
21
|
+
"""初始化 RuntimeConfig 实例.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
**configs: 可变关键字参数,表示 API 配置项的键值对.
|
|
25
|
+
"""
|
|
26
|
+
self._configs = configs
|
|
27
|
+
|
|
28
|
+
def get_config(self, key: str, default: Any = None) -> Any:
|
|
29
|
+
"""获取指定键的配置值.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
key: 配置项的键.
|
|
33
|
+
default: 如果键不存在时返回的默认值,默认为 None.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
配置项的值,如果键不存在则返回默认值.
|
|
37
|
+
"""
|
|
38
|
+
return self._configs.get(key, default)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_yaml(cls, path: str | Path = "config.yaml") -> RuntimeConfig:
|
|
42
|
+
"""从 YAML 配置文件加载配置.
|
|
43
|
+
|
|
44
|
+
自动将以下配置项转换为对应的类型对象:
|
|
45
|
+
|
|
46
|
+
- ``bilibili`` → ``bilibili_api.Credential``
|
|
47
|
+
- ``napcat`` → ``NapcatConfig``
|
|
48
|
+
|
|
49
|
+
可通过 :func:`register_builder` 注册自定义类型的构建器.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
path: YAML 配置文件路径,默认为 ``config.yaml``.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
RuntimeConfig 实例.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
FileNotFoundError: 配置文件不存在.
|
|
59
|
+
yaml.YAMLError: YAML 文件格式错误.
|
|
60
|
+
ConfigError: YAML 文件顶层不是键值映射,或配置项构建失败
|
|
61
|
+
(``ConfigError`` 同时继承 ``ValueError``).
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
``config.yaml`` 内容::
|
|
65
|
+
|
|
66
|
+
bilibili:
|
|
67
|
+
sessdata: ""
|
|
68
|
+
bili_jct: ""
|
|
69
|
+
buvid3: ""
|
|
70
|
+
|
|
71
|
+
napcat:
|
|
72
|
+
url: "ws://localhost:3001"
|
|
73
|
+
token: ""
|
|
74
|
+
heartbeat: 30.0
|
|
75
|
+
|
|
76
|
+
加载::
|
|
77
|
+
|
|
78
|
+
config = RuntimeConfig.from_yaml()
|
|
79
|
+
# 等价于:
|
|
80
|
+
# config = RuntimeConfig(
|
|
81
|
+
# bilibili=Credential(sessdata="", ...),
|
|
82
|
+
# napcat=NapcatConfig(url="ws://...", ...),
|
|
83
|
+
# )
|
|
84
|
+
"""
|
|
85
|
+
path = Path(path)
|
|
86
|
+
if not path.exists():
|
|
87
|
+
raise FileNotFoundError("配置文件不存在: %s" % path)
|
|
88
|
+
with open(path, encoding="utf-8") as f:
|
|
89
|
+
data = yaml.safe_load(f)
|
|
90
|
+
if not isinstance(data, dict):
|
|
91
|
+
raise ConfigError(
|
|
92
|
+
"配置文件格式错误: 顶层应为映射 (dict),实际得到 %s"
|
|
93
|
+
% type(data).__name__
|
|
94
|
+
)
|
|
95
|
+
configs: dict[str, Any] = {}
|
|
96
|
+
for key, value in data.items():
|
|
97
|
+
builder = _CONFIG_BUILDERS.get(key)
|
|
98
|
+
if builder is not None:
|
|
99
|
+
try:
|
|
100
|
+
configs[key] = builder(value)
|
|
101
|
+
except Exception as e:
|
|
102
|
+
raise ConfigError("配置项 '%s' 构建失败: %s" % (key, e)) from e
|
|
103
|
+
else:
|
|
104
|
+
configs[key] = value
|
|
105
|
+
return cls(**configs)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ==================== 配置对象构建器 ====================
|
|
109
|
+
|
|
110
|
+
_CONFIG_BUILDERS: dict[str, Any] = {}
|
|
111
|
+
"""配置键到构建函数的映射.
|
|
112
|
+
|
|
113
|
+
键是 YAML 配置文件中的顶层键名,值是对应的构建函数。
|
|
114
|
+
构建函数接收 YAML 中该键对应的值(dict),返回构造好的配置对象。
|
|
115
|
+
|
|
116
|
+
用户可以通过 :func:`register_builder` 注册自定义构建器。
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def register_builder(key: str, builder: Any) -> None:
|
|
121
|
+
"""注册自定义配置对象构建器.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
key: YAML 配置文件中的顶层键名.
|
|
125
|
+
builder: 构建函数,接收 dict 参数,返回配置对象实例.
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
为自定义 ``my_source`` 注册构建器::
|
|
129
|
+
|
|
130
|
+
def build_my_source(value: dict) -> MyConfig:
|
|
131
|
+
return MyConfig(**value)
|
|
132
|
+
|
|
133
|
+
register_builder("my_source", build_my_source)
|
|
134
|
+
|
|
135
|
+
config = RuntimeConfig.from_yaml("config.yaml")
|
|
136
|
+
"""
|
|
137
|
+
_CONFIG_BUILDERS[key] = builder
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- 内置构建器 ---
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _build_bilibili(value: dict) -> Any:
|
|
144
|
+
from bilibili_api import Credential
|
|
145
|
+
|
|
146
|
+
return Credential(**value)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _build_napcat(value: dict) -> Any:
|
|
150
|
+
from butterbot.sources.napcat.api.napcat_api import NapcatConfig
|
|
151
|
+
|
|
152
|
+
return NapcatConfig(**value)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
register_builder("bilibili", _build_bilibili)
|
|
156
|
+
register_builder("napcat", _build_napcat)
|