bazaar-contrib-messages-node 0.3.0rc1__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_contrib_messages_node/__init__.py +195 -0
- bazaar_contrib_messages_node/attachments.py +166 -0
- bazaar_contrib_messages_node/cli/__init__.py +1 -0
- bazaar_contrib_messages_node/cli/_client.py +90 -0
- bazaar_contrib_messages_node/cli/_format.py +190 -0
- bazaar_contrib_messages_node/cli/inbox.py +23 -0
- bazaar_contrib_messages_node/cli/message.py +227 -0
- bazaar_contrib_messages_node/cli/thread.py +36 -0
- bazaar_contrib_messages_node/concurrency.py +15 -0
- bazaar_contrib_messages_node/delivery.py +32 -0
- bazaar_contrib_messages_node/dispatch.py +789 -0
- bazaar_contrib_messages_node/operations.py +473 -0
- bazaar_contrib_messages_node/py.typed +0 -0
- bazaar_contrib_messages_node/queries.py +16 -0
- bazaar_contrib_messages_node/rendering.py +80 -0
- bazaar_contrib_messages_node/repository/__init__.py +2097 -0
- bazaar_contrib_messages_node/repository/base.py +58 -0
- bazaar_contrib_messages_node/repository/codec.py +561 -0
- bazaar_contrib_messages_node/repository/unread.py +183 -0
- bazaar_contrib_messages_node/resources/bcc/check.tpl +4 -0
- bazaar_contrib_messages_node/resources/bcc/error.tpl +4 -0
- bazaar_contrib_messages_node/resources/bcc/inbox_check.tpl +2 -0
- bazaar_contrib_messages_node/resources/bcc/read.tpl +5 -0
- bazaar_contrib_messages_node/resources/bcc/search.tpl +16 -0
- bazaar_contrib_messages_node/resources/bcc/unfollow.tpl +1 -0
- bazaar_contrib_messages_node/resources/command/attachment_suffix.tpl +1 -0
- bazaar_contrib_messages_node/resources/command/check_message.tpl +8 -0
- bazaar_contrib_messages_node/resources/command/freshness_hold.tpl +19 -0
- bazaar_contrib_messages_node/resources/command/read_message.tpl +1 -0
- bazaar_contrib_messages_node/resources/command/send_result.tpl +1 -0
- bazaar_contrib_messages_node/resources/command/sender.tpl +1 -0
- bazaar_contrib_messages_node/schema/__init__.py +72 -0
- bazaar_contrib_messages_node/schema/definitions.py +308 -0
- bazaar_contrib_messages_node/serialize.py +196 -0
- bazaar_contrib_messages_node/service.py +1204 -0
- bazaar_contrib_messages_node/store.py +450 -0
- bazaar_contrib_messages_node-0.3.0rc1.dist-info/METADATA +21 -0
- bazaar_contrib_messages_node-0.3.0rc1.dist-info/RECORD +41 -0
- bazaar_contrib_messages_node-0.3.0rc1.dist-info/WHEEL +4 -0
- bazaar_contrib_messages_node-0.3.0rc1.dist-info/entry_points.txt +9 -0
- bazaar_contrib_messages_node-0.3.0rc1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Agent-owned conversations, messages and resource-scoped commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import AsyncIterator, Mapping
|
|
8
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
9
|
+
from copy import copy
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from bazaar_contrib_audit import AUDIT, Audit
|
|
13
|
+
from bazaar_contrib_invoke import COMMANDS, CommandEntry
|
|
14
|
+
from bazaar_contrib_legacy_node_migrations import LEGACY_NODE_MIGRATIONS
|
|
15
|
+
from bazaar_contrib_messages import (
|
|
16
|
+
MESSAGES,
|
|
17
|
+
Actors,
|
|
18
|
+
Agent,
|
|
19
|
+
MessageSession,
|
|
20
|
+
Mode,
|
|
21
|
+
SessionBindingValidator,
|
|
22
|
+
ThreadConcurrency,
|
|
23
|
+
)
|
|
24
|
+
from bazaar_contrib_messages.operations import UnreadMessageOwner
|
|
25
|
+
from bazaar_contrib_schema import SCHEMA
|
|
26
|
+
from bazaar_contrib_storage import DB, Database, Session
|
|
27
|
+
from bazaar_contrib_timer import TIMER, Timer
|
|
28
|
+
from bazaar_core import Context, Contrib, ContribSpec
|
|
29
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictStr
|
|
30
|
+
lazy from bazaar_contrib_channel import Channel
|
|
31
|
+
lazy from bazaar_contrib_sidecar import CLIENT
|
|
32
|
+
|
|
33
|
+
from .attachments import AttachmentMaterializer
|
|
34
|
+
from .concurrency import ThreadLockRegistry
|
|
35
|
+
from .dispatch import CommandDispatcher
|
|
36
|
+
from .repository import Repository
|
|
37
|
+
from .repository.unread import list_unread_message_owners
|
|
38
|
+
from .schema import MODULE
|
|
39
|
+
from .service import NodeMessageService
|
|
40
|
+
from .store import MessageStore
|
|
41
|
+
lazy from .cli._client import MessageClient
|
|
42
|
+
lazy from .cli.inbox import inbox
|
|
43
|
+
lazy from .cli.message import message
|
|
44
|
+
lazy from .cli.thread import thread
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"MESSAGES",
|
|
48
|
+
"AttachmentMaterializer",
|
|
49
|
+
"CommandDispatcher",
|
|
50
|
+
"MessageStore",
|
|
51
|
+
"NodeMessageService",
|
|
52
|
+
"NodeMessages",
|
|
53
|
+
"ThreadLockRegistry",
|
|
54
|
+
"commands",
|
|
55
|
+
"inbox_entry",
|
|
56
|
+
"message_entry",
|
|
57
|
+
"messages",
|
|
58
|
+
"thread_entry",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _AgentRequest(BaseModel):
|
|
63
|
+
model_config = ConfigDict(extra="ignore", strict=True)
|
|
64
|
+
agent_id: StrictStr = Field(min_length=1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class NodeMessages:
|
|
68
|
+
"""Keep command bindings within their owning agent's lifespan."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, database: Database, audit: Audit, timer: Timer):
|
|
71
|
+
self.database = database
|
|
72
|
+
self.audit = audit
|
|
73
|
+
self.timer = timer
|
|
74
|
+
self._bindings: dict[Agent, CommandDispatcher] = {}
|
|
75
|
+
|
|
76
|
+
def storage(self, owner: Agent) -> MessageStore:
|
|
77
|
+
return MessageStore(self.database, owner, owner.id)
|
|
78
|
+
|
|
79
|
+
def in_session(self, session: Session, owner: Agent) -> MessageSession:
|
|
80
|
+
return Repository(session, owner, owner.id)
|
|
81
|
+
|
|
82
|
+
async def list_unread_message_owners(self) -> tuple[UnreadMessageOwner, ...]:
|
|
83
|
+
return await self.database.read(list_unread_message_owners)
|
|
84
|
+
|
|
85
|
+
@asynccontextmanager
|
|
86
|
+
async def bind(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
actors: Actors,
|
|
90
|
+
name: str,
|
|
91
|
+
channel: Channel,
|
|
92
|
+
workspace: Path,
|
|
93
|
+
concurrency: ThreadConcurrency,
|
|
94
|
+
provider_timeout: float,
|
|
95
|
+
shutdown_timeout: float,
|
|
96
|
+
session_binding_validator: SessionBindingValidator | None = None,
|
|
97
|
+
) -> AsyncIterator[NodeMessageService]:
|
|
98
|
+
owner = Agent(actors.agent_id)
|
|
99
|
+
if owner in self._bindings:
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"message commands are already bound for agent: {owner.id}"
|
|
102
|
+
)
|
|
103
|
+
service = NodeMessageService(
|
|
104
|
+
storage=MessageStore(self.database, owner, name),
|
|
105
|
+
audit=self.audit,
|
|
106
|
+
concurrency=concurrency,
|
|
107
|
+
channel=channel,
|
|
108
|
+
workspace=workspace,
|
|
109
|
+
provider_timeout=provider_timeout,
|
|
110
|
+
timer=self.timer,
|
|
111
|
+
)
|
|
112
|
+
await service._attachments.reconcile()
|
|
113
|
+
dispatcher = CommandDispatcher(
|
|
114
|
+
service, actors=actors, session_binding_validator=session_binding_validator
|
|
115
|
+
)
|
|
116
|
+
dispatcher.start_accepting()
|
|
117
|
+
self._bindings[owner] = dispatcher
|
|
118
|
+
try:
|
|
119
|
+
yield service
|
|
120
|
+
finally:
|
|
121
|
+
dispatcher.stop_accepting()
|
|
122
|
+
del self._bindings[owner]
|
|
123
|
+
await dispatcher.drain(timeout=shutdown_timeout)
|
|
124
|
+
|
|
125
|
+
async def request(self, request: Mapping[str, object]) -> Mapping[str, object]:
|
|
126
|
+
owner = Agent(_AgentRequest.model_validate(request).agent_id)
|
|
127
|
+
dispatcher = self._bindings.get(owner)
|
|
128
|
+
if dispatcher is None:
|
|
129
|
+
return {
|
|
130
|
+
"ok": False,
|
|
131
|
+
"code": "SERVICE_NOT_READY",
|
|
132
|
+
"error": "command service is not accepting requests",
|
|
133
|
+
}
|
|
134
|
+
return await dispatcher(request)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@asynccontextmanager
|
|
138
|
+
async def lifespan(ctx: Context):
|
|
139
|
+
async with ctx.accept(SCHEMA).register(MODULE):
|
|
140
|
+
ctx.offer(
|
|
141
|
+
MESSAGES, NodeMessages(ctx.accept(DB), ctx.accept(AUDIT), ctx.accept(TIMER))
|
|
142
|
+
)
|
|
143
|
+
yield
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@asynccontextmanager
|
|
147
|
+
async def cli_lifespan(ctx: Context):
|
|
148
|
+
client = ctx.accept(CLIENT)
|
|
149
|
+
timeout = client.timeout
|
|
150
|
+
client.timeout = None
|
|
151
|
+
try:
|
|
152
|
+
async with AsyncExitStack() as stack:
|
|
153
|
+
groups = [message, thread]
|
|
154
|
+
if os.environ.get("BCN_AGENT_MODE") == Mode.DANGEROUS_INDIVIDUAL.value:
|
|
155
|
+
groups.append(inbox)
|
|
156
|
+
for group in groups:
|
|
157
|
+
group = copy(group)
|
|
158
|
+
group.context_settings = {
|
|
159
|
+
**group.context_settings,
|
|
160
|
+
"obj": MessageClient(client.context),
|
|
161
|
+
}
|
|
162
|
+
stack.enter_context(ctx.accept(COMMANDS).register(group))
|
|
163
|
+
yield
|
|
164
|
+
finally:
|
|
165
|
+
client.timeout = timeout
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
messages = Contrib(
|
|
169
|
+
accepts=[SCHEMA, LEGACY_NODE_MIGRATIONS, DB, AUDIT, TIMER],
|
|
170
|
+
offers=[MESSAGES],
|
|
171
|
+
lifespan=lifespan,
|
|
172
|
+
)
|
|
173
|
+
commands = Contrib(accepts=[CLIENT, COMMANDS], lifespan=cli_lifespan)
|
|
174
|
+
_TRANSPORT = ContribSpec(
|
|
175
|
+
contrib="sidecar-windows" if sys.platform == "win32" else "sidecar-unix",
|
|
176
|
+
config={"endpoint": os.environ.get("BCN_ENDPOINT")},
|
|
177
|
+
)
|
|
178
|
+
message_entry = CommandEntry(
|
|
179
|
+
contrib="messages-cli", help="Message operations", dependencies=(_TRANSPORT,)
|
|
180
|
+
)
|
|
181
|
+
thread_entry = CommandEntry(
|
|
182
|
+
contrib="messages-cli",
|
|
183
|
+
help="Thread attention operations",
|
|
184
|
+
dependencies=(_TRANSPORT,),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
inbox_entry = (
|
|
188
|
+
CommandEntry(
|
|
189
|
+
contrib="messages-cli",
|
|
190
|
+
help="Inbox target summary operations",
|
|
191
|
+
dependencies=(_TRANSPORT,),
|
|
192
|
+
)
|
|
193
|
+
if os.environ.get("BCN_AGENT_MODE") == Mode.DANGEROUS_INDIVIDUAL.value
|
|
194
|
+
else None
|
|
195
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import mimetypes
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections.abc import AsyncIterable, Awaitable, Callable, Collection
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
from bazaar_contrib_messages.models import InboundAttachment
|
|
13
|
+
|
|
14
|
+
_SAFE_SUFFIX = re.compile(r"^\.[A-Za-z0-9]{1,10}$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AttachmentMaterializer:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
workspace: Callable[[], Path],
|
|
21
|
+
referenced_paths: Callable[[], Awaitable[Collection[str]]],
|
|
22
|
+
*,
|
|
23
|
+
max_file_bytes: int = 25 * 1024 * 1024,
|
|
24
|
+
) -> None:
|
|
25
|
+
self._workspace = workspace
|
|
26
|
+
self._referenced_paths = referenced_paths
|
|
27
|
+
self._max_file_bytes = max_file_bytes
|
|
28
|
+
|
|
29
|
+
async def reconcile(self) -> None:
|
|
30
|
+
root = self._workspace() / "attachments"
|
|
31
|
+
await asyncio.to_thread(root.mkdir, parents=True, exist_ok=True, mode=0o700)
|
|
32
|
+
files = await asyncio.to_thread(
|
|
33
|
+
lambda: tuple(
|
|
34
|
+
path
|
|
35
|
+
for path in root.iterdir()
|
|
36
|
+
if path.is_file() and not path.is_symlink()
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
referenced = set(await self._referenced_paths())
|
|
40
|
+
for path in files:
|
|
41
|
+
if not (path.name.startswith(".bcn-") and path.name.endswith(".part")):
|
|
42
|
+
try:
|
|
43
|
+
UUID(path.stem)
|
|
44
|
+
except ValueError:
|
|
45
|
+
continue
|
|
46
|
+
if str(PurePosixPath("attachments", path.name)) in referenced:
|
|
47
|
+
continue
|
|
48
|
+
await asyncio.to_thread(path.unlink)
|
|
49
|
+
|
|
50
|
+
async def _stage(
|
|
51
|
+
self,
|
|
52
|
+
source: AsyncIterable[bytes],
|
|
53
|
+
destination: Path,
|
|
54
|
+
) -> int:
|
|
55
|
+
"""Write an attachment aside, then move it into place once it is whole."""
|
|
56
|
+
|
|
57
|
+
descriptor, temporary = await asyncio.to_thread(
|
|
58
|
+
tempfile.mkstemp,
|
|
59
|
+
dir=destination.parent,
|
|
60
|
+
prefix=".bcn-",
|
|
61
|
+
suffix=".part",
|
|
62
|
+
)
|
|
63
|
+
temporary = Path(temporary)
|
|
64
|
+
try:
|
|
65
|
+
try:
|
|
66
|
+
size = await self._write(descriptor, source)
|
|
67
|
+
await asyncio.to_thread(os.fsync, descriptor)
|
|
68
|
+
stored = await asyncio.to_thread(os.fstat, descriptor)
|
|
69
|
+
if stored.st_size != size:
|
|
70
|
+
raise OSError(
|
|
71
|
+
"attachment write size mismatch: "
|
|
72
|
+
f"expected {size}, got {stored.st_size}"
|
|
73
|
+
)
|
|
74
|
+
finally:
|
|
75
|
+
await asyncio.to_thread(os.close, descriptor)
|
|
76
|
+
await asyncio.to_thread(os.replace, temporary, destination)
|
|
77
|
+
if os.name != "nt":
|
|
78
|
+
await asyncio.to_thread(destination.chmod, 0o600)
|
|
79
|
+
return size
|
|
80
|
+
except BaseException:
|
|
81
|
+
if await asyncio.to_thread(temporary.exists):
|
|
82
|
+
await asyncio.to_thread(temporary.unlink)
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
async def _write(
|
|
86
|
+
self,
|
|
87
|
+
descriptor: int,
|
|
88
|
+
source: AsyncIterable[bytes],
|
|
89
|
+
) -> int:
|
|
90
|
+
"""Write the source through, refusing it the moment it goes over a limit."""
|
|
91
|
+
|
|
92
|
+
size = 0
|
|
93
|
+
async for chunk in source:
|
|
94
|
+
size += len(chunk)
|
|
95
|
+
if size > self._max_file_bytes:
|
|
96
|
+
raise ValueError("attachment exceeds the per-file size limit")
|
|
97
|
+
await asyncio.to_thread(_write_all, descriptor, chunk)
|
|
98
|
+
return size
|
|
99
|
+
|
|
100
|
+
async def materialize(
|
|
101
|
+
self,
|
|
102
|
+
source: AsyncIterable[bytes],
|
|
103
|
+
*,
|
|
104
|
+
attachment_id: str,
|
|
105
|
+
name: str,
|
|
106
|
+
kind: str,
|
|
107
|
+
media_type: str | None = None,
|
|
108
|
+
) -> InboundAttachment:
|
|
109
|
+
if not name or not kind:
|
|
110
|
+
raise ValueError("attachment name and kind must be non-empty")
|
|
111
|
+
suffix = await asyncio.to_thread(_attachment_suffix, name, media_type)
|
|
112
|
+
relative = PurePosixPath("attachments", f"{attachment_id}{suffix.lower()}")
|
|
113
|
+
root = self._workspace() / "attachments"
|
|
114
|
+
destination = self._workspace().joinpath(*relative.parts)
|
|
115
|
+
await asyncio.to_thread(root.mkdir, parents=True, exist_ok=True, mode=0o700)
|
|
116
|
+
size = await self._stage(source, destination)
|
|
117
|
+
return InboundAttachment(
|
|
118
|
+
attachment_id=attachment_id,
|
|
119
|
+
name=Path(name).name,
|
|
120
|
+
kind=kind,
|
|
121
|
+
state="ready",
|
|
122
|
+
media_type=media_type,
|
|
123
|
+
relative_path=str(relative),
|
|
124
|
+
size_bytes=size,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def failed(
|
|
128
|
+
self,
|
|
129
|
+
*,
|
|
130
|
+
attachment_id: str,
|
|
131
|
+
name: str,
|
|
132
|
+
kind: str,
|
|
133
|
+
error: str | None,
|
|
134
|
+
media_type: str | None = None,
|
|
135
|
+
) -> InboundAttachment:
|
|
136
|
+
return InboundAttachment(
|
|
137
|
+
attachment_id=attachment_id,
|
|
138
|
+
name=Path(name).name or "attachment.bin",
|
|
139
|
+
kind=kind,
|
|
140
|
+
state="failed",
|
|
141
|
+
media_type=media_type,
|
|
142
|
+
error=error,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _write_all(descriptor: int, payload: bytes) -> None:
|
|
147
|
+
view = memoryview(payload)
|
|
148
|
+
while view:
|
|
149
|
+
written = os.write(descriptor, view)
|
|
150
|
+
if written == 0:
|
|
151
|
+
raise OSError("attachment write made no progress")
|
|
152
|
+
view = view[written:]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _attachment_suffix(name: str, media_type: str | None) -> str:
|
|
156
|
+
suffix = Path(name).suffix
|
|
157
|
+
if _SAFE_SUFFIX.fullmatch(suffix):
|
|
158
|
+
return suffix
|
|
159
|
+
media_type = (media_type or "").partition(";")[0].strip().lower()
|
|
160
|
+
if not media_type or media_type.endswith("/octet-stream"):
|
|
161
|
+
return ""
|
|
162
|
+
guessed = mimetypes.guess_extension(media_type) or ""
|
|
163
|
+
return guessed if _SAFE_SUFFIX.fullmatch(guessed) else ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
__all__ = ["AttachmentMaterializer"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Native message and thread commands contributed to bcc."""
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Callable, Coroutine, Mapping
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from bazaar_contrib_sidecar import Remote, Symbol
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BccCommandError(RuntimeError):
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
code: str,
|
|
18
|
+
draft_saved: bool | None = None,
|
|
19
|
+
next_action: str | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.code = code
|
|
23
|
+
self.draft_saved = draft_saved
|
|
24
|
+
self.next_action = next_action
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _environment(name: str, code: str) -> str:
|
|
28
|
+
value = os.environ.get(name)
|
|
29
|
+
if not value:
|
|
30
|
+
raise BccCommandError(f"{name} is not set", code=code)
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MessageClient:
|
|
35
|
+
def __init__(self, remote: Remote):
|
|
36
|
+
self.remote = remote
|
|
37
|
+
|
|
38
|
+
async def request(
|
|
39
|
+
self, resource: str, command: str, payload: Mapping[str, object] = {}
|
|
40
|
+
) -> Mapping[str, object]:
|
|
41
|
+
"""Wait for the node's own command budget and complete send outcome."""
|
|
42
|
+
request: dict[str, object] = {
|
|
43
|
+
"kind": "command",
|
|
44
|
+
"resource": resource,
|
|
45
|
+
"command": command,
|
|
46
|
+
"agent_id": _environment("BCN_AGENT_ID", "AGENT_REQUIRED"),
|
|
47
|
+
"actor_id": _environment("BCN_ACTOR_ID", "SESSION_REQUIRED"),
|
|
48
|
+
"runtime_session_id": _environment(
|
|
49
|
+
"BCN_RUNTIME_SESSION_ID", "SESSION_BINDING_REQUIRED"
|
|
50
|
+
),
|
|
51
|
+
"session_capability": _environment(
|
|
52
|
+
"BCN_COMMAND_CAPABILITY", "SESSION_BINDING_REQUIRED"
|
|
53
|
+
),
|
|
54
|
+
**payload,
|
|
55
|
+
}
|
|
56
|
+
service = cast(
|
|
57
|
+
Remote,
|
|
58
|
+
await self.remote.call(
|
|
59
|
+
"accept", Symbol("bazaar_contrib_messages:MESSAGES")
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
response = cast(Mapping[str, object], await service.call("request", request))
|
|
63
|
+
if response.get("ok") is not True:
|
|
64
|
+
raise BccCommandError(
|
|
65
|
+
str(response.get("error", "command failed")),
|
|
66
|
+
code=str(response.get("code", "COMMAND_FAILED")),
|
|
67
|
+
draft_saved=response.get("draft_saved") is True,
|
|
68
|
+
next_action=str(response["next_action"])
|
|
69
|
+
if response.get("next_action") is not None
|
|
70
|
+
else None,
|
|
71
|
+
)
|
|
72
|
+
return cast(Mapping[str, object], response["result"])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run[**P](
|
|
76
|
+
command: Callable[P, Coroutine[Any, Any, None]],
|
|
77
|
+
) -> Callable[P, Coroutine[Any, Any, None]]:
|
|
78
|
+
"""Return the command body to invoke's owning event loop."""
|
|
79
|
+
|
|
80
|
+
@wraps(command)
|
|
81
|
+
async def invoke(*args: P.args, **kwargs: P.kwargs) -> None:
|
|
82
|
+
try:
|
|
83
|
+
await command(*args, **kwargs)
|
|
84
|
+
except BccCommandError as error:
|
|
85
|
+
from ._format import print_error
|
|
86
|
+
|
|
87
|
+
print_error(error)
|
|
88
|
+
raise click.exceptions.Exit(1) from error
|
|
89
|
+
|
|
90
|
+
return invoke
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from html import escape
|
|
8
|
+
from typing import Annotated, cast
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictStr, ValidationError
|
|
12
|
+
|
|
13
|
+
from ..dispatch import format_check_message, format_read_message
|
|
14
|
+
from ..rendering import TextTemplate
|
|
15
|
+
from ._client import BccCommandError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def echo(text: str) -> None:
|
|
19
|
+
click.echo(text)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def print_error(error: BccCommandError) -> None:
|
|
23
|
+
click.echo(
|
|
24
|
+
_ERROR.render(
|
|
25
|
+
{
|
|
26
|
+
"message": str(error),
|
|
27
|
+
"code": error.code,
|
|
28
|
+
"draft_saved": error.draft_saved,
|
|
29
|
+
"next_action": error.next_action,
|
|
30
|
+
}
|
|
31
|
+
),
|
|
32
|
+
file=sys.stderr,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_INBOX_CHECK = TextTemplate.from_resource("bcc/inbox_check.tpl")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_CHECK = TextTemplate.from_resource("bcc/check.tpl")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_READ = TextTemplate.from_resource("bcc/read.tpl")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_SEARCH = TextTemplate.from_resource("bcc/search.tpl")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_UNFOLLOW = TextTemplate.from_resource("bcc/unfollow.tpl")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_ERROR = TextTemplate.from_resource("bcc/error.tpl")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def serialize_inbox_check(result: Mapping[str, object]) -> str:
|
|
55
|
+
targets = cast(list[Mapping[str, object]], result["targets"])
|
|
56
|
+
return _INBOX_CHECK.render(
|
|
57
|
+
{
|
|
58
|
+
"target_lines": [
|
|
59
|
+
" ".join(
|
|
60
|
+
(
|
|
61
|
+
f"[target={target['target']}",
|
|
62
|
+
f"pending={target['pending_count']}",
|
|
63
|
+
f"latest-msg={target['latest_message_id']}]",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
for target in targets
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def serialize_check(result: Mapping[str, object]) -> str:
|
|
73
|
+
messages = cast(list[Mapping[str, object]], result["messages"])
|
|
74
|
+
referenced_messages = cast(
|
|
75
|
+
list[Mapping[str, object]], result["referenced_messages"]
|
|
76
|
+
)
|
|
77
|
+
return _CHECK.render(
|
|
78
|
+
{
|
|
79
|
+
"referenced_lines": [
|
|
80
|
+
format_read_message(
|
|
81
|
+
message,
|
|
82
|
+
index=index,
|
|
83
|
+
count=len(referenced_messages),
|
|
84
|
+
)
|
|
85
|
+
for index, message in enumerate(referenced_messages, start=1)
|
|
86
|
+
],
|
|
87
|
+
"message_lines": [format_check_message(message) for message in messages],
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def serialize_read(result: Mapping[str, object]) -> str:
|
|
93
|
+
messages = cast(list[Mapping[str, object]], result["messages"])
|
|
94
|
+
referenced_messages = cast(
|
|
95
|
+
list[Mapping[str, object]], result["referenced_messages"]
|
|
96
|
+
)
|
|
97
|
+
return _READ.render(
|
|
98
|
+
{
|
|
99
|
+
"shown": len(messages),
|
|
100
|
+
"first_seq": result["first_seq"] if messages else None,
|
|
101
|
+
"last_seq": result["last_seq"] if messages else None,
|
|
102
|
+
"referenced_lines": [
|
|
103
|
+
format_read_message(
|
|
104
|
+
message,
|
|
105
|
+
index=index,
|
|
106
|
+
count=len(referenced_messages),
|
|
107
|
+
)
|
|
108
|
+
for index, message in enumerate(referenced_messages, start=1)
|
|
109
|
+
],
|
|
110
|
+
"message_lines": [
|
|
111
|
+
format_read_message(message, index=index, count=len(messages))
|
|
112
|
+
for index, message in enumerate(messages, start=1)
|
|
113
|
+
],
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _MessageSendResponse(BaseModel):
|
|
119
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
120
|
+
|
|
121
|
+
text: Annotated[StrictStr, Field(min_length=1)]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def serialize_send(result: Mapping[str, object]) -> str:
|
|
125
|
+
try:
|
|
126
|
+
return _MessageSendResponse.model_validate(result).text
|
|
127
|
+
except ValidationError as error:
|
|
128
|
+
raise BccCommandError(
|
|
129
|
+
"Message send returned an invalid response.",
|
|
130
|
+
code="SEND_RESPONSE_INVALID",
|
|
131
|
+
) from error
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _search_text(text: str, *, references: bool = True) -> str:
|
|
135
|
+
text = re.sub(
|
|
136
|
+
r"</?(?:result|preview|match)\b[^>]*>|<omit\s*/>",
|
|
137
|
+
lambda match: escape(match[0], quote=False),
|
|
138
|
+
text,
|
|
139
|
+
flags=re.IGNORECASE,
|
|
140
|
+
)
|
|
141
|
+
if not references:
|
|
142
|
+
return text
|
|
143
|
+
text = re.sub(r"\bdm:@([A-Za-z0-9][A-Za-z0-9_-]*)", r"dm:user:\1", text)
|
|
144
|
+
text = re.sub(r"\btask #([0-9]+)\b", r"task:\1", text)
|
|
145
|
+
text = re.sub(r"(^|[\n\s([{\"'`;])@([A-Za-z0-9][A-Za-z0-9_-]*)", r"\1user:\2", text)
|
|
146
|
+
return re.sub(r"(^|[\n\s([{\"'`;])#([A-Za-z][A-Za-z0-9_-]*)", r"\1channel:\2", text)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def serialize_search(result: Mapping[str, object]) -> str:
|
|
150
|
+
messages = cast(list[Mapping[str, object]], result["messages"])
|
|
151
|
+
hits: list[dict[str, object]] = []
|
|
152
|
+
for message in messages:
|
|
153
|
+
sender = cast(Mapping[str, object] | None, message["sender"])
|
|
154
|
+
timestamp = datetime.fromtimestamp(
|
|
155
|
+
cast(int, message["at_ms"]) / 1000, tz=UTC
|
|
156
|
+
).astimezone()
|
|
157
|
+
offset = timestamp.strftime("%z")
|
|
158
|
+
hits.append(
|
|
159
|
+
{
|
|
160
|
+
"source": _search_text(cast(str, message["target"]), references=False),
|
|
161
|
+
"message_id": message["message_id"],
|
|
162
|
+
"timestamp": (
|
|
163
|
+
f"{timestamp:%Y-%m-%d %H:%M:%S} {offset[:3]}:{offset[3:]}"
|
|
164
|
+
),
|
|
165
|
+
"sender_kind": message["sender_kind"],
|
|
166
|
+
"sender": _search_text(
|
|
167
|
+
"self"
|
|
168
|
+
if message["direction"] == "outbound"
|
|
169
|
+
else cast(str, sender.get("name") or sender.get("id") or "unknown")
|
|
170
|
+
if sender is not None
|
|
171
|
+
else "unknown"
|
|
172
|
+
),
|
|
173
|
+
"preview": "".join(
|
|
174
|
+
"<match>" + _search_text(text) + "</match>"
|
|
175
|
+
if matched
|
|
176
|
+
else _search_text(text)
|
|
177
|
+
for text, matched in cast(list[tuple[str, bool]], message["parts"])
|
|
178
|
+
),
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
return _SEARCH.render({**result, "messages": hits})
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def serialize_unfollow(result: Mapping[str, object]) -> str:
|
|
185
|
+
return _UNFOLLOW.render(
|
|
186
|
+
{
|
|
187
|
+
"target": cast(str, result["target"]),
|
|
188
|
+
"changed": cast(bool, result["changed"]),
|
|
189
|
+
}
|
|
190
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from ._client import MessageClient, run
|
|
6
|
+
from ._format import echo, serialize_inbox_check
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.group(help="Inbox target summary operations")
|
|
10
|
+
def inbox() -> None: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@inbox.command(
|
|
14
|
+
"check",
|
|
15
|
+
help="Show pending inbox targets without draining or reading message content.",
|
|
16
|
+
)
|
|
17
|
+
@click.pass_obj
|
|
18
|
+
@run
|
|
19
|
+
async def check_targets(client: MessageClient) -> None:
|
|
20
|
+
echo(serialize_inbox_check(await client.request("inbox", "check", {})))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = ["inbox"]
|