aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/chat.py55
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)