diff --git a/docs/cli/README.md b/docs/cli/README.md index 794be79..a4755fe 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -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` diff --git a/docs/guide/features.md b/docs/guide/features.md index a09faa7..e269f32 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -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` | diff --git a/meshterm/core/connection.py b/meshterm/core/connection.py index 9ff6866..0966732 100644 --- a/meshterm/core/connection.py +++ b/meshterm/core/connection.py @@ -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. diff --git a/meshterm/core/models.py b/meshterm/core/models.py index c64e55c..3ea636b 100644 --- a/meshterm/core/models.py +++ b/meshterm/core/models.py @@ -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.""" diff --git a/meshterm/services/chat_service.py b/meshterm/services/chat_service.py index c0e295f..a4d42e6 100644 --- a/meshterm/services/chat_service.py +++ b/meshterm/services/chat_service.py @@ -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 diff --git a/meshterm/services/rooms.py b/meshterm/services/rooms.py index ff4ef23..fa5c9bb 100644 --- a/meshterm/services/rooms.py +++ b/meshterm/services/rooms.py @@ -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) diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index 498d623..1f74d01 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -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, diff --git a/meshterm/tools/rooms.py b/meshterm/tools/rooms.py new file mode 100644 index 0000000..185ab38 --- /dev/null +++ b/meshterm/tools/rooms.py @@ -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, + ) diff --git a/meshterm/ui/room.py b/meshterm/ui/room.py index b7dacaf..7a5bd2a 100644 --- a/meshterm/ui/room.py +++ b/meshterm/ui/room.py @@ -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() diff --git a/meshterm/ui/rooms.py b/meshterm/ui/rooms.py new file mode 100644 index 0000000..b721d93 --- /dev/null +++ b/meshterm/ui/rooms.py @@ -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)}") diff --git a/tests/test_gallery.py b/tests/test_gallery.py index c33e6dc..b62db46 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -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), diff --git a/tests/test_menus.py b/tests/test_menus.py index c69670f..1e32c0a 100644 --- a/tests/test_menus.py +++ b/tests/test_menus.py @@ -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"], diff --git a/tests/test_rooms.py b/tests/test_rooms.py index 2abdc3f..d39c616 100644 --- a/tests/test_rooms.py +++ b/tests/test_rooms.py @@ -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"]