simplex-chat 6.5.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.
- simplex_chat/__init__.py +59 -0
- simplex_chat/__main__.py +35 -0
- simplex_chat/_native.py +257 -0
- simplex_chat/_version.py +9 -0
- simplex_chat/api.py +704 -0
- simplex_chat/bot.py +707 -0
- simplex_chat/core.py +200 -0
- simplex_chat/filters.py +45 -0
- simplex_chat/py.typed +0 -0
- simplex_chat/types/__init__.py +16 -0
- simplex_chat/types/_commands.py +705 -0
- simplex_chat/types/_events.py +379 -0
- simplex_chat/types/_responses.py +360 -0
- simplex_chat/types/_types.py +3506 -0
- simplex_chat/util.py +128 -0
- simplex_chat-6.5.1.dist-info/METADATA +98 -0
- simplex_chat-6.5.1.dist-info/RECORD +19 -0
- simplex_chat-6.5.1.dist-info/WHEEL +4 -0
- simplex_chat-6.5.1.dist-info/licenses/LICENSE +661 -0
simplex_chat/bot.py
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
"""User-facing `Bot` API: decorators, filters, Message wrapper, lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import signal as _signal
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Generic, Literal, TypeVar, overload
|
|
12
|
+
|
|
13
|
+
from . import util
|
|
14
|
+
from .api import ChatApi, Db
|
|
15
|
+
from .core import MigrationConfirmation
|
|
16
|
+
from .filters import compile_message_filter
|
|
17
|
+
from .types import CEvt, T
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("simplex_chat")
|
|
20
|
+
|
|
21
|
+
C = TypeVar("C", bound="T.MsgContent")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class BotProfile:
|
|
26
|
+
display_name: str
|
|
27
|
+
full_name: str = ""
|
|
28
|
+
short_descr: str | None = None
|
|
29
|
+
image: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class BotCommand:
|
|
34
|
+
keyword: str
|
|
35
|
+
label: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True, frozen=True)
|
|
39
|
+
class ParsedCommand:
|
|
40
|
+
keyword: str
|
|
41
|
+
args: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True, frozen=True)
|
|
45
|
+
class Message(Generic[C]):
|
|
46
|
+
chat_item: T.AChatItem
|
|
47
|
+
content: C
|
|
48
|
+
bot: "Bot"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def chat_info(self) -> T.ChatInfo:
|
|
52
|
+
return self.chat_item["chatInfo"]
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def text(self) -> str | None:
|
|
56
|
+
c = self.content
|
|
57
|
+
if isinstance(c, dict):
|
|
58
|
+
return c.get("text") # type: ignore[return-value]
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
async def reply(self, text: str) -> "Message[T.MsgContent]":
|
|
62
|
+
items = await self.bot.api.api_send_text_reply(self.chat_item, text)
|
|
63
|
+
ci = items[0]
|
|
64
|
+
content = ci["chatItem"]["content"]
|
|
65
|
+
# content is CIContent — snd variant has msgContent; cast for type safety.
|
|
66
|
+
msg_content: T.MsgContent = content["msgContent"] # type: ignore[index]
|
|
67
|
+
return Message(chat_item=ci, content=msg_content, bot=self.bot)
|
|
68
|
+
|
|
69
|
+
async def reply_content(self, content: T.MsgContent) -> "Message[T.MsgContent]":
|
|
70
|
+
items = await self.bot.api.api_send_messages(
|
|
71
|
+
self.chat_info, [{"msgContent": content, "mentions": {}}]
|
|
72
|
+
)
|
|
73
|
+
ci = items[0]
|
|
74
|
+
ci_content = ci["chatItem"]["content"]
|
|
75
|
+
msg_content: T.MsgContent = ci_content["msgContent"] # type: ignore[index]
|
|
76
|
+
return Message(chat_item=ci, content=msg_content, bot=self.bot)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Concrete narrowed aliases — one per MsgContent_<tag> variant in _types.py.
|
|
80
|
+
TextMessage = Message[T.MsgContent_text]
|
|
81
|
+
LinkMessage = Message[T.MsgContent_link]
|
|
82
|
+
ImageMessage = Message[T.MsgContent_image]
|
|
83
|
+
VideoMessage = Message[T.MsgContent_video]
|
|
84
|
+
VoiceMessage = Message[T.MsgContent_voice]
|
|
85
|
+
FileMessage = Message[T.MsgContent_file]
|
|
86
|
+
ReportMessage = Message[T.MsgContent_report]
|
|
87
|
+
ChatMessage = Message[T.MsgContent_chat]
|
|
88
|
+
UnknownMessage = Message[T.MsgContent_unknown]
|
|
89
|
+
|
|
90
|
+
MessageHandler = Callable[[Message[Any]], Awaitable[None]]
|
|
91
|
+
CommandHandler = Callable[[Message[Any], ParsedCommand], Awaitable[None]]
|
|
92
|
+
EventHandler = Callable[[CEvt.ChatEvent], Awaitable[None]]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Middleware:
|
|
96
|
+
"""Override `__call__` to wrap message handlers with cross-cutting logic.
|
|
97
|
+
|
|
98
|
+
`handler` is the next stage in the chain — call it with `(message, data)`
|
|
99
|
+
to continue, or skip the call to short-circuit. `data` is a per-dispatch
|
|
100
|
+
dict that middleware can use to pass values down the chain.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
async def __call__(
|
|
104
|
+
self,
|
|
105
|
+
handler: Callable[[Message[Any], dict[str, object]], Awaitable[None]],
|
|
106
|
+
message: Message[Any],
|
|
107
|
+
data: dict[str, object],
|
|
108
|
+
) -> None:
|
|
109
|
+
await handler(message, data)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Bot:
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
*,
|
|
116
|
+
profile: BotProfile,
|
|
117
|
+
db: Db,
|
|
118
|
+
welcome: str | T.MsgContent | None = None,
|
|
119
|
+
commands: list[BotCommand] | None = None,
|
|
120
|
+
confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP,
|
|
121
|
+
create_address: bool = True,
|
|
122
|
+
update_address: bool = True,
|
|
123
|
+
update_profile: bool = True,
|
|
124
|
+
auto_accept: bool = True,
|
|
125
|
+
business_address: bool = False,
|
|
126
|
+
allow_files: bool = False,
|
|
127
|
+
use_bot_profile: bool = True,
|
|
128
|
+
log_contacts: bool = True,
|
|
129
|
+
log_network: bool = False,
|
|
130
|
+
) -> None:
|
|
131
|
+
self._profile = profile
|
|
132
|
+
self._db = db
|
|
133
|
+
self._welcome = welcome
|
|
134
|
+
self._commands = commands or []
|
|
135
|
+
self._confirm_migrations = confirm_migrations
|
|
136
|
+
self._opts = {
|
|
137
|
+
"create_address": create_address,
|
|
138
|
+
"update_address": update_address,
|
|
139
|
+
"update_profile": update_profile,
|
|
140
|
+
"auto_accept": auto_accept,
|
|
141
|
+
"business_address": business_address,
|
|
142
|
+
"allow_files": allow_files,
|
|
143
|
+
"use_bot_profile": use_bot_profile,
|
|
144
|
+
"log_contacts": log_contacts,
|
|
145
|
+
"log_network": log_network,
|
|
146
|
+
}
|
|
147
|
+
self._api: ChatApi | None = None
|
|
148
|
+
self._serving = False
|
|
149
|
+
self._stop_event = asyncio.Event()
|
|
150
|
+
self._message_handlers: list[tuple[Callable[[Message[Any]], bool], MessageHandler]] = []
|
|
151
|
+
self._command_handlers: list[
|
|
152
|
+
tuple[tuple[str, ...], Callable[[Message[Any]], bool], CommandHandler]
|
|
153
|
+
] = []
|
|
154
|
+
self._event_handlers: dict[str, list[EventHandler]] = {}
|
|
155
|
+
self._middleware: list[Middleware] = []
|
|
156
|
+
# Track default-handler registration so __aenter__ on a re-used bot
|
|
157
|
+
# doesn't accumulate duplicate log/error handlers.
|
|
158
|
+
self._defaults_registered = False
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def api(self) -> ChatApi:
|
|
162
|
+
if self._api is None:
|
|
163
|
+
raise RuntimeError("Bot not initialized — call bot.run() or use `async with bot:`")
|
|
164
|
+
return self._api
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------ #
|
|
167
|
+
# Decorators
|
|
168
|
+
# ------------------------------------------------------------------ #
|
|
169
|
+
|
|
170
|
+
@overload
|
|
171
|
+
def on_message(
|
|
172
|
+
self, *, content_type: Literal["text"], **rest: Any
|
|
173
|
+
) -> Callable[
|
|
174
|
+
[Callable[[TextMessage], Awaitable[None]]],
|
|
175
|
+
Callable[[TextMessage], Awaitable[None]],
|
|
176
|
+
]: ...
|
|
177
|
+
|
|
178
|
+
@overload
|
|
179
|
+
def on_message(
|
|
180
|
+
self, *, content_type: Literal["link"], **rest: Any
|
|
181
|
+
) -> Callable[
|
|
182
|
+
[Callable[[LinkMessage], Awaitable[None]]],
|
|
183
|
+
Callable[[LinkMessage], Awaitable[None]],
|
|
184
|
+
]: ...
|
|
185
|
+
|
|
186
|
+
@overload
|
|
187
|
+
def on_message(
|
|
188
|
+
self, *, content_type: Literal["image"], **rest: Any
|
|
189
|
+
) -> Callable[
|
|
190
|
+
[Callable[[ImageMessage], Awaitable[None]]],
|
|
191
|
+
Callable[[ImageMessage], Awaitable[None]],
|
|
192
|
+
]: ...
|
|
193
|
+
|
|
194
|
+
@overload
|
|
195
|
+
def on_message(
|
|
196
|
+
self, *, content_type: Literal["video"], **rest: Any
|
|
197
|
+
) -> Callable[
|
|
198
|
+
[Callable[[VideoMessage], Awaitable[None]]],
|
|
199
|
+
Callable[[VideoMessage], Awaitable[None]],
|
|
200
|
+
]: ...
|
|
201
|
+
|
|
202
|
+
@overload
|
|
203
|
+
def on_message(
|
|
204
|
+
self, *, content_type: Literal["voice"], **rest: Any
|
|
205
|
+
) -> Callable[
|
|
206
|
+
[Callable[[VoiceMessage], Awaitable[None]]],
|
|
207
|
+
Callable[[VoiceMessage], Awaitable[None]],
|
|
208
|
+
]: ...
|
|
209
|
+
|
|
210
|
+
@overload
|
|
211
|
+
def on_message(
|
|
212
|
+
self, *, content_type: Literal["file"], **rest: Any
|
|
213
|
+
) -> Callable[
|
|
214
|
+
[Callable[[FileMessage], Awaitable[None]]],
|
|
215
|
+
Callable[[FileMessage], Awaitable[None]],
|
|
216
|
+
]: ...
|
|
217
|
+
|
|
218
|
+
@overload
|
|
219
|
+
def on_message(
|
|
220
|
+
self, *, content_type: Literal["report"], **rest: Any
|
|
221
|
+
) -> Callable[
|
|
222
|
+
[Callable[[ReportMessage], Awaitable[None]]],
|
|
223
|
+
Callable[[ReportMessage], Awaitable[None]],
|
|
224
|
+
]: ...
|
|
225
|
+
|
|
226
|
+
@overload
|
|
227
|
+
def on_message(
|
|
228
|
+
self, *, content_type: Literal["chat"], **rest: Any
|
|
229
|
+
) -> Callable[
|
|
230
|
+
[Callable[[ChatMessage], Awaitable[None]]],
|
|
231
|
+
Callable[[ChatMessage], Awaitable[None]],
|
|
232
|
+
]: ...
|
|
233
|
+
|
|
234
|
+
@overload
|
|
235
|
+
def on_message(
|
|
236
|
+
self, *, content_type: Literal["unknown"], **rest: Any
|
|
237
|
+
) -> Callable[
|
|
238
|
+
[Callable[[UnknownMessage], Awaitable[None]]],
|
|
239
|
+
Callable[[UnknownMessage], Awaitable[None]],
|
|
240
|
+
]: ...
|
|
241
|
+
|
|
242
|
+
@overload
|
|
243
|
+
def on_message(self, **rest: Any) -> Callable[[MessageHandler], MessageHandler]: ...
|
|
244
|
+
|
|
245
|
+
def on_message(self, **filter_kw: Any) -> Callable[[MessageHandler], MessageHandler]:
|
|
246
|
+
predicate = compile_message_filter(filter_kw)
|
|
247
|
+
|
|
248
|
+
def deco(fn: MessageHandler) -> MessageHandler:
|
|
249
|
+
self._message_handlers.append((predicate, fn))
|
|
250
|
+
return fn
|
|
251
|
+
|
|
252
|
+
return deco
|
|
253
|
+
|
|
254
|
+
def on_command(
|
|
255
|
+
self, name: str | tuple[str, ...], **filter_kw: Any
|
|
256
|
+
) -> Callable[[CommandHandler], CommandHandler]:
|
|
257
|
+
names = (name,) if isinstance(name, str) else tuple(name)
|
|
258
|
+
predicate = compile_message_filter(filter_kw)
|
|
259
|
+
|
|
260
|
+
def deco(fn: CommandHandler) -> CommandHandler:
|
|
261
|
+
self._command_handlers.append((names, predicate, fn))
|
|
262
|
+
return fn
|
|
263
|
+
|
|
264
|
+
return deco
|
|
265
|
+
|
|
266
|
+
def on_event(self, event: CEvt.ChatEvent_Tag, /) -> Callable[[EventHandler], EventHandler]:
|
|
267
|
+
def deco(fn: EventHandler) -> EventHandler:
|
|
268
|
+
self._event_handlers.setdefault(event, []).append(fn)
|
|
269
|
+
return fn
|
|
270
|
+
|
|
271
|
+
return deco
|
|
272
|
+
|
|
273
|
+
def use(self, middleware: Middleware) -> None:
|
|
274
|
+
self._middleware.append(middleware)
|
|
275
|
+
|
|
276
|
+
# ------------------------------------------------------------------ #
|
|
277
|
+
# Lifecycle
|
|
278
|
+
# ------------------------------------------------------------------ #
|
|
279
|
+
|
|
280
|
+
async def __aenter__(self) -> "Bot":
|
|
281
|
+
# Order matters: libsimplex `/_start` requires an active user, so
|
|
282
|
+
# ensure (or create) the user first, THEN start the chat, THEN
|
|
283
|
+
# do address + profile sync. Mirrors Node bot.ts:48-64.
|
|
284
|
+
self._api = await ChatApi.init(self._db, self._confirm_migrations)
|
|
285
|
+
user = await self._ensure_active_user()
|
|
286
|
+
await self._api.start_chat()
|
|
287
|
+
await self._sync_address_and_profile(user)
|
|
288
|
+
self._register_log_handlers()
|
|
289
|
+
return self
|
|
290
|
+
|
|
291
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
292
|
+
self.stop()
|
|
293
|
+
if self._api is not None:
|
|
294
|
+
try:
|
|
295
|
+
await self._api.stop_chat()
|
|
296
|
+
finally:
|
|
297
|
+
await self._api.close()
|
|
298
|
+
self._api = None
|
|
299
|
+
|
|
300
|
+
def run(self) -> None:
|
|
301
|
+
"""Blocking entry: runs serve_forever() with SIGINT/SIGTERM handlers installed.
|
|
302
|
+
|
|
303
|
+
Configures `logging.basicConfig(level=INFO)` if the root logger has no
|
|
304
|
+
handlers yet, so the bot's startup messages and the announced address
|
|
305
|
+
are visible without callers having to set up logging. Embedders that
|
|
306
|
+
manage logging themselves are unaffected (basicConfig is a no-op when
|
|
307
|
+
handlers already exist).
|
|
308
|
+
"""
|
|
309
|
+
if not logging.getLogger().handlers:
|
|
310
|
+
logging.basicConfig(
|
|
311
|
+
level=logging.INFO,
|
|
312
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
async def _main() -> None:
|
|
316
|
+
async with self:
|
|
317
|
+
loop = asyncio.get_running_loop()
|
|
318
|
+
# First Ctrl+C → graceful stop (~500ms, bounded by the
|
|
319
|
+
# receive-loop poll interval). Second Ctrl+C → force-exit
|
|
320
|
+
# immediately (in case stop_chat / close hang on a wedged
|
|
321
|
+
# FFI call). Standard CLI UX (jupyter, ipython, …).
|
|
322
|
+
sigint_count = 0
|
|
323
|
+
|
|
324
|
+
def on_interrupt() -> None:
|
|
325
|
+
nonlocal sigint_count
|
|
326
|
+
sigint_count += 1
|
|
327
|
+
if sigint_count == 1:
|
|
328
|
+
log.info("stopping bot... (press Ctrl+C again to force exit)")
|
|
329
|
+
self.stop()
|
|
330
|
+
else:
|
|
331
|
+
os._exit(130) # 128 + SIGINT
|
|
332
|
+
|
|
333
|
+
if hasattr(_signal, "SIGINT"):
|
|
334
|
+
try:
|
|
335
|
+
loop.add_signal_handler(_signal.SIGINT, on_interrupt)
|
|
336
|
+
loop.add_signal_handler(_signal.SIGTERM, self.stop)
|
|
337
|
+
except NotImplementedError: # Windows
|
|
338
|
+
_signal.signal(_signal.SIGINT, lambda *_: on_interrupt())
|
|
339
|
+
await self.serve_forever()
|
|
340
|
+
|
|
341
|
+
asyncio.run(_main())
|
|
342
|
+
|
|
343
|
+
async def serve_forever(self) -> None:
|
|
344
|
+
if self._serving:
|
|
345
|
+
raise RuntimeError("already serving")
|
|
346
|
+
self._serving = True
|
|
347
|
+
self._stop_event.clear()
|
|
348
|
+
try:
|
|
349
|
+
await self._receive_loop()
|
|
350
|
+
finally:
|
|
351
|
+
self._serving = False
|
|
352
|
+
|
|
353
|
+
def stop(self) -> None:
|
|
354
|
+
self._stop_event.set()
|
|
355
|
+
|
|
356
|
+
async def _receive_loop(self) -> None:
|
|
357
|
+
# Catch broad Exception so a single malformed event or transient
|
|
358
|
+
# native error doesn't crash the whole bot. CancelledError must
|
|
359
|
+
# always re-raise so `bot.stop()` and asyncio cancellation work.
|
|
360
|
+
# `wait_us=500_000` (500ms) bounds the worst-case Ctrl+C latency:
|
|
361
|
+
# the C call blocks the worker thread until timeout, and the loop
|
|
362
|
+
# only checks `_stop_event` between polls.
|
|
363
|
+
while not self._stop_event.is_set():
|
|
364
|
+
try:
|
|
365
|
+
event = await self.api.recv_chat_event(wait_us=500_000)
|
|
366
|
+
except asyncio.CancelledError:
|
|
367
|
+
raise
|
|
368
|
+
except Exception:
|
|
369
|
+
log.exception("recv_chat_event failed")
|
|
370
|
+
# Bound the spin rate when the FFI is wedged on a persistent
|
|
371
|
+
# error (vs the timeout path, which already paces itself).
|
|
372
|
+
await asyncio.sleep(0.5)
|
|
373
|
+
continue
|
|
374
|
+
if event is None:
|
|
375
|
+
continue
|
|
376
|
+
try:
|
|
377
|
+
await self._dispatch_event(event)
|
|
378
|
+
except asyncio.CancelledError:
|
|
379
|
+
raise
|
|
380
|
+
except Exception:
|
|
381
|
+
log.exception("dispatch_event failed for tag=%s", event.get("type"))
|
|
382
|
+
|
|
383
|
+
# ------------------------------------------------------------------ #
|
|
384
|
+
# Dispatch
|
|
385
|
+
# ------------------------------------------------------------------ #
|
|
386
|
+
|
|
387
|
+
async def _dispatch_event(self, event: CEvt.ChatEvent) -> None:
|
|
388
|
+
tag = event["type"]
|
|
389
|
+
for h in self._event_handlers.get(tag, []):
|
|
390
|
+
try:
|
|
391
|
+
await h(event)
|
|
392
|
+
except Exception:
|
|
393
|
+
log.exception("on_event handler failed")
|
|
394
|
+
if tag == "newChatItems":
|
|
395
|
+
evt: CEvt.NewChatItems = event # type: ignore[assignment]
|
|
396
|
+
for ci in evt["chatItems"]:
|
|
397
|
+
content = ci["chatItem"]["content"]
|
|
398
|
+
if content["type"] != "rcvMsgContent":
|
|
399
|
+
continue
|
|
400
|
+
msg_content = content["msgContent"] # type: ignore[index]
|
|
401
|
+
msg: Message[T.MsgContent] = Message(chat_item=ci, content=msg_content, bot=self)
|
|
402
|
+
await self._dispatch_message(msg)
|
|
403
|
+
|
|
404
|
+
async def _dispatch_message(self, msg: Message[Any]) -> None:
|
|
405
|
+
# First-match-wins. The squaring bot's `@on_message(text=NUMBER_RE)`
|
|
406
|
+
# and catch-all `@on_message(content_type="text")` both match a number
|
|
407
|
+
# like "1"; we want only the first to fire. Registration order is the
|
|
408
|
+
# priority order — register the most-specific filters first.
|
|
409
|
+
#
|
|
410
|
+
# Slash-commands are tried first against command handlers; if no
|
|
411
|
+
# command handler matches, fall through to message handlers (so
|
|
412
|
+
# `@on_message` can still catch unknown slash-commands).
|
|
413
|
+
cmd = self._parse_command(msg)
|
|
414
|
+
if cmd is not None:
|
|
415
|
+
for names, predicate, handler in self._command_handlers:
|
|
416
|
+
if cmd.keyword in names and predicate(msg):
|
|
417
|
+
await self._invoke_command_with_middleware(handler, msg, cmd)
|
|
418
|
+
return
|
|
419
|
+
for predicate, handler in self._message_handlers:
|
|
420
|
+
if predicate(msg):
|
|
421
|
+
await self._invoke_with_middleware(handler, msg)
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
async def _invoke_with_middleware(self, handler: MessageHandler, message: Message[Any]) -> None:
|
|
425
|
+
# Fast path: most bots register no middleware. Skip the closure-chain
|
|
426
|
+
# construction and the empty-data dict on every dispatch.
|
|
427
|
+
if not self._middleware:
|
|
428
|
+
try:
|
|
429
|
+
await handler(message)
|
|
430
|
+
except Exception:
|
|
431
|
+
log.exception("message handler failed")
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
async def call(m: Message[Any], _data: dict[str, object]) -> None:
|
|
435
|
+
await handler(m)
|
|
436
|
+
|
|
437
|
+
chain: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = call
|
|
438
|
+
for mw in reversed(self._middleware):
|
|
439
|
+
inner = chain
|
|
440
|
+
|
|
441
|
+
async def _wrapped(
|
|
442
|
+
m: Message[Any],
|
|
443
|
+
d: dict[str, object],
|
|
444
|
+
mw: Middleware = mw,
|
|
445
|
+
inner: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = inner,
|
|
446
|
+
) -> None:
|
|
447
|
+
await mw(inner, m, d)
|
|
448
|
+
|
|
449
|
+
chain = _wrapped
|
|
450
|
+
|
|
451
|
+
try:
|
|
452
|
+
await chain(message, {})
|
|
453
|
+
except Exception:
|
|
454
|
+
log.exception("message handler failed")
|
|
455
|
+
|
|
456
|
+
async def _invoke_command_with_middleware(
|
|
457
|
+
self, handler: CommandHandler, message: Message[Any], cmd: ParsedCommand
|
|
458
|
+
) -> None:
|
|
459
|
+
if not self._middleware:
|
|
460
|
+
try:
|
|
461
|
+
await handler(message, cmd)
|
|
462
|
+
except Exception:
|
|
463
|
+
log.exception("command handler failed")
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
async def call(m: Message[Any], _data: dict[str, object]) -> None:
|
|
467
|
+
await handler(m, cmd)
|
|
468
|
+
|
|
469
|
+
chain: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = call
|
|
470
|
+
for mw in reversed(self._middleware):
|
|
471
|
+
inner = chain
|
|
472
|
+
|
|
473
|
+
async def _wrapped(
|
|
474
|
+
m: Message[Any],
|
|
475
|
+
d: dict[str, object],
|
|
476
|
+
mw: Middleware = mw,
|
|
477
|
+
inner: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = inner,
|
|
478
|
+
) -> None:
|
|
479
|
+
await mw(inner, m, d)
|
|
480
|
+
|
|
481
|
+
chain = _wrapped
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
await chain(message, {})
|
|
485
|
+
except Exception:
|
|
486
|
+
log.exception("command handler failed")
|
|
487
|
+
|
|
488
|
+
@staticmethod
|
|
489
|
+
def _parse_command(msg: Message[Any]) -> ParsedCommand | None:
|
|
490
|
+
parsed = util.ci_bot_command(msg.chat_item["chatItem"])
|
|
491
|
+
if parsed is None:
|
|
492
|
+
return None
|
|
493
|
+
keyword, args = parsed
|
|
494
|
+
return ParsedCommand(keyword=keyword, args=args)
|
|
495
|
+
|
|
496
|
+
# ------------------------------------------------------------------ #
|
|
497
|
+
# Profile + address sync
|
|
498
|
+
# ------------------------------------------------------------------ #
|
|
499
|
+
|
|
500
|
+
async def _ensure_active_user(self) -> T.User:
|
|
501
|
+
"""Get or create the active user. Must run before `start_chat`.
|
|
502
|
+
|
|
503
|
+
Mirrors Node `createBotUser` (bot.ts:158-166). The chat controller
|
|
504
|
+
won't accept `/_start` without a user, so this phase has to land
|
|
505
|
+
before lifecycle proceeds.
|
|
506
|
+
"""
|
|
507
|
+
api = self.api
|
|
508
|
+
user = await api.api_get_active_user()
|
|
509
|
+
if user is None:
|
|
510
|
+
log.info("No active user in database, creating...")
|
|
511
|
+
user = await api.api_create_active_user(self._bot_profile_to_wire())
|
|
512
|
+
log.info("Bot user: %s", user["profile"]["displayName"])
|
|
513
|
+
return user
|
|
514
|
+
|
|
515
|
+
async def _sync_address_and_profile(self, user: T.User) -> None:
|
|
516
|
+
"""Address + profile sync. Runs after `start_chat` (mirrors bot.ts:57-63)."""
|
|
517
|
+
api = self.api
|
|
518
|
+
user_id = user["userId"]
|
|
519
|
+
|
|
520
|
+
# 2. Address (numbered to match bot.ts comments — phase 1 was user creation).
|
|
521
|
+
address = await api.api_get_user_address(user_id)
|
|
522
|
+
if address is None:
|
|
523
|
+
if self._opts["create_address"]:
|
|
524
|
+
log.info("Bot has no address, creating...")
|
|
525
|
+
await api.api_create_user_address(user_id)
|
|
526
|
+
address = await api.api_get_user_address(user_id)
|
|
527
|
+
if address is None:
|
|
528
|
+
raise RuntimeError("Failed reading newly created user address")
|
|
529
|
+
else:
|
|
530
|
+
log.warning("Bot has no address")
|
|
531
|
+
|
|
532
|
+
# Always announce the address — matches Node bot.ts:60.
|
|
533
|
+
link: str | None = None
|
|
534
|
+
if address is not None:
|
|
535
|
+
link = util.contact_address_str(address["connLinkContact"])
|
|
536
|
+
log.info("Bot address: %s", link)
|
|
537
|
+
|
|
538
|
+
# 3. Address settings (auto-accept + welcome message). Mirrors bot.ts:185-194.
|
|
539
|
+
# autoAccept present → accept; absent → no auto-accept (mirrors Node bot.ts).
|
|
540
|
+
if address is not None and self._opts["update_address"]:
|
|
541
|
+
desired: T.AddressSettings = {"businessAddress": self._opts["business_address"]}
|
|
542
|
+
if self._opts["auto_accept"]:
|
|
543
|
+
desired["autoAccept"] = {"acceptIncognito": False}
|
|
544
|
+
if self._welcome is not None:
|
|
545
|
+
desired["autoReply"] = (
|
|
546
|
+
{"type": "text", "text": self._welcome}
|
|
547
|
+
if isinstance(self._welcome, str)
|
|
548
|
+
else self._welcome
|
|
549
|
+
)
|
|
550
|
+
if address["addressSettings"] != desired:
|
|
551
|
+
log.info("Bot address settings changed, updating...")
|
|
552
|
+
await api.api_set_address_settings(user_id, desired)
|
|
553
|
+
|
|
554
|
+
# 4. Profile update. Mirrors Node `updateBotUserProfile` (bot.ts:199-214).
|
|
555
|
+
# Field-by-field comparison: user["profile"] is LocalProfile (has extra
|
|
556
|
+
# fields profileId, localAlias, preferences, peerType) so a full-dict
|
|
557
|
+
# equality would always differ.
|
|
558
|
+
new_profile = self._bot_profile_to_wire()
|
|
559
|
+
if link is not None and self._opts["use_bot_profile"]:
|
|
560
|
+
# Mirrors bot.ts:62 — embed the connection link in the bot's profile
|
|
561
|
+
# so contacts that resolve the bot via stored profile data see the
|
|
562
|
+
# current address.
|
|
563
|
+
new_profile["contactLink"] = link
|
|
564
|
+
cur = user["profile"]
|
|
565
|
+
changed = (
|
|
566
|
+
cur["displayName"] != new_profile["displayName"]
|
|
567
|
+
or cur.get("fullName", "") != new_profile.get("fullName", "")
|
|
568
|
+
or cur.get("shortDescr") != new_profile.get("shortDescr")
|
|
569
|
+
or cur.get("image") != new_profile.get("image")
|
|
570
|
+
or cur.get("preferences") != new_profile.get("preferences")
|
|
571
|
+
or cur.get("peerType") != new_profile.get("peerType")
|
|
572
|
+
or cur.get("contactLink") != new_profile.get("contactLink")
|
|
573
|
+
)
|
|
574
|
+
if changed and self._opts["update_profile"]:
|
|
575
|
+
log.info("Bot profile changed, updating...")
|
|
576
|
+
await api.api_update_profile(user_id, new_profile)
|
|
577
|
+
|
|
578
|
+
def _bot_profile_to_wire(self) -> T.Profile:
|
|
579
|
+
"""Construct wire-format Profile, applying bot conventions when use_bot_profile=True.
|
|
580
|
+
|
|
581
|
+
Mirrors Node mkBotProfile (bot.ts:88-102): bots get peerType="bot",
|
|
582
|
+
calls/voice prefs disabled, files gated on `allow_files`, and any
|
|
583
|
+
registered `commands` embedded in the profile preferences.
|
|
584
|
+
"""
|
|
585
|
+
p: T.Profile = {
|
|
586
|
+
"displayName": self._profile.display_name,
|
|
587
|
+
"fullName": self._profile.full_name,
|
|
588
|
+
}
|
|
589
|
+
if self._profile.short_descr is not None:
|
|
590
|
+
p["shortDescr"] = self._profile.short_descr
|
|
591
|
+
if self._profile.image is not None:
|
|
592
|
+
p["image"] = self._profile.image
|
|
593
|
+
if self._opts["use_bot_profile"]:
|
|
594
|
+
prefs: T.Preferences = {
|
|
595
|
+
"calls": {"allow": "no"},
|
|
596
|
+
"voice": {"allow": "no"},
|
|
597
|
+
"files": {"allow": "yes" if self._opts["allow_files"] else "no"},
|
|
598
|
+
}
|
|
599
|
+
if self._commands:
|
|
600
|
+
prefs["commands"] = [
|
|
601
|
+
{"type": "command", "keyword": c.keyword, "label": c.label}
|
|
602
|
+
for c in self._commands
|
|
603
|
+
]
|
|
604
|
+
p["preferences"] = prefs
|
|
605
|
+
p["peerType"] = "bot"
|
|
606
|
+
elif self._commands:
|
|
607
|
+
raise ValueError(
|
|
608
|
+
"use_bot_profile=False but commands were passed; commands are "
|
|
609
|
+
"only sent when use_bot_profile=True (they're embedded in the "
|
|
610
|
+
"user profile preferences)."
|
|
611
|
+
)
|
|
612
|
+
return p
|
|
613
|
+
|
|
614
|
+
# ------------------------------------------------------------------ #
|
|
615
|
+
# Log subscriptions (mirror Node subscribeLogEvents bot.ts:142-156)
|
|
616
|
+
# ------------------------------------------------------------------ #
|
|
617
|
+
|
|
618
|
+
def _register_log_handlers(self) -> None:
|
|
619
|
+
# Idempotent: a Bot reused across multiple `__aenter__` cycles must
|
|
620
|
+
# not stack duplicate log handlers. Always-on error handlers run
|
|
621
|
+
# regardless of log_contacts/log_network so messageError/chatError/
|
|
622
|
+
# chatErrors don't disappear into the void.
|
|
623
|
+
if self._defaults_registered:
|
|
624
|
+
return
|
|
625
|
+
self._defaults_registered = True
|
|
626
|
+
self._event_handlers.setdefault("messageError", []).append(self._log_message_error)
|
|
627
|
+
self._event_handlers.setdefault("chatError", []).append(self._log_chat_error)
|
|
628
|
+
self._event_handlers.setdefault("chatErrors", []).append(self._log_chat_errors)
|
|
629
|
+
if self._opts["log_contacts"]:
|
|
630
|
+
self._event_handlers.setdefault("contactConnected", []).append(
|
|
631
|
+
self._log_contact_connected
|
|
632
|
+
)
|
|
633
|
+
self._event_handlers.setdefault("contactDeletedByContact", []).append(
|
|
634
|
+
self._log_contact_deleted
|
|
635
|
+
)
|
|
636
|
+
if self._opts["log_network"]:
|
|
637
|
+
self._event_handlers.setdefault("hostConnected", []).append(self._log_host_connected)
|
|
638
|
+
self._event_handlers.setdefault("hostDisconnected", []).append(
|
|
639
|
+
self._log_host_disconnected
|
|
640
|
+
)
|
|
641
|
+
self._event_handlers.setdefault("subscriptionStatus", []).append(
|
|
642
|
+
self._log_subscription_status
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
@staticmethod
|
|
646
|
+
async def _log_contact_connected(evt: CEvt.ChatEvent) -> None:
|
|
647
|
+
log.info("%s connected", evt["contact"]["profile"]["displayName"]) # type: ignore[index]
|
|
648
|
+
|
|
649
|
+
@staticmethod
|
|
650
|
+
async def _log_contact_deleted(evt: CEvt.ChatEvent) -> None:
|
|
651
|
+
log.info(
|
|
652
|
+
"%s deleted connection with bot",
|
|
653
|
+
evt["contact"]["profile"]["displayName"], # type: ignore[index]
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
@staticmethod
|
|
657
|
+
async def _log_host_connected(evt: CEvt.ChatEvent) -> None:
|
|
658
|
+
log.info("connected server %s", evt["transportHost"]) # type: ignore[index]
|
|
659
|
+
|
|
660
|
+
@staticmethod
|
|
661
|
+
async def _log_host_disconnected(evt: CEvt.ChatEvent) -> None:
|
|
662
|
+
log.info("disconnected server %s", evt["transportHost"]) # type: ignore[index]
|
|
663
|
+
|
|
664
|
+
@staticmethod
|
|
665
|
+
async def _log_subscription_status(evt: CEvt.ChatEvent) -> None:
|
|
666
|
+
log.info(
|
|
667
|
+
"%d subscription(s) %s",
|
|
668
|
+
len(evt["connections"]), # type: ignore[index]
|
|
669
|
+
evt["subscriptionStatus"]["type"], # type: ignore[index]
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
@staticmethod
|
|
673
|
+
async def _log_message_error(evt: CEvt.ChatEvent) -> None:
|
|
674
|
+
log.warning("messageError: %s", evt.get("severity", "?")) # type: ignore[union-attr]
|
|
675
|
+
|
|
676
|
+
@staticmethod
|
|
677
|
+
async def _log_chat_error(evt: CEvt.ChatEvent) -> None:
|
|
678
|
+
err = evt.get("chatError") # type: ignore[union-attr]
|
|
679
|
+
log.error("chatError: %s", err.get("type") if isinstance(err, dict) else err)
|
|
680
|
+
|
|
681
|
+
@staticmethod
|
|
682
|
+
async def _log_chat_errors(evt: CEvt.ChatEvent) -> None:
|
|
683
|
+
errs = evt.get("chatErrors") or [] # type: ignore[union-attr]
|
|
684
|
+
log.error("chatErrors: %d errors", len(errs))
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# Suppress unused-import warnings for re-exported names used only at type-check time.
|
|
688
|
+
__all__ = [
|
|
689
|
+
"Bot",
|
|
690
|
+
"BotCommand",
|
|
691
|
+
"BotProfile",
|
|
692
|
+
"ChatMessage",
|
|
693
|
+
"FileMessage",
|
|
694
|
+
"ImageMessage",
|
|
695
|
+
"LinkMessage",
|
|
696
|
+
"Message",
|
|
697
|
+
"MessageHandler",
|
|
698
|
+
"CommandHandler",
|
|
699
|
+
"EventHandler",
|
|
700
|
+
"Middleware",
|
|
701
|
+
"ParsedCommand",
|
|
702
|
+
"ReportMessage",
|
|
703
|
+
"TextMessage",
|
|
704
|
+
"UnknownMessage",
|
|
705
|
+
"VideoMessage",
|
|
706
|
+
"VoiceMessage",
|
|
707
|
+
]
|