Rooms get their own page, and a room that doesn't answer is explained

Joining a room moves out of Chat into a Rooms page beside Channels. It
lists every room server the radio knows, joined ones first, with each
room's access, unread count, last post and last-heard time. Each room
has a page: join, log in again, change password, open the board, forget
it, or delete its stored posts. Chat now lists only joined rooms, the way
it lists only channels on a slot, and only a joined room raises the
unread badge. chat join becomes rooms join, alongside rooms list and
rooms forget.

"No reply" told the reader nothing, because a room is just as silent for
a wrong password as for a login that never reached it. Testing on
hardware showed the commonest cause is neither: a login sent along the
route the radio had learned met silence, and the same login by flood was
accepted in eleven seconds. A silent login is now explained from what is
known: how it went out, when the room was last heard, and whether this
password has worked before. It offers the one next step that evidence
points at:
- Try by flood, after a learned route met silence. The radio forgets
  the route (local, nothing transmitted), then sends the login once.
- Try again…, after a flood met silence. The prompt reopens with the
  password filled in, so Enter resends it and typing replaces it.
- Add it & try again, when the radio doesn't hold the room as a contact.
Every retry is a button press, and none is ever sent on its own. A
password over 15 bytes is refused at the prompt, since MeshCore sends
only the first 15.

On the board, a failed or unfinished login used to leave the compose
line taking posts the room would drop. It now opens only while we are in
and may post, and otherwise says why and what to press. Opening a joined
room that has gone quiet logs in in the background. ^L runs the
explained flow.

Underneath, the login exchange listens for the radio's own confirmation,
matched to the login by its expected ack. It records whether the login
went by flood or along a route, or the radio's reason for not sending it
at all. This also corrects the debug log: "send unacknowledged" read the
library's wait for the *answer* as the send. On hardware the radio
confirms in under 0.1 s. Device.reset_route is new. The simulator learns
routes, can be given a stale one, and refloods.

Refs: #24
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-05 02:07:10 -04:00
1 parent 4f72210927
commit 19c910bbde
13 files changed
+1841 -411

No files matched your search

+70 -24
View File
@@ -1005,7 +1005,6 @@ The live transcript is a menu screen; from the command line, pick a subcommand.
| `history [--to NAME \| --channel N] [--limit N]` | Print a conversation's stored transcript. Default limit 50. |
| `list` | Every channel, room and contact with its unread count and last message. |
| `listen [-s SECONDS] [--debug]` | Tail inbound messages live. `-s 0` (the default) runs until Ctrl-C. |
| `join ROOM [--password PASSWORD]` | Log in to a room server, and print what it lets you do. |
Exactly one of `--to` and `--channel` is required on `send` and `history`.
@@ -1035,25 +1034,9 @@ $ meshterm --absolute chat history --to Alice
of the line — a message body is the one field that can hold absolutely anything, so
nothing may follow it.
**Rooms.** A room server is a message board that lives on a radio. It keeps the latest
posts, and once you have logged in it sends you every post you missed, then each new one as
people write it. You join a room by logging in with its password, which you get from
whoever runs it:
```console
$ meshterm chat join "Lakeside BBS" --password hello
member
```
`join` prints what the room let you do: `member` (read and post), `admin` (the room's
owner), or `read-only` (the room drops anything you post). A password that works is
remembered, so the next `join` needs no `--password`; an empty one (`--password ""`) asks
the room whether it already knows you. A room never says a password is wrong. It just
doesn't answer, so a `join` that hears nothing fails with exit `4`, and the message names
both possible reasons. The posts the room sends after a login wait on your radio until
something collects them: `chat listen`, or Chat in the menu.
Post with `send --to`, exactly like a direct message. `acked yes` means the room stored the
**Rooms.** A room server is a message board that lives on a radio. Join one first with
[`meshterm rooms join`](#meshterm-rooms); after that, post with `send --to`, exactly like a
direct message. `acked yes` means the room stored the
post, not that anyone has read it. A room's `history` names who wrote each post, where a
direct conversation names the peer:
@@ -1156,13 +1139,76 @@ An inbound message carries only the sender's key prefix, so its `node` is thinne
same node in `contacts` — `name` is whatever the resolver could find and `key` is `null`.
Join on `hash`.
`join` is the room and the access it granted:
`history`, `list` and `listen` return `5` when there is nothing to report.
```json
{"room":{"name":"Lakeside BBS","key":"f6a7b8c900000000000000000000000000000000000000000000000000000000","hash":"f6a7b8c9","type":"room server","self":false},"access":"member"}
#### `meshterm rooms`
A room server is a message board that lives on a radio. It keeps the latest posts, and once
you have logged in it sends you every post you missed, then each new one as people write it.
Joining a room is logging in with its password, which you get from whoever runs it. On its
own, `meshterm rooms` prints this group's help; the Rooms page in the menu does the same
jobs with dialogs.
| Subcommand | What it does |
| --- | --- |
| `list` | Every room server your radio knows, joined or not. |
| `join ROOM [--password PASSWORD] [--flood]` | Log in, and print what the room lets you do. |
| `forget ROOM` | Stop logging in to a room, and forget its password. |
```console
$ meshterm rooms list
ROOM ACCESS HEARD LAST_POST UNREAD
Lakeside BBS member 12m 3m 0
$ meshterm rooms join "Lakeside BBS" --password hello
member
```
`history`, `list` and `listen` return `5` when there is nothing to report.
`join` prints what the room let you do: `member` (read and post), `admin` (the room's
owner), or `read-only` (the room won't keep anything you post). A password that works is
remembered, so the next `join` needs no `--password`. An empty one (`--password ""`) asks
the room whether it already knows you. MeshCore sends at most 15 characters of a password,
so a longer one is refused before anything goes out (exit `2`).
A room never says a password is wrong. It just doesn't answer. So a `join` that hears
nothing fails with exit `4`, and the message says what is known: how the login went out,
when the room was last heard, and whether this password has worked before:
```console
$ meshterm rooms join "Lakeside BBS" --password nope
meshterm: Lakeside BBS didn't answer. It went along the route your radio learned to the
room (1 hop), and a route goes stale when the mesh changes. The room was heard 12m ago. A
room doesn't answer a wrong password — it stays silent — so either it didn't hear you, or
the password is wrong. Try --flood.
```
Your radio sends a login along the route it learned the last time the room answered. When
that route has gone stale, the login vanishes on the way. `--flood` makes the radio forget
the route first, so the login goes to the whole mesh instead, and the room's answer teaches
it a fresh route. Forgetting the route sends nothing; the login is still one transmission.
The posts a room sends after a login wait on your radio until something collects them:
`chat listen`, or Chat in the menu. `chat history --to ROOM` reads them by author.
`forget` is local and prints nothing. MeshCore has no way to leave a room, so the room may
keep sending your radio new posts until it restarts, or until it needs your seat for a new
member (it holds 20, and drops the least active). `forget` exits `5` if you hadn't joined.
**`--json`.** `list` is an array, one object per room:
```json
{"node":{"name":"Lakeside BBS","key":"f6a7b8c900000000000000000000000000000000000000000000000000000000","hash":"f6a7b8c9","type":"room server","self":false},"joined":true,"access":"member","heard_at":null,"last_post_at":null,"unread":0}
```
`access` is `null` for a room you haven't joined, and `heard_at` is `null` for one never
heard. `join` answers with the room, the access, and how the login went out (`route` is
`"flood"`, `"direct"`, or `null` if the radio never confirmed sending it):
```json
{"room":{"name":"Lakeside BBS","key":"f6a7b8c900000000000000000000000000000000000000000000000000000000","hash":"f6a7b8c9","type":"room server","self":false},"access":"member","route":"flood"}
```
`forget` answers with the room and `forgotten` (`true`, or `false` when you hadn't joined).
#### `meshterm channels`
+2 -1
View File
@@ -14,8 +14,9 @@ Every interactive screen is listed here with its scripted equivalent, where one
| Feature | What it does | Scripted |
| --- | --- | --- |
| **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. **Rooms** are here too: a room server is a message board on a radio that keeps the latest posts and sends you the ones you missed. Join one with its password, then read and post, each post shown under whoever wrote it. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list / listen / join` |
| **💬 Chat** | Live full-screen channel and direct messaging — a scrolling transcript with a pinned input line where sent and received messages stream together. The rooms you've joined are here too, each post shown under whoever wrote it. Every message is logged; unread counts show in the menu header. | `meshterm chat send / history / list / listen` |
| **📻 Channels** | Create, join, reorder, mute, and share mesh channels — with QR codes and `meshcore://` share links. Give a channel a **send scope** and its messages are flooded into one region, relayed only by the repeaters that carry it. | `meshterm channels list / add / join / import / share / clear / scope` |
| **📌 Rooms** | Every room server your radio knows. A room server is a message board on a radio that keeps the latest posts and sends you the ones you missed. Join one with its password; when a room doesn't answer, MeshTerm explains what it knows and offers the next thing to try, such as resending by flood when the route your radio learned has gone stale. | `meshterm rooms list / join / forget` |
| **📨 Courier** | Store-and-forward outbox for contacts that aren't reachable yet. Queue a message; it goes out (with ack tracking and polite exponential backoff) the moment the contact is next heard, or at a scheduled time. | `meshterm courier queue / list / send / cancel / clear` |
| **👥 Contacts** | This node and its known contacts — a recency heat-map, overheard packet counts, and full public keys with the path-hash prefix highlighted. The scripted listing is the contacts alone (this node is `meshterm info`'s answer, in far more detail). | `meshterm contacts` |
+162 -42
View File
@@ -130,6 +130,26 @@ _MESSAGE_GET_TIMEOUT_S = 5.0
#: a command-line exchange.)
_TXT_TYPE_SIGNED_PLAIN = 2
@dataclass(frozen=True, slots=True)
class _LoginExchange:
"""One login exchange as :meth:`MeshCoreDevice._login` saw it.
Attributes:
result: How it ended.
payload: The accepting ``LOGIN_SUCCESS`` frame's payload; ``None`` unless accepted.
flood: How the radio confirmed sending it — ``True`` by flood, ``False`` along its
learned route — or ``None`` when it never confirmed sending it.
radio_error: The companion's reason for not sending it, when it never did and said
why; ``None`` otherwise.
"""
result: LoginResult
payload: dict | None
flood: bool | None
radio_error: str | None = None
#: How many uncorrelated frames one :meth:`MeshCoreDevice.admin_login` may quote in its log
#: line. They are evidence for reading a failure afterwards, not a record to keep, and a busy
#: mesh can push a great many through the window — a handful names the fault, and the rest
@@ -1198,6 +1218,24 @@ class Device(ABC):
How the login ended, and the access granted when it was accepted.
"""
@abstractmethod
async def reset_route(self, node: Contact) -> None:
"""Forget the route the radio learned to ``node``, so its next message floods.
A route is learned from the path a flood took to arrive, and it goes stale as the
mesh changes — a repeater moved, switched off, or out-heard — after which every
message sent along it vanishes without a word. Forgetting it is local (nothing is
transmitted); the next message to the node floods the whole mesh, and the radio
learns a fresh route from the answer. MeshCore's own apps call this *reset path*.
Args:
node: The contact whose route to forget.
Raises:
ContactNotOnDeviceError: If the radio holds no contact for the node.
DeviceCommandError: If the radio refused for any other reason.
"""
@abstractmethod
async def send_remote_command(
self, node: Contact, command: str, *, timeout: float = 8.0
@@ -3093,18 +3131,29 @@ class MeshCoreDevice(Device):
return pub
async def admin_login(self, node: Contact, password: str) -> LoginResult: # noqa: D102
result, _payload = await self._login(node, password, what="admin login")
return result
return (await self._login(node, password, what="admin login")).result
async def room_login(self, room: Contact, password: str) -> RoomLogin: # noqa: D102
result, payload = await self._login(room, password, what="room login")
if result is not LoginResult.ACCEPTED:
return RoomLogin(result)
return RoomLogin(result, RoomAccess.from_login(payload or {}))
exchange = await self._login(room, password, what="room login")
access = None
if exchange.result is LoginResult.ACCEPTED:
access = RoomAccess.from_login(exchange.payload or {})
return RoomLogin(
exchange.result, access, flood=exchange.flood, radio_error=exchange.radio_error
)
async def _login(
self, node: Contact, password: str, *, what: str
) -> tuple[LoginResult, dict | None]:
async def reset_route(self, node: Contact) -> None: # noqa: D102 - inherited docstring
mc = self._require()
pub = self._node_pubkey(node)
result = await mc.commands.reset_path(pub)
if result is None or getattr(result, "is_error", lambda: False)():
if error_code(result) == _ERR_NOT_FOUND:
raise ContactNotOnDeviceError(node)
raise DeviceCommandError(
f"couldn't reset the route to {node.name}: {reject_reason(result)}"
)
async def _login(self, node: Contact, password: str, *, what: str) -> _LoginExchange:
"""Run one login exchange with a remote node and report how it ended.
The one login a remote node has, whichever role the password earns:
@@ -3116,8 +3165,8 @@ class MeshCoreDevice(Device):
what: How the debug log names the exchange.
Returns:
The outcome, and the accepting ``LOGIN_SUCCESS`` frame's payload (``None``
unless the login was accepted).
The outcome, the accepting ``LOGIN_SUCCESS`` frame's payload, and how the radio
sent the request (see :class:`_LoginExchange`).
"""
from meshcore import EventType
@@ -3151,7 +3200,22 @@ class MeshCoreDevice(Device):
# when it gives up early, the node still gets a full budget of its own, sized to the
# route the way a trace to the same node would be, and counted from the send rather
# than from the queuing — see the wait below for why that distinction is the fix.
#
# What ``send_login_sync`` returns is the library's own wait for the *answer* (it
# gives the node ``suggested_timeout / 800`` seconds), never whether the request
# went out — so the radio's confirmation is heard here, on its own listener. On
# hardware it lands within a tenth of a second while the library's wait runs on for
# six; this log used to call every such login "unacknowledged" (verified against a
# room 2026-10-05, whose acceptance then arrived ten seconds after the send). The
# confirmation also says how the radio sent it, a flood or along the route it had
# learned — what tells a stale route from a silent node.
answer: asyncio.Future = loop.create_future()
# The radio's confirmation of *this* request: a login's ``expected_ack`` is the first
# four bytes of the addressed key (MeshCore's ``pending_login``), which tells it from
# the MSG_SENT of any other command in flight.
confirm: asyncio.Future = loop.create_future()
ours = bytes.fromhex(pub[:8])
errors: list[object] = []
# Frames that landed during the exchange but are not our verdict. Kept only to be
# logged beside the outcome: a "no reply" with one of these next to it is a
# different fault from a node that stayed silent, and nothing else would show it.
@@ -3177,14 +3241,21 @@ class MeshCoreDevice(Device):
# ``send_login_sync`` erases it — it turns its own ERROR into a bare ``None``,
# indistinguishable from an acknowledgement that was merely slow — so the reason
# is only recoverable by listening for the frame. Uncorrelated (an ERROR carries
# no request id and may belong to another command in flight), so it is evidence
# for the log and never a verdict.
# no request id and may belong to another command in flight), so it becomes the
# reason only when this request was never confirmed sent at all.
note("companion error:", event)
errors.append(event)
def on_sent(event) -> None: # noqa: ANN001 - meshcore Event
payload = getattr(event, "payload", None) or {}
if not confirm.done() and payload.get("expected_ack") == ours:
confirm.set_result((loop.time(), payload))
subscriptions = [
mc.subscribe(EventType.LOGIN_SUCCESS, on_answer),
mc.subscribe(EventType.LOGIN_FAILED, on_answer),
mc.subscribe(EventType.ERROR, on_local_error),
mc.subscribe(EventType.MSG_SENT, on_sent),
]
# A login is a round trip along the contact's route and back, which is the shape a
# trace budget already describes; the stored route is one-way, so the wire carries
@@ -3197,17 +3268,18 @@ class MeshCoreDevice(Device):
started = loop.time()
try:
async with self.transmitting():
sent = await mc.commands.send_login_sync(pub, password)
# The budget times the *node's* answer, so it is spent from the moment the
# request is on the air — not from the moment we began queuing it. Everything
# before that belongs to the companion, and it can be most of a minute:
# ``send_login_sync`` first waits its turn on the library's mesh-request lock,
# which every telemetry poll and courier retry holds for a whole round trip, and
# then blocks until a MSG_SENT it correlates by event type alone arrives. Timing
# the repeater from before all that is what made the last fix a longer way of
# failing at the same ten seconds: the wait was widened, then handed a window
# some other command had already spent.
sent_at = loop.time()
library = await mc.commands.send_login_sync(pub, password)
# The budget is spent *after* ``send_login_sync`` returns, never from the moment
# we began queuing. Everything before that belongs to the companion and the
# library, and it can be most of a minute: it waits its turn on the library's
# mesh-request lock, which every telemetry poll and courier retry holds for a
# whole round trip, then for the radio's confirmation, then — the part this log
# long mistook for the send — for the node's answer, ``suggested_timeout / 800``
# seconds. So a node gets that library wait *and* the budget: about six seconds
# and ten for a flood, which is what a room five hops out needed (its answers
# landed ten seconds after the confirmation, on hardware). Timing from the
# queuing is what made an earlier fix a longer way of failing at the same ten
# seconds: the wait was widened, then handed a window something else had spent.
if not answer.done():
# Shielded: a timeout here must leave the future readable, not cancel it.
try:
@@ -3219,37 +3291,52 @@ class MeshCoreDevice(Device):
subscription.unsubscribe()
event = answer.result() if answer.done() else None
if event is None and getattr(sent, "type", None) is EventType.LOGIN_SUCCESS:
if event is None and getattr(library, "type", None) is EventType.LOGIN_SUCCESS:
# Cannot normally happen — our subscription was registered first, so anything the
# library's own wait saw, ours saw too. Deferring to it anyway costs nothing and
# can only ever turn a false no-reply into the acceptance it really was, which is
# the direction this whole method is trying to fail in.
event = sent
event = library
etype = getattr(event, "type", None)
# Three numbers, because a no-reply has three different causes and they are told
# apart only by where the time went: a send that took seconds means the request sat
# behind another command, a send that returned at once with no acknowledgement means
# the companion never put it on the air, and a full budget spent after a clean send
# means the node really did stay silent.
confirmed_at, confirmation = confirm.result() if confirm.done() else (None, {})
flood = None if confirmed_at is None else confirmation.get("type") == 1
# Where the time went, because a no-reply has three different causes and they are
# told apart by it: a confirmation that took seconds means the request sat behind
# another command, no confirmation at all means the companion never put it on the
# air (and the error it raised, if any, is logged beside it), and a full wait after
# a prompt confirmation means the node really did stay silent.
answered = loop.time()
_log.debug(
"%s to %s: send %s in %.0fms, budget=%.1fs -> %s after %.0fms%s",
"%s to %s: %s, budget=%.1fs -> %s %.0fms after %s%s",
what,
node.name,
"acknowledged" if sent is not None else "unacknowledged",
(sent_at - started) * 1000.0,
"never confirmed sent"
if confirmed_at is None
else f"sent by {'flood' if flood else 'its route'}, confirmed in "
f"{(confirmed_at - started) * 1000.0:.0f}ms",
budget,
etype,
(loop.time() - sent_at) * 1000.0,
(answered - (confirmed_at or started)) * 1000.0,
"the send" if confirmed_at is not None else "queuing it",
f" [also heard: {'; '.join(stray)}]" if stray else "",
)
radio_error = None
if confirmed_at is None and errors:
radio_error = (
"this node isn't in the radio's contacts"
if error_code(errors[-1]) == _ERR_NOT_FOUND
else reject_reason(errors[-1])
)
if etype is EventType.LOGIN_SUCCESS:
return LoginResult.ACCEPTED, dict(getattr(event, "payload", None) or {})
return _LoginExchange(
LoginResult.ACCEPTED, dict(getattr(event, "payload", None) or {}), flood
)
if etype is EventType.LOGIN_FAILED:
return LoginResult.REFUSED, None
return _LoginExchange(LoginResult.REFUSED, None, flood)
# Nothing came back — including the local-ERROR case, where the companion would not
# even send the request. Either way we never heard the node, so the password stands
# unproven rather than disproven and the caller must keep it.
return LoginResult.NO_REPLY, None
return _LoginExchange(LoginResult.NO_REPLY, None, flood, radio_error)
@staticmethod
def _refers_to(event: object, pubkey: str) -> bool:
@@ -4226,6 +4313,10 @@ class MockDevice(Device):
#: Whoever is subscribed to the event stream right now, so a room can push its posts
#: to them after a login the way a real room's catch-up arrives — unsolicited.
self._listeners: list[EventCallback] = []
#: Nodes whose learned route has gone stale: a message sent along it meets silence,
#: and a flood gets through and learns a fresh one. Empty by default; name a contact
#: here to walk the "try by flood" path a stale route leaves a reader on.
self._stale_routes: set[str] = set()
# Remote-admin simulation: which nodes we're "logged in" to, and each tuned
# node's transmit power keyed by full public key. ``_default_remote_tx`` is the
# assumed power before the optimizer first writes one.
@@ -4437,8 +4528,21 @@ class MockDevice(Device):
async def room_login(self, room: Contact, password: str) -> RoomLogin: # noqa: D102
await asyncio.sleep(0)
key = self._mock_key(room)
if room.name in self._unreachable:
return RoomLogin(LoginResult.NO_REPLY)
held = next((c for c in self._contacts if self._mock_key(c) == key), None)
if held is None:
return RoomLogin(
LoginResult.NO_REPLY,
flood=None,
radio_error="this node isn't in the radio's contacts",
)
# How the radio sends it: along the route it learned, or by flood once that is gone.
flood = held.route_hops is None
if room.name in self._unreachable or (room.name in self._stale_routes and not flood):
return RoomLogin(LoginResult.NO_REPLY, flood=flood)
if flood:
# The answer to a flood brings a fresh route back with it, as on hardware.
self._stale_routes.discard(room.name)
self._set_route(key, self._LEARNED_ROUTE)
cfg = self._remote_config(room)
if password == self._admin_password:
access = RoomAccess.ADMIN
@@ -4450,12 +4554,28 @@ class MockDevice(Device):
access = RoomAccess.READ_ONLY
else:
# A room never says no: a wrong password is met with silence.
return RoomLogin(LoginResult.NO_REPLY)
return RoomLogin(LoginResult.NO_REPLY, flood=flood)
self._room_access[key] = access
if access is RoomAccess.ADMIN:
self._admin_sessions.add(key)
self._push_room(room)
return RoomLogin(LoginResult.ACCEPTED, access)
return RoomLogin(LoginResult.ACCEPTED, access, flood=flood)
async def reset_route(self, node: Contact) -> None: # noqa: D102 - inherited docstring
await asyncio.sleep(0)
key = self._mock_key(node)
if not any(self._mock_key(c) == key for c in self._contacts):
raise ContactNotOnDeviceError(node)
self._set_route(key, None)
#: The route the simulator "learns" back from a flood's answer: one hop, via Yagi.
_LEARNED_ROUTE = ("a1b2c3d4",)
def _set_route(self, key: str, hops: tuple[str, ...] | None) -> None:
"""Give the contact under ``key`` a learned route, or forget it (``None``)."""
self._contacts = [
replace(c, route_hops=hops) if self._mock_key(c) == key else c for c in self._contacts
]
def _push_room(self, room: Contact) -> None:
"""Start a room's catch-up: each post newer than the last one we hold, oldest first.
+8
View File
@@ -159,10 +159,18 @@ class RoomLogin:
password gets no reply at all, so :attr:`~LoginResult.NO_REPLY` is also what a
mistyped password looks like.
access: The access granted, when the login was accepted.
flood: How the radio sent the request, as it confirmed sending it: ``True`` to the
whole mesh, ``False`` along the route it had learned for the room. ``None`` when
it never confirmed sending it at all. Half of why a login meets silence: a
learned route goes stale as the mesh changes, and a flood is the way round it.
radio_error: The companion's own reason for not sending the request, when it
refused to (a room missing from its contacts, most often); ``None`` otherwise.
"""
result: LoginResult
access: RoomAccess | None = None
flood: bool | None = None
radio_error: str | None = None
def __bool__(self) -> bool:
"""Truthy only when logged in, like the :class:`LoginResult` it carries."""
+5 -1
View File
@@ -426,7 +426,11 @@ class ChatService:
ident = (ident or "").lower()
if ident and (ident.startswith(peer) or peer.startswith(ident)):
if contact.is_room:
return post
# Chat lists a room once it is joined, as it lists a channel once it
# is on a slot; a room still sending to us after we forgot it has
# no row for the badge to point at.
rooms = getattr(self._ctx, "rooms", None)
return post and (rooms is None or rooms.joined(contact))
return is_direct_messageable(contact.node_type)
return False
+51 -8
View File
@@ -70,8 +70,39 @@ class RoomService:
return self._ctx.room_store.password(room) if self._ctx.room_store else None
def joined(self, room: Contact) -> bool:
"""Whether MeshTerm can log in to ``room`` without asking for a password."""
return self.password(room) is not None
"""Whether we have joined ``room``: a login to it was accepted, and not forgotten since.
Joining is what the Rooms page does, and what puts a room in Chat — the way a
channel is in Chat once it is on a slot. An admin password Repeater admin happens to
remember is not a join: it lets a join go through without asking, nothing more.
"""
store = self._ctx.room_store
return store is not None and store.membership(room) is not None
def worked_before(self, room: Contact, password: str) -> bool:
"""Whether ``password`` is one that has got us into ``room`` before.
The one piece of evidence a silent room leaves about the password: a password that
worked last time is still right unless the owner changed it, so silence then points
at the room not hearing us rather than at the password.
"""
if self._ctx.admin_store.get(room) == password and password:
return True
store = self._ctx.room_store
return store is not None and self.joined(room) and store.password(room) == password
def forget(self, room: Contact) -> None:
"""Leave ``room``, as far as MeshTerm can: stop logging in, and forget its password.
The room keeps sending to the radio until it restarts or gives up on us — a member
cannot resign — and its posts already stored here are kept. An admin password
Repeater admin remembers for it is Repeater admin's, and stays.
"""
if self._ctx.room_store is not None:
self._ctx.room_store.forget(room)
peer = self._peer(room)
self._access.pop(peer, None)
self._heard.pop(peer, None)
def access(self, room: Contact) -> RoomAccess | None:
"""What ``room`` lets us do: as granted this session, else at our last login, if any."""
@@ -106,17 +137,18 @@ class RoomService:
)
def login_due(self, room: Contact) -> bool:
"""Whether opening ``room`` should log in: a password at hand, and a quiet room.
"""Whether opening ``room`` should log in: joined, a password at hand, and quiet.
Never while a login to it is already on its way — that one's answer is this one's.
"""
return (
self.joined(room)
and self.password(room) is not None
and not self.heard_recently(room)
and self._peer(room) not in self._inflight
)
async def login(self, room: Contact, password: str) -> RoomLogin:
async def login(self, room: Contact, password: str, *, flood: bool = False) -> RoomLogin:
"""Log in to ``room`` with ``password`` — one exchange — and remember the outcome.
An accepted login is remembered the way each password is kept: the access and the
@@ -136,9 +168,13 @@ class RoomService:
Args:
room: The room server to log in to.
password: The password to offer (``""`` asks the room whether it knows us).
flood: Forget the route the radio learned to the room first, so the login
floods the whole mesh — the way round a route that has gone stale, which is
silent exactly as a wrong password is. Forgetting it is local; the login is
still the one transmission.
Returns:
How the login ended, and the access granted.
How the login ended, the access granted, and how the radio sent it.
Raises:
Exception: Propagates a device error (no connection, a contact the companion
@@ -148,13 +184,13 @@ class RoomService:
running = self._inflight.get(peer)
if running is not None:
task, sent = running
if sent == password:
if sent == password and not flood:
return await asyncio.shield(task)
try:
await asyncio.shield(task)
except Exception: # noqa: BLE001 - its own caller hears about its failure
pass
task = asyncio.ensure_future(self._login_once(room, password))
task = asyncio.ensure_future(self._login_once(room, password, flood=flood))
self._inflight[peer] = (task, password)
# Cleared when the exchange ends, not when this caller stops waiting: a room view
# closed mid-login leaves the login running, and it is still the one in flight.
@@ -168,9 +204,16 @@ class RoomService:
if not task.cancelled():
task.exception()
async def _login_once(self, room: Contact, password: str) -> RoomLogin:
async def _login_once(self, room: Contact, password: str, *, flood: bool) -> RoomLogin:
"""One login exchange, and what its outcome means for the stores and the session."""
device = await self._ctx.device()
if flood:
await device.reset_route(room)
# The route the contact cache holds is gone; the next read re-fetches it (with
# the one the radio learns from this login's answer).
devstate = getattr(self._ctx, "devstate", None)
if devstate is not None:
devstate.invalidate_contacts()
login = await device.room_login(room, password)
if self._ctx.room_store is not None:
self._ctx.room_store.record(room, password, login)
+12 -125
View File
@@ -4,9 +4,9 @@
Interactively it opens a conversation picker and then a live, full-screen chat (see
:mod:`meshterm.ui.chat`) where sent and received messages stream together — or, for a room
server, its board (:mod:`meshterm.ui.room`). On the CLI it exposes ``send``, ``history``,
``list``, ``listen`` and ``join`` subcommands for scripted use; a room is addressed by
``--to`` like a contact, and ``join`` logs in to one. Channels are only *listed* here for
picking; creating and editing channel slots lives in the ``channels`` tool.
``list`` and ``listen`` subcommands for scripted use; a room is addressed by ``--to`` like a
contact. Channels and rooms are only *listed* here for picking: configuring channel slots is
the ``channels`` tool's, and joining a room is the ``rooms`` tool's.
Every inbound message is recorded to history by the always-on
:class:`~meshterm.services.chat_service.ChatService`, and outbound messages are recorded on
@@ -193,10 +193,12 @@ class ChatTool(Tool):
# (the app-wide DM rule, see is_direct_messageable). Room servers have their own
# section: somewhere you join and post, not someone you message.
companions = [c for c in contacts if is_direct_messageable(c.node_type)]
# Only rooms we have joined — as only channels on a slot are listed. Joining is the
# Rooms page's (see meshterm.ui.rooms).
rooms = [
Conversation(label=c.name, is_channel=False, contact=c)
for c in contacts
if is_room(c.node_type)
if is_room(c.node_type) and ctx.rooms.joined(c)
]
room_peers = [conv.peer for conv in rooms if conv.peer]
# A stable snapshot orders the rows (so the list doesn't reshuffle under the
@@ -216,9 +218,6 @@ class ChatTool(Tool):
direct = [Conversation(label=c.name, is_channel=False, contact=c) for c in companions]
direct.sort(key=lambda conv: _recency_key(conv, lasts))
rooms.sort(key=lambda conv: _recency_key(conv, lasts))
# Read once per build rather than per repaint: a room is joined from inside it, and
# the list is rebuilt when the reader comes back.
joined = {conv.key for conv in rooms if ctx.rooms.joined(conv.contact)}
# One measurement for the whole list, headings and rows alike, taken before either is
# built: the name lane is only as wide as the names actually in it, and every cell it
# gives back goes to the message preview (see _lanes).
@@ -245,15 +244,7 @@ class ChatTool(Tool):
for conversation in rooms:
items.append(
Choice(
title=_row_title(
ctx,
conversation,
live,
key_of,
lanes,
name_of=name_of,
joined=conversation.key in joined,
),
title=_row_title(ctx, conversation, live, key_of, lanes, name_of=name_of),
value=conversation,
deletable=lasts.get(conversation.key) is not None,
hscroll_from=lanes.head,
@@ -261,8 +252,8 @@ class ChatTool(Tool):
)
if not rooms:
# Said, not left out: "how do I get on a room?" is a question people bring here,
# and an empty section answers where one will be.
items.append(Separator(" no rooms yet — a room server is listed once it's heard"))
# and an empty section answers where to go.
items.append(Separator(" no rooms joined — join one on the Rooms page"))
items.append(section_heading("👤 Direct"))
if companions:
@@ -318,7 +309,7 @@ class ChatTool(Tool):
Args:
ctx: Shared application context.
action: One of ``send``, ``history``, ``listen``, ``join``, ``list``.
action: One of ``send``, ``history``, ``listen``, ``list``.
params: The action's arguments.
Returns:
@@ -326,8 +317,6 @@ class ChatTool(Tool):
"""
if action == "send":
return await self._cli_send(ctx, params)
if action == "join":
return await self._cli_join(ctx, params)
if action == "history":
return await self._cli_history(ctx, params)
if action == "listen":
@@ -350,7 +339,7 @@ class ChatTool(Tool):
``--to`` a room server posts to it. The room acknowledges a post once it has stored
it, so ``acked`` reads the same way; a room that no longer knows us (it restarted,
or we never joined) drops the post unacknowledged — ``chat join`` first.
or we never joined) drops the post unacknowledged — ``rooms join`` first.
"""
device = await ctx.device()
text = str(params["text"])
@@ -417,50 +406,6 @@ class ChatTool(Tool):
exit_code=exitcodes.OK if messages else exitcodes.NO_RESULT,
)
async def _cli_join(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Log in to a room server — one exchange — and print the access it granted.
The password is ``--password``, else the one MeshTerm remembers for the room (its
room password, then its admin password); one that works is remembered, exactly as
the menu's join prompt remembers it. The room then sends every post the companion
hasn't seen, one at a time — they wait on the radio for whatever reads it next
(``chat listen``, the menu), and ``chat history`` has them from then on.
A room never says no: a wrong password gets no reply at all, so silence is a
failure here (exit 4) that names both of its causes, and keeps whatever password
was remembered.
"""
from ..core.connection import DeviceCommandError
from ..core.models import LoginResult
device = await ctx.device()
name = str(params["room"])
room = _resolve_contact(await device.get_contacts(), name)
# Both are the argument being wrong, with nothing yet on the air: usage errors.
if not room.is_room:
raise typer.BadParameter(f"{room.name!r} is not a room server")
password = params.get("password")
if password is None:
password = ctx.rooms.password(room)
if password is None:
raise typer.BadParameter(
f"no password remembered for {room.name!r}; pass --password "
"(an empty one asks the room whether it already knows you)"
)
login = await ctx.rooms.login(room, str(password))
if login.result is LoginResult.REFUSED:
raise DeviceCommandError(f"the radio refused the login to {room.name!r}")
if login.access is None:
raise DeviceCommandError(
f"{room.name!r} did not answer the login — it is out of reach, or the "
"password is wrong (a room says nothing to a wrong one). Any remembered "
"password was kept."
)
return ToolResult(
summary={"room": room.name, "access": login.access.value},
report=(_joined(room, login.access.value),),
)
async def _cli_listen(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Tail inbound messages live in the console (and record them to history).
@@ -597,28 +542,6 @@ class ChatTool(Tool):
def _list_cmd() -> None:
run_tool_command(self, {"cli_action": "list"})
@chat_app.command("join", help="Log in to a room server, so it sends you its posts")
def _join_cmd(
room: str = typer.Argument(..., help="The room server's name or key prefix"),
password: str | None = typer.Option(
None, "--password", help="Room or admin password (else remembered)"
),
) -> None:
run_tool_command(self, {"cli_action": "join", "room": room, "password": password})
@chat_app.command("listen", help="Tail inbound messages live in the console")
def _listen_cmd(
seconds: int = typer.Option(
0, "--seconds", "-s", help="How long to listen (0 = until Ctrl-C)"
),
debug: bool = typer.Option(
False, "--debug", help="Log the message-pull activity (diagnostic)"
),
) -> None:
if debug:
_enable_receive_debug()
run_tool_command(self, {"cli_action": "listen", "seconds": seconds})
app.add_typer(chat_app, name=self.name)
@@ -939,7 +862,6 @@ def _row_title(
lanes: _Lanes,
*,
name_of: NodeResolver | None = None,
joined: bool = True,
) -> Callable[[], str | Text]:
"""Return a picker-row title *callable* the select screen re-renders on each repaint.
@@ -954,12 +876,11 @@ def _row_title(
key_of: Maps a sender name back to its node's key, for the preview hues.
lanes: The list's measured lane widths.
name_of: Maps a room post's author key to a name (rooms only).
joined: Whether MeshTerm can log in to the room unasked (rooms only).
Returns:
A zero-argument callable producing the current row title.
"""
return lambda: _title(ctx, conversation, lasts, key_of, lanes, name_of=name_of, joined=joined)
return lambda: _title(ctx, conversation, lasts, key_of, lanes, name_of=name_of)
def _title(
@@ -970,7 +891,6 @@ def _title(
lanes: _Lanes,
*,
name_of: NodeResolver | None = None,
joined: bool = True,
) -> str | Text:
"""Build a picker row as fixed-width, colour-coded lanes.
@@ -990,10 +910,6 @@ def _title(
key_of: Maps a preview sender/mention name back to its node's key.
lanes: The list's measured lane widths.
name_of: Maps a room post's author key to a name (rooms only).
joined: For a room, whether MeshTerm holds a password for it. A room with nothing
to preview and no password says ``not joined…`` in the preview lane — opening
it asks for the password, so the row ends on the ellipsis a row that opens a
prompt carries.
Returns:
The row title as a styled :class:`~rich.text.Text`.
@@ -1023,8 +939,6 @@ def _title(
text.append(" ")
if last is not None:
text.append_text(_preview_text(last, key_of, name_of))
elif conversation.is_room and not joined:
text.append("not joined…", style="muted")
return text
@@ -1383,33 +1297,6 @@ def _conversations(ctx: AppContext, rows: list, lasts: dict) -> Listing:
)
def _joined(room: Contact, access: str) -> Facts:
"""What ``chat join`` won: the access a room granted, bare — the one fact asked for.
The room rides in the document, the caller having named it; the plain face prints the
access word alone (``member``, ``admin`` or ``read-only``), which is what a script
deciding whether it may post branches on.
"""
from ..ui import fields
from ..ui.report import BARE, Facts
return Facts(
key="joined",
fields=(fields.node("room", lanes=()), fields.word("access", "access")),
values={
"room": NodeRef(
name=room.name,
key=(room.public_key or "").lower() or None,
hash=(room.key_prefix or "").lower() or None,
type=NODE_TYPE_LABELS.get(room.node_type),
),
"access": access,
},
shape=BARE,
bare="access",
)
def _sent(
*,
contact: object | None = None,
+280
View File
@@ -0,0 +1,280 @@
# SPDX-License-Identifier: Apache-2.0
"""The ``rooms`` tool: the room servers your radio knows, and joining them.
Interactively it opens the Rooms page (see :mod:`meshterm.ui.rooms`) — every room server
the radio knows, joined ones first, each with a page to join it, log in again, open its
board, or forget it. On the CLI it exposes ``list``, ``join`` and ``forget``.
A room's board is *read* in Chat, the way a channel's messages are; this tool owns
everything to do with getting in, as the ``channels`` tool owns everything to do with
configuring a slot.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.models import NODE_TYPE_LABELS, Contact, LoginResult
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.fields import NodeRef
from ..ui.report import Facts, Listing
@register
class RoomsTool(Tool):
"""Join room servers, and keep track of the ones you have."""
name = "rooms"
title = "Rooms"
icon = "📌"
help = "Join room servers' message boards"
category = "Message"
order = 25 # beside Channels: the other half of what Chat lists
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Open the Rooms page (menu) or run a scripted action (CLI).
Args:
ctx: Shared application context.
params: A ``cli_action`` with its arguments (CLI), or empty for the menu.
Returns:
A :class:`ToolResult` recording what happened (the menu path shows nothing).
"""
action = params.get("cli_action")
if action == "join":
return await self._cli_join(ctx, params)
if action == "forget":
return await self._cli_forget(ctx, params)
if action == "list":
return await self._cli_list(ctx)
from ..ui.rooms import manage_rooms
return ToolResult(summary={"logins": await manage_rooms(ctx)})
# -- CLI --------------------------------------------------------------------
async def _rooms(self, ctx: AppContext) -> list[Contact]:
"""Every room server the radio knows, as the session's contact cache holds it."""
await ctx.device()
return [c for c in await ctx.devstate.contacts() if c.is_room]
async def _room(self, ctx: AppContext, needle: str) -> Contact:
"""The room server a name or key prefix names — a usage error for anything else."""
folded = needle.casefold()
contacts = await ctx.devstate.contacts()
for contact in contacts:
pub = (contact.public_key or "").lower()
if contact.name.casefold() == folded or (folded and pub.startswith(folded)):
if not contact.is_room:
raise typer.BadParameter(f"{contact.name!r} is not a room server")
return contact
raise typer.BadParameter(f"no room server matches {needle!r}")
async def _cli_list(self, ctx: AppContext) -> ToolResult:
"""List every room server the radio knows: joined, access, heard, posts, unread."""
rooms = await self._rooms(ctx)
peers = [_peer(r) for r in rooms]
lasts = ctx.repo.last_chat_messages(rooms=peers)
records = []
for room in sorted(rooms, key=lambda r: (not ctx.rooms.joined(r), r.name.casefold())):
joined = ctx.rooms.joined(room)
access = ctx.rooms.access(room) if joined else None
last = lasts.get(f"dm:{_peer(room)}")
records.append(
{
"node": _node(room),
"joined": joined,
"access": access.value if access else None,
"heard_at": room.last_seen,
"last_post_at": last.created_at if last is not None else None,
"unread": ctx.chat.unread(f"dm:{_peer(room)}"),
}
)
return ToolResult(
summary={"rooms": len(records)},
report=(_listing(records),),
exit_code=exitcodes.OK if records else exitcodes.NO_RESULT,
)
async def _cli_join(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Log in to a room server — one exchange — and print the access it granted.
The password is ``--password``, else the one MeshTerm remembers for the room (its
admin password, then its room password); one that works is remembered, exactly as
the Rooms page remembers it. ``--flood`` forgets the route the radio learned to the
room first, the way round a route gone stale. The room then sends every post the
radio hasn't seen, one at a time — they wait on the radio for whatever reads it next
(``chat listen``, the menu), and ``chat history`` has them from then on.
A room never says no: a wrong password gets no reply at all. So silence is a
failure here (exit 4), explained in the Rooms page's own words — how the login went
out, when the room was last heard, and whether this password has worked before —
and whatever password was remembered is kept.
"""
from ..core.connection import DeviceCommandError
from ..ui.rooms import silence_explanation, valid_password
room = await self._room(ctx, str(params["room"]))
password = params.get("password")
if password is None:
password = ctx.rooms.password(room)
if password is None:
raise typer.BadParameter(
f"no password remembered for {room.name!r}; pass --password "
"(an empty one asks the room whether it already knows you)"
)
verdict = valid_password(str(password))
if verdict is not True:
raise typer.BadParameter(str(verdict))
login = await ctx.rooms.login(room, str(password), flood=bool(params.get("flood")))
if login.result is LoginResult.REFUSED:
raise DeviceCommandError(f"{room.name!r} refused the login; the password may be wrong")
if login.access is None:
explained = " ".join(silence_explanation(ctx, room, login, str(password)))
hint = " Try --flood." if login.flood is False else ""
raise DeviceCommandError(explained + hint)
return ToolResult(
summary={"room": room.name, "access": login.access.value},
report=(_joined(room, login.access.value, login.flood),),
)
async def _cli_forget(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Stop logging in to a room and forget its password; its stored posts are kept."""
room = await self._room(ctx, str(params["room"]))
was = ctx.rooms.joined(room)
ctx.rooms.forget(room)
return ToolResult(
summary={"room": room.name, "forgotten": was},
report=(_forgotten(room, was),),
exit_code=exitcodes.OK if was else exitcodes.NO_RESULT,
)
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``rooms`` subcommand group.
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
rooms_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode=None)
@rooms_app.command("list", help="Every room server your radio knows")
def _list_cmd() -> None:
run_tool_command(self, {"cli_action": "list"})
@rooms_app.command("join", help="Log in to a room server, so it sends you its posts")
def _join_cmd(
room: str = typer.Argument(..., help="The room server's name or key prefix"),
password: str | None = typer.Option(
None, "--password", help="Room or admin password (else remembered)"
),
flood: bool = typer.Option(
False, "--flood", help="Forget the radio's route to the room first, and flood"
),
) -> None:
run_tool_command(
self,
{"cli_action": "join", "room": room, "password": password, "flood": flood},
)
@rooms_app.command("forget", help="Stop logging in to a room, and forget its password")
def _forget_cmd(
room: str = typer.Argument(..., help="The room server's name or key prefix"),
) -> None:
run_tool_command(self, {"cli_action": "forget", "room": room})
app.add_typer(rooms_app, name=self.name)
# -- the reports ----------------------------------------------------------------------
def _peer(room: Contact) -> str:
"""The room's key as its conversation stores it."""
return (room.key_prefix or room.public_key[:12] or "").lower()
def _node(room: Contact) -> NodeRef:
"""A room as the shared node object."""
from ..ui.fields import NodeRef
return NodeRef(
name=room.name,
key=(room.public_key or "").lower() or None,
hash=(room.key_prefix or "").lower() or None,
type=NODE_TYPE_LABELS.get(room.node_type),
)
def _listing(records: list[dict]) -> Listing:
"""``rooms list``: one room per record, joined ones first.
``ACCESS`` is ``-`` for a room not joined (the document's ``joined`` says so outright);
``HEARD`` is when the room was last heard on the air, the one column that says whether
a login has any chance of reaching it.
"""
from ..ui import fields
from ..ui.report import Listing
return Listing(
key="rooms",
columns=(
fields.node("node", lanes=(("name", "ROOM"),)),
fields.hidden("joined"),
fields.word("access", "ACCESS"),
fields.when("heard_at", "HEARD", absent="never"),
fields.when("last_post_at", "LAST_POST", absent="never"),
fields.integer("unread", "UNREAD"),
),
rows=records,
order=("ROOM", "ACCESS", "HEARD", "LAST_POST", "UNREAD"),
)
def _joined(room: Contact, access: str, flood: bool | None) -> Facts:
"""What ``rooms join`` won: the access a room granted, bare — the one fact asked for.
The room and how the login went out ride in the document; the plain face prints the
access word alone (``member``, ``admin`` or ``read-only``), which is what a script
deciding whether it may post branches on.
"""
from ..ui import fields
from ..ui.report import BARE, Facts
return Facts(
key="joined",
fields=(
fields.node("room", lanes=()),
fields.word("access", "access"),
fields.hidden("route"),
),
values={
"room": _node(room),
"access": access,
"route": None if flood is None else ("flood" if flood else "direct"),
},
shape=BARE,
bare="access",
)
def _forgotten(room: Contact, was: bool) -> Facts:
"""What ``rooms forget`` did: silent on the plain face, the room and the fact in JSON."""
from ..ui import fields
from ..ui.report import SILENT, Facts
return Facts(
key="forgotten",
fields=(fields.node("room", lanes=()), fields.flag("forgotten", "forgotten")),
values={"room": _node(room), "forgotten": was},
shape=SILENT,
)
+109 -82
View File
@@ -15,12 +15,13 @@ Three things set it apart, each the room's own:
key, and under the bare hash — in the unidentified-node grey — when nothing does. Either
way its colour is the key's, never a guess from the name.
* **Getting in is a login.** The title carries what the room let us do (``member``,
``admin``, ``read-only``) or where the login stands. Opening a room logs in to it only
when it has been quiet (:meth:`~meshterm.services.rooms.RoomService.login_due`), asks for
the room's password the first time, and ^L logs in again on demand. The login runs while
the board stays open to read, since a room several hops out can take a while to answer.
* **Read-only means silent.** A room drops a read-only member's posts without a word, so
the compose line says so instead of taking typing that would go nowhere.
``admin``, ``read-only``) or where the login stands. Joining is the Rooms page's
(:mod:`meshterm.ui.rooms`); here, opening a joined room logs in to it again only when it
has been quiet (:meth:`~meshterm.services.rooms.RoomService.login_due`), quietly and with
the board open to read, and ^L runs the explained login on demand.
* **Posting waits for the room.** A room drops a post from anyone it doesn't know — or
knows read-only — without a word, so the compose line opens only once we are in and may
post, and until then says why not and what to press.
"""
from __future__ import annotations
@@ -34,6 +35,7 @@ from rich.text import Text
from ..core.events import EventKind, MeshEvent
from ..core.models import (
ChatMessage,
Contact,
Conversation,
LoginResult,
NodeResolver,
@@ -51,8 +53,10 @@ LOGGING_IN = "logging in…"
NO_REPLY = "no reply"
#: The title's login atom after the companion refused the login outright.
REFUSED = "refused"
#: The title's atom for a room we hold no password for, the join prompt dismissed.
#: The title's atom for a board opened on a room not joined.
NOT_JOINED = "not joined"
#: The title's login atom after a login failed outright (the link, the radio).
FAILED = "login failed"
def author_label(author: str, resolve: NodeResolver) -> tuple[str, str | None]:
@@ -119,9 +123,9 @@ class RoomScreen(ChatScreen):
names: dict[str, str],
session, # noqa: ANN001 - TuiSession, imported lazily to avoid a cycle
resolve: NodeResolver,
password: Callable[[], str | None],
ask: Callable[[], Awaitable[str | None]],
log_in: Callable[[str], Awaitable[RoomLogin]],
auto_login: Callable[[], Awaitable[RoomLogin | None]],
join: Callable[[bool], Awaitable[RoomLogin | None]],
joined: bool = True,
access: RoomAccess | None = None,
resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None,
paths: Callable[[ChatMessage], Awaitable[None]] | None = None,
@@ -137,10 +141,13 @@ class RoomScreen(ChatScreen):
names: Contact key prefix → name (what the chat screen labels a peer with).
session: The running :class:`~meshterm.ui.tui.session.TuiSession`.
resolve: Maps an author's key prefix to a name (see :func:`author_label`).
password: The password at hand for this room, or ``None`` when none is
remembered (``""`` is a password: an open room).
ask: Asks the reader for the room's password; ``None`` when they decline.
log_in: Logs in with a password — one exchange — and says how it ended.
auto_login: Logs in quietly with the remembered password — what opening a quiet
room does; ``None`` when there is none to log in with.
join: The explained login flow (:func:`~meshterm.ui.rooms.join_room`), its
dialogs floating over the board; called with whether to ask for the password
even when one is remembered. ``None`` when the reader backed out.
joined: Whether the room has been joined. A board opened on one that hasn't
(its stored posts, read from the Rooms page) takes no posts.
access: What the room let us do when we last logged in, if we ever did.
resend: Posts an unacknowledged post again (^R).
paths: Presents the paths a picked post took (^P).
@@ -158,74 +165,82 @@ class RoomScreen(ChatScreen):
)
self._label = conversation.label
self._resolve = resolve
self._password = password
self._ask = ask
self._log_in = log_in
self._auto_login = auto_login
self._join = join
self._access = access
#: Where the login stands, when that is what the title should say instead of the
#: access (one of the module's atoms); ``None`` lets the access speak.
self._login_state: str | None = None
self._login_state: str | None = None if joined else NOT_JOINED
self._login_open = False
self._apply_access()
# --- the login ----------------------------------------------------------------------
def begin_login(self, *, ask: bool = False) -> None:
"""Log in to the room, off the key handler; one login at a time.
"""^L: the explained login flow, its prompt and its outcome floating over the board.
Args:
ask: Ask for the password even when one is remembered — what ^L does after a
login met silence, since a room is just as silent for a wrong password.
ask: Ask for the password even when one is remembered — what a read-only member
needs, the remembered one having earned no more than that.
"""
if self._login_open:
return
self._login_open = True
self._session.run_detached(self._run_login(ask))
self._session.run_detached(self._settle(self._join(ask)))
async def _run_login(self, ask: bool) -> None:
"""Find a password (asking if need be), log in with it, and show how it went."""
def begin_auto_login(self) -> None:
"""Opening a quiet room: log in with what is remembered, the board open meanwhile.
No dialogs — the reader asked for the board, not for a login — so the title says
``logging in…`` while it runs, and the compose line waits for the answer: a room
that has forgotten us drops whatever we post, so posting opens only once it has let
us in. Silence shows in the same two places, and ^L is the explained retry.
"""
if self._login_open:
return
self._login_open = True
self._login_state = LOGGING_IN
self._apply_access()
self._session.run_detached(self._settle(self._auto_login()))
async def _settle(self, attempt: Awaitable[RoomLogin | None]) -> None:
"""Await one login attempt, and show how it ended."""
try:
password = None if ask else self._password()
if password is None:
password = await self._ask()
if password is None: # declined: the board stays open to read
if self._access is None and self._password() is None:
self._login_state = NOT_JOINED
return
self._login_state = LOGGING_IN
self._status = ""
self._retitle()
try:
login = await self._log_in(password)
except Exception as exc: # noqa: BLE001 - report inline, keep the board open
self._login_state = None
self._status = f"login failed: {exc}"
return
self._apply_login(login)
login = await attempt
except Exception as exc: # noqa: BLE001 - shown on the board, which stays open
self._login_state = FAILED
self._status = f"login failed: {exc}"
else:
if login is not None:
self._apply_login(login)
elif self._login_state == LOGGING_IN:
self._login_state = None # nothing to log in with after all
finally:
self._login_open = False
self._retitle()
self._apply_access()
self._session.invalidate()
def _apply_login(self, login: RoomLogin) -> None:
"""Take in a login's outcome: the access it won, or why there is none."""
self._status = ""
if login.access is not None and login.result is LoginResult.ACCEPTED:
self._access = login.access
self._login_state = None
self._status = ""
elif login.result is LoginResult.REFUSED:
self._login_state = REFUSED
self._status = "The radio refused the login. ^L to enter the password again."
else:
# The one failure a room has: silence. It is also how it answers a wrong
# password, so the line names both and ^L asks for the password next time.
self._login_state = NO_REPLY
self._status = "No reply — out of reach, or a wrong password. ^L to try again."
self._apply_access()
def _apply_access(self) -> None:
"""Let the compose line take typing only where the room keeps what we post."""
self._composing = self._access is None or self._access.can_post
"""Open the compose line only while we are in, and the room keeps what we post.
Not while a login is on its way or has met silence: a room that has forgotten us —
it restarted, or gave up on us — drops a post without a word, and a compose line
that took one anyway was the one thing the board said that wasn't so.
"""
self._composing = (
self._login_state is None and self._access is not None and self._access.can_post
)
self._retitle()
def _retitle(self) -> None:
@@ -268,10 +283,24 @@ class RoomScreen(ChatScreen):
return super()._header_key(sender, message)
def _compose_line(self, width: int) -> RenderableType:
"""The compose line — or, for a read-only member, why there is none."""
"""The compose line — or, while there can be none, why not and what to press."""
if self._composing:
return super()._compose_line(width)
return Text("read-only — this room doesn't keep what you post", style="muted")
# Each inside the handhelds' 53 columns, so the reason is one line everywhere.
why = {
LOGGING_IN: "logging in… posting opens once the room lets you in",
NO_REPLY: "not logged in: no answer from the room · ^L to retry",
REFUSED: "not logged in: the login was refused · ^L to retry",
FAILED: "not logged in · ^L to try again",
NOT_JOINED: "not joined · ^L to join",
}.get(self._login_state or "")
if why is None:
why = (
"read-only — the room drops your posts · ^L to log in"
if self._access is RoomAccess.READ_ONLY
else "not logged in · ^L to log in"
)
return Text(why, style="muted")
def _submit(self) -> None:
"""Post the compose line, unless the room is known to drop our posts."""
@@ -281,14 +310,9 @@ class RoomScreen(ChatScreen):
def handle(self, action: str, data: str = "") -> None:
"""^L logs in; everything else is the chat's."""
if action == "login":
# Asked for, rather than sent with what is remembered, wherever that would only
# repeat the outcome on screen: after silence (a room's answer to a wrong
# password too), and when all we are is read-only — the room password is what
# lets a reader post.
ask = self._login_state in (NO_REPLY, REFUSED, NOT_JOINED) or (
self._access is RoomAccess.READ_ONLY
)
self.begin_login(ask=ask)
# A read-only member's remembered password has earned all it can, so ^L asks
# for another; everyone else's is tried first, the flow asking only if it fails.
self.begin_login(ask=self._access is RoomAccess.READ_ONLY)
self._session.invalidate()
return
super().handle(action, data)
@@ -318,11 +342,12 @@ class RoomScreen(ChatScreen):
async def open_room(ctx: AppContext, conversation: Conversation) -> int:
"""Open the room view for ``conversation`` and run it until the reader leaves.
The board opens at once on what is stored. A room with no password remembered asks for
one over it (the first visit — joining); one that has been quiet logs in with the
password it has, which restarts the room's catch-up; one heard from lately sends
nothing at all. Posts that arrive while it is open append live, and the room's unread
count stays clear while the reader is on it.
The board opens at once on what is stored. A joined room that has been quiet logs in
with the password remembered for it — in the background, the board readable meanwhile —
which restarts the room's catch-up; one heard from lately sends nothing at all. Joining
is the Rooms page's (:mod:`meshterm.ui.rooms`), so a room not joined opens read-only on
its stored posts, and ^L joins it. Posts that arrive while the board is open append
live, and the room's unread count stays clear while the reader is on it.
Args:
ctx: The shared application context (running the interactive TUI surface).
@@ -332,6 +357,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
The number of posts on the board when the view closed.
"""
from ..services import trace_runner
from .rooms import join_room
session = ctx.ui.session
room = conversation.contact
@@ -357,15 +383,18 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
async def resend(message: ChatMessage) -> ChatMessage:
return await _with_restore(ctx, lambda: ctx.chat.resend_post(room, message))
async def ask() -> str | None:
joined = ctx.rooms.joined(room)
return await session.text(
f"Log in to {room.name}" if joined else f"Join {room.name}",
prompt="Room password (blank if the room already knows you):",
help_text="Ask whoever runs the room. One left on its stock settings uses hello.",
password=True,
floating=True,
)
async def fresh() -> Contact:
"""The room as the contact cache holds it now: its route and heard time move."""
held = await ctx.devstate.contacts()
key = room.public_key or room.key_prefix
return next((c for c in held if (c.public_key or c.key_prefix) == key), room)
async def auto_login() -> RoomLogin | None:
password = ctx.rooms.password(room)
return None if password is None else await ctx.rooms.login(room, password)
async def join(ask: bool) -> RoomLogin | None:
return await join_room(ctx, await fresh(), ask=ask)
screen = RoomScreen(
conversation,
@@ -374,9 +403,9 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
names=_contact_names(contacts),
session=session,
resolve=trace_runner.make_node_resolver(contacts, stored_names),
password=lambda: ctx.rooms.password(room),
ask=ask,
log_in=lambda password: ctx.rooms.login(room, password),
auto_login=auto_login,
join=join,
joined=ctx.rooms.joined(room),
access=ctx.rooms.access(room),
resend=resend,
paths=await _make_paths_presenter(ctx, conversation, device),
@@ -391,13 +420,11 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
unsubscribe = ctx.events.subscribe(on_event, EventKind.MESSAGE)
try:
# Kept pushed for the visit (one round of it) so the join prompt can float over the
# Kept pushed for the visit (one round of it) so a login's dialogs float over the
# board the moment it is up, rather than over whatever the room was opened from.
async with session.stay(screen) as visit:
if not ctx.rooms.joined(room):
screen.begin_login(ask=True)
elif ctx.rooms.login_due(room):
screen.begin_login()
if ctx.rooms.login_due(room):
screen.begin_auto_login()
await visit.result() # the board resolves only when the reader leaves it
finally:
unsubscribe()
+594
View File
@@ -0,0 +1,594 @@
# SPDX-License-Identifier: Apache-2.0
"""The Rooms page: the room servers your radio knows, and joining them.
The Channels page's counterpart for rooms (see :mod:`meshterm.ui.channels`). A channel is
something the radio holds in a slot; a room is somewhere you log in to — so where Channels
creates and keys slots, this page lists every room server the radio knows, joined ones
first, and joins them: a password, one login, and what the room let us do. Once joined, a
room is in Chat, the way a channel is once it has a slot, and its board is read and posted
to there (:mod:`meshterm.ui.room`).
Joining is also where a room is hardest to understand, because a room never says *no*. A
wrong password gets no reply at all; so does a room out of reach, and so does a login sent
along a route the radio learned that has since gone stale. "No reply" alone is no help, so
the outcome is explained from what is actually known — how the login went out, when the
room was last heard, whether this password has worked before — and the way on is offered as
buttons: try by flood (which forgets the stale route first), try again, or try another
password. Each of those is one more login the reader asked for; none is ever sent on its own.
That flow — :func:`join_room` — is shared: the board's ^L runs it too, and the command
line's ``rooms join`` explains a silent room in the same words (:func:`silence_explanation`).
The page follows the Channels page's shape: one list kept pushed for the whole visit, each
room a detail page with its vital signs over its actions, every prompt floating over it.
"""
from __future__ import annotations
import asyncio
import time
from typing import TYPE_CHECKING
from rich.cells import cell_len
from rich.text import Text
from ..core.connection import ContactNotOnDeviceError
from ..core.models import (
NODE_TYPE_ROOM,
Contact,
Conversation,
LoginResult,
RoomAccess,
RoomLogin,
is_room,
)
from ..platforms import get_platform
from .menus import (
Lane,
column_header,
fit_cells,
icon_lane,
marked_label,
menu_rows,
section_heading,
)
from .theme import name_style
from .tui import CANCEL, Choice, SelectScreen, Separator
from .widgets import NODE_GLYPHS, age_seconds, format_age, format_ago
if TYPE_CHECKING:
from ..context import AppContext
#: The page's footer sentence: navigation, then the filter, then the action, Esc last.
_PAGE_HINT = "↑↓ move · type to filter · Enter open · Esc back"
#: MeshCore copies at most this many bytes of a login password into the request
#: (``BaseChatMesh::sendLogin``) and drops the rest without a word, so a longer password
#: could never work — it is refused at the prompt instead of meeting silence on the air.
PASSWORD_BYTES = 15
# Detail-page action sentinels.
_OPEN = "open"
_JOIN = "join"
_RELOGIN = "relogin"
_PASSWORD = "password"
_FORGET = "forget"
_DELETE = "delete"
# What an outcome dialog's buttons answer with.
_RETRY_FLOOD = "flood"
_RETRY_PASSWORD = "password"
_RETRY = "again"
_RESTORE = "restore"
#: A room row's marker: the room server's ``■``, in its type colour (the map's, the
#: contact list's, the chat picker's).
_ROOM_MARK = NODE_GLYPHS[NODE_TYPE_ROOM]
# --- joining ---------------------------------------------------------------------------
def valid_password(text: str) -> bool | str:
"""A password a login can carry whole: :data:`PASSWORD_BYTES` bytes at most."""
if len(text.encode("utf-8")) > PASSWORD_BYTES:
return f"MeshCore sends at most {PASSWORD_BYTES} characters of a password"
return True
async def ask_password(ctx: AppContext, room: Contact, default: str = "") -> str | None:
"""Ask for ``room``'s password; ``None`` when the reader backs out.
A blank answer is a real one: it asks the room whether it already knows us, which it
does for its admins and for anyone who has logged in since it last restarted.
``default`` fills the field (masked) with the password just tried, so trying it again
is Enter and trying another is typing over it.
"""
verb = "Log in" if ctx.rooms.joined(room) else "Join"
return await ctx.ui.text(
f"{verb} — {room.name}",
prompt="Room password (blank if the room already knows you):",
help_text="Ask whoever runs the room. One left on its stock settings uses hello.",
default=default,
validate=valid_password,
password=True,
floating=True,
)
def silence_explanation(
ctx: AppContext, room: Contact, login: RoomLogin, password: str
) -> list[str]:
"""Why a login met silence, in what is actually known — one sentence each.
THE explanation, shared by the join dialogs and the command line. A room that doesn't
answer could have missed the login, or heard it with the wrong password, and it says
nothing either way; so instead of a guess, the facts that tell the two apart: how the
radio sent it (a stale learned route is the commonest silent failure, and a flood is
the way round it), when the room was last heard, and whether this password has ever
worked here.
Args:
ctx: Shared application context.
room: The room, as the session's contact cache holds it (its ``last_seen`` is the
later of its own advert and our last reception of it).
login: The login that met silence.
password: The password it carried.
Returns:
The sentences, in reading order.
"""
if login.flood is None:
why = f": {login.radio_error}" if login.radio_error else ""
return [f"Your radio didn't send the login{why}."]
lines = [f"{room.name} didn't answer."]
if login.flood:
lines.append("The login went out to the whole mesh, as a flood.")
else:
hops = len(room.route_hops or ())
span = f" ({hops} hop{'' if hops == 1 else 's'})" if hops else ""
lines.append(
f"It went along the route your radio learned to the room{span}, and a route "
"goes stale when the mesh changes."
)
if room.last_seen is None:
lines.append("The room has never been heard here.")
else:
ago = format_ago(age_seconds(room.last_seen))
lines.append(
"The room was heard just now." if ago == "now" else f"It was last heard {ago}."
)
if ctx.rooms.worked_before(room, password):
lines.append("This password got you in before, so the room most likely didn't hear you.")
else:
lines.append(
"A room doesn't answer a wrong password — it stays silent — so either it didn't "
"hear you, or the password is wrong."
)
return lines
async def join_room(ctx: AppContext, room: Contact, *, ask: bool = False) -> RoomLogin | None:
"""Join ``room``, or log in to it again — THE explicit flow, explained to the end.
The password remembered for the room is used unless ``ask`` (or there is none), in
which case the reader is asked. The login runs under a modal card counting the seconds,
since a room several hops out can take fifteen to answer. A login that lets the reader
post ends the flow quietly — the page or board it was started from shows the access.
Anything else is explained (:func:`_outcome`), and the reader picks what to try next;
every retry is one more login they chose to send.
Args:
ctx: Shared application context (running the interactive TUI surface).
room: The room server, as the session's contact cache holds it.
ask: Ask for the password even when one is remembered.
Returns:
The last login's outcome, or ``None`` when the reader backed out before sending one.
"""
password = None if ask else ctx.rooms.password(room)
tried = ""
flood = False
last: RoomLogin | None = None
while True:
if password is None:
password = await ask_password(ctx, room, default=tried)
if password is None:
return last
try:
last = await _log_in(ctx, room, password, flood=flood)
except ContactNotOnDeviceError:
if await _restore(ctx, room):
continue
return last
except Exception as exc: # noqa: BLE001 - every failure is said, none is fatal
await ctx.ui.dialog(
Text(f"The login to {room.name} failed: {exc}", style="err"),
[("OK", None)],
title=_title(ctx, room),
)
return last
if last.access is not None and last.access.can_post:
return last
choice = await _outcome(ctx, room, last, password)
if choice == _RETRY_FLOOD:
flood = True
elif choice == _RETRY:
flood = False
elif choice == _RETRY_PASSWORD:
tried, password, flood = password, None, False
elif choice == _RESTORE:
if not await _restore(ctx, room, asked=True):
return last
else:
return last
def _title(ctx: AppContext, room: Contact) -> str:
"""The flow's dialog title: ``Join — Room``, or ``Log in — Room`` once joined."""
return f"{'Log in' if ctx.rooms.joined(room) else 'Join'} — {room.name}"
async def _log_in(ctx: AppContext, room: Contact, password: str, *, flood: bool) -> RoomLogin:
"""Send one login under a modal card that says how it went out and counts the wait."""
if flood or room.route_hops is None:
how = "by flood"
elif room.route_hops:
how = f"along its {len(room.route_hops)}-hop route"
else:
how = "directly"
caption = f"logging in to {room.name} {how}…"
started = time.monotonic()
async with ctx.ui.busy_dialog(caption, title="Rooms") as busy:
async def count() -> None:
while True:
await asyncio.sleep(1.0)
busy.message = f"{caption} {int(time.monotonic() - started)} s"
ticker = asyncio.ensure_future(count())
try:
return await ctx.rooms.login(room, password, flood=flood)
finally:
ticker.cancel()
try:
await ticker
except asyncio.CancelledError:
pass
async def _outcome(ctx: AppContext, room: Contact, login: RoomLogin, password: str) -> object:
"""Explain a login that didn't let us post, and ask what to try next.
Two buttons at most — the safe way out and the one next step the evidence points at —
so the row fits the handhelds' 45-cell dialog whole. A stale route is the first thing
to rule out (its silence is the commonest, and the cheapest to test: the same password
by flood); only once a flood has met silence is the password in question, and *Try
again…* then reopens the prompt with it filled in, so Enter resends it and typing
replaces it.
Returns:
The chosen button's value: one of the ``_RETRY*`` / ``_RESTORE`` sentinels, or
``None`` to stop.
"""
title = _title(ctx, room)
if login.access is RoomAccess.READ_ONLY:
text = Text(f"You're in {room.name}, but read-only.", style="warn")
text.append(
" You can read its posts, and it won't keep yours. A room lets any password in "
"read-only when its owner allows that, so this one may not be the room password."
)
buttons = [("Keep read-only", None), ("Try again…", _RETRY_PASSWORD)]
return await ctx.ui.dialog(text, buttons, title=title, default=1)
if login.result is LoginResult.REFUSED:
text = Text(f"{room.name} refused the login.", style="warn")
text.append(" The password may be wrong.")
buttons = [("Cancel", None), ("Try again…", _RETRY_PASSWORD)]
return await ctx.ui.dialog(text, buttons, title=title, default=1)
lines = silence_explanation(ctx, room, login, password)
text = Text(lines[0], style="warn")
for line in lines[1:]:
text.append(" " + line)
if login.flood is None:
missing = bool(login.radio_error) and "contacts" in login.radio_error
step = ("Add it & try again", _RESTORE) if missing else ("Try again", _RETRY)
elif not login.flood:
# A learned route that worked before can fail silently; a flood goes round it.
step = ("Try by flood", _RETRY_FLOOD)
elif ctx.rooms.worked_before(room, password):
step = ("Try again", _RETRY)
else:
step = ("Try again…", _RETRY_PASSWORD)
return await ctx.ui.dialog(text, [("Cancel", None), step], title=title, default=1)
async def _restore(ctx: AppContext, room: Contact, *, asked: bool = False) -> bool:
"""Put a room the radio has forgotten back in its contacts; ``True`` once it is there.
A login is addressed through the radio's own contact entry, so a room MeshTerm still
lists but the radio has dropped can't be logged in to until it is written back — the
same one write a direct message's "add it back" makes.
"""
if not asked:
add = await ctx.ui.dialog(
f"{room.name} isn't in this radio's contacts, so it can't be addressed. "
"Add it back and try again?",
[("Cancel", False), ("Add & try again", True)],
title=_title(ctx, room),
default=1,
)
if not add:
return False
device = await ctx.device()
await device.add_contact(room)
ctx.devstate.invalidate_contacts()
return True
# --- the page --------------------------------------------------------------------------
#: Width of the access lane (fits ``read-only``).
_ACCESS_WIDTH = 9
#: Width of the unread-badge lane (fits ``● 999``).
_BADGE_WIDTH = 5
#: Width of the age lanes (right-aligned; fits ``now`` and ``59m``).
_AGE_WIDTH = 5
#: The room name lane's floor and ceiling: as wide as the names in it, within these.
_NAME_MIN = 8
_NAME_MAX = 16
async def manage_rooms(ctx: AppContext) -> int:
"""Run the Rooms page until the reader backs out.
Args:
ctx: Shared application context (running the interactive TUI surface).
Returns:
How many logins the visit accepted — the run log's record, not anything shown.
"""
joins = 0
async def rows() -> tuple[str, list]:
contacts = await ctx.devstate.contacts()
return _page_items(ctx, [c for c in contacts if is_room(c.node_type)])
title, items = await rows()
menu = SelectScreen(title, items, footer_hint=_PAGE_HINT)
async with ctx.ui.session.stay(menu) as visit:
while True:
choice = await visit.result()
if choice is CANCEL or choice is None:
return joins
joins += await _room_detail(ctx, str(choice))
title, items = await rows()
menu.replace_items(items, title=title)
def _page_items(ctx: AppContext, rooms: list[Contact]) -> tuple[str, list]:
"""The page's title and rows: the lane names, then Joined, then Not joined."""
joined = sorted((r for r in rooms if ctx.rooms.joined(r)), key=lambda r: r.name.casefold())
others = sorted(
(r for r in rooms if not ctx.rooms.joined(r)),
# Heard most recently first: the rooms in reach lead the ones long gone.
key=lambda r: (r.last_seen is None, -(r.last_seen.timestamp() if r.last_seen else 0)),
)
peers = [_peer(r) for r in rooms]
lasts = ctx.repo.last_chat_messages(rooms=peers)
longest = max((cell_len(r.name) for r in rooms), default=0)
name_w = max(_NAME_MIN, min(_NAME_MAX, longest))
marker_w = cell_len(_ROOM_MARK[0]) + 1
items: list = [Separator(lambda w: _header(marker_w, name_w, w), pinned=True)]
items.append(section_heading("Joined"))
for room in joined:
items.append(_row(ctx, room, lasts, marker_w, name_w))
if not joined:
items.append(Separator(" no rooms joined yet — pick one below to join it"))
items.append(section_heading("Not joined"))
for room in others:
items.append(_row(ctx, room, lasts, marker_w, name_w))
if not others:
items.append(Separator(" no other room servers heard yet"))
return f"Rooms · {len(joined)} joined", items
def _peer(room: Contact) -> str:
"""The room's key as its conversation stores it."""
return (room.key_prefix or room.public_key[:12] or "").lower()
def _header(marker_w: int, name_w: int, width: int) -> str:
"""Column headings over the page's lanes."""
return column_header(
[
Lane("ROOM", name_w + 2),
Lane("ACCESS", _ACCESS_WIDTH + 2),
Lane("NEW", _BADGE_WIDTH + 2),
Lane("LAST", _AGE_WIDTH + 2),
Lane("HEARD"),
],
width,
indent=2 + marker_w,
)
def _row(ctx: AppContext, room: Contact, lasts: dict, marker_w: int, name_w: int) -> Choice:
"""One room as fixed lanes: mark, name, access, unread, last post, and last heard."""
def title() -> Text:
conversation = Conversation(label=room.name, is_channel=False, contact=room)
text = Text(no_wrap=True, overflow="ellipsis")
mark, style = _ROOM_MARK
text.append(mark, style=style)
text.append(" " * max(1, marker_w - cell_len(mark)))
text.append(fit_cells(room.name, name_w), style=name_style(room.name, room.public_key))
text.append(" ")
access = ctx.rooms.access(room) if ctx.rooms.joined(room) else None
text.append(fit_cells(access.value if access else "", _ACCESS_WIDTH), style="muted")
text.append(" ")
unread = ctx.chat.unread(conversation.key)
if unread:
text.append("●", style="err")
text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn")
else:
text.append(" " * _BADGE_WIDTH)
text.append(" ")
last = lasts.get(conversation.key)
posted = format_age(age_seconds(last.created_at)) if last is not None else ""
text.append(f"{posted:>{_AGE_WIDTH}}", style="muted")
text.append(" ")
heard = format_age(age_seconds(room.last_seen)) if room.last_seen else "never"
text.append(f"{heard:>{_AGE_WIDTH}}", style="muted")
return text
return Choice(title=title, value=room.public_key or room.key_prefix, fitted=True)
async def _room_detail(ctx: AppContext, key: str) -> int:
"""One room's page: its vital signs over its actions; returns logins accepted."""
joins = 0
async def current() -> Contact | None:
contacts = await ctx.devstate.contacts()
return next((c for c in contacts if (c.public_key or c.key_prefix) == key), None)
room = await current()
if room is None: # pragma: no cover - the row came from this very cache
return 0
menu = SelectScreen(
f"Room — {room.name}",
_detail_items(ctx, room),
prompt=_summary(ctx, room),
footer_hint="↑↓ move · Enter select · Esc back",
)
async with ctx.ui.session.stay(menu) as visit:
while True:
choice = await visit.result()
if choice is CANCEL or choice is None:
return joins
if choice in (_JOIN, _RELOGIN, _PASSWORD):
login = await join_room(ctx, room, ask=choice != _RELOGIN)
if login is not None and login.access is not None:
joins += 1
elif choice == _OPEN:
from .room import open_room
await open_room(ctx, Conversation(label=room.name, is_channel=False, contact=room))
elif choice == _FORGET:
await _forget(ctx, room)
elif choice == _DELETE:
await _delete_posts(ctx, room)
room = await current() or room
menu.replace_items(
_detail_items(ctx, room), title=f"Room — {room.name}", prompt=_summary(ctx, room)
)
def _summary(ctx: AppContext, room: Contact) -> str:
"""One line of vital signs, shed from the right until it fits the platform."""
joined = ctx.rooms.joined(room)
access = ctx.rooms.access(room) if joined else None
parts = [access.value if access else ("joined" if joined else "not joined")]
if room.last_seen is not None:
ago = format_ago(age_seconds(room.last_seen))
parts.append("heard just now" if ago == "now" else f"heard {ago}")
else:
parts.append("never heard")
if room.route_hops is None:
parts.append("no route — logins flood")
else:
hops = len(room.route_hops)
parts.append(f"{hops}-hop route" if hops else "a neighbour")
posts = len(ctx.repo.recent_chat_messages(is_channel=False, peer=_peer(room), posts_only=True))
parts.append(f"{posts} post{'' if posts == 1 else 's'} stored" if posts else "no posts stored")
# The page floats as a box, its border and padding eight cells inside the screen.
width = get_platform().readable_cols - 8
while len(parts) > 1 and cell_len(" · ".join(parts)) > width:
parts.pop()
return " · ".join(parts)
def _detail_items(ctx: AppContext, room: Contact) -> list:
"""The detail page's action rows, for a joined room or one not joined yet."""
joined = ctx.rooms.joined(room)
peer = _peer(room)
stored = bool(
ctx.repo.recent_chat_messages(is_channel=False, peer=peer, posts_only=True, limit=1)
)
lane = icon_lane(("💬", "↻", "🔑", "🗑"))
unread = ctx.chat.unread(f"dm:{peer}")
rows: list = []
if joined:
board = marked_label("💬", "Open the board", "", lane=lane)
if unread:
board.append(" ●", style="err")
board.append(f" {unread}", style="warn")
rows += [
(board, "Read the posts, and post", _OPEN),
(
marked_label("↻", "Log in again", "", lane=lane),
"Ask the room for what you missed",
_RELOGIN,
),
(
marked_label("🔑", "Change password…", "", lane=lane),
"Log in with a different password",
_PASSWORD,
),
(
marked_label("🗑", "Forget this room…", "err", lane=lane),
"Stop logging in, and forget its password",
_FORGET,
),
]
else:
rows.append(
(marked_label("🔑", "Join…", "", lane=lane), "Log in with the room's password", _JOIN)
)
if stored:
rows.append(
(
marked_label("💬", "Read stored posts", "", lane=lane),
"What it sent while you were in",
_OPEN,
)
)
if stored:
rows.append(
(
marked_label("🗑", "Delete stored posts…", "err", lane=lane),
"Remove its posts from this machine",
_DELETE,
)
)
return menu_rows(rows)
async def _forget(ctx: AppContext, room: Contact) -> None:
"""Confirm, then leave a room as far as MeshTerm can (see ``RoomService.forget``)."""
if await ctx.ui.dialog(
f"Forget {room.name}? MeshTerm stops logging in to it and forgets its password. "
"The room may go on sending your radio new posts until it restarts; the posts "
"stored here are kept.",
[("Cancel", False), ("Forget", True)],
title=f"Forget — {room.name}",
default=1,
danger=True,
):
ctx.rooms.forget(room)
async def _delete_posts(ctx: AppContext, room: Contact) -> None:
"""Confirm, then delete the room's stored posts (irreversible: the reserved red)."""
if await ctx.ui.dialog(
f"Delete the posts stored from {room.name}? They are removed from this machine, "
"and the room won't send them again.",
[("Cancel", False), ("Delete", True)],
title="Delete posts",
default=1,
destructive=True,
):
ctx.repo.delete_chat_history(_peer(room))
ctx.chat.clear_unread(f"dm:{_peer(room)}")
+77 -3
View File
@@ -562,14 +562,86 @@ def _room(cols: int, rows: int, *, access=None, retry: bool = False) -> Screen:
names={},
session=_GallerySession(cols, rows),
resolve=lambda h: names.get(h or "", h),
password=lambda: "hello",
ask=never,
log_in=never,
auto_login=never,
join=never,
access=access or RoomAccess.MEMBER,
resend=never,
)
class _RoomsCtx:
"""The minimal AppContext surface the Rooms page and a room's page read."""
def __init__(self) -> None:
from meshterm.core.models import RoomAccess
self._access = {"Lakeside BBS": RoomAccess.MEMBER}
self.rooms = SimpleNamespace(
joined=lambda room: room.name in self._access,
access=lambda room: self._access.get(room.name),
)
self.chat = SimpleNamespace(unread=lambda key: 128 if key.endswith("f6" * 6) else 0)
lasts = {
f"dm:{'f6' * 6}": ChatMessage(
text="x", peer="f6" * 6, author="d4e5f6a7", created_at=utcnow()
)
}
self.repo = SimpleNamespace(
last_chat_messages=lambda rooms=(): lasts,
recent_chat_messages=lambda **kw: [lasts[f"dm:{'f6' * 6}"]],
)
def _gallery_rooms() -> list[Contact]:
"""A joined room with a long name and a busy board, and two heard but not joined."""
from datetime import timedelta
now = utcnow()
return [
Contact(
name="Lakeside BBS and Swap Meet",
public_key="f6" * 32,
key_prefix="f6" * 6,
node_type=3,
last_seen=now,
route_hops=("a1", "b2", "c3", "d4", "e5"),
),
Contact(
name="Hilltop Swap",
public_key="b7" * 32,
key_prefix="b7" * 6,
node_type=3,
last_seen=now - timedelta(days=6),
),
Contact(name="Old Room", public_key="c8" * 32, key_prefix="c8" * 6, node_type=3),
]
def _rooms_page(cols: int, rows: int) -> Screen:
"""The Rooms page at its widest: a long name, a three-digit badge, and a room never heard."""
from meshterm.ui.rooms import _PAGE_HINT, _page_items
ctx = _RoomsCtx()
ctx._access = {"Lakeside BBS and Swap Meet": ctx._access["Lakeside BBS"]}
title, items = _page_items(ctx, _gallery_rooms())
return SelectScreen(title, items, footer_hint=_PAGE_HINT)
def _room_page(cols: int, rows: int) -> Screen:
"""A joined room's page: its vital signs over its actions, the badge on Open the board."""
from meshterm.ui.rooms import _detail_items, _summary
ctx = _RoomsCtx()
room = _gallery_rooms()[0]
ctx._access = {room.name: ctx._access["Lakeside BBS"]}
return SelectScreen(
f"Room — {room.name}",
_detail_items(ctx, room),
prompt=_summary(ctx, room),
footer_hint="↑↓ move · Enter select · Esc back",
)
def _room_read_only(cols: int, rows: int) -> Screen:
"""A read-only member: the compose line gives way to the line saying why."""
from meshterm.core.models import RoomAccess
@@ -1571,6 +1643,8 @@ _ENTRIES: list[_Entry] = [
_Entry("room", _room),
_Entry("room_read_only", _room_read_only),
_Entry("room_retry", _room_retry),
_Entry("rooms_page", _rooms_page),
_Entry("room_page", _room_page),
_Entry("channels_manager", _channels_manager),
_Entry("channel_detail", _channel_detail),
_Entry("channel_detail_scoped", _channel_detail_scoped),
+1 -1
View File
@@ -198,7 +198,7 @@ def test_main_menu_sections_answer_the_menus_own_question() -> None:
assert list(sections) == _CATEGORY_ORDER # all_tools already sorts them this way
assert sections == {
"Message": ["chat", "channels", "courier", "contacts"],
"Message": ["chat", "channels", "rooms", "courier", "contacts"],
"Watch": ["dashboard", "livefeed", "watchtower", "timemachine"],
"Explore": ["map", "walk", "trace", "trace-path", "records"],
"This node": ["info", "config", "advert"],
+470 -124
View File
@@ -326,6 +326,23 @@ def test_the_admin_password_is_tried_before_the_room_password(tmp_path: Path) ->
assert ctx.rooms.password(ROOM) == "admin"
def test_an_admin_password_alone_is_not_a_join(tmp_path: Path) -> None:
"""Repeater admin's password lets a join go through unasked; joining is still a login."""
ctx = _Ctx(tmp_path, MockDevice())
ctx.admin_store.remember(ROOM, "admin")
assert ctx.rooms.password(ROOM) == "admin"
assert not ctx.rooms.joined(ROOM) and not ctx.rooms.login_due(ROOM)
async def test_forgetting_a_room_leaves_it(tmp_path: Path) -> None:
"""The membership and its password go; nothing is transmitted."""
ctx = _Ctx(tmp_path, MockDevice())
await ctx.rooms.login(ROOM, "hello")
ctx.rooms.forget(ROOM)
assert not ctx.rooms.joined(ROOM) and ctx.rooms.password(ROOM) is None
assert not ctx.rooms.heard_recently(ROOM)
def test_read_only_never_replaces_the_room_password(rooms: RoomStore) -> None:
"""A room that lets readers in lets any password in: that proves nothing about one."""
rooms.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
@@ -537,11 +554,33 @@ async def _record(chat: ChatService, ctx, message: Message) -> None: # noqa: AN
await chat._queue.join()
def _join(ctx) -> None: # noqa: ANN001
"""Record a membership, as an accepted login does."""
ctx.room_store.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER))
async def test_a_room_not_joined_records_its_posts_but_raises_no_badge(
tmp_path: Path, repo: Repository
) -> None:
"""Chat has no row for a room not joined, so the badge would point at nothing."""
ctx = _GatedCtx(tmp_path, MockDevice(), repo)
chat = ChatService(ctx)
await chat.start()
try:
await _record(chat, ctx, _post("still sending after we forgot it"))
assert chat.unread(f"dm:{ROOM.key_prefix}") == 0
board = repo.recent_chat_messages(is_channel=False, peer=ROOM.key_prefix, posts_only=True)
assert [m.text for m in board] == ["still sending after we forgot it"]
finally:
await chat.stop()
async def test_a_post_is_recorded_once_and_raises_the_badge_once(
tmp_path: Path, repo: Repository
) -> None:
"""A re-sent post lands twice on the wire and once in history."""
ctx = _GatedCtx(tmp_path, MockDevice(), repo)
_join(ctx)
chat = ChatService(ctx)
await chat.start()
try:
@@ -640,22 +679,22 @@ def _view(
messages: list[ChatMessage] | None = None,
*,
access: RoomAccess | None = RoomAccess.MEMBER,
password: str | None = "hello",
ask_answer: str | None = "hello",
joined: bool = True,
login: RoomLogin | None = None,
) -> tuple[RoomScreen, dict]:
"""A room view over stubbed send/login hooks; ``calls`` records what they were asked."""
calls: dict = {"asked": 0, "logins": [], "sent": []}
"""A board over stubbed hooks; ``calls`` records what they were asked."""
calls: dict = {"auto": 0, "joins": [], "sent": []}
outcome = login if login is not None else RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER)
async def ask() -> str | None:
calls["asked"] += 1
return ask_answer
async def log_in(pw: str) -> RoomLogin:
calls["logins"].append(pw)
async def auto_login() -> RoomLogin | None:
calls["auto"] += 1
await asyncio.sleep(0) # an exchange on the air: the board goes on drawing meanwhile
# Not ``login or …``: a login that failed is falsy, which is the point of it.
return login if login is not None else RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER)
return outcome
async def join(ask: bool) -> RoomLogin | None:
calls["joins"].append(ask)
await asyncio.sleep(0)
return outcome
async def send(text: str) -> ChatMessage:
calls["sent"].append(text)
@@ -668,14 +707,19 @@ def _view(
names={},
session=_Session(),
resolve=_resolve,
password=lambda: password,
ask=ask,
log_in=log_in,
auto_login=auto_login,
join=join,
joined=joined,
access=access,
)
return screen, calls
async def _settled(screen: RoomScreen) -> None:
while screen._login_open:
await asyncio.sleep(0)
def _board() -> list[ChatMessage]:
return [
ChatMessage.from_message(_post("Anyone driving Saturday?", minutes=-30)),
@@ -685,6 +729,10 @@ def _board() -> list[ChatMessage]:
]
def _compose(screen: RoomScreen) -> str:
return plain("\n".join(screen.render_body(72))).splitlines()[-1]
def test_an_author_is_named_by_key_or_stands_as_a_grey_hash() -> None:
"""A key something names is that name, in its hue; one nothing names stays a hash."""
assert author_label(ALICE_KEY, _resolve) == ("Alice", ALICE_KEY)
@@ -710,20 +758,6 @@ def test_a_post_that_arrives_twice_is_shown_once_and_a_reply_not_at_all() -> Non
assert screen._messages[-1].text == "New one"
async def test_a_read_only_member_gets_a_line_instead_of_a_compose_box() -> None:
"""Typing that could go nowhere is not taken, and the line says why."""
screen, calls = _view(access=RoomAccess.READ_ONLY)
for ch in "hi":
screen.handle("text", ch)
screen.handle("enter")
await asyncio.sleep(0)
assert calls["sent"] == []
body = plain("\n".join(screen.render_body(72)))
assert "read-only" in body
assert "Enter send" not in screen.footer_hint
assert screen.title.endswith("· read-only")
async def test_a_member_posts_from_the_compose_line() -> None:
"""Enter posts, through the same path a direct message takes."""
screen, calls = _view()
@@ -735,6 +769,63 @@ async def test_a_member_posts_from_the_compose_line() -> None:
assert calls["sent"] == ["hi"]
async def test_the_compose_line_waits_for_the_quiet_login() -> None:
"""Opening a quiet room logs in in the background; posting opens once it lets us in."""
screen, calls = _view(access=RoomAccess.MEMBER)
screen.begin_auto_login()
assert screen.title == f"{ROOM.name} · {LOGGING_IN}"
assert "posting opens once" in _compose(screen)
screen.handle("text", "x")
assert screen._editor.text == "", "nothing typed while the room may not know us"
await _settled(screen)
assert calls["auto"] == 1 and screen.title == f"{ROOM.name} · member"
assert screen._composing
async def test_after_silence_nothing_can_be_posted_and_the_line_says_why() -> None:
"""THE reported bug: a login met silence, and the compose line went on taking posts."""
screen, calls = _view(login=RoomLogin(LoginResult.NO_REPLY, flood=True))
screen.begin_auto_login()
await _settled(screen)
assert screen.title.endswith(NO_REPLY)
assert "no answer" in _compose(screen) and "^L" in _compose(screen)
for ch in "hi":
screen.handle("text", ch)
screen.handle("enter")
await asyncio.sleep(0)
assert calls["sent"] == []
assert "Enter send" not in screen.footer_hint
async def test_control_l_runs_the_explained_login() -> None:
"""^L hands the login to the Rooms flow, its dialogs over the board, and shows the end."""
screen, calls = _view(access=None, login=RoomLogin(LoginResult.ACCEPTED, RoomAccess.ADMIN))
screen.handle("login")
assert screen._login_open
assert "^L log in" not in screen.footer_hint # a second press would do nothing
await _settled(screen)
assert calls["joins"] == [False]
assert screen.title == f"{ROOM.name} · admin" and screen._composing
async def test_a_read_only_member_is_asked_for_another_password_and_offered_no_retry() -> None:
"""^L asks (the room password is what lets a reader post); ^R would only be dropped."""
unacked = ChatMessage(text="lost", outbound=True, peer=ROOM.key_prefix, acked=False)
screen, calls = _view([unacked], access=RoomAccess.READ_ONLY)
assert "read-only" in _compose(screen)
assert screen._retry_target() is None and "^R" not in screen.footer_hint
screen.handle("login")
await _settled(screen)
assert calls["joins"] == [True]
def test_a_board_not_joined_takes_no_posts() -> None:
"""Its stored posts read from the Rooms page; posting starts with joining."""
screen, _ = _view(_board(), access=None, joined=False)
assert screen.title.endswith(NOT_JOINED)
assert "not joined" in _compose(screen) and not screen._composing
async def test_enter_on_a_picked_post_replies_to_its_author() -> None:
"""A board has many voices, so Enter on a post primes an @mention, as a channel does."""
screen, _ = _view(_board())
@@ -744,43 +835,23 @@ async def test_enter_on_a_picked_post_replies_to_its_author() -> None:
assert screen._editor.text == f"@[{STRANGER_KEY}] "
async def test_control_l_logs_in_with_the_remembered_password() -> None:
"""^L logs in without asking, and the title says so while it does."""
screen, calls = _view(access=None)
screen.handle("login")
assert screen._login_open
assert "^L log in" not in screen.footer_hint # a second press would do nothing
titles = []
while screen._login_open:
titles.append(screen.title)
await asyncio.sleep(0)
assert f"{ROOM.name} · {LOGGING_IN}" in titles
assert calls == {"asked": 0, "logins": ["hello"], "sent": []}
assert screen.title == f"{ROOM.name} · member"
async def test_a_read_only_member_picks_a_post_to_see_its_paths_not_to_reply() -> None:
"""With no compose line, Enter on a post has nothing to prime, and a paste nowhere to go."""
screen, _ = _view(_board(), access=RoomAccess.READ_ONLY)
opened: list[ChatMessage] = []
async def paths(message: ChatMessage) -> None:
opened.append(message)
async def test_after_silence_control_l_asks_for_the_password() -> None:
"""A room is as silent for a wrong password as for a missing room — so ask next time."""
screen, calls = _view(login=RoomLogin(LoginResult.NO_REPLY))
screen.begin_login()
while screen._login_open:
await asyncio.sleep(0)
assert screen.title.endswith(NO_REPLY)
assert "^L" in screen._status
screen.handle("login")
while screen._login_open:
await asyncio.sleep(0)
assert calls["asked"] == 1
async def test_declining_to_join_leaves_the_board_open_and_says_so() -> None:
"""Esc on the join prompt keeps the board to read, titled for what it is."""
screen, calls = _view(access=None, password=None, ask_answer=None)
screen.begin_login(ask=True)
while screen._login_open:
await asyncio.sleep(0)
assert calls["logins"] == []
assert screen.title.endswith(NOT_JOINED)
screen._paths = paths
screen.handle("paste", "pasted text")
assert not screen._paste_open and screen._editor.text == ""
screen.handle("up")
assert screen.footer_hint.startswith("Enter paths")
screen.handle("enter")
await asyncio.sleep(0)
assert screen._editor.text == ""
assert [m.text for m in opened] == ["I'll check it"]
def test_the_hint_names_log_in_and_fits() -> None:
@@ -795,24 +866,295 @@ def test_the_hint_names_log_in_and_fits() -> None:
def test_the_handheld_lane_puts_log_in_behind_f1() -> None:
"""Every chord earns a chip where the keyboard has no Ctrl to reach it by."""
screen, _ = _view(_board())
deck = fkeys.PICOCALC_LYRA_DECK
lane = screen.picocalc_lyra_lane
assert lane[0].opp_label == "Log in"
assert deck.action_for(lane, 6) == "login"
assert fkeys.PICOCALC_LYRA_DECK.action_for(lane, 6) == "login"
# --- joining: the explained flow ------------------------------------------------------
class _ScriptedUi:
"""A UI surface answering the join flow's prompt and dialogs from a script.
``choices`` are button *labels*, pressed in order; every dialog shown is kept as
``(text, labels, default label)`` for the test to read.
"""
def __init__(self, *, passwords=(), choices=()) -> None: # noqa: ANN001
self.passwords = list(passwords)
self.choices = list(choices)
self.asked: list[str] = []
self.defaults: list[str] = []
self.dialogs: list[tuple[str, list[str], str]] = []
async def text(self, title: str, **kwargs): # noqa: ANN003, ANN201
self.asked.append(title)
self.defaults.append(kwargs.get("default", ""))
assert kwargs["validate"]("x" * 16) is not True, "a password the radio would cut"
return self.passwords.pop(0) if self.passwords else None
async def dialog(self, prompt, buttons, **kwargs): # noqa: ANN001, ANN003, ANN201
labels = [label for label, _ in buttons]
default = labels[kwargs.get("default", 0)]
self.dialogs.append((getattr(prompt, "plain", prompt), labels, default))
pressed = self.choices.pop(0) if self.choices else labels[0]
return dict(buttons)[pressed]
def busy_dialog(self, message: str = "", *, title: str = ""): # noqa: ANN201
from contextlib import asynccontextmanager
@asynccontextmanager
async def card(): # noqa: ANN202
yield SimpleNamespace(message=message)
return card()
class _JoinCtx(_Ctx):
"""A context with a scripted UI and a device-state stub the flow invalidates."""
def __init__(self, tmp_path: Path, device: MockDevice, ui: _ScriptedUi) -> None:
super().__init__(tmp_path, device)
self.ui = ui
self.devstate = SimpleNamespace(invalidate_contacts=lambda: None)
async def test_joining_with_the_right_password_says_nothing_more(tmp_path: Path) -> None:
"""A login that lets us post ends the flow quietly; the page shows the access."""
from meshterm.ui.rooms import join_room
ui = _ScriptedUi(passwords=["hello"])
ctx = _JoinCtx(tmp_path, MockDevice(), ui)
login = await join_room(ctx, ROOM)
assert login.access is RoomAccess.MEMBER and ctx.rooms.joined(ROOM)
assert ui.asked == [f"Join — {ROOM.name}"] and ui.dialogs == []
async def test_a_stale_route_is_explained_and_a_flood_gets_through(tmp_path: Path) -> None:
"""THE case on hardware: a learned route silently dead, the same login by flood fine."""
from meshterm.ui.rooms import join_room
device = MockDevice()
device._stale_routes.add(ROOM.name)
ui = _ScriptedUi(passwords=["hello"], choices=["Try by flood"])
ctx = _JoinCtx(tmp_path, device, ui)
login = await join_room(ctx, ROOM)
assert login.access is RoomAccess.MEMBER and login.flood is True
text, labels, default = ui.dialogs[0]
assert text.startswith(f"{ROOM.name} didn't answer.")
assert "route your radio learned to the room (1 hop)" in text
assert labels == ["Cancel", "Try by flood"] and default == "Try by flood"
assert len(ui.asked) == 1, "the flood carries the same password"
async def test_silence_by_flood_names_both_causes_and_offers_another_password(
tmp_path: Path,
) -> None:
"""A password that never worked here could be the problem; the dialog says so."""
from meshterm.ui.rooms import join_room
device = MockDevice()
await device.reset_route(ROOM) # no route: every login floods
ui = _ScriptedUi(passwords=["nope", "hello"], choices=["Try again…"])
ctx = _JoinCtx(tmp_path, device, ui)
login = await join_room(ctx, ROOM)
assert login.access is RoomAccess.MEMBER and len(ui.asked) == 2
text, labels, default = ui.dialogs[0]
assert "went out to the whole mesh" in text and "the password is wrong" in text
assert labels == ["Cancel", "Try again…"] and default == "Try again…"
assert ui.defaults == ["", "nope"], "the second prompt holds the password just tried"
async def test_silence_with_a_password_that_worked_points_at_reach(tmp_path: Path) -> None:
"""A remembered password is still right; the room most likely didn't hear us."""
from meshterm.ui.rooms import join_room
device = MockDevice()
await device.reset_route(ROOM)
ui = _ScriptedUi(choices=["Cancel"])
ctx = _JoinCtx(tmp_path, device, ui)
await ctx.rooms.login(ROOM, "hello")
await device.reset_route(ROOM) # the answer taught it a route; flood the next one too
device._unreachable.add(ROOM.name)
login = await join_room(ctx, ROOM)
assert login.result is LoginResult.NO_REPLY and ui.asked == []
text, labels, _ = ui.dialogs[0]
assert "got you in before" in text and labels == ["Cancel", "Try again"]
assert ctx.rooms.password(ROOM) == "hello", "silence keeps the password"
async def test_read_only_is_explained_and_another_password_offered(tmp_path: Path) -> None:
"""In, but not as a member: the dialog says what that means and why it may be."""
from meshterm.ui.rooms import join_room
device = MockDevice()
device._remote_config(ROOM)["allow.read.only"] = "on"
ui = _ScriptedUi(passwords=["guess"], choices=["Keep read-only"])
ctx = _JoinCtx(tmp_path, device, ui)
login = await join_room(ctx, ROOM)
assert login.access is RoomAccess.READ_ONLY
text, labels, default = ui.dialogs[0]
assert "read-only" in text and labels == ["Keep read-only", "Try again…"]
async def test_a_room_the_radio_forgot_is_put_back_and_tried_again(tmp_path: Path) -> None:
"""Not sent at all is its own case: the radio said why, and the fix is one write."""
from meshterm.ui.rooms import join_room
device = MockDevice()
device._contacts = [c for c in device._contacts if not c.is_room]
ui = _ScriptedUi(passwords=["hello"], choices=["Add it & try again"])
ctx = _JoinCtx(tmp_path, device, ui)
login = await join_room(ctx, ROOM)
assert login.access is RoomAccess.MEMBER
text, labels, _ = ui.dialogs[0]
assert text == "Your radio didn't send the login: this node isn't in the radio's contacts."
assert labels == ["Cancel", "Add it & try again"]
async def test_backing_out_of_the_prompt_sends_nothing(tmp_path: Path) -> None:
"""Esc on the password prompt is the end of it; no login goes out."""
from meshterm.ui.rooms import join_room
device = MockDevice()
ui = _ScriptedUi()
ctx = _JoinCtx(tmp_path, device, ui)
assert await join_room(ctx, ROOM) is None
assert not device._room_access
def test_a_password_the_radio_would_cut_is_refused() -> None:
"""MeshCore sends 15 bytes of a password; a longer one could never work."""
from meshterm.ui.rooms import valid_password
assert valid_password("x" * 15) is True
assert valid_password("x" * 16) is not True
assert valid_password("é" * 8) is not True, "bytes, not characters"
def test_how_long_since_the_room_was_heard_is_part_of_the_explanation(tmp_path: Path) -> None:
"""Never heard, heard just now: the evidence a reader judges reach by."""
from dataclasses import replace
from meshterm.ui.rooms import silence_explanation
ctx = _Ctx(tmp_path, MockDevice())
silent = RoomLogin(LoginResult.NO_REPLY, flood=True)
never = silence_explanation(ctx, replace(ROOM, last_seen=None), silent, "x")
assert "The room has never been heard here." in never
now = datetime.now(timezone.utc)
fresh = silence_explanation(ctx, replace(ROOM, last_seen=now), silent, "x")
assert "The room was heard just now." in fresh
# --- the radio's confirmation ---------------------------------------------------------
class _ConfirmingMeshCore:
"""A meshcore stand-in whose login sends confirmations and answers as scripted."""
def __init__(self, frames: list[tuple[str, dict]]) -> None:
self.frames = frames
self.subs: list = []
self.commands = self
def subscribe(self, event_type, callback, attribute_filters=None): # noqa: ANN001, ANN201
sub = SimpleNamespace(event_type=event_type, callback=callback)
sub.unsubscribe = lambda: self.subs.remove(sub) if sub in self.subs else None
self.subs.append(sub)
return sub
async def send_login_sync(self, pub, password): # noqa: ANN001, ANN201
from meshcore import EventType
for name, payload in self.frames:
event = SimpleNamespace(type=getattr(EventType, name), payload=payload)
for sub in list(self.subs):
if sub.event_type is event.type:
sub.callback(event)
return None
def _confirmed_login(frames: list[tuple[str, dict]]) -> RoomLogin:
device = MeshCoreDevice(port="mock")
device._mc = _ConfirmingMeshCore(frames)
return asyncio.run(device.room_login(ROOM, "hello"))
def test_the_radios_confirmation_says_how_the_login_went_out(quick_budget) -> None: # noqa: ANN001, F811
"""Matched by the login's expected ack — the room's first four key bytes."""
ours = bytes.fromhex(ROOM.public_key[:8])
flood = _confirmed_login(
[("MSG_SENT", {"type": 1, "expected_ack": ours}), ("LOGIN_SUCCESS", {"acl_permissions": 2})]
)
assert flood.access is RoomAccess.MEMBER and flood.flood is True
direct = _confirmed_login([("MSG_SENT", {"type": 0, "expected_ack": ours})])
assert direct.result is LoginResult.NO_REPLY and direct.flood is False
def test_another_commands_confirmation_is_not_ours(quick_budget) -> None: # noqa: ANN001, F811
"""A MSG_SENT for anything else in flight says nothing about this login."""
other = _confirmed_login([("MSG_SENT", {"type": 1, "expected_ack": b"\x00\x01\x02\x03"})])
assert other.flood is None and other.radio_error is None
def test_a_login_the_radio_would_not_send_carries_its_reason(quick_budget) -> None: # noqa: ANN001, F811
"""No confirmation, and the radio's own error: the room isn't in its contacts."""
refused = _confirmed_login([("ERROR", {"error_code": 2})])
assert refused.flood is None
assert refused.radio_error == "this node isn't in the radio's contacts"
async def test_the_simulator_floods_once_its_route_is_forgotten(tmp_path: Path) -> None:
"""A stale route meets silence; forgetting it floods, and the answer brings a new one."""
device = MockDevice()
device._stale_routes.add(ROOM.name)
stale = await device.room_login(ROOM, "hello")
assert stale.result is LoginResult.NO_REPLY and stale.flood is False
await device.reset_route(ROOM)
flooded = await device.room_login(ROOM, "hello")
assert flooded.access is RoomAccess.MEMBER and flooded.flood is True
again = await device.room_login(ROOM, "")
assert again.flood is False and again.access is RoomAccess.MEMBER
# --- the Rooms page -------------------------------------------------------------------
def test_the_page_lists_joined_rooms_first(tmp_path: Path, repo: Repository) -> None:
"""Joined, then the rest heard most recently first, each with its lanes."""
from dataclasses import replace
from meshterm.ui.rooms import _page_items
ctx = _Ctx(tmp_path, MockDevice(), repo)
ctx.chat = SimpleNamespace(unread=lambda key: 0)
now = datetime.now(timezone.utc)
joined = replace(ROOM, last_seen=now)
hilltop = Contact(
name="Hilltop Swap", public_key="b7" * 32, key_prefix="b7" * 6, node_type=NODE_TYPE_ROOM
)
_join(ctx)
title, items = _page_items(ctx, [hilltop, joined])
assert title == "Rooms · 1 joined"
rows = [i.title().plain for i in items if hasattr(i, "value")]
assert rows[0].startswith("■ Lakeside BBS") and "member" in rows[0] and "now" in rows[0]
assert rows[1].startswith("■ Hilltop Swap") and "never" in rows[1]
# --- end to end, through the real session ---------------------------------------------
async def test_joining_a_room_end_to_end(tmp_path: Path) -> None:
"""Open an unjoined room, answer the join prompt, read the catch-up, post, leave."""
async def test_joining_from_the_rooms_page_end_to_end(tmp_path: Path) -> None:
"""Rooms, the room's page, Join…, the password, Open the board: read, post, leave."""
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output import DummyOutput
from rich.console import Console
from meshterm.context import AppContext
from meshterm.core.device_store import DeviceStore
from meshterm.ui.chat import open_chat
from meshterm.ui.rooms import manage_rooms
from meshterm.ui.surface import TuiUi
from meshterm.ui.tui.session import TuiSession
@@ -825,44 +1167,59 @@ async def test_joining_a_room_end_to_end(tmp_path: Path) -> None:
admin_store=AdminStore(tmp_path / "admin.json"),
mock=True,
)
conv = Conversation(label=ROOM.name, is_channel=False, contact=ROOM)
def board() -> list[ChatMessage]:
return ctx.repo.recent_chat_messages(
is_channel=False, peer=ROOM.key_prefix, posts_only=True
)
def top() -> str:
return getattr(session._stack[-1], "title", "") if session._stack else ""
async def until(check, what: str) -> None: # noqa: ANN001
for _ in range(500):
if check():
return
await asyncio.sleep(0.01)
raise AssertionError(f"never reached: {what} (top is {top()!r})")
try:
with create_pipe_input() as inp:
session = TuiSession(input=inp, output=DummyOutput())
ctx.ui = TuiUi(session)
async def drive() -> None:
while not session._stack or len(session._stack) < 2:
await asyncio.sleep(0.01) # the board, and the join prompt over it
await until(lambda: top().startswith("Rooms"), "the Rooms page")
inp.send_text("\r") # the one room, under Not joined
await until(lambda: top() == f"Room — {ROOM.name}", "the room's page")
inp.send_text("\r") # Join…
await until(lambda: top() == f"Join — {ROOM.name}", "the password prompt")
inp.send_text("hello\r")
while len([m for m in board() if m.is_post]) < 5:
await asyncio.sleep(0.01)
await until(lambda: ctx.rooms.joined(ROOM), "the join")
await until(lambda: top() == f"Room — {ROOM.name}", "back on the room's page")
inp.send_text("\r") # Open the board
await until(lambda: top().startswith(f"{ROOM.name} ·"), "the board")
await until(lambda: len([m for m in board() if m.is_post]) >= 5, "the catch-up")
inp.send_text("count me in\r")
while not [m for m in board() if m.outbound]:
await asyncio.sleep(0.01)
await until(lambda: [m for m in board() if m.outbound], "the post")
inp.send_text("\x1b") # board, back to the room's page
await until(lambda: top() == f"Room — {ROOM.name}", "the room's page again")
inp.send_text("\x1b") # the room's page, back to Rooms
await until(lambda: top().startswith("Rooms"), "the Rooms page again")
inp.send_text("\x1b")
async def main() -> None:
driver = asyncio.ensure_future(drive())
try:
await open_chat(ctx, conv)
await manage_rooms(ctx)
finally:
driver.cancel()
await asyncio.wait_for(session.run(main()), timeout=10)
await asyncio.wait_for(session.run(main()), timeout=20)
posts = board()
assert len([m for m in posts if m.is_post]) == 5
mine = [m for m in posts if m.outbound]
mine = [m for m in board() if m.outbound]
assert [(m.text, m.acked) for m in mine] == [("count me in", True)]
assert ctx.room_store.password(ROOM) == "hello"
assert ctx.rooms.access(ROOM) is RoomAccess.MEMBER
finally:
await ctx.aclose()
@@ -898,25 +1255,47 @@ def test_chat_list_names_a_room_as_one(cli) -> None: # noqa: ANN001
assert kinds["Lakeside BBS"] == "room" and kinds["Alice"] == "direct"
def test_chat_join_answers_with_the_access_won(cli) -> None: # noqa: ANN001
"""The access word alone on the plain face; the room and the access in the document."""
def test_rooms_join_answers_with_the_access_won(cli) -> None: # noqa: ANN001
"""The access word alone on the plain face; the room, the access and the route in JSON."""
import json
from meshterm.core import exitcodes
unknown = cli("chat", "join", "Lakeside BBS")
unknown = cli("rooms", "join", "Lakeside BBS")
assert unknown.exit_code == exitcodes.USAGE, "nothing remembered, nothing sent"
assert cli("chat", "join", "Alice", "--password", "x").exit_code == exitcodes.USAGE
assert cli("rooms", "join", "Alice", "--password", "x").exit_code == exitcodes.USAGE
long = cli("rooms", "join", "Lakeside BBS", "--password", "x" * 16)
assert long.exit_code == exitcodes.USAGE, "a password the radio would cut"
silent = cli("chat", "join", "Lakeside BBS", "--password", "nope")
silent = cli("rooms", "join", "Lakeside BBS", "--password", "nope")
assert silent.exit_code == exitcodes.DEVICE
assert silent.stdout == "" and "wrong" in silent.stderr
assert silent.stdout == "" and "didn't answer" in silent.stderr
joined = cli("chat", "join", "Lakeside BBS", "--password", "hello")
joined = cli("rooms", "join", "Lakeside BBS", "--password", "hello")
assert joined.exit_code == exitcodes.OK and joined.stdout == "member\n"
again = json.loads(cli("--json", "chat", "join", "Lakeside BBS").stdout) # remembered
assert again["access"] == "member"
again = json.loads(cli("--json", "rooms", "join", "Lakeside BBS").stdout) # remembered
assert again["access"] == "member" and again["route"] == "direct"
assert again["room"]["name"] == "Lakeside BBS" and again["room"]["type"] == "room server"
flooded = json.loads(cli("--json", "rooms", "join", "Lakeside BBS", "--flood").stdout)
assert flooded["route"] == "flood"
def test_rooms_list_and_forget(cli) -> None: # noqa: ANN001
"""Every room the radio knows, joined or not; forgetting one is local and silent."""
import json
from meshterm.core import exitcodes
header, *rows = cli("rooms", "list").stdout.splitlines()
assert header.split() == ["ROOM", "ACCESS", "HEARD", "LAST_POST", "UNREAD"]
assert rows[0].startswith("Lakeside BBS")
cli("rooms", "join", "Lakeside BBS", "--password", "hello")
docs = json.loads(cli("--json", "rooms", "list").stdout)
assert docs[0]["joined"] is True and docs[0]["access"] == "member"
forgot = cli("rooms", "forget", "Lakeside BBS")
assert forgot.exit_code == exitcodes.OK and forgot.stdout == ""
assert cli("rooms", "forget", "Lakeside BBS").exit_code == exitcodes.NO_RESULT
assert json.loads(cli("--json", "rooms", "list").stdout)[0]["joined"] is False
def test_chat_history_reads_a_room_by_author(cli) -> None: # noqa: ANN001
@@ -946,8 +1325,6 @@ def test_chat_history_reads_a_room_by_author(cli) -> None: # noqa: ANN001
"type": None,
"self": False,
}
direct = json.loads(cli("--json", "chat", "list").stdout)
assert direct, "the list still answers"
def test_chat_send_to_a_room_is_a_post(cli) -> None: # noqa: ANN001
@@ -957,34 +1334,3 @@ def test_chat_send_to_a_room_is_a_post(cli) -> None: # noqa: ANN001
receipt = json.loads(cli("--json", "chat", "send", "--to", "Lakeside BBS", "hi").stdout)
assert receipt["kind"] == "room"
assert receipt["acked"] is False, "this simulator never joined, so the room drops it"
async def test_a_read_only_member_is_asked_for_a_password_and_offered_no_retry() -> None:
"""^L asks (the room password is what lets a reader post); ^R would only be dropped."""
unacked = ChatMessage(text="lost", outbound=True, peer=ROOM.key_prefix, acked=False)
screen, calls = _view([unacked], access=RoomAccess.READ_ONLY)
assert screen._retry_target() is None
assert "^R" not in screen.footer_hint
screen.handle("login")
while screen._login_open:
await asyncio.sleep(0)
assert calls["asked"] == 1
async def test_a_read_only_member_picks_a_post_to_see_its_paths_not_to_reply() -> None:
"""With no compose line, Enter on a post has nothing to prime, and a paste nowhere to go."""
screen, _ = _view(_board(), access=RoomAccess.READ_ONLY)
opened: list[ChatMessage] = []
async def paths(message: ChatMessage) -> None:
opened.append(message)
screen._paths = paths
screen.handle("paste", "pasted text")
assert not screen._paste_open and screen._editor.text == ""
screen.handle("up")
assert screen.footer_hint.startswith("Enter paths")
screen.handle("enter")
await asyncio.sleep(0)
assert screen._editor.text == ""
assert [m.text for m in opened] == ["I'll check it"]