bazaar-compute-node 0.2.1rc15__py3-none-any.whl → 0.2.2__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/lark/channel.py +1 -3
- bazaar_compute_node/contrib/telegram/channel.py +11 -12
- bazaar_compute_node/contrib/wecom/channel.py +90 -38
- 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.2.dist-info}/METADATA +1 -1
- {bazaar_compute_node-0.2.1rc15.dist-info → bazaar_compute_node-0.2.2.dist-info}/RECORD +13 -11
- {bazaar_compute_node-0.2.1rc15.dist-info → bazaar_compute_node-0.2.2.dist-info}/WHEEL +0 -0
- {bazaar_compute_node-0.2.1rc15.dist-info → bazaar_compute_node-0.2.2.dist-info}/entry_points.txt +0 -0
- {bazaar_compute_node-0.2.1rc15.dist-info → bazaar_compute_node-0.2.2.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)
|
|
@@ -437,6 +437,7 @@ class LarkChannel(IChannel):
|
|
|
437
437
|
|
|
438
438
|
quoted: Message | None = None
|
|
439
439
|
if parent_id is not None:
|
|
440
|
+
metadata["provider_reply_to_message_id"] = parent_id
|
|
440
441
|
quoted, failure = await self._quoted_message(
|
|
441
442
|
parent_id,
|
|
442
443
|
chat_id=fields.chat_id,
|
|
@@ -462,7 +463,6 @@ class LarkChannel(IChannel):
|
|
|
462
463
|
mentions_agent=target_kind is ChannelTargetKind.GROUP,
|
|
463
464
|
notifies_runtime=True,
|
|
464
465
|
received_at_ms=received_at_ms,
|
|
465
|
-
reply_to_message_id=quoted.message_id if quoted is not None else None,
|
|
466
466
|
provider_payload_metadata=metadata,
|
|
467
467
|
)
|
|
468
468
|
await self._queue_inbound(
|
|
@@ -485,7 +485,6 @@ class LarkChannel(IChannel):
|
|
|
485
485
|
mentions_agent: bool,
|
|
486
486
|
notifies_runtime: bool,
|
|
487
487
|
received_at_ms: int,
|
|
488
|
-
reply_to_message_id: str | None = None,
|
|
489
488
|
provider_payload_metadata: Mapping[str, object] | None = None,
|
|
490
489
|
) -> Message:
|
|
491
490
|
provider_message_id = _provider_text(message.get("message_id"))
|
|
@@ -546,7 +545,6 @@ class LarkChannel(IChannel):
|
|
|
546
545
|
notifies_runtime=notifies_runtime,
|
|
547
546
|
attachments=attachments,
|
|
548
547
|
provider_time_ms=_provider_time_ms(message.get("create_time")),
|
|
549
|
-
reply_to_message_id=reply_to_message_id,
|
|
550
548
|
metadata=metadata,
|
|
551
549
|
)
|
|
552
550
|
|
|
@@ -779,9 +779,9 @@ class TelegramChannel(IChannel):
|
|
|
779
779
|
reply = message.get("reply_to_message")
|
|
780
780
|
|
|
781
781
|
received_at_ms = time_ns() // 1_000_000
|
|
782
|
-
|
|
782
|
+
reference: str | None = None
|
|
783
783
|
if isinstance(reply, Mapping):
|
|
784
|
-
|
|
784
|
+
reference = await self._queue_reply_backfill(
|
|
785
785
|
reply,
|
|
786
786
|
update_id=update_id,
|
|
787
787
|
identity=identity,
|
|
@@ -818,8 +818,8 @@ class TelegramChannel(IChannel):
|
|
|
818
818
|
notifies_runtime=activation.notifies_runtime,
|
|
819
819
|
attachments=update.content.attachments,
|
|
820
820
|
provider_time_ms=update.provider_time_s * 1_000,
|
|
821
|
-
reply_to_message_id=reply_to_message_id,
|
|
822
821
|
metadata={
|
|
822
|
+
"provider_reply_to_message_id": reference,
|
|
823
823
|
"telegram_update_id": update_id,
|
|
824
824
|
"telegram_chat_id": update.chat_id,
|
|
825
825
|
"telegram_message_thread_id": update.topic_id,
|
|
@@ -866,20 +866,20 @@ class TelegramChannel(IChannel):
|
|
|
866
866
|
bot_username = self._bot_username or ""
|
|
867
867
|
if bot_id is None:
|
|
868
868
|
raise RuntimeError("Telegram bot identity is not initialized")
|
|
869
|
-
# what the bot itself said is on record as sent; quoted back, it is
|
|
870
|
-
# not heard a second time as if someone else had said it
|
|
871
869
|
quoted_sender = reply.get("from")
|
|
872
|
-
if isinstance(quoted_sender, Mapping) and quoted_sender.get("id") == bot_id:
|
|
873
|
-
return None
|
|
874
870
|
content = await self._content(
|
|
875
871
|
reply,
|
|
876
872
|
bot_id=bot_id,
|
|
877
873
|
bot_username=bot_username,
|
|
878
874
|
)
|
|
879
875
|
if content is None:
|
|
880
|
-
return
|
|
876
|
+
return str(provider_message_id)
|
|
881
877
|
|
|
882
|
-
sender =
|
|
878
|
+
sender = (
|
|
879
|
+
None
|
|
880
|
+
if isinstance(quoted_sender, Mapping) and quoted_sender.get("id") == bot_id
|
|
881
|
+
else self._sender(reply)
|
|
882
|
+
)
|
|
883
883
|
provider_time_s = reply.get("date")
|
|
884
884
|
provider_time_ms: int | None = None
|
|
885
885
|
historical: bool | None = None
|
|
@@ -892,12 +892,11 @@ class TelegramChannel(IChannel):
|
|
|
892
892
|
historical = provider_time_s < self._started_at_s
|
|
893
893
|
|
|
894
894
|
channel_session_id = identity.channel_session_id
|
|
895
|
-
message_id = identity.message_id(provider_message_id)
|
|
896
895
|
await self._inbound.put(
|
|
897
896
|
Message(
|
|
898
897
|
direction=MessageDirection.INBOUND,
|
|
899
898
|
seq=0,
|
|
900
|
-
message_id=message_id,
|
|
899
|
+
message_id=identity.message_id(provider_message_id),
|
|
901
900
|
thread_id=identity.session_id,
|
|
902
901
|
channel_session_id=channel_session_id,
|
|
903
902
|
channel="telegram",
|
|
@@ -935,7 +934,7 @@ class TelegramChannel(IChannel):
|
|
|
935
934
|
)
|
|
936
935
|
self._messages_queued += 1
|
|
937
936
|
self._quoted_messages_queued += 1
|
|
938
|
-
return
|
|
937
|
+
return str(provider_message_id)
|
|
939
938
|
|
|
940
939
|
async def _content(
|
|
941
940
|
self,
|
|
@@ -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")
|
|
@@ -1247,7 +1292,7 @@ class WeComChannel(IChannel):
|
|
|
1247
1292
|
)
|
|
1248
1293
|
return None
|
|
1249
1294
|
quote_content = await self._content(quote, quote_type)
|
|
1250
|
-
|
|
1295
|
+
message_id = str(
|
|
1251
1296
|
uuid5(
|
|
1252
1297
|
NAMESPACE_URL,
|
|
1253
1298
|
f"bcn:wecom:{self._bot_id}:quoted-message:{conversation}:"
|
|
@@ -1258,7 +1303,7 @@ class WeComChannel(IChannel):
|
|
|
1258
1303
|
Message(
|
|
1259
1304
|
direction=MessageDirection.INBOUND,
|
|
1260
1305
|
seq=0,
|
|
1261
|
-
message_id=
|
|
1306
|
+
message_id=message_id,
|
|
1262
1307
|
thread_id=session_id,
|
|
1263
1308
|
channel_session_id=channel_session_id,
|
|
1264
1309
|
channel=self.name,
|
|
@@ -1277,7 +1322,7 @@ class WeComChannel(IChannel):
|
|
|
1277
1322
|
metadata={"sender_kind": SenderKind.HUMAN.value},
|
|
1278
1323
|
)
|
|
1279
1324
|
)
|
|
1280
|
-
return
|
|
1325
|
+
return quote_content.fingerprint
|
|
1281
1326
|
|
|
1282
1327
|
def _update_approval_card(
|
|
1283
1328
|
self,
|
|
@@ -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
|
|
@@ -1428,7 +1479,7 @@ class WeComChannel(IChannel):
|
|
|
1428
1479
|
create_time = body.get("create_time")
|
|
1429
1480
|
if isinstance(create_time, int) and not isinstance(create_time, bool):
|
|
1430
1481
|
metadata["provider_create_time"] = create_time
|
|
1431
|
-
|
|
1482
|
+
reference = await self._queue_quoted_message(
|
|
1432
1483
|
body.get("quote"),
|
|
1433
1484
|
provider_message_id=provider_message_id,
|
|
1434
1485
|
conversation=conversation,
|
|
@@ -1438,6 +1489,8 @@ class WeComChannel(IChannel):
|
|
|
1438
1489
|
target_kind=target_kind,
|
|
1439
1490
|
received_at_ms=received_at_ms,
|
|
1440
1491
|
)
|
|
1492
|
+
if reference is not None:
|
|
1493
|
+
metadata["provider_reply_to_message_id"] = reference
|
|
1441
1494
|
await self._inbound.put(
|
|
1442
1495
|
Message(
|
|
1443
1496
|
direction=MessageDirection.INBOUND,
|
|
@@ -1462,7 +1515,6 @@ class WeComChannel(IChannel):
|
|
|
1462
1515
|
target_kind=target_kind,
|
|
1463
1516
|
mentions_agent=mentions_agent,
|
|
1464
1517
|
attachments=content.attachments,
|
|
1465
|
-
reply_to_message_id=reply_to_message_id,
|
|
1466
1518
|
metadata=metadata,
|
|
1467
1519
|
)
|
|
1468
1520
|
)
|
|
@@ -1476,7 +1528,7 @@ class WeComChannel(IChannel):
|
|
|
1476
1528
|
session_id=session_id,
|
|
1477
1529
|
target_kind=target_kind.value,
|
|
1478
1530
|
message_type=message_type,
|
|
1479
|
-
referenced=
|
|
1531
|
+
referenced=reference is not None,
|
|
1480
1532
|
)
|
|
1481
1533
|
|
|
1482
1534
|
async def _send_approval_card_update(
|
|
@@ -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
|
+
]
|