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/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
+ ]