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/tests/test_interactive.py | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) (limited to 'packages/meshbay-cli/tests/test_interactive.py') diff --git a/packages/meshbay-cli/tests/test_interactive.py b/packages/meshbay-cli/tests/test_interactive.py index 49180d5..fa7e96c 100644 --- a/packages/meshbay-cli/tests/test_interactive.py +++ b/packages/meshbay-cli/tests/test_interactive.py @@ -206,3 +206,82 @@ async def test_delete_removes_what_is_yours_and_asks_first(world): await pilot.press("delete") await pilot.pause(0.2) assert any("select them on the right" in n.message for n in app._notifications) + + +def _log_text(screen) -> str: + from textual.widgets import RichLog + return "\n".join("".join(seg.text for seg in strip) + for strip in screen.query_one(RichLog).lines) + + +async def test_the_interactive_chat_joins_talks_leaves_and_comes_back(world, monkeypatch): + from meshbay_cli.tui.chat import ChatGroupsScreen, ChatRoomScreen + from textual.widgets import DataTable, Input + hub, node, _ = world + hub.groups.append({"id": "g9", "name": "Quiet", "owner_username": "bob-test", + "node_online": True, "is_admin": False}) + quiet = FakeNode() + quiet.ack["enabled_apps"] = ["files"] + sessions = {} + + async def open_group(account, group): + target = quiet if group["id"] == "g9" else node + sessions[group["id"]] = target.session() + return sessions[group["id"]] + monkeypatch.setattr(pool, "open_group", open_group) + node.say("earlier, from bob") + assert await asyncio.to_thread(cli.main, ["--hub", "https://hub.test", "-u", USERNAME, + "--password", PASSPHRASE, "login"]) == 0 + + app = MeshBayApp(context.Context(cli.build_parser().parse_args( + ["--hub", "https://hub.test"])), ChatGroupsScreen) + async with app.run_test(size=(120, 30)) as pilot: + await _until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen)) + table = app.screen.query_one(DataTable) + await _until(pilot, lambda: table.row_count == 2) + + # A group whose chat is off is not entered. + table.move_cursor(row=1) # Quiet@bob-test + await pilot.press("enter") + await _until(pilot, lambda: any("chat turned off" in n.message + for n in app._notifications)) + assert isinstance(app.screen, ChatGroupsScreen) + + table.move_cursor(row=0) # Family@alice-test + await pilot.press("enter") + await _until(pilot, lambda: isinstance(app.screen, ChatRoomScreen)) + room = app.screen + await _until(pilot, lambda: " earlier, from bob" in _log_text(room)) + + # Posting: sealed, signed, stored on the node, shown here at once. + room.query_one(Input).value = "hello from the room" + await pilot.press("enter") + await _until(pilot, lambda: " hello from the room" in _log_text(room)) + assert len(node.chat) == 2 + + # What the node pushes appears as it arrives. + bob = node.say("live, from bob") + sessions["g1"].ch._emit({**node.chat[-1], "type": "chat_msg"}) + await _until(pilot, lambda: " live, from bob" in _log_text(room)) + assert bob.pk_ed + + # /part goes back; entering again shows the conversation once, and a + # pushed message once — the room left behind no handler. + room.query_one(Input).value = "/part" + await pilot.press("enter") + await _until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen)) + await pilot.press("enter") + await _until(pilot, lambda: isinstance(app.screen, ChatRoomScreen)) + room = app.screen + await _until(pilot, lambda: "live, from bob" in _log_text(room)) + node.say("once") + sessions["g1"].ch._emit({**node.chat[-1], "type": "chat_msg"}) + await _until(pilot, lambda: " once" in _log_text(room)) + await pilot.pause(0.2) + assert _log_text(room).count(" once") == 1 + + # Esc goes back too; q quits from the list. + await pilot.press("escape") + await _until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen)) + await pilot.press("q") + assert app.return_code in (0, None) -- cgit v1.2.3