aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 09:57:18 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 09:57:18 +0200
commit2f90f4b60cd50f7e9649daaf8e76a120e608cff6 (patch)
tree9d3724602959b05602f7fadb8a45bd26d0fc9b91 /packages/meshbay-cli/src/meshbay_cli
parent5044bdfdfffceceecc0d92a776983ad361079839 (diff)
downloadmeshbay-2f90f4b60cd50f7e9649daaf8e76a120e608cff6.tar.gz
feat(cli): /put and /get in the interactive chat
/put FILE sends one local file, relative to the current directory or absolute, into the chat's folder, then posts the application's attachment message naming it ({"text": "", "attachment": {filename, size, type}}), so the page shows it as its paperclip's. /get NAME saves a file of that folder, named from it, into the current directory, never over a file already there. The chat's folder is the page's rule: the operator's choice while its root is writable and present, else the first such root; a chat_directory_ack moves it. A line above the one written in shows the transfer's progress. Attachment messages, from the page or from /put, read as [file: name, size] in the room and in `chat`, and as an `attachment` field with --json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/chat.py55
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/chat.py9
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/manpage.py9
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/chat.py178
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