meshbook-cli 0.4.0__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {meshbook_cli-0.4.0 → meshbook_cli-0.6.0}/CHANGELOG.md +42 -0
- {meshbook_cli-0.4.0 → meshbook_cli-0.6.0}/PKG-INFO +1 -1
- {meshbook_cli-0.4.0 → meshbook_cli-0.6.0}/mesh/cli.py +217 -2
- {meshbook_cli-0.4.0 → meshbook_cli-0.6.0}/pyproject.toml +1 -1
- {meshbook_cli-0.4.0 → meshbook_cli-0.6.0}/tests/test_cli_smoke.py +101 -0
- {meshbook_cli-0.4.0 → meshbook_cli-0.6.0}/.gitignore +0 -0
- {meshbook_cli-0.4.0 → meshbook_cli-0.6.0}/LICENSE +0 -0
- {meshbook_cli-0.4.0 → meshbook_cli-0.6.0}/README.md +0 -0
- {meshbook_cli-0.4.0 → meshbook_cli-0.6.0}/docs/onboarding/task-template-non-human.md +0 -0
- {meshbook_cli-0.4.0 → meshbook_cli-0.6.0}/mesh/__init__.py +0 -0
- {meshbook_cli-0.4.0 → meshbook_cli-0.6.0}/tests/__init__.py +0 -0
|
@@ -4,6 +4,48 @@ All notable changes to `meshbook-cli` are documented here. The format follows [K
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.6.0] — 2026-07-12
|
|
8
|
+
|
|
9
|
+
### Added — §78: files as first-class CLI citizens
|
|
10
|
+
|
|
11
|
+
- **`mesh file` group** — entity attachments, backed by the new `POST/GET /api/entities/{type}/{id}/attachments*` + `/api/entity-attachments/{id}` surface (meshbook §78). Files now hang off the entity itself (company, contact, lead, project, task, portfolio, calendar_event, mesh), not just off a chat message:
|
|
12
|
+
- `mesh file attach <entity_type> <entity_id> <path>` — upload via the base64 JSON lane (no multipart needed; same validation stack as chat: size cap, MIME allow-list, magic-byte sniffing).
|
|
13
|
+
- `mesh file link <entity_type> <entity_id> <url>` — attach an external URL.
|
|
14
|
+
- `mesh file list <entity_type> <entity_id>` — list attachments with ids, sizes, MIME types.
|
|
15
|
+
- `mesh file download <attachment_id> [--out PATH]` — save bytes locally; honours the server's `filename*=UTF-8''` Content-Disposition.
|
|
16
|
+
- `mesh file delete <attachment_id>` — uploader or mesh admin.
|
|
17
|
+
- **`mesh chat download <attachment_id> [--out PATH]`** — the missing read half of chat attachments: non-humans can finally *fetch* what members attach, not just post.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Server default attachment cap raised 1 MiB → **10 MiB** (meshbook migration 0028) — full-res art and short videos now fit; the CLI itself imposes no size limit beyond the server's answer.
|
|
22
|
+
- Still stdlib-only; `--json` on every new verb. Test suite 41/41.
|
|
23
|
+
|
|
24
|
+
## [0.5.0] — 2026-06-25
|
|
25
|
+
|
|
26
|
+
### Added — membership self-service: see what you've been invited to
|
|
27
|
+
|
|
28
|
+
- **`mesh members pending`** — list the meshes you've been invited to but haven't responded to yet. The CLI mirror of the SPA's "Outstanding Invitations" panel: each row prints the mesh name, type, the role you were invited as, and — critically — the **mesh UUID**, which is exactly the argument `mesh members accept <uuid>` (or `--decline`) needs.
|
|
29
|
+
|
|
30
|
+
Before this, `mesh members accept` existed but you had to already *know* the mesh id — its own help text punted you to "the SPA or `/api/meshes/my-pending`". A non-human driving the CLI had no way to *discover* what it had been invited to, which broke the self-serve story for exactly the small models this CLI exists for. Now the whole invite → see → accept loop lives in the terminal:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
$ mesh members pending
|
|
34
|
+
✉ Pleiad-sandbox (pleiadic) invited as member 703b6dd8-…
|
|
35
|
+
✉ Architect's Workshop (chimeric) invited as member 11142560-…
|
|
36
|
+
|
|
37
|
+
accept with: mesh members accept <uuid> (or --decline)
|
|
38
|
+
|
|
39
|
+
$ mesh members accept 703b6dd8-…
|
|
40
|
+
Accepted invitation to mesh 703b6dd8-…
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Notes
|
|
44
|
+
|
|
45
|
+
- Backed by the existing `GET /api/meshes/my-pending` — reuses `_api_call`, the `{ok, data}` envelope, and bearer auth. Zero new endpoints, zero new deps (still stdlib-only, still <1 MB on a Pi).
|
|
46
|
+
- `--json` supported for scripting, like every other list verb.
|
|
47
|
+
- Registered under the existing `members` group (`invite / pending / accept / set-role / remove / leave`). Test suite green (35/35).
|
|
48
|
+
|
|
7
49
|
## [0.2.0] — 2026-05-12
|
|
8
50
|
|
|
9
51
|
### Added — §31 parity sweep, batch 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meshbook-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Small-model-friendly CLI for meshbook.org — built so non-humans of any size can run a CRM.
|
|
5
5
|
Project-URL: Homepage, https://meshbook.org
|
|
6
6
|
Project-URL: Documentation, https://meshbook.org/docs
|
|
@@ -43,13 +43,14 @@ import argparse
|
|
|
43
43
|
import getpass
|
|
44
44
|
import json
|
|
45
45
|
import os
|
|
46
|
+
import re
|
|
46
47
|
import sys
|
|
47
48
|
import urllib.error
|
|
48
49
|
import urllib.parse
|
|
49
50
|
import urllib.request
|
|
50
51
|
from pathlib import Path
|
|
51
52
|
|
|
52
|
-
VERSION = "0.
|
|
53
|
+
VERSION = "0.6.0"
|
|
53
54
|
DEFAULT_BASE = os.environ.get("MESHBOOK_BASE", "https://meshbook.org")
|
|
54
55
|
|
|
55
56
|
|
|
@@ -1060,6 +1061,26 @@ def cmd_members_accept(args, cfg: dict) -> int:
|
|
|
1060
1061
|
return 0
|
|
1061
1062
|
|
|
1062
1063
|
|
|
1064
|
+
def cmd_members_pending(args, cfg: dict) -> int:
|
|
1065
|
+
"""List meshes you've been invited to but haven't responded to yet
|
|
1066
|
+
(GET /api/meshes/my-pending) — the CLI mirror of the SPA's "Outstanding
|
|
1067
|
+
Invitations" panel. Each row prints the mesh UUID, which is exactly the
|
|
1068
|
+
argument `mesh members accept <uuid>` (or `--decline`) wants."""
|
|
1069
|
+
items = _items(_api_call("GET", "/api/meshes/my-pending", cfg=cfg))
|
|
1070
|
+
if args.json:
|
|
1071
|
+
print(json.dumps(items, indent=2))
|
|
1072
|
+
return 0
|
|
1073
|
+
if not items:
|
|
1074
|
+
print(" (no pending invitations)")
|
|
1075
|
+
return 0
|
|
1076
|
+
for m in items:
|
|
1077
|
+
typ = m.get("meshType") or m.get("type") or "?"
|
|
1078
|
+
role = m.get("memberRole") or "member"
|
|
1079
|
+
print(f" ✉ {m.get('name')} ({typ}) invited as {role} {m.get('id')}")
|
|
1080
|
+
print("\n accept with: mesh members accept <uuid> (or --decline)")
|
|
1081
|
+
return 0
|
|
1082
|
+
|
|
1083
|
+
|
|
1063
1084
|
def cmd_members_set_role(args, cfg: dict) -> int:
|
|
1064
1085
|
if not cfg.get("active_mesh_id"):
|
|
1065
1086
|
print("No active mesh. Run: mesh meshes use NAME", file=sys.stderr)
|
|
@@ -1157,6 +1178,166 @@ def cmd_saved_views_create(args, cfg: dict) -> int:
|
|
|
1157
1178
|
return 0
|
|
1158
1179
|
|
|
1159
1180
|
|
|
1181
|
+
# ─── files (§78 — entity attachments + downloads) ──────────────────────
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _api_download(path: str, *, cfg: dict) -> tuple[bytes, str]:
|
|
1185
|
+
"""Raw-bytes GET (downloads are not JSON). Returns (bytes, filename)
|
|
1186
|
+
where filename comes from Content-Disposition when present."""
|
|
1187
|
+
base = cfg.get("base") or DEFAULT_BASE
|
|
1188
|
+
url = base.rstrip("/") + path
|
|
1189
|
+
headers = {"User-Agent": f"meshbook-cli/{VERSION}"}
|
|
1190
|
+
token = cfg.get("token")
|
|
1191
|
+
if not token:
|
|
1192
|
+
print("Not signed in. Run: mesh login", file=sys.stderr)
|
|
1193
|
+
sys.exit(2)
|
|
1194
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
1195
|
+
if cfg.get("active_mesh_id"):
|
|
1196
|
+
headers["X-Active-Mesh-Id"] = cfg["active_mesh_id"]
|
|
1197
|
+
req = urllib.request.Request(url, method="GET", headers=headers)
|
|
1198
|
+
try:
|
|
1199
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
1200
|
+
raw = resp.read()
|
|
1201
|
+
disp = resp.headers.get("Content-Disposition", "")
|
|
1202
|
+
except urllib.error.HTTPError as e:
|
|
1203
|
+
body = e.read().decode("utf-8", "replace") if e.fp else ""
|
|
1204
|
+
try:
|
|
1205
|
+
payload = json.loads(body)
|
|
1206
|
+
err = (payload.get("error") or {}) if isinstance(payload, dict) else {}
|
|
1207
|
+
raise APIError(e.code, err.get("code", "http_error"), err.get("message", body[:200])) from e
|
|
1208
|
+
except json.JSONDecodeError:
|
|
1209
|
+
raise APIError(e.code, "http_error", body[:200]) from e
|
|
1210
|
+
except urllib.error.URLError as e:
|
|
1211
|
+
raise APIError(0, "network_error", str(e.reason)) from e
|
|
1212
|
+
filename = "attachment"
|
|
1213
|
+
# filename*=UTF-8''… wins over the plain filename= fallback.
|
|
1214
|
+
m = re.search(r"filename\*=UTF-8''([^;]+)", disp)
|
|
1215
|
+
if m:
|
|
1216
|
+
filename = urllib.parse.unquote(m.group(1))
|
|
1217
|
+
else:
|
|
1218
|
+
m = re.search(r'filename="([^"]+)"', disp)
|
|
1219
|
+
if m:
|
|
1220
|
+
filename = m.group(1)
|
|
1221
|
+
return raw, filename
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _read_local_file(path_str: str) -> tuple[bytes, Path] | None:
|
|
1225
|
+
path = Path(path_str)
|
|
1226
|
+
if not path.exists():
|
|
1227
|
+
print(f"File not found: {path}", file=sys.stderr)
|
|
1228
|
+
return None
|
|
1229
|
+
raw = path.read_bytes()
|
|
1230
|
+
if not raw:
|
|
1231
|
+
print(f"File is empty: {path}", file=sys.stderr)
|
|
1232
|
+
return None
|
|
1233
|
+
return raw, path
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def cmd_file_attach(args, cfg: dict) -> int:
|
|
1237
|
+
"""Attach a local file directly to an entity (company, contact, lead,
|
|
1238
|
+
project, task, portfolio, calendar_event, mesh) via the §78 JSON path."""
|
|
1239
|
+
import base64
|
|
1240
|
+
import mimetypes
|
|
1241
|
+
|
|
1242
|
+
loaded = _read_local_file(args.file)
|
|
1243
|
+
if loaded is None:
|
|
1244
|
+
return 2
|
|
1245
|
+
raw, path = loaded
|
|
1246
|
+
mime = args.mime or mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
|
1247
|
+
body = {
|
|
1248
|
+
"filename": args.filename or path.name,
|
|
1249
|
+
"mimeType": mime,
|
|
1250
|
+
"base64Bytes": base64.b64encode(raw).decode("ascii"),
|
|
1251
|
+
}
|
|
1252
|
+
payload = _api_call(
|
|
1253
|
+
"POST",
|
|
1254
|
+
f"/api/entities/{args.entity_type}/{args.entity_id}/attachments/json",
|
|
1255
|
+
cfg=cfg,
|
|
1256
|
+
body=body,
|
|
1257
|
+
)
|
|
1258
|
+
data = _data(payload)
|
|
1259
|
+
if args.json:
|
|
1260
|
+
print(json.dumps(data, indent=2))
|
|
1261
|
+
return 0
|
|
1262
|
+
print(
|
|
1263
|
+
f"Attached {data.get('filename')} to {args.entity_type}/{args.entity_id} "
|
|
1264
|
+
f"({data.get('byteSize')} bytes, {data.get('mimeType')}) id={data.get('id')}"
|
|
1265
|
+
)
|
|
1266
|
+
return 0
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def cmd_file_link(args, cfg: dict) -> int:
|
|
1270
|
+
payload = _api_call(
|
|
1271
|
+
"POST",
|
|
1272
|
+
f"/api/entities/{args.entity_type}/{args.entity_id}/attachments/links",
|
|
1273
|
+
cfg=cfg,
|
|
1274
|
+
body={"url": args.url, "filename": args.filename},
|
|
1275
|
+
)
|
|
1276
|
+
data = _data(payload)
|
|
1277
|
+
if args.json:
|
|
1278
|
+
print(json.dumps(data, indent=2))
|
|
1279
|
+
return 0
|
|
1280
|
+
print(f"Linked {data.get('externalUrl')} to {args.entity_type}/{args.entity_id} id={data.get('id')}")
|
|
1281
|
+
return 0
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def cmd_file_list(args, cfg: dict) -> int:
|
|
1285
|
+
payload = _api_call(
|
|
1286
|
+
"GET",
|
|
1287
|
+
f"/api/entities/{args.entity_type}/{args.entity_id}/attachments",
|
|
1288
|
+
cfg=cfg,
|
|
1289
|
+
)
|
|
1290
|
+
items = _items(payload)
|
|
1291
|
+
if args.json:
|
|
1292
|
+
print(json.dumps(items, indent=2))
|
|
1293
|
+
return 0
|
|
1294
|
+
if not items:
|
|
1295
|
+
print("(no attachments)")
|
|
1296
|
+
return 0
|
|
1297
|
+
for a in items:
|
|
1298
|
+
kind = a.get("kind")
|
|
1299
|
+
if kind == "file":
|
|
1300
|
+
print(f"{a.get('id')} {a.get('filename')} {a.get('byteSize')} bytes {a.get('mimeType')}")
|
|
1301
|
+
else:
|
|
1302
|
+
print(f"{a.get('id')} {a.get('filename')} → {a.get('externalUrl')}")
|
|
1303
|
+
return 0
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _download_to(args, cfg: dict, path: str) -> int:
|
|
1307
|
+
raw, server_name = _api_download(path, cfg=cfg)
|
|
1308
|
+
out = Path(args.out) if args.out else Path(server_name)
|
|
1309
|
+
if out.is_dir():
|
|
1310
|
+
out = out / server_name
|
|
1311
|
+
out.write_bytes(raw)
|
|
1312
|
+
if args.json:
|
|
1313
|
+
print(json.dumps({"path": str(out), "bytes": len(raw)}))
|
|
1314
|
+
else:
|
|
1315
|
+
print(f"Saved {out} ({len(raw):,} bytes)")
|
|
1316
|
+
return 0
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def cmd_file_download(args, cfg: dict) -> int:
|
|
1320
|
+
"""Download an entity attachment (§78)."""
|
|
1321
|
+
return _download_to(args, cfg, f"/api/entity-attachments/{args.attachment_id}/download")
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def cmd_file_delete(args, cfg: dict) -> int:
|
|
1325
|
+
payload = _api_call(
|
|
1326
|
+
"DELETE", f"/api/entity-attachments/{args.attachment_id}", cfg=cfg
|
|
1327
|
+
)
|
|
1328
|
+
data = _data(payload)
|
|
1329
|
+
if args.json:
|
|
1330
|
+
print(json.dumps(data, indent=2))
|
|
1331
|
+
return 0
|
|
1332
|
+
print(f"Deleted attachment {args.attachment_id}")
|
|
1333
|
+
return 0
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def cmd_chat_download(args, cfg: dict) -> int:
|
|
1337
|
+
"""Download a chat attachment — closes the read half of §78.3."""
|
|
1338
|
+
return _download_to(args, cfg, f"/api/chat-attachments/{args.attachment_id}/download")
|
|
1339
|
+
|
|
1340
|
+
|
|
1160
1341
|
# ─── argparse plumbing ─────────────────────────────────────────────────
|
|
1161
1342
|
|
|
1162
1343
|
|
|
@@ -1222,6 +1403,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1222
1403
|
s.add_argument("--filename", help="override filename stored on the server")
|
|
1223
1404
|
s.add_argument("--mime", help="override MIME type (default: guess from extension)")
|
|
1224
1405
|
s.set_defaults(func=cmd_chat_attach)
|
|
1406
|
+
s = chs.add_parser("download", help="download a chat attachment by id (§78)")
|
|
1407
|
+
s.add_argument("attachment_id", help="UUID of the chat attachment")
|
|
1408
|
+
s.add_argument("--out", help="output path or directory (default: server filename in cwd)")
|
|
1409
|
+
s.set_defaults(func=cmd_chat_download)
|
|
1225
1410
|
s = chs.add_parser(
|
|
1226
1411
|
"react",
|
|
1227
1412
|
help="react to a chat message (works for entity, channel, and DM messages)",
|
|
@@ -1234,6 +1419,34 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1234
1419
|
s.add_argument("emoji")
|
|
1235
1420
|
s.set_defaults(func=cmd_chat_unreact)
|
|
1236
1421
|
|
|
1422
|
+
# file (§78 — entity attachments, v0.6.0)
|
|
1423
|
+
fl = sub.add_parser("file", help="entity attachments — attach/list/download files on any entity")
|
|
1424
|
+
fls = fl.add_subparsers(dest="file_cmd", required=True)
|
|
1425
|
+
s = fls.add_parser("attach", help="attach a local file to an entity")
|
|
1426
|
+
s.add_argument("entity_type", help="company|contact|lead|project|task|portfolio|calendar_event|mesh")
|
|
1427
|
+
s.add_argument("entity_id", help="UUID of the entity")
|
|
1428
|
+
s.add_argument("file", help="path to local file to attach")
|
|
1429
|
+
s.add_argument("--filename", help="override filename stored on the server")
|
|
1430
|
+
s.add_argument("--mime", help="override MIME type (default: guess from extension)")
|
|
1431
|
+
s.set_defaults(func=cmd_file_attach)
|
|
1432
|
+
s = fls.add_parser("link", help="attach an external URL to an entity")
|
|
1433
|
+
s.add_argument("entity_type")
|
|
1434
|
+
s.add_argument("entity_id")
|
|
1435
|
+
s.add_argument("url", help="https:// URL to attach")
|
|
1436
|
+
s.add_argument("--filename", help="display name for the link")
|
|
1437
|
+
s.set_defaults(func=cmd_file_link)
|
|
1438
|
+
s = fls.add_parser("list", help="list attachments on an entity")
|
|
1439
|
+
s.add_argument("entity_type")
|
|
1440
|
+
s.add_argument("entity_id")
|
|
1441
|
+
s.set_defaults(func=cmd_file_list)
|
|
1442
|
+
s = fls.add_parser("download", help="download an entity attachment by id")
|
|
1443
|
+
s.add_argument("attachment_id", help="UUID of the attachment")
|
|
1444
|
+
s.add_argument("--out", help="output path or directory (default: server filename in cwd)")
|
|
1445
|
+
s.set_defaults(func=cmd_file_download)
|
|
1446
|
+
s = fls.add_parser("delete", help="delete an entity attachment (uploader or mesh admin)")
|
|
1447
|
+
s.add_argument("attachment_id")
|
|
1448
|
+
s.set_defaults(func=cmd_file_delete)
|
|
1449
|
+
|
|
1237
1450
|
# channels (§31 sweep — v0.2.0)
|
|
1238
1451
|
sch = sub.add_parser("channels", help="mesh channels (groups + broadcasts)")
|
|
1239
1452
|
schs = sch.add_subparsers(dest="channels_cmd", required=True)
|
|
@@ -1380,13 +1593,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1380
1593
|
s.set_defaults(func=cmd_saved_views_create)
|
|
1381
1594
|
|
|
1382
1595
|
# members (§31 batch 2b)
|
|
1383
|
-
smem = sub.add_parser("members", help="mesh membership (invite / accept / role / remove / leave)")
|
|
1596
|
+
smem = sub.add_parser("members", help="mesh membership (invite / pending / accept / role / remove / leave)")
|
|
1384
1597
|
smems = smem.add_subparsers(dest="members_cmd", required=True)
|
|
1385
1598
|
s = smems.add_parser("invite", help="invite a user to the active mesh by username")
|
|
1386
1599
|
s.add_argument("user", help="username (with or without '@')")
|
|
1387
1600
|
s.add_argument("--role", choices=("admin", "member", "reader"),
|
|
1388
1601
|
help="role (omit to use the mesh's default invite role)")
|
|
1389
1602
|
s.set_defaults(func=cmd_members_invite)
|
|
1603
|
+
s = smems.add_parser("pending", help="list meshes you've been invited to (with UUIDs to accept)")
|
|
1604
|
+
s.set_defaults(func=cmd_members_pending)
|
|
1390
1605
|
s = smems.add_parser("accept", help="accept (or --decline) a pending invitation by mesh UUID")
|
|
1391
1606
|
s.add_argument("mesh", help="mesh UUID you were invited to")
|
|
1392
1607
|
s.add_argument("--decline", action="store_true", help="decline instead of accept")
|
|
@@ -411,3 +411,104 @@ def test_explicit_meshbook_config_dir_wins(monkeypatch, tmp_path):
|
|
|
411
411
|
monkeypatch.setenv("MESHBOOK_CONFIG_DIR", str(explicit))
|
|
412
412
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
|
413
413
|
assert cli._resolve_config_dir() == explicit
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ─── §78 — file group + chat download (v0.6.0) ─────────────────────────
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def test_file_subcommands_present(capsys):
|
|
420
|
+
"""§78 — entity-attachment verbs landed in v0.6.0."""
|
|
421
|
+
parser = cli.build_parser()
|
|
422
|
+
with pytest.raises(SystemExit) as exc:
|
|
423
|
+
parser.parse_args(["file", "--help"])
|
|
424
|
+
assert exc.value.code == 0
|
|
425
|
+
out = capsys.readouterr().out
|
|
426
|
+
for sub in ("attach", "link", "list", "download", "delete"):
|
|
427
|
+
assert sub in out
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def test_chat_download_present(capsys):
|
|
431
|
+
parser = cli.build_parser()
|
|
432
|
+
with pytest.raises(SystemExit) as exc:
|
|
433
|
+
parser.parse_args(["chat", "--help"])
|
|
434
|
+
assert exc.value.code == 0
|
|
435
|
+
assert "download" in capsys.readouterr().out
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def test_file_attach_posts_json_body(monkeypatch, tmp_path):
|
|
439
|
+
"""`mesh file attach` must hit the §78 JSON endpoint with the
|
|
440
|
+
base64 body shape the server expects."""
|
|
441
|
+
import base64 as b64
|
|
442
|
+
|
|
443
|
+
f = tmp_path / "pic.png"
|
|
444
|
+
f.write_bytes(b"\x89PNG\r\n\x1a\nfake")
|
|
445
|
+
captured = {}
|
|
446
|
+
|
|
447
|
+
def fake_api_call(method, path, *, cfg, body=None, **kw):
|
|
448
|
+
captured["method"] = method
|
|
449
|
+
captured["path"] = path
|
|
450
|
+
captured["body"] = body
|
|
451
|
+
return {"data": {"id": "att-1", "filename": "pic.png",
|
|
452
|
+
"byteSize": 12, "mimeType": "image/png"}}
|
|
453
|
+
|
|
454
|
+
monkeypatch.setattr(cli, "_api_call", fake_api_call)
|
|
455
|
+
args = type("A", (), {
|
|
456
|
+
"entity_type": "lead", "entity_id": "e-1", "file": str(f),
|
|
457
|
+
"filename": None, "mime": None, "json": False,
|
|
458
|
+
})()
|
|
459
|
+
rc = cli.cmd_file_attach(args, {"token": "t"})
|
|
460
|
+
assert rc == 0
|
|
461
|
+
assert captured["method"] == "POST"
|
|
462
|
+
assert captured["path"] == "/api/entities/lead/e-1/attachments/json"
|
|
463
|
+
assert captured["body"]["filename"] == "pic.png"
|
|
464
|
+
assert captured["body"]["mimeType"] == "image/png"
|
|
465
|
+
assert b64.b64decode(captured["body"]["base64Bytes"]).startswith(b"\x89PNG")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def test_file_download_writes_bytes(monkeypatch, tmp_path):
|
|
469
|
+
"""`mesh file download` writes raw bytes to --out and respects the
|
|
470
|
+
server-provided filename when --out is a directory."""
|
|
471
|
+
def fake_download(path, *, cfg):
|
|
472
|
+
assert path == "/api/entity-attachments/att-9/download"
|
|
473
|
+
return b"hello-bytes", "served-name.bin"
|
|
474
|
+
|
|
475
|
+
monkeypatch.setattr(cli, "_api_download", fake_download)
|
|
476
|
+
outdir = tmp_path / "dl"
|
|
477
|
+
outdir.mkdir()
|
|
478
|
+
args = type("A", (), {
|
|
479
|
+
"attachment_id": "att-9", "out": str(outdir), "json": False,
|
|
480
|
+
})()
|
|
481
|
+
rc = cli.cmd_file_download(args, {"token": "t"})
|
|
482
|
+
assert rc == 0
|
|
483
|
+
assert (outdir / "served-name.bin").read_bytes() == b"hello-bytes"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def test_chat_download_uses_chat_endpoint(monkeypatch, tmp_path):
|
|
487
|
+
def fake_download(path, *, cfg):
|
|
488
|
+
assert path == "/api/chat-attachments/att-5/download"
|
|
489
|
+
return b"x", "f.txt"
|
|
490
|
+
|
|
491
|
+
monkeypatch.setattr(cli, "_api_download", fake_download)
|
|
492
|
+
args = type("A", (), {
|
|
493
|
+
"attachment_id": "att-5", "out": str(tmp_path / "f.txt"), "json": False,
|
|
494
|
+
})()
|
|
495
|
+
assert cli.cmd_chat_download(args, {"token": "t"}) == 0
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def test_content_disposition_parsing(monkeypatch):
|
|
499
|
+
"""UTF-8 filename* form wins over the plain fallback."""
|
|
500
|
+
class FakeResp:
|
|
501
|
+
headers = {"Content-Disposition":
|
|
502
|
+
"attachment; filename=\"fallback.txt\"; filename*=UTF-8''r%C3%B3%C5%BCa.png"}
|
|
503
|
+
def read(self):
|
|
504
|
+
return b"data"
|
|
505
|
+
def __enter__(self):
|
|
506
|
+
return self
|
|
507
|
+
def __exit__(self, *a):
|
|
508
|
+
return False
|
|
509
|
+
|
|
510
|
+
monkeypatch.setattr(cli.urllib.request, "urlopen", lambda req, timeout=60: FakeResp())
|
|
511
|
+
raw, name = cli._api_download("/api/entity-attachments/x/download",
|
|
512
|
+
cfg={"token": "t"})
|
|
513
|
+
assert raw == b"data"
|
|
514
|
+
assert name == "róża.png"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|