bazaar-compute-node 0.2.1rc15__py3-none-any.whl → 0.2.2rc2__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/app/attachments.py +6 -26
- bazaar_compute_node/contrib/wecom/channel.py +83 -32
- bazaar_compute_node/contrib/wecom/identity.py +49 -1
- bazaar_compute_node/contrib/wecom/topic.py +711 -0
- bazaar_compute_node/contrib/wecom/topic_api.py +364 -0
- bazaar_compute_node/core/orchestration/orchestrator.py +39 -2
- {bazaar_compute_node-0.2.1rc15.dist-info → bazaar_compute_node-0.2.2rc2.dist-info}/METADATA +1 -1
- {bazaar_compute_node-0.2.1rc15.dist-info → bazaar_compute_node-0.2.2rc2.dist-info}/RECORD +11 -9
- {bazaar_compute_node-0.2.1rc15.dist-info → bazaar_compute_node-0.2.2rc2.dist-info}/WHEEL +0 -0
- {bazaar_compute_node-0.2.1rc15.dist-info → bazaar_compute_node-0.2.2rc2.dist-info}/entry_points.txt +0 -0
- {bazaar_compute_node-0.2.1rc15.dist-info → bazaar_compute_node-0.2.2rc2.dist-info}/licenses/LICENSE +0 -0
|
@@ -21,12 +21,10 @@ class AttachmentMaterializer:
|
|
|
21
21
|
referenced_paths: Callable[[], Awaitable[set[str]]],
|
|
22
22
|
*,
|
|
23
23
|
max_file_bytes: int = 25 * 1024 * 1024,
|
|
24
|
-
max_workspace_bytes: int = 1024 * 1024 * 1024,
|
|
25
24
|
) -> None:
|
|
26
25
|
self._workspace = workspace
|
|
27
26
|
self._referenced_paths = referenced_paths
|
|
28
27
|
self._max_file_bytes = max_file_bytes
|
|
29
|
-
self._max_workspace_bytes = max_workspace_bytes
|
|
30
28
|
self._lock = asyncio.Lock()
|
|
31
29
|
|
|
32
30
|
async def reconcile(self) -> None:
|
|
@@ -66,11 +64,9 @@ class AttachmentMaterializer:
|
|
|
66
64
|
source: bytes | AsyncIterable[bytes],
|
|
67
65
|
temporary: Path,
|
|
68
66
|
destination: Path,
|
|
69
|
-
root: Path,
|
|
70
67
|
) -> int:
|
|
71
68
|
"""Write an attachment aside, then move it into place once it is whole."""
|
|
72
69
|
|
|
73
|
-
current_size = await asyncio.to_thread(self._stored_size, root)
|
|
74
70
|
descriptor: int | None = None
|
|
75
71
|
try:
|
|
76
72
|
descriptor = await asyncio.to_thread(
|
|
@@ -80,7 +76,7 @@ class AttachmentMaterializer:
|
|
|
80
76
|
0o600,
|
|
81
77
|
)
|
|
82
78
|
try:
|
|
83
|
-
size = await self._write(descriptor, source
|
|
79
|
+
size = await self._write(descriptor, source)
|
|
84
80
|
await asyncio.to_thread(os.fsync, descriptor)
|
|
85
81
|
stored = await asyncio.to_thread(os.fstat, descriptor)
|
|
86
82
|
if stored.st_size != size:
|
|
@@ -107,12 +103,12 @@ class AttachmentMaterializer:
|
|
|
107
103
|
self,
|
|
108
104
|
descriptor: int,
|
|
109
105
|
source: bytes | AsyncIterable[bytes],
|
|
110
|
-
current_size: int,
|
|
111
106
|
) -> int:
|
|
112
107
|
"""Write the source through, refusing it the moment it goes over a limit."""
|
|
113
108
|
|
|
114
109
|
if isinstance(source, bytes):
|
|
115
|
-
|
|
110
|
+
if len(source) > self._max_file_bytes:
|
|
111
|
+
raise ValueError("attachment exceeds the per-file size limit")
|
|
116
112
|
await asyncio.to_thread(_write_all, descriptor, source)
|
|
117
113
|
return len(source)
|
|
118
114
|
size = 0
|
|
@@ -120,16 +116,11 @@ class AttachmentMaterializer:
|
|
|
120
116
|
if not isinstance(chunk, bytes):
|
|
121
117
|
raise TypeError("attachment stream must yield bytes")
|
|
122
118
|
size += len(chunk)
|
|
123
|
-
self.
|
|
119
|
+
if size > self._max_file_bytes:
|
|
120
|
+
raise ValueError("attachment exceeds the per-file size limit")
|
|
124
121
|
await asyncio.to_thread(_write_all, descriptor, chunk)
|
|
125
122
|
return size
|
|
126
123
|
|
|
127
|
-
def _check_room(self, size: int, current_size: int) -> None:
|
|
128
|
-
if size > self._max_file_bytes:
|
|
129
|
-
raise ValueError("attachment exceeds the per-file size limit")
|
|
130
|
-
if current_size + size > self._max_workspace_bytes:
|
|
131
|
-
raise ValueError("attachment workspace quota exceeded")
|
|
132
|
-
|
|
133
124
|
async def materialize(
|
|
134
125
|
self,
|
|
135
126
|
source: bytes | AsyncIterable[bytes],
|
|
@@ -153,7 +144,7 @@ class AttachmentMaterializer:
|
|
|
153
144
|
await asyncio.to_thread(
|
|
154
145
|
staging.mkdir, parents=True, exist_ok=True, mode=0o700
|
|
155
146
|
)
|
|
156
|
-
size = await self._stage(source, temporary, destination
|
|
147
|
+
size = await self._stage(source, temporary, destination)
|
|
157
148
|
return InboundAttachment(
|
|
158
149
|
attachment_id=attachment_id,
|
|
159
150
|
name=Path(name).name,
|
|
@@ -181,17 +172,6 @@ class AttachmentMaterializer:
|
|
|
181
172
|
error=error,
|
|
182
173
|
)
|
|
183
174
|
|
|
184
|
-
@staticmethod
|
|
185
|
-
def _stored_size(root: Path) -> int:
|
|
186
|
-
if not root.exists():
|
|
187
|
-
return 0
|
|
188
|
-
return sum(
|
|
189
|
-
path.stat().st_size
|
|
190
|
-
for pattern in ("*/content", "*/content.*")
|
|
191
|
-
for path in root.glob(pattern)
|
|
192
|
-
if path.is_file() and not path.is_symlink()
|
|
193
|
-
)
|
|
194
|
-
|
|
195
175
|
|
|
196
176
|
def _write_all(descriptor: int, payload: bytes) -> None:
|
|
197
177
|
view = memoryview(payload)
|
|
@@ -43,7 +43,7 @@ from ...core.timerwheel import TimerWheel
|
|
|
43
43
|
from ...core.utils.clock import remaining
|
|
44
44
|
from ...core.utils.markdown import split_markdown, utf8_bytes
|
|
45
45
|
from ...i18n import ENGLISH, Translator, create_translator
|
|
46
|
-
from .identity import conversation_thread_id, parse_provider_thread_id
|
|
46
|
+
from .identity import TopicAddress, conversation_thread_id, parse_provider_thread_id
|
|
47
47
|
from .outbound import (
|
|
48
48
|
AttachmentReader,
|
|
49
49
|
PreparedAttachment,
|
|
@@ -52,6 +52,7 @@ from .outbound import (
|
|
|
52
52
|
prepare_attachments,
|
|
53
53
|
visible_message_body,
|
|
54
54
|
)
|
|
55
|
+
from .topic import TopicConversations
|
|
55
56
|
|
|
56
57
|
_STOP = object()
|
|
57
58
|
_MAX_MEDIA_BYTES = 25 * 1024 * 1024
|
|
@@ -198,6 +199,13 @@ class WeComChannel(IChannel):
|
|
|
198
199
|
self._logger.addHandler(logging.StreamHandler())
|
|
199
200
|
self._logger.setLevel(logging.INFO)
|
|
200
201
|
self._logger.propagate = False
|
|
202
|
+
self._topics = TopicConversations(
|
|
203
|
+
context,
|
|
204
|
+
bot_id=bot_id,
|
|
205
|
+
secret=secret,
|
|
206
|
+
emit=self._inbound.put,
|
|
207
|
+
logger=self._logger,
|
|
208
|
+
)
|
|
201
209
|
|
|
202
210
|
@property
|
|
203
211
|
def name(self) -> str:
|
|
@@ -241,6 +249,7 @@ class WeComChannel(IChannel):
|
|
|
241
249
|
self._startup_finished.clear()
|
|
242
250
|
self._startup_error = None
|
|
243
251
|
self._state = "connecting"
|
|
252
|
+
self._topics.start()
|
|
244
253
|
self._runner = asyncio.create_task(self._run(), name="bcn-wecom-channel")
|
|
245
254
|
try:
|
|
246
255
|
await asyncio.wait_for(self._startup_finished.wait(), timeout=timeout)
|
|
@@ -249,46 +258,57 @@ class WeComChannel(IChannel):
|
|
|
249
258
|
self._runner.cancel()
|
|
250
259
|
await asyncio.gather(self._runner, return_exceptions=True)
|
|
251
260
|
self._runner = None
|
|
261
|
+
await self._topics.stop(timeout=timeout)
|
|
252
262
|
self._state = "stopped"
|
|
253
263
|
raise
|
|
254
264
|
if self._startup_error is not None:
|
|
265
|
+
await self._topics.stop(timeout=timeout)
|
|
255
266
|
raise self._startup_error
|
|
256
267
|
|
|
257
268
|
async def stop(self, *, timeout: float) -> None:
|
|
258
269
|
self._stopping.set()
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
ApprovalResult(
|
|
264
|
-
request_id=pending.request.approval.request_id,
|
|
265
|
-
decision=ApprovalDecision.REJECTED,
|
|
266
|
-
decided_at_ms=decided_at_ms,
|
|
267
|
-
reason="channel_stopped",
|
|
268
|
-
)
|
|
269
|
-
)
|
|
270
|
-
self._pending_approvals.clear()
|
|
271
|
-
self._approval_task_ids_by_request.clear()
|
|
272
|
-
await self._cancel_approval_card_updates()
|
|
270
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
271
|
+
shutdown = asyncio.create_task(
|
|
272
|
+
self._topics.stop(timeout=timeout), name="bcn-wecom-topics-stop"
|
|
273
|
+
)
|
|
273
274
|
try:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
275
|
+
decided_at_ms = time_ns() // 1_000_000
|
|
276
|
+
for pending in tuple(self._pending_approvals.values()):
|
|
277
|
+
if not pending.future.done():
|
|
278
|
+
pending.future.set_result(
|
|
279
|
+
ApprovalResult(
|
|
280
|
+
request_id=pending.request.approval.request_id,
|
|
281
|
+
decision=ApprovalDecision.REJECTED,
|
|
282
|
+
decided_at_ms=decided_at_ms,
|
|
283
|
+
reason="channel_stopped",
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
self._pending_approvals.clear()
|
|
287
|
+
self._approval_task_ids_by_request.clear()
|
|
288
|
+
await self._cancel_approval_card_updates()
|
|
284
289
|
try:
|
|
285
|
-
await asyncio.wait_for(
|
|
290
|
+
await asyncio.wait_for(
|
|
291
|
+
self._send_lock.acquire(), timeout=remaining(deadline)
|
|
292
|
+
)
|
|
286
293
|
except TimeoutError:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
294
|
+
pass
|
|
295
|
+
else:
|
|
296
|
+
self._send_lock.release()
|
|
297
|
+
connection = self._connection
|
|
298
|
+
if connection is not None:
|
|
299
|
+
await connection.close()
|
|
300
|
+
runner = self._runner
|
|
301
|
+
if runner is not None:
|
|
302
|
+
try:
|
|
303
|
+
await asyncio.wait_for(runner, timeout=remaining(deadline))
|
|
304
|
+
except TimeoutError:
|
|
305
|
+
runner.cancel()
|
|
306
|
+
await asyncio.gather(runner, return_exceptions=True)
|
|
307
|
+
finally:
|
|
308
|
+
self._runner = None
|
|
309
|
+
await shutdown
|
|
310
|
+
self._state = "stopped"
|
|
311
|
+
await self._inbound.put(_STOP)
|
|
292
312
|
|
|
293
313
|
async def receive(self) -> AsyncIterator[Message]:
|
|
294
314
|
while True:
|
|
@@ -510,6 +530,24 @@ class WeComChannel(IChannel):
|
|
|
510
530
|
async def send(
|
|
511
531
|
self, request: ChannelSendRequest, *, timeout: float
|
|
512
532
|
) -> ProviderCallResult[ChannelDeliveryReceipt]:
|
|
533
|
+
try:
|
|
534
|
+
address = TopicAddress.parse(request.provider_thread_id)
|
|
535
|
+
if address is not None and address.bot_id != self._bot_id:
|
|
536
|
+
raise ValueError("WeCom topic belongs to another bot")
|
|
537
|
+
except ValueError as error:
|
|
538
|
+
return ProviderCallResult(
|
|
539
|
+
status=ProviderCallStatus.FAILED,
|
|
540
|
+
error_kind="invalid_topic_target",
|
|
541
|
+
error_message=str(error),
|
|
542
|
+
)
|
|
543
|
+
if address is not None:
|
|
544
|
+
if self._stopping.is_set():
|
|
545
|
+
return ProviderCallResult(
|
|
546
|
+
status=ProviderCallStatus.FAILED,
|
|
547
|
+
error_kind="channel_stopped",
|
|
548
|
+
error_message="WeCom channel is stopping",
|
|
549
|
+
)
|
|
550
|
+
return await self._topics.send(request, address, timeout=timeout)
|
|
513
551
|
_, target_id = parse_provider_thread_id(request.provider_thread_id)
|
|
514
552
|
try:
|
|
515
553
|
batches = (
|
|
@@ -945,6 +983,13 @@ class WeComChannel(IChannel):
|
|
|
945
983
|
async def request_approval(
|
|
946
984
|
self, request: ChannelApprovalRequest, *, timeout: float
|
|
947
985
|
) -> ApprovalResult:
|
|
986
|
+
if TopicAddress.parse(request.provider_thread_id) is not None:
|
|
987
|
+
return ApprovalResult(
|
|
988
|
+
request_id=request.approval.request_id,
|
|
989
|
+
decision=ApprovalDecision.REJECTED,
|
|
990
|
+
decided_at_ms=time_ns() // 1_000_000,
|
|
991
|
+
reason="WeCom native topics do not support approval cards",
|
|
992
|
+
)
|
|
948
993
|
request_id = request.approval.request_id
|
|
949
994
|
if timeout <= 0:
|
|
950
995
|
raise TimeoutError("WeCom approval card delivery timed out")
|
|
@@ -1375,7 +1420,7 @@ class WeComChannel(IChannel):
|
|
|
1375
1420
|
return False
|
|
1376
1421
|
|
|
1377
1422
|
async def _receive_event(self, frame: Mapping[str, object]) -> None:
|
|
1378
|
-
"""
|
|
1423
|
+
"""Route native topic events and ordinary chat approval decisions."""
|
|
1379
1424
|
|
|
1380
1425
|
body = frame.get("body")
|
|
1381
1426
|
event = body.get("event") if isinstance(body, dict) else None
|
|
@@ -1387,6 +1432,8 @@ class WeComChannel(IChannel):
|
|
|
1387
1432
|
"wecom.event.received",
|
|
1388
1433
|
event_type=self._last_event_type,
|
|
1389
1434
|
)
|
|
1435
|
+
if isinstance(body, dict) and self._topics.receive(body):
|
|
1436
|
+
return
|
|
1390
1437
|
if (
|
|
1391
1438
|
self._last_event_type == "template_card_event"
|
|
1392
1439
|
and isinstance(event, dict)
|
|
@@ -1408,6 +1455,10 @@ class WeComChannel(IChannel):
|
|
|
1408
1455
|
if not isinstance(body, dict):
|
|
1409
1456
|
self._filter_message("invalid_body")
|
|
1410
1457
|
return
|
|
1458
|
+
compatibility = body.get("compat_info")
|
|
1459
|
+
if isinstance(compatibility, dict) and compatibility.get("source") == "channel":
|
|
1460
|
+
self._filter_message("native_topic_projection")
|
|
1461
|
+
return
|
|
1411
1462
|
route = self._route(body)
|
|
1412
1463
|
if route is None:
|
|
1413
1464
|
return
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from urllib.parse import quote, unquote
|
|
5
|
+
|
|
3
6
|
_PROVIDER_PREFIX = "wecom"
|
|
4
7
|
|
|
5
8
|
|
|
@@ -20,4 +23,49 @@ def parse_provider_thread_id(value: str) -> tuple[str, str]:
|
|
|
20
23
|
return parts[1], parts[2]
|
|
21
24
|
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class TopicAddress:
|
|
28
|
+
bot_id: str
|
|
29
|
+
topic_id: str
|
|
30
|
+
post_id: str | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def provider_thread_id(self) -> str:
|
|
34
|
+
value = (
|
|
35
|
+
f"wecom:{quote(self.bot_id, safe='')}:topic:{quote(self.topic_id, safe='')}"
|
|
36
|
+
)
|
|
37
|
+
if self.post_id is not None:
|
|
38
|
+
value += f":post:{quote(self.post_id, safe='')}"
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def parse(cls, value: str) -> TopicAddress | None:
|
|
43
|
+
parts = value.split(":")
|
|
44
|
+
if len(parts) not in {4, 6} or parts[0] != "wecom" or parts[2] != "topic":
|
|
45
|
+
return None
|
|
46
|
+
if not parts[1] or not parts[3]:
|
|
47
|
+
raise ValueError("WeCom topic address requires a bot and channel")
|
|
48
|
+
if len(parts) == 6 and (parts[4] != "post" or not parts[5]):
|
|
49
|
+
raise ValueError("WeCom topic address requires a post id")
|
|
50
|
+
return cls(
|
|
51
|
+
unquote(parts[1]),
|
|
52
|
+
unquote(parts[3]),
|
|
53
|
+
unquote(parts[5]) if len(parts) == 6 else None,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def post_message_id(post_id: str) -> str:
|
|
58
|
+
return "post:" + quote(post_id, safe="")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def reply_message_id(post_id: str, reply_id: str) -> str:
|
|
62
|
+
return "reply:" + quote(post_id, safe="") + ":" + quote(reply_id, safe="")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"TopicAddress",
|
|
67
|
+
"conversation_thread_id",
|
|
68
|
+
"parse_provider_thread_id",
|
|
69
|
+
"post_message_id",
|
|
70
|
+
"reply_message_id",
|
|
71
|
+
]
|