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,524 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import NoReturn
|
|
9
|
+
from uuid import uuid7
|
|
10
|
+
|
|
11
|
+
from .app.command import format_message_time
|
|
12
|
+
from .app.transport import LocalCommandClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BccCommandError(RuntimeError):
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
message: str,
|
|
19
|
+
*,
|
|
20
|
+
code: str,
|
|
21
|
+
draft_saved: bool = False,
|
|
22
|
+
next_action: str | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.message = message
|
|
26
|
+
self.code = code
|
|
27
|
+
self.draft_saved = draft_saved
|
|
28
|
+
self.next_action = next_action
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
prog="bcc",
|
|
34
|
+
description="Session-scoped collaboration commands for a Bazaar Compute Node.",
|
|
35
|
+
)
|
|
36
|
+
subparsers = parser.add_subparsers(dest="resource", required=True)
|
|
37
|
+
message_parser = subparsers.add_parser("message")
|
|
38
|
+
message_subparsers = message_parser.add_subparsers(dest="command", required=True)
|
|
39
|
+
message_subparsers.add_parser("check")
|
|
40
|
+
|
|
41
|
+
read_parser = message_subparsers.add_parser("read")
|
|
42
|
+
read_parser.add_argument("--target", required=True)
|
|
43
|
+
read_parser.add_argument("--around")
|
|
44
|
+
read_parser.add_argument("--limit", type=int, default=100)
|
|
45
|
+
|
|
46
|
+
send_parser = message_subparsers.add_parser("send")
|
|
47
|
+
send_parser.add_argument("--target", required=True)
|
|
48
|
+
send_parser.add_argument("--reply-to")
|
|
49
|
+
|
|
50
|
+
thread_parser = subparsers.add_parser("thread")
|
|
51
|
+
thread_subparsers = thread_parser.add_subparsers(dest="command", required=True)
|
|
52
|
+
unfollow_parser = thread_subparsers.add_parser("unfollow")
|
|
53
|
+
unfollow_parser.add_argument("--target", required=True)
|
|
54
|
+
return parser
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _request(
|
|
58
|
+
args: argparse.Namespace,
|
|
59
|
+
*,
|
|
60
|
+
body: str | None = None,
|
|
61
|
+
) -> Mapping[str, object]:
|
|
62
|
+
endpoint = os.environ.get("BCN_ENDPOINT")
|
|
63
|
+
session_id = os.environ.get("BCN_SESSION_ID")
|
|
64
|
+
if not endpoint:
|
|
65
|
+
raise BccCommandError(
|
|
66
|
+
"BCN_ENDPOINT is not set",
|
|
67
|
+
code="LOCAL_ENDPOINT_REQUIRED",
|
|
68
|
+
)
|
|
69
|
+
if not session_id:
|
|
70
|
+
raise BccCommandError(
|
|
71
|
+
"BCN_SESSION_ID is not set",
|
|
72
|
+
code="SESSION_REQUIRED",
|
|
73
|
+
)
|
|
74
|
+
runtime_session_id = os.environ.get("BCN_RUNTIME_SESSION_ID")
|
|
75
|
+
if not runtime_session_id:
|
|
76
|
+
raise BccCommandError(
|
|
77
|
+
"BCN_RUNTIME_SESSION_ID is not set",
|
|
78
|
+
code="SESSION_BINDING_REQUIRED",
|
|
79
|
+
)
|
|
80
|
+
session_capability = os.environ.get("BCN_COMMAND_CAPABILITY")
|
|
81
|
+
if not session_capability:
|
|
82
|
+
raise BccCommandError(
|
|
83
|
+
"BCN_COMMAND_CAPABILITY is not set",
|
|
84
|
+
code="SESSION_BINDING_REQUIRED",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
request: dict[str, object] = {
|
|
88
|
+
"kind": "command",
|
|
89
|
+
"session_id": session_id,
|
|
90
|
+
"runtime_session_id": runtime_session_id,
|
|
91
|
+
"session_capability": session_capability,
|
|
92
|
+
"command": args.command,
|
|
93
|
+
}
|
|
94
|
+
if args.command == "read":
|
|
95
|
+
request["target"] = args.target
|
|
96
|
+
request["around_message_id"] = args.around
|
|
97
|
+
request["limit"] = args.limit
|
|
98
|
+
elif args.command == "send":
|
|
99
|
+
request["target"] = args.target
|
|
100
|
+
request["body"] = body if body is not None else ""
|
|
101
|
+
request["command_id"] = f"bcc-{uuid7().hex}"
|
|
102
|
+
request["reply_to_message_id"] = args.reply_to
|
|
103
|
+
elif args.command == "unfollow":
|
|
104
|
+
request["target"] = args.target
|
|
105
|
+
|
|
106
|
+
response = await LocalCommandClient.request(endpoint, request)
|
|
107
|
+
if response.get("ok") is not True:
|
|
108
|
+
raise BccCommandError(
|
|
109
|
+
str(response.get("error", "command failed")),
|
|
110
|
+
code=str(response.get("code", "COMMAND_FAILED")),
|
|
111
|
+
draft_saved=response.get("draft_saved") is True,
|
|
112
|
+
next_action=(
|
|
113
|
+
str(response["next_action"])
|
|
114
|
+
if response.get("next_action") is not None
|
|
115
|
+
else None
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
return response
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _require_result(response: Mapping[str, object]) -> Mapping[str, object]:
|
|
122
|
+
result = response.get("result")
|
|
123
|
+
if not isinstance(result, Mapping):
|
|
124
|
+
raise BccCommandError(
|
|
125
|
+
"command response has no result object",
|
|
126
|
+
code="INVALID_RESPONSE",
|
|
127
|
+
)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _require_message_list(
|
|
132
|
+
result: Mapping[str, object], field_name: str
|
|
133
|
+
) -> list[Mapping[str, object]]:
|
|
134
|
+
messages = result.get(field_name)
|
|
135
|
+
if not isinstance(messages, list):
|
|
136
|
+
raise BccCommandError(
|
|
137
|
+
f"command response has no {field_name} list",
|
|
138
|
+
code="INVALID_RESPONSE",
|
|
139
|
+
)
|
|
140
|
+
if not all(isinstance(message, Mapping) for message in messages):
|
|
141
|
+
raise BccCommandError(
|
|
142
|
+
"command response contains an invalid message",
|
|
143
|
+
code="INVALID_RESPONSE",
|
|
144
|
+
)
|
|
145
|
+
return messages
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _require_messages(result: Mapping[str, object]) -> list[Mapping[str, object]]:
|
|
149
|
+
return _require_message_list(result, "messages")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _invalid_response(message: str) -> NoReturn:
|
|
153
|
+
raise BccCommandError(message, code="INVALID_RESPONSE")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _require_text(
|
|
157
|
+
message: Mapping[str, object],
|
|
158
|
+
field_name: str,
|
|
159
|
+
*,
|
|
160
|
+
allow_empty: bool = False,
|
|
161
|
+
) -> str:
|
|
162
|
+
value = message.get(field_name)
|
|
163
|
+
if not isinstance(value, str) or (not allow_empty and not value):
|
|
164
|
+
_invalid_response(f"command response contains an invalid message {field_name}")
|
|
165
|
+
return value
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _require_non_negative_int(
|
|
169
|
+
message: Mapping[str, object],
|
|
170
|
+
field_name: str,
|
|
171
|
+
) -> int:
|
|
172
|
+
value = message.get(field_name)
|
|
173
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
174
|
+
_invalid_response(f"command response contains an invalid message {field_name}")
|
|
175
|
+
return value
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _require_result_sequence(result: Mapping[str, object], field_name: str) -> int:
|
|
179
|
+
value = result.get(field_name)
|
|
180
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
181
|
+
_invalid_response(f"command response contains an invalid {field_name}")
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _message_timestamp(message: Mapping[str, object]) -> int:
|
|
186
|
+
timestamp = message.get("provider_time_ms")
|
|
187
|
+
if timestamp is None:
|
|
188
|
+
timestamp = message.get("received_at_ms")
|
|
189
|
+
if isinstance(timestamp, bool) or not isinstance(timestamp, int) or timestamp < 0:
|
|
190
|
+
_invalid_response("command response contains an invalid message timestamp")
|
|
191
|
+
return timestamp
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _format_message_timestamp(message: Mapping[str, object]) -> str:
|
|
195
|
+
return format_message_time(_message_timestamp(message))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _message_header_fields(
|
|
199
|
+
message: Mapping[str, object],
|
|
200
|
+
) -> tuple[str, str, str, str, str | None, str]:
|
|
201
|
+
target = _require_text(message, "canonical_target")
|
|
202
|
+
message_id = _require_text(message, "message_id")
|
|
203
|
+
sender = message.get("sender")
|
|
204
|
+
if sender is not None and (not isinstance(sender, str) or not sender):
|
|
205
|
+
_invalid_response("command response contains an invalid message sender")
|
|
206
|
+
return (
|
|
207
|
+
target,
|
|
208
|
+
message_id,
|
|
209
|
+
_format_message_timestamp(message),
|
|
210
|
+
_require_text(message, "message_type"),
|
|
211
|
+
sender,
|
|
212
|
+
_require_text(message, "body", allow_empty=True),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _format_check_message(message: Mapping[str, object]) -> str:
|
|
217
|
+
(
|
|
218
|
+
target,
|
|
219
|
+
message_id,
|
|
220
|
+
timestamp,
|
|
221
|
+
message_type,
|
|
222
|
+
sender,
|
|
223
|
+
body,
|
|
224
|
+
) = _message_header_fields(message)
|
|
225
|
+
line = (
|
|
226
|
+
f"[target={target} msg={message_id} time={timestamp} "
|
|
227
|
+
f"type={message_type} mentioned={str(message.get('mentions_agent') is True).lower()}"
|
|
228
|
+
)
|
|
229
|
+
reply_to_message_id = message.get("reply_to_message_id")
|
|
230
|
+
if reply_to_message_id is not None:
|
|
231
|
+
if not isinstance(reply_to_message_id, str) or not reply_to_message_id:
|
|
232
|
+
_invalid_response(
|
|
233
|
+
"command response contains an invalid message reply_to_message_id"
|
|
234
|
+
)
|
|
235
|
+
line += f" reply_to={reply_to_message_id}"
|
|
236
|
+
line += "] "
|
|
237
|
+
if sender is not None:
|
|
238
|
+
line += f"@{sender}: "
|
|
239
|
+
return line + body + _attachment_suffix(message)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _format_read_message(
|
|
243
|
+
message: Mapping[str, object],
|
|
244
|
+
*,
|
|
245
|
+
index: int,
|
|
246
|
+
count: int,
|
|
247
|
+
) -> str:
|
|
248
|
+
(
|
|
249
|
+
target,
|
|
250
|
+
message_id,
|
|
251
|
+
timestamp,
|
|
252
|
+
message_type,
|
|
253
|
+
sender,
|
|
254
|
+
body,
|
|
255
|
+
) = _message_header_fields(message)
|
|
256
|
+
fields = [
|
|
257
|
+
f"seq={_require_non_negative_int(message, 'seq')}",
|
|
258
|
+
f"msg={message_id}",
|
|
259
|
+
f"time={timestamp}",
|
|
260
|
+
f"type={message_type}",
|
|
261
|
+
]
|
|
262
|
+
fields.append(f"replyTarget={target}")
|
|
263
|
+
fields.append(f"mentioned={str(message.get('mentions_agent') is True).lower()}")
|
|
264
|
+
reply_to_message_id = message.get("reply_to_message_id")
|
|
265
|
+
if reply_to_message_id is not None:
|
|
266
|
+
if not isinstance(reply_to_message_id, str) or not reply_to_message_id:
|
|
267
|
+
_invalid_response(
|
|
268
|
+
"command response contains an invalid message reply_to_message_id"
|
|
269
|
+
)
|
|
270
|
+
fields.append(f"replyTo={reply_to_message_id}")
|
|
271
|
+
line = f"[{index}/{count} {' '.join(fields)}] "
|
|
272
|
+
if sender is not None:
|
|
273
|
+
line += f"@{sender}: "
|
|
274
|
+
return line + body + _attachment_suffix(message)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _attachment_suffix(message: Mapping[str, object]) -> str:
|
|
278
|
+
attachments = message.get("attachments")
|
|
279
|
+
if not isinstance(attachments, list):
|
|
280
|
+
_invalid_response("command response contains invalid attachments")
|
|
281
|
+
if not attachments:
|
|
282
|
+
return ""
|
|
283
|
+
rendered: list[str] = []
|
|
284
|
+
for attachment in attachments:
|
|
285
|
+
if not isinstance(attachment, dict):
|
|
286
|
+
_invalid_response("command response contains an invalid attachment")
|
|
287
|
+
name = _require_text(attachment, "name")
|
|
288
|
+
attachment_id = _require_text(attachment, "attachment_id")
|
|
289
|
+
state = _require_text(attachment, "state")
|
|
290
|
+
if state == "ready":
|
|
291
|
+
path = _require_text(attachment, "relative_path")
|
|
292
|
+
rendered.append(f"{name} (id:{attachment_id}, path:{path})")
|
|
293
|
+
elif state == "failed":
|
|
294
|
+
error = _require_text(attachment, "error")
|
|
295
|
+
rendered.append(f"{name} (id:{attachment_id}, state:failed, error:{error})")
|
|
296
|
+
else:
|
|
297
|
+
_invalid_response("command response contains an invalid attachment state")
|
|
298
|
+
label = "attachment" if len(rendered) == 1 else "attachments"
|
|
299
|
+
return f" [{len(rendered)} {label}: {', '.join(rendered)}]"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def serialize_check(result: Mapping[str, object]) -> str:
|
|
303
|
+
"""Serialize one check result using the stable agent-facing text contract."""
|
|
304
|
+
|
|
305
|
+
snapshot_seq = _require_result_sequence(result, "snapshot_seq")
|
|
306
|
+
delivered_through_seq = _require_result_sequence(result, "delivered_through_seq")
|
|
307
|
+
if delivered_through_seq > snapshot_seq:
|
|
308
|
+
_invalid_response(
|
|
309
|
+
"command response contains an invalid check sequence boundary"
|
|
310
|
+
)
|
|
311
|
+
messages = _require_messages(result)
|
|
312
|
+
referenced_messages = _require_message_list(result, "referenced_messages")
|
|
313
|
+
lines: list[str] = []
|
|
314
|
+
if referenced_messages:
|
|
315
|
+
lines.append(f"Referenced messages: {len(referenced_messages)}")
|
|
316
|
+
lines.extend(
|
|
317
|
+
_format_read_message(
|
|
318
|
+
message,
|
|
319
|
+
index=index,
|
|
320
|
+
count=len(referenced_messages),
|
|
321
|
+
)
|
|
322
|
+
for index, message in enumerate(referenced_messages, start=1)
|
|
323
|
+
)
|
|
324
|
+
lines.append("New messages:")
|
|
325
|
+
lines.extend(_format_check_message(message) for message in messages)
|
|
326
|
+
if not lines:
|
|
327
|
+
lines.append("No more new messages.")
|
|
328
|
+
return "\n".join(lines)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def serialize_read(result: Mapping[str, object]) -> str:
|
|
332
|
+
"""Serialize one read result with history positioning fields."""
|
|
333
|
+
|
|
334
|
+
_require_result_sequence(result, "snapshot_seq")
|
|
335
|
+
messages = _require_messages(result)
|
|
336
|
+
referenced_messages = _require_message_list(result, "referenced_messages")
|
|
337
|
+
first_seq = result.get("first_seq")
|
|
338
|
+
last_seq = result.get("last_seq")
|
|
339
|
+
if not messages:
|
|
340
|
+
if first_seq is not None or last_seq is not None:
|
|
341
|
+
_invalid_response("empty read response has sequence bounds")
|
|
342
|
+
bounds = "none-none"
|
|
343
|
+
else:
|
|
344
|
+
if (
|
|
345
|
+
isinstance(first_seq, bool)
|
|
346
|
+
or not isinstance(first_seq, int)
|
|
347
|
+
or first_seq < 0
|
|
348
|
+
or isinstance(last_seq, bool)
|
|
349
|
+
or not isinstance(last_seq, int)
|
|
350
|
+
or last_seq < first_seq
|
|
351
|
+
):
|
|
352
|
+
_invalid_response("command response contains invalid read bounds")
|
|
353
|
+
first_message_seq = _require_non_negative_int(messages[0], "seq")
|
|
354
|
+
last_message_seq = _require_non_negative_int(messages[-1], "seq")
|
|
355
|
+
if first_seq != first_message_seq or last_seq != last_message_seq:
|
|
356
|
+
_invalid_response("command response read bounds do not match messages")
|
|
357
|
+
bounds = f"{first_seq}-{last_seq}"
|
|
358
|
+
lines = [f"Read window: {len(messages)} returned, seq {bounds}, oldest to newest."]
|
|
359
|
+
if referenced_messages:
|
|
360
|
+
lines.append(f"Referenced messages: {len(referenced_messages)}")
|
|
361
|
+
lines.extend(
|
|
362
|
+
_format_read_message(
|
|
363
|
+
message,
|
|
364
|
+
index=index,
|
|
365
|
+
count=len(referenced_messages),
|
|
366
|
+
)
|
|
367
|
+
for index, message in enumerate(referenced_messages, start=1)
|
|
368
|
+
)
|
|
369
|
+
lines.append("Window messages:")
|
|
370
|
+
lines.extend(
|
|
371
|
+
_format_read_message(message, index=index, count=len(messages))
|
|
372
|
+
for index, message in enumerate(messages, start=1)
|
|
373
|
+
)
|
|
374
|
+
return "\n".join(lines)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _render_check(result: Mapping[str, object]) -> None:
|
|
378
|
+
print(serialize_check(result))
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _render_read(result: Mapping[str, object]) -> None:
|
|
382
|
+
print(serialize_read(result))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _invalid_send_response(message: str) -> NoReturn:
|
|
386
|
+
raise BccCommandError(message, code="INVALID_RESPONSE")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _require_outbound_text(
|
|
390
|
+
outbound: Mapping[str, object],
|
|
391
|
+
field_name: str,
|
|
392
|
+
*,
|
|
393
|
+
allow_empty: bool = False,
|
|
394
|
+
) -> str:
|
|
395
|
+
value = outbound.get(field_name)
|
|
396
|
+
if not isinstance(value, str) or (not allow_empty and not value):
|
|
397
|
+
_invalid_send_response(
|
|
398
|
+
f"command response contains an invalid outbound {field_name}"
|
|
399
|
+
)
|
|
400
|
+
return value
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _require_outbound_timestamp(
|
|
404
|
+
outbound: Mapping[str, object], field_name: str
|
|
405
|
+
) -> int | None:
|
|
406
|
+
value = outbound.get(field_name)
|
|
407
|
+
if value is None:
|
|
408
|
+
return None
|
|
409
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
410
|
+
_invalid_send_response(
|
|
411
|
+
f"command response contains an invalid outbound {field_name}"
|
|
412
|
+
)
|
|
413
|
+
return value
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def serialize_send(result: Mapping[str, object]) -> str:
|
|
417
|
+
"""Serialize a send result or raise the stable command error contract."""
|
|
418
|
+
|
|
419
|
+
outbound = result.get("outbound")
|
|
420
|
+
if not isinstance(outbound, Mapping):
|
|
421
|
+
_invalid_send_response("command response has no outbound object")
|
|
422
|
+
state = outbound.get("state")
|
|
423
|
+
if not isinstance(state, str) or state not in {
|
|
424
|
+
"sent",
|
|
425
|
+
"queued",
|
|
426
|
+
"partial",
|
|
427
|
+
"failed",
|
|
428
|
+
"unknown",
|
|
429
|
+
"rejected",
|
|
430
|
+
}:
|
|
431
|
+
_invalid_send_response("command response contains an invalid outbound state")
|
|
432
|
+
target = _require_outbound_text(outbound, "target")
|
|
433
|
+
outbound_message_id = _require_outbound_text(outbound, "outbound_message_id")
|
|
434
|
+
if state == "sent":
|
|
435
|
+
return f"Message sent to {target}. Message ID: {outbound_message_id}"
|
|
436
|
+
if state == "queued":
|
|
437
|
+
return f"Message queued to {target}. Message ID: {outbound_message_id}"
|
|
438
|
+
|
|
439
|
+
error_kind = outbound.get("error_kind")
|
|
440
|
+
if not isinstance(error_kind, str) or not error_kind:
|
|
441
|
+
_invalid_send_response(
|
|
442
|
+
"command response contains no outbound error kind for a failed delivery"
|
|
443
|
+
)
|
|
444
|
+
error_message = outbound.get("error_message")
|
|
445
|
+
if not isinstance(error_message, str) or not error_message:
|
|
446
|
+
_invalid_send_response(
|
|
447
|
+
"command response contains no outbound error message for a failed delivery"
|
|
448
|
+
)
|
|
449
|
+
draft_saved_at_ms = _require_outbound_timestamp(outbound, "draft_saved_at_ms")
|
|
450
|
+
if state == "rejected" and draft_saved_at_ms is None and error_kind != "empty_body":
|
|
451
|
+
_invalid_send_response("rejected outbound response has no saved draft")
|
|
452
|
+
next_action_value = outbound.get("next_action")
|
|
453
|
+
if next_action_value is not None and (
|
|
454
|
+
not isinstance(next_action_value, str) or not next_action_value
|
|
455
|
+
):
|
|
456
|
+
_invalid_send_response(
|
|
457
|
+
"command response contains an invalid outbound next_action"
|
|
458
|
+
)
|
|
459
|
+
next_action = next_action_value if isinstance(next_action_value, str) else None
|
|
460
|
+
if state == "partial":
|
|
461
|
+
code = "SEND_PARTIAL"
|
|
462
|
+
elif state == "unknown" or error_kind == "provider_unknown":
|
|
463
|
+
code = "SEND_UNKNOWN"
|
|
464
|
+
elif error_kind == "empty_body":
|
|
465
|
+
code = "SEND_EMPTY_BODY"
|
|
466
|
+
elif error_kind == "fresh_check_required":
|
|
467
|
+
code = "SEND_FRESH_CHECK_REQUIRED"
|
|
468
|
+
elif error_kind == "fresh_check_failed":
|
|
469
|
+
code = "SEND_FRESH_CHECK_FAILED"
|
|
470
|
+
elif state == "failed" or error_kind in {
|
|
471
|
+
"provider_failed",
|
|
472
|
+
"target_not_replyable",
|
|
473
|
+
}:
|
|
474
|
+
code = "SEND_FAILED"
|
|
475
|
+
else:
|
|
476
|
+
code = "SEND_REJECTED"
|
|
477
|
+
raise BccCommandError(
|
|
478
|
+
error_message,
|
|
479
|
+
code=code,
|
|
480
|
+
draft_saved=draft_saved_at_ms is not None,
|
|
481
|
+
next_action=next_action,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _render_send(result: Mapping[str, object]) -> None:
|
|
486
|
+
print(serialize_send(result))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
async def async_main(argv: Sequence[str] | None = None) -> int:
|
|
490
|
+
parser = build_parser()
|
|
491
|
+
args = parser.parse_args(argv)
|
|
492
|
+
body = None
|
|
493
|
+
if args.command == "send":
|
|
494
|
+
body = await asyncio.to_thread(sys.stdin.read)
|
|
495
|
+
response = await _request(args, body=body)
|
|
496
|
+
result = _require_result(response)
|
|
497
|
+
if args.command == "check":
|
|
498
|
+
_render_check(result)
|
|
499
|
+
elif args.command == "read":
|
|
500
|
+
_render_read(result)
|
|
501
|
+
elif args.command == "send":
|
|
502
|
+
_render_send(result)
|
|
503
|
+
return 0
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _print_error(error: BccCommandError) -> NoReturn:
|
|
507
|
+
print(f"Error: {error.message}", file=sys.stderr)
|
|
508
|
+
print(f"Code: {error.code}", file=sys.stderr)
|
|
509
|
+
if error.draft_saved:
|
|
510
|
+
print("Draft saved: yes", file=sys.stderr)
|
|
511
|
+
if error.next_action is not None:
|
|
512
|
+
print(f"Next action: {error.next_action}", file=sys.stderr)
|
|
513
|
+
raise SystemExit(1)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
517
|
+
try:
|
|
518
|
+
return asyncio.run(async_main(argv))
|
|
519
|
+
except BccCommandError as error:
|
|
520
|
+
_print_error(error)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
if __name__ == "__main__":
|
|
524
|
+
raise SystemExit(main())
|