zettcode-broker 0.3.4__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.
- zettcode/broker/__init__.py +22 -0
- zettcode/broker/__main__.py +10 -0
- zettcode/broker/cli.py +282 -0
- zettcode/broker/client.py +837 -0
- zettcode/broker/files.py +133 -0
- zettcode/broker/finder.py +210 -0
- zettcode/broker/hub.py +964 -0
- zettcode/broker/log.py +11 -0
- zettcode/broker/models.py +132 -0
- zettcode/broker/process.py +70 -0
- zettcode/broker/protocol.py +428 -0
- zettcode/broker/py.typed +0 -0
- zettcode/broker/reload.py +94 -0
- zettcode/broker/roster.py +182 -0
- zettcode/broker/runner.py +160 -0
- zettcode/broker/server.py +183 -0
- zettcode/broker/store.py +470 -0
- zettcode/broker/text.py +81 -0
- zettcode/broker/web.py +261 -0
- zettcode_broker-0.3.4.dist-info/METADATA +22 -0
- zettcode_broker-0.3.4.dist-info/RECORD +22 -0
- zettcode_broker-0.3.4.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
"""What a session uses to reach the broker: stay connected, say what this session is, send and receive.
|
|
2
|
+
|
|
3
|
+
It opens the session's WebSocket (which is what registers it), tells the broker what the session is now (and again
|
|
4
|
+
after every reconnection, so the broker always hears the latest and never an old value), keeps the roster the broker
|
|
5
|
+
pushes, hands each message that arrives to the shell, and sends the shell's messages with a plain ``POST /messages``
|
|
6
|
+
(see :mod:`.protocol`). Finding the broker, and starting it, is :mod:`.finder`'s.
|
|
7
|
+
|
|
8
|
+
The client lives on the shell's event loop and never blocks it. Nothing it does can fail the session: every error
|
|
9
|
+
ends as an unconnected client, or as a :class:`~.models.SendError` the caller reports. Delivery is at most once: a send
|
|
10
|
+
that was in flight when the connection ended fails with a reason that says the message may or may not have arrived,
|
|
11
|
+
and nothing is retried behind the caller's back.
|
|
12
|
+
|
|
13
|
+
When the connection ends it is made again: at once when the broker closed it with code 1012 (its way of saying it is
|
|
14
|
+
going away), after a growing delay otherwise, and slowly while the broker speaks a newer protocol than this client.
|
|
15
|
+
|
|
16
|
+
``httpx`` and ``websockets`` are imported where they are first used, because the shell imports this module with the
|
|
17
|
+
rest of the application and they are only wanted once there is a broker to talk to.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import contextlib
|
|
24
|
+
import itertools
|
|
25
|
+
import json
|
|
26
|
+
import time
|
|
27
|
+
from collections import deque
|
|
28
|
+
from collections.abc import Callable, Sequence
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from zettcode.modes import MODE_FOLLOWUP, MODE_STEER, MODES
|
|
33
|
+
|
|
34
|
+
from .finder import Finder
|
|
35
|
+
from .models import Delivered, GroupDelivered, GroupInfo, Incoming, Receipt, RosterEntry, SendError, SessionInfo
|
|
36
|
+
from .protocol import (
|
|
37
|
+
ACK_TIMEOUT,
|
|
38
|
+
PROTOCOL_VERSION,
|
|
39
|
+
VIA_AGENT,
|
|
40
|
+
VIA_USER,
|
|
41
|
+
VIAS,
|
|
42
|
+
ImageData,
|
|
43
|
+
Limits,
|
|
44
|
+
PeerInfo,
|
|
45
|
+
Refused,
|
|
46
|
+
parse_images,
|
|
47
|
+
roster_from_wire,
|
|
48
|
+
)
|
|
49
|
+
from .text import clean_text
|
|
50
|
+
|
|
51
|
+
#: What the broker closes a connection with when it is going away (WebSocket's "service restart").
|
|
52
|
+
GOODBYE = 1012
|
|
53
|
+
|
|
54
|
+
#: Seconds to wait for the broker's answer to anything but a send: the greeting, and the description of this session.
|
|
55
|
+
REQUEST_TIMEOUT = 5.0
|
|
56
|
+
|
|
57
|
+
#: Seconds between attempts to reach the broker, growing to the last one.
|
|
58
|
+
BACKOFF = (0.2, 0.5, 1.0, 2.0, 5.0)
|
|
59
|
+
|
|
60
|
+
#: Seconds between attempts while the broker speaks a newer protocol than this client can, which is the only case
|
|
61
|
+
#: that is waited out: an older one is ended and replaced. Looking costs one file read, so this is short.
|
|
62
|
+
INCOMPATIBLE_DELAY = 5.0
|
|
63
|
+
|
|
64
|
+
#: Seconds the broker may have been out of reach before the client says so.
|
|
65
|
+
PROBLEM_AFTER = 8.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _Outage:
|
|
69
|
+
"""Says, once, that the broker has been out of reach for a while, and once that it is back.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
tell: Told the sentence, or ``None`` when the broker is back.
|
|
73
|
+
after: Seconds out of reach before it speaks, unless the broker is there but cannot be spoken to.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, tell: Callable[[str | None], None] | None, after: float) -> None:
|
|
77
|
+
self.tell = tell
|
|
78
|
+
self._after = after
|
|
79
|
+
self._since: float | None = None
|
|
80
|
+
self._said: str | None = None
|
|
81
|
+
|
|
82
|
+
def failed(self, reason: str, *, at_once: bool) -> None:
|
|
83
|
+
now = time.monotonic()
|
|
84
|
+
if self._since is None:
|
|
85
|
+
self._since = now
|
|
86
|
+
if now - self._since < self._after and not at_once:
|
|
87
|
+
return
|
|
88
|
+
if reason != self._said:
|
|
89
|
+
self._said = reason
|
|
90
|
+
self._speak(reason)
|
|
91
|
+
|
|
92
|
+
def recovered(self) -> None:
|
|
93
|
+
self._since = None
|
|
94
|
+
if self._said is not None:
|
|
95
|
+
self._said = None
|
|
96
|
+
self._speak(None)
|
|
97
|
+
|
|
98
|
+
def _speak(self, message: str | None) -> None:
|
|
99
|
+
if self.tell is not None:
|
|
100
|
+
with contextlib.suppress(Exception):
|
|
101
|
+
self.tell(message)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class BrokerClient:
|
|
105
|
+
"""A session's connection to the broker.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
on_message: Called, on the event loop, with each :class:`~.models.Incoming`; returns the
|
|
109
|
+
:class:`~.models.Receipt` that is sent back. It must not block. An exception becomes a refusal.
|
|
110
|
+
on_roster: Called with the roster each time it changes, and with an empty one when the connection is lost.
|
|
111
|
+
on_conflict: Called with a sentence when the broker refuses this session's registration because another
|
|
112
|
+
process holds it, and with ``None`` when that is no longer so.
|
|
113
|
+
on_problem: Called once with a sentence when the broker has been out of reach for ``problem_after``
|
|
114
|
+
seconds (or speaks another protocol), and with ``None`` when the connection is back.
|
|
115
|
+
directory: The broker's directory; :func:`~.files.broker_dir` by default.
|
|
116
|
+
spawner: Starts a broker when none answers; see :class:`~.finder.Finder`.
|
|
117
|
+
reply_timeout: Seconds to wait for the answer to a send.
|
|
118
|
+
request_timeout: Seconds to wait for the broker's answer to anything else.
|
|
119
|
+
connect_timeout: Seconds to wait for a connection.
|
|
120
|
+
spawn_wait: Seconds to wait for a broker that has just been started.
|
|
121
|
+
backoff: Seconds to wait between attempts, growing.
|
|
122
|
+
problem_after: Seconds out of reach before ``on_problem`` is told.
|
|
123
|
+
incompatible_delay: Seconds between attempts while the broker speaks a newer protocol.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
on_message: Callable[[Incoming], Receipt] | None = None,
|
|
130
|
+
on_roster: Callable[[tuple[PeerInfo, ...]], None] | None = None,
|
|
131
|
+
on_conflict: Callable[[str | None], None] | None = None,
|
|
132
|
+
on_problem: Callable[[str | None], None] | None = None,
|
|
133
|
+
directory: Path | None = None,
|
|
134
|
+
spawner: Callable[[Path], None] | None = None,
|
|
135
|
+
reply_timeout: float = ACK_TIMEOUT + 5.0,
|
|
136
|
+
request_timeout: float = REQUEST_TIMEOUT,
|
|
137
|
+
connect_timeout: float = 2.0,
|
|
138
|
+
spawn_wait: float = 5.0,
|
|
139
|
+
backoff: tuple[float, ...] = BACKOFF,
|
|
140
|
+
problem_after: float = PROBLEM_AFTER,
|
|
141
|
+
incompatible_delay: float = INCOMPATIBLE_DELAY,
|
|
142
|
+
) -> None:
|
|
143
|
+
self.on_message = on_message
|
|
144
|
+
self.on_roster = on_roster
|
|
145
|
+
#: Called, on the loop, with the id of a watched session and one thing its turn said (see ``STREAM_TYPES``).
|
|
146
|
+
self.on_stream: Callable[[str, dict[str, Any]], None] | None = None
|
|
147
|
+
self.on_conflict = on_conflict
|
|
148
|
+
self.reply_timeout = reply_timeout
|
|
149
|
+
self.request_timeout = request_timeout
|
|
150
|
+
self.backoff = backoff
|
|
151
|
+
self.incompatible_delay = incompatible_delay
|
|
152
|
+
#: What the broker said it accepts, once connected.
|
|
153
|
+
self.limits = Limits()
|
|
154
|
+
self._finder = Finder(
|
|
155
|
+
directory=directory, spawner=spawner, connect_timeout=connect_timeout, spawn_wait=spawn_wait
|
|
156
|
+
)
|
|
157
|
+
self._outage = _Outage(on_problem, problem_after)
|
|
158
|
+
self._session: SessionInfo | None = None
|
|
159
|
+
self._roster: tuple[PeerInfo, ...] = ()
|
|
160
|
+
self._groups: tuple[GroupInfo, ...] = ()
|
|
161
|
+
#: Called, on the loop, with each line an observer is told: ``{"kind": "message", "message": {...}}`` for a
|
|
162
|
+
#: message that passed, ``{"kind": "graph"}`` when who is here or which groups exist changed.
|
|
163
|
+
self.on_traffic: Callable[[dict[str, Any]], None] | None = None
|
|
164
|
+
self._observing = False
|
|
165
|
+
self._conflict: str | None = None
|
|
166
|
+
# The connection that is up, if any.
|
|
167
|
+
self._ws: Any = None
|
|
168
|
+
self._http: Any = None
|
|
169
|
+
self._connected = False
|
|
170
|
+
#: Whether anybody watches this session's turns: the broker says so, and until it does nothing is streamed.
|
|
171
|
+
self._watched = False
|
|
172
|
+
self._watching: set[str] = set()
|
|
173
|
+
self._outbox: deque[str] = deque(maxlen=512)
|
|
174
|
+
self._asks: deque[tuple[str, str]] = deque()
|
|
175
|
+
self._asker: asyncio.Task[None] | None = None
|
|
176
|
+
self._streamer: asyncio.Task[None] | None = None
|
|
177
|
+
self._goodbye = False
|
|
178
|
+
self._came_up = asyncio.Event()
|
|
179
|
+
# Asking the broker something over the WebSocket and waiting for its answer.
|
|
180
|
+
self._replies: dict[int, asyncio.Future[dict[str, Any]]] = {}
|
|
181
|
+
self._numbers = itertools.count(1)
|
|
182
|
+
# Telling the broker what this session is.
|
|
183
|
+
self._dirty = False
|
|
184
|
+
self._pusher: asyncio.Task[None] | None = None
|
|
185
|
+
self._supervisor: asyncio.Task[None] | None = None
|
|
186
|
+
self._closing = False
|
|
187
|
+
|
|
188
|
+
# -- what the shell asks ----------------------------------------------------------------------
|
|
189
|
+
@property
|
|
190
|
+
def connected(self) -> bool:
|
|
191
|
+
"""Return whether this session is connected to a broker right now."""
|
|
192
|
+
return self._connected
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def on_problem(self) -> Callable[[str | None], None] | None:
|
|
196
|
+
"""Told once when the broker has been out of reach for a while, and again when it is back."""
|
|
197
|
+
return self._outage.tell
|
|
198
|
+
|
|
199
|
+
@on_problem.setter
|
|
200
|
+
def on_problem(self, tell: Callable[[str | None], None] | None) -> None:
|
|
201
|
+
self._outage.tell = tell
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def last_error(self) -> str | None:
|
|
205
|
+
"""Return the last reason a connection attempt failed, for a person trying to find out why."""
|
|
206
|
+
return self._finder.last_error
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def roster(self) -> tuple[PeerInfo, ...]:
|
|
210
|
+
"""Return every registered session, this one included, as of the last update."""
|
|
211
|
+
return self._roster
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def groups(self) -> tuple[GroupInfo, ...]:
|
|
215
|
+
"""Return the group chats this session may write to, as of the last update."""
|
|
216
|
+
return self._groups
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def session(self) -> SessionInfo | None:
|
|
220
|
+
"""Return what this session has told the broker, or will when it connects."""
|
|
221
|
+
return self._session
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def conflict(self) -> str | None:
|
|
225
|
+
"""Return why registration was refused, or ``None``."""
|
|
226
|
+
return self._conflict
|
|
227
|
+
|
|
228
|
+
def observe(self, callback: Callable[[dict[str, Any]], None] | None) -> None:
|
|
229
|
+
"""Ask to be told of every message that passes through the broker (``None`` stops).
|
|
230
|
+
|
|
231
|
+
This is a view of the whole broker, not of this session's own mail, and is for a front end that draws who talks
|
|
232
|
+
to whom. Kept across a lost connection.
|
|
233
|
+
"""
|
|
234
|
+
self.on_traffic = callback
|
|
235
|
+
wanted = callback is not None
|
|
236
|
+
if wanted != self._observing:
|
|
237
|
+
self._observing = wanted
|
|
238
|
+
self._say({"op": "observe" if wanted else "unobserve"})
|
|
239
|
+
|
|
240
|
+
def others(self) -> tuple[PeerInfo, ...]:
|
|
241
|
+
"""Return the sessions that are not this one."""
|
|
242
|
+
mine = self._session.session_id if self._session is not None else None
|
|
243
|
+
return tuple(entry for entry in self._roster if entry.session_id != mine)
|
|
244
|
+
|
|
245
|
+
# -- watching a turn --------------------------------------------------------------------------
|
|
246
|
+
@property
|
|
247
|
+
def watched(self) -> bool:
|
|
248
|
+
"""Return whether anybody is watching this session's turns right now."""
|
|
249
|
+
return self._watched and self._connected
|
|
250
|
+
|
|
251
|
+
def watch(self, session_id: str) -> None:
|
|
252
|
+
"""Ask to be told what a session's turns say, as they happen. Kept across a lost connection."""
|
|
253
|
+
if session_id in self._watching:
|
|
254
|
+
return
|
|
255
|
+
self._watching.add(session_id)
|
|
256
|
+
self._ask("watch", session_id)
|
|
257
|
+
|
|
258
|
+
def unwatch(self, session_id: str) -> None:
|
|
259
|
+
"""Stop watching a session."""
|
|
260
|
+
if session_id in self._watching:
|
|
261
|
+
self._watching.discard(session_id)
|
|
262
|
+
self._ask("unwatch", session_id)
|
|
263
|
+
|
|
264
|
+
def _ask(self, op: str, session_id: str) -> None:
|
|
265
|
+
"""Ask the broker to ``watch`` or ``unwatch`` a session, one request after another in the order they were made.
|
|
266
|
+
|
|
267
|
+
A ``watch`` the broker refuses (it caps what a connection watches) is forgotten rather than kept, so what this
|
|
268
|
+
client believes it watches is what the broker does. Nothing is sent while disconnected: the connection that
|
|
269
|
+
follows asks for every session still wanted.
|
|
270
|
+
"""
|
|
271
|
+
if not self._connected:
|
|
272
|
+
return
|
|
273
|
+
self._asks.append((op, session_id))
|
|
274
|
+
if self._asker is None or self._asker.done():
|
|
275
|
+
self._asker = asyncio.ensure_future(self._ask_all())
|
|
276
|
+
|
|
277
|
+
async def _ask_all(self) -> None:
|
|
278
|
+
while self._asks:
|
|
279
|
+
op, session_id = self._asks.popleft()
|
|
280
|
+
try:
|
|
281
|
+
await self._tell({"op": op, "session_id": session_id})
|
|
282
|
+
except Refused:
|
|
283
|
+
if op == "watch":
|
|
284
|
+
self._watching.discard(session_id)
|
|
285
|
+
except Exception: # noqa: BLE001 - a lost connection: what is still wanted is asked for on the next one
|
|
286
|
+
self._asks.clear()
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
def publish(self, event: dict[str, Any]) -> None:
|
|
290
|
+
"""Say one thing about this session's turn to whoever watches it; nothing is sent when nobody does."""
|
|
291
|
+
if self.watched:
|
|
292
|
+
self._say({"op": "stream", "event": event})
|
|
293
|
+
|
|
294
|
+
def _say(self, line: dict[str, Any]) -> None:
|
|
295
|
+
"""Send a line that expects no answer, in the order given. A connection that is down loses it."""
|
|
296
|
+
if not self._connected:
|
|
297
|
+
return
|
|
298
|
+
self._outbox.append(json.dumps(line))
|
|
299
|
+
if self._streamer is None or self._streamer.done():
|
|
300
|
+
self._streamer = asyncio.ensure_future(self._pump())
|
|
301
|
+
|
|
302
|
+
async def _pump(self) -> None:
|
|
303
|
+
"""Send what is queued, one line after another, until there is nothing."""
|
|
304
|
+
while self._outbox and (ws := self._ws) is not None:
|
|
305
|
+
try:
|
|
306
|
+
await ws.send(self._outbox.popleft())
|
|
307
|
+
except Exception: # noqa: BLE001 - a lost connection: what was watched is asked for again on the next
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
def set_session(self, info: SessionInfo | None) -> None:
|
|
311
|
+
"""Say what this session is now: a new id, a new title, a new status.
|
|
312
|
+
|
|
313
|
+
Safe to call at any time and from the loop only. While disconnected it is remembered and sent on
|
|
314
|
+
connecting, so the broker always hears the latest and never an old value.
|
|
315
|
+
"""
|
|
316
|
+
self._session = info
|
|
317
|
+
self._schedule_push()
|
|
318
|
+
|
|
319
|
+
async def start(self) -> None:
|
|
320
|
+
"""Begin connecting in the background; returns at once."""
|
|
321
|
+
if self._supervisor is None and not self._closing:
|
|
322
|
+
self._supervisor = asyncio.ensure_future(self._supervise())
|
|
323
|
+
|
|
324
|
+
async def wait_connected(self, timeout: float) -> bool:
|
|
325
|
+
"""Wait until connected, for at most ``timeout`` seconds; used by tests and by callers that must know."""
|
|
326
|
+
if not self._connected:
|
|
327
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
328
|
+
await asyncio.wait_for(self._came_up.wait(), timeout)
|
|
329
|
+
return self._connected
|
|
330
|
+
|
|
331
|
+
async def close(self) -> None:
|
|
332
|
+
"""Disconnect and stop trying. Quick, idempotent, and never raises."""
|
|
333
|
+
self._closing = True
|
|
334
|
+
for task in (self._pusher, self._supervisor, self._asker):
|
|
335
|
+
if task is not None:
|
|
336
|
+
task.cancel()
|
|
337
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
338
|
+
self._pusher = self._supervisor = self._asker = None
|
|
339
|
+
ws, http = self._ws, self._http
|
|
340
|
+
self._drop()
|
|
341
|
+
if http is not None:
|
|
342
|
+
with contextlib.suppress(Exception):
|
|
343
|
+
await http.aclose()
|
|
344
|
+
if ws is not None:
|
|
345
|
+
# Closing is waited for, briefly: it is what makes the broker see the connection end now and not
|
|
346
|
+
# whenever the loop next turns, which at exit is never.
|
|
347
|
+
with contextlib.suppress(Exception):
|
|
348
|
+
await asyncio.wait_for(ws.close(), 0.5)
|
|
349
|
+
|
|
350
|
+
# -- sending ----------------------------------------------------------------------------------
|
|
351
|
+
async def send(
|
|
352
|
+
self,
|
|
353
|
+
to: str,
|
|
354
|
+
text: str,
|
|
355
|
+
*,
|
|
356
|
+
hops: int = 0,
|
|
357
|
+
via: str = VIA_AGENT,
|
|
358
|
+
direct: bool = False,
|
|
359
|
+
mode: str = MODE_STEER,
|
|
360
|
+
images: Sequence[ImageData] = (),
|
|
361
|
+
) -> Delivered:
|
|
362
|
+
"""Send a message to another session and return once it has taken it.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
to: The session's handle or id.
|
|
366
|
+
text: What to say; it may be empty when ``images`` is not.
|
|
367
|
+
hops: How many sessions it has passed through already.
|
|
368
|
+
via: How it came: the reader, the agent, or an external program.
|
|
369
|
+
direct: Ask a session that can to run it as its own reader's prompt and answer the sender.
|
|
370
|
+
mode: What a busy recipient does with it: ``"steer"`` (the default) hands it to the running request,
|
|
371
|
+
``"followup"`` waits until that request ends.
|
|
372
|
+
images: Pictures to send with it. Only a ``direct`` message from a reader may carry them, and the broker
|
|
373
|
+
refuses them for any other.
|
|
374
|
+
|
|
375
|
+
Raises:
|
|
376
|
+
SendError: There is no broker, or the broker or the recipient refused, or nobody answered in time, or
|
|
377
|
+
the connection was lost with the message in flight.
|
|
378
|
+
"""
|
|
379
|
+
if mode not in MODES:
|
|
380
|
+
raise SendError("invalid", "mode is 'steer' or 'followup'")
|
|
381
|
+
cleaned = self._checked(text, silent=bool(images))
|
|
382
|
+
body: dict[str, Any] = {"to": to, "text": cleaned, "via": via, "hops": hops, "mode": mode}
|
|
383
|
+
if direct:
|
|
384
|
+
body["direct"] = True
|
|
385
|
+
if images:
|
|
386
|
+
body["images"] = [picture.to_wire() for picture in images]
|
|
387
|
+
return self._delivered(await self._request("POST", "/messages", json=body, send=True))
|
|
388
|
+
|
|
389
|
+
async def send_group(
|
|
390
|
+
self, group: str, text: str, *, hops: int = 0, via: str = VIA_AGENT, mode: str = MODE_STEER
|
|
391
|
+
) -> GroupDelivered:
|
|
392
|
+
"""Write to a group chat: every other member that is connected is handed the message.
|
|
393
|
+
|
|
394
|
+
Raises:
|
|
395
|
+
SendError: As for :meth:`send`; ``denied`` when this session is not in the group, ``unknown`` when there
|
|
396
|
+
is no such group, ``gone`` when no other member is connected, ``rejected`` etc. when nobody took it.
|
|
397
|
+
"""
|
|
398
|
+
if mode not in MODES:
|
|
399
|
+
raise SendError("invalid", "mode is 'steer' or 'followup'")
|
|
400
|
+
body = {"group": group, "text": self._checked(text), "via": via, "hops": hops, "mode": mode}
|
|
401
|
+
reply = await self._request("POST", "/messages", json=body, send=True)
|
|
402
|
+
name = reply.get("group")
|
|
403
|
+
delivered = []
|
|
404
|
+
for item in reply.get("delivered", []) if isinstance(reply.get("delivered"), list) else []:
|
|
405
|
+
peer = PeerInfo.from_wire(item.get("to")) if isinstance(item, dict) else None
|
|
406
|
+
if peer is not None:
|
|
407
|
+
disposition = item.get("disposition")
|
|
408
|
+
delivered.append((peer, disposition if isinstance(disposition, str) else ""))
|
|
409
|
+
failed = tuple(
|
|
410
|
+
(str(item.get("handle", "")), str(item.get("reason", "")))
|
|
411
|
+
for item in reply.get("failed", [])
|
|
412
|
+
if isinstance(item, dict)
|
|
413
|
+
)
|
|
414
|
+
return GroupDelivered(name if isinstance(name, str) else group, tuple(delivered), failed)
|
|
415
|
+
|
|
416
|
+
def _checked(self, text: str, *, silent: bool = False) -> str:
|
|
417
|
+
cleaned = clean_text(text)
|
|
418
|
+
if not cleaned.strip() and not silent:
|
|
419
|
+
raise SendError("empty", "a message needs some text")
|
|
420
|
+
if len(cleaned) > self.limits.max_text:
|
|
421
|
+
raise SendError("too_long", f"a message is limited to {self.limits.max_text} characters")
|
|
422
|
+
return cleaned
|
|
423
|
+
|
|
424
|
+
async def _request(
|
|
425
|
+
self,
|
|
426
|
+
method: str,
|
|
427
|
+
path: str,
|
|
428
|
+
*,
|
|
429
|
+
json: dict[str, Any] | None = None,
|
|
430
|
+
params: dict[str, Any] | None = None,
|
|
431
|
+
send: bool = False,
|
|
432
|
+
headers: dict[str, str] | None = None,
|
|
433
|
+
) -> dict[str, Any]:
|
|
434
|
+
"""Ask the broker over HTTP and return its JSON object; a refusal, or a connection lost, is a ``SendError``."""
|
|
435
|
+
import httpx
|
|
436
|
+
|
|
437
|
+
http = self._http if self._connected else None
|
|
438
|
+
if http is None:
|
|
439
|
+
raise SendError("offline", "not connected to the session broker")
|
|
440
|
+
await self._registered()
|
|
441
|
+
try:
|
|
442
|
+
response = await http.request(
|
|
443
|
+
method, path, json=json, params=params, headers=headers, timeout=self.reply_timeout
|
|
444
|
+
)
|
|
445
|
+
except httpx.TimeoutException:
|
|
446
|
+
raise SendError(
|
|
447
|
+
"timeout",
|
|
448
|
+
"the session broker did not answer in time"
|
|
449
|
+
+ ("; the message may or may not have been delivered" if send else ""),
|
|
450
|
+
) from None
|
|
451
|
+
except httpx.HTTPError:
|
|
452
|
+
raise SendError(
|
|
453
|
+
"lost",
|
|
454
|
+
"the connection to the session broker was lost"
|
|
455
|
+
+ ("; the message may or may not have been delivered" if send else ""),
|
|
456
|
+
) from None
|
|
457
|
+
try:
|
|
458
|
+
body = response.json()
|
|
459
|
+
except ValueError:
|
|
460
|
+
body = None
|
|
461
|
+
if response.status_code != 200 or not isinstance(body, dict):
|
|
462
|
+
refused = Refused.from_wire(body.get("error") if isinstance(body, dict) else None)
|
|
463
|
+
raise SendError(refused.reason, str(refused))
|
|
464
|
+
return body
|
|
465
|
+
|
|
466
|
+
async def manage_group(self, action: str, name: str, members: list[str] | None = None) -> GroupInfo | None:
|
|
467
|
+
"""Create a group, add or remove members, leave it or delete it; returns the group after, or ``None`` if gone.
|
|
468
|
+
|
|
469
|
+
Raises:
|
|
470
|
+
SendError: ``denied`` for a group this session is not in, ``duplicate``, ``unknown``, ``invalid``, ``busy``.
|
|
471
|
+
"""
|
|
472
|
+
body = await self._request("POST", "/groups", json={"action": action, "name": name, "members": members or []})
|
|
473
|
+
return GroupInfo.from_wire(body.get("group"))
|
|
474
|
+
|
|
475
|
+
async def join_group(self, name: str) -> GroupInfo | None:
|
|
476
|
+
"""Put this session in a group, as the owner of the broker (whoever holds its token) may.
|
|
477
|
+
|
|
478
|
+
A session can only add to a group it is already in, which a person at a front end who wants to write to a
|
|
479
|
+
group that is not theirs cannot be; the owner can, and does it without a session key.
|
|
480
|
+
|
|
481
|
+
Raises:
|
|
482
|
+
SendError: ``unknown`` for a group that is not there, ``busy`` when it is full.
|
|
483
|
+
"""
|
|
484
|
+
mine = self._session.session_id if self._session is not None else None
|
|
485
|
+
if mine is None:
|
|
486
|
+
raise SendError("not_registered", "this session has not said who it is")
|
|
487
|
+
body = await self._request(
|
|
488
|
+
"POST",
|
|
489
|
+
"/groups",
|
|
490
|
+
json={"action": "add", "name": name, "members": [mine]},
|
|
491
|
+
headers={"X-Session-Key": ""},
|
|
492
|
+
)
|
|
493
|
+
return GroupInfo.from_wire(body.get("group"))
|
|
494
|
+
|
|
495
|
+
async def history(
|
|
496
|
+
self, *, with_: str = "", group: str = "", limit: int = 20, before: int | None = None
|
|
497
|
+
) -> tuple[list[dict[str, Any]], str]:
|
|
498
|
+
"""Return stored messages, oldest first, and the id of the peer when one was asked for.
|
|
499
|
+
|
|
500
|
+
With ``with_`` it is the conversation between this session and that one, with ``group`` what was said in a
|
|
501
|
+
group this session is in, and with neither all of this session's own messages.
|
|
502
|
+
|
|
503
|
+
Raises:
|
|
504
|
+
SendError: ``unknown`` or ``ambiguous`` for a name, ``denied`` for a group this session is not in.
|
|
505
|
+
"""
|
|
506
|
+
params: dict[str, Any] = {"limit": limit}
|
|
507
|
+
if with_:
|
|
508
|
+
params["with"] = with_
|
|
509
|
+
if group:
|
|
510
|
+
params["group"] = group
|
|
511
|
+
if before:
|
|
512
|
+
params["before"] = before
|
|
513
|
+
body = await self._request("GET", "/history", params=params)
|
|
514
|
+
messages = body.get("messages")
|
|
515
|
+
peer = body.get("peer")
|
|
516
|
+
return ([m for m in messages if isinstance(m, dict)] if isinstance(messages, list) else []), (
|
|
517
|
+
peer if isinstance(peer, str) else ""
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
async def graph(self) -> dict[str, Any]:
|
|
521
|
+
"""Return the broker's whole picture: sessions, groups, how much each pair has said, the latest messages."""
|
|
522
|
+
return await self._request("GET", "/graph")
|
|
523
|
+
|
|
524
|
+
async def older(self, before: int, limit: int = 50, group: str = "") -> dict[str, Any]:
|
|
525
|
+
"""Return the messages stored before the message with id ``before``, oldest first, and ``more``.
|
|
526
|
+
|
|
527
|
+
With ``group`` they are that group's messages, and ``before`` of ``0`` asks for its newest.
|
|
528
|
+
"""
|
|
529
|
+
params: dict[str, Any] = {"before": before, "limit": limit}
|
|
530
|
+
if group:
|
|
531
|
+
params["group"] = group
|
|
532
|
+
return await self._request("GET", "/older", params=params)
|
|
533
|
+
|
|
534
|
+
async def timeline(self, since: float, until: float, limit: int = 500) -> dict[str, Any]:
|
|
535
|
+
"""Return the messages stored between two times (epoch seconds), oldest first, to replay on screen."""
|
|
536
|
+
return await self._request("GET", "/timeline", params={"since": since, "until": until, "limit": limit})
|
|
537
|
+
|
|
538
|
+
@staticmethod
|
|
539
|
+
def _delivered(body: dict[str, Any]) -> Delivered:
|
|
540
|
+
"""Read the broker's answer to a send: who took the message."""
|
|
541
|
+
recipient = PeerInfo.from_wire(body.get("to"))
|
|
542
|
+
if recipient is None:
|
|
543
|
+
raise SendError("internal", "the broker's answer did not say who took the message")
|
|
544
|
+
disposition = body.get("disposition")
|
|
545
|
+
return Delivered(recipient, disposition if isinstance(disposition, str) else "", body.get("direct") is True)
|
|
546
|
+
|
|
547
|
+
async def _registered(self) -> None:
|
|
548
|
+
"""Wait, for a moment, until the broker has been told about this session.
|
|
549
|
+
|
|
550
|
+
A connection is usable the moment the broker has greeted it, which is before the session has been described
|
|
551
|
+
to it; a send in that gap would be refused for having no session. The wait is bounded and a failure of it is
|
|
552
|
+
not this call's to report: the send that follows gets the broker's own answer.
|
|
553
|
+
"""
|
|
554
|
+
pusher = self._pusher
|
|
555
|
+
if pusher is not None and not pusher.done():
|
|
556
|
+
with contextlib.suppress(Exception): # the send that follows gets the broker's own answer
|
|
557
|
+
await asyncio.wait_for(asyncio.shield(pusher), self.request_timeout)
|
|
558
|
+
|
|
559
|
+
# -- staying connected ------------------------------------------------------------------------
|
|
560
|
+
async def _supervise(self) -> None:
|
|
561
|
+
"""Stay connected: connect, serve until the connection ends, wait a little, again."""
|
|
562
|
+
failures = 0
|
|
563
|
+
while not self._closing:
|
|
564
|
+
self._goodbye = False
|
|
565
|
+
try:
|
|
566
|
+
served = await self._serve_once()
|
|
567
|
+
except asyncio.CancelledError:
|
|
568
|
+
raise
|
|
569
|
+
except Exception as error: # noqa: BLE001 - a broker problem must never become its session's
|
|
570
|
+
served = False
|
|
571
|
+
self._finder.last_error = (
|
|
572
|
+
str(error) if isinstance(error, ConnectionError) else f"{type(error).__name__}: {error}"
|
|
573
|
+
)
|
|
574
|
+
self._drop()
|
|
575
|
+
if self._closing:
|
|
576
|
+
return
|
|
577
|
+
failures = 0 if served else failures + 1
|
|
578
|
+
if not served:
|
|
579
|
+
self._outage.failed(
|
|
580
|
+
self._finder.last_error or "the session broker cannot be reached", at_once=self._finder.incompatible
|
|
581
|
+
)
|
|
582
|
+
await asyncio.sleep(self._delay(failures))
|
|
583
|
+
|
|
584
|
+
def _delay(self, failures: int) -> float:
|
|
585
|
+
"""Return how long to wait before the next attempt."""
|
|
586
|
+
if self._goodbye:
|
|
587
|
+
return 0.05 # the broker said it was leaving: look for the next one at once
|
|
588
|
+
if self._finder.incompatible:
|
|
589
|
+
return self.incompatible_delay
|
|
590
|
+
return self.backoff[min(failures, len(self.backoff) - 1)] if failures else self.backoff[0]
|
|
591
|
+
|
|
592
|
+
def _drop(self) -> None:
|
|
593
|
+
"""Forget the connection: nothing can be sent now, and whatever was waiting on it has failed."""
|
|
594
|
+
was, self._connected = self._connected, False
|
|
595
|
+
self._watched = False
|
|
596
|
+
self._groups = ()
|
|
597
|
+
self._outbox.clear()
|
|
598
|
+
self._asks.clear()
|
|
599
|
+
self._came_up.clear()
|
|
600
|
+
self._ws = self._http = None
|
|
601
|
+
for reply in self._replies.values():
|
|
602
|
+
if not reply.done():
|
|
603
|
+
reply.set_exception(ConnectionError("the connection ended"))
|
|
604
|
+
if was and self._roster:
|
|
605
|
+
self._roster = ()
|
|
606
|
+
self._notify_roster()
|
|
607
|
+
|
|
608
|
+
async def _serve_once(self) -> bool:
|
|
609
|
+
"""Connect once and serve it to the end; return whether it ever got as far as being greeted."""
|
|
610
|
+
from websockets.exceptions import ConnectionClosed
|
|
611
|
+
|
|
612
|
+
ws, note = await self._finder.open(lambda: self._closing)
|
|
613
|
+
http = None
|
|
614
|
+
greeted = False
|
|
615
|
+
try:
|
|
616
|
+
hello = await self._greeting(ws, note.pid)
|
|
617
|
+
http = self._http_client(note.port, note.token, str(hello.get("key")))
|
|
618
|
+
greeted = True
|
|
619
|
+
self.limits = Limits.from_wire(hello.get("limits"))
|
|
620
|
+
self._ws, self._http, self._connected = ws, http, True
|
|
621
|
+
self._finder.incompatible = False
|
|
622
|
+
self._finder.last_error = None
|
|
623
|
+
self._outage.recovered()
|
|
624
|
+
self._came_up.set()
|
|
625
|
+
self._schedule_push()
|
|
626
|
+
for watching in list(self._watching):
|
|
627
|
+
self._ask("watch", watching)
|
|
628
|
+
if self._observing:
|
|
629
|
+
self._say({"op": "observe"})
|
|
630
|
+
try:
|
|
631
|
+
async for raw in ws:
|
|
632
|
+
await self._heard(ws, raw)
|
|
633
|
+
except ConnectionClosed:
|
|
634
|
+
pass
|
|
635
|
+
self._goodbye = ws.close_code == GOODBYE
|
|
636
|
+
finally:
|
|
637
|
+
if http is not None:
|
|
638
|
+
with contextlib.suppress(Exception):
|
|
639
|
+
await http.aclose()
|
|
640
|
+
with contextlib.suppress(Exception):
|
|
641
|
+
await ws.close()
|
|
642
|
+
return greeted
|
|
643
|
+
|
|
644
|
+
async def _greeting(self, ws: Any, pid: int) -> dict[str, Any]:
|
|
645
|
+
"""Read the broker's greeting, turning what can go wrong into a sentence."""
|
|
646
|
+
from websockets.exceptions import ConnectionClosed
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
hello = json.loads(await asyncio.wait_for(ws.recv(), self.request_timeout))
|
|
650
|
+
except asyncio.TimeoutError:
|
|
651
|
+
raise ConnectionError(f"the session broker (pid {pid}) is running but does not answer") from None
|
|
652
|
+
except (ConnectionClosed, ValueError):
|
|
653
|
+
raise ConnectionError("the session broker closed the connection while greeting") from None
|
|
654
|
+
if not isinstance(hello, dict) or hello.get("op") != "hello" or hello.get("protocol") != PROTOCOL_VERSION:
|
|
655
|
+
self._finder.incompatible = True
|
|
656
|
+
raise ConnectionError(f"the session broker (pid {pid}) speaks another protocol")
|
|
657
|
+
return hello
|
|
658
|
+
|
|
659
|
+
@staticmethod
|
|
660
|
+
def _http_client(port: int, token: str, key: str) -> Any:
|
|
661
|
+
import httpx
|
|
662
|
+
|
|
663
|
+
return httpx.AsyncClient(
|
|
664
|
+
base_url=f"http://127.0.0.1:{port}",
|
|
665
|
+
headers={"Authorization": f"Bearer {token}", "X-Session-Key": key},
|
|
666
|
+
trust_env=False, # the broker is on this machine; a proxy named in the environment is not for it
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
# -- what the broker says ---------------------------------------------------------------------
|
|
670
|
+
async def _heard(self, ws: Any, raw: str | bytes) -> None:
|
|
671
|
+
"""Act on one line from the broker; one that makes no sense is ignored."""
|
|
672
|
+
try:
|
|
673
|
+
line = json.loads(raw)
|
|
674
|
+
except ValueError:
|
|
675
|
+
return
|
|
676
|
+
if not isinstance(line, dict):
|
|
677
|
+
return
|
|
678
|
+
match line.get("op"):
|
|
679
|
+
case "roster":
|
|
680
|
+
self._roster = roster_from_wire(line.get("sessions"))
|
|
681
|
+
self._notify_roster()
|
|
682
|
+
case "groups":
|
|
683
|
+
listed = line.get("groups")
|
|
684
|
+
groups = tuple(
|
|
685
|
+
g for g in (GroupInfo.from_wire(item) for item in (listed if isinstance(listed, list) else [])) if g
|
|
686
|
+
)
|
|
687
|
+
if groups != self._groups:
|
|
688
|
+
self._groups = groups
|
|
689
|
+
self._notify_roster()
|
|
690
|
+
case "traffic":
|
|
691
|
+
if self.on_traffic is not None:
|
|
692
|
+
with contextlib.suppress(Exception): # a listener's bug must not end the connection
|
|
693
|
+
self.on_traffic({k: v for k, v in line.items() if k != "op"})
|
|
694
|
+
case "watched":
|
|
695
|
+
count = line.get("count")
|
|
696
|
+
self._watched = isinstance(count, int) and not isinstance(count, bool) and count > 0
|
|
697
|
+
case "stream":
|
|
698
|
+
session_id, event = line.get("session_id"), line.get("event")
|
|
699
|
+
if self.on_stream is not None and isinstance(session_id, str) and isinstance(event, dict):
|
|
700
|
+
with contextlib.suppress(Exception): # a listener's bug must not end the connection
|
|
701
|
+
self.on_stream(session_id, event)
|
|
702
|
+
case "deliver":
|
|
703
|
+
await ws.send(json.dumps({"op": "ack", "id": line.get("id"), **self._deliver(line)}))
|
|
704
|
+
case "done" | "refused":
|
|
705
|
+
reply = self._replies.get(line["n"]) if isinstance(line.get("n"), int) else None
|
|
706
|
+
if reply is not None and not reply.done():
|
|
707
|
+
reply.set_result(line)
|
|
708
|
+
|
|
709
|
+
def _deliver(self, line: dict[str, Any]) -> dict[str, Any]:
|
|
710
|
+
"""Hand a message to the session and return what the session said, as the fields of an ack."""
|
|
711
|
+
sender, text, hops, via = (
|
|
712
|
+
PeerInfo.from_wire(line.get("from")),
|
|
713
|
+
line.get("text"),
|
|
714
|
+
line.get("hops"),
|
|
715
|
+
line.get("via"),
|
|
716
|
+
)
|
|
717
|
+
if sender is None or not isinstance(text, str) or not isinstance(hops, int) or isinstance(hops, bool):
|
|
718
|
+
return _ack(Receipt(False, "the message was malformed"))
|
|
719
|
+
if self.on_message is None:
|
|
720
|
+
return _ack(Receipt(False, "this session is not taking messages"))
|
|
721
|
+
mode = line.get("mode")
|
|
722
|
+
if not isinstance(mode, str):
|
|
723
|
+
mode = MODE_STEER # a peer that does not say: the protocol's default
|
|
724
|
+
elif mode not in MODES:
|
|
725
|
+
mode = MODE_FOLLOWUP # one this version does not know: the safe half, not the disruptive one
|
|
726
|
+
try:
|
|
727
|
+
pictures = parse_images(line.get("images")) if line.get("direct") is True and via == VIA_USER else ()
|
|
728
|
+
except Refused:
|
|
729
|
+
return _ack(Receipt(False, "the message's pictures were malformed"))
|
|
730
|
+
incoming = Incoming(
|
|
731
|
+
sender,
|
|
732
|
+
clean_text(text),
|
|
733
|
+
hops,
|
|
734
|
+
via if via in VIAS else VIA_AGENT,
|
|
735
|
+
line.get("direct") is True and via == VIA_USER,
|
|
736
|
+
mode,
|
|
737
|
+
line["group"] if isinstance(line.get("group"), str) else "",
|
|
738
|
+
pictures,
|
|
739
|
+
)
|
|
740
|
+
try:
|
|
741
|
+
return _ack(self.on_message(incoming))
|
|
742
|
+
except Exception as error: # noqa: BLE001 - the sender is told, the session carries on
|
|
743
|
+
return _ack(Receipt(False, f"the session could not take it ({type(error).__name__})"))
|
|
744
|
+
|
|
745
|
+
def _notify_roster(self) -> None:
|
|
746
|
+
if self.on_roster is not None:
|
|
747
|
+
with contextlib.suppress(Exception): # a listener's bug must not end the connection
|
|
748
|
+
self.on_roster(self._roster)
|
|
749
|
+
|
|
750
|
+
# -- telling the broker what this session is --------------------------------------------------
|
|
751
|
+
def _schedule_push(self) -> None:
|
|
752
|
+
self._dirty = True
|
|
753
|
+
if self._connected and (self._pusher is None or self._pusher.done()):
|
|
754
|
+
self._pusher = asyncio.ensure_future(self._push())
|
|
755
|
+
|
|
756
|
+
async def _push(self) -> None:
|
|
757
|
+
"""Send the session's latest description, and again if it changed while that was in flight."""
|
|
758
|
+
while self._dirty and self._connected:
|
|
759
|
+
self._dirty = False
|
|
760
|
+
info = self._session
|
|
761
|
+
try:
|
|
762
|
+
await self._tell({"op": "leave"} if info is None else _describe(info))
|
|
763
|
+
self._set_conflict(None)
|
|
764
|
+
except Refused as error:
|
|
765
|
+
if error.reason == "duplicate":
|
|
766
|
+
self._set_conflict(str(error))
|
|
767
|
+
except Exception: # noqa: BLE001 - a lost connection: the next one pushes the latest again
|
|
768
|
+
return
|
|
769
|
+
|
|
770
|
+
async def _tell(self, line: dict[str, Any]) -> dict[str, Any]:
|
|
771
|
+
"""Say something to the broker and return its ``done``; its refusal is raised.
|
|
772
|
+
|
|
773
|
+
Raises:
|
|
774
|
+
Refused: The broker refused.
|
|
775
|
+
"""
|
|
776
|
+
ws = self._ws
|
|
777
|
+
if ws is None:
|
|
778
|
+
raise ConnectionError("not connected")
|
|
779
|
+
number = next(self._numbers)
|
|
780
|
+
reply: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
|
|
781
|
+
self._replies[number] = reply
|
|
782
|
+
try:
|
|
783
|
+
await ws.send(json.dumps({**line, "n": number}))
|
|
784
|
+
answer = await asyncio.wait_for(reply, self.request_timeout)
|
|
785
|
+
finally:
|
|
786
|
+
self._replies.pop(number, None)
|
|
787
|
+
if answer.get("op") == "refused":
|
|
788
|
+
raise Refused.from_wire(answer.get("error"))
|
|
789
|
+
return answer
|
|
790
|
+
|
|
791
|
+
def _set_conflict(self, message: str | None) -> None:
|
|
792
|
+
if message == self._conflict:
|
|
793
|
+
return
|
|
794
|
+
self._conflict = message
|
|
795
|
+
if self.on_conflict is not None:
|
|
796
|
+
with contextlib.suppress(Exception):
|
|
797
|
+
self.on_conflict(message)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _describe(info: SessionInfo) -> dict[str, Any]:
|
|
801
|
+
"""Return a session as the line that tells the broker what it is."""
|
|
802
|
+
return {
|
|
803
|
+
"op": "session",
|
|
804
|
+
"session_id": info.session_id,
|
|
805
|
+
"title": info.title,
|
|
806
|
+
"workspace": info.workspace,
|
|
807
|
+
"status": info.status,
|
|
808
|
+
"kind": info.kind,
|
|
809
|
+
"direct": info.direct,
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _ack(receipt: Receipt) -> dict[str, Any]:
|
|
814
|
+
"""Return a receipt as the fields of an ``ack``."""
|
|
815
|
+
answer: dict[str, Any] = {
|
|
816
|
+
"accepted": receipt.accepted,
|
|
817
|
+
"reason": receipt.reason,
|
|
818
|
+
"disposition": receipt.disposition,
|
|
819
|
+
}
|
|
820
|
+
if receipt.busy:
|
|
821
|
+
answer["busy"] = True
|
|
822
|
+
return answer
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
__all__ = [
|
|
826
|
+
"BACKOFF",
|
|
827
|
+
"BrokerClient",
|
|
828
|
+
"Delivered",
|
|
829
|
+
"INCOMPATIBLE_DELAY",
|
|
830
|
+
"Incoming",
|
|
831
|
+
"PROBLEM_AFTER",
|
|
832
|
+
"REQUEST_TIMEOUT",
|
|
833
|
+
"Receipt",
|
|
834
|
+
"RosterEntry",
|
|
835
|
+
"SendError",
|
|
836
|
+
"SessionInfo",
|
|
837
|
+
]
|