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 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)