Files
MeshTerm/meshterm/ui/courier_screen.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

592 lines
25 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The Courier screens: the outbox, queueing flow, and per-message actions.
The interactive face of the ``courier`` tool. One select-list screen carries the whole
feature (the persistent-backdrop pattern): the waiting outbox — each entry with its
live state (waiting to hear the contact, scheduled for a time, backing off between
retries) — the finished history (delivered / given-up), and the queueing flow: pick a
contact, write the message, choose when. Enter on a waiting entry offers *Send now*
(one forced attempt, outcome in a dialog) and *Cancel*.
The outbox is **live** while it sits open: every row is a callable title recomputed on
each repaint (so "retry in ~N m" counts down for real), and a once-a-second ticker
compares the store's shape — entry set and statuses — rebuilding the sections in place
when something moved (a delivery lands its row in *Finished* within a second, no
keypress needed), keeping the highlight on its entry.
Delivery itself happens in the background service (:mod:`meshterm.services.courier`),
which the menu starts with the other always-on services — this screen never needs to
stay open for a queued message to go out.
"""
from __future__ import annotations
import asyncio
import re
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any
from rich.cells import cell_len
from rich.text import Text
from ..core.courier_store import DELIVERED, QUEUED, QueuedMessage
from ..core.models import Contact, is_direct_messageable, utcnow
from .contactlist import SORT_COLUMNS, SORT_OPENS_ASCENDING, ContactListScreen, ContactRow
from .menus import icon_lane, marked_label, run_steps, section_heading
from .tui import CANCEL, DM_BYTE_LIMIT, Choice, SelectScreen, Separator
from .watchtower_screen import contact_watch_key
from .widgets import ContactsSort, age_seconds, contact_packets, format_ago
if TYPE_CHECKING:
from ..context import AppContext
# Menu action sentinels (tuples so they never collide with entry ids).
_QUEUE = ("queue",)
_CLEAR = ("clear",)
#: The status marks an outbox entry leads with: waiting, delivered, gave up. They are the
#: row's outcome rather than decoration, so unlike a command row's icon they are drawn on
#: every platform — and measured raw, since the PicoCalc's render fold pads a folded mark
#: back out to the width the emoji measured.
_ENTRY_MARKS = ("⏳", "✓", "✗")
#: The widest entry mark in cells: the part of the outbox's icon column no platform drops.
_MARK_LANE = max(cell_len(mark) for mark in _ENTRY_MARKS)
#: The outbox's decorative command icons — queue a message, clear the finished history.
_COMMAND_ICONS = ("📨", "🗑")
#: Seconds between the open outbox's refresh ticks (shape check + repaint).
_REFRESH_S = 1.0
#: The widest a message body renders in a row before it is ellipsized.
_TEXT_W = 36
def _shorten(text: str, width: int = _TEXT_W) -> str:
"""The message body shortened for row display."""
return text if len(text) <= width else text[: width - 1] + "…"
def _local_stamp(when: datetime) -> str:
"""A compact local timestamp: ``Jul 12 07:00``."""
return when.astimezone().strftime("%b %d %H:%M")
def parse_clock(text: str, now: datetime | None = None) -> datetime | None:
"""Parse a local ``HH:MM`` into its *next* occurrence, as aware UTC.
``07:00`` typed at 23:40 means tomorrow morning; typed at 06:00 it means an hour
from now. Returns ``None`` for anything that isn't a plausible clock time.
Args:
text: The typed time.
now: The reference time (defaults to the current time; tests inject).
Returns:
The next occurrence as an aware UTC datetime, or ``None``.
"""
match = re.fullmatch(r"\s*(\d{1,2})[:h](\d{2})\s*", text)
if not match:
return None
hour, minute = int(match.group(1)), int(match.group(2))
if hour > 23 or minute > 59:
return None
now = now or utcnow()
local = now.astimezone()
candidate = local.replace(hour=hour, minute=minute, second=0, microsecond=0)
if candidate <= local:
candidate += timedelta(days=1)
return candidate.astimezone(timezone.utc)
async def open_courier(ctx: AppContext) -> dict[str, Any] | None:
"""Run the Courier screen until dismissed.
Args:
ctx: The shared application context (must be running the interactive TUI).
Returns:
A summary of what happened (for the tool's log), or ``None`` on plain exit.
Raises:
RuntimeError: If called outside the interactive menu (no full-screen session).
"""
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("the courier is only available in the menu")
session = ctx.ui.session
await ctx.courier.start() # idempotent; normally already running
contacts: list[Contact] = []
try:
if ctx.is_connected or ctx.settings.connect_on_start:
contacts = await ctx.devstate.contacts()
except Exception: # noqa: BLE001 - the outbox renders fine without contacts
contacts = []
store = ctx.courier_store
# One screen for the whole visit, refreshed in place: the outbox already knows how to
# recompose its sections around the highlight (:meth:`CourierOutboxScreen.refresh`), which
# is what the once-a-second ticker calls while the list is open. Driving it through a
# visit means every sub-flow — queueing a message, an entry's actions, the clear confirm —
# floats over the list and lands back on the row it was opened from, and the ticker is
# started once rather than per round.
menu = CourierOutboxScreen(ctx)
async def tick() -> None:
"""Fold store changes in and repaint, once a second, while the list is open."""
while True:
await asyncio.sleep(_REFRESH_S)
menu.refresh()
session.invalidate()
async with session.stay(menu) as visit:
ticker = asyncio.ensure_future(tick())
try:
while True:
choice = await visit.result()
if choice in (None, CANCEL):
return {"queued": store.pending_count()}
if choice == _QUEUE:
await _queue_flow(ctx, contacts)
elif choice == _CLEAR:
done = store.done_count()
if await ctx.ui.dialog(
f"Clear {done} finished "
f"{'entry' if done == 1 else 'entries'} from the history?",
[("Cancel", False), ("Clear", True)],
title="Clear finished",
default=1,
destructive=True,
):
store.clear_done()
elif isinstance(choice, tuple) and choice[0] == "msg":
await _entry_actions(ctx, int(choice[1]))
# Fold the flow's effect in straight away rather than waiting for a tick,
# so the list the reader lands back on already shows what they just did.
menu.refresh()
finally:
ticker.cancel()
try:
await ticker
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - teardown must never surface a tick hiccup
pass
# --- the menu ---------------------------------------------------------------------------
class CourierOutboxScreen(SelectScreen):
"""The live outbox list: row text recomputes per repaint, sections per tick.
Rows are callable titles (see :attr:`~meshterm.ui.tui.select.Choice.title`), so
every repaint re-reads each entry's live state — the retry countdown, the fresh/
waiting note, a finished row's age. Structure changes (an entry moving waiting →
finished, a new queue, a cleared history) can't be expressed by a row re-rendering
itself, so the opener's ticker calls :meth:`refresh`: it fingerprints the store's
shape and recomposes the sections in place only when that changed, keeping the
highlight on its entry (the :class:`~meshterm.ui.contactlist.ContactListScreen` rebuild
idiom).
"""
def __init__(self, ctx: AppContext, *, default: Any = None) -> None:
"""Open the outbox on the store's current entries, remembering their shape.
The remembered shape is what :meth:`refresh` compares against, so a tick that
changed nothing leaves the highlight exactly where the reader put it.
"""
self._ctx = ctx
self._shape = self._fingerprint()
super().__init__(
"Courier — store-and-forward outbox",
_menu_items(ctx, ctx.courier_store.entries()),
default=default,
footer_hint="↑↓ move · Enter select · Esc back",
)
def _fingerprint(self) -> tuple:
"""The store's shape: which entries exist and what status each is in."""
return tuple((m.ident, m.status) for m in self._ctx.courier_store.entries())
def refresh(self) -> None:
"""Recompose the sections if the store's shape changed, keeping the highlight."""
shape = self._fingerprint()
if shape == self._shape:
return
self._shape = shape
current = self._current_choice()
keep = current.value if current is not None else None
self._items = _menu_items(self._ctx, self._ctx.courier_store.entries())
self._reselect(keep)
def _reselect(self, value: Any) -> None:
"""Move the highlight back onto the choice with ``value`` (else clamp in range)."""
choices = self._choices()
for i, choice in enumerate(choices):
if choice.value == value:
self._index = i
return
self._index = max(0, min(self._index, len(choices) - 1)) if choices else 0
def _menu_items(ctx: AppContext, entries: list[QueuedMessage]) -> list:
"""Build the screen's rows: the waiting outbox, then the finished history.
Entry rows are zero-arg callables so their live state re-renders every repaint;
the fixed action rows stay plain strings.
"""
waiting = [m for m in entries if m.status == QUEUED]
done = [m for m in entries if m.status != QUEUED]
# One icon column for the whole list, across both sections. The terminal draws ⏳ and
# 📨 in two cells but ✓ ✗ 🗑 in one, so rows written ``mark + " "`` started a finished
# entry's recipient a column left of a waiting one's, and *Clear finished* a column left
# of *Queue a message…*. The entry marks always keep their share of the column; the
# command icons go where the platform draws no icon lane, and marked_label leaves those
# two labels bare rather than padded out to a mark that isn't there.
lane = max(icon_lane(_COMMAND_ICONS), _MARK_LANE)
items: list = [section_heading("Outbox")]
if not waiting:
items.append(Separator(" empty — queued messages wait here for their moment"))
for message in waiting:
items.append(Choice(lambda m=message: _waiting_row(ctx, m, lane), ("msg", message.ident)))
items.append(Separator(" ")) # space the action off the outbox rows above it
items.append(Choice(marked_label("📨", "Queue a message…", "", lane=lane), _QUEUE))
if done:
items.append(Separator(" "))
items.append(section_heading("Finished"))
for message in done[:15]:
items.append(Choice(lambda m=message: _done_row(m, lane), ("msg", message.ident)))
items.append(Choice(marked_label("🗑", "Clear finished", "", lane=lane), _CLEAR))
return items
def _entry_mark(mark: str, style: str, lane: int) -> Text:
"""An entry's status mark, tinted and padded out to the outbox's icon column.
The entry-row twin of :func:`~meshterm.ui.menus.icon_mark`, which cannot serve here:
that one drops its icon where the platform draws no icon lane, and an entry's mark is
its outcome (waiting, delivered, gave up), which every platform must still show.
Args:
mark: One of :data:`_ENTRY_MARKS`.
style: The theme style the mark is drawn in.
lane: The list's icon column in cells (see :func:`_menu_items`).
Returns:
The mark followed by enough spaces to start the words in the column after it.
"""
text = Text(mark, style=style)
text.append(" " * (lane - cell_len(mark) + 1))
return text
def _waiting_row(ctx: AppContext, message: QueuedMessage, lane: int = _MARK_LANE) -> Text:
"""One waiting entry: recipient, body, and what it is waiting for."""
row = _entry_mark("⏳", "warn", lane)
row.append(message.node_name)
row.append(f" “{_shorten(message.text)}”", style="muted")
row.append(" · ", style="muted")
now = utcnow()
if message.not_before is not None and now < message.not_before:
row.append(f"scheduled {_local_stamp(message.not_before)}", style="brand")
else:
retry = ctx.courier.next_retry_s(message, now)
if retry is not None:
row.append(
f"try {message.attempts} · retry in ~{max(1, round(retry / 60))} m",
style="warn",
)
elif message.attempts > 0:
row.append(f"try {message.attempts} · waiting to hear it again", style="muted")
elif ctx.courier.heard_recently(message.node_key, now):
row.append("contact is fresh — next pass", style="ok")
else:
row.append("waiting to hear the contact", style="muted")
return row
def _done_row(message: QueuedMessage, lane: int = _MARK_LANE) -> Text:
"""One finished entry: outcome marker, recipient, body, and when it settled."""
if message.status == DELIVERED:
row = _entry_mark("✓", "ok", lane)
else:
row = _entry_mark("✗", "err", lane)
row.append(message.node_name, style="muted")
row.append(f" “{_shorten(message.text)}”", style="muted")
when = message.finished or message.created
verb = "delivered" if message.status == DELIVERED else "gave up"
row.append(
f" · {verb} {format_ago(age_seconds(when))}"
f" · {message.attempts} attempt{'s' if message.attempts != 1 else ''}",
style="muted",
)
return row
# --- the flows --------------------------------------------------------------------------
#: The recipient picker's footer: the shared contact-list grammar with a committing Enter,
#: Esc cancelling the queueing step it sits in.
_PICK_HINT = "↑↓ move · ^←→↑↓ sort · type to filter · Enter select · Esc cancel"
class CourierRecipientScreen(ContactListScreen):
"""The recipient picker on the shared contact list.
The full ``NAME · HEARD · PKTS · KEY`` lanes, the Ctrl+arrow sort ring, and
type-to-filter, exactly as the Contacts screen and the Time Machine picker draw
contacts — names in their key-derived hue, heard ages in recency heat. Unlike the
Contacts screen, Enter *commits*: the shared list's Enter resolves the highlighted
row's value, which is the :class:`~meshterm.core.models.Contact` itself.
Only companion contacts are ever passed in — a courier message is a direct message,
and direct messages go to companions only (see
:func:`~meshterm.core.models.is_direct_messageable`); the caller filters before
building the picker.
"""
def __init__(
self,
*,
contacts: list[Contact],
prefix_bytes: int,
counts: dict[str, int],
sort: ContactsSort,
) -> None:
"""Build the picker over the device's companion contacts.
Args:
contacts: The candidate recipients — companion contacts only (the caller
filters non-companions out).
prefix_bytes: The hash width in bytes to light at the head of each key.
counts: Overheard-packet tallies keyed by lowercased 12-hex node id.
sort: The sort state (defaults open on ``name``, A→Z).
"""
rows = [
ContactRow(
value=c,
name=c.name,
key=c.public_key or c.key_prefix or "",
node_type=c.node_type,
last_seen=c.last_seen,
count=contact_packets(c, counts),
)
for c in contacts
]
super().__init__(
"Courier — recipient",
rows=rows,
prefix_bytes=prefix_bytes,
sort=sort,
prompt="The message waits in the outbox until this contact can take it:",
footer_hint=_PICK_HINT,
)
async def _queue_flow(ctx: AppContext, contacts: list[Contact]) -> None:
"""Float the queueing flow: recipient, message, schedule — as a stack.
Three prompts, so Esc means "back one step", not "throw the whole thing away": from the
schedule to the message with what was written still in the field, from the message to
the recipient list (which stays pushed the whole time, so it is still on the row the
message was being written to), and from there out to the outbox. Nothing typed is lost
to a single keypress — see :func:`~meshterm.ui.menus.run_steps`.
"""
session = ctx.ui.session
# A courier message is a direct message, so only companions can receive one — a
# repeater, room, or sensor is never a recipient (the app-wide DM rule, see
# is_direct_messageable). Filter before the picker so non-companions never appear.
companions = [c for c in contacts if is_direct_messageable(c.node_type)]
if not companions:
await session.message_dialog(
Text(
"No companion contacts available — connect a device that knows a "
"companion to message first.",
style="muted",
),
title="Queue a message",
)
return
# The shared contact-list presentation (see CourierRecipientScreen), opened A→Z by
# name — the default of a re-sortable list, with heard/packets/key a Ctrl+arrow away.
counts = {n.node: n.count for n in ctx.repo.heard_nodes() if n.node}
prefix_bytes = await ctx.devstate.routing_prefix_bytes()
picker = CourierRecipientScreen(
contacts=companions,
prefix_bytes=prefix_bytes,
counts=counts,
sort=ContactsSort.from_name("name", SORT_COLUMNS, SORT_OPENS_ASCENDING),
)
async with session.stay(picker) as visit:
answers = await run_steps(
[
lambda vals: _next_recipient(visit),
lambda vals: session.text(
f"Message for {vals[0].name}",
prompt="Delivered as a normal direct message when its moment comes.",
byte_limit=DM_BYTE_LIMIT,
default=vals[1] or "",
),
lambda vals: _schedule_step(ctx, vals[0].name),
]
)
if answers is None: # Esc off the recipient list — out to the outbox
return
contact, text, when = answers
not_before = None if when is WHEN_HEARD else when
key = contact_watch_key(contact)
if key is None:
await session.message_dialog(
Text(f"{contact.name!r} has no usable key to address.", style="err"),
title="Queue a message",
)
return
ctx.courier_store.queue(key, contact.name, text, not_before=not_before)
async def _next_recipient(visit: Any) -> Contact | None:
"""One round of the visited recipient list: the picked contact, or ``None`` on Esc."""
chosen = await visit.result()
return chosen if isinstance(chosen, Contact) else None
async def _schedule_step(ctx: AppContext, name: str) -> object | None:
"""The schedule step, in the shape :func:`~meshterm.ui.menus.run_steps` reads.
A chain step signals *step back* with ``None``, which is exactly what
:func:`_pick_schedule` returns for the real answer "no schedule — send on the next sign
of life". So the two are swapped here: that answer travels as :data:`WHEN_HEARD` (its
own row's value) and the cancel becomes the ``None``.
"""
picked = await _pick_schedule(ctx, name)
if picked is CANCEL_SCHEDULE:
return None
return WHEN_HEARD if picked is None else picked
#: Sentinel: the schedule picker was cancelled (distinct from "no schedule").
CANCEL_SCHEDULE = object()
#: Sentinel: "send when it's next heard" — the no-schedule choice. It carries its own
#: value (never ``None``) because :meth:`session.select` already returns ``None`` for a
#: cancel; sharing that value made picking this row read as a cancel and silently drop the
#: message instead of queueing it.
WHEN_HEARD = object()
async def _pick_schedule(ctx: AppContext, name: str):
"""Float the when-to-send picker; return an aware UTC time, ``None``, or cancel.
``None`` means "no schedule — send on the next sign of life" (the explicit
*When it's next heard* row); an aware UTC datetime holds until then;
:data:`CANCEL_SCHEDULE` means the user backed out and nothing should queue.
The *At a time…* row opens a second prompt, and Esc there steps back to these rungs
rather than out of the queueing flow — the chain rule one level down (see
:func:`~meshterm.ui.menus.run_steps`).
"""
session = ctx.ui.session
while True:
now = utcnow()
tomorrow_7 = parse_clock("07:00", now)
items = [
Choice("When it's next heard (recommended)", WHEN_HEARD),
Choice("In 1 h", now + timedelta(hours=1)),
Choice("In 3 h", now + timedelta(hours=3)),
Choice("In 8 h", now + timedelta(hours=8)),
Choice(f"Next 07:00 ({_local_stamp(tomorrow_7)})", tomorrow_7),
Choice("At a time… (HH:MM, next occurrence)", "custom"),
]
picked = await session.select(
f"When should {name} get it?",
items,
filterable=False,
footer_hint="↑↓ move · Enter select · Esc cancel",
)
if picked is None:
# Esc on the picker steps back out of the schedule; "when next heard" is its
# own explicit row (WHEN_HEARD), so backing out never silently queues anything.
return CANCEL_SCHEDULE
if picked is WHEN_HEARD:
return None # no schedule constraint — delivered on the next pass
if picked != "custom":
return picked
while True:
typed = await session.text(
"Send at (local HH:MM)",
prompt="A time already past today means tomorrow.",
)
if not typed:
break # Esc on the time: back to the rungs it was reached from
when = parse_clock(typed)
if when is not None:
return when
async def _entry_actions(ctx: AppContext, ident: int) -> None:
"""Float one entry's action menu: send now, cancel, or just look at it."""
session = ctx.ui.session
store = ctx.courier_store
message = store.get(ident)
if message is None:
return
if message.status != QUEUED:
# A finished entry has no actions; show its full text instead.
body = Text(message.text)
body.append(
f"\n\n{message.status} · {message.attempts} attempt"
f"{'s' if message.attempts != 1 else ''}",
style="muted",
)
await session.message_dialog(body, title=message.node_name)
return
# One measured column for both rows: 📤 draws two cells and ✗ one, and the Cancel row
# measuring only its own mark started its words a column left of Send now's. Where the
# platform draws no icon lane both go bare, Cancel's err tint moving onto its words.
lane = icon_lane(("📤", "✗"))
items = [
Choice(marked_label("📤", "Send now — one forced attempt", "", lane=lane), "send"),
Choice(marked_label("✗", "Cancel this message", "err", lane=lane), "cancel"),
]
picked = await session.select(
f"{message.node_name} — “{_shorten(message.text, 28)}”",
items,
filterable=False,
footer_hint="↑↓ move · Enter select · Esc back",
)
if picked == "cancel":
store.cancel(ident)
elif picked == "send":
try:
async with ctx.ui.busy_overlay(f"sending to {message.node_name}…"):
outcome = await ctx.courier.attempt_now(ident)
except Exception as exc: # noqa: BLE001 - surface the failure, keep the queue
await session.message_dialog(Text(f"send failed: {exc}", style="err"), title="Courier")
return
notes = {
"delivered": Text("✓ delivered — acknowledged by the contact", style="ok"),
"no ack": Text(
"sent, but no acknowledgement — it stays queued and the courier "
"will retry with backoff",
style="warn",
),
"gave up": Text("no acknowledgement — the retry budget is spent", style="err"),
"unknown contact": Text(
"the device's contact list doesn't know this contact yet; it stays queued",
style="warn",
),
"busy": Text("another delivery is in flight — try again in a moment", style="muted"),
"gone": Text("this entry is no longer queued", style="muted"),
}
await session.message_dialog(notes.get(outcome, Text(outcome)), title="Courier")