diff --git a/meshterm/context.py b/meshterm/context.py index 8aa730f..4badcac 100644 --- a/meshterm/context.py +++ b/meshterm/context.py @@ -28,6 +28,7 @@ from .core.preferences import PREFERENCES_FILENAME, Preferences, report_dropped from .core.preferences import install as install_preferences from .core.region_store import RegionStore from .core.remote_store import RemoteStore +from .core.room_store import RoomStore from .core.selection import resolve_device from .core.settings_store import SettingsStore from .core.watch_store import WatchStore @@ -45,6 +46,7 @@ if TYPE_CHECKING: from .services.device_state import DeviceState from .services.event_hub import EventHub from .services.monitor_service import MonitorService + from .services.rooms import RoomService from .services.watchtower import WatchtowerService from .ui.surface import Ui @@ -90,6 +92,9 @@ class AppContext: contact_store: Store for contacts read through MeshTerm, merged back into the contact lists for a device that forgot them (a firmware-less radio bridge). Keyed by device public key; defaults to ``/contacts.json`` when not injected. + room_store: Store for the rooms this machine has joined — each room's password and + the access it granted (defaults to ``/rooms.json`` when not + injected). mock: Whether the simulator device is in use. port_override: Explicit serial port (from ``--port`` or the interactive picker), overriding the profile. @@ -130,6 +135,7 @@ class AppContext: channel_store: ChannelStore | None = None settings_store: SettingsStore | None = None contact_store: ContactStore | None = None + room_store: RoomStore | None = None profile: DeviceProfile | None = None mock: bool = False port_override: str | None = None @@ -165,6 +171,7 @@ class AppContext: _events: EventHub | None = field(default=None, init=False, repr=False) _monitor: MonitorService | None = field(default=None, init=False, repr=False) _chat: ChatService | None = field(default=None, init=False, repr=False) + _rooms: RoomService | None = field(default=None, init=False, repr=False) _adverts: AdvertScheduler | None = field(default=None, init=False, repr=False) _watchtower: WatchtowerService | None = field(default=None, init=False, repr=False) _clock_sync: ClockSync | None = field(default=None, init=False, repr=False) @@ -203,6 +210,8 @@ class AppContext: self.settings_store = SettingsStore(self.settings.config_dir / "settings.json") if self.contact_store is None: self.contact_store = ContactStore(self.settings.config_dir / "contacts.json") + if self.room_store is None: + self.room_store = RoomStore(self.settings.config_dir / "rooms.json") @property def profile_name(self) -> str | None: @@ -376,6 +385,20 @@ class AppContext: self._chat = ChatService(self) return self._chat + @property + def rooms(self) -> RoomService: + """Return the session's room service, creating it on first use. + + The service joins room servers and remembers, for the session, what each one let us + do and when it was last heard — which is what decides whether opening a room logs + in to it again. It never transmits on its own. + """ + if self._rooms is None: + from .services.rooms import RoomService + + self._rooms = RoomService(self) + return self._rooms + @property def adverts(self) -> AdvertScheduler: """Return the session's background-advert scheduler, creating it on first use. diff --git a/meshterm/core/connection.py b/meshterm/core/connection.py index 8e51289..9ff6866 100644 --- a/meshterm/core/connection.py +++ b/meshterm/core/connection.py @@ -36,6 +36,7 @@ from .frames import frame_addressing, trace_link_snrs from .models import ( NODE_TYPE_CHAT, NODE_TYPE_REPEATER, + NODE_TYPE_ROOM, Ack, Contact, Hop, @@ -43,6 +44,8 @@ from .models import ( Message, NeighbourInfo, Observation, + RoomAccess, + RoomLogin, TraceResult, advert_time, utcnow, @@ -122,6 +125,11 @@ def charging_from_battery_level_status(data: bytes) -> bool | None: #: drain loop (seconds). _MESSAGE_GET_TIMEOUT_S = 5.0 +#: MeshCore's ``TXT_TYPE_SIGNED_PLAIN``: the text type a room server pushes its posts in, +#: the body led by the first four bytes of the author's key. (``0`` is plain text and ``1`` +#: a command-line exchange.) +_TXT_TYPE_SIGNED_PLAIN = 2 + #: 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 @@ -1166,6 +1174,30 @@ class Device(ABC): the password is unproven, not disproven). """ + @abstractmethod + async def room_login(self, room: Contact, password: str) -> RoomLogin: + """Log in to a room server, as a member or as its admin, and say what it allowed. + + The same exchange as :meth:`admin_login` — a room server has one login, and the + password picks the role — with the one more thing a room's answer carries: the + access it granted (:class:`~meshterm.core.models.RoomAccess`). The companion adds + the time of the last post it already holds from this room, so the room answers by + sending each post since, one at a time, as ordinary inbound messages. + + A room never says *no*: a wrong password gets no reply at all (unless its owner + lets anyone in read-only), so a refusal is :attr:`~LoginResult.NO_REPLY` here and + a caller must not read that as "unreachable" alone. A blank password asks the room + whether it already knows us — it does for its admins, and for anyone who has + logged in since it last restarted. + + Args: + room: The room server to log in to. Its ``public_key`` addresses it. + password: The room password, the admin password, or ``""``. + + Returns: + How the login ended, and the access granted when it was accepted. + """ + @abstractmethod async def send_remote_command( self, node: Contact, command: str, *, timeout: float = 8.0 @@ -3061,6 +3093,32 @@ 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 + + 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 {})) + + async def _login( + self, node: Contact, password: str, *, what: str + ) -> tuple[LoginResult, dict | None]: + """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: + :meth:`admin_login` and :meth:`room_login` are each a reading of its answer. + + Args: + node: The node to log in to. + password: The password to offer. + 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). + """ from meshcore import EventType mc = self._require() @@ -3174,7 +3232,8 @@ class MeshCoreDevice(Device): # the companion never put it on the air, and a full budget spent after a clean send # means the node really did stay silent. _log.debug( - "admin login to %s: send %s in %.0fms, budget=%.1fs -> %s after %.0fms%s", + "%s to %s: send %s in %.0fms, budget=%.1fs -> %s after %.0fms%s", + what, node.name, "acknowledged" if sent is not None else "unacknowledged", (sent_at - started) * 1000.0, @@ -3184,13 +3243,13 @@ class MeshCoreDevice(Device): f" [also heard: {'; '.join(stray)}]" if stray else "", ) if etype is EventType.LOGIN_SUCCESS: - return LoginResult.ACCEPTED + return LoginResult.ACCEPTED, dict(getattr(event, "payload", None) or {}) if etype is EventType.LOGIN_FAILED: - return LoginResult.REFUSED + return LoginResult.REFUSED, None # 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 + return LoginResult.NO_REPLY, None @staticmethod def _refers_to(event: object, pubkey: str) -> bool: @@ -3213,6 +3272,14 @@ class MeshCoreDevice(Device): textual reply arrives later as a ``CONTACT_MSG_RECV`` event. We return that reply text (or ``None`` if none arrived before ``timeout``). + The reply is the first message *from this node* that is not a room post. Any + direct message used to count, so a companion's message landing during the wait + was taken for the repeater's answer — and a room server pushes its members' + posts down the very same channel, one after another as each is acknowledged, so + administering a room you are a member of would read a stranger's post as the + result of ``get``. The listener is armed before the command goes out, like the + login's, so a reply quicker than the send's own return is not missed either. + Args: node: The remote contact (must already be logged in). cmd: The repeater CLI command, e.g. ``"set tx 20"``. @@ -3228,16 +3295,32 @@ class MeshCoreDevice(Device): mc = self._require() pub = self._node_pubkey(node) - async with self.transmitting(): - sent = await mc.commands.send_cmd(pub, cmd) - if sent is not None and getattr(sent, "is_error", lambda: False)(): - raise DeviceCommandError( - f"couldn't send admin command {cmd!r} to {node.name}: {reject_reason(sent)}" - ) - reply = await mc.wait_for_event(EventType.CONTACT_MSG_RECV, timeout=timeout) - if reply is None: - return None - payload = getattr(reply, "payload", {}) or {} + answer: asyncio.Future = asyncio.get_running_loop().create_future() + + def on_message(event) -> None: # noqa: ANN001 - meshcore Event + payload = getattr(event, "payload", None) or {} + if answer.done() or payload.get("txt_type") == _TXT_TYPE_SIGNED_PLAIN: + return + answer.set_result(payload) + + # The firmware stamps a received direct message with its sender's six-byte prefix, + # which the library files as the event's ``pubkey_prefix`` attribute. + subscription = mc.subscribe( + EventType.CONTACT_MSG_RECV, on_message, {"pubkey_prefix": pub[:12]} + ) + try: + async with self.transmitting(): + sent = await mc.commands.send_cmd(pub, cmd) + if sent is not None and getattr(sent, "is_error", lambda: False)(): + raise DeviceCommandError( + f"couldn't send admin command {cmd!r} to {node.name}: {reject_reason(sent)}" + ) + try: + payload = await asyncio.wait_for(asyncio.shield(answer), timeout) + except asyncio.TimeoutError: + return None + finally: + subscription.unsubscribe() return str(payload.get("text", payload.get("msg", ""))) async def send_remote_command( # noqa: D102 - inherited docstring @@ -4101,7 +4184,48 @@ class MockDevice(Device): node_type=NODE_TYPE_CHAT, route_hops=("a1b2c3d4",), ), + # Last, so every rotation over the first four (the synthetic traffic, the + # tests that count on it) is the one it always was. + Contact( + name="Lakeside BBS", + public_key=_mock_pub("f6a7b8c9"), + key_prefix="f6a7b8c9", + node_type=NODE_TYPE_ROOM, + route_hops=("a1b2c3d4",), + ), ] + # The simulated room server's board, oldest first, as (author key prefix, posted at, + # text): what Lakeside BBS holds when the simulator starts. Its authors are the three + # a room view has to draw — a contact (Alice, Observer-Bot), a node never befriended + # (``e5f6a7b8``, a neighbour no contact names, so no name places it), and the room + # itself, which is how a notice its admin posts with ``room.post`` arrives — and it + # spans two days, so the transcript has a day to divide. + now = utcnow() + self._room_board: list[tuple[str, datetime, str]] = [ + ("d4e5f6a7", now - timedelta(hours=26), "Anyone driving to the swap meet Saturday?"), + ( + "c3d4e5f6", + now - timedelta(hours=25), + "I can take two. Leaving 8am from the IGA lot.", + ), + ("f6a7b8c9", now - timedelta(hours=3), "Reminder: this board keeps the last 32 posts."), + ( + "e5f6a7b8", + now - timedelta(minutes=50), + "Is the north repeater down? Nothing since 6.", + ), + ("d4e5f6a7", now - timedelta(minutes=12), "Heard it an hour ago. Probably the solar."), + ] + #: Our access in each room we have logged in to, keyed by the room's mock key — the + #: room's own access list, as far as it concerns us. A room forgets its members when + #: it restarts; the simulator never restarts one. + self._room_access: dict[str, RoomAccess] = {} + #: The time of the newest post we hold from each room: the companion's "sync since", + #: which a login carries so the room sends only what is newer. + self._room_synced: dict[str, datetime] = {} + #: 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] = [] # 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. @@ -4274,6 +4398,14 @@ class MockDevice(Device): # chat screen's "add it back and send" offer walkable on the simulator. if self._mock_key(contact) not in {self._mock_key(c) for c in self._contacts}: raise ContactNotOnDeviceError(contact) + if contact.is_room: + # A post. The room keeps it — and acknowledges — only from a member it knows + # who may post; anyone else's is dropped with no reply, which is all a read-only + # member ever learns about why a post went nowhere. + access = self._room_access.get(self._mock_key(contact)) + if access is None or not access.can_post: + return None + self._room_board.append((self._self_prefix(), utcnow(), text)) # Otherwise the simulator "delivers" instantly and always acknowledges, so outbound # direct messages show as acked without a radio. return Ack(code="mock") @@ -4290,6 +4422,11 @@ class MockDevice(Device): async def admin_login(self, node: Contact, password: str) -> LoginResult: # noqa: D102 await asyncio.sleep(0) + if node.is_room: + # One login, whichever screen sends it: an admin login to a room makes us a + # member it pushes posts to, and the room's rules decide the answer — including + # its silence for a wrong password — exactly as the firmware's do. + return (await self.room_login(node, password)).result if node.name in self._unreachable: return LoginResult.NO_REPLY # simulates a node that is down or out of range if password != self._admin_password: @@ -4297,6 +4434,88 @@ class MockDevice(Device): self._admin_sessions.add(self._mock_key(node)) return LoginResult.ACCEPTED + 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) + cfg = self._remote_config(room) + if password == self._admin_password: + access = RoomAccess.ADMIN + elif not password and key in self._room_access: + access = self._room_access[key] # "do you know me?" — it does + elif password == cfg["guest.password"]: + access = RoomAccess.MEMBER + elif cfg["allow.read.only"] == "on": + access = RoomAccess.READ_ONLY + else: + # A room never says no: a wrong password is met with silence. + return RoomLogin(LoginResult.NO_REPLY) + self._room_access[key] = access + if access is RoomAccess.ADMIN: + self._admin_sessions.add(key) + self._push_room(room) + return RoomLogin(LoginResult.ACCEPTED, access) + + def _push_room(self, room: Contact) -> None: + """Start a room's catch-up: each post newer than the last one we hold, oldest first. + + A real room sends one post, waits for the companion's acknowledgement, then sends + the next; the simulator spaces them by its event cadence so a room view fills + visibly rather than all at once. Our own posts are never sent back to us. + """ + key = self._mock_key(room) + since = self._room_synced.get(key) + own = self._self_prefix() + pending = [ + post + for post in self._room_board + if post[0] != own and (since is None or post[1] > since) + ] + if not pending: + return + + async def push() -> None: + for author, posted_at, text in pending: + await asyncio.sleep(_MOCK_MONITOR_INTERVAL_S) + self._room_synced[key] = posted_at + message = Message( + text=text, + sender=room.key_prefix, + sender_timestamp=posted_at, + snr=round(self._rng.gauss(6.0, 3.0), 1), + author=author, + ) + for listener in list(self._listeners): + listener(MeshEvent.message_event(message)) + + task = asyncio.create_task(push()) + self._bg_tasks.add(task) + task.add_done_callback(self._bg_tasks.discard) + + def _self_prefix(self) -> str: + """Our own node's four-byte key prefix — how a room signs a post we wrote.""" + return str(self._info.get("public_key") or "")[:8].lower() + + def _remote_config(self, node: Contact) -> dict[str, str]: + """A simulated node's CLI-visible configuration, seeded with the defaults on first touch. + + A room server starts with the stock room password its build sets (MeshCore's + ``ROOM_PASSWORD``, ``hello``) and with forwarding off, as a room ships; the rest is + the repeater defaults, which the two firmwares share. + """ + key = self._mock_key(node) + cfg = self._remote_cfg.get(key) + if cfg is None: + cfg = {"name": node.name, **self._REMOTE_CFG_DEFAULTS} + if node.is_room: + cfg.update({"guest.password": self._ROOM_PASSWORD, "repeat": "off"}) + self._remote_cfg[key] = cfg + return cfg + + #: The stock room password a MeshCore room server is built with. + _ROOM_PASSWORD = "hello" + #: The simulated repeater CLI's configuration, keyed and answered the way MeshCore's #: ``CommonCLI.cpp`` does (see send_remote_command). What is absent is absent on purpose: #: a board with no front-end module and a build with no bridge, so a read of those @@ -4345,11 +4564,18 @@ class MockDevice(Device): key = self._mock_key(node) if key not in self._admin_sessions: return None # firmware ignores strangers — reads as a timeout, like hardware - cfg = self._remote_cfg.setdefault(key, {"name": node.name, **self._REMOTE_CFG_DEFAULTS}) + cfg = self._remote_config(node) parts = command.strip().split() verb = parts[0].lower() if parts else "" if verb == "ver": return "MeshCore v1.15.0 (simulator)" + if verb == "room.post" and node.is_room: + # A notice the admin posts in the room's own name (MeshCore's ``addSystemPost``). + text = command.strip()[len("room.post") :].strip() + if not text: + return "ERR empty message" + self._room_board.append((key[:8], utcnow(), text)) + return "OK" if verb == "clock": return "OK - clock synced" if parts[1:] == ["sync"] else "12:00 - 1/1/2026 UTC" if verb == "advert": @@ -4688,9 +4914,12 @@ class MockDevice(Device): task = asyncio.create_task(emit_loop()) self._bg_tasks.add(task) task.add_done_callback(self._bg_tasks.discard) + self._listeners.append(on_event) def unsubscribe() -> None: stop.set() # wakes the loop's wait immediately; it exits on the next check + if on_event in self._listeners: + self._listeners.remove(on_event) return unsubscribe @@ -4713,9 +4942,9 @@ class MockDevice(Device): ``advert`` otherwise, carrying a location for the simulated repeaters. """ lat_lon = self._MOCK_LOCATIONS.get(contact.name) - # The two located nodes are the simulated repeaters (fixed infrastructure); the rest - # advertise as ordinary chat nodes, so the map has both classes to prioritise. - node_type = NODE_TYPE_REPEATER if contact.name in self._MOCK_LOCATIONS else NODE_TYPE_CHAT + # Each node advertises the type its contact holds — the two located repeaters, the + # companions, the room server — so the map and the lists have every class to draw. + node_type = contact.node_type or NODE_TYPE_CHAT return Observation( node=contact.key_prefix or contact.public_key[:12], public_key=contact.public_key or None, @@ -4761,7 +4990,9 @@ class MockDevice(Device): Returns: A :class:`Message` from one of the known contacts. """ - contact = self._contacts[seq % len(self._contacts)] + # A room server sends posts, never a message of its own (see _push_room). + senders = [c for c in self._contacts if not c.is_room] + contact = senders[seq % len(senders)] return Message( text=f"hello from {contact.name} #{seq}", sender=contact.key_prefix or contact.public_key[:12], @@ -5020,6 +5251,12 @@ def message_from_event(event) -> Message | None: # noqa: ANN001 ``channel_idx`` instead (``type`` is ``"PRIV"`` or ``"CHAN"``). Field names are best-effort and should be validated against your firmware's event schema. + A room post is a direct message from the room server of the *signed* text type + (:data:`_TXT_TYPE_SIGNED_PLAIN`), and the library hands the four bytes the room signs it + with over as ``signature``: not a cryptographic signature but the first four bytes of + the author's public key (MeshCore's ``pushPostToClient``), which become + :attr:`~meshterm.core.models.Message.author`. + Args: event: A meshcore message event (anything exposing a ``payload`` mapping). @@ -5035,6 +5272,9 @@ def message_from_event(event) -> Message | None: # noqa: ANN001 sender_ts = ( datetime.fromtimestamp(ts, tz=timezone.utc) if isinstance(ts, (int, float)) and ts else None ) + author = None + if not is_channel and payload.get("txt_type") == _TXT_TYPE_SIGNED_PLAIN: + author = str(payload.get("signature") or "").lower() or None return Message( text=str(text), sender=None if is_channel else payload.get("pubkey_prefix"), @@ -5042,6 +5282,7 @@ def message_from_event(event) -> Message | None: # noqa: ANN001 is_channel=is_channel, sender_timestamp=sender_ts, snr=_as_float(payload.get("SNR", payload.get("snr"))), + author=author, raw=payload, ) diff --git a/meshterm/core/models.py b/meshterm/core/models.py index 8ca38b1..c64e55c 100644 --- a/meshterm/core/models.py +++ b/meshterm/core/models.py @@ -95,11 +95,104 @@ class LoginResult(Enum): return self is LoginResult.ACCEPTED +class RoomAccess(Enum): + """What a room server let us do when we logged in to it — the words a room view says. + + A room answers a login with the *role* it filed us under, the low two bits of the + permissions byte its access list keeps per client (MeshCore's ``PERM_ACL_*``). The three + members here are what those roles *do* in a room, which is what the reader needs to + know, and the firmware's names for them are not quite that: a room drops posts only + from role 0, its "guest" — the one a wrong password lands on when the owner has turned + ``allow.read.only`` on. Role 1, which the firmware calls read-only, is assigned by hand + (``setperm``) and never by a login, and the room still takes its posts; so it reads as + a member here, because that is what it is. + """ + + #: Read, post, and run the room's command line. The room keeps admins across restarts. + ADMIN = "admin" + #: Read and post — the room password's level. + MEMBER = "member" + #: Read only. The room drops this member's posts without an acknowledgement. + READ_ONLY = "read-only" + + @property + def can_post(self) -> bool: + """Whether the room keeps what this member posts.""" + return self is not RoomAccess.READ_ONLY + + @classmethod + def from_login(cls, payload: dict) -> RoomAccess: + """Read the access a room granted from its login reply. + + Firmware that sends the access-list permissions byte (companion 1.10 and later) + is read from that; an older reply carries only the legacy flag in its first byte, + which a room sets to ``1`` for an admin and ``2`` for a guest. + + Args: + payload: The ``LOGIN_SUCCESS`` event's payload, as the meshcore library + parses it (``acl_permissions`` and ``permissions``). + + Returns: + The access the room granted. + """ + acl = payload.get("acl_permissions") + if isinstance(acl, int): + role = acl & 0x03 + if role == 0x03: + return cls.ADMIN + return cls.READ_ONLY if role == 0 else cls.MEMBER + legacy = payload.get("permissions") + if legacy == 1: + return cls.ADMIN + return cls.READ_ONLY if legacy == 2 else cls.MEMBER + + +@dataclass(frozen=True, slots=True) +class RoomLogin: + """How a login to a room server ended, and what it let us do. + + The :class:`LoginResult` keeps its meaning — and its policy on what to remember — and a + room adds the one fact a repeater's login never needed: which of its roles it gave us. + + Attributes: + result: Accepted, refused, or never answered. A room never refuses: a wrong + 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. + """ + + result: LoginResult + access: RoomAccess | None = None + + def __bool__(self) -> bool: + """Truthy only when logged in, like the :class:`LoginResult` it carries.""" + return bool(self.result) + + +def is_room(node_type: int | None) -> bool: + """Whether a node of this advert type is a room server — something you join, not DM. + + The room counterpart of :func:`is_direct_messageable`: the two together sort every + contact the Chat picker lists into the section it belongs in. A contact whose type was + never advertised is not a room, because joining one starts with a password prompt, and + a companion should not be asked for one. + + Args: + node_type: The node's advert type, or ``None`` when it was never advertised. + + Returns: + ``True`` only for :data:`NODE_TYPE_ROOM`. + """ + return node_type == NODE_TYPE_ROOM + + def is_direct_messageable(node_type: int | None) -> bool: """Whether a node of this advert type is a direct-message recipient — THE DM rule. - We only send direct messages to *companion* (chat) nodes: a repeater, room server, or - sensor is infrastructure, not someone to message. A contact whose type was never + We only send direct messages to *companion* (chat) nodes: a repeater or sensor is + infrastructure, not someone to message, and a room server is somewhere you *post* + (see :func:`is_room`) — the transmission is the same, but what the reader is doing, + and what the room view draws, is not. A contact whose type was never advertised (``None``) gets the benefit of the doubt, so a real companion is never hidden by a missing type. Every DM recipient picker (the chat conversation list, the courier outbox) filters through this one predicate, so "DMs go to companions only" is @@ -202,6 +295,11 @@ class Contact: """Whether this contact advertises as a repeater (fixed infrastructure).""" return self.node_type == NODE_TYPE_REPEATER + @property + def is_room(self) -> bool: + """Whether this contact advertises as a room server (see :func:`is_room`).""" + return is_room(self.node_type) + @dataclass(slots=True) class MapMarker: @@ -322,9 +420,16 @@ class Message: sender: Key prefix of the sending contact (direct messages), if known. channel: Channel index the message arrived on (channel messages), if applicable. is_channel: Whether this is a channel message rather than a direct one. - sender_timestamp: The sender's own timestamp for the message, if carried. + sender_timestamp: The sender's own timestamp for the message, if carried. A room + post carries the time it was *posted*, by the room's clock — a backlog post + arrives long after it. snr: Signal-to-noise ratio (dB) of the reception, if reported. received_at: When the companion delivered the message to us. + author: For a room post — a direct message from a room server, *signed* with the + first four bytes of whoever wrote it — that author's key prefix, as lowercase + hex. The room is the :attr:`sender` (it is the node that transmitted), and the + author is the member it vouches for. ``None`` for anything else, a room's own + command-line replies included. raw: Optional raw event payload for debugging/replay. """ @@ -335,8 +440,14 @@ class Message: sender_timestamp: datetime | None = None snr: float | None = None received_at: datetime = field(default_factory=utcnow) + author: str | None = None raw: dict | None = None + @property + def is_post(self) -> bool: + """Whether this is a room post (a message a room relayed on a member's behalf).""" + return self.author is not None + @dataclass(slots=True) class Ack: @@ -409,6 +520,11 @@ class ChatMessage: :data:`~meshterm.core.regions.WILDCARD` (``*``) for unscoped, or ``None`` where it isn't known — every inbound or direct message, and anything sent before the scope was recorded. + author: For an inbound room post, the key prefix of the member who wrote it (see + :attr:`Message.author`); :attr:`peer` is then the room. ``None`` for every + other message — including our own posts, which a room never sends back, and a + room's command-line replies, which share its conversation key but are not + posts (see :attr:`is_post`). """ text: str @@ -423,6 +539,12 @@ class ChatMessage: created_at: datetime = field(default_factory=utcnow) row_id: int | None = None scope: str | None = None + author: str | None = None + + @property + def is_post(self) -> bool: + """Whether this is an inbound room post, written by the member :attr:`author` names.""" + return self.author is not None @property def key(self) -> str: @@ -463,12 +585,18 @@ class ChatMessage: # so the transcript reflects message time rather than retrieval time. Falls # back to the receive time when the sender carried no timestamp. created_at=message.sender_timestamp or message.received_at, + author=None if message.is_channel else message.author, ) @dataclass(slots=True) class Conversation: - """A selectable chat thread: a channel or a direct exchange with a contact. + """A selectable chat thread: a channel, a room, or a direct exchange with a contact. + + A room is addressed like a direct exchange — one node, its key prefix keying the + history — so it is a conversation with a :attr:`contact` whose type is a room server + (:attr:`is_room`). What differs is what the transcript holds: posts by many members, + each under its :attr:`ChatMessage.author`, rather than one peer's messages. Attributes: label: Display name (e.g. ``#general`` or ``Alice``). @@ -478,7 +606,7 @@ class Conversation: :func:`~meshterm.core.channels.channel_identity`). secret: The channel's 16-byte secret, for channels — used only to show its public/private openness marker; ``None`` when unknown. - contact: The contact, for direct conversations. + contact: The contact, for direct conversations and rooms. """ label: str @@ -490,11 +618,16 @@ class Conversation: @property def peer(self) -> str | None: - """The peer key prefix for a direct conversation, else ``None``.""" + """The peer key prefix for a direct conversation or a room, else ``None``.""" if self.is_channel or self.contact is None: return None return self.contact.key_prefix or self.contact.public_key[:12] or None + @property + def is_room(self) -> bool: + """Whether this conversation is a room server's board.""" + return not self.is_channel and self.contact is not None and self.contact.is_room + @property def key(self) -> str: """The conversation's stable key (see :func:`conversation_key`).""" diff --git a/meshterm/core/preferences.py b/meshterm/core/preferences.py index dc244fc..152841a 100644 --- a/meshterm/core/preferences.py +++ b/meshterm/core/preferences.py @@ -277,6 +277,21 @@ PREFERENCES: tuple[PrefSpec, ...] = ( minimum=0, maximum=2, ), + PrefSpec( + key="room_relogin_minutes", + label="Room re-login", + help="Opening a room this quiet logs in again", + group="Sending", + value_type="int", + # A login is one exchange, and it is what restarts a room's catch-up: a room stops + # sending to a member after three posts go unacknowledged, and forgets every + # non-admin member when it restarts. A room heard from within this long is still + # sending, so opening it again sends nothing. + default=30, + minimum=1, + maximum=1440, + unit="min", + ), # --- TX optimize ------------------------------------------------------------- PrefSpec( key="tx_opt_min", diff --git a/meshterm/core/remote_config.py b/meshterm/core/remote_config.py index 748afcc..0dae9ab 100644 --- a/meshterm/core/remote_config.py +++ b/meshterm/core/remote_config.py @@ -48,7 +48,9 @@ from __future__ import annotations import re from collections.abc import Iterable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace + +from .models import is_room #: Reply text (lowercased) that reads as the firmware refusing/unknowing a command. ``??:`` #: is how ``handleGetCmd`` answers a key it doesn't know; ``unknown config:`` is its ``set`` @@ -129,6 +131,9 @@ class RemoteSetting: discovered: Whether this setting is not in the catalog at all, but was learned from a command the reader ran on *one node's* command line (see :func:`discovered_setting`). Never true of a catalog entry. + as_room: ``(label, help)`` for a setting a room server means something else by — + the firmwares share their command line, not every word's purpose. Empty for a + setting that reads the same on both; see :meth:`for_node`. """ key: str @@ -151,6 +156,27 @@ class RemoteSetting: part: int = 0 overlaps: tuple[str, ...] = () discovered: bool = False + as_room: tuple[str, str] = () + + def for_node(self, node_type: int | None) -> RemoteSetting: + """This setting as a node of ``node_type`` presents it: a room server's own words. + + A repeater's ``guest.password`` admits read-only guests; a room server's is the + password its members join with and post under. Same key, same value, a different + thing to the reader — so the page names it for the node it is open on, and the + catalog stays one list. + + Args: + node_type: The administered node's advert type. + + Returns: + The setting with its room label and help where it has them and the node is a + room server; otherwise this very setting. + """ + if self.as_room and is_room(node_type): + label, help_text = self.as_room + return replace(self, label=label, help=help_text) + return self @property def get_command(self) -> str: @@ -485,6 +511,7 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = ( label="Guest password", category="Access", help="Password for read-only (guest) logins", + as_room=("Room password", "The password members join and post with"), ), RemoteSetting( key="allow.read.only", @@ -492,6 +519,7 @@ REPEATER_SETTINGS: tuple[RemoteSetting, ...] = ( category="Access", kind="bool", help="Allow read-only access (room servers)", + as_room=("Allow read-only", "Let any password in to read, but not to post"), ), # -- Power ------------------------------------------------------------------- RemoteSetting( diff --git a/meshterm/core/room_store.py b/meshterm/core/room_store.py new file mode 100644 index 0000000..6672dfd --- /dev/null +++ b/meshterm/core/room_store.py @@ -0,0 +1,174 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Persistence for the rooms this machine has joined: each room's password and our access. + +A room server has two passwords. The *admin* password is the one Repeater admin already +remembers (:mod:`meshterm.core.admin_store`), and it gets you into a room as its admin; the +*room* password is the one its owner hands out to members, and it is what this store keeps. +Joining a room is logging in with one of them, so a room is "joined" here exactly when +MeshTerm holds a password it can log in with — and opening the room again logs in with it +silently instead of asking. + +An admin login's password is never written here, only the access it earned: the admin +password lives in one place, where Repeater admin can change it, so a room never logs in +with a copy that has gone stale. A room whose owner cleared the room password is open to +anyone, and its remembered password is the empty string — a real answer, not an absence, +which is why :meth:`RoomStore.password` returns ``None`` for "nothing remembered". + +Like the admin passwords, this is global machine state in a small JSON file +(``/rooms.json``), written owner-only because it holds passwords in plaintext. +The file is read into :class:`RoomMembership` records and written back out of them, so a +field no record knows is gone after the next write (see :data:`RoomMembership.RETIRED`). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar + +from .admin_store import admin_key +from .atomicwrite import write_atomically +from .models import Contact, LoginResult, RoomAccess, RoomLogin, utcnow + + +@dataclass(slots=True) +class RoomMembership: + """What we remember about one room we have joined. + + Attributes: + key: The :func:`~meshterm.core.admin_store.admin_key` the room is stored under — + the same key its admin password has, so the two stores agree on which room is + which (the record's key in the file, not one of its fields). + password: The room password that got us in, ``""`` for a room open to anyone, or + ``None`` when we got in as its admin (that password lives in the admin store). + access: The access the room granted at our last login: a + :class:`~meshterm.core.models.RoomAccess` value. + label: The room's name, for display. + last_login: ISO-8601 timestamp of the last accepted login. + """ + + key: str + password: str | None + access: str + label: str + last_login: str + + #: Fields this record once held and must never hold again under another meaning (see + #: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused). + RETIRED: ClassVar[frozenset[str]] = frozenset() + + @classmethod + def read(cls, key: str, raw: object) -> RoomMembership | None: + """Build a record from one file entry, or ``None`` when it is not one we can use.""" + if not isinstance(raw, dict): + return None + password = raw.get("password") + access = raw.get("access") + if access not in {a.value for a in RoomAccess}: + return None + return cls( + key=key, + password=None if password is None else str(password), + access=str(access), + label=str(raw.get("label") or ""), + last_login=str(raw.get("last_login") or ""), + ) + + def to_json(self) -> dict: + """The record as written: its own fields and nothing it happened to be read with.""" + return { + "password": self.password, + "access": self.access, + "label": self.label, + "last_login": self.last_login, + } + + +class RoomStore: + """Reads and writes the rooms this machine has joined.""" + + def __init__(self, path: Path) -> None: + """Open the store against a JSON file location. + + Args: + path: Path to the JSON state file (created lazily on the first join). + """ + self._path = path + + def _load(self) -> dict[str, RoomMembership]: + """Read every membership, keyed by room; empty on a missing or corrupt file.""" + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + if not isinstance(data, dict): + return {} + records = {} + for key, raw in data.items(): + record = RoomMembership.read(str(key), raw) + if record is not None: + records[record.key] = record + return records + + def membership(self, room: Contact) -> RoomMembership | None: + """What we remember about ``room``, or ``None`` if we never joined it.""" + return self._load().get(admin_key(room)) + + def password(self, room: Contact) -> str | None: + """The room password to log in to ``room`` with, or ``None`` when none is remembered. + + ``""`` is a remembered password — the room is open — and an admin membership + remembers none here (its password is the admin store's). + """ + record = self.membership(room) + return record.password if record is not None else None + + def access(self, room: Contact) -> RoomAccess | None: + """The access ``room`` granted at our last login, or ``None`` if we never joined it.""" + record = self.membership(room) + return RoomAccess(record.access) if record is not None else None + + def record(self, room: Contact, password: str, login: RoomLogin) -> None: + """Update what we remember about ``room`` from how a login to it ended. + + The admin store's credential policy (:meth:`~meshterm.core.admin_store.AdminStore. + record`), for the room password: kept when the room accepts it, forgotten when the + node *refuses* it, and left as it is when nothing came back — silence is not a + verdict, and from a room it is also how a wrong password sounds, so a password that + never once worked is simply never written. An admin login is remembered as the + access alone; its password is the admin store's to keep. + + Args: + room: The room the login addressed. + password: The password that was tried. + login: How the login ended. + """ + if login.result is LoginResult.ACCEPTED and login.access is not None: + records = self._load() + key = admin_key(room) + records[key] = RoomMembership( + key=key, + password=None if login.access is RoomAccess.ADMIN else password, + access=login.access.value, + label=room.name, + last_login=utcnow().isoformat(), + ) + self._write(records) + elif login.result is LoginResult.REFUSED: + self.forget(room) + + def forget(self, room: Contact) -> None: + """Remove everything remembered about ``room``. + + Args: + room: The room to forget. + """ + records = self._load() + if records.pop(admin_key(room), None) is not None: + self._write(records) + + def _write(self, records: dict[str, RoomMembership]) -> None: + """Persist ``records`` atomically with owner-only permissions where supported.""" + data = {key: record.to_json() for key, record in records.items()} + write_atomically(self._path, json.dumps(data, indent=2), owner_only=True) diff --git a/meshterm/persistence/db.py b/meshterm/persistence/db.py index 373297a..d90baea 100644 --- a/meshterm/persistence/db.py +++ b/meshterm/persistence/db.py @@ -11,7 +11,7 @@ from __future__ import annotations import sqlite3 from pathlib import Path -SCHEMA_VERSION = 17 +SCHEMA_VERSION = 18 _SCHEMA = """ CREATE TABLE IF NOT EXISTS schema_meta ( @@ -162,8 +162,9 @@ CREATE TABLE IF NOT EXISTS messages ( snr REAL, acked INTEGER, created_at TEXT NOT NULL, - scope TEXT -- outbound floods: the region it was sent under, '*' for + scope TEXT, -- outbound floods: the region it was sent under, '*' for -- unscoped, NULL where it was never known + author TEXT -- a room post: its author's key prefix (peer is the room) ); CREATE INDEX IF NOT EXISTS idx_traces_run ON traces(run_id); @@ -363,3 +364,13 @@ def _migrate(conn: sqlite3.Connection) -> None: # anything else — inbound messages, direct messages, and every row sent before this # column existed, whose scope was never known and cannot be recovered. conn.execute("ALTER TABLE messages ADD COLUMN scope TEXT") + if "author" not in message_cols: + # v17 -> v18: who wrote a room post. A room server relays its members' posts as + # direct messages from *itself*, so the row's peer is the room and the member who + # wrote the post is a second fact the wire carries beside it — the first four bytes + # of their key, which the room signs the post with. Stored as that lowercase hex; + # NULL for every other message, a room's own command-line replies included, which + # is what tells a post from a reply in the one conversation the two share. Rows + # from before this column are NULL too: a post recorded then lost its author on + # the way in, and nothing on disk can bring it back. + conn.execute("ALTER TABLE messages ADD COLUMN author TEXT") diff --git a/meshterm/persistence/repository.py b/meshterm/persistence/repository.py index d38e572..e13c41c 100644 --- a/meshterm/persistence/repository.py +++ b/meshterm/persistence/repository.py @@ -10,7 +10,7 @@ from __future__ import annotations import json import sqlite3 import statistics -from collections.abc import Sequence +from collections.abc import Iterable, Sequence from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path @@ -1959,7 +1959,8 @@ class Repository: cur = self._conn.execute( "INSERT INTO messages " "(run_id, outbound, is_channel, channel_id, channel_idx, peer, peer_name, text, " - "snr, acked, created_at, scope) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "snr, acked, created_at, scope, author) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( run_id, int(msg.outbound), @@ -1973,11 +1974,40 @@ class Repository: None if msg.acked is None else int(msg.acked), msg.created_at.isoformat(), msg.scope, + msg.author.lower() if msg.author else None, ), ) self._conn.commit() return int(cur.lastrowid) + def has_room_post(self, msg: ChatMessage) -> bool: + """Whether this room post is already in the room's history. + + A room re-sends a post it never heard us acknowledge — the acknowledgement lost + on the way back looks, from the room's side, exactly like the post being lost — + and a login asks for every post newer than the last one the *companion* recorded, + which can lag the last one *we* stored. Either way the same post arrives twice, + and history should hold it once. A room stamps each post with a unique time by + its own clock, so the room, the author, the time and the text together name it. + + Args: + msg: An inbound room post (:attr:`~ChatMessage.is_post`). + + Returns: + ``True`` when a row with the same room, author, time and text exists. + """ + row = self._conn.execute( + "SELECT 1 FROM messages WHERE is_channel = 0 AND peer = ? AND author = ? " + "AND created_at = ? AND text = ? LIMIT 1", + ( + (msg.peer or "").lower(), + (msg.author or "").lower(), + msg.created_at.isoformat(), + msg.text, + ), + ).fetchone() + return row is not None + def update_chat_ack(self, message_id: int, acked: bool) -> None: """Update one outbound message's delivery acknowledgement (used on retry). @@ -2015,14 +2045,19 @@ class Repository: channel_id: str | None = None, peer: str | None = None, limit: int = 200, + posts_only: bool = False, ) -> list[ChatMessage]: """Return a conversation's most recent messages, oldest-first. Args: is_channel: Whether to load a channel conversation. channel_id: The channel's slot-independent identity (channel conversations). - peer: The contact key prefix (direct conversations). + peer: The contact key prefix (direct conversations), or a room's. limit: Maximum number of messages to return. + posts_only: Load a room's *board*: the posts it relayed to us and the ones we + posted to it, leaving out what else arrives from a room under the same key + — its replies to an admin's commands, and any post stored before posts + carried their author (see :attr:`ChatMessage.is_post`). Returns: The messages in chronological order (ready to render as a transcript). @@ -2031,27 +2066,46 @@ class Repository: where, params = "is_channel = 1 AND channel_id = ?", [channel_id] else: where, params = "is_channel = 0 AND peer = ?", [(peer or "").lower()] + if posts_only: + where += " AND (outbound = 1 OR author IS NOT NULL)" rows = self._conn.execute( f"SELECT * FROM messages WHERE {where} ORDER BY id DESC LIMIT ?", [*params, limit], ).fetchall() return [self._row_to_chat(row) for row in reversed(rows)] - def last_chat_messages(self) -> dict[str, ChatMessage]: + def last_chat_messages(self, rooms: Iterable[str] = ()) -> dict[str, ChatMessage]: """Return the latest message per conversation, keyed by conversation key. Backs the conversation picker's preview snippets. One row per distinct conversation, taken as the highest-id (most recent) message in each. + Args: + rooms: The key prefixes of the room servers among the conversations, lowercase + as stored. A room's latest message is its latest *post* — what + :meth:`recent_chat_messages` loads with ``posts_only`` — so an admin's + command reply never stands in as the last thing said on the board. + Returns: A mapping of :func:`~meshterm.core.models.conversation_key` to its latest :class:`ChatMessage`. """ + peers = sorted({room.lower() for room in rooms if room}) + replies = "" + if peers: + # Leave out a room's inbound rows that are not posts. A peer is matched exactly: + # a room's rows are stored under the prefix its own contact entry carries. + marks = ", ".join("?" * len(peers)) + replies = ( + f" WHERE NOT (is_channel = 0 AND outbound = 0 AND author IS NULL " + f"AND peer IN ({marks}))" + ) rows = self._conn.execute( "SELECT * FROM messages WHERE id IN (" - " SELECT MAX(id) FROM messages GROUP BY " + f" SELECT MAX(id) FROM messages{replies} GROUP BY " " CASE WHEN is_channel = 1 THEN 'chan:' || channel_id " - " ELSE 'dm:' || peer END)" + " ELSE 'dm:' || peer END)", + peers, ).fetchall() return {msg.key: msg for msg in (self._row_to_chat(r) for r in rows)} @@ -2267,4 +2321,5 @@ class Repository: created_at=datetime.fromisoformat(row["created_at"]), row_id=row["id"], scope=row["scope"] if "scope" in row.keys() else None, + author=row["author"] if "author" in row.keys() else None, ) diff --git a/meshterm/services/chat_service.py b/meshterm/services/chat_service.py index 5ae27d5..c0e295f 100644 --- a/meshterm/services/chat_service.py +++ b/meshterm/services/chat_service.py @@ -298,6 +298,15 @@ class ChatService: while True: message = await self._queue.get() try: + if message.is_post: + # A room is still sending to us — the fact that decides whether opening + # it needs a login (see RoomService.login_due) — whether or not this + # post is one we already hold. + rooms = getattr(self._ctx, "rooms", None) + if rooms is not None: + rooms.heard(message.sender) + if self._ctx.repo.has_room_post(ChatMessage.from_message(message)): + continue # a re-sent post: recorded once, counted once channel_id = ( await self.channel_id_for(message.channel) if message.is_channel else None ) @@ -344,9 +353,12 @@ class ChatService: What that excludes is, in practice, machine traffic rather than correspondence: - * A direct message from a node that isn't a DM *recipient* — a repeater, room server, - or sensor (see :func:`~meshterm.core.models.is_direct_messageable`), which the picker + * A direct message from a node that isn't a DM *recipient* — a repeater or sensor + (see :func:`~meshterm.core.models.is_direct_messageable`), which the picker doesn't list. Every remote-CLI reply from a repeater you administer arrives this way. + * Anything from a room server but its *posts*. A room is listed — its board is a + conversation — but what else it sends, its replies to an admin's commands, is not + on the board, so it has no row to point at. * A direct message from a sender the contact table doesn't hold at all. * A channel message the device has no configured slot for — including one whose slot couldn't be identified, which carries the slot-derived fallback identity. @@ -370,7 +382,7 @@ class ChatService: if store is not None and store.is_muted(channel_id): return False return await self._is_listed_channel(channel_id) - return await self._is_listed_contact(message.sender) + return await self._is_listed_contact(message.sender, post=message.is_post) async def _is_listed_channel(self, channel_id: str | None) -> bool: """Whether a channel identity is one of the device's configured channel slots. @@ -390,12 +402,13 @@ class ChatService: return True return any(channel_identity(slot.name, slot.secret) == channel_id for slot in slots) - async def _is_listed_contact(self, sender: str | None) -> bool: - """Whether a direct message's sender is a contact the Chat picker lists. + async def _is_listed_contact(self, sender: str | None, *, post: bool = False) -> bool: + """Whether a direct message's sender is a contact the Chat picker lists, for it. Matched the way the live chat matches an inbound sender to its thread: either prefix may be the shorter one, since what the wire addresses and what the contact table stores - need not be the same width. + need not be the same width. A companion is listed for its messages, a room server + for its posts (``post``) and nothing else. """ if not sender: return False @@ -412,6 +425,8 @@ class ChatService: for ident in (contact.key_prefix, contact.public_key[:12]): ident = (ident or "").lower() if ident and (ident.startswith(peer) or peer.startswith(ident)): + if contact.is_room: + return post return is_direct_messageable(contact.node_type) return False @@ -510,6 +525,60 @@ class ChatService: self._ctx.repo.update_chat_ack(message.row_id, message.acked) return message + async def send_post(self, room: Contact, text: str) -> ChatMessage: + """Post to a room and record the post in the room's history. + + A post travels exactly as a direct message does — addressed to the room server, + which acknowledges it once it has *stored* it — and it is recorded under the room's + key the same way, so the board's history is one conversation. The acknowledgement + means the room kept it, never that anyone has read it; a read-only member's post is + dropped with no acknowledgement at all. + + Unlike a direct message, a post is never soft-retried. A retry is a new message to + the room (it carries a new timestamp), so a post the room stored whose + acknowledgement was lost on the way home would be stored *again* and sent to every + member twice — a duplicate the whole room sees, where a direct message's is seen by + one. ^R remains the reader's own retry. + + Args: + room: The room server. + text: The post. + + Returns: + The recorded outbound :class:`ChatMessage`; its ``acked`` says whether the room + stored it. + """ + device = await self._ctx.device() + ack = await device.send_direct_message(room, text) + chat = ChatMessage( + text=text, + outbound=True, + is_channel=False, + peer=room.key_prefix or room.public_key[:12] or None, + peer_name=room.name, + acked=ack is not None, + created_at=utcnow(), + ) + chat.row_id = self._ctx.repo.record_chat_message(chat, run_id=self._run_id) + return chat + + async def resend_post(self, room: Contact, message: ChatMessage) -> ChatMessage: + """Post an unacknowledged post again, once, updating its row in place (^R). + + Args: + room: The room server. + message: The post that went unacknowledged; mutated with the new outcome. + + Returns: + The same ``message``, with :attr:`~ChatMessage.acked` refreshed. + """ + device = await self._ctx.device() + ack = await device.send_direct_message(room, message.text) + message.acked = ack is not None + if message.row_id is not None: + self._ctx.repo.update_chat_ack(message.row_id, message.acked) + return message + def channel_scope(self, channel_id: str | None) -> str | None: """The region a channel's messages are sent under, or ``None`` for the device default. diff --git a/meshterm/services/rooms.py b/meshterm/services/rooms.py new file mode 100644 index 0000000..f4c8a3a --- /dev/null +++ b/meshterm/services/rooms.py @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The room service: joining room servers, and knowing when to log in to one again. + +A room server is a bulletin board on a radio. It keeps its most recent posts and, once a +member has logged in, sends each post that member hasn't seen — one at a time, waiting for +the acknowledgement before the next — and then each new one as it is written. Everything +that *arrives* from a room is an ordinary inbound message, recorded by the +:class:`~meshterm.services.chat_service.ChatService` like any other; this service owns the +other half, the part that *asks*: the login, the passwords it uses, and the access it won. + +The session state is small and deliberately so. A login is one exchange on the air, and a +room needs one only when it may have stopped sending: after it forgot us (a restart drops +every member but its admins) or gave up on us (three unacknowledged posts). So the service +remembers when each room was last *heard* — a login it accepted, or a post it sent — and a +room heard within ``preferences.room_relogin_minutes`` is left alone when it is opened again. +Nothing here ever transmits on its own: a login happens because a reader opened a room or +asked for one, never in the background. + +Session-scoped state on the :class:`~meshterm.context.AppContext` (``ctx.rooms``). +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from ..core.models import Contact, LoginResult, RoomAccess, RoomLogin + +if TYPE_CHECKING: + from ..context import AppContext + + +class RoomService: + """Joins rooms, remembers what they let us do, and decides when to log in again.""" + + def __init__(self, ctx: AppContext) -> None: + """Bind the service to an application context. + + Args: + ctx: The shared application context (device, stores, preferences). + """ + self._ctx = ctx + #: The access each room granted at a login *this session*, keyed by room peer. + self._access: dict[str, RoomAccess] = {} + #: When each room was last heard from this session (a login or a post), on the + #: monotonic clock, keyed by room peer. + self._heard: dict[str, float] = {} + + @staticmethod + def _peer(room: Contact) -> str: + """The room's key as its conversation stores it (see ``Conversation.peer``).""" + return (room.key_prefix or room.public_key[:12] or "").lower() + + def password(self, room: Contact) -> str | None: + """The password to log in to ``room`` with, or ``None`` when we hold none. + + The room password first — it is what a member was given, and what got us in — + then the room's admin password, which Repeater admin may already remember and + which gets us in as its admin. ``""`` is a password (an open room). + """ + stored = self._ctx.room_store.password(room) if self._ctx.room_store else None + if stored is not None: + return stored + return self._ctx.admin_store.get(room) + + 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 + + def access(self, room: Contact) -> RoomAccess | None: + """What ``room`` lets us do: as granted this session, else at our last login, if any.""" + granted = self._access.get(self._peer(room)) + if granted is not None: + return granted + return self._ctx.room_store.access(room) if self._ctx.room_store else None + + def heard(self, peer: str | None) -> None: + """Note that a room just sent us a post, so it is still sending to us. + + Called by the chat recorder for every room post it stores. + + Args: + peer: The room's key prefix, as the post's sender carried it. + """ + if peer: + self._heard[peer.lower()] = time.monotonic() + + def heard_recently(self, room: Contact) -> bool: + """Whether ``room`` was heard within ``room_relogin_minutes``, so needs no login. + + A post carrying any width of the room's prefix counts, since the wire's prefix and + the contact table's need not be the same length. + """ + peer = self._peer(room) + window = self._ctx.preferences.room_relogin_minutes * 60 + now = time.monotonic() + return any( + (known.startswith(peer) or peer.startswith(known)) and now - at < window + for known, at in self._heard.items() + ) + + def login_due(self, room: Contact) -> bool: + """Whether opening ``room`` should log in to it: a password at hand and a quiet room.""" + return self.joined(room) and not self.heard_recently(room) + + async def login(self, room: Contact, password: str) -> 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 + room password in the room store, an admin password in the admin store (shared with + Repeater admin, which will log in with it silently from now on). A login the node + *refused* forgets the password that was tried; one that met silence keeps + everything, since a room also stays silent for a wrong password and only a + password that once worked is ever stored. + + Args: + room: The room server to log in to. + password: The password to offer (``""`` asks the room whether it knows us). + + Returns: + How the login ended, and the access granted. + + Raises: + Exception: Propagates a device error (no connection, a contact the companion + cannot address); nothing is remembered then. + """ + device = await self._ctx.device() + login = await device.room_login(room, password) + if self._ctx.room_store is not None: + self._ctx.room_store.record(room, password, login) + if login.access is RoomAccess.ADMIN: + self._ctx.admin_store.record(room, password, LoginResult.ACCEPTED) + elif login.result is LoginResult.REFUSED and password == self._ctx.admin_store.get(room): + self._ctx.admin_store.record(room, password, login.result) + if login and login.access is not None: + peer = self._peer(room) + self._access[peer] = login.access + self._heard[peer] = time.monotonic() + return login diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index 61ee6f3..30cd669 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -39,12 +39,19 @@ from ..core.events import EventKind, MeshEvent from ..core.models import ( NODE_TYPE_CHAT, NODE_TYPE_LABELS, + NODE_TYPE_ROOM, ChatMessage, Contact, Conversation, + NodeResolver, is_direct_messageable, + is_room, +) +from ..services.trace_runner import ( + NameKeyResolver, + make_name_key_resolver, + make_node_resolver, ) -from ..services.trace_runner import NameKeyResolver, make_name_key_resolver from ..ui import renderers from ..ui.fields import ChannelRef, NodeRef from ..ui.menus import Lane, column_header, fit_cells, section_heading @@ -157,7 +164,7 @@ class ChatTool(Tool): picker.replace_items(await self._picker_items(ctx)) async def _picker_items(self, ctx: AppContext) -> list: - """Build the picker's rows: the pinned lane names, the Channels group, then Direct. + """Build the picker's rows: the pinned lane names, then Channels, Rooms and Direct. Read fresh every time the list is built or swapped, so a thread that just gained messages sits where its recency puts it. Cheap enough to redo after each chat: the @@ -179,27 +186,41 @@ class ChatTool(Tool): # :class:`~meshterm.services.device_state.DeviceState`). channels = _channels_from_slots(await ctx.devstate.channel_slots()) contacts = await ctx.devstate.contacts() - # Only companion nodes are listed — we don't DM repeaters, rooms, or sensors; - # a contact whose type was never advertised gets the benefit of the doubt (the - # app-wide DM rule, see is_direct_messageable). + # Only companion nodes are listed under Direct — we don't DM repeaters, rooms, or + # sensors; a contact whose type was never advertised gets the benefit of the doubt + # (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)] + rooms = [ + Conversation(label=c.name, is_channel=False, contact=c) + for c in contacts + if is_room(c.node_type) + ] + 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 # cursor), while a self-refreshing view feeds each row's live preview - # (see _LiveLasts). - lasts = ctx.repo.last_chat_messages() - live = _LiveLasts(ctx, seed=lasts) + # (see _LiveLasts). A room's latest is its latest *post*. + lasts = ctx.repo.last_chat_messages(rooms=room_peers) + live = _LiveLasts(ctx, seed=lasts, rooms=room_peers) # Names in previews/mentions resolve back to keys for their hue (the app-wide - # colour rule); a name no contact or stored advert carries stays muted. - key_of = make_name_key_resolver(contacts, ctx.repo.node_names()) + # colour rule); a name no contact or stored advert carries stays muted. A room + # post names its author by key, which resolves the other way. + stored_names = ctx.repo.node_names() + key_of = make_name_key_resolver(contacts, stored_names) + name_of = make_node_resolver(contacts, stored_names) # List contacts by recency — those with messages first, newest exchange at the top — - # then the never-contacted ones alphabetically (see _recency_key). + # then the never-contacted ones alphabetically (see _recency_key). Rooms the same. 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). - lanes = _lanes(channels + direct) + lanes = _lanes(channels + rooms + direct) # The lane names pin for the whole picker (they mean the same in both groups), # so scrolling into Direct keeps them overhead with that group's heading under @@ -218,6 +239,29 @@ class ChatTool(Tool): ) ) + items.append(section_heading("📌 Rooms")) + 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, + ), + value=conversation, + deletable=lasts.get(conversation.key) is not None, + hscroll_from=lanes.head, + ) + ) + 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")) + items.append(section_heading("👤 Direct")) if companions: for conversation in direct: @@ -238,16 +282,24 @@ class ChatTool(Tool): @staticmethod async def _delete_history(ctx: AppContext, conversation: Conversation) -> None: - """Confirm and delete one direct conversation's stored history. + """Confirm and delete one direct conversation's — or room's — stored history. Deleting history is irreversible data loss, so the confirm wears the reserved red (``destructive``): Cancel on the left, the committing Delete on the right. On confirm the peer's messages are removed from the database and the thread's unread count is cleared; the contact itself (a device-side record) is untouched. + A room's posts go from this machine only. The room keeps its own, and won't send + them again: it has seen them acknowledged. """ + prompt = ( + f"Delete the posts stored from {conversation.label}? They are removed from " + "this machine, and the room won't send them again." + if conversation.is_room + else f"Delete the chat history with {conversation.label}? Every stored " + "message in this conversation is removed." + ) if not await ctx.ui.dialog( - f"Delete the chat history with {conversation.label}? Every stored " - "message in this conversation is removed.", + prompt, [("Cancel", False), ("Delete", True)], title="Delete history", default=1, @@ -669,11 +721,18 @@ class _LiveLasts: second (bounded by ``ttl``) rather than once per row per repaint, so a wide list stays cheap. """ - def __init__(self, ctx: AppContext, *, seed: dict, ttl: float = 0.5) -> None: - """Bind to a context, seeding the cache with the snapshot already loaded at open.""" + def __init__( + self, ctx: AppContext, *, seed: dict, ttl: float = 0.5, rooms: list[str] | None = None + ) -> None: + """Bind to a context, seeding the cache with the snapshot already loaded at open. + + ``rooms`` are the room servers' peers among the conversations, so a refresh reads + a room's latest *post* just as the seed did (see ``Repository.last_chat_messages``). + """ self._ctx = ctx self._ttl = ttl self._cache = seed + self._rooms = list(rooms or ()) self._at = time.monotonic() def get(self, key: str) -> ChatMessage | None: @@ -681,7 +740,7 @@ class _LiveLasts: now = time.monotonic() if now - self._at >= self._ttl: try: - self._cache = self._ctx.repo.last_chat_messages() + self._cache = self._ctx.repo.last_chat_messages(rooms=self._rooms) except Exception: # noqa: BLE001 - keep the last good snapshot on a read error pass self._at = now @@ -796,6 +855,9 @@ def _row_title( lasts: _LiveLasts, key_of: NameKeyResolver, 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. @@ -809,11 +871,13 @@ def _row_title( lasts: The self-refreshing latest-message view feeding the preview. 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) + return lambda: _title(ctx, conversation, lasts, key_of, lanes, name_of=name_of, joined=joined) def _title( @@ -822,6 +886,9 @@ def _title( lasts: _LiveLasts, key_of: NameKeyResolver, lanes: _Lanes, + *, + name_of: NodeResolver | None = None, + joined: bool = True, ) -> str | Text: """Build a picker row as fixed-width, colour-coded lanes. @@ -840,6 +907,11 @@ def _title( lasts: The self-refreshing latest-message view. 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`. @@ -868,7 +940,9 @@ def _title( text.append(f"{age:>{_AGE_WIDTH}}", style="muted") text.append(" ") if last is not None: - text.append_text(_preview_text(last, key_of)) + 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 @@ -877,6 +951,11 @@ def _title( #: name beside it carries the person's own key-derived hue. _COMPANION_DOT_STYLE = NODE_GLYPHS[NODE_TYPE_CHAT][1] +#: A room's marker: the room server's own ``■``, in its type colour — the mark the map and +#: the contact list give a room. The console font has no hollow square, so it carries no +#: history state; the preview lane says whether there is anything to read, or to join. +_ROOM_MARK = NODE_GLYPHS[NODE_TYPE_ROOM] + def _append_marker( text: Text, conversation: Conversation, last: ChatMessage | None, lanes: _Lanes @@ -896,19 +975,27 @@ def _append_marker( if conversation.is_channel: glyph = channel_glyph(conversation.label, conversation.secret) text.append(glyph + " " * max(1, lanes.marker - cell_len(glyph))) + elif conversation.is_room: + mark, style = _ROOM_MARK + text.append(mark, style=style) + text.append(" " * max(1, lanes.marker - cell_len(mark))) else: dot = "●" if last is not None else "○" text.append(dot, style=_COMPANION_DOT_STYLE) text.append(" " * max(1, lanes.marker - cell_len(dot))) -def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text: +def _preview_text( + last: ChatMessage, key_of: NameKeyResolver, name_of: NodeResolver | None = None +) -> Text: """A muted last-message preview with sender names and ``@mentions`` lit in their hue. Mirrors the live transcript: our own messages get a ``you:`` prefix, an inbound channel message's inline ``Name:`` sender is coloured in its key-derived hue (muted when no known - node carries the name), and every ``@[Name]`` mention reads as a bare ``@Name`` the same - way — so the list and the chat speak the same colour language. + node carries the name), a room post leads with its author — named by key the way the room + view names them (:func:`~meshterm.ui.room.author_label`) — and every ``@[Name]`` mention + reads as a bare ``@Name`` the same way, so the list and the chat speak the same colour + language. The preview is built *whole*, however long the message ran. It used to be clipped to a fixed 40 cells, which is a cut nothing could undo: the row is what ←→ scroll now, and a @@ -928,6 +1015,13 @@ def _preview_text(last: ChatMessage, key_of: NameKeyResolver) -> Text: _append_body(text, body, key_of) else: _append_body(text, body_raw, key_of) + elif last.author and name_of is not None: + from ..ui.room import author_label + + name, key = author_label(last.author, name_of) + text.append(name, style=name_style(name, key)) + text.append(": ", style="muted") + _append_body(text, body_raw, key_of) else: _append_body(text, body_raw, key_of) return text diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index 606ea58..83e03ab 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -216,6 +216,13 @@ class ChatScreen(Screen): self._scope = scope if conversation.is_channel else None self.title = f"{conversation.label} · {self._scope}" if self._scope else conversation.label self._is_channel = conversation.is_channel + # Many voices or one: a channel and a room are read the same way — each message under + # whoever wrote it, Enter on one replying with an @mention — where a direct chat has + # two people and Enter on a message opens its paths. + self._multiparty = conversation.is_channel or conversation.is_room + # Whether typing reaches the compose line. Off where nothing typed could be sent (a + # room that keeps nothing from us), so keys never fill a line the reader cannot see. + self._composing = True self._key_of: Callable[[str], str | None] = key_of or (lambda name: None) # A direct thread's one remote sender is the peer; its key colours the header # even when the resolver can't place the display name. @@ -312,7 +319,7 @@ class ChatScreen(Screen): self._selected_line = None self._selected_end = None if not self._messages: - lines = render_lines(Text("No messages yet — say hello!", style="muted"), width) + lines = render_lines(Text(self._empty_text, style="muted"), width) else: # Both direct and channel threads use the same grouped, Discord/Slack-style # transcript: consecutive messages from one sender share a colored header, with @@ -324,14 +331,7 @@ class ChatScreen(Screen): self._selected = max(0, min(self._selected, len(self._messages) - 1)) lines = self._render_grouped(width) - limit = self._byte_limit() - # The byte budget is pinned to the right edge of the input's *last* line — so a - # compose that wraps onto a second line keeps the counter in the bottom-right - # corner rather than letting it trail the cursor down the wrap. Only a last line - # already full to the edge pushes it onto a right-aligned line of its own. - input_line = self._editor.render(overflow_at=self._overflow_at(limit)) - counter = self._byte_counter(limit) - compose = right_aligned_tail(input_line, counter, width) + compose = self._compose_line(width) footer_parts: list[RenderableType] = [ Text("─" * width, style="muted"), compose, @@ -360,6 +360,21 @@ class ChatScreen(Screen): """Keep the picked reply target in view; otherwise free scroll (managed by stick).""" return self._selected_line + #: What an empty transcript says. + _empty_text = "No messages yet — say hello!" + + def _compose_line(self, width: int) -> RenderableType: + """The compose line under the transcript: the input, with its byte budget. + + The byte budget is pinned to the right edge of the input's *last* line — so a + compose that wraps onto a second line keeps the counter in the bottom-right + corner rather than letting it trail the cursor down the wrap. Only a last line + already full to the edge pushes it onto a right-aligned line of its own. + """ + limit = self._byte_limit() + input_line = self._editor.render(overflow_at=self._overflow_at(limit)) + return right_aligned_tail(input_line, self._byte_counter(limit), width) + # --- outgoing byte budget ------------------------------------------------ def _byte_limit(self) -> int: @@ -496,7 +511,7 @@ class ChatScreen(Screen): if new_day or group != prev_group: if not new_day and lines: lines += render_lines(Text(""), width) # gap between sender groups - header = self._group_header(sender, is_self=message.outbound) + header = self._group_header(sender, message) lines += render_lines(header, width) if selected: # The first message owns the head of the transcript: nothing precedes @@ -538,7 +553,7 @@ class ChatScreen(Screen): return (name or "·"), body return (self._name(message.peer) or message.peer or "?"), message.text - def _group_header(self, sender: str, *, is_self: bool = False) -> Text: + def _group_header(self, sender: str, message: ChatMessage) -> Text: """Build the sender header that starts a group: the name, drawn as a chip. The chip is what separates a *label* from the prose under it — a name standing @@ -551,10 +566,19 @@ class ChatScreen(Screen): :meth:`_render_mentions`) is part of what was said, and inscribing it would put a chip in the middle of a sentence. """ - if is_self: + if message.outbound: return name_chip("", you=True) # ``·`` is a channel line that arrived with no sender prefix — nobody to key on. - return name_chip(sender, None if sender == "·" else self._sender_key(sender)) + return name_chip(sender, None if sender == "·" else self._header_key(sender, message)) + + def _header_key(self, sender: str, message: ChatMessage) -> str | None: + """The key that colours a group's sender chip: the one the sender's name resolves to. + + A hook rather than a call, because a room knows more than a name: each post carries + its author's key prefix, which colours the chip with no name lookup at all (see + :class:`~meshterm.ui.room.RoomScreen`). + """ + return self._sender_key(sender) def _body_lines( self, body: str, message: ChatMessage, width: int, *, selected: bool = False @@ -666,7 +690,7 @@ class ChatScreen(Screen): message = self._messages[self._selected] sender, _ = self._sender_and_body(message) who = "this message" if message.outbound or sender == "·" else sender - if self._is_channel: + if self._multiparty: return Text( f"↩ Enter to reply to {who} with an @mention · End to cancel", style="accent", @@ -711,7 +735,7 @@ class ChatScreen(Screen): (:meth:`_group_header`) so the two can't disagree about who a name is. """ key = self._key_of(sender) - if not key and not self._is_channel and not mention: + if not key and not self._multiparty and not mention: key = self._peer_key or None return key @@ -734,7 +758,7 @@ class ChatScreen(Screen): """ if action == "enter": if self._selected is not None: - if self._is_channel: + if self._multiparty: self._begin_reply() else: self._open_paths(self._selected) @@ -777,7 +801,7 @@ class ChatScreen(Screen): self._clear_selection() self._stick = True # jump back to the live tail / compose line else: - if self._editor.edit(action, data): + if self._composing and self._editor.edit(action, data): # Touching the compose line returns focus there — nothing stays picked. self._status = "" # trimming clears the "too long" notice self._clear_selection() @@ -1210,6 +1234,10 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int: if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller raise RuntimeError("live chat is only available in the interactive menu") + if conversation.is_room: + from .room import open_room + + return await open_room(ctx, conversation) session = ctx.ui.session device = await ctx.device() diff --git a/meshterm/ui/repeater_admin.py b/meshterm/ui/repeater_admin.py index 0ccc9ea..f3b108b 100644 --- a/meshterm/ui/repeater_admin.py +++ b/meshterm/ui/repeater_admin.py @@ -454,7 +454,7 @@ async def _admin_session(ctx: AppContext, device: Device, node: Contact) -> dict # A read-only fact has nothing to stage; asking again is all Enter can do. await read_settings(ctx, device, node, [spec]) else: - await _stage_setting(ctx, str(choice), cache, pending) + await _stage_setting(ctx, node, str(choice), cache, pending) # A read or an apply rewrites the cache the rows are drawn from; re-read it so # the values are the ones the action just produced. cache = ctx.remote_store.settings(node) @@ -477,6 +477,9 @@ def _menu_items(node: Contact, cache: dict, pending: dict[str, str]) -> tuple[st for category, specs in settings_by_category(): rows: list[tuple[str, Text, str, Any]] = [] for spec in specs: + # In the words of the node it is open on — a room server's guest password is + # the room's own (RemoteSetting.for_node). + spec = spec.for_node(node.node_type) if spec.key == "lat": # The map pick sets both coordinates at once, so it heads the pair it fills — # the same row, in the same place, as on the Device config page. @@ -584,11 +587,17 @@ def _value_text( # --- staging and applying --------------------------------------------------------- -async def _stage_setting(ctx: AppContext, key: str, cache: dict, pending: dict[str, str]) -> None: - """Prompt for one setting's new value and stage it (nothing is sent yet).""" +async def _stage_setting( + ctx: AppContext, node: Contact, key: str, cache: dict, pending: dict[str, str] +) -> None: + """Prompt for one setting's new value and stage it (nothing is sent yet). + + The prompt names the setting as its row does, in the words of the node it is for. + """ spec = _spec_for(key, cache) if spec is None or not spec.writable: # pragma: no cover - menu offers only real keys return + spec = spec.for_node(node.node_type) cached = cache.get(key) known = cached.value if cached is not None and cached.supported else None current = pending.get(key, known or "") diff --git a/meshterm/ui/room.py b/meshterm/ui/room.py new file mode 100644 index 0000000..8b5291c --- /dev/null +++ b/meshterm/ui/room.py @@ -0,0 +1,392 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The room view: a room server's board, read and posted to like a chat. + +A room server keeps its members' most recent posts and sends each member the ones they +missed. To the reader that is a conversation with many voices — the shape a channel already +has — so the view *is* the chat screen (:class:`~meshterm.ui.chat.ChatScreen`), with what a +room knows that a channel doesn't: who wrote each post, by key rather than by a name typed in +front of it, and whether the room will take what you post. + +Three things set it apart, each the room's own: + +* **Authors are keys.** A room relays a post as a direct message from *itself*, signed with + the first four bytes of the author's key (:attr:`~meshterm.core.models.ChatMessage.author`). + The post is filed under its author's name when a contact or an overheard advert names that + 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. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING + +from rich.console import RenderableType +from rich.text import Text + +from ..core.events import EventKind, MeshEvent +from ..core.models import ( + ChatMessage, + Conversation, + LoginResult, + NodeResolver, + RoomAccess, + RoomLogin, +) +from .chat import ChatScreen, _belongs, _contact_names, _make_paths_presenter, _with_restore + +if TYPE_CHECKING: + from ..context import AppContext + +#: The title's login atom while a login is in flight. +LOGGING_IN = "logging in…" +#: The title's login atom after a login met silence. +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. +NOT_JOINED = "not joined" + + +def author_label(author: str, resolve: NodeResolver) -> tuple[str, str | None]: + """How a post's author is named and coloured: their name and key, or their bare hash. + + THE rule for an author, shared by the room view and the Chat picker's preview so the + two cannot name one person differently. A key prefix some contact or overheard advert + carries resolves to that node's name, coloured by the key itself; one nothing names + stands as the hash, with no key to colour it — the unidentified-node grey, since a bare + hash standing in as a name is exactly what that grey is for. + + Args: + author: The author's key prefix, as the post carried it. + resolve: Maps a key prefix to a node's name, or back to itself when unknown (see + :func:`~meshterm.services.trace_runner.make_node_resolver`). + + Returns: + ``(label, key)``: the name and the key that colours it, or the hash and ``None``. + """ + name = resolve(author) + if not name or name.lower() == author.lower(): + return author, None + return name, author + + +class RoomScreen(ChatScreen): + """A room's board: posts under their authors, a compose line, and the login's state. + + Everything a chat does it does — pick a post and Enter replies with an ``@mention``, ^P + shows the paths a post took, ^R retries a post the room never acknowledged — plus ^L, + which logs in to the room: with the remembered password, or by asking for one when + none is remembered or the last login met silence. + """ + + _empty_text = "No posts yet" + + @property + def picocalc_lyra_lane(self): + """The chat's lane, with *Log in* on F1's Shift half (F6), where ^L has no key. + + Every chord the desktop reaches needs a chip on the handheld, and F1's Shift half is + the one the chat leaves free. Dim while a login is already under way. + """ + from .tui.fkeys import FPair + + lane = list(super().picocalc_lyra_lane) + day = lane[0] + lane[0] = FPair( + day.label, + day.action, + "Log in", + "login", + enabled=day.enabled, + opp_enabled=not self._login_open, + ) + return lane + + def __init__( + self, + conversation: Conversation, + messages: list[ChatMessage], + *, + send: Callable[[str], Awaitable[ChatMessage | None]], + 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]], + access: RoomAccess | None = None, + resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None, + paths: Callable[[ChatMessage], Awaitable[None]] | None = None, + key_of: Callable[[str], str | None] | None = None, + ) -> None: + """Build the room view. + + Args: + conversation: The room's conversation (its label titles the screen). + messages: The board as stored, oldest first — posts only (see + :meth:`~meshterm.persistence.repository.Repository.recent_chat_messages`). + send: Posts a line to the room and returns the recorded post. + 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. + 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). + key_of: Maps a name back to its node's key, for ``@mention`` hues. + """ + super().__init__( + conversation, + messages, + send=send, + names=names, + session=session, + resend=resend, + paths=paths, + key_of=key_of, + ) + self._label = conversation.label + self._resolve = resolve + self._password = password + self._ask = ask + self._log_in = log_in + 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_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. + + 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. + """ + if self._login_open: + return + self._login_open = True + self._session.run_detached(self._run_login(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.""" + 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) + finally: + self._login_open = False + self._retitle() + 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.""" + 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 + self._retitle() + + def _retitle(self) -> None: + """Title the board with the room and its one status atom (``Room · member``).""" + atom = self._login_state or (self._access.value if self._access else None) + self.title = f"{self._label} · {atom}" if atom else self._label + + # --- the board ---------------------------------------------------------------------- + + def append(self, message: ChatMessage) -> None: + """Add a post that just arrived — once, however many times the room sends it. + + A room re-sends a post it never heard acknowledged, so the same post can land + twice; and what else a room sends under its key (an admin's command replies) is + not on the board at all. + """ + if not message.is_post: + return + for held in reversed(self._messages): + if ( + held.author == message.author + and held.created_at == message.created_at + and held.text == message.text + ): + return + super().append(message) + + def _sender_and_body(self, message: ChatMessage) -> tuple[str, str]: + """A post's author (by name, or by hash), and its text — ``you`` for our own.""" + if message.outbound: + return "you", message.text + if message.author: + return author_label(message.author, self._resolve)[0], message.text + return self._label, message.text + + def _header_key(self, sender: str, message: ChatMessage) -> str | None: + """The author's own key colours their chip; a hash nothing names stays grey.""" + if message.author: + return author_label(message.author, self._resolve)[1] + 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.""" + if self._composing: + return super()._compose_line(width) + return Text("read-only — this room doesn't keep what you post", style="muted") + + def _submit(self) -> None: + """Post the compose line, unless the room is known to drop our posts.""" + if self._composing: + super()._submit() + + def handle(self, action: str, data: str = "") -> None: + """^L logs in; everything else is the chat's.""" + if action == "login": + self.begin_login(ask=self._login_state in (NO_REPLY, REFUSED, NOT_JOINED)) + self._session.invalidate() + return + super().handle(action, data) + + @property + def footer_hint(self) -> str: + """Key hint: the chat's, in a board's words, with ^L while a login could start.""" + if self._selected is not None: + code = " · ^U QR" if self._picked_urls() else "" + return f"Enter reply (@mention) · ^P paths{code} · ↑↓ pick · ^End/Esc cancel" + atoms = ["Enter send"] if self._composing else [] + atoms.append("↑ pick a message") + if self._retry_target() is not None: + atoms.append("^R retry failed") + if not self._login_open: + atoms.append("^L log in") + atoms.append("Esc back") + return " · ".join(atoms) + + +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. + + Args: + ctx: The shared application context (running the interactive TUI surface). + conversation: The room's conversation (:attr:`~Conversation.is_room`). + + Returns: + The number of posts on the board when the view closed. + """ + from ..services import trace_runner + + session = ctx.ui.session + room = conversation.contact + assert room is not None + device = await ctx.device() + try: + await ctx.chat.start() # record inbound posts, if nothing has started it yet + except Exception: # noqa: BLE001 - the hub may already be running; recording is best-effort + pass + + board = ctx.repo.recent_chat_messages( + is_channel=False, + peer=conversation.peer, + limit=ctx.preferences.chat_history_limit, + posts_only=True, + ) + contacts = await ctx.devstate.contacts() + stored_names = ctx.repo.node_names() + + async def send(text: str) -> ChatMessage | None: + return await _with_restore(ctx, lambda: ctx.chat.send_post(room, text)) + + 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, + ) + + screen = RoomScreen( + conversation, + board, + send=send, + 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), + access=ctx.rooms.access(room), + resend=resend, + paths=await _make_paths_presenter(ctx, conversation, device), + key_of=trace_runner.make_name_key_resolver(contacts, stored_names), + ) + ctx.chat.set_active(conversation.key) + + def on_event(event: MeshEvent) -> None: + message = event.message + if message is not None and message.is_post and _belongs(message, conversation): + screen.append(ChatMessage.from_message(message, peer_name=conversation.label)) + + 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 + # 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() + await visit.result() # the board resolves only when the reader leaves it + finally: + unsubscribe() + ctx.chat.set_active(None) + return len(screen._messages) diff --git a/meshterm/ui/theme.py b/meshterm/ui/theme.py index 4330224..e61c986 100644 --- a/meshterm/ui/theme.py +++ b/meshterm/ui/theme.py @@ -725,6 +725,7 @@ _GLYPH_MAP: dict[str, str] = { "📰": "…", # live feed — a stream of items "🎧": "≈", # monitor — listening to the waveform "🗼": "▲", # repeater admin — the repeater mark itself + "📌": "■", # rooms — the room server mark itself "🔌": "~", # serial port — the cable "📍": "╨", # a radio on the host's SPI bus — the antenna on its board "🔖": "╬", # region/scope — a flood's label; the grid square of an area diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 0585e93..8c4784c 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -80,6 +80,8 @@ from .spinner import spinner_interval #: terminal — prompt_toolkit's raw mode clears ``IXON``/``IXOFF``, so ^Q is not XON here. _CTRL_LETTER_CHORDS: dict[str, str] = { "c": "quit", + # ^L logs in to the room a room view is open on. + "l": "login", "p": "paths", "q": "quit", "r": "retry", diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index e7add69..2777f14 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -371,16 +371,21 @@ def test_contacts_names_every_node_bare_and_ages_every_time(run) -> None: # noq header, *records = result_lines(run("contacts")) assert header.split() == ["NAME", "TYPE", "HEARD", "PKTS", "HASH", "LOCATION", "KEY"] assert records, "the simulator always has contacts" + # Read the lane where its heading stands rather than by counting words: a value may + # hold a space (the simulator's `Lakeside BBS` is a `room server`), and alignment, not + # whitespace, is what delimits the fields. + heard = header.index("HEARD") for line in records: assert not line.startswith('"') - age = line.split()[2] + age = line[heard:].split()[0] assert age in ("now", "never") or age[-1] in "mhdw" # `--absolute` asks for a different *form* of time, not for one fewer fact, so # `never` survives it: a node that has never been heard has no instant to print. - absolute = result_lines(run("--absolute", "contacts"))[1:] + header, *absolute = result_lines(run("--absolute", "contacts")) + heard = header.index("HEARD") for line in absolute: - stamp = line.split()[2] + stamp = line[heard:].split()[0] assert stamp in ("-", "never") or stamp[:4].isdigit() diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 1f21242..c33e6dc 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -528,6 +528,60 @@ def _chat(cols: int, rows: int) -> Screen: ) +def _room(cols: int, rows: int, *, access=None, retry: bool = False) -> Screen: # noqa: ANN001 + """A room's board at its widest: every kind of author, and the title's access atom. + + A post by a contact (named, in its hue), one by an author nothing names (the grey hash), + a notice the room posted itself, and our own — the last unacknowledged when ``retry``, + so ^R joins ^L on the hint, the longest the hint gets. + """ + from meshterm.core.models import LoginResult, RoomAccess, RoomLogin + from meshterm.ui.room import RoomScreen + + room = Contact(name="Lakeside BBS", public_key="f6" * 32, key_prefix="f6" * 6, node_type=3) + peer = room.key_prefix + messages = [ + ChatMessage(text="Anyone driving to the swap meet Saturday?", peer=peer, author="d4e5f6a7"), + ChatMessage( + text="Is the north repeater down? Nothing since 6.", peer=peer, author="e5f6a7b8" + ), + ChatMessage( + text="Reminder: this board keeps the last 32 posts.", peer=peer, author="f6f6f6f6" + ), + ChatMessage(text="I'll check it tonight", outbound=True, peer=peer, acked=not retry), + ] + names = {"d4e5f6a7": "Alice", "f6f6f6f6": room.name} + + async def never(*_): # noqa: ANN002, ANN202 - nothing is pressed in the gallery + return RoomLogin(LoginResult.NO_REPLY) + + return RoomScreen( + Conversation(label=room.name, is_channel=False, contact=room), + messages, + send=never, + names={}, + session=_GallerySession(cols, rows), + resolve=lambda h: names.get(h or "", h), + password=lambda: "hello", + ask=never, + log_in=never, + access=access or RoomAccess.MEMBER, + resend=never, + ) + + +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 + + return _room(cols, rows, access=RoomAccess.READ_ONLY) + + +def _room_retry(cols: int, rows: int) -> Screen: + """An unacknowledged post of ours: ^R retry joins ^L on the hint.""" + return _room(cols, rows, retry=True) + + def _chat_channel_scoped(cols: int, rows: int) -> Screen: """A channel with a send scope: the title's ``· scope`` atom, and a resend under it. @@ -634,7 +688,7 @@ class _PickerRepo: def __init__(self, lasts: dict) -> None: self._lasts = lasts - def last_chat_messages(self) -> dict: + def last_chat_messages(self, rooms=()) -> dict: # noqa: ANN001 - the repository's shape return dict(self._lasts) def node_names(self) -> dict: @@ -654,6 +708,10 @@ def _chat_picker(cols: int, rows: int) -> Screen: Contact(name="Alice", public_key="aa" * 32, key_prefix="aa" * 6, node_type=1), Contact(name="A Rather Long Contact Name", public_key="d4" * 32, key_prefix="d4" * 6), Contact(name="Homestead", public_key="60" * 32, key_prefix="60" * 6, node_type=1), + # A room whose latest post is by an author nothing names (the hash stands in), and + # one never joined, whose preview lane says so. + Contact(name="Lakeside BBS", public_key="f6" * 32, key_prefix="f6" * 6, node_type=3), + Contact(name="Hilltop Swap", public_key="b7" * 32, key_prefix="b7" * 6, node_type=3), ] now = datetime(2026, 7, 12, 14, 30, tzinfo=timezone.utc) lasts = { @@ -670,6 +728,12 @@ def _chat_picker(cols: int, rows: int) -> Screen: peer="aa" * 6, created_at=now, ), + f"dm:{'f6' * 6}": ChatMessage( + text="Is the north repeater down? Nothing since 6, and the solar was fine", + peer="f6" * 6, + author="e5f6a7b8", + created_at=now, + ), } ctx = _PickerCtx(contacts, lasts) items = asyncio.run(ChatTool()._picker_items(ctx)) @@ -802,7 +866,8 @@ class _PickerCtx: def __init__(self, contacts: list[Contact], lasts: dict) -> None: self.devstate = _PickerDevstate(contacts, []) self.repo = _PickerRepo(lasts) - self.chat = _PickerChat({"chan:slot:0": 3, f"dm:{'aa' * 6}": 12}) + self.chat = _PickerChat({"chan:slot:0": 3, f"dm:{'aa' * 6}": 12, f"dm:{'f6' * 6}": 4}) + self.rooms = SimpleNamespace(joined=lambda room: room.name == "Lakeside BBS") def _livefeed(cols: int, rows: int) -> Screen: @@ -1503,6 +1568,9 @@ _ENTRIES: list[_Entry] = [ _Entry("chat_urls", _chat_urls), _Entry("chat_long_url", _chat_long_url), _Entry("chat_picker", _chat_picker), + _Entry("room", _room), + _Entry("room_read_only", _room_read_only), + _Entry("room_retry", _room_retry), _Entry("channels_manager", _channels_manager), _Entry("channel_detail", _channel_detail), _Entry("channel_detail_scoped", _channel_detail_scoped), diff --git a/tests/test_retired.py b/tests/test_retired.py index 2313430..ae18eab 100644 --- a/tests/test_retired.py +++ b/tests/test_retired.py @@ -37,6 +37,7 @@ from meshterm.core.preferences import ( report_dropped, ) from meshterm.core.remote_store import RemoteStore +from meshterm.core.room_store import RoomStore from meshterm.core.settings_store import SettingsStore @@ -76,6 +77,7 @@ _EXPECTED_RECORDS = { "courier_store": {"QueuedMessage"}, "device_store": {"RememberedDevice"}, "remote_store": {"CachedValue", "_RemoteNode"}, + "room_store": {"RoomMembership"}, "watch_store": {"WatchedNode", "Alert", "_State"}, } @@ -165,6 +167,34 @@ def test_the_admin_store_writes_only_known_fields(tmp_path: Path) -> None: assert written["cd" * 32].keys() == {"password", "label", "last_used"} +def test_the_room_store_writes_only_known_fields(tmp_path: Path) -> None: + """A stray field on a membership, or a record with no access, is gone after a write.""" + from meshterm.core.models import LoginResult, RoomAccess, RoomLogin + + path = tmp_path / "rooms.json" + path.write_text( + json.dumps( + { + "cd" * 32: { + "password": "hello", + "access": "member", + "label": "H", + "last_login": "", + "role": "x", + }, + "junk": {"password": "no access"}, + } + ) + ) + store = RoomStore(path) + assert store.password(_NODE) == "hello" + other = Contact(name="Other", public_key="ef" * 32, key_prefix="ef" * 6, node_type=3) + store.record(other, "pw", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER)) + written = json.loads(path.read_text()) + assert set(written) == {"cd" * 32, "ef" * 32} + assert written["cd" * 32].keys() == {"password", "access", "label", "last_login"} + + def test_the_remote_store_writes_only_known_fields(tmp_path: Path) -> None: """Unknown fields on a node or a cached value do not survive, and the spelling holds.""" path = tmp_path / "remote.json" diff --git a/tests/test_rooms.py b/tests/test_rooms.py new file mode 100644 index 0000000..48279b9 --- /dev/null +++ b/tests/test_rooms.py @@ -0,0 +1,800 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Room servers: joining one, reading its board under each post's author, and posting. + +A room server is a bulletin board on a radio. It keeps its members' latest posts and sends +each member the ones they missed — as direct messages from *itself*, each signed with the +first four bytes of its author's key. So the facts these tests pin are the ones a room adds +to a direct exchange: who wrote a post (``author``), what a login let us do +(:class:`~meshterm.core.models.RoomAccess`), when a room needs logging in to again, and +that a room's posts stay a board — deduplicated, kept apart from its command replies, and +raising the unread badge — rather than leaking into anything else. + +Everything runs against the simulator and scratch stores; no hardware. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace + +import pytest +from rich.cells import cell_len + +from meshterm.core.admin_store import AdminStore +from meshterm.core.config import Settings +from meshterm.core.connection import MeshCoreDevice, MockDevice, message_from_event +from meshterm.core.events import MeshEvent +from meshterm.core.models import ( + NODE_TYPE_CHAT, + NODE_TYPE_REPEATER, + NODE_TYPE_ROOM, + ChatMessage, + Contact, + Conversation, + LoginResult, + Message, + RoomAccess, + RoomLogin, + is_direct_messageable, + is_room, +) +from meshterm.core.preferences import Preferences +from meshterm.core.remote_config import get_setting +from meshterm.core.room_store import RoomStore +from meshterm.persistence.repository import Repository +from meshterm.services.chat_service import ChatService +from meshterm.services.event_hub import EventHub +from meshterm.services.rooms import RoomService +from meshterm.ui.room import LOGGING_IN, NO_REPLY, NOT_JOINED, RoomScreen, author_label +from meshterm.ui.tui import fkeys +from tests.conftest import plain +from tests.test_admin_login import _device, _Event, _FakeMeshCore, quick_budget # noqa: F401 + +#: The simulator's room server, exactly as MockDevice lists it. +ROOM = Contact( + name="Lakeside BBS", + public_key="f6a7b8c9" + "0" * 56, + key_prefix="f6a7b8c9", + node_type=NODE_TYPE_ROOM, + route_hops=("a1b2c3d4",), +) +ALICE_KEY = "d4e5f6a7" +#: A node no contact names (the simulator's neighbour that never advertised to us). +STRANGER_KEY = "e5f6a7b8" +NOW = datetime(2026, 10, 4, 12, 0, tzinfo=timezone.utc) + + +def _post(text: str, author: str = ALICE_KEY, *, minutes: int = 0) -> Message: + """One room post as the wire hands it over: from the room, signed by its author.""" + return Message( + text=text, + sender=ROOM.key_prefix, + sender_timestamp=NOW + timedelta(minutes=minutes), + author=author, + ) + + +@pytest.fixture() +def repo(tmp_path: Path) -> Repository: + """A Repository on a scratch database.""" + r = Repository(tmp_path / "rooms.db") + yield r + r.close() + + +# --- the model ------------------------------------------------------------------------ + + +def test_only_a_room_server_is_a_room() -> None: + """A room is its own section — and still not a direct-message recipient.""" + assert is_room(NODE_TYPE_ROOM) + assert not is_room(NODE_TYPE_CHAT) and not is_room(None) and not is_room(NODE_TYPE_REPEATER) + assert not is_direct_messageable(NODE_TYPE_ROOM) + assert ROOM.is_room + assert Conversation(label=ROOM.name, is_channel=False, contact=ROOM).is_room + assert not Conversation(label="#x", is_channel=True, channel_id="c").is_room + + +@pytest.mark.parametrize( + "payload,access", + [ + ({"acl_permissions": 3, "permissions": 1}, RoomAccess.ADMIN), + ({"acl_permissions": 2, "permissions": 0}, RoomAccess.MEMBER), + # Role 1 is the firmware's "read-only", set by hand; the room still keeps its posts. + ({"acl_permissions": 1, "permissions": 0}, RoomAccess.MEMBER), + ({"acl_permissions": 0, "permissions": 2}, RoomAccess.READ_ONLY), + # Only the high bits differ — the role is the low two. + ({"acl_permissions": 0x80 | 2}, RoomAccess.MEMBER), + # A reply from before the permissions byte: only the legacy flag. + ({"permissions": 1}, RoomAccess.ADMIN), + ({"permissions": 2}, RoomAccess.READ_ONLY), + ({"permissions": 0}, RoomAccess.MEMBER), + ({}, RoomAccess.MEMBER), + ], +) +def test_the_access_a_login_reply_grants(payload: dict, access: RoomAccess) -> None: + """Read from the access-list byte where there is one, the legacy flag where not.""" + assert RoomAccess.from_login(payload) is access + + +def test_only_a_read_only_member_cannot_post() -> None: + """The one access a room drops posts from.""" + assert RoomAccess.ADMIN.can_post and RoomAccess.MEMBER.can_post + assert not RoomAccess.READ_ONLY.can_post + + +def test_a_room_login_is_truthy_only_when_accepted() -> None: + """The ``if not login`` idiom keeps LoginResult's meaning.""" + assert RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER) + assert not RoomLogin(LoginResult.NO_REPLY) + assert not RoomLogin(LoginResult.REFUSED) + + +def test_a_post_keeps_its_author_and_the_time_it_was_posted() -> None: + """The room is the sender; the author rides beside it; the time is the post's own.""" + chat = ChatMessage.from_message(_post("swap meet?")) + assert chat.peer == ROOM.key_prefix + assert chat.author == ALICE_KEY + assert chat.is_post + assert chat.created_at == NOW + assert chat.key == f"dm:{ROOM.key_prefix}" # the room's board, keyed like the room + + +# --- the wire ------------------------------------------------------------------------- + + +def _received(txt_type: int, **extra) -> SimpleNamespace: + payload = { + "type": "PRIV", + "pubkey_prefix": "f6a7b8c90000", + "txt_type": txt_type, + "sender_timestamp": int(NOW.timestamp()), + "text": "hello", + **extra, + } + return SimpleNamespace(payload=payload) + + +def test_a_signed_message_names_its_author() -> None: + """The library's ``signature`` is the author's key prefix, not a signature.""" + message = message_from_event(_received(2, signature="E5F6A7B8")) + assert message.author == STRANGER_KEY + assert message.is_post + assert message.sender == "f6a7b8c90000" + + +@pytest.mark.parametrize("txt_type", [0, 1]) +def test_plain_text_and_command_replies_have_no_author(txt_type: int) -> None: + """A room's command reply shares its key with its posts; only a post has an author.""" + message = message_from_event(_received(txt_type, signature="e5f6a7b8")) + assert message.author is None and not message.is_post + + +def test_a_channel_message_never_has_an_author() -> None: + """Authors belong to room posts; a channel names its sender in the text.""" + event = SimpleNamespace( + payload={"type": "CHAN", "channel_idx": 0, "txt_type": 2, "signature": "ab", "text": "x"} + ) + assert message_from_event(event).author is None + + +def test_a_room_login_reads_the_access_from_the_reply(quick_budget) -> None: # noqa: ANN001, F811 + """One login exchange, read for the role the room filed us under.""" + from meshcore import EventType + + answer = _Event( + EventType.LOGIN_SUCCESS, + {"pubkey_prefix": "f6a7b8c90000", "permissions": 2, "acl_permissions": 0}, + ) + mc = _FakeMeshCore(answer=answer) + login = asyncio.run(_device(mc).room_login(ROOM, "anything")) + assert login.result is LoginResult.ACCEPTED + assert login.access is RoomAccess.READ_ONLY + assert mc.sent == [(ROOM.public_key, "anything")] + + +def test_a_room_that_stays_silent_is_no_reply_with_no_access(quick_budget) -> None: # noqa: ANN001, F811 + """A room says nothing to a wrong password — silence, never a refusal.""" + login = asyncio.run(_device(_FakeMeshCore()).room_login(ROOM, "wrong")) + assert login == RoomLogin(LoginResult.NO_REPLY) + + +class _CommandMeshCore: + """A meshcore client stand-in for an admin command: arms, sends, then hears replies. + + ``replies`` are delivered after the command goes out, each through the subscription's + attribute filter exactly as the library's dispatcher applies it. + """ + + def __init__(self, replies: list[dict]) -> None: + self._replies = replies + self._subs: list = [] + self.commands = self + + def subscribe(self, event_type, callback, attribute_filters=None): # noqa: ANN001, ANN201 + sub = SimpleNamespace(callback=callback, filters=attribute_filters or {}) + sub.unsubscribe = lambda: self._subs.remove(sub) if sub in self._subs else None + self._subs.append(sub) + return sub + + async def send_cmd(self, pub, cmd): # noqa: ANN001, ANN201 + for payload in self._replies: + event = SimpleNamespace(payload=payload) + for sub in list(self._subs): + if all(payload.get(k) == v for k, v in sub.filters.items()): + sub.callback(event) + return None + + +def test_an_admin_command_reply_is_not_a_post_or_someone_elses_message() -> None: + """THE trap a room sets: its members' posts arrive down the command reply's channel. + + A post pushed by the room, and a companion's message landing mid-command, both used to + be taken for the node's answer. + """ + room_prefix = ROOM.public_key[:12] + mc = _CommandMeshCore( + [ + {"pubkey_prefix": "d4e5f6a70000", "txt_type": 0, "text": "hi from Alice"}, + {"pubkey_prefix": room_prefix, "txt_type": 2, "text": "a member's post"}, + {"pubkey_prefix": room_prefix, "txt_type": 1, "text": "> hello"}, + ] + ) + device = MeshCoreDevice(port="mock") + device._mc = mc + assert asyncio.run(device._send_admin_cmd(ROOM, "get guest.password", timeout=1)) == "> hello" + + +# --- the room store ------------------------------------------------------------------- + + +@pytest.fixture() +def rooms(tmp_path: Path) -> RoomStore: + """A room store on a scratch file.""" + return RoomStore(tmp_path / "rooms.json") + + +def test_an_accepted_member_login_remembers_the_room_password(rooms: RoomStore) -> None: + """The password that got us in is the one opening the room again uses.""" + rooms.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER)) + assert rooms.password(ROOM) == "hello" + assert rooms.access(ROOM) is RoomAccess.MEMBER + + +def test_an_open_room_remembers_its_empty_password(rooms: RoomStore) -> None: + """``""`` is a password that worked, not an absence.""" + rooms.record(ROOM, "", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER)) + assert rooms.password(ROOM) == "" + + +def test_an_admin_login_remembers_the_access_but_not_the_password(rooms: RoomStore) -> None: + """The admin password lives in one place, where Repeater admin can change it.""" + rooms.record(ROOM, "s3cret", RoomLogin(LoginResult.ACCEPTED, RoomAccess.ADMIN)) + assert rooms.password(ROOM) is None + assert rooms.access(ROOM) is RoomAccess.ADMIN + + +def test_silence_neither_remembers_nor_forgets(rooms: RoomStore) -> None: + """A room is silent for a wrong password, so silence proves nothing either way.""" + rooms.record(ROOM, "typo", RoomLogin(LoginResult.NO_REPLY)) + assert rooms.membership(ROOM) is None + rooms.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER)) + rooms.record(ROOM, "hello", RoomLogin(LoginResult.NO_REPLY)) + assert rooms.password(ROOM) == "hello" + + +def test_a_refusal_forgets_the_room(rooms: RoomStore) -> None: + """The node said no: the password it refused is gone.""" + rooms.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER)) + rooms.record(ROOM, "hello", RoomLogin(LoginResult.REFUSED)) + assert rooms.membership(ROOM) is None + + +# --- the room service ----------------------------------------------------------------- + + +class _Ctx: + """The slice of AppContext the room service and the chat recorder read.""" + + def __init__(self, tmp_path: Path, device: MockDevice, repo: Repository | None = None): + self._device = device + self.repo = repo + self.log = logging.getLogger("test.rooms") + self.profile_name = None + self.settings = Settings() + self.preferences = Preferences() + self.admin_store = AdminStore(tmp_path / "admin.json") + self.room_store = RoomStore(tmp_path / "rooms.json") + self.events = EventHub(self) + self.rooms = RoomService(self) + + async def device(self) -> MockDevice: + await self._device.connect() + return self._device + + +def test_the_room_password_is_tried_before_the_admin_password(tmp_path: Path) -> None: + """A member's password first; the admin password, which also gets us in, after.""" + ctx = _Ctx(tmp_path, MockDevice()) + assert ctx.rooms.password(ROOM) is None and not ctx.rooms.joined(ROOM) + ctx.admin_store.remember(ROOM, "admin") + assert ctx.rooms.password(ROOM) == "admin" + ctx.room_store.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER)) + assert ctx.rooms.password(ROOM) == "hello" + + +async def test_joining_with_the_room_password_makes_us_a_member(tmp_path: Path) -> None: + """The stock room password, the simulator's room, a member.""" + ctx = _Ctx(tmp_path, MockDevice()) + login = await ctx.rooms.login(ROOM, "hello") + assert login.access is RoomAccess.MEMBER + assert ctx.rooms.joined(ROOM) and ctx.rooms.access(ROOM) is RoomAccess.MEMBER + assert ctx.admin_store.get(ROOM) is None # a member password is never an admin one + + +async def test_joining_with_the_admin_password_remembers_it_for_repeater_admin( + tmp_path: Path, +) -> None: + """Typed into a room's join prompt, it works for administering the room too.""" + ctx = _Ctx(tmp_path, MockDevice()) + login = await ctx.rooms.login(ROOM, "admin") + assert login.access is RoomAccess.ADMIN + assert ctx.admin_store.get(ROOM) == "admin" + assert ctx.room_store.password(ROOM) is None + assert ctx.rooms.password(ROOM) == "admin" + + +async def test_a_wrong_password_is_silence_and_is_never_remembered(tmp_path: Path) -> None: + """A password that never worked is never written down.""" + ctx = _Ctx(tmp_path, MockDevice()) + login = await ctx.rooms.login(ROOM, "nope") + assert login.result is LoginResult.NO_REPLY + assert not ctx.rooms.joined(ROOM) + + +async def test_a_room_heard_lately_needs_no_login(tmp_path: Path) -> None: + """A login is a transmission; a room still sending to us needs none.""" + ctx = _Ctx(tmp_path, MockDevice()) + assert not ctx.rooms.login_due(ROOM), "nothing to log in with yet" + ctx.room_store.record(ROOM, "hello", RoomLogin(LoginResult.ACCEPTED, RoomAccess.MEMBER)) + assert ctx.rooms.login_due(ROOM), "joined, but not heard this session" + ctx.rooms.heard("F6A7B8C90000") # a post, its prefix at the wire's width + assert not ctx.rooms.login_due(ROOM) + + +async def test_a_login_counts_as_hearing_the_room(tmp_path: Path) -> None: + """A room that just let us in is sending: opening it again sends nothing.""" + ctx = _Ctx(tmp_path, MockDevice()) + await ctx.rooms.login(ROOM, "hello") + assert not ctx.rooms.login_due(ROOM) + + +# --- the simulator's room ------------------------------------------------------------- + + +async def test_the_simulators_room_sends_its_board_after_a_login(tmp_path: Path) -> None: + """Oldest first, from the room, each under its author — and never our own posts.""" + device = MockDevice() + heard: list[Message] = [] + unsubscribe = await device.subscribe_events( + lambda event: heard.append(event.message) if event.message is not None else None + ) + try: + heard.clear() # the stream's opening burst carries a synthetic DM of its own + assert await device.send_direct_message(ROOM, "before joining") is None + login = await device.room_login(ROOM, "hello") + assert login.access is RoomAccess.MEMBER + while len([m for m in heard if m.is_post]) < 5: + await asyncio.sleep(0.01) + posts = [m for m in heard if m.is_post] + assert all(m.sender == ROOM.key_prefix for m in posts) + assert [m.sender_timestamp for m in posts] == sorted(m.sender_timestamp for m in posts) + assert {m.author for m in posts} == {ALICE_KEY, "c3d4e5f6", STRANGER_KEY, "f6a7b8c9"} + + assert await device.send_direct_message(ROOM, "mine") is not None # kept, acked + heard.clear() + await device.room_login(ROOM, "") # the room knows us; nothing new but our own + await asyncio.sleep(0.2) + assert not [m for m in heard if m.is_post] + finally: + unsubscribe() + await device.disconnect() + + +async def test_a_read_only_member_is_heard_but_not_kept(tmp_path: Path) -> None: + """``allow.read.only`` lets any password in — and drops what it posts.""" + device = MockDevice() + device._remote_config(ROOM)["allow.read.only"] = "on" + login = await device.room_login(ROOM, "anything") + assert login.access is RoomAccess.READ_ONLY + assert await device.send_direct_message(ROOM, "lost") is None + await device.disconnect() + + +async def test_an_admin_posts_in_the_rooms_own_name(tmp_path: Path) -> None: + """``room.post`` adds a notice authored by the room itself.""" + device = MockDevice() + assert await device.admin_login(ROOM, "admin") is LoginResult.ACCEPTED + assert await device.send_remote_command(ROOM, "room.post Meeting at 7") == "OK" + assert device._room_board[-1][0] == "f6a7b8c9" + assert await device.send_remote_command(ROOM, "get guest.password") == "> hello" + await device.disconnect() + + +# --- history and the recorder --------------------------------------------------------- + + +def test_a_rooms_board_leaves_out_its_command_replies(repo: Repository) -> None: + """Posts and our own posts are the board; a reply under the room's key is not.""" + peer = ROOM.key_prefix + repo.record_chat_message(ChatMessage.from_message(_post("first", minutes=1))) + repo.record_chat_message(ChatMessage(text="OK", peer=peer)) # an admin command's reply + repo.record_chat_message(ChatMessage(text="mine", outbound=True, peer=peer, acked=True)) + + board = repo.recent_chat_messages(is_channel=False, peer=peer, posts_only=True) + assert [m.text for m in board] == ["first", "mine"] + assert board[0].author == ALICE_KEY + everything = repo.recent_chat_messages(is_channel=False, peer=peer) + assert [m.text for m in everything] == ["first", "OK", "mine"] + + repo.record_chat_message(ChatMessage(text="OK again", peer=peer)) + assert repo.last_chat_messages()[f"dm:{peer}"].text == "OK again" + assert repo.last_chat_messages(rooms=[peer])[f"dm:{peer}"].text == "mine" + + +def test_a_post_already_stored_is_recognised(repo: Repository) -> None: + """The same room, author, time and text name one post.""" + post = ChatMessage.from_message(_post("swap meet?")) + assert not repo.has_room_post(post) + repo.record_chat_message(post) + assert repo.has_room_post(ChatMessage.from_message(_post("swap meet?"))) + assert not repo.has_room_post(ChatMessage.from_message(_post("swap meet?", minutes=1))) + assert not repo.has_room_post(ChatMessage.from_message(_post("swap meet?", STRANGER_KEY))) + + +class _GatedCtx(_Ctx): + """With the device-state cache, so the badge rule can place a sender.""" + + def __init__(self, tmp_path: Path, device: MockDevice, repo: Repository) -> None: + super().__init__(tmp_path, device, repo) + from meshterm.services.device_state import DeviceState + + self.devstate = DeviceState(self) + + +async def _record(chat: ChatService, ctx, message: Message) -> None: # noqa: ANN001 + ctx.events.publish(MeshEvent.message_event(message)) + await chat._queue.join() + + +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) + chat = ChatService(ctx) + await chat.start() + try: + await _record(chat, ctx, _post("swap meet?")) + await _record(chat, ctx, _post("swap meet?")) + key = f"dm:{ROOM.key_prefix}" + assert chat.unread(key) == 1 + board = repo.recent_chat_messages(is_channel=False, peer=ROOM.key_prefix, posts_only=True) + assert [(m.text, m.author) for m in board] == [("swap meet?", ALICE_KEY)] + assert ctx.rooms.heard_recently(ROOM) + finally: + await chat.stop() + + +async def test_a_rooms_command_reply_is_recorded_but_silent( + tmp_path: Path, repo: Repository +) -> None: + """The board is listed; what else a room sends has no row to point the badge at.""" + ctx = _GatedCtx(tmp_path, MockDevice(), repo) + chat = ChatService(ctx) + await chat.start() + try: + await _record(chat, ctx, Message(text="> hello", sender=ROOM.key_prefix)) + assert chat.unread(f"dm:{ROOM.key_prefix}") == 0 + assert [m.text for m in repo.recent_chat_messages(is_channel=False, peer=ROOM.key_prefix)] + finally: + await chat.stop() + + +async def test_posting_records_the_post_under_the_room(tmp_path: Path, repo: Repository) -> None: + """Acknowledged means the room kept it; a read-only member's post is not.""" + device = MockDevice() + ctx = _Ctx(tmp_path, device, repo) + chat = ChatService(ctx) + await device.room_login(ROOM, "hello") + kept = await chat.send_post(ROOM, "count me in") + assert kept.acked is True and kept.outbound and kept.peer == ROOM.key_prefix + + device._room_access[device._mock_key(ROOM)] = RoomAccess.READ_ONLY + dropped = await chat.send_post(ROOM, "and me") + assert dropped.acked is False + board = repo.recent_chat_messages(is_channel=False, peer=ROOM.key_prefix, posts_only=True) + assert [m.text for m in board] == ["count me in", "and me"] + + +async def test_a_post_is_never_soft_retried(tmp_path: Path, repo: Repository) -> None: + """A retry is a new post, which every member would receive twice.""" + device = MockDevice() + ctx = _Ctx(tmp_path, device, repo) + ctx.preferences.direct_message_soft_retries = 2 + sends: list[str] = [] + original = device.send_direct_message + + async def counting(contact, text): # noqa: ANN001, ANN202 + sends.append(text) + return await original(contact, text) + + device.send_direct_message = counting + await ChatService(ctx).send_post(ROOM, "unacked") # not a member: no ack + assert sends == ["unacked"] + + +# --- the repeater-admin page in a room server's words -------------------------------- + + +def test_a_room_servers_guest_password_is_its_room_password() -> None: + """Same key on both firmwares; a room's page calls it what it is there.""" + spec = get_setting("guest.password") + assert spec.for_node(NODE_TYPE_ROOM).label == "Room password" + assert spec.for_node(NODE_TYPE_REPEATER).label == "Guest password" + assert spec.for_node(NODE_TYPE_ROOM).key == "guest.password" + plain_spec = get_setting("txdelay") + assert plain_spec.for_node(NODE_TYPE_ROOM) is plain_spec + + +# --- the room view -------------------------------------------------------------------- + + +class _Session: + def __init__(self) -> None: + self.invalidations = 0 + + def invalidate(self) -> None: + self.invalidations += 1 + + def run_detached(self, work): # noqa: ANN001, ANN201 + return asyncio.ensure_future(work) + + +def _resolve(hash_: str | None) -> str | None: + """Alice's key names her and the room's names the room; nothing names the stranger.""" + return {ALICE_KEY: "Alice", "f6a7b8c9": ROOM.name}.get(hash_ or "", hash_) + + +def _view( + messages: list[ChatMessage] | None = None, + *, + access: RoomAccess | None = RoomAccess.MEMBER, + password: str | None = "hello", + ask_answer: str | None = "hello", + 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": []} + + async def ask() -> str | None: + calls["asked"] += 1 + return ask_answer + + async def log_in(pw: str) -> RoomLogin: + calls["logins"].append(pw) + 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) + + async def send(text: str) -> ChatMessage: + calls["sent"].append(text) + return ChatMessage(text=text, outbound=True, peer=ROOM.key_prefix, acked=True) + + screen = RoomScreen( + Conversation(label=ROOM.name, is_channel=False, contact=ROOM), + messages or [], + send=send, + names={}, + session=_Session(), + resolve=_resolve, + password=lambda: password, + ask=ask, + log_in=log_in, + access=access, + ) + return screen, calls + + +def _board() -> list[ChatMessage]: + return [ + ChatMessage.from_message(_post("Anyone driving Saturday?", minutes=-30)), + ChatMessage.from_message(_post("Is the north repeater down?", STRANGER_KEY, minutes=-20)), + ChatMessage.from_message(_post("Board keeps 32 posts.", "f6a7b8c9", minutes=-10)), + ChatMessage(text="I'll check it", outbound=True, peer=ROOM.key_prefix, acked=True), + ] + + +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) + assert author_label(STRANGER_KEY, _resolve) == (STRANGER_KEY, None) + + +def test_the_board_draws_each_post_under_its_author() -> None: + """A contact, a stranger, the room's own notice, and us — each under its own chip.""" + screen, _ = _view(_board()) + text = plain("\n".join(screen.render_body(72))) + for expected in ("Alice", STRANGER_KEY, ROOM.name, "Anyone driving Saturday?", "I'll check it"): + assert expected in text + assert screen.title == f"{ROOM.name} · member" + + +def test_a_post_that_arrives_twice_is_shown_once_and_a_reply_not_at_all() -> None: + """The live view applies the same two rules the recorder does.""" + screen, _ = _view(_board()) + screen.append(ChatMessage.from_message(_post("Board keeps 32 posts.", "f6a7b8c9", minutes=-10))) + screen.append(ChatMessage(text="> hello", peer=ROOM.key_prefix)) # a command reply + assert len(screen._messages) == 4 + screen.append(ChatMessage.from_message(_post("New one", minutes=1))) + 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() + for ch in "hi": + screen.handle("text", ch) + screen.handle("enter") + while screen._sending: + await asyncio.sleep(0) + assert calls["sent"] == ["hi"] + + +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()) + for _ in range(3): # ours, the room's notice, then the stranger's post + screen.handle("up") + screen.handle("enter") + 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_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) + + +def test_the_hint_names_log_in_and_fits() -> None: + """^L is named where it acts, inside 72 cells, picked or not.""" + screen, _ = _view(_board()) + assert "^L log in" in screen.footer_hint + assert cell_len(screen.footer_hint) <= 72 + screen.handle("up") + assert cell_len(screen.footer_hint) <= 72 + + +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" + + +# --- 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.""" + 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.surface import TuiUi + from meshterm.ui.tui.session import TuiSession + + settings = Settings(config_dir=tmp_path, db_path=tmp_path / "e2e.db") + ctx = AppContext( + console=Console(), + settings=settings, + repo=Repository(settings.db_path), + device_store=DeviceStore(tmp_path / "devices.json"), + 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 + ) + + 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 + inp.send_text("hello\r") + while len([m for m in board() if m.is_post]) < 5: + await asyncio.sleep(0.01) + inp.send_text("count me in\r") + while not [m for m in board() if m.outbound]: + await asyncio.sleep(0.01) + inp.send_text("\x1b") + + async def main() -> None: + driver = asyncio.ensure_future(drive()) + try: + await open_chat(ctx, conv) + finally: + driver.cancel() + + await asyncio.wait_for(session.run(main()), timeout=10) + + posts = board() + assert len([m for m in posts if m.is_post]) == 5 + mine = [m for m in posts 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() diff --git a/tests/test_send_scope.py b/tests/test_send_scope.py index c6bc1d3..05d47ff 100644 --- a/tests/test_send_scope.py +++ b/tests/test_send_scope.py @@ -337,7 +337,7 @@ def test_a_v16_database_gains_the_scope_column(tmp_path: Path) -> None: assert "scope" in cols assert conn.execute("SELECT scope FROM messages").fetchone()["scope"] is None version = conn.execute("SELECT value FROM schema_meta WHERE key = 'version'").fetchone() - assert version["value"] == "17" + assert int(version["value"]) >= 17 conn.close()