Files
Jean-Pierre MartineauandClaude Opus 5.5 5fc224476c Docstrings and comments of meshterm/services are in ASD-STE100
The same rewrite as for the core package. Three comments now agree with
the code: the activity bucket of the header is one minute
(ACTIVITY_BUCKET_S), the RX log holds packets, and a reference to a
module note that does not exist is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:48:44 -04:00

243 lines
12 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The room service: join room servers, and know when to log in to a room server again.
A room server is a message board on a radio. It keeps its most recent posts. After a member
logs in, the room server sends each post that the member did not get yet, one at a time,
and waits for the acknowledgement before it sends the next. Then it sends each new post when
it is written. All that *arrives* from a room is an ordinary inbound message, and the
:class:`~meshterm.services.chat_service.ChatService` stores it as any other message. This
service owns the other half, the part that *asks*: the login, the passwords that it uses,
and the access that it got.
The session state is small, and this is intentional. A login is one exchange on the air. A
room must have a login only when it may have stopped sending: after it forgot us (a restart
removes all members except its admins), or after it stopped sending to us (three posts with
no acknowledgement). Thus the service remembers when each room was last *heard*: a login
that it accepted, or a post that it sent. When the user opens a room again that was heard
within ``preferences.room_relogin_minutes``, the service does not log in. No code here ever
transmits by itself: a login occurs because the user opened a room or asked for one, never
in the background.
State for the session, on the :class:`~meshterm.context.AppContext` (``ctx.rooms``).
"""
from __future__ import annotations
import asyncio
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 that each room gave at a login *in this session*, indexed by room peer.
self._access: dict[str, RoomAccess] = {}
#: The time at which each room was last heard in this session (a login or a post),
#: on the monotonic clock, indexed by room peer.
self._heard: dict[str, float] = {}
#: The login in progress to each room, and the password that it carries, indexed by
#: peer.
self._inflight: dict[str, tuple[asyncio.Future, str]] = {}
@staticmethod
def _peer(room: Contact) -> str:
"""The key prefix of the room, as its conversation stores it (``Conversation.peer``)."""
return (room.key_prefix or room.public_key[:12] or "").lower()
def password(self, room: Contact) -> str | None:
"""The password with which to log in to ``room``, or ``None`` when we have none.
First, the admin password of the room, when Repeater admin (or a join) remembers
one: it gives us all that the room password gives, and more. The order is
important. A login to a room *replaces* the role of a known member with the role
that the password gives. Thus an owner who logged in with the room password
becomes a member until their next admin login. Then, the room password. ``""`` is
a password (a blank login that the room accepted).
"""
admin = self._ctx.admin_store.get(room)
if admin:
return admin
return self._ctx.room_store.password(room) if self._ctx.room_store else None
def joined(self, room: Contact) -> bool:
"""True if we joined ``room``: a login to it was accepted and not forgotten after that.
The Rooms page joins rooms, and a join puts a room in Chat, as a channel is in Chat
when it is on a slot. An admin password that Repeater admin remembers is not a
join. It only lets a join occur without a question.
"""
store = self._ctx.room_store
return store is not None and store.membership(room) is not None
def worked_before(self, room: Contact, password: str) -> bool:
"""True if ``password`` let us into ``room`` before.
This is the only evidence about the password that a silent room gives. A password
that worked the last time is still correct, unless the owner changed it. Thus the
silence then shows that the room does not hear us. It does not show a wrong
password.
"""
if self._ctx.admin_store.get(room) == password and password:
return True
store = self._ctx.room_store
return store is not None and self.joined(room) and store.password(room) == password
def forget(self, room: Contact) -> None:
"""Leave ``room``, as much as MeshTerm can: stop the logins, and forget its password.
The room continues to send to our node until it restarts or stops sending to us,
because a member cannot resign. Its posts that are already stored here are kept.
An admin password that Repeater admin remembers for the room belongs to Repeater
admin, and stays.
"""
if self._ctx.room_store is not None:
self._ctx.room_store.forget(room)
peer = self._peer(room)
self._access.pop(peer, None)
self._heard.pop(peer, None)
def access(self, room: Contact) -> RoomAccess | None:
"""What ``room`` lets us do: as given in 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 sent us a post a moment ago, thus it still sends to us.
The chat recorder calls this method for each room post that it stores.
Args:
peer: The key prefix of the room, as the sender of the post carried it.
"""
if peer:
self._heard[peer.lower()] = time.monotonic()
def heard_recently(self, room: Contact) -> bool:
"""True if ``room`` was heard within ``room_relogin_minutes``: then no login is necessary.
A post that carries the prefix of the room at any width counts, because the prefix
on the wire and the prefix in the contact table can have different lengths.
"""
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:
"""True if the open of ``room`` must log in: joined, with a password, and quiet.
Never while a login to the room is already in progress: the answer to that login is
also the answer to this one.
"""
return (
self.joined(room)
and self.password(room) is not None
and not self.heard_recently(room)
and self._peer(room) not in self._inflight
)
async def login(self, room: Contact, password: str, *, flood: bool = False) -> RoomLogin:
"""Log in to ``room`` with ``password`` (one exchange), and remember the result.
The service remembers an accepted login in the same places where it keeps each
password. The access and the room password go in the room store
(:meth:`~meshterm.core.room_store.RoomStore.record` says what each type of
acceptance proves). An admin password that the user typed goes in the admin store,
which Repeater admin shares. From then on, Repeater admin logs in with it silently.
If the node *refused* a login, the service forgets the password that was tried. If
a login got no answer, the service keeps everything, because a room also stays
silent for a wrong password, and only a password that worked one time is ever
stored.
Only one login to a room at a time, from any caller. A login can use most of its
reply budget. Without this rule, a room screen that the user closes and opens again
during that time sends a second login. The room answers both logins with a new
start of its catch-up. A caller that asks with the password that is already in
progress shares the answer of that login. A caller with a different password waits
until that login finishes, then sends its own.
Args:
room: The room server to log in to.
password: The password to give (``""`` asks the room if it knows us).
flood: First, forget the route to the room that the device learned, so that the
login floods the full mesh. This is the way around a route that is no
longer valid, which is silent exactly as a wrong password is. To forget the
route is a local action. The login is still the one transmission.
Returns:
How the login ended, the access given, and how the device sent it.
Raises:
Exception: A device error goes up to the caller (no connection, or a contact
that the companion cannot address). Then nothing is remembered.
"""
peer = self._peer(room)
running = self._inflight.get(peer)
if running is not None:
task, sent = running
if sent == password and not flood:
return await asyncio.shield(task)
try:
await asyncio.shield(task)
except Exception: # noqa: BLE001 - its own caller gets the failure
pass
task = asyncio.ensure_future(self._login_once(room, password, flood=flood))
self._inflight[peer] = (task, password)
# Cleared when the exchange ends, not when this caller stops waiting. If the user
# closes a room screen during the login, the login continues, and it is still the
# login in progress.
task.add_done_callback(lambda done: self._settle(peer, done))
return await asyncio.shield(task)
def _settle(self, peer: str, task: asyncio.Future) -> None:
"""Forget a finished login as the login in progress.
Also get its failure, which no caller awaits.
"""
if self._inflight.get(peer, (None, None))[0] is task:
del self._inflight[peer]
if not task.cancelled():
task.exception()
async def _login_once(self, room: Contact, password: str, *, flood: bool) -> RoomLogin:
"""One login exchange, and what its result means for the stores and the session."""
device = await self._ctx.device()
if flood:
await device.reset_route(room)
# The route in the contact cache is not valid now. The next read gets it again
# (with the route that the device learns from the answer to this login).
devstate = getattr(self._ctx, "devstate", None)
if devstate is not None:
devstate.invalidate_contacts()
login = await device.room_login(room, password)
if self._ctx.room_store is not None:
self._ctx.room_store.record(room, password, login)
if login.access is RoomAccess.ADMIN and password:
# A blank login proves that the room knows us. It does not prove the admin password.
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