bazaar-compute-node 0.1.3__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.
Files changed (62) hide show
  1. bazaar_compute_node/__init__.py +3 -0
  2. bazaar_compute_node/app/__init__.py +1 -0
  3. bazaar_compute_node/app/application.py +398 -0
  4. bazaar_compute_node/app/attachments.py +154 -0
  5. bazaar_compute_node/app/command.py +342 -0
  6. bazaar_compute_node/app/config.py +121 -0
  7. bazaar_compute_node/app/registry.py +120 -0
  8. bazaar_compute_node/app/transport.py +264 -0
  9. bazaar_compute_node/app/windows_pipe.py +463 -0
  10. bazaar_compute_node/app/wrapper.py +63 -0
  11. bazaar_compute_node/bcc.py +524 -0
  12. bazaar_compute_node/cli.py +382 -0
  13. bazaar_compute_node/contrib/__init__.py +1 -0
  14. bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
  15. bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
  16. bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
  17. bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
  18. bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
  19. bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
  20. bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
  21. bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
  22. bazaar_compute_node/contrib/logging/__init__.py +5 -0
  23. bazaar_compute_node/contrib/logging/audit.py +61 -0
  24. bazaar_compute_node/contrib/logging/plugin.py +11 -0
  25. bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
  26. bazaar_compute_node/contrib/sqlite/codec.py +768 -0
  27. bazaar_compute_node/contrib/sqlite/database.py +282 -0
  28. bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
  29. bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
  30. bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
  31. bazaar_compute_node/contrib/wecom/__init__.py +1 -0
  32. bazaar_compute_node/contrib/wecom/channel.py +960 -0
  33. bazaar_compute_node/contrib/wecom/markdown.py +146 -0
  34. bazaar_compute_node/contrib/wecom/plugin.py +29 -0
  35. bazaar_compute_node/core/__init__.py +5 -0
  36. bazaar_compute_node/core/approval.py +51 -0
  37. bazaar_compute_node/core/audit.py +101 -0
  38. bazaar_compute_node/core/channel.py +121 -0
  39. bazaar_compute_node/core/client.py +30 -0
  40. bazaar_compute_node/core/command.py +85 -0
  41. bazaar_compute_node/core/concurrency.py +29 -0
  42. bazaar_compute_node/core/correlation.py +48 -0
  43. bazaar_compute_node/core/instruction.py +224 -0
  44. bazaar_compute_node/core/lifecycle.py +48 -0
  45. bazaar_compute_node/core/models/__init__.py +63 -0
  46. bazaar_compute_node/core/models/entities.py +514 -0
  47. bazaar_compute_node/core/models/states.py +369 -0
  48. bazaar_compute_node/core/observability.py +47 -0
  49. bazaar_compute_node/core/orchestration/__init__.py +5 -0
  50. bazaar_compute_node/core/orchestration/command.py +614 -0
  51. bazaar_compute_node/core/orchestration/services.py +135 -0
  52. bazaar_compute_node/core/orchestration/session.py +891 -0
  53. bazaar_compute_node/core/orchestration/turn.py +451 -0
  54. bazaar_compute_node/core/outcomes.py +51 -0
  55. bazaar_compute_node/core/paths.py +19 -0
  56. bazaar_compute_node/core/runtime.py +118 -0
  57. bazaar_compute_node/core/storage.py +167 -0
  58. bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
  59. bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
  60. bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
  61. bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
  62. bazaar_compute_node-0.1.3.dist-info/licenses/LICENSE +613 -0
@@ -0,0 +1,960 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import random
9
+ from collections.abc import AsyncIterator, Mapping
10
+ from dataclasses import dataclass
11
+ from email.message import Message
12
+ from time import time_ns
13
+ from urllib.parse import unquote
14
+ from uuid import NAMESPACE_URL, uuid4, uuid5
15
+
16
+ import aiohttp
17
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
18
+
19
+ from ...core.channel import (
20
+ ChannelContext,
21
+ ChannelDeliveryReceipt,
22
+ ChannelSendRequest,
23
+ IChannel,
24
+ )
25
+ from ...core.models import (
26
+ ApprovalDecision,
27
+ ApprovalRequest,
28
+ ApprovalResult,
29
+ ChannelTargetKind,
30
+ InboundAttachment,
31
+ InboundMessage,
32
+ StreamEvent,
33
+ )
34
+ from ...core.outcomes import ProviderCallResult, ProviderCallStatus
35
+ from .markdown import split_markdown
36
+
37
+ _STOP = object()
38
+ _MAX_MEDIA_BYTES = 25 * 1024 * 1024
39
+ _MAX_MARKDOWN_BYTES = 20_480
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class _BatchResult:
44
+ status: ProviderCallStatus
45
+ receipt: Mapping[str, object]
46
+ error_kind: str | None = None
47
+ error_message: str | None = None
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class _InboundContent:
52
+ body: str
53
+ attachments: tuple[InboundAttachment, ...]
54
+ fingerprint: str
55
+
56
+
57
+ class WeComChannel(IChannel):
58
+ def __init__(
59
+ self,
60
+ context: ChannelContext,
61
+ *,
62
+ bot_id: str,
63
+ secret: str,
64
+ websocket_url: str,
65
+ ) -> None:
66
+ self._context = context
67
+ self._bot_id = bot_id
68
+ self._secret = secret
69
+ self._websocket_url = websocket_url
70
+ self._inbound: asyncio.Queue[InboundMessage | object] = asyncio.Queue()
71
+ self._ready = asyncio.Event()
72
+ self._stopping = asyncio.Event()
73
+ self._startup_finished = asyncio.Event()
74
+ self._startup_error: Exception | None = None
75
+ self._runner: asyncio.Task[None] | None = None
76
+ self._connection: aiohttp.ClientWebSocketResponse | None = None
77
+ self._send_lock = asyncio.Lock()
78
+ self._pending_acks: dict[str, asyncio.Future[Mapping[str, object]]] = {}
79
+ self._degraded = False
80
+ self._heartbeat_ack = ""
81
+ self._state = "stopped"
82
+ self._network_attempts = 0
83
+ self._auth_attempts = 0
84
+ self._last_disconnect_kind: str | None = None
85
+ self._connected_at_ms: int | None = None
86
+ self._last_frame_at_ms: int | None = None
87
+ self._connection_generation = 0
88
+ self._ignored_event_frames = 0
89
+ self._message_frames_received = 0
90
+ self._message_frames_queued = 0
91
+ self._message_frames_filtered = 0
92
+ self._last_message_frame_at_ms: int | None = None
93
+ self._last_message_disposition: str | None = None
94
+ self._last_message_filter_reason: str | None = None
95
+ self._last_event_type: str | None = None
96
+ self._logger = logging.getLogger("bazaar_compute_node.channel.wecom")
97
+ if not self._logger.handlers:
98
+ self._logger.addHandler(logging.StreamHandler())
99
+ self._logger.setLevel(logging.INFO)
100
+ self._logger.propagate = False
101
+
102
+ @property
103
+ def name(self) -> str:
104
+ return "wecom"
105
+
106
+ @property
107
+ def health(self) -> Mapping[str, object]:
108
+ return {
109
+ "state": self._state,
110
+ "full_group_ingress": False,
111
+ "network_attempts": self._network_attempts,
112
+ "auth_attempts": self._auth_attempts,
113
+ "connection_generation": self._connection_generation,
114
+ "connected_at_ms": self._connected_at_ms,
115
+ "last_frame_at_ms": self._last_frame_at_ms,
116
+ "last_disconnect_kind": self._last_disconnect_kind,
117
+ "ignored_event_frames": self._ignored_event_frames,
118
+ "message_frames_received": self._message_frames_received,
119
+ "message_frames_queued": self._message_frames_queued,
120
+ "message_frames_filtered": self._message_frames_filtered,
121
+ "last_message_frame_at_ms": self._last_message_frame_at_ms,
122
+ "last_message_disposition": self._last_message_disposition,
123
+ "last_message_filter_reason": self._last_message_filter_reason,
124
+ "last_event_type": self._last_event_type,
125
+ }
126
+
127
+ async def start(self, *, timeout: float) -> None:
128
+ if self._runner is not None:
129
+ return
130
+ self._stopping.clear()
131
+ self._startup_finished.clear()
132
+ self._startup_error = None
133
+ self._state = "connecting"
134
+ self._runner = asyncio.create_task(self._run(), name="bcn-wecom-channel")
135
+ try:
136
+ await asyncio.wait_for(self._startup_finished.wait(), timeout=timeout)
137
+ except BaseException:
138
+ self._stopping.set()
139
+ self._runner.cancel()
140
+ await asyncio.gather(self._runner, return_exceptions=True)
141
+ self._runner = None
142
+ self._state = "stopped"
143
+ raise
144
+ if self._startup_error is not None:
145
+ raise self._startup_error
146
+
147
+ async def stop(self, *, timeout: float) -> None:
148
+ self._stopping.set()
149
+ try:
150
+ await asyncio.wait_for(self._send_lock.acquire(), timeout=timeout)
151
+ except TimeoutError:
152
+ pass
153
+ else:
154
+ self._send_lock.release()
155
+ connection = self._connection
156
+ if connection is not None:
157
+ await connection.close()
158
+ runner = self._runner
159
+ if runner is not None:
160
+ try:
161
+ await asyncio.wait_for(runner, timeout=timeout)
162
+ except TimeoutError:
163
+ runner.cancel()
164
+ await asyncio.gather(runner, return_exceptions=True)
165
+ self._runner = None
166
+ self._state = "stopped"
167
+ await self._inbound.put(_STOP)
168
+
169
+ async def receive(self) -> AsyncIterator[InboundMessage]:
170
+ while True:
171
+ item = await self._inbound.get()
172
+ if item is _STOP:
173
+ return
174
+ if not isinstance(item, InboundMessage):
175
+ raise TypeError("WeCom inbound queue contained an invalid message")
176
+ yield item
177
+
178
+ def offer_stream_event(self, event: StreamEvent) -> None:
179
+ return None
180
+
181
+ async def send(
182
+ self, request: ChannelSendRequest, *, timeout: float
183
+ ) -> ProviderCallResult[ChannelDeliveryReceipt]:
184
+ message = request.outbound
185
+ target_id = request.provider_thread_id
186
+ try:
187
+ batches = split_markdown(message.body, limit=_MAX_MARKDOWN_BYTES)
188
+ except ValueError as error:
189
+ return ProviderCallResult(
190
+ status=ProviderCallStatus.FAILED,
191
+ error_kind="invalid_markdown",
192
+ error_message=str(error),
193
+ )
194
+
195
+ loop = asyncio.get_running_loop()
196
+ deadline = loop.time() + timeout
197
+ receipts: list[dict[str, object]] = []
198
+ confirmed = 0
199
+ async with self._send_lock:
200
+ for batch in batches:
201
+ if loop.time() >= deadline:
202
+ return self._clear_failure(
203
+ total=len(batches),
204
+ receipts=receipts,
205
+ confirmed=confirmed,
206
+ error_kind="delivery_timeout",
207
+ error_message=(
208
+ "WeCom delivery timed out between batches"
209
+ if confirmed
210
+ else "WeCom delivery timed out before sending"
211
+ ),
212
+ )
213
+ connection = self._connection
214
+ if connection is None or connection.closed or not self._ready.is_set():
215
+ return self._clear_failure(
216
+ total=len(batches),
217
+ receipts=receipts,
218
+ confirmed=confirmed,
219
+ error_kind="connection_unavailable",
220
+ error_message=(
221
+ "WeCom connection became unavailable between batches"
222
+ if confirmed
223
+ else "WeCom connection is unavailable"
224
+ ),
225
+ )
226
+ result = await self._send_batch(
227
+ connection,
228
+ target_id=target_id,
229
+ content=batch,
230
+ deadline=deadline,
231
+ )
232
+ receipts.append(dict(result.receipt))
233
+ if result.status is ProviderCallStatus.CONFIRMED:
234
+ confirmed += 1
235
+ continue
236
+ if result.status is ProviderCallStatus.FAILED:
237
+ return self._clear_failure(
238
+ total=len(batches),
239
+ receipts=receipts,
240
+ confirmed=confirmed,
241
+ error_kind=result.error_kind or "provider_rejected_batch",
242
+ error_message=result.error_message
243
+ or "WeCom rejected the outbound message",
244
+ )
245
+ return ProviderCallResult(
246
+ status=ProviderCallStatus.UNKNOWN,
247
+ error_kind=result.error_kind or "ack_unknown",
248
+ error_message=result.error_message
249
+ or "WeCom acknowledgement outcome is unknown",
250
+ receipt=self._delivery_receipt(len(batches), confirmed, receipts),
251
+ )
252
+
253
+ return ProviderCallResult(
254
+ status=ProviderCallStatus.CONFIRMED,
255
+ value=ChannelDeliveryReceipt(
256
+ provider_receipt_ref=str(receipts[-1]["provider_request_id"])
257
+ ),
258
+ receipt=self._delivery_receipt(len(batches), confirmed, receipts),
259
+ )
260
+
261
+ async def _send_batch(
262
+ self,
263
+ connection: aiohttp.ClientWebSocketResponse,
264
+ *,
265
+ target_id: str,
266
+ content: str,
267
+ deadline: float,
268
+ ) -> _BatchResult:
269
+ request_id = f"aibot_send_msg-{uuid4()}"
270
+ attempted_at_ms = time_ns() // 1_000_000
271
+ loop = asyncio.get_running_loop()
272
+ future: asyncio.Future[Mapping[str, object]] = loop.create_future()
273
+ self._pending_acks[request_id] = future
274
+ try:
275
+ await connection.send_str(
276
+ json.dumps(
277
+ {
278
+ "cmd": "aibot_send_msg",
279
+ "headers": {"req_id": request_id},
280
+ "body": {
281
+ "chatid": target_id,
282
+ "msgtype": "markdown",
283
+ "markdown": {"content": content},
284
+ },
285
+ },
286
+ separators=(",", ":"),
287
+ )
288
+ )
289
+ except asyncio.CancelledError:
290
+ self._pending_acks.pop(request_id, None)
291
+ future.cancel()
292
+ raise
293
+ except Exception as error: # noqa: BLE001
294
+ self._pending_acks.pop(request_id, None)
295
+ future.cancel()
296
+ return _BatchResult(
297
+ status=ProviderCallStatus.UNKNOWN,
298
+ receipt={
299
+ "provider_request_id": request_id,
300
+ "state": "unknown",
301
+ "attempted_at_ms": attempted_at_ms,
302
+ "error_type": type(error).__name__,
303
+ },
304
+ error_kind="send_unknown",
305
+ error_message="WeCom send outcome is unknown",
306
+ )
307
+ try:
308
+ remaining = deadline - loop.time()
309
+ if remaining <= 0:
310
+ raise TimeoutError
311
+ frame = await asyncio.wait_for(future, timeout=remaining)
312
+ except asyncio.CancelledError:
313
+ raise
314
+ except (TimeoutError, ConnectionError) as error:
315
+ return _BatchResult(
316
+ status=ProviderCallStatus.UNKNOWN,
317
+ receipt={
318
+ "provider_request_id": request_id,
319
+ "state": "unknown",
320
+ "attempted_at_ms": attempted_at_ms,
321
+ "error_type": type(error).__name__,
322
+ },
323
+ error_kind="ack_unknown",
324
+ error_message="WeCom acknowledgement outcome is unknown",
325
+ )
326
+ finally:
327
+ self._pending_acks.pop(request_id, None)
328
+
329
+ acknowledged_at_ms = time_ns() // 1_000_000
330
+ error_code = frame.get("errcode")
331
+ error_message = frame.get("errmsg")
332
+ if not isinstance(error_code, int) or isinstance(error_code, bool):
333
+ return _BatchResult(
334
+ status=ProviderCallStatus.UNKNOWN,
335
+ receipt={
336
+ "provider_request_id": request_id,
337
+ "state": "unknown",
338
+ "attempted_at_ms": attempted_at_ms,
339
+ "acknowledged_at_ms": acknowledged_at_ms,
340
+ "error_type": "InvalidAcknowledgement",
341
+ },
342
+ error_kind="invalid_ack",
343
+ error_message="WeCom acknowledgement is malformed",
344
+ )
345
+ if error_code != 0:
346
+ return _BatchResult(
347
+ status=ProviderCallStatus.FAILED,
348
+ receipt={
349
+ "provider_request_id": request_id,
350
+ "state": "failed",
351
+ "attempted_at_ms": attempted_at_ms,
352
+ "acknowledged_at_ms": acknowledged_at_ms,
353
+ "error_code": error_code,
354
+ "error_message": (
355
+ error_message[:256] if isinstance(error_message, str) else None
356
+ ),
357
+ },
358
+ error_kind="provider_rejected_batch",
359
+ error_message="WeCom rejected an outbound batch",
360
+ )
361
+ return _BatchResult(
362
+ status=ProviderCallStatus.CONFIRMED,
363
+ receipt={
364
+ "provider_request_id": request_id,
365
+ "state": "confirmed",
366
+ "attempted_at_ms": attempted_at_ms,
367
+ "acknowledged_at_ms": acknowledged_at_ms,
368
+ "error_code": 0,
369
+ },
370
+ )
371
+
372
+ @staticmethod
373
+ def _delivery_receipt(
374
+ total: int, confirmed: int, receipts: list[dict[str, object]]
375
+ ) -> Mapping[str, object]:
376
+ return {
377
+ "total_batches": total,
378
+ "confirmed_batches": confirmed,
379
+ "batches": tuple(receipts),
380
+ }
381
+
382
+ def _clear_failure(
383
+ self,
384
+ *,
385
+ total: int,
386
+ receipts: list[dict[str, object]],
387
+ confirmed: int,
388
+ error_kind: str,
389
+ error_message: str,
390
+ ) -> ProviderCallResult[ChannelDeliveryReceipt]:
391
+ receipt = self._delivery_receipt(total, confirmed, receipts)
392
+ if confirmed:
393
+ return ProviderCallResult(
394
+ status=ProviderCallStatus.PARTIAL,
395
+ value=ChannelDeliveryReceipt(
396
+ provider_receipt_ref=str(
397
+ receipts[confirmed - 1]["provider_request_id"]
398
+ )
399
+ ),
400
+ error_kind=error_kind,
401
+ error_message=error_message,
402
+ receipt=receipt,
403
+ )
404
+ return ProviderCallResult(
405
+ status=ProviderCallStatus.FAILED,
406
+ error_kind=error_kind,
407
+ error_message=error_message,
408
+ receipt=receipt,
409
+ )
410
+
411
+ async def request_approval(
412
+ self, request: ApprovalRequest, *, timeout: float
413
+ ) -> ApprovalResult:
414
+ return ApprovalResult(
415
+ request_id=request.request_id,
416
+ decision=ApprovalDecision.APPROVED,
417
+ decided_at_ms=time_ns() // 1_000_000,
418
+ )
419
+
420
+ async def _run(self) -> None:
421
+ network_attempt = 0
422
+ auth_attempt = 0
423
+ while not self._stopping.is_set() and not self._degraded:
424
+ try:
425
+ await self._connect_once()
426
+ network_attempt = 0
427
+ auth_attempt = 0
428
+ except asyncio.CancelledError:
429
+ raise
430
+ except _AuthenticationError as error:
431
+ auth_attempt += 1
432
+ self._auth_attempts = auth_attempt
433
+ self._state = "reconnecting"
434
+ self._last_disconnect_kind = "authentication_failed"
435
+ if not self._startup_finished.is_set() or auth_attempt >= 3:
436
+ self._startup_error = error
437
+ self._startup_finished.set()
438
+ return
439
+ await self._backoff(auth_attempt)
440
+ except Exception as error: # noqa: BLE001
441
+ network_attempt += 1
442
+ self._network_attempts = network_attempt
443
+ self._state = "reconnecting"
444
+ self._last_disconnect_kind = type(error).__name__
445
+ if not self._startup_finished.is_set() and network_attempt >= 6:
446
+ self._startup_error = RuntimeError(
447
+ f"WeCom connection failed: {type(error).__name__}"
448
+ )
449
+ self._startup_finished.set()
450
+ return
451
+ await self._backoff(network_attempt)
452
+
453
+ async def _connect_once(self) -> None:
454
+ timeout = aiohttp.ClientTimeout(total=None, connect=10)
455
+ async with (
456
+ aiohttp.ClientSession(timeout=timeout) as session,
457
+ session.ws_connect(
458
+ self._websocket_url,
459
+ heartbeat=20,
460
+ autoclose=True,
461
+ autoping=True,
462
+ max_msg_size=2 * 1024 * 1024,
463
+ ) as connection,
464
+ ):
465
+ self._connection = connection
466
+ subscribe_id = f"aibot_subscribe-{uuid4()}"
467
+ await connection.send_str(
468
+ json.dumps(
469
+ {
470
+ "cmd": "aibot_subscribe",
471
+ "headers": {"req_id": subscribe_id},
472
+ "body": {"bot_id": self._bot_id, "secret": self._secret},
473
+ },
474
+ separators=(",", ":"),
475
+ )
476
+ )
477
+ response = await asyncio.wait_for(connection.receive(), timeout=10)
478
+ frame = self._frame(self._message_data(response))
479
+ if self._request_id(frame) != subscribe_id or frame.get("errcode") != 0:
480
+ raise _AuthenticationError("WeCom authentication failed")
481
+ self._ready.set()
482
+ self._state = "connected"
483
+ self._network_attempts = 0
484
+ self._auth_attempts = 0
485
+ self._connected_at_ms = time_ns() // 1_000_000
486
+ self._last_frame_at_ms = self._connected_at_ms
487
+ self._last_disconnect_kind = None
488
+ self._connection_generation += 1
489
+ self._startup_finished.set()
490
+ heartbeat = asyncio.create_task(
491
+ self._heartbeat(connection), name="bcn-wecom-heartbeat"
492
+ )
493
+ try:
494
+ async for response in connection:
495
+ if response.type in {
496
+ aiohttp.WSMsgType.CLOSE,
497
+ aiohttp.WSMsgType.CLOSED,
498
+ }:
499
+ break
500
+ if response.type is aiohttp.WSMsgType.ERROR:
501
+ raise ConnectionError("WeCom WebSocket reader failed")
502
+ frame = self._frame(self._message_data(response))
503
+ self._last_frame_at_ms = time_ns() // 1_000_000
504
+ if self._is_disconnected_event(frame):
505
+ self._last_event_type = "disconnected_event"
506
+ self._observe(
507
+ "wecom.event.received",
508
+ event_type="disconnected_event",
509
+ )
510
+ self._degraded = True
511
+ self._state = "degraded"
512
+ self._last_disconnect_kind = "disconnected_event"
513
+ await connection.close()
514
+ return
515
+ request_id = self._request_id(frame)
516
+ if request_id.startswith("ping-") and frame.get("errcode") == 0:
517
+ self._heartbeat_ack = request_id
518
+ continue
519
+ pending = self._pending_acks.get(request_id)
520
+ if pending is not None and not pending.done():
521
+ pending.set_result(frame)
522
+ continue
523
+ if frame.get("cmd") in {
524
+ "aibot_msg_callback",
525
+ "aibot_event_callback",
526
+ }:
527
+ await self._receive_message(frame)
528
+ if not self._stopping.is_set() and not self._degraded:
529
+ raise ConnectionError("WeCom WebSocket closed")
530
+ finally:
531
+ heartbeat.cancel()
532
+ await asyncio.gather(heartbeat, return_exceptions=True)
533
+ for pending in self._pending_acks.values():
534
+ if not pending.done():
535
+ pending.set_exception(
536
+ ConnectionError("WeCom connection closed before ack")
537
+ )
538
+ self._pending_acks.clear()
539
+ self._connection = None
540
+ self._ready.clear()
541
+ if not self._stopping.is_set() and not self._degraded:
542
+ self._state = "reconnecting"
543
+
544
+ async def _heartbeat(self, connection: aiohttp.ClientWebSocketResponse) -> None:
545
+ missed = 0
546
+ last_request = ""
547
+ while not self._stopping.is_set():
548
+ await asyncio.sleep(30)
549
+ if last_request and self._heartbeat_ack != last_request:
550
+ missed += 1
551
+ else:
552
+ missed = 0
553
+ if missed >= 2:
554
+ await connection.close(code=1011, message=b"heartbeat timeout")
555
+ return
556
+ last_request = f"ping-{uuid4()}"
557
+ await connection.send_str(
558
+ json.dumps(
559
+ {"cmd": "ping", "headers": {"req_id": last_request}},
560
+ separators=(",", ":"),
561
+ )
562
+ )
563
+
564
+ async def _receive_message(self, frame: Mapping[str, object]) -> None:
565
+ command = frame.get("cmd")
566
+ body = frame.get("body")
567
+ if command == "aibot_event_callback":
568
+ self._ignored_event_frames += 1
569
+ event = body.get("event") if isinstance(body, dict) else None
570
+ event_type = event.get("eventtype") if isinstance(event, dict) else None
571
+ self._last_event_type = (
572
+ event_type if isinstance(event_type, str) and event_type else "unknown"
573
+ )
574
+ self._observe(
575
+ "wecom.event.received",
576
+ event_type=self._last_event_type,
577
+ )
578
+ return
579
+ now_ms = time_ns() // 1_000_000
580
+ self._message_frames_received += 1
581
+ self._last_message_frame_at_ms = now_ms
582
+ if not isinstance(body, dict):
583
+ self._filter_message("invalid_body")
584
+ return
585
+ provider_message_id = body.get("msgid")
586
+ self._observe(
587
+ "wecom.message.received",
588
+ provider_message_id=(
589
+ provider_message_id
590
+ if isinstance(provider_message_id, str) and provider_message_id
591
+ else None
592
+ ),
593
+ message_type=body.get("msgtype"),
594
+ chat_type=body.get("chattype"),
595
+ )
596
+ if not isinstance(provider_message_id, str) or not provider_message_id:
597
+ self._filter_message("missing_message_id")
598
+ return
599
+ chat_type = body.get("chattype")
600
+ sender = body.get("from")
601
+ sender_id = sender.get("userid") if isinstance(sender, dict) else None
602
+ if not isinstance(sender_id, str) or not sender_id:
603
+ self._filter_message(
604
+ "missing_sender", provider_message_id=provider_message_id
605
+ )
606
+ return
607
+ message_type = body.get("msgtype")
608
+ if not isinstance(message_type, str) or not message_type:
609
+ self._filter_message(
610
+ "missing_message_type", provider_message_id=provider_message_id
611
+ )
612
+ return
613
+ chat_id = body.get("chatid")
614
+ if chat_type == "group":
615
+ conversation = chat_id
616
+ target_kind = ChannelTargetKind.GROUP
617
+ mentions_agent = True
618
+ target_prefix = "group"
619
+ elif chat_type == "single":
620
+ conversation = sender_id
621
+ target_kind = ChannelTargetKind.DM
622
+ mentions_agent = False
623
+ target_prefix = "dm"
624
+ else:
625
+ self._filter_message(
626
+ "unsupported_chat_type", provider_message_id=provider_message_id
627
+ )
628
+ return
629
+ if not isinstance(conversation, str) or not conversation:
630
+ self._filter_message(
631
+ "missing_conversation", provider_message_id=provider_message_id
632
+ )
633
+ return
634
+ identity = f"wecom:{target_prefix}:{conversation}"
635
+ channel_session_id = str(uuid5(NAMESPACE_URL, identity))
636
+ session_id = str(uuid5(NAMESPACE_URL, f"bcn:{identity}"))
637
+ canonical_target = f"{target_prefix}:{channel_session_id}"
638
+ received_at_ms = time_ns() // 1_000_000
639
+ content = await self._content(body, message_type)
640
+ metadata: dict[str, object] = {}
641
+ create_time = body.get("create_time")
642
+ if isinstance(create_time, int) and not isinstance(create_time, bool):
643
+ metadata["provider_create_time"] = create_time
644
+ reply_to_message_id = None
645
+ quote = body.get("quote")
646
+ if isinstance(quote, dict):
647
+ quote_type = quote.get("msgtype")
648
+ if isinstance(quote_type, str) and quote_type:
649
+ quote_content = await self._content(quote, quote_type)
650
+ quote_provider_message_id = quote_content.fingerprint
651
+ reply_to_message_id = str(
652
+ uuid5(
653
+ NAMESPACE_URL,
654
+ "bcn:wecom:quoted-message:"
655
+ f"{conversation}:{quote_content.fingerprint}",
656
+ )
657
+ )
658
+ await self._inbound.put(
659
+ InboundMessage(
660
+ seq=0,
661
+ message_id=reply_to_message_id,
662
+ session_id=session_id,
663
+ channel_session_id=channel_session_id,
664
+ channel=self.name,
665
+ provider_thread_id=conversation,
666
+ provider_message_id=quote_provider_message_id,
667
+ received_at_ms=received_at_ms,
668
+ sender=None,
669
+ message_type=quote_type,
670
+ canonical_target=canonical_target,
671
+ body=quote_content.body,
672
+ target_kind=target_kind,
673
+ mentions_agent=False,
674
+ notifies_runtime=False,
675
+ attachments=quote_content.attachments,
676
+ )
677
+ )
678
+ else:
679
+ self._observe(
680
+ "wecom.message.reference_unresolved",
681
+ provider_message_id=provider_message_id,
682
+ reason="missing_message_type",
683
+ )
684
+ await self._inbound.put(
685
+ InboundMessage(
686
+ seq=0,
687
+ message_id=str(
688
+ uuid5(
689
+ NAMESPACE_URL,
690
+ f"bcn:wecom:message:{provider_message_id}",
691
+ )
692
+ ),
693
+ session_id=session_id,
694
+ channel_session_id=channel_session_id,
695
+ channel=self.name,
696
+ provider_thread_id=conversation,
697
+ provider_message_id=provider_message_id,
698
+ received_at_ms=received_at_ms,
699
+ sender=sender_id,
700
+ message_type=message_type,
701
+ canonical_target=canonical_target,
702
+ body=content.body,
703
+ target_kind=target_kind,
704
+ mentions_agent=mentions_agent,
705
+ attachments=content.attachments,
706
+ reply_to_message_id=reply_to_message_id,
707
+ metadata=metadata,
708
+ )
709
+ )
710
+ self._message_frames_queued += 1
711
+ self._last_message_disposition = "queued"
712
+ self._last_message_filter_reason = None
713
+ self._observe(
714
+ "wecom.message.queued",
715
+ provider_message_id=provider_message_id,
716
+ channel_session_id=channel_session_id,
717
+ session_id=session_id,
718
+ target_kind=target_kind.value,
719
+ message_type=message_type,
720
+ referenced=reply_to_message_id is not None,
721
+ )
722
+
723
+ def _filter_message(
724
+ self,
725
+ reason: str,
726
+ *,
727
+ provider_message_id: str | None = None,
728
+ ) -> None:
729
+ self._message_frames_filtered += 1
730
+ self._last_message_disposition = "filtered"
731
+ self._last_message_filter_reason = reason
732
+ self._observe(
733
+ "wecom.message.filtered",
734
+ reason=reason,
735
+ provider_message_id=provider_message_id,
736
+ )
737
+
738
+ def _observe(self, event_name: str, **metadata: object) -> None:
739
+ self._logger.info(
740
+ "%s",
741
+ json.dumps(
742
+ {
743
+ "event_name": event_name,
744
+ "created_at_ms": time_ns() // 1_000_000,
745
+ "metadata": {
746
+ key: value
747
+ for key, value in metadata.items()
748
+ if value is not None
749
+ },
750
+ },
751
+ separators=(",", ":"),
752
+ sort_keys=True,
753
+ default=str,
754
+ ),
755
+ )
756
+
757
+ async def _content(
758
+ self, body: Mapping[str, object], message_type: str
759
+ ) -> _InboundContent:
760
+ if message_type in {"text", "voice"}:
761
+ part = body.get(message_type)
762
+ content = part.get("content") if isinstance(part, dict) else None
763
+ text = content if isinstance(content, str) else ""
764
+ return _InboundContent(
765
+ body=text,
766
+ attachments=(),
767
+ fingerprint=self._content_fingerprint(
768
+ {"message_type": message_type, "body": text}
769
+ ),
770
+ )
771
+ if message_type == "mixed":
772
+ mixed = body.get("mixed")
773
+ items = mixed.get("msg_item") if isinstance(mixed, dict) else None
774
+ texts: list[str] = []
775
+ attachments: list[InboundAttachment] = []
776
+ fingerprint_items: list[object] = []
777
+ if isinstance(items, list):
778
+ for item in items[:20]:
779
+ if not isinstance(item, dict):
780
+ continue
781
+ if item.get("msgtype") == "text":
782
+ text = item.get("text")
783
+ content = (
784
+ text.get("content") if isinstance(text, dict) else None
785
+ )
786
+ if isinstance(content, str):
787
+ texts.append(content)
788
+ fingerprint_items.append(
789
+ {"message_type": "text", "body": content}
790
+ )
791
+ elif item.get("msgtype") == "image":
792
+ image = item.get("image")
793
+ if isinstance(image, dict):
794
+ attachment, fingerprint = await self._media(image, "image")
795
+ attachments.append(attachment)
796
+ fingerprint_items.append(fingerprint)
797
+ return _InboundContent(
798
+ body="\n".join(texts),
799
+ attachments=tuple(attachments),
800
+ fingerprint=self._content_fingerprint(
801
+ {"message_type": message_type, "items": fingerprint_items}
802
+ ),
803
+ )
804
+ if message_type in {"image", "file", "video"}:
805
+ media = body.get(message_type)
806
+ if isinstance(media, dict):
807
+ attachment, fingerprint = await self._media(media, message_type)
808
+ return _InboundContent(
809
+ body="",
810
+ attachments=(attachment,),
811
+ fingerprint=self._content_fingerprint(
812
+ {"message_type": message_type, "media": fingerprint}
813
+ ),
814
+ )
815
+ unsupported = f"[unsupported WeCom message type: {message_type}]"
816
+ return _InboundContent(
817
+ body=unsupported,
818
+ attachments=(),
819
+ fingerprint=self._content_fingerprint(
820
+ {"message_type": message_type, "body": unsupported}
821
+ ),
822
+ )
823
+
824
+ async def _media(
825
+ self, media: Mapping[str, object], kind: str
826
+ ) -> tuple[InboundAttachment, object]:
827
+ url = media.get("url")
828
+ aes_key = media.get("aeskey")
829
+ if not isinstance(url, str) or not url:
830
+ return (
831
+ self._context.attachments.failed(
832
+ name=f"{kind}.bin", kind=kind, error="missing_media_url"
833
+ ),
834
+ {"kind": kind, "error": "missing_media_url"},
835
+ )
836
+ try:
837
+ timeout = aiohttp.ClientTimeout(total=60, connect=10)
838
+ async with (
839
+ aiohttp.ClientSession(timeout=timeout) as session,
840
+ session.get(url, allow_redirects=True) as response,
841
+ ):
842
+ response.raise_for_status()
843
+ content = bytearray()
844
+ async for chunk in response.content.iter_chunked(64 * 1024):
845
+ content.extend(chunk)
846
+ if len(content) > _MAX_MEDIA_BYTES:
847
+ raise ValueError("media_too_large")
848
+ name = self._filename(response.headers.get("Content-Disposition"), kind)
849
+ media_type = response.headers.get("Content-Type")
850
+ plaintext = bytes(content)
851
+ if isinstance(aes_key, str) and aes_key:
852
+ plaintext = self._decrypt(plaintext, aes_key)
853
+ return (
854
+ await self._context.attachments.materialize(
855
+ plaintext, name=name, kind=kind, media_type=media_type
856
+ ),
857
+ {"kind": kind, "sha256": hashlib.sha256(plaintext).hexdigest()},
858
+ )
859
+ except asyncio.CancelledError:
860
+ raise
861
+ except Exception as error: # noqa: BLE001
862
+ error_kind = f"media_materialization_failed:{type(error).__name__}"
863
+ source_identity = self._content_fingerprint(
864
+ {
865
+ "url": url,
866
+ "aes_key": aes_key if isinstance(aes_key, str) else None,
867
+ }
868
+ )
869
+ return (
870
+ self._context.attachments.failed(
871
+ name=f"{kind}.bin",
872
+ kind=kind,
873
+ error=error_kind,
874
+ ),
875
+ {
876
+ "kind": kind,
877
+ "error": error_kind,
878
+ "source_identity": source_identity,
879
+ },
880
+ )
881
+
882
+ @staticmethod
883
+ def _content_fingerprint(value: object) -> str:
884
+ encoded = json.dumps(
885
+ value,
886
+ ensure_ascii=False,
887
+ separators=(",", ":"),
888
+ sort_keys=True,
889
+ ).encode()
890
+ return hashlib.sha256(encoded).hexdigest()
891
+
892
+ @staticmethod
893
+ def _decrypt(content: bytes, aes_key: str) -> bytes:
894
+ key = base64.b64decode(aes_key + "=" * (-len(aes_key) % 4))
895
+ if len(key) != 32 or not content or len(content) % 16:
896
+ raise ValueError("invalid encrypted media")
897
+ decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor()
898
+ padded = decryptor.update(content) + decryptor.finalize()
899
+ padding = padded[-1]
900
+ if (
901
+ padding < 1
902
+ or padding > 32
903
+ or padded[-padding:] != bytes([padding]) * padding
904
+ ):
905
+ raise ValueError("invalid encrypted media padding")
906
+ return padded[:-padding]
907
+
908
+ @staticmethod
909
+ def _filename(value: str | None, kind: str) -> str:
910
+ if value:
911
+ message = Message()
912
+ message["Content-Disposition"] = value
913
+ filename = message.get_filename()
914
+ if filename:
915
+ return unquote(filename)
916
+ return f"{kind}.bin"
917
+
918
+ @staticmethod
919
+ def _frame(raw: str | bytes) -> dict[str, object]:
920
+ if isinstance(raw, bytes):
921
+ raw = raw.decode("utf-8")
922
+ payload = json.loads(raw)
923
+ if not isinstance(payload, dict):
924
+ raise TypeError("WeCom frame must be a JSON object")
925
+ return payload
926
+
927
+ @staticmethod
928
+ def _message_data(message: aiohttp.WSMessage) -> str | bytes:
929
+ if message.type not in {aiohttp.WSMsgType.TEXT, aiohttp.WSMsgType.BINARY}:
930
+ raise TypeError("WeCom frame must be text or binary")
931
+ if not isinstance(message.data, str | bytes):
932
+ raise TypeError("WeCom frame payload is invalid")
933
+ return message.data
934
+
935
+ @staticmethod
936
+ def _request_id(frame: Mapping[str, object]) -> str:
937
+ headers = frame.get("headers")
938
+ request_id = headers.get("req_id") if isinstance(headers, dict) else None
939
+ return request_id if isinstance(request_id, str) else ""
940
+
941
+ @staticmethod
942
+ def _is_disconnected_event(frame: Mapping[str, object]) -> bool:
943
+ if frame.get("cmd") != "aibot_event_callback":
944
+ return False
945
+ body = frame.get("body")
946
+ event = body.get("event") if isinstance(body, dict) else None
947
+ return (
948
+ isinstance(event, dict) and event.get("eventtype") == "disconnected_event"
949
+ )
950
+
951
+ async def _backoff(self, attempt: int) -> None:
952
+ delay = min(2 ** max(attempt - 1, 0), 30)
953
+ await asyncio.sleep(delay + random.uniform(0, min(delay * 0.2, 1)))
954
+
955
+
956
+ class _AuthenticationError(RuntimeError):
957
+ pass
958
+
959
+
960
+ __all__ = ["WeComChannel"]