`ruff format .` across the repo: 153 files, no behaviour change. This is the second gate CONTRIBUTING.md promises, and until now it reported 154 files needing reformat — so a first contributor ran the command the guide told them to run and could not tell their own diff from the background. Landed on its own, with nothing else in it, because a whole-repo reformat is only safe to skip past in blame if it is provably nothing but layout. It was checked that way rather than assumed: every changed file was formatted in memory and compared by full `ast.dump()`, and 152 of 153 come out structurally identical (the one exception was a docstring opening on a quote character, fixed in the commit before this one). The string and comment token streams were diffed separately, so a quote flip or an edited comment could not hide inside a re-layout — five quote-style normalisations and sixteen implicit-concatenation joins, all value-identical, and one commented-out line gaining the space after its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite is 1888 green, gallery gate and `test_theme16` included. What actually changed, in order of how much of the diff it is: wrapped calls and signatures rejoin onto one line where they fit inside the hundred columns; the magic trailing comma expands what it is there to expand; complex slices gain their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are laid out to be read were fenced in the previous commit and are untouched here. The aligned trailing-comment column is gone from about ninety lanes that were not fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced where it sits. Every comment stays on the entry it describes, so nothing there reads wrong; it just no longer lines up. Add this commit to `.git-blame-ignore-revs`, which follows immediately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
271 lines
11 KiB
Python
271 lines
11 KiB
Python
"""The ``courier`` tool: store-and-forward messaging for contacts that aren't there yet.
|
|
|
|
Interactively it opens the outbox screen (see :mod:`meshterm.ui.courier_screen`):
|
|
queued messages with their live state, the queueing flow (recipient → message →
|
|
when), forced sends, and the delivered/given-up history. Delivery runs in the
|
|
background for the whole session (see :mod:`meshterm.services.courier`): a queued
|
|
message goes out when its contact is next heard — or at its scheduled time — as a normal
|
|
direct message with acknowledgement tracking and polite exponential backoff, and the
|
|
outcome lights the header's Watchtower badge.
|
|
|
|
On the CLI the same outbox is scriptable through subcommands: ``queue`` adds a message
|
|
(reading the contact list to address it — nothing is transmitted) for the next
|
|
interactive session's courier to deliver, ``send`` forces one delivery attempt right now,
|
|
and ``list`` / ``cancel`` / ``clear`` inspect and prune the outbox — the scriptable face
|
|
of the outbox screen's own Send-now, Cancel, and Clear-finished actions.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import typer
|
|
|
|
from ..context import AppContext
|
|
from ..core.connection import DeviceCommandError
|
|
from .base import Tool, ToolResult, register
|
|
|
|
|
|
@register
|
|
class CourierTool(Tool):
|
|
"""Queue messages for offline contacts; they go out when the contact is next heard."""
|
|
|
|
name = "courier"
|
|
title = "Courier"
|
|
icon = "📨"
|
|
help = "Outbox that delivers when a contact is heard"
|
|
category = "Message"
|
|
order = 30 # after Chat and Channels: the same conversations, minus the waiting around
|
|
|
|
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
|
|
"""Run the outbox screen; there are never parameters to collect.
|
|
|
|
The screen presents everything itself and returns when dismissed, so
|
|
returning ``None`` tells the menu the invocation is complete (the same
|
|
pattern as the ``dashboard`` tool).
|
|
|
|
Args:
|
|
ctx: Shared application context.
|
|
|
|
Returns:
|
|
Always ``None``.
|
|
"""
|
|
from ..ui.courier_screen import open_courier
|
|
|
|
await open_courier(ctx)
|
|
return None
|
|
|
|
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
|
"""Dispatch a scripted CLI action (the menu path lives in :meth:`prompt_params`).
|
|
|
|
Args:
|
|
ctx: Shared application context.
|
|
params: A ``cli_action`` naming the outbox action, plus its arguments.
|
|
|
|
Returns:
|
|
A :class:`ToolResult` describing the action's outcome.
|
|
"""
|
|
action = params.get("cli_action", "queue")
|
|
if action == "list":
|
|
return await self._cli_list(ctx)
|
|
if action == "send":
|
|
return await self._cli_send(ctx, params)
|
|
if action == "cancel":
|
|
return await self._cli_cancel(ctx, params)
|
|
if action == "clear":
|
|
return await self._cli_clear(ctx)
|
|
return await self._cli_queue(ctx, params)
|
|
|
|
# -- CLI --------------------------------------------------------------------
|
|
|
|
async def _cli_queue(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
|
"""Queue one message from the CLI (transmits nothing).
|
|
|
|
Args:
|
|
ctx: Shared application context.
|
|
params: ``contact`` (contact name), ``text`` (the message body), and an
|
|
optional local ``at`` clock time (``HH:MM``, next occurrence).
|
|
|
|
Returns:
|
|
A :class:`ToolResult` describing the queued entry.
|
|
"""
|
|
from ..ui.courier_screen import parse_clock
|
|
from ..ui.watchtower_screen import contact_watch_key
|
|
|
|
device = await ctx.device()
|
|
contacts = await device.get_contacts()
|
|
name = str(params["contact"])
|
|
needle = name.casefold()
|
|
contact = next((c for c in contacts if c.name.casefold() == needle), None)
|
|
if contact is None:
|
|
raise DeviceCommandError(f"unknown contact: {name!r}")
|
|
key = contact_watch_key(contact)
|
|
if key is None:
|
|
raise DeviceCommandError(f"{name!r} has no usable key to address")
|
|
|
|
not_before = None
|
|
if params.get("at"):
|
|
not_before = parse_clock(str(params["at"]))
|
|
if not_before is None:
|
|
raise DeviceCommandError(
|
|
f"--at wants a clock time like 07:00, not {params['at']!r}"
|
|
)
|
|
message = ctx.courier_store.queue(
|
|
key, contact.name, str(params["text"]), not_before=not_before
|
|
)
|
|
when = (
|
|
f"at {not_before.astimezone().strftime('%b %d %H:%M')}"
|
|
if not_before is not None
|
|
else "when the contact is next heard"
|
|
)
|
|
ctx.ui.note(
|
|
f"[ok]queued[/ok] #{message.ident} for [brand]{contact.name}[/brand] — "
|
|
f"delivers {when} (an interactive session's courier does the sending)"
|
|
)
|
|
return ToolResult(
|
|
summary={"queued": message.ident, "contact": contact.name, "at": params.get("at")}
|
|
)
|
|
|
|
async def _cli_list(self, ctx: AppContext) -> ToolResult:
|
|
"""Print the outbox — waiting entries then finished ones — as a table.
|
|
|
|
A read-only view over the stored outbox: it needs no device, so it works the same
|
|
whether or not a radio is attached (the scriptable face of the outbox screen).
|
|
"""
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
|
|
from ..core.courier_store import DELIVERED, QUEUED
|
|
from ..core.models import utcnow
|
|
from ..ui.courier_screen import _local_stamp, _shorten
|
|
from ..ui.widgets import _age_seconds, format_ago
|
|
|
|
entries = ctx.courier_store.entries()
|
|
if not entries:
|
|
ctx.ui.note("[muted]the outbox is empty[/muted]")
|
|
return ToolResult(summary={"entries": 0})
|
|
|
|
table = Table(title="Courier outbox", border_style="muted", expand=False)
|
|
table.add_column("ID", justify="right")
|
|
table.add_column("STATE")
|
|
table.add_column("NODE")
|
|
table.add_column("MESSAGE")
|
|
table.add_column("WHEN")
|
|
now = utcnow()
|
|
for m in entries:
|
|
if m.status == QUEUED:
|
|
state = Text("⏳ waiting", style="warn")
|
|
if m.not_before is not None and now < m.not_before:
|
|
when = f"scheduled {_local_stamp(m.not_before)}"
|
|
else:
|
|
when = "when next heard"
|
|
elif m.status == DELIVERED:
|
|
state = Text("✓ delivered", style="ok")
|
|
when = f"delivered {format_ago(_age_seconds(m.finished or m.created))}"
|
|
else:
|
|
state = Text("✗ gave up", style="err")
|
|
when = f"gave up {format_ago(_age_seconds(m.finished or m.created))}"
|
|
table.add_row(
|
|
str(m.ident),
|
|
state,
|
|
Text(m.node_name, style="brand"),
|
|
_shorten(m.text),
|
|
Text(when, style="muted"),
|
|
)
|
|
ctx.ui.show(table)
|
|
return ToolResult(summary={"entries": len(entries)})
|
|
|
|
async def _cli_send(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
|
"""Force one delivery attempt for a waiting entry, right now.
|
|
|
|
The scriptable ``Send now`` — one forced attempt through the same courier the
|
|
outbox screen uses, skipping the "wait until the contact is heard" eligibility check.
|
|
"""
|
|
ident = int(params["id"])
|
|
if ctx.courier_store.get(ident) is None:
|
|
raise DeviceCommandError(f"no outbox entry #{ident}")
|
|
outcome = await ctx.courier.attempt_now(ident)
|
|
notes = {
|
|
"delivered": "[ok]✓ delivered[/ok] — acknowledged by the contact",
|
|
"no ack": "[warn]sent, but no acknowledgement[/warn] — it stays queued for retry",
|
|
"gave up": "[err]✗ gave up[/err] — the retry budget is spent",
|
|
"unknown contact": (
|
|
"[warn]the device's contacts don't know this contact yet[/warn] — it stays queued"
|
|
),
|
|
"busy": "[muted]another delivery is in flight — try again in a moment[/muted]",
|
|
"gone": "[muted]that entry is no longer waiting[/muted]",
|
|
}
|
|
ctx.ui.note(notes.get(outcome, outcome))
|
|
return ToolResult(summary={"id": ident, "outcome": outcome})
|
|
|
|
async def _cli_cancel(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
|
|
"""Remove a waiting entry from the outbox (finished ones use ``clear``)."""
|
|
ident = int(params["id"])
|
|
if ctx.courier_store.cancel(ident):
|
|
ctx.ui.note(f"[warn]cancelled outbox entry #{ident}[/warn]")
|
|
return ToolResult(summary={"id": ident, "cancelled": True})
|
|
ctx.ui.note(f"[muted]no waiting entry #{ident} to cancel[/muted]")
|
|
return ToolResult(summary={"id": ident, "cancelled": False})
|
|
|
|
async def _cli_clear(self, ctx: AppContext) -> ToolResult:
|
|
"""Drop every finished (delivered / given-up) entry, leaving the queue untouched."""
|
|
before = len(ctx.courier_store.entries())
|
|
ctx.courier_store.clear_done()
|
|
cleared = before - len(ctx.courier_store.entries())
|
|
if cleared:
|
|
ctx.ui.note(f"[ok]cleared {cleared} finished entr{'y' if cleared == 1 else 'ies'}[/ok]")
|
|
else:
|
|
ctx.ui.note("[muted]no finished entries to clear[/muted]")
|
|
return ToolResult(summary={"cleared": cleared})
|
|
|
|
def register_cli(self, app: typer.Typer) -> None:
|
|
"""Register the ``courier`` subcommand group.
|
|
|
|
Args:
|
|
app: The Typer application.
|
|
"""
|
|
from ..cli import run_tool_command
|
|
|
|
courier_app = typer.Typer(help=self.help, no_args_is_help=True, rich_markup_mode="rich")
|
|
|
|
@courier_app.command(
|
|
"queue", help="Queue a message for delivery when the contact is next heard"
|
|
)
|
|
def _queue_cmd(
|
|
contact: str = typer.Argument(..., help="The recipient contact's name"),
|
|
text: list[str] = typer.Argument(..., help="The message to queue"),
|
|
at: str | None = typer.Option(
|
|
None, "--at", help="Hold until this local time (HH:MM, next occurrence)"
|
|
),
|
|
) -> None:
|
|
tool_params: dict[str, Any] = {
|
|
"cli_action": "queue",
|
|
"contact": contact,
|
|
"text": " ".join(text),
|
|
}
|
|
if at is not None:
|
|
tool_params["at"] = at
|
|
run_tool_command(self, tool_params)
|
|
|
|
@courier_app.command("list", help="Show the outbox — waiting and finished entries")
|
|
def _list_cmd() -> None:
|
|
run_tool_command(self, {"cli_action": "list"})
|
|
|
|
@courier_app.command("send", help="Force one delivery attempt for a waiting entry now")
|
|
def _send_cmd(
|
|
id: int = typer.Argument(..., help="Outbox entry id (from `courier list`)"),
|
|
) -> None:
|
|
run_tool_command(self, {"cli_action": "send", "id": id})
|
|
|
|
@courier_app.command("cancel", help="Remove a waiting entry from the outbox")
|
|
def _cancel_cmd(
|
|
id: int = typer.Argument(..., help="Outbox entry id (from `courier list`)"),
|
|
) -> None:
|
|
run_tool_command(self, {"cli_action": "cancel", "id": id})
|
|
|
|
@courier_app.command("clear", help="Drop finished (delivered / given-up) entries")
|
|
def _clear_cmd() -> None:
|
|
run_tool_command(self, {"cli_action": "clear"})
|
|
|
|
app.add_typer(courier_app, name=self.name)
|