Chat joins room servers, and reads each post under its author
A room server is a message board on a radio: it keeps its members' last posts and sends each member the ones they missed. MeshTerm could see rooms and run your own, but not join one. Posts that reached the radio were filed as direct messages from the room, with their authors thrown away. Chat now has a Rooms section between Channels and Direct. Opening a room shows its board at once, each post under the member who wrote it. A post carries the first four bytes of its author's key, so it is named by any contact or overheard advert with that key and coloured by the key itself. An author nothing names stands as the hash, in the unknown-node grey. The first visit asks for the room's password over the board. After that, opening a room logs in again only once it has been quiet for room_relogin_minutes (a new preference, 30 by default), and ^L (F6 on the handhelds) logs in on demand. The title says what the room allowed: member, admin or read-only. A read-only member gets a line saying the room drops their posts, in place of a compose box. A post goes out once, never soft-retried, since a duplicate would reach every member. Underneath: messages carry an author (schema 18), a room's board leaves out its command replies, and a re-sent post is recorded once. Room posts raise the unread badge and a room's command replies don't. The device gains room_login, sharing admin_login's exchange and reading the access the room granted. Room passwords are remembered in rooms.json; an admin login is kept in admin.json, shared with Repeater admin. The simulator has a room, Lakeside BBS, to join with "hello". Two fixes the research turned up. An admin command took the first direct message from anyone as its reply, so a room post or a companion's message landing mid-command was read as the node's answer. It now listens before sending, and only for a reply from that node that is not a post. And Repeater admin names a room server's guest password "Room password", the password members join with. Refs: #24 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ff5315ffcf
commit
1ceda7e22f
21 files changed
+2406
-89
No files matched your search
@@ -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 ``<config_dir>/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 ``<config_dir>/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.
|
||||
|
||||
+260
-19
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+139
-6
@@ -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`)."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
(``<config_dir>/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)
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
+117
-23
@@ -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
|
||||
|
||||
+45
-17
@@ -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()
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
+70
-2
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user