diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 01:35:09 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 01:35:09 +0200 |
| commit | 47583c5e405f3011a526df73cc9485c46598f214 (patch) | |
| tree | 90fafd2e7d3978071b38402253cf7eb70717ec7f /packages/meshbay-cli/src/meshbay_cli/commands | |
| parent | ced66b96f898abccec2526650ab356eb6b82f066 (diff) | |
| download | meshbay-47583c5e405f3011a526df73cc9485c46598f214.tar.gz | |
feat(cli): chat — read a group's last messages, post one
chat GROUP prints the last ten messages, oldest first (-N, as head -N, or
-n N for another count, paging back past the node's 200), and -m posts one
(- reads it from stdin). The application's construction: every epoch key
from chat_keys_req, a post sealed under the current epoch through the
device subkey and signed with this machine's key on the node, the sender's
name inside the envelope; nothing sent past the node's ciphertext bound.
A stored message is read signature first, then key, then content, and one
that fails is shown as unreadable, never dropped. Not done here: the Tier 2
roster check on the signing device.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/commands')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/chat.py | 108 |
1 files changed, 108 insertions, 0 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py new file mode 100644 index 0000000..61e9fa7 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py @@ -0,0 +1,108 @@ +""" +`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` +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 +be read is shown as such, never left out. +""" + +import json +import re +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 ..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" + +_COUNT = re.compile(r"^-(\d+)$") + + +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("-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", + help="post TEXT instead of reading; - reads it from stdin") + + +def rewrite_count(argv: list[str], actions) -> list[str]: + """`chat -100 GROUP` as `chat --last 100 GROUP`: argparse reads -100 as a + negative number, not as an option. Only when the action is `chat`.""" + action = next((i for i, a in enumerate(argv) if a in actions), None) + if action is None or argv[action] != "chat": + return argv + out = argv[:action + 1] + for a in argv[action + 1:]: + m = _COUNT.match(a) + out += ["--last", m.group(1)] if m else [a] + return out + + +def _text(message: str) -> str: + text = sys.stdin.read() if message == "-" else message + text = text.rstrip("\n") + if not text.strip(): + raise CliError(USAGE, "Nothing to post.") + return text + + +async def run(ctx: Context) -> int: + a = ctx.args + if a.last < 1: + raise CliError(USAGE, "Ask for one message at least.") + text = _text(a.message) if a.message is not None else None + await ctx.hub.check_version() + account = ctx.account() + session = None + try: + group = resolve_group(await my_groups(account), group_arg(a.group)) + session = await open_group(account, group) + keys = await chat_keys(session) + if text is not None: + await post(session, keys, text, account.username) + if a.json: + print(json.dumps({"posted": True, "group": handle(group)})) + 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)] + finally: + if session is not None: + await session.close() + await account.close() + + 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], + sys.stdout, indent=1, ensure_ascii=False) + print() + return 0 + if not messages and not a.quiet: + err(f"No message in {safe(handle(group))} yet.") + for m in messages: + head = f"{date(int(m.timestamp))} {safe(m.sender)}: " + if m.unreadable: + print(f"{head}[this message cannot be read: {m.unreadable}]") + continue + lines = safe_lines(m.text) + print(head + lines[0]) + for line in lines[1:]: + print(" " * len(head) + line) + return 0 + + +def safe_lines(text: str) -> list[str]: + """A message's lines, each escaped: a line break stays a line break, every + other control character is shown, never sent to the terminal.""" + return [safe(line) for line in text.split("\n")] or [""] |