diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 09:57:18 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 09:57:18 +0200 |
| commit | 2f90f4b60cd50f7e9649daaf8e76a120e608cff6 (patch) | |
| tree | 9d3724602959b05602f7fadb8a45bd26d0fc9b91 /packages/meshbay-cli/src/meshbay_cli/client | |
| parent | 5044bdfdfffceceecc0d92a776983ad361079839 (diff) | |
| download | meshbay-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/client')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/chat.py | 55 |
1 files changed, 54 insertions, 1 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) |