diff options
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/chat.py | 55 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/chat.py | 9 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/manpage.py | 9 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/tui/chat.py | 178 |
4 files changed, 243 insertions, 8 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/chat.py b/packages/meshbay-cli/src/meshbay_cli/client/chat.py index 22a99ef..587303a 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/chat.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/chat.py @@ -17,8 +17,13 @@ Then whose device signed it: the node names an account (`sender_id`), and `roster.RosterCheck` answers whether the signing device is one this machine accepted for that account, or one countersigned into it since (Tier 2, design §3.3). The name shown is the one sealed in the message by its sender. + +A file sent in the chat is two things, as the application sends it: the file, +uploaded into the chat's folder, then a message whose text is +`{"text": "", "attachment": {filename, size, type}}` naming it there. """ +import json import time from dataclasses import dataclass @@ -51,6 +56,8 @@ class Message: # Which message this is, wherever it was read from — history or a push, # which carries no id: the node stores a (device, nonce) pair only once. key: str = "" + # A file sent in the chat: {filename, size, type}, in the chat's folder. + attachment: dict | None = None class ChatKeys: @@ -116,7 +123,7 @@ async def open_row(row: dict, keys: ChatKeys, group_id: str, except Exception: msg.unreadable = "does not decrypt" return msg - msg.text = str(payload.get("text") or "") + msg.text, msg.attachment = read_attachment(str(payload.get("text") or "")) msg.sender = str(payload.get("sender_name") or sender) if check is not None: msg.trust = await check.status(str(row.get("sender_id") or ""), @@ -124,6 +131,52 @@ async def open_row(row: dict, keys: ChatKeys, group_id: str, return msg +def read_attachment(text: str) -> tuple[str, dict | None]: + """A message's text and the file it names, if it is the application's + attachment message; anything else is text as it is.""" + if not text.startswith("{"): + return text, None + try: + parsed = json.loads(text) + except ValueError: + return text, None + att = parsed.get("attachment") if isinstance(parsed, dict) else None + if not (isinstance(att, dict) and isinstance(att.get("filename"), str) + and att["filename"]): + return text, None + size = att.get("size") + return (str(parsed.get("text") or ""), + {"filename": att["filename"], + "size": size if isinstance(size, int) and size >= 0 else 0, + "type": str(att.get("type") or "file")}) + + +IMAGE_EXT = {"jpg", "jpeg", "png", "gif", "webp", "svg"} +VIDEO_EXT = {"mp4", "webm", "mkv", "mov", "avi"} + + +def attachment_text(stored_as: str, size: int) -> str: + """The message that shows `stored_as` as a file in the application's chat.""" + ext = stored_as.rsplit(".", 1)[-1].lower() if "." in stored_as else "" + kind = "image" if ext in IMAGE_EXT else "video" if ext in VIDEO_EXT else "file" + return json.dumps({"text": "", "attachment": {"filename": stored_as, "size": size, + "type": kind}}) + + +def chat_folder(ack: dict, roots: dict[str, dict]) -> str: + """ + Where the chat's files are: the folder the operator chose for it, while its + root is writable and present, else the first such root — the application's + rule (group-page.js `attachDir`/`attachRoot`). Empty: nowhere to write. + """ + usable = [name for name, r in roots.items() + if r.get("writable") and r.get("available", True)] + chosen = str(ack.get("chat_directory") or "").strip("/") + if chosen and chosen.split("/", 1)[0] in usable: + return chosen + return usable[0] if usable else "" + + async def post(session: GroupSession, keys: ChatKeys, text: str, sender_name: str) -> None: """Seal, sign and send one message under the current epoch.""" epoch = int(session.ack.get("chat_epoch") or keys.current) diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py index d650af3..e410a6a 100644 --- a/packages/meshbay-cli/src/meshbay_cli/commands/chat.py +++ b/packages/meshbay-cli/src/meshbay_cli/commands/chat.py @@ -28,7 +28,7 @@ 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 NODE, USAGE, CliError -from ..output import date, err, safe, sentence +from ..output import date, err, human_size, safe, sentence from .context import Context HELP = ("chat: alone, interactively; with a group, its last 10 messages (-N for N), " @@ -147,6 +147,10 @@ def print_message(m, *, flush: bool = False) -> None: print(f"{head}[this message cannot be read: {m.unreadable}]", flush=flush) return lines = safe_lines(m.text) + if m.attachment: + file_line = (f"[file: {safe(m.attachment['filename'])}, " + f"{human_size(m.attachment['size'])}]") + lines = [file_line] if lines == [""] else [file_line, *lines] print(head + lines[0]) for line in lines[1:]: print(" " * len(head) + line) @@ -158,7 +162,8 @@ def print_message(m, *, flush: bool = False) -> None: def as_json(m) -> dict: return {"id": m.id, "timestamp": m.timestamp, "sender": m.sender, "text": m.text, - "trust": m.trust, **({"unreadable": m.unreadable} if m.unreadable else {})} + "trust": m.trust, **({"unreadable": m.unreadable} if m.unreadable else {}), + **({"attachment": m.attachment} if m.attachment else {})} class Follower: diff --git a/packages/meshbay-cli/src/meshbay_cli/manpage.py b/packages/meshbay-cli/src/meshbay_cli/manpage.py index d40e479..601bd5b 100644 --- a/packages/meshbay-cli/src/meshbay_cli/manpage.py +++ b/packages/meshbay-cli/src/meshbay_cli/manpage.py @@ -257,6 +257,15 @@ A right-click copies the text selected with the mouse, else the line under the pointer, and pastes into the line written in: the system clipboard's text, read with the platform's tool (wl\-paste, xclip or xsel, pbpaste, PowerShell), else what was copied here last. +.B /put +\fIFILE\fR sends one local file, relative to the current directory or absolute, +into the chat's folder and names it in the conversation, as the application's +paperclip does; +.B /get +\fINAME\fR saves a file of that folder, named from it, into the current +directory. +The chat's folder is the one the group's operator chose for it while its root +accepts uploads and is present, else the group's first such root. .PP .B browser\-access on makes this machine leave, on each node it opens a group on, a copy of its keys diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py index 0717141..e4ea1fe 100644 --- a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py +++ b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py @@ -7,14 +7,19 @@ 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. A right-click copies — the text selected with the mouse, else the line under the pointer — and pastes into the line written -in. Messages are read and posted exactly as the +in. `/put FILE` sends one local file (relative to the current directory, or +absolute) into the chat's folder and names it in the conversation, as the +application's paperclip does; `/get NAME` saves a file of that folder, named +from it, into the current directory. 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 os import time +from pathlib import Path from rich.segment import Segment from rich.text import Text @@ -27,19 +32,30 @@ from textual.strip import Strip 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.chat import ( + Message, + attachment_text, + chat_folder, + 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 ..client.transfers import PART_SUFFIX, download, upload +from ..client.tree import Tree +from ..client.upload_plan import refused_names from ..errors import CliError -from ..output import safe +from ..output import human_size, safe from . import clipboard # How much of the conversation a room opens on. BACKLOG = 50 -HELP = ("/part or Esc: back to the groups /quit or Ctrl+Q: quit " - "right-click: copy, paste /help: this") +HELP = ("/put FILE: send a file /get NAME: save one here /part or Esc: back " + "/quit or Ctrl+Q: quit right-click: copy, paste /help: this") # Under a message's first line, its next ones and what is said about it. INDENT = " " * 6 KEY_CHANGED = ("! This account is using a key you have not seen before: a new device " @@ -203,6 +219,7 @@ class ChatRoomScreen(Screen): DEFAULT_CSS = """ ChatRoomScreen > .title { height: 1; padding: 0 1; background: $panel; } ChatRoomScreen > ChatLog { height: 1fr; padding: 0 1; } + ChatRoomScreen > .transfer { height: 1; padding: 0 1; display: none; } ChatRoomScreen > ChatInput { dock: bottom; } """ @@ -215,11 +232,13 @@ class ChatRoomScreen(Screen): self.check = RosterCheck(account, session) self._day = "" self._ready = asyncio.Event() + self._transferring = False def compose(self) -> ComposeResult: yield Static(Text(f"#{safe(handle(self.group))} — {HELP}", no_wrap=True, overflow="ellipsis"), classes="title") yield ChatLog(wrap=True, markup=False, highlight=False, auto_scroll=True) + yield Static("", classes="transfer", markup=False) yield ChatInput(placeholder=f"Message to {safe(handle(self.group))}", select_on_focus=False) yield Footer() @@ -232,11 +251,13 @@ class ChatRoomScreen(Screen): 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.session.ch.on_push("chat_directory_ack", self._folder_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) + self.session.ch.off_push("chat_directory_ack", self._folder_moved) async def _open(self) -> None: try: @@ -267,6 +288,11 @@ class ChatRoomScreen(Screen): if m.unreadable: line.append(f"[this message cannot be read: {m.unreadable}]", style="italic") else: + if m.attachment: + line.append(f"[file: {safe(m.attachment['filename'])}, " + f"{human_size(m.attachment['size'])}]", style="cyan") + if m.text: + line.append("\n" + INDENT) line.append(("\n" + INDENT).join(safe(part) for part in m.text.split("\n"))) self.log_view.write(line) if m.trust == "changed": @@ -297,6 +323,10 @@ class ChatRoomScreen(Screen): with contextlib.suppress(CliError): self.keys = await chat_keys(self.session) + def _folder_moved(self, msg: dict) -> None: + """The operator chose another folder for the chat's files.""" + self.session.ack["chat_directory"] = str(msg.get("path") or "") + # ── Writing ────────────────────────────────────────────────────────────── async def on_input_submitted(self, event: Input.Submitted) -> None: @@ -314,6 +344,19 @@ class ChatRoomScreen(Screen): if command == "/help": self.note(HELP) return + verb, _, arg = text.strip().partition(" ") + if verb.lower() in ("/put", "/get"): + arg = _unquote(arg.strip()) + if not arg: + self.note(f"{verb.lower()} FILE: " + ("the local file to send." + if verb.lower() == "/put" else "the file to save, named from " + "the chat's folder.")) + elif self._transferring: + self.note("! A file is on its way already; wait for it.", style="bold red") + else: + self.run_worker(self._put(arg) if verb.lower() == "/put" else self._get(arg), + group="transfer") + return if command.startswith("/") and not command.startswith("//"): self.note(f"Unknown command {safe(text.split()[0])}. {HELP}") return @@ -330,3 +373,128 @@ class ChatRoomScreen(Screen): def action_part(self) -> None: self.app.pop_screen() + + # ── Files ──────────────────────────────────────────────────────────────── + + async def _folder(self) -> tuple[Tree, str] | None: + tree = Tree.from_index(await self.session.index()) + folder = chat_folder(self.session.ack, tree.roots) + if not folder: + self.note("! This group has no folder for the chat's files: every one is " + "read-only or unavailable.", style="bold red") + return None + return tree, folder + + def _progress(self, arrow: str, name: str, size: int): + bar = self.query_one(".transfer", Static) + done = [0] + started = time.monotonic() + + def advance(n: int) -> None: + done[0] += n + rate = done[0] / max(time.monotonic() - started, 0.001) + pct = 100 * done[0] // size if size else 100 + bar.update(Text(f"{arrow} {safe(name)} {pct}% {human_size(int(rate))}/s", + no_wrap=True, overflow="ellipsis")) + + def queued(n: int) -> None: + bar.update(Text(f"{arrow} {safe(name)} waiting for a free slot on the node" + + (f" ({n} ahead)" if n else ""), no_wrap=True)) + bar.update(Text(f"{arrow} {safe(name)}", no_wrap=True)) + bar.display = True + return advance, queued + + def _transfer_ended(self) -> None: + self._transferring = False + bar = self.query_one(".transfer", Static) + bar.display = False + bar.update("") + + async def _put(self, arg: str) -> None: + path = Path(os.path.abspath(os.path.expanduser(arg))) + if not path.is_file(): + self.note(f"! {safe(arg)}: " + ("a folder; /put sends one file." + if path.is_dir() else "no such file."), style="bold red") + return + name = path.name + if problems := refused_names([name], []): + self.note(f"! Not sent: {safe(problems[0])}", style="bold red") + return + self._transferring = True + try: + await self._ready.wait() + if self.keys is None: + self.note("! Not sent: the chat could not be opened.", style="bold red") + return + found = await self._folder() + if found is None: + return + _, folder = found + size = path.stat().st_size + advance, queued = self._progress("↑", name, size) + with open(path, "rb") as fh: + def read(offset: int, length: int) -> bytes: + fh.seek(offset) + return fh.read(length) + stored = await upload(self.session, read, size, name, folder, + progress=advance, on_queued=queued) + try: + await post(self.session, self.keys, attachment_text(stored, size), + self.account.username) + except CliError as e: + self.note(f"! {safe(stored)} is in {safe(folder)}, but the message naming " + f"it was not sent: {safe(e.message)}", style="bold red") + return + except OSError as e: + self.note(f"! Not sent: {safe(arg)}: {e.strerror}", style="bold red") + return + except CliError as e: + self.note(f"! Not sent: {safe(e.message)}", style="bold red") + return + finally: + self._transfer_ended() + self.show(Message(0, time.time(), self.account.username, "", trust="pinned", + attachment={"filename": stored, "size": size, "type": "file"})) + if stored != name: + self.note(f"{INDENT}stored as {safe(stored)}: {safe(name)} was taken.") + + async def _get(self, arg: str) -> None: + name = arg.replace("\\", "/").strip("/") + if not name or any(p in ("", ".", "..") for p in name.split("/")): + self.note(f"! {safe(arg)}: name a file of the chat's folder.", style="bold red") + return + self._transferring = True + try: + found = await self._folder() + if found is None: + return + tree, folder = found + entry = tree.file(f"{folder}/{name}") + if entry is None: + self.note(f"! No file {safe(name)} in the chat's folder ({safe(folder)}).", + style="bold red") + return + target = Path.cwd() / name.rsplit("/", 1)[-1] + if target.exists() and not target.with_name(target.name + PART_SUFFIX).exists(): + self.note(f"! {safe(str(target))} is here already; nothing was downloaded.", + style="bold red") + return + size = int(entry["size"]) + advance, queued = self._progress("↓", name, size) + await download(self.session, entry, target, progress=advance, on_queued=queued) + except OSError as e: + self.note(f"! Not saved: {e.strerror}", style="bold red") + return + except CliError as e: + self.note(f"! Not saved: {safe(e.message)}", style="bold red") + return + finally: + self._transfer_ended() + self.note(f"Saved {safe(str(target))} ({human_size(size)}).") + + +def _unquote(text: str) -> str: + """A path pasted with its quotes, as a file manager or a shell gives it.""" + if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'": + return text[1:-1] + return text |