summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/commands/chat.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 02:06:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 02:06:35 +0200
commit419af660eeb8a4403e37a63c54a15586d04d5a8e (patch)
treeb0c0cdd831a5fb2864d5bedf581681aa0ec86023 /packages/meshbay-cli/src/meshbay_cli/commands/chat.py
parent47583c5e405f3011a526df73cc9485c46598f214 (diff)
downloadmeshbay-419af660eeb8a4403e37a63c54a15586d04d5a8e.tar.gz
feat(cli): chat — Tier 2 check on the signing device, interactive room
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/commands/chat.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/chat.py36
1 files changed, 30 insertions, 6 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py
index 61e9fa7..6fa084d 100644
--- a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py
+++ b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py
@@ -1,7 +1,9 @@
"""
`chat`: a group's conversation — the last messages, or one more posted.
-`chat GROUP` prints the last ten, oldest first; `-N` (as `head -N`) or `-n N`
+`chat` alone opens the interactive chat (`tui/chat.py`): the groups, one
+group's conversation IRC-style, back to the groups. `chat GROUP` prints the
+last ten, oldest first; `-N` (as `head -N`) or `-n N`
asks for another count. `chat GROUP -m TEXT` posts TEXT (`-m -` reads it from
stdin), sealed and signed as the application seals and signs it. Each message
is shown with its time and the name its sender sealed in it; one that cannot
@@ -15,20 +17,27 @@ import sys
from ..client.chat import chat_keys, history, open_row, post
from ..client.groups import my_groups, open_group
from ..client.paths import group_arg, handle, resolve_group
+from ..client.roster import RosterCheck
from ..errors import USAGE, CliError
from ..output import date, err, safe, sentence
from .context import Context
-HELP = "read a group's chat (the last 10 messages, -N for N), or post with -m"
+HELP = ("chat: alone, interactively; with a group, its last 10 messages (-N for N), "
+ "or post with -m")
_COUNT = re.compile(r"^-(\d+)$")
+# The one verdict worth a line (roster.py): the application's words.
+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 add_parser(sub, parent) -> None:
p = sub.add_parser("chat", parents=[parent], help=HELP, description=sentence(HELP)
+ ". Messages are end-to-end encrypted and signed; the node "
"stores what it cannot read.")
- p.add_argument("group", metavar="GROUP", help="the group, as `groups` lists it")
+ p.add_argument("group", metavar="GROUP", nargs="?",
+ help="the group, as `groups` lists it; without it, the interactive "
+ "chat: choose a group, talk, leave it, choose another")
p.add_argument("-n", "--last", type=int, default=10, metavar="N",
help="how many of the last messages to show (also -N, as -100)")
p.add_argument("-m", "--message", metavar="TEXT",
@@ -60,6 +69,16 @@ async def run(ctx: Context) -> int:
a = ctx.args
if a.last < 1:
raise CliError(USAGE, "Ask for one message at least.")
+ if a.group is None:
+ if a.message is not None:
+ raise CliError(USAGE, "Name the group to post to.")
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
+ raise CliError(USAGE, "The interactive chat needs a terminal; name a group "
+ "to read it, or to post with -m.")
+ from ..tui.app import run_interactive
+ from ..tui.chat import ChatGroupsScreen
+ # As with no argument at all: the sign-in screen checks the hub.
+ return await run_interactive(ctx, ChatGroupsScreen)
text = _text(a.message) if a.message is not None else None
await ctx.hub.check_version()
account = ctx.account()
@@ -75,7 +94,9 @@ async def run(ctx: Context) -> int:
elif not a.quiet:
err(f"Posted to {safe(handle(group))}.")
return 0
- messages = [open_row(r, keys, group["id"]) for r in await history(session, a.last)]
+ check = RosterCheck(account, session)
+ messages = [await open_row(r, keys, group["id"], check)
+ for r in await history(session, a.last)]
finally:
if session is not None:
await session.close()
@@ -83,8 +104,9 @@ async def run(ctx: Context) -> int:
if a.json:
json.dump([{"id": m.id, "timestamp": m.timestamp, "sender": m.sender,
- "text": m.text, **({"unreadable": m.unreadable} if m.unreadable
- else {})} for m in messages],
+ "text": m.text, "trust": m.trust,
+ **({"unreadable": m.unreadable} if m.unreadable else {})}
+ for m in messages],
sys.stdout, indent=1, ensure_ascii=False)
print()
return 0
@@ -99,6 +121,8 @@ async def run(ctx: Context) -> int:
print(head + lines[0])
for line in lines[1:]:
print(" " * len(head) + line)
+ if m.trust == "changed":
+ print(" " * len(head) + KEY_CHANGED)
return 0