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.
- bazaar_compute_node/__init__.py +3 -0
- bazaar_compute_node/app/__init__.py +1 -0
- bazaar_compute_node/app/application.py +398 -0
- bazaar_compute_node/app/attachments.py +154 -0
- bazaar_compute_node/app/command.py +342 -0
- bazaar_compute_node/app/config.py +121 -0
- bazaar_compute_node/app/registry.py +120 -0
- bazaar_compute_node/app/transport.py +264 -0
- bazaar_compute_node/app/windows_pipe.py +463 -0
- bazaar_compute_node/app/wrapper.py +63 -0
- bazaar_compute_node/bcc.py +524 -0
- bazaar_compute_node/cli.py +382 -0
- bazaar_compute_node/contrib/__init__.py +1 -0
- bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
- bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
- bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
- bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
- bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
- bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
- bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
- bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
- bazaar_compute_node/contrib/logging/__init__.py +5 -0
- bazaar_compute_node/contrib/logging/audit.py +61 -0
- bazaar_compute_node/contrib/logging/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
- bazaar_compute_node/contrib/sqlite/codec.py +768 -0
- bazaar_compute_node/contrib/sqlite/database.py +282 -0
- bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
- bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
- bazaar_compute_node/contrib/wecom/__init__.py +1 -0
- bazaar_compute_node/contrib/wecom/channel.py +960 -0
- bazaar_compute_node/contrib/wecom/markdown.py +146 -0
- bazaar_compute_node/contrib/wecom/plugin.py +29 -0
- bazaar_compute_node/core/__init__.py +5 -0
- bazaar_compute_node/core/approval.py +51 -0
- bazaar_compute_node/core/audit.py +101 -0
- bazaar_compute_node/core/channel.py +121 -0
- bazaar_compute_node/core/client.py +30 -0
- bazaar_compute_node/core/command.py +85 -0
- bazaar_compute_node/core/concurrency.py +29 -0
- bazaar_compute_node/core/correlation.py +48 -0
- bazaar_compute_node/core/instruction.py +224 -0
- bazaar_compute_node/core/lifecycle.py +48 -0
- bazaar_compute_node/core/models/__init__.py +63 -0
- bazaar_compute_node/core/models/entities.py +514 -0
- bazaar_compute_node/core/models/states.py +369 -0
- bazaar_compute_node/core/observability.py +47 -0
- bazaar_compute_node/core/orchestration/__init__.py +5 -0
- bazaar_compute_node/core/orchestration/command.py +614 -0
- bazaar_compute_node/core/orchestration/services.py +135 -0
- bazaar_compute_node/core/orchestration/session.py +891 -0
- bazaar_compute_node/core/orchestration/turn.py +451 -0
- bazaar_compute_node/core/outcomes.py +51 -0
- bazaar_compute_node/core/paths.py +19 -0
- bazaar_compute_node/core/runtime.py +118 -0
- bazaar_compute_node/core/storage.py +167 -0
- bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
- bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
- bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
- bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
- 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"]
|