moemoe 0.0.1__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.
- moemoe/__init__.py +60 -0
- moemoe/app.py +412 -0
- moemoe/bot.py +180 -0
- moemoe/command.py +199 -0
- moemoe/errors.py +41 -0
- moemoe/event.py +248 -0
- moemoe/loader.py +75 -0
- moemoe/message.py +205 -0
- moemoe/permission.py +62 -0
- moemoe/py.typed +0 -0
- moemoe/scheduler.py +110 -0
- moemoe/session.py +205 -0
- moemoe-0.0.1.dist-info/METADATA +378 -0
- moemoe-0.0.1.dist-info/RECORD +16 -0
- moemoe-0.0.1.dist-info/WHEEL +4 -0
- moemoe-0.0.1.dist-info/licenses/LICENSE +21 -0
moemoe/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""moemoe —— QQ 官方机器人的应用端框架,配合 robot 协议端。
|
|
2
|
+
|
|
3
|
+
QQ 官方 ←WSS→ robot(协议端)←反向 WS→ moemoe(应用端)
|
|
4
|
+
|
|
5
|
+
robot 主动拨号,所以 moemoe 是 WS 服务端。一个 Moemoe 同时接多个 robot,每个 robot
|
|
6
|
+
一个 `Bot` 实例;`Event.bot` 指回收到这条消息的那个。
|
|
7
|
+
|
|
8
|
+
app / bot 应用对象与已连接的机器人
|
|
9
|
+
event / message 事件与消息,纯数据
|
|
10
|
+
command / permission 分发
|
|
11
|
+
loader 扫包加载插件
|
|
12
|
+
errors 异常
|
|
13
|
+
|
|
14
|
+
三方依赖只有 starlette 和 loguru。`from moemoe import logger` 拿到的就是 loguru 的
|
|
15
|
+
全局 logger 原样再导出,`logger.disable('moemoe')` 让库彻底闭嘴。
|
|
16
|
+
|
|
17
|
+
设计理由见 docs/design.md,使用者要知道的行为和限制见 README.md。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from loguru import logger
|
|
21
|
+
|
|
22
|
+
from .app import MESSAGE_EVENTS, Failure, Moemoe
|
|
23
|
+
from .bot import Bot
|
|
24
|
+
from .command import Args, Command, Registry
|
|
25
|
+
from .errors import CallError, SessionTimeout, TooManySessions
|
|
26
|
+
from .event import Event
|
|
27
|
+
from .loader import load_plugins
|
|
28
|
+
from .message import At, Image, Keyboard, Markdown, Message, Segment, Text, coerce
|
|
29
|
+
from .session import Session, Sessions
|
|
30
|
+
from .permission import (
|
|
31
|
+
EVERYBODY,
|
|
32
|
+
GROUP,
|
|
33
|
+
GROUP_ADMIN,
|
|
34
|
+
GROUP_OWNER,
|
|
35
|
+
NOBODY,
|
|
36
|
+
PRIVATE,
|
|
37
|
+
SUPERUSER,
|
|
38
|
+
Permission,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__version__ = '0.0.1'
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
# 应用
|
|
45
|
+
'Moemoe', 'Bot', 'Event', 'Failure', 'load_plugins', 'MESSAGE_EVENTS',
|
|
46
|
+
# 命令
|
|
47
|
+
'Args', 'Command', 'Registry',
|
|
48
|
+
# 会话
|
|
49
|
+
'Session', 'Sessions',
|
|
50
|
+
# 消息
|
|
51
|
+
'Message', 'Segment', 'Text', 'At', 'Image', 'Markdown', 'Keyboard', 'coerce',
|
|
52
|
+
# 权限
|
|
53
|
+
'Permission', 'EVERYBODY', 'NOBODY', 'GROUP', 'PRIVATE',
|
|
54
|
+
'GROUP_ADMIN', 'GROUP_OWNER', 'SUPERUSER',
|
|
55
|
+
# 异常
|
|
56
|
+
'CallError', 'SessionTimeout', 'TooManySessions',
|
|
57
|
+
# 日志,loguru 的全局 logger 原样再导出
|
|
58
|
+
'logger',
|
|
59
|
+
'__version__',
|
|
60
|
+
]
|
moemoe/app.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Moemoe —— 应用对象:接连接、分发事件、跑命令。
|
|
2
|
+
|
|
3
|
+
robot 主动拨号,所以这边是 WS 服务端。一个 Moemoe 同时接多个 robot,每个一个 Bot。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from loguru import logger
|
|
15
|
+
from starlette.applications import Starlette
|
|
16
|
+
from starlette.routing import Route, WebSocketRoute
|
|
17
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
18
|
+
|
|
19
|
+
from .bot import Bot
|
|
20
|
+
from .command import Command, Handler as CommandHandler, Registry
|
|
21
|
+
from .errors import CallError
|
|
22
|
+
from .event import Event
|
|
23
|
+
from .session import Sessions
|
|
24
|
+
|
|
25
|
+
__all__ = ['Moemoe', 'Failure', 'MESSAGE_EVENTS']
|
|
26
|
+
|
|
27
|
+
Handler = Callable[..., Awaitable[Any]]
|
|
28
|
+
|
|
29
|
+
# 消息类事件。`on_message` 订的就是这三种,命令也只在这三种上跑
|
|
30
|
+
MESSAGE_EVENTS = (
|
|
31
|
+
'GROUP_AT_MESSAGE_CREATE',
|
|
32
|
+
'GROUP_MESSAGE_CREATE',
|
|
33
|
+
'C2C_MESSAGE_CREATE',
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Failure:
|
|
38
|
+
"""一次处理函数出错,交给 `on_error` 钩子
|
|
39
|
+
|
|
40
|
+
:ivar error: 抛出来的异常
|
|
41
|
+
:ivar handler: 出错的处理函数
|
|
42
|
+
:ivar args: 当时传给它的参数,消息类事件时第一个是 `Event`
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, error: BaseException, handler: Handler,
|
|
46
|
+
args: tuple[Any, ...]) -> None:
|
|
47
|
+
self.error = error
|
|
48
|
+
self.handler = handler
|
|
49
|
+
self.args = args
|
|
50
|
+
|
|
51
|
+
def __repr__(self) -> str:
|
|
52
|
+
name = getattr(self.handler, '__name__', repr(self.handler))
|
|
53
|
+
return f'<Failure {name} {type(self.error).__name__}>'
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def event(self) -> Event | None:
|
|
57
|
+
"""出错时手上那条事件,钩子不是事件触发的就是 None"""
|
|
58
|
+
first = self.args[0] if self.args else None
|
|
59
|
+
return first if isinstance(first, Event) else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Moemoe:
|
|
63
|
+
"""应用对象
|
|
64
|
+
|
|
65
|
+
:param access_token: 和 robot 配的一致,留空表示不校验
|
|
66
|
+
:param path: WebSocket 路径,robot 的 `backend.url` 指到这儿
|
|
67
|
+
:param timeout: 调用等回执多久算超时,秒
|
|
68
|
+
:param prefixes: 命令前缀,`('/', '')` 表示带不带斜杠都认
|
|
69
|
+
:param nicknames: 昵称触发,「萌萌,查天气」也能触发命令
|
|
70
|
+
:param superusers: 超级用户的 openid,`SUPERUSER` 谓词读它
|
|
71
|
+
:param sessions: 最多同时挂多少个会话
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, *, access_token: str = '', path: str = '/robot',
|
|
75
|
+
timeout: float = 30.0,
|
|
76
|
+
prefixes: Sequence[str] = ('/',),
|
|
77
|
+
nicknames: Sequence[str] = (),
|
|
78
|
+
superusers: Sequence[str] = (),
|
|
79
|
+
sessions: int = 1000) -> None:
|
|
80
|
+
self.access_token = access_token
|
|
81
|
+
self.timeout = timeout
|
|
82
|
+
self.superusers = tuple(superusers)
|
|
83
|
+
|
|
84
|
+
self.commands = Registry(prefixes, nicknames=nicknames)
|
|
85
|
+
self.bots: dict[str, Bot] = {}
|
|
86
|
+
self.sessions = Sessions(sessions)
|
|
87
|
+
|
|
88
|
+
self._handlers: list[tuple[str | None, Handler]] = []
|
|
89
|
+
self._connect: list[Handler] = []
|
|
90
|
+
self._disconnect: list[Handler] = []
|
|
91
|
+
self._startup: list[Handler] = []
|
|
92
|
+
self._shutdown: list[Handler] = []
|
|
93
|
+
self._errors: list[Handler] = []
|
|
94
|
+
# 强引用住在跑的任务,事件循环只持弱引用
|
|
95
|
+
self._tasks: set[asyncio.Task] = set()
|
|
96
|
+
|
|
97
|
+
self.asgi = Starlette(routes=[WebSocketRoute(path, self._serve)],
|
|
98
|
+
lifespan=self._lifespan)
|
|
99
|
+
|
|
100
|
+
def __repr__(self) -> str:
|
|
101
|
+
return f'<Moemoe {len(self.bots)} bots, {len(self.commands)} commands>'
|
|
102
|
+
|
|
103
|
+
def bot(self, id: str | None = None) -> Bot:
|
|
104
|
+
"""拿一个已连上的机器人
|
|
105
|
+
|
|
106
|
+
:param id: 机器人的 app_id,不给且只连了一个时返回那一个
|
|
107
|
+
:return: Bot
|
|
108
|
+
:raises CallError: 没有已连上的机器人,或者指名的那个不在
|
|
109
|
+
"""
|
|
110
|
+
if id is not None:
|
|
111
|
+
if found := self.bots.get(id):
|
|
112
|
+
return found
|
|
113
|
+
raise CallError(0, {'error': f'机器人 {id} 没连上'})
|
|
114
|
+
|
|
115
|
+
if len(self.bots) == 1:
|
|
116
|
+
return next(iter(self.bots.values()))
|
|
117
|
+
if not self.bots:
|
|
118
|
+
raise CallError(0, {'error': '还没有机器人连上来'})
|
|
119
|
+
raise CallError(0, {'error': f'连着 {len(self.bots)} 个机器人,要指名是哪个'})
|
|
120
|
+
|
|
121
|
+
# ── 注册 ──
|
|
122
|
+
|
|
123
|
+
def command(self, name: str, **kwargs: Any) -> Callable[[CommandHandler], CommandHandler]:
|
|
124
|
+
"""注册命令,处理函数签名 `(event, args)`,返回非 None 会当消息发出去
|
|
125
|
+
|
|
126
|
+
:param name: 命令名
|
|
127
|
+
:param kwargs: alias / patterns / permission / denied / to_me,见 `Command`
|
|
128
|
+
:return: 装饰器
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def wrap(func: CommandHandler) -> CommandHandler:
|
|
132
|
+
self.commands.add(Command(name, func, **kwargs))
|
|
133
|
+
return func
|
|
134
|
+
|
|
135
|
+
return wrap
|
|
136
|
+
|
|
137
|
+
def on_event(self, kind: str | None = None) -> Callable[[Handler], Handler]:
|
|
138
|
+
"""注册事件处理函数,签名 `(event)`
|
|
139
|
+
|
|
140
|
+
:param kind: 事件类型,不给表示全收
|
|
141
|
+
:return: 装饰器
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def wrap(func: Handler) -> Handler:
|
|
145
|
+
self._handlers.append((kind, func))
|
|
146
|
+
return func
|
|
147
|
+
|
|
148
|
+
return wrap
|
|
149
|
+
|
|
150
|
+
def on_message(self) -> Callable[[Handler], Handler]:
|
|
151
|
+
"""注册消息处理函数,签名 `(event)`,订 `MESSAGE_EVENTS` 那三种
|
|
152
|
+
|
|
153
|
+
:return: 装饰器
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def wrap(func: Handler) -> Handler:
|
|
157
|
+
for kind in MESSAGE_EVENTS:
|
|
158
|
+
self._handlers.append((kind, func))
|
|
159
|
+
return func
|
|
160
|
+
|
|
161
|
+
return wrap
|
|
162
|
+
|
|
163
|
+
def on_bot_connect(self, func: Handler) -> Handler:
|
|
164
|
+
"""注册连接建立钩子,签名 `(bot)`,钩子里可以调接口
|
|
165
|
+
|
|
166
|
+
:param func: 钩子
|
|
167
|
+
:return: 原样返回
|
|
168
|
+
"""
|
|
169
|
+
self._connect.append(func)
|
|
170
|
+
return func
|
|
171
|
+
|
|
172
|
+
def on_bot_disconnect(self, func: Handler) -> Handler:
|
|
173
|
+
"""注册连接断开钩子,签名 `(bot)`
|
|
174
|
+
|
|
175
|
+
:param func: 钩子
|
|
176
|
+
:return: 原样返回
|
|
177
|
+
"""
|
|
178
|
+
self._disconnect.append(func)
|
|
179
|
+
return func
|
|
180
|
+
|
|
181
|
+
def on_startup(self, func: Handler) -> Handler:
|
|
182
|
+
"""注册启动钩子,签名 `()`,在事件循环里跑
|
|
183
|
+
|
|
184
|
+
:param func: 钩子
|
|
185
|
+
:return: 原样返回
|
|
186
|
+
"""
|
|
187
|
+
self._startup.append(func)
|
|
188
|
+
return func
|
|
189
|
+
|
|
190
|
+
def on_shutdown(self, func: Handler) -> Handler:
|
|
191
|
+
"""注册关闭钩子,签名 `()`
|
|
192
|
+
|
|
193
|
+
:param func: 钩子
|
|
194
|
+
:return: 原样返回
|
|
195
|
+
"""
|
|
196
|
+
self._shutdown.append(func)
|
|
197
|
+
return func
|
|
198
|
+
|
|
199
|
+
def on_error(self, func: Handler) -> Handler:
|
|
200
|
+
"""注册出错钩子,签名 `(error)`,参数是 `Failure`
|
|
201
|
+
|
|
202
|
+
钩子自己抛异常只记一行,不再往上抛。
|
|
203
|
+
|
|
204
|
+
:param func: 钩子
|
|
205
|
+
:return: 原样返回
|
|
206
|
+
"""
|
|
207
|
+
self._errors.append(func)
|
|
208
|
+
return func
|
|
209
|
+
|
|
210
|
+
def route(self, path: str, methods: Sequence[str] = ('GET',),
|
|
211
|
+
**kwargs: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
212
|
+
"""注册一条 HTTP 路由,处理函数签名 `(request)`
|
|
213
|
+
|
|
214
|
+
挂在同一个 ASGI 应用上,跟 WebSocket 那条路共用端口。
|
|
215
|
+
|
|
216
|
+
:param path: 路径
|
|
217
|
+
:param methods: HTTP 方法
|
|
218
|
+
:param kwargs: 透传给 `starlette.routing.Route`
|
|
219
|
+
:return: 装饰器
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def wrap(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
223
|
+
self.asgi.routes.append(Route(path, func, methods=list(methods), **kwargs))
|
|
224
|
+
return func
|
|
225
|
+
|
|
226
|
+
return wrap
|
|
227
|
+
|
|
228
|
+
# ── 生命周期 ──
|
|
229
|
+
|
|
230
|
+
@asynccontextmanager
|
|
231
|
+
async def _lifespan(self, _app: Starlette) -> AsyncIterator[None]:
|
|
232
|
+
"""ASGI 的启动和关闭"""
|
|
233
|
+
await self._boot()
|
|
234
|
+
try:
|
|
235
|
+
yield
|
|
236
|
+
finally:
|
|
237
|
+
await self._halt()
|
|
238
|
+
|
|
239
|
+
async def _boot(self) -> None:
|
|
240
|
+
"""跑启动钩子"""
|
|
241
|
+
for hook in self._startup:
|
|
242
|
+
await self._guard(hook)
|
|
243
|
+
|
|
244
|
+
async def _halt(self) -> None:
|
|
245
|
+
"""关掉所有会话,跑关闭钩子"""
|
|
246
|
+
self.sessions.close()
|
|
247
|
+
for hook in self._shutdown:
|
|
248
|
+
await self._guard(hook)
|
|
249
|
+
|
|
250
|
+
# ── 连接 ──
|
|
251
|
+
|
|
252
|
+
async def _serve(self, ws: WebSocket) -> None:
|
|
253
|
+
"""一条 robot 连接的生命周期"""
|
|
254
|
+
token = (ws.headers.get('authorization') or '').removeprefix('Bearer ').strip()
|
|
255
|
+
if self.access_token and token != self.access_token:
|
|
256
|
+
logger.warning('access_token 不对,拒了来自 {} 的连接', ws.client)
|
|
257
|
+
await ws.close(code=1008)
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
id = ws.headers.get('x-bot-id') or ''
|
|
261
|
+
if not id:
|
|
262
|
+
logger.warning('连接没带 X-Bot-Id,拒了')
|
|
263
|
+
await ws.close(code=1008)
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
await ws.accept()
|
|
267
|
+
bot = Bot(id, ws, app=self, timeout=self.timeout)
|
|
268
|
+
if old := self.bots.get(id):
|
|
269
|
+
old.abort(f'机器人 {id} 换了一条新连接')
|
|
270
|
+
self.bots[id] = bot
|
|
271
|
+
logger.info('{} 连上了,现在连着 {} 个机器人', bot, len(self.bots))
|
|
272
|
+
|
|
273
|
+
for hook in self._connect:
|
|
274
|
+
self._spawn(hook, bot, bot=bot)
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
while True:
|
|
278
|
+
await self._handle(bot, json.loads(await ws.receive_text()))
|
|
279
|
+
except WebSocketDisconnect:
|
|
280
|
+
logger.warning('{} 断开', bot)
|
|
281
|
+
except Exception as error: # noqa: BLE001
|
|
282
|
+
logger.exception('{} 连接异常: {}', bot, error)
|
|
283
|
+
finally:
|
|
284
|
+
if self.bots.get(id) is bot:
|
|
285
|
+
del self.bots[id]
|
|
286
|
+
bot.abort('robot 断开了')
|
|
287
|
+
# 断开钩子不归这个机器人管 —— abort 刚把它的任务全取消了
|
|
288
|
+
for hook in self._disconnect:
|
|
289
|
+
self._spawn(hook, bot)
|
|
290
|
+
|
|
291
|
+
async def _handle(self, bot: Bot, frame: dict) -> None:
|
|
292
|
+
"""处理一帧"""
|
|
293
|
+
kind = frame.get('type')
|
|
294
|
+
|
|
295
|
+
if kind == 'result':
|
|
296
|
+
bot.settle(str(frame.get('echo') or ''), frame)
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
if kind == 'robot':
|
|
300
|
+
self._status(bot, frame)
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
if kind == 'event':
|
|
304
|
+
event = Event(frame.get('payload') or {}, bot)
|
|
305
|
+
|
|
306
|
+
# 会话优先。答案同时触发一条命令的话,多轮问答就没法用了
|
|
307
|
+
if event.type in MESSAGE_EVENTS and self.sessions.deliver(event):
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
if event.type in MESSAGE_EVENTS and len(self.commands):
|
|
311
|
+
self._spawn(self._dispatch, event, bot=bot)
|
|
312
|
+
for want, handler in self._handlers:
|
|
313
|
+
if want is None or want == event.type:
|
|
314
|
+
self._spawn(handler, event, bot=bot)
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
logger.debug('{} 发来不认识的帧: {}', bot, kind)
|
|
318
|
+
|
|
319
|
+
def _status(self, bot: Bot, frame: dict) -> None:
|
|
320
|
+
"""robot 自己的状态帧"""
|
|
321
|
+
data = frame.get('data') or {}
|
|
322
|
+
event = frame.get('event')
|
|
323
|
+
|
|
324
|
+
if event == 'ready':
|
|
325
|
+
bot.openid = data.get('openid') or ''
|
|
326
|
+
bot.name = data.get('name') or ''
|
|
327
|
+
logger.info('{} 就绪,openid={}', bot, bot.openid or '(robot 还没问到)')
|
|
328
|
+
elif event == 'gateway' and not data.get('up'):
|
|
329
|
+
logger.warning('{} 的 QQ 网关断了: {}', bot, data.get('reason', ''))
|
|
330
|
+
|
|
331
|
+
# ── 分发 ──
|
|
332
|
+
|
|
333
|
+
async def _dispatch(self, event: Event) -> None:
|
|
334
|
+
"""认出命令就跑,返回非 None 当消息发出去"""
|
|
335
|
+
found = self.commands.parse(event.text)
|
|
336
|
+
if found is None:
|
|
337
|
+
logger.debug('{} 认不出命令: {}', event.bot, event.text)
|
|
338
|
+
return
|
|
339
|
+
command, args = found
|
|
340
|
+
|
|
341
|
+
if command.to_me and event.is_group and not event.to_me:
|
|
342
|
+
logger.debug('{} 不是冲机器人来的,挡了 {}', event.user_openid, command.name)
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
if command.permission is not None and not command.permission(event, self.superusers):
|
|
346
|
+
logger.debug('{} 权限不够,挡了 {}', event.user_openid, command.name)
|
|
347
|
+
if command.denied:
|
|
348
|
+
await event.send(command.denied)
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
out = await command.handler(event, args)
|
|
352
|
+
if out is not None:
|
|
353
|
+
await event.send(out)
|
|
354
|
+
|
|
355
|
+
def _spawn(self, handler: Handler, *args: Any, bot: Bot | None = None) -> None:
|
|
356
|
+
"""起一个任务跑处理函数,异常在任务里就地记掉
|
|
357
|
+
|
|
358
|
+
所有处理函数走同一个错误边界:命令、事件处理函数、各种钩子。
|
|
359
|
+
|
|
360
|
+
给了 bot 就交给它管,那条连接断开时一起取消。
|
|
361
|
+
|
|
362
|
+
:param handler: 处理函数
|
|
363
|
+
:param args: 传给它的参数
|
|
364
|
+
:param bot: 这个处理函数属于哪个机器人
|
|
365
|
+
"""
|
|
366
|
+
task = asyncio.create_task(self._guard(handler, *args))
|
|
367
|
+
if bot is not None:
|
|
368
|
+
bot.hold(task)
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
# 不属于任何机器人的(启动、关闭钩子),应用自己留一份强引用
|
|
372
|
+
self._tasks.add(task)
|
|
373
|
+
task.add_done_callback(self._tasks.discard)
|
|
374
|
+
|
|
375
|
+
async def _guard(self, handler: Handler, *args: Any) -> None:
|
|
376
|
+
"""跑一个处理函数,出错只记不抛,并交给 on_error 钩子"""
|
|
377
|
+
name = getattr(handler, '__name__', repr(handler))
|
|
378
|
+
try:
|
|
379
|
+
await handler(*args)
|
|
380
|
+
except CallError as error:
|
|
381
|
+
logger.error('{} 调用失败: {}', name, error)
|
|
382
|
+
await self._report(Failure(error, handler, args))
|
|
383
|
+
except Exception as error: # noqa: BLE001
|
|
384
|
+
logger.exception('{} 出错: {}', name, error)
|
|
385
|
+
await self._report(Failure(error, handler, args))
|
|
386
|
+
|
|
387
|
+
async def _report(self, failure: Failure) -> None:
|
|
388
|
+
"""把一次出错交给 on_error 钩子,钩子自己抛了只记一行"""
|
|
389
|
+
for hook in self._errors:
|
|
390
|
+
try:
|
|
391
|
+
await hook(failure)
|
|
392
|
+
except Exception as error: # noqa: BLE001
|
|
393
|
+
logger.exception('on_error 钩子自己出错了: {}', error)
|
|
394
|
+
|
|
395
|
+
# ── 跑起来 ──
|
|
396
|
+
|
|
397
|
+
def run(self, host: str = '127.0.0.1', port: int = 8000, **kwargs: Any) -> None:
|
|
398
|
+
"""起服务等 robot 连进来
|
|
399
|
+
|
|
400
|
+
挂进已有的 ASGI 应用就别用这个,把 `asgi` 交给你的服务器。
|
|
401
|
+
|
|
402
|
+
:param host: 监听地址
|
|
403
|
+
:param port: 监听端口
|
|
404
|
+
:param kwargs: 透传给 uvicorn
|
|
405
|
+
"""
|
|
406
|
+
try:
|
|
407
|
+
import uvicorn
|
|
408
|
+
except ImportError:
|
|
409
|
+
raise RuntimeError('没装 uvicorn:pip install moemoe[uvicorn],'
|
|
410
|
+
'或者把 Moemoe.asgi 交给你自己的 ASGI 服务器') from None
|
|
411
|
+
|
|
412
|
+
uvicorn.run(self.asgi, host=host, port=port, **kwargs)
|
moemoe/bot.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Bot —— 一个已连上的 robot,代表一个 QQ 机器人。
|
|
2
|
+
|
|
3
|
+
一个 Moemoe 同时接多个 robot,每个 robot 一个 Bot 实例。发消息、调接口都经过这里,
|
|
4
|
+
所以走的一定是这个机器人自己那条连接。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
from .errors import CallError
|
|
17
|
+
from .message import Message, Segment, coerce
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from starlette.websockets import WebSocket
|
|
21
|
+
|
|
22
|
+
__all__ = ['Bot']
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Bot:
|
|
26
|
+
"""一个已连上的 robot
|
|
27
|
+
|
|
28
|
+
:param id: 机器人的 app_id,来自连接时的 `X-Bot-Id` 头
|
|
29
|
+
:param ws: 这个机器人的 WebSocket 连接
|
|
30
|
+
:param app: 收留它的应用对象
|
|
31
|
+
:param timeout: 等回执多久算超时,秒
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, id: str, ws: WebSocket, *, app: Any = None,
|
|
35
|
+
timeout: float = 30.0) -> None:
|
|
36
|
+
self.id = id
|
|
37
|
+
self.app = app
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
# 机器人自己的 openid 和显示名,robot 的 ready 帧带过来
|
|
40
|
+
self.openid: str = ''
|
|
41
|
+
self.name: str = ''
|
|
42
|
+
|
|
43
|
+
self._ws = ws
|
|
44
|
+
self._waiting: dict[str, asyncio.Future] = {}
|
|
45
|
+
self._echo = 0
|
|
46
|
+
# 这个机器人的处理函数。断开时一起取消,而且事件循环只持弱引用,
|
|
47
|
+
# 不自己留一份的话任务可能跑到一半被回收
|
|
48
|
+
self._tasks: set[asyncio.Task] = set()
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
return f'<Bot {self.name or self.id}>'
|
|
52
|
+
|
|
53
|
+
async def call(self, method: str, path: str, body: dict | None = None,
|
|
54
|
+
*, borrow: bool = False) -> dict:
|
|
55
|
+
"""调一次官方接口
|
|
56
|
+
|
|
57
|
+
:param method: HTTP 方法
|
|
58
|
+
:param path: 接口路径,照腾讯的文档写。`/robot/` 开头的是 robot 自己的接口
|
|
59
|
+
:param body: 请求体,字段照腾讯的文档写
|
|
60
|
+
:param borrow: 让 robot 补一条这个会话还没过期的被动回复凭据
|
|
61
|
+
:return: QQ 的响应体
|
|
62
|
+
:raises CallError: 连接不在、超时,或者 QQ 回了 4xx / 5xx
|
|
63
|
+
"""
|
|
64
|
+
self._echo += 1
|
|
65
|
+
echo = str(self._echo)
|
|
66
|
+
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
67
|
+
self._waiting[echo] = future
|
|
68
|
+
|
|
69
|
+
frame: dict[str, Any] = {'type': 'call', 'echo': echo,
|
|
70
|
+
'method': method, 'path': path}
|
|
71
|
+
if body is not None:
|
|
72
|
+
frame['body'] = body
|
|
73
|
+
if borrow:
|
|
74
|
+
frame['passive'] = 'auto'
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
await self._ws.send_text(json.dumps(frame, ensure_ascii=False))
|
|
78
|
+
result = await asyncio.wait_for(future, self.timeout)
|
|
79
|
+
except asyncio.TimeoutError:
|
|
80
|
+
raise CallError(0, {'error': f'{method} {path} 等回执超过 {self.timeout} 秒'}) from None
|
|
81
|
+
finally:
|
|
82
|
+
self._waiting.pop(echo, None)
|
|
83
|
+
|
|
84
|
+
status = result.get('status') or 0
|
|
85
|
+
payload = result.get('body')
|
|
86
|
+
if status < 200 or status >= 300:
|
|
87
|
+
raise CallError(status, payload, result.get('trace') or '')
|
|
88
|
+
return payload if isinstance(payload, dict) else {}
|
|
89
|
+
|
|
90
|
+
async def upload(self, kind: str, target: str, url: str) -> str:
|
|
91
|
+
"""把一个公网图片交给 QQ 转存
|
|
92
|
+
|
|
93
|
+
:param kind: `groups` 或 `users`
|
|
94
|
+
:param target: 群 openid 或单聊对方的 openid
|
|
95
|
+
:param url: 公网可访问的图片地址
|
|
96
|
+
:return: 发消息要用的 `file_info`
|
|
97
|
+
"""
|
|
98
|
+
out = await self.call('POST', f'/v2/{kind}/{target}/files',
|
|
99
|
+
{'file_type': 1, 'url': url, 'srv_send_msg': False})
|
|
100
|
+
return out.get('file_info') or ''
|
|
101
|
+
|
|
102
|
+
async def deliver(self, kind: str, target: str,
|
|
103
|
+
message: Message | Segment | str | dict,
|
|
104
|
+
extra: dict[str, Any],
|
|
105
|
+
next_seq: Callable[[], int] | None,
|
|
106
|
+
*, borrow: bool = False) -> list[dict]:
|
|
107
|
+
"""把一条消息发出去,必要时拆成几条
|
|
108
|
+
|
|
109
|
+
:param kind: `groups` 或 `users`
|
|
110
|
+
:param target: 群 openid 或单聊对方的 openid
|
|
111
|
+
:param message: 消息段、字符串,或者当请求体用的 dict
|
|
112
|
+
:param extra: 补进每个请求体的字段,被动回复凭据走这儿
|
|
113
|
+
:param next_seq: 每条消息的 `msg_seq`,主动消息传 None
|
|
114
|
+
:param borrow: 让 robot 补一条还没过期的凭据
|
|
115
|
+
:return: 每条消息的响应体
|
|
116
|
+
"""
|
|
117
|
+
built = coerce(message)
|
|
118
|
+
bodies = [built] if isinstance(built, dict) else await built.build(self, kind, target)
|
|
119
|
+
|
|
120
|
+
sent = []
|
|
121
|
+
for body in bodies:
|
|
122
|
+
payload = {**body, **extra}
|
|
123
|
+
if next_seq is not None:
|
|
124
|
+
payload['msg_seq'] = next_seq()
|
|
125
|
+
sent.append(await self.call('POST', f'/v2/{kind}/{target}/messages',
|
|
126
|
+
payload, borrow=borrow))
|
|
127
|
+
return sent
|
|
128
|
+
|
|
129
|
+
async def push(self, target: str, message: Message | Segment | str | dict,
|
|
130
|
+
*, private: bool = False, borrow: bool = False) -> list[dict]:
|
|
131
|
+
"""主动推送到某个会话
|
|
132
|
+
|
|
133
|
+
不带 `msg_id`,也不带 `message_reference`。
|
|
134
|
+
|
|
135
|
+
:param target: 群 openid,或单聊对方的 openid
|
|
136
|
+
:param message: 消息段、字符串,或者当请求体用的 dict
|
|
137
|
+
:param private: 发单聊
|
|
138
|
+
:param borrow: 让 robot 找一条这个会话还没过期的凭据,找到就免费
|
|
139
|
+
:return: 每条消息的响应体
|
|
140
|
+
"""
|
|
141
|
+
kind = 'users' if private else 'groups'
|
|
142
|
+
return await self.deliver(kind, target, message, {}, None, borrow=borrow)
|
|
143
|
+
|
|
144
|
+
def settle(self, echo: str, result: dict) -> None:
|
|
145
|
+
"""回执到了,唤醒等它的那次调用
|
|
146
|
+
|
|
147
|
+
:param echo: 回执带回来的 echo
|
|
148
|
+
:param result: 整个 result 帧
|
|
149
|
+
"""
|
|
150
|
+
if future := self._waiting.pop(echo, None):
|
|
151
|
+
if not future.done():
|
|
152
|
+
future.set_result(result)
|
|
153
|
+
|
|
154
|
+
def hold(self, task: asyncio.Task) -> None:
|
|
155
|
+
"""把一个处理函数交给这个机器人管
|
|
156
|
+
|
|
157
|
+
:param task: 任务
|
|
158
|
+
"""
|
|
159
|
+
self._tasks.add(task)
|
|
160
|
+
task.add_done_callback(self._tasks.discard)
|
|
161
|
+
|
|
162
|
+
def abort(self, reason: str) -> None:
|
|
163
|
+
"""连接没了:挂着的调用立刻失败,还在跑的处理函数取消掉
|
|
164
|
+
|
|
165
|
+
处理函数拿着的是一条已经死了的连接,接着跑只会往空处撞。
|
|
166
|
+
|
|
167
|
+
:param reason: 失败原因
|
|
168
|
+
"""
|
|
169
|
+
waiting, running = len(self._waiting), len(self._tasks)
|
|
170
|
+
|
|
171
|
+
for future in self._waiting.values():
|
|
172
|
+
if not future.done():
|
|
173
|
+
future.set_exception(CallError(0, {'error': reason}))
|
|
174
|
+
self._waiting.clear()
|
|
175
|
+
|
|
176
|
+
for task in list(self._tasks):
|
|
177
|
+
task.cancel()
|
|
178
|
+
|
|
179
|
+
if waiting or running:
|
|
180
|
+
logger.debug('{} 中止了 {} 次挂起调用、{} 个处理函数', self, waiting, running)
|