aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md6
-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
-rw-r--r--packages/meshbay-cli/tests/test_chat_files.py44
-rw-r--r--packages/meshbay-cli/tests/test_e2e_local.py78
-rw-r--r--packages/meshbay-cli/tests/test_interactive.py80
8 files changed, 449 insertions, 10 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 19510d5..28763af 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -3080,7 +3080,11 @@ said meanwhile from the history, each message printed once (a device and nonce
pair is stored once). `chat` alone opens the interactive chat: the groups, one group's
conversation IRC-style with what the node pushes arriving live, and back. A
right-click copies from the conversation (the mouse selection, else the line
-under the pointer) and pastes into the line written in.
+under the pointer) and pastes into the line written in. `/put FILE` uploads
+one local file into the chat's folder and posts the application's attachment
+message naming it; `/get NAME` saves a file of that folder into the current
+directory. The chat's folder follows the page's rule: the operator's choice
+while its root is writable and present, else the first such root.
**Deleting** (`rm`) is `file_delete` signed with the machine's key on the node
(§5.4): the node accepts the uploading account's devices and the operator's.
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
diff --git a/packages/meshbay-cli/tests/test_chat_files.py b/packages/meshbay-cli/tests/test_chat_files.py
new file mode 100644
index 0000000..4c83cba
--- /dev/null
+++ b/packages/meshbay-cli/tests/test_chat_files.py
@@ -0,0 +1,44 @@
+"""Files in the chat: where they go, and how a message names one."""
+
+import json
+
+from meshbay_cli.client.chat import Message, attachment_text, chat_folder, read_attachment
+from meshbay_cli.commands.chat import as_json, print_message
+
+ROOTS = {"music": {"name": "music", "writable": False},
+ "share": {"name": "share", "writable": True},
+ "usb": {"name": "usb", "writable": True, "available": False}}
+
+
+def test_the_chat_folder_is_the_operators_choice_while_it_can_be_written():
+ assert chat_folder({"chat_directory": "share/chat"}, ROOTS) == "share/chat"
+ assert chat_folder({"chat_directory": "/share/chat/"}, ROOTS) == "share/chat"
+ # Read-only, unplugged, gone, or never chosen: the first writable root.
+ for chosen in ("music/x", "usb/x", "gone/x", ""):
+ assert chat_folder({"chat_directory": chosen}, ROOTS) == "share"
+ assert chat_folder({}, {"music": ROOTS["music"]}) == ""
+
+
+def test_an_attachment_reads_back_as_the_application_writes_it():
+ text = attachment_text("IMG 1.JPG", 1234)
+ assert json.loads(text) == {"text": "", "attachment": {
+ "filename": "IMG 1.JPG", "size": 1234, "type": "image"}}
+ assert read_attachment(text) == ("", {"filename": "IMG 1.JPG", "size": 1234,
+ "type": "image"})
+ assert json.loads(attachment_text("clip.mkv", 1))["attachment"]["type"] == "video"
+ assert json.loads(attachment_text("README", 1))["attachment"]["type"] == "file"
+
+
+def test_text_that_only_looks_like_json_stays_text():
+ for text in ('{"a": 1}', "{not json", '{"attachment": "x"}',
+ '{"attachment": {"filename": ""}}', "[1]", "hello"):
+ assert read_attachment(text) == (text, None)
+
+
+def test_the_chat_action_prints_a_file_by_name_and_size(capsys):
+ m = Message(1, 1_700_000_000, "bob", "look", attachment={
+ "filename": "a.pdf", "size": 2048, "type": "file"})
+ print_message(m)
+ out = capsys.readouterr().out.splitlines()
+ assert out[0].endswith("bob: [file: a.pdf, 2.0 KB]") and out[1].strip() == "look"
+ assert as_json(m)["attachment"] == {"filename": "a.pdf", "size": 2048, "type": "file"}
diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py
index 359ab06..fb251bd 100644
--- a/packages/meshbay-cli/tests/test_e2e_local.py
+++ b/packages/meshbay-cli/tests/test_e2e_local.py
@@ -1070,6 +1070,82 @@ async def test_the_interactive_chat_against_the_real_node(world, monkeypatch, tm
assert r.stdout.strip().endswith(f"{USERNAME}: typed in the room"), r.stdout
+async def test_put_and_get_in_the_interactive_chat_against_the_real_node(
+ world, monkeypatch, tmp_path):
+ """/put sends a file into the chat's folder on a real node and names it in
+ the chat, as the page's paperclip does: `chat` and the group page show it as
+ a file. /get saves a file of that folder into the current directory."""
+ from meshbay_cli import __main__ as main_module
+ from meshbay_cli.commands.context import Context
+ from meshbay_cli.tui.app import MeshBayApp
+ from meshbay_cli.tui.chat import ChatGroupsScreen, ChatLog, ChatRoomScreen
+ from textual.widgets import DataTable, Input
+
+ group = _handle()
+ r = await asyncio.to_thread(cli, world, "chat", "-1", group, home="cli-chat")
+ assert r.returncode == 0, "the chat tests above did not leave this machine linked"
+ monkeypatch.setenv("MESHBAY_CLI_HOME", str(world["root"] / "cli-chat"))
+ here = tmp_path / "here"
+ here.mkdir()
+ monkeypatch.chdir(here)
+ sent = secrets.token_bytes(3 * 1024 * 1024 + 17) # several chunks
+ (here / "from the room.bin").write_bytes(sent)
+ app = MeshBayApp(Context(main_module.build_parser().parse_args(
+ ["--hub", world["hub_url"]])), ChatGroupsScreen)
+
+ def said(screen) -> str:
+ return "\n".join(t for *_, t in screen.query_one(ChatLog)._blocks)
+
+ async def until(pilot, cond, timeout=60):
+ deadline = time.monotonic() + timeout
+ while not cond():
+ assert time.monotonic() < deadline, [n.message for n in app._notifications]
+ await pilot.pause(0.1)
+
+ async with app.run_test(size=(140, 40)) as pilot:
+ await until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen)
+ and app.screen.query_one(DataTable).row_count >= 1)
+ await pilot.press("enter")
+ await until(pilot, lambda: isinstance(app.screen, ChatRoomScreen))
+ room = app.screen
+ await until(pilot, lambda: "typed in the room" in said(room))
+
+ # No folder chosen for the chat: the first writable root, `share`.
+ room.query_one(Input).value = "/put from the room.bin"
+ await pilot.press("enter")
+ await until(pilot, lambda: "[file: from the room.bin, 3.0 MB]" in said(room))
+ assert (world["share"] / "from the room.bin").read_bytes() == sent
+
+ room.query_one(Input).value = "/get notes.txt"
+ await pilot.press("enter")
+ await until(pilot, lambda: "Saved " in said(room) or "! " in said(room))
+ assert (here / "notes.txt").read_text() == "hello\n", said(room)
+ await pilot.press("escape")
+
+ r = await asyncio.to_thread(cli, world, "chat", "-1", group, home="cli-chat")
+ assert r.stdout.strip().endswith(f"{USERNAME}: [file: from the room.bin, 3.0 MB]"), \
+ r.stdout
+ r = await asyncio.to_thread(cli, world, "--json", "chat", "-1", group, home="cli-chat")
+ assert json.loads(r.stdout)[0]["attachment"] == {
+ "filename": "from the room.bin", "size": len(sent), "type": "file"}
+
+ if shutil.which("google-chrome"):
+ await asyncio.to_thread(_the_page_shows_the_file, world, "from the room.bin")
+
+
+def _the_page_shows_the_file(world: dict, name: str) -> None:
+ # Not revoke_the_others: `chat --follow` below runs on this machine's key.
+ chrome = _linked_group_page(world)
+ try:
+ files = chrome.until(
+ "[...document.querySelectorAll('.chat-attachment')].map(a => a.innerText)",
+ lambda found: any(name in f for f in found or []))
+ assert any("3" in f and "MB" in f for f in files if name in f), files
+ assert '"attachment"' not in chrome.js("document.body.innerText")
+ finally:
+ chrome.close()
+
+
def test_chat_follow_against_the_real_node(world):
"""`chat --follow` in its own process: messages posted from another run
arrive as the node pushes them, across more than one liveness ping, and
@@ -1083,7 +1159,7 @@ def test_chat_follow_against_the_real_node(world):
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
first = json.loads(follower.stdout.readline()) # the last one, at once
- assert first["text"]
+ assert first["text"] or first.get("attachment") # /put's, above
for text, wait in (("followed one", 0), ("followed two, after a ping", 36)):
time.sleep(wait)
r = cli(world, "chat", group, "-m", text, home="cli-chat")
diff --git a/packages/meshbay-cli/tests/test_interactive.py b/packages/meshbay-cli/tests/test_interactive.py
index 5b9d9b0..36b7f47 100644
--- a/packages/meshbay-cli/tests/test_interactive.py
+++ b/packages/meshbay-cli/tests/test_interactive.py
@@ -219,6 +219,12 @@ async def _right_click(pilot, widget, offset=(0, 0)) -> None:
await pilot.pause()
+def _said(screen) -> str:
+ """Each thing written to the conversation whole, however it wrapped."""
+ from meshbay_cli.tui.chat import ChatLog
+ return "\n".join(text for *_, text in screen.query_one(ChatLog)._blocks)
+
+
def _log_text(screen) -> str:
from textual.widgets import RichLog
return "\n".join("".join(seg.text for seg in strip)
@@ -364,3 +370,77 @@ async def test_a_right_click_copies_from_the_chat_and_pastes_into_the_line(world
assert field.value == "bob-test"
assert app.screen is room # a right-click left nothing behind
await pilot.press("escape")
+
+
+async def test_put_sends_a_file_into_the_chat_folder_and_get_saves_one_here(
+ world, monkeypatch, tmp_path):
+ """As the application's paperclip: the file in the chat's folder, then a
+ message naming it; and a file of that folder saved by its name."""
+ from meshbay_cli.client.chat import ChatKeys, open_row
+ from meshbay_cli.tui.chat import ChatGroupsScreen, ChatInput, ChatRoomScreen
+ hub, node, work = world
+ node.ack["chat_directory"] = "share/chat"
+ node.dirs.add("share/chat")
+ photo = bytes(range(256)) * 12_000 # several chunks
+ (work / "photo.jpg").write_bytes(photo)
+ elsewhere = tmp_path / "elsewhere"
+ elsewhere.mkdir()
+ (elsewhere / "a note.txt").write_bytes(b"absolute")
+ node.add("share/chat/old", "notes.txt", b"from the chat folder")
+ 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))
+ await pilot.pause(0.3)
+ await pilot.press("enter")
+ await _until(pilot, lambda: isinstance(app.screen, ChatRoomScreen))
+ room = app.screen
+ field = room.query_one(ChatInput)
+ await _until(pilot, lambda: "No message yet" in _said(room))
+
+ async def say(line: str) -> None:
+ field.value = line
+ await pilot.press("enter")
+
+ # Relative to the current directory.
+ await say("/put photo.jpg")
+ await _until(pilot, lambda: len(node.chat) == 1)
+ assert node.stored("share/chat/photo.jpg") == photo
+ sent = await open_row(node.chat[0], ChatKeys(node.chat_epochs, 1), "g1")
+ assert sent.attachment == {"filename": "photo.jpg", "size": len(photo),
+ "type": "image"}
+ assert sent.text == ""
+ await _until(pilot, lambda: "[file: photo.jpg, " in _said(room))
+
+ # Absolute, quoted as a file manager pastes it; a taken name is renamed.
+ await say(f'/put "{elsewhere / "a note.txt"}"')
+ await _until(pilot, lambda: len(node.chat) == 2)
+ assert node.stored("share/chat/a note.txt") == b"absolute"
+ await say("/put photo.jpg")
+ await _until(pilot, lambda: len(node.chat) == 3)
+ assert node.stored("share/chat/photo (1).jpg") == photo
+ await _until(pilot, lambda: "stored as photo (1).jpg" in _said(room))
+
+ # Nothing sent for a folder or a missing file.
+ await say("/put .")
+ await say("/put nope.bin")
+ await _until(pilot, lambda: "no such file" in _said(room))
+ assert "a folder; /put sends one file" in _said(room)
+ assert len(node.chat) == 3
+
+ # Named from the chat's folder, saved here under its own name.
+ await say("/get old/notes.txt")
+ await _until(pilot, lambda: (work / "notes.txt").exists())
+ assert (work / "notes.txt").read_bytes() == b"from the chat folder"
+ await _until(pilot, lambda: "Saved " in _said(room))
+ await say("/get old/notes.txt")
+ await _until(pilot, lambda: "is here already" in _said(room))
+ await say("/get notes.txt")
+ await _until(pilot, lambda: "No file notes.txt in the chat's folder (share/chat)"
+ in _said(room))
+ await say("/get ../share/x")
+ await _until(pilot, lambda: "name a file of the chat's folder" in _said(room))
+ await pilot.press("escape")