nekomoe 0.1.0__tar.gz

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 (53) hide show
  1. nekomoe-0.1.0/.gitignore +7 -0
  2. nekomoe-0.1.0/CHANGELOG.md +52 -0
  3. nekomoe-0.1.0/LICENSE +21 -0
  4. nekomoe-0.1.0/PKG-INFO +338 -0
  5. nekomoe-0.1.0/README.md +309 -0
  6. nekomoe-0.1.0/RULES.md +113 -0
  7. nekomoe-0.1.0/docs/design.md +651 -0
  8. nekomoe-0.1.0/docs/logging.md +185 -0
  9. nekomoe-0.1.0/examples/__init__.py +0 -0
  10. nekomoe-0.1.0/examples/app.py +40 -0
  11. nekomoe-0.1.0/examples/config.example.py +51 -0
  12. nekomoe-0.1.0/examples/lifecycle.py +159 -0
  13. nekomoe-0.1.0/examples/main.py +217 -0
  14. nekomoe-0.1.0/examples/minimal.py +26 -0
  15. nekomoe-0.1.0/examples/plugins/__init__.py +11 -0
  16. nekomoe-0.1.0/examples/plugins/_draft.py +14 -0
  17. nekomoe-0.1.0/examples/plugins/admin.py +44 -0
  18. nekomoe-0.1.0/examples/plugins/daily.py +58 -0
  19. nekomoe-0.1.0/examples/plugins/dice.py +31 -0
  20. nekomoe-0.1.0/nekomoe/__init__.py +89 -0
  21. nekomoe-0.1.0/nekomoe/bot.py +172 -0
  22. nekomoe-0.1.0/nekomoe/bus.py +148 -0
  23. nekomoe-0.1.0/nekomoe/command.py +440 -0
  24. nekomoe-0.1.0/nekomoe/context.py +137 -0
  25. nekomoe-0.1.0/nekomoe/event.py +268 -0
  26. nekomoe-0.1.0/nekomoe/exceptions.py +139 -0
  27. nekomoe-0.1.0/nekomoe/loader.py +81 -0
  28. nekomoe-0.1.0/nekomoe/log.py +141 -0
  29. nekomoe-0.1.0/nekomoe/message.py +529 -0
  30. nekomoe-0.1.0/nekomoe/permission.py +277 -0
  31. nekomoe-0.1.0/nekomoe/py.typed +0 -0
  32. nekomoe-0.1.0/nekomoe/scheduler.py +113 -0
  33. nekomoe-0.1.0/nekomoe/session.py +406 -0
  34. nekomoe-0.1.0/nekomoe/tasks.py +100 -0
  35. nekomoe-0.1.0/nekomoe/tower.py +819 -0
  36. nekomoe-0.1.0/nekomoe/transport.py +373 -0
  37. nekomoe-0.1.0/pyproject.toml +80 -0
  38. nekomoe-0.1.0/tests/__init__.py +11 -0
  39. nekomoe-0.1.0/tests/helpers.py +309 -0
  40. nekomoe-0.1.0/tests/smoke_hypercorn.py +213 -0
  41. nekomoe-0.1.0/tests/smoke_uvicorn.py +239 -0
  42. nekomoe-0.1.0/tests/stress.py +434 -0
  43. nekomoe-0.1.0/tests/test_bot.py +187 -0
  44. nekomoe-0.1.0/tests/test_bus.py +212 -0
  45. nekomoe-0.1.0/tests/test_command.py +397 -0
  46. nekomoe-0.1.0/tests/test_event.py +177 -0
  47. nekomoe-0.1.0/tests/test_loader.py +104 -0
  48. nekomoe-0.1.0/tests/test_message.py +180 -0
  49. nekomoe-0.1.0/tests/test_scheduler.py +143 -0
  50. nekomoe-0.1.0/tests/test_session.py +470 -0
  51. nekomoe-0.1.0/tests/test_tasks.py +150 -0
  52. nekomoe-0.1.0/tests/test_tower.py +379 -0
  53. nekomoe-0.1.0/tests/test_transport.py +288 -0
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ logs/
4
+ examples/config.py
5
+ dist/
6
+ *.egg-info/
7
+ .venv/
@@ -0,0 +1,52 @@
1
+ # 更新日志
2
+
3
+ 本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
+
5
+ ## 0.1.0
6
+
7
+ 第一个版本。
8
+
9
+ ### 传输
10
+
11
+ - 反向 WebSocket 端点,一条连接一个 `WSChannel`,按 `(self_id, role)` 建表
12
+ - `Authorization: Bearer` 与 `?access_token=` 两种鉴权,按字节比较
13
+ - `universal` / `event` / `api` 三种 `X-Client-Role`,缺失按 `universal` 处理;
14
+ 只收事件的连接调 API 时自动转给同账号里能调的那条
15
+ - 断线时把还在等响应的调用全部中止,不让它们空等到超时
16
+
17
+ ### 派发
18
+
19
+ - 有界队列 + worker 池,队列满按 WARNING 丢弃并累计计数
20
+ - 处理函数一进入 `capture()` / `prompt()` 就交还 worker,长会话不占并发额度
21
+ - 事件名逐级派发(`message` → `message.group` → `message.group.normal`),
22
+ 最多三次字典查询,与注册了多少处理函数无关
23
+
24
+ ### 命令
25
+
26
+ - 前缀 + 名字 O(1) 查表,正则只在查表没命中时走
27
+ - 洋葱式中间件,权限检查在中间件之前
28
+ - `Args` 取参数:`get` / `int` / `rest`
29
+ - 可组合的权限谓词,`|` `&` `~` 三个运算符
30
+ - `Finish` 中止并回一句话
31
+
32
+ ### 会话
33
+
34
+ - `ctx.prompt()` 一问一答,`ctx.capture()` 多轮
35
+ - 作用域 `user` / `group`,滑动超时加总时长上限双重兜底
36
+ - `match=` 谓词决定哪些消息算答案,也用来放行问答期间的命令
37
+
38
+ ### 消息
39
+
40
+ - 20 种消息段构造器,与规范一一对应
41
+ - CQ 码字符串与数组格式互转,转义顺序按规范
42
+ - 相邻纯文本段自动合并,空文本段丢弃
43
+ - 发送时一律转成数组格式,不让实现端解析 CQ 码
44
+
45
+ ### 其他
46
+
47
+ - `load_plugins()` 扫包加载,出错默认抛出
48
+ - `Tasks` 后台任务,关服有宽限期
49
+ - APScheduler 接线,可选依赖
50
+ - `on_startup` / `on_shutdown` / `on_connect` / `on_disconnect` / `on_error` 钩子
51
+ - `app.route()` 注册 HTTP 路由,与 WebSocket 端点同一个端口
52
+ - `Tower.asgi` 是纯 ASGI 应用,任意 ASGI 服务器都能跑;`Tower.run()` 用 uvicorn
nekomoe-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nekomoe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
nekomoe-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,338 @@
1
+ Metadata-Version: 2.5
2
+ Name: nekomoe
3
+ Version: 0.1.0
4
+ Summary: OneBot v11 应用端框架
5
+ Project-URL: Homepage, https://code.nicemoe.cn
6
+ Author-email: nekomoe <255655@qq.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: asgi,bot,chatbot,onebot,onebot11,qq
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Communications :: Chat
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: loguru>=0.7
22
+ Requires-Dist: starlette>=1.0
23
+ Provides-Extra: scheduler
24
+ Requires-Dist: apscheduler<4,>=3.9; extra == 'scheduler'
25
+ Provides-Extra: uvicorn
26
+ Requires-Dist: uvicorn>=0.50; extra == 'uvicorn'
27
+ Requires-Dist: websockets>=13; extra == 'uvicorn'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # nekomoe
31
+
32
+ OneBot v11 应用端框架。
33
+
34
+ 严格按 [OneBot v11 规范](https://github.com/botuniverse/onebot-11) 实现,三方依赖只有
35
+ `starlette` 和 `loguru`。
36
+
37
+ > 术语:规范里的「OneBot」指**实现端**(NapCat / Lagrange / LLOneBot),本框架是
38
+ > **应用端** —— 消费事件、调用 API。所以应用类叫 `Tower`(塔台:所有连接向它汇报、
39
+ > 由它统一调度,它自己不飞);`OneBot` 这个词在本项目里**只**表示实现端,日志里的
40
+ > `[OneBot 15853655]` 就是它。
41
+
42
+ ## 安装
43
+
44
+ ```bash
45
+ pip install nekomoe[uvicorn]
46
+ ```
47
+
48
+ 核心只要 `starlette` 和 `loguru`。`[uvicorn]` 这个 extra 装的就是 uvicorn ——
49
+ 用 hypercorn / granian / daphne 的自己装那一个,把 `Tower.asgi` 交给它即可。
50
+
51
+ 需要 Python 3.11+。
52
+
53
+ ## 三分钟上手
54
+
55
+ ```python
56
+ # config.py —— 和代码分开,进 .gitignore
57
+ ACCESS_TOKEN = '' # 和实现端配的一致,留空表示不校验
58
+ SUPERUSERS = [10001000]
59
+ NICKNAMES = ['萌萌'] # 「萌萌,查询 xxx」也能触发,昵称会被剥掉
60
+ COMMAND_PREFIXES = ('/', '') # 带不带 / 都认
61
+ LOG_FILE = 'logs/{time:YYYY-MM-DD}.log'
62
+ HOST, PORT = '127.0.0.1', 8080
63
+ ```
64
+
65
+ ```python
66
+ # main.py
67
+ import config
68
+ from nekomoe import Tower, Finish, logger
69
+ from nekomoe.permission import SUPERUSER, GROUP_ADMIN
70
+
71
+ app = Tower.init(config) # 大写配置项自动映射,不认识的安静忽略
72
+
73
+ @app.command('查询', alias='查', cooldown=5)
74
+ async def query(ctx):
75
+ """查询角色信息。"""
76
+ if not ctx.args:
77
+ raise Finish('用法:查询 <区服> <角色名>')
78
+ return f"{ctx.args[0]} 的 {ctx.args.rest(1)}" # 返回非 None 自动发出去
79
+
80
+ @app.command('踢', permission=SUPERUSER | GROUP_ADMIN, denied='需要管理员权限')
81
+ async def kick(ctx):
82
+ await ctx.bot.set_group_kick(group_id=ctx.group_id, user_id=ctx.args.int(0))
83
+
84
+ @app.on('notice.group_increase')
85
+ async def welcome(ctx):
86
+ await ctx.send('欢迎进群~')
87
+
88
+ if __name__ == '__main__':
89
+ app.run()
90
+ ```
91
+
92
+ 实现端的反向 WebSocket 地址填 `ws://你的地址:端口/onebot/v11/ws`。
93
+
94
+ ## HTTP 接口
95
+
96
+ 反向 WebSocket 和业务 HTTP 接口跑在同一个进程、同一个端口:
97
+
98
+ ```python
99
+ from starlette.responses import JSONResponse
100
+
101
+ @app.route('/api/push', methods=['POST'])
102
+ async def push(request):
103
+ data = await request.json()
104
+ await app.get_bot().send_group_msg(group_id=data['group_id'],
105
+ message=data['message'])
106
+ return JSONResponse({'ok': True})
107
+ ```
108
+
109
+ `app.asgi` 是标准的 ASGI 应用,也可以挂载别的应用:
110
+
111
+ ```python
112
+ app.asgi.mount('/admin', fastapi_app)
113
+ ```
114
+
115
+ ## ASGI 服务器
116
+
117
+ 框架的产物是 `app.asgi`,一个纯 ASGI 应用——**服务器由你选**。
118
+
119
+ `app.run()` 是框架**替你选的那一个:uvicorn**。不想自己挑就用它,`host` / `port` 取
120
+ 配置里的值:
121
+
122
+ ```python
123
+ if __name__ == '__main__':
124
+ app.run()
125
+ ```
126
+
127
+ 用别家的话,在模块里留一个变量:
128
+
129
+ ```python
130
+ app = Tower.init(config)
131
+ asgi = app.asgi
132
+ ```
133
+
134
+ ```bash
135
+ uvicorn main:asgi --host 127.0.0.1 --port 8080
136
+ hypercorn main:asgi --bind 127.0.0.1:8080
137
+ granian --interface asgi --host 127.0.0.1 --port 8080 main:asgi
138
+ daphne -b 127.0.0.1 -p 8080 main:asgi
139
+ ```
140
+
141
+ 四家都别加 `--workers`,见下面「只能单进程」。日志不用改——框架接管了标准库 logging,
142
+ 四家的日志都会流进同一个格式。
143
+
144
+ ⚠ 不走 `app.run()` 时,`log_config=None` 得你自己带:uvicorn 会在启动时 `dictConfig`
145
+ 一把顶掉框架的日志配置,而它的命令行没有「别碰日志」的开关。这是 `app.run()` 唯一替你
146
+ 做的服务器决定。
147
+
148
+ `tests/smoke_uvicorn.py` 和 `tests/smoke_hypercorn.py` 是同一套流程在两个服务器上跑。
149
+
150
+ ## 定时任务
151
+
152
+ ```bash
153
+ pip install "nekomoe[scheduler]"
154
+ ```
155
+
156
+ ```python
157
+ from nekomoe.scheduler import Scheduler
158
+
159
+ scheduler = Scheduler(app) # 自动挂 startup / shutdown
160
+
161
+ @scheduler.scheduled_job('cron', hour=7, minute=15, misfire_grace_time=300,
162
+ id='daily-report')
163
+ async def daily_report():
164
+ await app.get_bot().send_group_msg(group_id=..., message='早上好')
165
+ ```
166
+
167
+ 调度器不是框架写的——cron 解析、时区、夏令时、错过补发都归 APScheduler。框架只管
168
+ 接线:什么时候 `start()`(必须在事件循环里)、什么时候 `shutdown()`(`wait=False`,
169
+ 一个卡住的 job 不该拖住关服)、没装时报一句人话。认不出的属性直接转给底层调度器,
170
+ 照 APScheduler 的文档写即可。
171
+
172
+ 只支持 APScheduler 3.x。
173
+
174
+ ## 多轮问答与会话
175
+
176
+ ```python
177
+ @app.command('绑定')
178
+ async def bind(ctx):
179
+ reply = await ctx.prompt('要绑定哪个角色?', timeout=60)
180
+ return f"已绑定 {reply.text}"
181
+ ```
182
+
183
+ 状态就是协程的局部变量,不需要全局的「谁在绑定」表。挂起的处理函数**不占 worker**,
184
+ 所以 2 个 worker 能同时撑住几千个进行中的会话。
185
+
186
+ **问答期间,这个人在这个上下文里说的任何话都算答案**——包括他发的命令。要放行就给
187
+ 一个谓词:
188
+
189
+ ```python
190
+ reply = await ctx.prompt('要绑定哪个角色?',
191
+ match=lambda e: not e.text.strip().startswith('/'))
192
+ ```
193
+
194
+ 以 `/` 开头的消息不算答案,落回命令解析正常执行,而问答继续等着。
195
+
196
+ 群级捕获用 `ctx.capture`,`scope='group'` 时**几乎总该给 `match`**,否则这一局期间整个
197
+ 群的消息都会被吃掉,别人的命令全失灵:
198
+
199
+ ```python
200
+ async with ctx.capture(scope='group',
201
+ match=lambda e: e.text.strip().isdigit(),
202
+ timeout=60, deadline=300) as stream:
203
+ async for event in stream:
204
+ ...
205
+ ```
206
+
207
+ `timeout` 是滑动超时(距上一条捕获到的消息多久没动静),`deadline` 是总时长上限。
208
+
209
+ ## 后台任务
210
+
211
+ ```python
212
+ from nekomoe.tasks import Tasks
213
+
214
+ tasks = Tasks(app) # 传应用进去自动挂关服钩子
215
+
216
+ @app.command('推送')
217
+ async def push(ctx):
218
+ tasks.create_task(broadcast_all(), name='推送')
219
+ return '已开始推送'
220
+ ```
221
+
222
+ `asyncio.create_task` 直接用有三个坑,每一个都是静默的:没人 await 的 task 抛异常时
223
+ 只在被 GC 时才**可能**留下一句不带上下文的警告;事件循环只持 task 的弱引用,不自己留
224
+ 一份强引用的话它可能跑到一半被回收;进程退出时正在发的推送直接断在半路。
225
+
226
+ `Tasks` 把这三件事收在一处:强引用登记、异常打成 ERROR、关服时先给 `grace` 秒收尾再
227
+ 硬取消。只依赖标准库,没有额外的包要装。
228
+
229
+ ## 扩展:继承 Tower
230
+
231
+ 框架不提供 `state` 之类的容器,扩展方式只有继承一种:
232
+
233
+ ```python
234
+ class MyBot(Tower):
235
+ db: Pool
236
+
237
+ app = MyBot.init(config)
238
+
239
+ @app.on_startup
240
+ async def _():
241
+ app.db = await create_pool(...)
242
+ ```
243
+
244
+ `Tower` 已占用这些名字,子类别撞上:`access_token` `ws_path` `api_timeout`
245
+ `queue_size` `worker_count` `host` `port` `superusers` `nicknames` `bus` `commands`
246
+ `roles` `dropped` `bots` `connections` `asgi` `init` `get_bot` `on` `command`
247
+ `middleware` `route` `invoke` `startup` `shutdown` `run` `attach` `detach`
248
+ `dispatch` `api_peer`,以及 `on_bot_connect` / `on_bot_disconnect` / `on_startup` /
249
+ `on_shutdown` / `on_error`。
250
+
251
+ ## 必须知道的几件事
252
+
253
+ **只能单进程。** 多 worker 会让反向 WebSocket 连接随机落到某个进程,连接表因此分裂
254
+ —— 定时任务在进程 A 里取不到连在进程 B 上的账号。`Tower.run()` 收到 `workers != 1`
255
+ 会直接报错;用别的 ASGI 服务器时这条挡不住(它们的多进程参数在命令行上),请自行注意。
256
+
257
+ **裸字符串一律当纯文本。** `f"欢迎 {昵称} 加入"` 不会因为有人把昵称改成
258
+ `[CQ:at,qq=all]` 就 @全体成员。要让实现端解析请显式 `Message.from_cq_code()`。
259
+
260
+ **只实现反向 WebSocket。** 规范定义的另外三种通信方式(HTTP、HTTP POST、正向
261
+ WebSocket)不做。
262
+
263
+ **这几件事由 ASGI 服务器决定,框架不作保证:**
264
+
265
+ | | |
266
+ | --- | --- |
267
+ | 握手被拒时的 HTTP 状态码 | uvicorn 和 hypercorn 都回 403,别家可能不同 |
268
+ | **收**事件的单条消息大小上限 | uvicorn 是 `--ws-max-size`,默认 16 MiB |
269
+ | WS 层 keepalive ping | 由服务器的 WebSocket 实现决定 |
270
+ | `bot.channel.close_reason` 里的关闭码 | 同样是客户端正常关闭,uvicorn 报 `1000`,hypercorn 报 `1006` |
271
+
272
+ **发**出去的消息还受另一个上限管:实现端是这条连接的 WebSocket **客户端**,上限由它
273
+ 定,我们控制不了。用 `websockets` 库的实现端默认只收 **1 MiB**——发一张 base64 大图
274
+ (1 MiB 原图转码后约 1.37 MiB)就会被对端以 `1009 message too big` 关掉连接。
275
+ 发大图请优先用 URL 或 `file://` 路径,让实现端自己去取。
276
+
277
+ 框架不依赖它们保证正确性:连接的生死以自己在 `try` / `finally` 里观察到的为准。
278
+
279
+ **日志只在 `Tower.init()` 或 `setup_logging()` 里配置。** `import nekomoe` 不装任何
280
+ sink。`from nekomoe import logger` 拿到的就是 loguru 的全局 logger 原样再导出,
281
+ `nekomoe.logger is loguru.logger` 为真;`logger.disable('nekomoe')` 让库彻底闭嘴。
282
+
283
+ ## 不做的事
284
+
285
+ 不做 OneBot v12 抽象层、不做插件热重载、不做依赖注入、不做自然语言意图路由。
286
+
287
+ 冷却、功能开关、黑名单、授权、使用统计都**不在库里** —— 它们要知道数据存在哪,属于
288
+ 使用者的产品形态。框架给的是 `@app.middleware` 挂载点和 `ctx.command.meta` 元数据透传。
289
+
290
+ 判据写在 [RULES.md](RULES.md) 的铁律四里:**使用者自己写会写错的才收,只是麻烦但写得
291
+ 对的不收。**
292
+
293
+ ## 开发
294
+
295
+ ```bash
296
+ python -m unittest discover -s tests -t .
297
+ ```
298
+
299
+ 不开端口、不起服务器,直接驱动 ASGI 接口。
300
+
301
+ ```bash
302
+ python -m tests.smoke_uvicorn # pip install "nekomoe[uvicorn]"
303
+ python -m tests.smoke_hypercorn # pip install hypercorn
304
+ ```
305
+
306
+ 同一套流程在两个 ASGI 服务器上跑:握手拒绝、命令、`Finish`、多轮问答、心跳、同端口
307
+ HTTP 路由、断线清理(uvicorn 那份还多测一帧 2 MiB 的大消息)。会占一个临时端口,
308
+ 所以不进 `unittest discover`。
309
+
310
+ ```bash
311
+ python -m tests.stress
312
+ ```
313
+
314
+ 压力测试。测的不是「跑得多快」——没有对照的吞吐数字没有意义——而是那些**会静默退化的
315
+ 设计承诺**:命令查表是不是 O(1)、挂起的会话占不占 worker、有界队列的丢弃数准不准、
316
+ 一轮混合负载之后会话表和托管 task 归不归零、断线时挂起的调用是不是立刻抛出。每条都是
317
+ 可判定的断言,有一条不成立就退出码非零。
318
+
319
+ 三个可跑的例子在 [examples/](examples):
320
+
321
+ | | |
322
+ | --- | --- |
323
+ | `python -m examples.minimal` | 最小可跑的机器人,七行 |
324
+ | `python -m examples.lifecycle` | 五个生命周期钩子:启动、关闭、连接、断开、出错上报 |
325
+ | `python -m examples.main` | 完整骨架:配置文件、权限、多轮问答、中间件、HTTP 接口、插件目录 |
326
+
327
+ 最后一个要先建配置:
328
+
329
+ ```bash
330
+ copy examples\config.example.py examples\config.py
331
+ ```
332
+
333
+ 四条铁律见 [RULES.md](RULES.md),每个决定的理由见 [docs/design.md](docs/design.md),
334
+ 日志正文的写法约定见 [docs/logging.md](docs/logging.md)。
335
+
336
+ ## 许可
337
+
338
+ MIT