From 419af660eeb8a4403e37a63c54a15586d04d5a8e Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 02:06:35 +0200 Subject: feat(cli): chat — Tier 2 check on the signing device, interactive room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each message's signing device is checked against the group's roster as the page checks it (a port of transport-roster.js, held to it by the parity test): chains of countersignatures walked from each account's roots, the keys accepted per account and node kept in the store, the roster read again once before a key is called changed. Only "changed" is said, under the message; --json carries every verdict. chat with no group opens the interactive chat: the groups, then one group's conversation IRC-style with pushed messages arriving live and a line to write in; Esc or /part goes back to the groups, /quit or Ctrl+Q quits. Like the bare command, it opens on the sign-in screen when nothing is stored. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-cli/src/meshbay_cli/tui/app.py | 15 +- packages/meshbay-cli/src/meshbay_cli/tui/chat.py | 246 +++++++++++++++++++++++ 2 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 packages/meshbay-cli/src/meshbay_cli/tui/chat.py (limited to 'packages/meshbay-cli/src/meshbay_cli/tui') diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/app.py b/packages/meshbay-cli/src/meshbay_cli/tui/app.py index dbb773c..fe7fa2c 100644 --- a/packages/meshbay-cli/src/meshbay_cli/tui/app.py +++ b/packages/meshbay-cli/src/meshbay_cli/tui/app.py @@ -3,7 +3,8 @@ Textual, because it runs on the same asyncio loop as aiortc and httpx rather than beside them, and on Linux terminals and Windows' consoles alike. Signed in -already, it opens on the two panes; otherwise on the sign-in screen first. +already, it opens on the two panes — or, for `meshbay-cli chat` with no group, +on the groups to chat in (`tui/chat.py`); otherwise on the sign-in screen first. Nothing is printed while it runs — a line on stderr would tear the screen — so the log goes to a file in the CLI's data directory instead. @@ -24,10 +25,12 @@ from .screens import DeviceCode, MainScreen, SignInScreen class MeshBayApp(App): TITLE = "MeshBay" - def __init__(self, ctx: Context): + def __init__(self, ctx: Context, home=MainScreen): super().__init__() self.ctx = ctx self.hub = ctx.hub + # The screen a signed-in account lands on, made from the account. + self.home = home async def use_hub(self, url: str) -> None: if url != self.hub.url: @@ -51,9 +54,9 @@ class MeshBayApp(App): def start(self, account: Account) -> None: account.on_device_code = DeviceCode(self) if isinstance(self.screen, SignInScreen): - self.switch_screen(MainScreen(account)) + self.switch_screen(self.home(account)) else: - self.push_screen(MainScreen(account)) + self.push_screen(self.home(account)) def _log_to_file() -> None: @@ -70,7 +73,7 @@ def _log_to_file() -> None: root.addHandler(handler) -async def run_interactive(ctx: Context) -> int: +async def run_interactive(ctx: Context, home=MainScreen) -> int: _log_to_file() - await MeshBayApp(ctx).run_async() + await MeshBayApp(ctx, home).run_async() return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py new file mode 100644 index 0000000..83c1753 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py @@ -0,0 +1,246 @@ +""" +`meshbay-cli chat` with no group: the groups, then one group's chat, IRC-style. + +The list shows every group of the account; Enter opens one whose node is up and +whose chat is on (`enabled_apps` in the sealed ack, empty meaning all). The room +shows the last messages, then the ones that arrive (`chat_msg` pushed by the +node), and a line at the bottom to write in. Esc, or `/part`, goes back to the +list; Ctrl+Q, or `/quit`, quits. Messages are read and posted exactly as the +`chat` action reads and posts them — signature, epoch key, content, then the +roster's verdict on the signing device — and drawn as text, never markup. +""" + +import asyncio +import contextlib +import time + +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.screen import Screen +from textual.widgets import DataTable, Footer, Input, RichLog, Static + +from ..client.account import Account +from ..client.chat import Message, chat_keys, history, open_row, post +from ..client.groups import my_groups +from ..client.paths import handle +from ..client.pool import Pool +from ..client.roster import RosterCheck +from ..errors import CliError +from ..output import safe + +# How much of the conversation a room opens on. +BACKLOG = 50 +HELP = "/part or Esc: back to the groups /quit or Ctrl+Q: quit /help: this" +KEY_CHANGED = ("! This account is using a key you have not seen before: a new device " + "added elsewhere, or the node naming the wrong person.") + + +def chat_enabled(ack: dict) -> bool: + apps = ack.get("enabled_apps") or [] + return not apps or "chat" in apps + + +class ChatGroupsScreen(Screen): + BINDINGS = [ + Binding("r", "refresh", "Refresh"), + Binding("q", "app.quit", "Quit"), + ] + + DEFAULT_CSS = """ + ChatGroupsScreen > .title { height: 1; padding: 0 1; background: $panel; } + ChatGroupsScreen > DataTable { height: 1fr; } + """ + + def __init__(self, account: Account): + super().__init__() + self.account = account + self.pool = Pool(account) + self.groups: list[dict] = [] + + def compose(self) -> ComposeResult: + yield Static(Text("Chat — choose a group, Enter to join"), classes="title") + table = DataTable(cursor_type="row") + table.add_column("Group", key="group") + table.add_column("Node", key="node") + yield table + yield Footer() + + def on_mount(self) -> None: + self.query_one(DataTable).focus() + self.action_refresh() + + def action_refresh(self) -> None: + self.run_worker(self._load(), exclusive=True, group="groups") + + async def _load(self) -> None: + try: + self.groups = sorted(await my_groups(self.account), + key=lambda g: (g["name"].lower(), g.get("owner_username"))) + except CliError as e: + self.app.notify(e.message, severity="error", markup=False) + return + table = self.query_one(DataTable) + table.clear() + for g in self.groups: + up = g.get("node_online") + table.add_row(Text(safe(handle(g)), style="" if up else "dim", no_wrap=True, + overflow="ellipsis"), + Text("up" if up else "down", style="" if up else "dim"), + key=g["id"]) + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + group = next((g for g in self.groups if g["id"] == event.row_key.value), None) + if group is not None: + self.run_worker(self._join(group), exclusive=True, group="join") + + async def _join(self, group: dict) -> None: + self.app.notify(f"Joining {safe(handle(group))}…", timeout=3, markup=False) + try: + session = await self.pool.session(group) + except CliError as e: + self.app.notify(e.message, severity="error", markup=False, timeout=8) + return + if not chat_enabled(session.ack): + self.app.notify(f"{safe(handle(group))} has its chat turned off.", + severity="warning", markup=False) + return + await self.app.push_screen(ChatRoomScreen(self.account, group, session)) + + async def on_unmount(self) -> None: + with contextlib.suppress(Exception): + await self.pool.close() + + +class ChatRoomScreen(Screen): + BINDINGS = [Binding("escape", "part", "Back to the groups")] + + DEFAULT_CSS = """ + ChatRoomScreen > .title { height: 1; padding: 0 1; background: $panel; } + ChatRoomScreen > RichLog { height: 1fr; padding: 0 1; } + ChatRoomScreen > Input { dock: bottom; } + """ + + def __init__(self, account: Account, group: dict, session): + super().__init__() + self.account = account + self.group = group + self.session = session + self.keys = None + self.check = RosterCheck(account, session) + self._day = "" + self._ready = asyncio.Event() + + def compose(self) -> ComposeResult: + yield Static(Text(f"#{safe(handle(self.group))} — {HELP}", no_wrap=True, + overflow="ellipsis"), classes="title") + yield RichLog(wrap=True, markup=False, highlight=False, auto_scroll=True) + yield Input(placeholder=f"Message to {safe(handle(self.group))}") + yield Footer() + + @property + def log_view(self) -> RichLog: + return self.query_one(RichLog) + + def on_mount(self) -> None: + self.query_one(Input).focus() + self.session.ch.on_push("chat_msg", self._pushed) + self.session.ch.on_push("chat_epoch_ack", self._epoch_moved) + self.run_worker(self._open(), group="room") + + def on_unmount(self) -> None: + self.session.ch.off_push("chat_msg", self._pushed) + self.session.ch.off_push("chat_epoch_ack", self._epoch_moved) + + async def _open(self) -> None: + try: + self.keys = await chat_keys(self.session) + rows = await history(self.session, BACKLOG) + for row in rows: + self.show(await open_row(row, self.keys, self.group["id"], self.check)) + except CliError as e: + self.note(f"! {e.message}") + return + finally: + self._ready.set() + if not rows: + self.note("No message yet. Say something.") + + # ── Drawing ────────────────────────────────────────────────────────────── + + def note(self, text: str, style: str = "italic dim") -> None: + self.log_view.write(Text(text, style=style)) + + def show(self, m: Message) -> None: + day = time.strftime("%Y-%m-%d", time.localtime(m.timestamp)) + if day != self._day: + self._day = day + self.note(f"— {day} —") + line = Text(time.strftime("%H:%M ", time.localtime(m.timestamp)), style="dim") + line.append(f"<{safe(m.sender)}> ", style="bold") + if m.unreadable: + line.append(f"[this message cannot be read: {m.unreadable}]", style="italic") + else: + line.append("\n ".join(safe(part) for part in m.text.split("\n"))) + self.log_view.write(line) + if m.trust == "changed": + self.note(" " + KEY_CHANGED, style="bold yellow") + + # ── What the node pushes ───────────────────────────────────────────────── + + def _pushed(self, msg: dict) -> None: + self.run_worker(self._arrived(msg), group="arrivals") + + async def _arrived(self, msg: dict) -> None: + await self._ready.wait() + if self.keys is None: + return + # A message sealed under an epoch opened since the keys were fetched. + if int(msg.get("epoch") or 0) not in self.keys.by_epoch: + with contextlib.suppress(CliError): + self.keys = await chat_keys(self.session) + msg = {**msg, "timestamp": msg.get("timestamp") or time.time()} + self.show(await open_row(msg, self.keys, self.group["id"], self.check)) + + def _epoch_moved(self, msg: dict) -> None: + """Somebody was removed: a new epoch, and the key to post under with it.""" + self.session.ack["chat_epoch"] = int(msg.get("epoch") or 0) + self.run_worker(self._refetch_keys(), group="keys") + + async def _refetch_keys(self) -> None: + with contextlib.suppress(CliError): + self.keys = await chat_keys(self.session) + + # ── Writing ────────────────────────────────────────────────────────────── + + async def on_input_submitted(self, event: Input.Submitted) -> None: + text = event.value.rstrip() + event.input.value = "" + if not text.strip(): + return + command = text.strip().lower() + if command in ("/part", "/leave"): + self.action_part() + return + if command in ("/quit", "/exit"): + self.app.exit() + return + if command == "/help": + self.note(HELP) + return + if command.startswith("/") and not command.startswith("//"): + self.note(f"Unknown command {safe(text.split()[0])}. {HELP}") + return + if command.startswith("//"): + text = text.lstrip()[1:] # "//x" posts "/x" + await self._ready.wait() + try: + await post(self.session, self.keys, text, self.account.username) + except CliError as e: + self.note(f"! Not sent: {e.message}", style="bold red") + return + # The node relays to everybody else; this side shows its own at once. + self.show(Message(0, time.time(), self.account.username, text, trust="pinned")) + + def action_part(self) -> None: + self.app.pop_screen() -- cgit v1.2.3