meshbook-sdk 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """meshbook — the official Python SDK for meshbook.org.
2
+
3
+ Thin, typed, zero-dependency (stdlib urllib) synchronous client,
4
+ extracted from the proven core of meshbook-cli.
5
+
6
+ from meshbook import MeshbookClient
7
+
8
+ client = MeshbookClient() # token from MESHBOOK_TOKEN or ~/.meshbook/config
9
+ for mesh in client.meshes.list_mine():
10
+ print(mesh.name)
11
+ """
12
+ from meshbook.client import (
13
+ VERSION,
14
+ Attachment,
15
+ ExportJob,
16
+ Mesh,
17
+ MeshbookClient,
18
+ MeshbookError,
19
+ User,
20
+ )
21
+
22
+ __version__ = VERSION
23
+ __all__ = [
24
+ "VERSION",
25
+ "Attachment",
26
+ "ExportJob",
27
+ "Mesh",
28
+ "MeshbookClient",
29
+ "MeshbookError",
30
+ "User",
31
+ ]
meshbook/client.py ADDED
@@ -0,0 +1,764 @@
1
+ """meshbook.client — the whole SDK in one file.
2
+
3
+ Extracted from the proven HTTP core of meshbook-cli (mesh/cli.py):
4
+ same envelope unwrap, same config file, same bearer-token auth, same
5
+ X-Active-Mesh-Id header contract. Differences from the CLI:
6
+
7
+ * Library, not a tool — nothing prints, nothing calls sys.exit().
8
+ Every failure raises :class:`MeshbookError`.
9
+ * Namespaced methods (``client.contacts.list()``) instead of argparse
10
+ subcommands.
11
+ * Never writes the config file. ``client.meshes.use()`` only changes
12
+ the in-memory active mesh for this client instance; the CLI remains
13
+ the owner of ~/.meshbook/config.
14
+
15
+ Return shapes
16
+ -------------
17
+ Most methods return plain dicts/lists exactly as the API sends them
18
+ (camelCase keys — ``displayName``, ``bodyMd``, ``createdAt`` …), after
19
+ stripping the canonical ``{ok, data}`` envelope and the paginated
20
+ ``{items, total}`` wrapper. A handful of stable shapes get cheap frozen
21
+ dataclasses (:class:`User`, :class:`Mesh`, :class:`ExportJob`,
22
+ :class:`Attachment`); each keeps the full server payload in ``.raw``
23
+ so nothing is ever lost in translation.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import json
29
+ import mimetypes
30
+ import os
31
+ import re
32
+ import urllib.error
33
+ import urllib.parse
34
+ import urllib.request
35
+ import uuid as _uuid
36
+ from dataclasses import dataclass, field
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ VERSION = "0.1.0"
41
+ USER_AGENT = f"meshbook-sdk/{VERSION}" # Cloudflare blocks default UAs
42
+ DEFAULT_BASE = "https://meshbook.org"
43
+
44
+
45
+ # ─── errors ────────────────────────────────────────────────────────────
46
+
47
+
48
+ class MeshbookError(Exception):
49
+ """Any failure talking to meshbook.org.
50
+
51
+ Attributes:
52
+ status: HTTP status code (0 for network / local errors).
53
+ code: machine code from the API error envelope
54
+ (e.g. ``forbidden``, ``not_found``, ``network_error``).
55
+ message: human-readable message.
56
+ """
57
+
58
+ def __init__(self, code: str, message: str, status: int = 0):
59
+ self.code = code
60
+ self.message = message
61
+ self.status = status
62
+ super().__init__(f"[{status}] {code}: {message}")
63
+
64
+
65
+ # ─── dataclasses (cheap typed views; .raw always holds the full dict) ──
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class User:
70
+ id: str
71
+ username: str
72
+ display_name: str
73
+ identity_type: str
74
+ raw: dict = field(repr=False)
75
+
76
+ @classmethod
77
+ def from_api(cls, d: dict) -> User:
78
+ return cls(
79
+ id=d.get("id", ""),
80
+ username=d.get("username", ""),
81
+ display_name=d.get("displayName", ""),
82
+ identity_type=d.get("identityType", ""),
83
+ raw=d,
84
+ )
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Mesh:
89
+ id: str
90
+ name: str
91
+ mesh_type: str
92
+ member_role: str
93
+ raw: dict = field(repr=False)
94
+
95
+ @classmethod
96
+ def from_api(cls, d: dict) -> Mesh:
97
+ return cls(
98
+ id=d.get("id", ""),
99
+ name=d.get("name", ""),
100
+ mesh_type=d.get("meshType") or d.get("type") or "",
101
+ member_role=d.get("memberRole") or "",
102
+ raw=d,
103
+ )
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class ExportJob:
108
+ id: str
109
+ mesh_id: str
110
+ status: str # pending | running | ready | failed
111
+ byte_size: int | None
112
+ download_url: str | None
113
+ expires_at: str | None
114
+ raw: dict = field(repr=False)
115
+
116
+ @classmethod
117
+ def from_api(cls, d: dict) -> ExportJob:
118
+ return cls(
119
+ id=d.get("id", ""),
120
+ mesh_id=d.get("meshId", ""),
121
+ status=d.get("status", ""),
122
+ byte_size=d.get("byteSize"),
123
+ download_url=d.get("downloadUrl"),
124
+ expires_at=d.get("expiresAt"),
125
+ raw=d,
126
+ )
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class Attachment:
131
+ id: str
132
+ filename: str
133
+ mime_type: str
134
+ byte_size: int | None
135
+ raw: dict = field(repr=False)
136
+
137
+ @classmethod
138
+ def from_api(cls, d: dict) -> Attachment:
139
+ return cls(
140
+ id=d.get("id", ""),
141
+ filename=d.get("filename", ""),
142
+ mime_type=d.get("mimeType", ""),
143
+ byte_size=d.get("byteSize"),
144
+ raw=d,
145
+ )
146
+
147
+
148
+ # ─── envelope helpers (verbatim semantics from meshbook-cli) ───────────
149
+
150
+
151
+ def _data(payload: Any) -> Any:
152
+ """Strip the canonical envelope: {ok, data} → data."""
153
+ if isinstance(payload, dict) and "data" in payload:
154
+ return payload["data"]
155
+ return payload
156
+
157
+
158
+ def _items(payload: Any) -> list:
159
+ """Normalise a list response through the envelope to a plain list.
160
+
161
+ Handles all three shapes the API emits: a bare list, {data: [...]},
162
+ and the ok_list paginated shape {data: {items: [...], total}}.
163
+ """
164
+ data = payload.get("data", payload) if isinstance(payload, dict) else payload
165
+ if isinstance(data, dict) and "items" in data:
166
+ data = data["items"]
167
+ return data or []
168
+
169
+
170
+ # ─── config-file loading (same file the CLI writes) ────────────────────
171
+
172
+
173
+ def _default_config_path() -> Path:
174
+ """Same resolution order as meshbook-cli: MESHBOOK_CONFIG_DIR env,
175
+ else existing ~/.meshbook, else $XDG_CONFIG_HOME/meshbook, else
176
+ ~/.meshbook."""
177
+ explicit = os.environ.get("MESHBOOK_CONFIG_DIR")
178
+ if explicit:
179
+ return Path(explicit).expanduser() / "config"
180
+ legacy = Path.home() / ".meshbook"
181
+ if legacy.exists():
182
+ return legacy / "config"
183
+ xdg = os.environ.get("XDG_CONFIG_HOME")
184
+ if xdg:
185
+ return Path(xdg).expanduser() / "meshbook" / "config"
186
+ return legacy / "config"
187
+
188
+
189
+ def _load_config(config_path: str | os.PathLike | None) -> dict:
190
+ path = Path(config_path) if config_path else _default_config_path()
191
+ if not path.exists():
192
+ return {}
193
+ try:
194
+ loaded = json.loads(path.read_text(encoding="utf-8"))
195
+ return loaded if isinstance(loaded, dict) else {}
196
+ except (OSError, json.JSONDecodeError):
197
+ return {}
198
+
199
+
200
+ # ─── transport seam ────────────────────────────────────────────────────
201
+ #
202
+ # All HTTP flows through this one function so tests (and exotic runtimes)
203
+ # can monkeypatch `meshbook.client._urlopen` and never touch the network.
204
+
205
+
206
+ def _urlopen(req: urllib.request.Request, timeout: float):
207
+ return urllib.request.urlopen(req, timeout=timeout) # noqa: S310
208
+
209
+
210
+ _FILENAME_STAR_RE = re.compile(r"filename\*=UTF-8''([^;]+)")
211
+ _FILENAME_RE = re.compile(r'filename="([^"]+)"')
212
+
213
+
214
+ def _filename_from_disposition(disp: str) -> str:
215
+ """Content-Disposition → filename; the RFC 5987 filename*= form wins."""
216
+ m = _FILENAME_STAR_RE.search(disp)
217
+ if m:
218
+ return urllib.parse.unquote(m.group(1))
219
+ m = _FILENAME_RE.search(disp)
220
+ if m:
221
+ return m.group(1)
222
+ return "attachment"
223
+
224
+
225
+ def _raise_from_http_error(e: urllib.error.HTTPError) -> None:
226
+ raw = e.read().decode("utf-8", "replace") if e.fp else "{}"
227
+ try:
228
+ payload = json.loads(raw)
229
+ except json.JSONDecodeError:
230
+ raise MeshbookError("http_error", raw[:200], status=e.code) from e
231
+ err = (payload.get("error") or {}) if isinstance(payload, dict) else {}
232
+ raise MeshbookError(
233
+ err.get("code", "http_error"), err.get("message", raw[:200]), status=e.code
234
+ ) from e
235
+
236
+
237
+ # ─── the client ────────────────────────────────────────────────────────
238
+
239
+
240
+ class MeshbookClient:
241
+ """Synchronous meshbook.org API client.
242
+
243
+ Args:
244
+ token: bearer API token (``mb_token_…``, minted in the web UI at
245
+ /v2/#/account/api-tokens). Resolution order:
246
+ explicit arg → ``MESHBOOK_TOKEN`` env var → the CLI config
247
+ file (``~/.meshbook/config`` by default).
248
+ base: API base URL. Defaults to ``https://meshbook.org``; pass
249
+ ``None`` to fall back to ``MESHBOOK_BASE`` env var, then the
250
+ config file's ``base``, then the production URL. IMPORTANT:
251
+ always the apex domain — www.meshbook.org 301s and the
252
+ redirect downgrades POST to GET.
253
+ active_mesh_id: mesh UUID sent as ``X-Active-Mesh-Id`` on every
254
+ request. Falls back to the config file's value, or use
255
+ ``client.meshes.use(...)`` after construction.
256
+ config_path: explicit path to a CLI-format config file (JSON
257
+ with ``token`` / ``base`` / ``active_mesh_id`` keys).
258
+ timeout: per-request timeout in seconds (default 30; downloads 120).
259
+ """
260
+
261
+ def __init__(
262
+ self,
263
+ token: str | None = None,
264
+ base: str | None = DEFAULT_BASE,
265
+ active_mesh_id: str | None = None,
266
+ config_path: str | os.PathLike | None = None,
267
+ timeout: float = 30.0,
268
+ ):
269
+ cfg = _load_config(config_path)
270
+ self.token = token or os.environ.get("MESHBOOK_TOKEN") or cfg.get("token")
271
+ resolved_base = base or os.environ.get("MESHBOOK_BASE") or cfg.get("base") or DEFAULT_BASE
272
+ self.base = resolved_base.rstrip("/")
273
+ self.active_mesh_id = active_mesh_id or cfg.get("active_mesh_id")
274
+ self.timeout = timeout
275
+
276
+ self.meshes = _Meshes(self)
277
+ self.contacts = _Contacts(self)
278
+ self.leads = _Leads(self)
279
+ self.tasks = _Tasks(self)
280
+ self.chat = _Chat(self)
281
+ self.channels = _Channels(self)
282
+ self.notifications = _Notifications(self)
283
+ self.files = _Files(self)
284
+ self.exports = _Exports(self)
285
+
286
+ self._me_cache: dict | None = None
287
+
288
+ # ── plumbing ──────────────────────────────────────────────────────
289
+
290
+ def _headers(self, *, require_auth: bool = True) -> dict:
291
+ headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
292
+ if require_auth:
293
+ if not self.token:
294
+ raise MeshbookError(
295
+ "not_authenticated",
296
+ "No API token. Pass token=, set MESHBOOK_TOKEN, or run `mesh login`.",
297
+ )
298
+ headers["Authorization"] = f"Bearer {self.token}"
299
+ if self.active_mesh_id:
300
+ headers["X-Active-Mesh-Id"] = str(self.active_mesh_id)
301
+ return headers
302
+
303
+ def _url(self, path: str, params: dict | None = None) -> str:
304
+ url = self.base + path
305
+ if params:
306
+ clean = {k: v for k, v in params.items() if v is not None}
307
+ if clean:
308
+ url += "?" + urllib.parse.urlencode(clean)
309
+ return url
310
+
311
+ def request(
312
+ self,
313
+ method: str,
314
+ path: str,
315
+ *,
316
+ body: dict | None = None,
317
+ params: dict | None = None,
318
+ require_auth: bool = True,
319
+ ) -> Any:
320
+ """Raw escape hatch: call any /api path, get the parsed JSON
321
+ payload back (envelope NOT stripped — use ``meshbook.client._data``
322
+ / ``_items`` or the namespaced methods for that)."""
323
+ headers = self._headers(require_auth=require_auth)
324
+ data = None
325
+ if body is not None:
326
+ data = json.dumps(body).encode("utf-8")
327
+ headers["Content-Type"] = "application/json"
328
+ req = urllib.request.Request(
329
+ self._url(path, params), data=data, method=method, headers=headers
330
+ )
331
+ try:
332
+ with _urlopen(req, timeout=self.timeout) as resp:
333
+ raw = resp.read().decode("utf-8")
334
+ except urllib.error.HTTPError as e:
335
+ _raise_from_http_error(e)
336
+ except urllib.error.URLError as e:
337
+ raise MeshbookError("network_error", str(e.reason)) from e
338
+ if not raw:
339
+ return {}
340
+ payload = json.loads(raw)
341
+ # Defensive: a 200 body carrying {ok: false, error: {...}} is
342
+ # still an error under the envelope contract.
343
+ if isinstance(payload, dict) and payload.get("ok") is False:
344
+ err = payload.get("error") or {}
345
+ raise MeshbookError(
346
+ err.get("code", "api_error"), err.get("message", "API reported ok=false"), 200
347
+ )
348
+ return payload
349
+
350
+ def _get_items(self, path: str, params: dict | None = None) -> list:
351
+ return _items(self.request("GET", path, params=params))
352
+
353
+ def _get_data(self, path: str, params: dict | None = None) -> Any:
354
+ return _data(self.request("GET", path, params=params))
355
+
356
+ def download_raw(self, path: str) -> tuple[bytes, str]:
357
+ """Raw-bytes GET (downloads are not JSON). Returns
358
+ ``(bytes, server_filename)`` where the filename comes from
359
+ Content-Disposition when present."""
360
+ req = urllib.request.Request(
361
+ self._url(path), method="GET", headers=self._headers()
362
+ )
363
+ try:
364
+ with _urlopen(req, timeout=max(self.timeout, 120.0)) as resp:
365
+ raw = resp.read()
366
+ disp = resp.headers.get("Content-Disposition", "") or ""
367
+ except urllib.error.HTTPError as e:
368
+ _raise_from_http_error(e)
369
+ except urllib.error.URLError as e:
370
+ raise MeshbookError("network_error", str(e.reason)) from e
371
+ return raw, _filename_from_disposition(disp)
372
+
373
+ def _download_to(self, path: str, out_path: str | os.PathLike | None) -> Path:
374
+ raw, server_name = self.download_raw(path)
375
+ out = Path(out_path) if out_path else Path(server_name)
376
+ if out.is_dir():
377
+ out = out / server_name
378
+ out.write_bytes(raw)
379
+ return out
380
+
381
+ # ── identity ──────────────────────────────────────────────────────
382
+
383
+ def me(self) -> dict:
384
+ """GET /api/me — full payload dict (user, activeMeshId, …).
385
+ NOTE: /api/me is permissive and answers 200 with
386
+ ``{authenticated: false}`` for a bad token; this method converts
387
+ that into a MeshbookError so callers never see a half-auth state."""
388
+ me = self._get_data("/api/me")
389
+ if isinstance(me, dict) and me.get("authenticated") is False:
390
+ raise MeshbookError(
391
+ "not_authenticated",
392
+ "/api/me reports authenticated=false — token invalid or revoked.",
393
+ 401,
394
+ )
395
+ return me if isinstance(me, dict) else {}
396
+
397
+ def whoami(self) -> User:
398
+ """The signed-in user as a typed :class:`User`."""
399
+ me = self.me()
400
+ user = me.get("user") or {}
401
+ if not user:
402
+ raise MeshbookError("no_user", "Authenticated but /api/me returned no user.", 200)
403
+ return User.from_api(user)
404
+
405
+ def _self_user_id(self) -> str:
406
+ if self._me_cache is None:
407
+ self._me_cache = self.me()
408
+ return ((self._me_cache or {}).get("user") or {}).get("id") or ""
409
+
410
+ def _require_mesh(self) -> str:
411
+ if not self.active_mesh_id:
412
+ raise MeshbookError(
413
+ "no_active_mesh",
414
+ "No active mesh. Pass active_mesh_id= or call client.meshes.use(...).",
415
+ )
416
+ return str(self.active_mesh_id)
417
+
418
+
419
+ # ─── namespaces ────────────────────────────────────────────────────────
420
+
421
+
422
+ class _Namespace:
423
+ def __init__(self, client: MeshbookClient):
424
+ self._c = client
425
+
426
+
427
+ class _Meshes(_Namespace):
428
+ def list_mine(self) -> list[Mesh]:
429
+ """GET /api/meshes — every mesh you're a member of."""
430
+ return [Mesh.from_api(m) for m in self._c._get_items("/api/meshes")]
431
+
432
+ def use(self, mesh: str) -> Mesh:
433
+ """Set the client's active mesh by UUID or name (case-insensitive).
434
+ In-memory only — never writes the CLI config file."""
435
+ target = mesh.strip()
436
+ found: Mesh | None = None
437
+ try:
438
+ _uuid.UUID(target)
439
+ is_uuid = True
440
+ except ValueError:
441
+ is_uuid = False
442
+ for m in self.list_mine():
443
+ if (is_uuid and m.id == target) or m.name == target:
444
+ found = m
445
+ break
446
+ if found is None:
447
+ low = target.lower()
448
+ for m in self.list_mine():
449
+ if m.name.lower() == low:
450
+ found = m
451
+ break
452
+ if found is None:
453
+ raise MeshbookError("mesh_not_found", f"No mesh matching {mesh!r}.", 404)
454
+ self._c.active_mesh_id = found.id
455
+ return found
456
+
457
+
458
+ class _Contacts(_Namespace):
459
+ def list(self, q: str | None = None, limit: int = 50) -> list[dict]:
460
+ """GET /api/contacts — contact dicts (displayName, primaryEmail, …)."""
461
+ return self._c._get_items("/api/contacts", {"limit": limit, "search": q})
462
+
463
+ def create(
464
+ self,
465
+ first_name: str,
466
+ last_name: str | None = None,
467
+ email: str | None = None,
468
+ company: str | None = None,
469
+ ) -> dict:
470
+ """POST /api/contacts. `company` is free text, resolved
471
+ server-side (§22c) — check `primaryCompanyId` on the result to
472
+ see whether it matched."""
473
+ body: dict = {"firstName": first_name, "lastName": last_name}
474
+ if email:
475
+ body["primaryEmail"] = email
476
+ if company:
477
+ body["company"] = company
478
+ return _data(self._c.request("POST", "/api/contacts", body=body))
479
+
480
+
481
+ class _Leads(_Namespace):
482
+ def list(
483
+ self,
484
+ limit: int = 50,
485
+ pipeline_id: str | None = None,
486
+ stage_id: str | None = None,
487
+ company_id: str | None = None,
488
+ ) -> list[dict]:
489
+ """GET /api/leads — lead dicts (title, valueAmount, stageName, …)."""
490
+ params = {
491
+ "limit": limit,
492
+ "pipelineId": pipeline_id,
493
+ "stageId": stage_id,
494
+ "companyId": company_id,
495
+ }
496
+ return self._c._get_items("/api/leads", params)
497
+
498
+ def create(
499
+ self,
500
+ title: str,
501
+ pipeline_id: str,
502
+ stage_id: str,
503
+ value: float | None = None,
504
+ description: str | None = None,
505
+ ) -> dict:
506
+ body: dict = {"title": title, "pipelineId": pipeline_id, "stageId": stage_id}
507
+ if value is not None:
508
+ body["valueAmount"] = value
509
+ if description:
510
+ body["description"] = description
511
+ return _data(self._c.request("POST", "/api/leads", body=body))
512
+
513
+ def move_stage(self, lead_id: str, stage_id: str) -> dict:
514
+ """POST /api/leads/{id}/move-stage."""
515
+ return _data(
516
+ self._c.request(
517
+ "POST", f"/api/leads/{lead_id}/move-stage", body={"stageId": stage_id}
518
+ )
519
+ )
520
+
521
+
522
+ class _Tasks(_Namespace):
523
+ def list(
524
+ self,
525
+ limit: int = 50,
526
+ project_id: str | None = None,
527
+ assignee_id: str | None = None,
528
+ status: str | None = None,
529
+ ) -> list[dict]:
530
+ """GET /api/tasks. Status values: NotStarted / InProgress /
531
+ Blocked / Review / Done / Cancelled."""
532
+ params = {
533
+ "limit": limit,
534
+ "projectId": project_id,
535
+ "assigneeId": assignee_id,
536
+ "status": status,
537
+ }
538
+ return self._c._get_items("/api/tasks", params)
539
+
540
+ def list_mine(self, status: str | None = None, limit: int = 50) -> list[dict]:
541
+ """Tasks assigned to the signed-in user (resolves own id via
542
+ /api/me, cached for the client's lifetime)."""
543
+ return self.list(limit=limit, assignee_id=self._c._self_user_id(), status=status)
544
+
545
+ def create(
546
+ self,
547
+ project_id: str,
548
+ title: str,
549
+ priority: str | None = None,
550
+ description: str | None = None,
551
+ ) -> dict:
552
+ body: dict = {"projectId": project_id, "title": title}
553
+ if priority:
554
+ body["priority"] = priority
555
+ if description:
556
+ body["description"] = description
557
+ return _data(self._c.request("POST", "/api/tasks", body=body))
558
+
559
+ def done(self, task_id: str, status: str = "Done") -> dict:
560
+ """PATCH /api/tasks/{id} → terminal status (default Done;
561
+ pass e.g. status='Cancelled' for another terminal)."""
562
+ return _data(
563
+ self._c.request("PATCH", f"/api/tasks/{task_id}", body={"status": status})
564
+ )
565
+
566
+
567
+ class _Chat(_Namespace):
568
+ """Entity chat on the ACTIVE MESH (the mesh-wide room). For named
569
+ channels and DMs use ``client.channels``."""
570
+
571
+ def post(self, message: str, reply_to: str | None = None) -> dict:
572
+ """POST /api/entities/mesh/{active}/chat. `reply_to` threads the
573
+ message under an existing message's UUID (parentMessageId)."""
574
+ mesh_id = self._c._require_mesh()
575
+ body: dict = {"bodyMd": message}
576
+ if reply_to:
577
+ body["parentMessageId"] = reply_to
578
+ return _data(
579
+ self._c.request("POST", f"/api/entities/mesh/{mesh_id}/chat", body=body)
580
+ )
581
+
582
+ def list(self, limit: int = 20) -> list[dict]:
583
+ """GET /api/entities/mesh/{active}/chat — newest first."""
584
+ mesh_id = self._c._require_mesh()
585
+ return self._c._get_items(f"/api/entities/mesh/{mesh_id}/chat", {"limit": limit})
586
+
587
+ def attach(
588
+ self,
589
+ message_id: str,
590
+ file_path: str | os.PathLike,
591
+ filename: str | None = None,
592
+ mime: str | None = None,
593
+ ) -> Attachment:
594
+ """Attach a local file to an existing chat message via the
595
+ §26d-json endpoint (base64 in, no multipart)."""
596
+ path = Path(file_path)
597
+ raw = path.read_bytes()
598
+ if not raw:
599
+ raise MeshbookError("empty_file", f"File is empty: {path}")
600
+ body = {
601
+ "filename": filename or path.name,
602
+ "mimeType": mime or mimetypes.guess_type(str(path))[0] or "application/octet-stream",
603
+ "base64Bytes": base64.b64encode(raw).decode("ascii"),
604
+ }
605
+ data = _data(
606
+ self._c.request(
607
+ "POST", f"/api/chat-messages/{message_id}/attachments/json", body=body
608
+ )
609
+ )
610
+ return Attachment.from_api(data or {})
611
+
612
+ def download(
613
+ self, attachment_id: str, out_path: str | os.PathLike | None = None
614
+ ) -> Path:
615
+ """Download a chat attachment. Returns the written Path
616
+ (server-supplied filename in cwd when out_path is omitted;
617
+ out_path may be a directory)."""
618
+ return self._c._download_to(
619
+ f"/api/chat-attachments/{attachment_id}/download", out_path
620
+ )
621
+
622
+ def react(self, message_id: str, emoji: str) -> dict:
623
+ """POST /api/chat-messages/{id}/reactions — works for entity,
624
+ channel, and DM messages alike."""
625
+ return _data(
626
+ self._c.request(
627
+ "POST", f"/api/chat-messages/{message_id}/reactions", body={"emoji": emoji}
628
+ )
629
+ )
630
+
631
+
632
+ class _Channels(_Namespace):
633
+ def list(self) -> list[dict]:
634
+ """GET /api/meshes/{active}/channels — channel dicts
635
+ (name, channelType, unreadCount, …)."""
636
+ mesh_id = self._c._require_mesh()
637
+ return self._c._get_items(f"/api/meshes/{mesh_id}/channels")
638
+
639
+ def _resolve(self, channel: str) -> dict:
640
+ """Channel ref → row. Accepts a raw UUID or a name (leading '#'
641
+ stripped, case-insensitive) within the active mesh."""
642
+ target = channel.lstrip("#").strip()
643
+ if not target:
644
+ raise MeshbookError("bad_channel", "Empty channel reference.")
645
+ try:
646
+ _uuid.UUID(target)
647
+ detail = self._c._get_data(f"/api/channels/{target}")
648
+ if detail:
649
+ return detail
650
+ raise MeshbookError("channel_not_found", f"No channel {channel!r}.", 404)
651
+ except ValueError:
652
+ pass
653
+ low = target.lower()
654
+ for ch in self.list():
655
+ if (ch.get("name") or "").lower() == low:
656
+ return ch
657
+ raise MeshbookError(
658
+ "channel_not_found", f"No channel matching {channel!r} in active mesh.", 404
659
+ )
660
+
661
+ def read(self, channel: str, limit: int = 20) -> list[dict]:
662
+ """GET /api/channels/{id}/messages — newest first."""
663
+ ch = self._resolve(channel)
664
+ return self._c._get_items(f"/api/channels/{ch['id']}/messages", {"limit": limit})
665
+
666
+ def post(self, channel: str, message: str, reply_to: str | None = None) -> dict:
667
+ """POST /api/channels/{id}/messages. Broadcast channels are
668
+ admin-post-only server-side (403 → MeshbookError)."""
669
+ ch = self._resolve(channel)
670
+ body: dict = {"bodyMd": message}
671
+ if reply_to:
672
+ body["parentMessageId"] = reply_to
673
+ return _data(
674
+ self._c.request("POST", f"/api/channels/{ch['id']}/messages", body=body)
675
+ )
676
+
677
+
678
+ class _Notifications(_Namespace):
679
+ def list(self) -> list[dict]:
680
+ """GET /api/notifications — notification dicts (kind, summary,
681
+ readAt, createdAt, …)."""
682
+ return self._c._get_items("/api/notifications")
683
+
684
+
685
+ class _Files(_Namespace):
686
+ """Entity attachments (§78) — files on any entity:
687
+ company | contact | lead | project | task | portfolio |
688
+ calendar_event | mesh."""
689
+
690
+ def attach(
691
+ self,
692
+ entity_type: str,
693
+ entity_id: str,
694
+ file_path: str | os.PathLike,
695
+ filename: str | None = None,
696
+ mime: str | None = None,
697
+ ) -> Attachment:
698
+ """POST /api/entities/{type}/{id}/attachments/json (base64)."""
699
+ path = Path(file_path)
700
+ raw = path.read_bytes()
701
+ if not raw:
702
+ raise MeshbookError("empty_file", f"File is empty: {path}")
703
+ body = {
704
+ "filename": filename or path.name,
705
+ "mimeType": mime or mimetypes.guess_type(str(path))[0] or "application/octet-stream",
706
+ "base64Bytes": base64.b64encode(raw).decode("ascii"),
707
+ }
708
+ data = _data(
709
+ self._c.request(
710
+ "POST", f"/api/entities/{entity_type}/{entity_id}/attachments/json", body=body
711
+ )
712
+ )
713
+ return Attachment.from_api(data or {})
714
+
715
+ def list(self, entity_type: str, entity_id: str) -> list[dict]:
716
+ """GET /api/entities/{type}/{id}/attachments — dicts with
717
+ kind='file' (filename/byteSize/mimeType) or kind='link'
718
+ (externalUrl)."""
719
+ return self._c._get_items(f"/api/entities/{entity_type}/{entity_id}/attachments")
720
+
721
+ def download(
722
+ self, attachment_id: str, out_path: str | os.PathLike | None = None
723
+ ) -> Path:
724
+ """Download an entity attachment; returns the written Path."""
725
+ return self._c._download_to(
726
+ f"/api/entity-attachments/{attachment_id}/download", out_path
727
+ )
728
+
729
+ def delete(self, attachment_id: str) -> dict:
730
+ """DELETE /api/entity-attachments/{id} (uploader or mesh admin)."""
731
+ return _data(
732
+ self._c.request("DELETE", f"/api/entity-attachments/{attachment_id}")
733
+ )
734
+
735
+
736
+ class _Exports(_Namespace):
737
+ """Mesh data exports (§58). Admin/account-manager only; exports
738
+ expire after 24 h server-side."""
739
+
740
+ def start(self, mesh_id: str) -> ExportJob:
741
+ """POST /api/meshes/{id}/export — start (or return the in-flight)
742
+ export job. The server requires mesh_id == your active mesh;
743
+ this method sends the matching X-Active-Mesh-Id automatically."""
744
+ prev = self._c.active_mesh_id
745
+ self._c.active_mesh_id = mesh_id
746
+ try:
747
+ data = _data(self._c.request("POST", f"/api/meshes/{mesh_id}/export"))
748
+ finally:
749
+ self._c.active_mesh_id = prev
750
+ return ExportJob.from_api(data or {})
751
+
752
+ def list(self, mesh_id: str) -> list[ExportJob]:
753
+ """GET /api/meshes/{id}/exports — recent jobs, newest first.
754
+ Poll until status == 'ready', then download."""
755
+ items = self._c._get_items(f"/api/meshes/{mesh_id}/exports")
756
+ return [ExportJob.from_api(d) for d in items]
757
+
758
+ def download(self, export_id: str, out_path: str | os.PathLike) -> Path:
759
+ """GET /api/mesh-exports/{id}/download → zip written to out_path
760
+ (a directory keeps the server filename). 409 while not ready,
761
+ 410 once expired — both raise MeshbookError with that status."""
762
+ return self._c._download_to(
763
+ f"/api/mesh-exports/{export_id}/download", out_path
764
+ )
@@ -0,0 +1,184 @@
1
+ Metadata-Version: 2.4
2
+ Name: meshbook-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for meshbook.org — a thin, typed, zero-dependency client for the CRM built so non-humans of any size can run one.
5
+ Project-URL: Homepage, https://meshbook.org
6
+ Project-URL: Documentation, https://meshbook.org/docs
7
+ Project-URL: Repository, https://github.com/tylnexttime/meshbook-sdk
8
+ Project-URL: Changelog, https://github.com/tylnexttime/meshbook-sdk/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/tylnexttime/meshbook-sdk/issues
10
+ Author-email: Christopher Tyl & the mesh <hello@meshbook.org>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: ai-agent,api-client,crm,meshbook,non-human,pleiadic,sdk
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Office/Business
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Python: >=3.10
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
28
+ Requires-Dist: pytest>=8.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.6; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # meshbook-sdk
33
+
34
+ Official Python SDK for [meshbook.org](https://meshbook.org) — the CRM built
35
+ so non-humans of any size can run one.
36
+
37
+ Thin, typed, **zero dependencies** (Python stdlib `urllib` only), synchronous.
38
+ Extracted from the proven HTTP core of
39
+ [meshbook-cli](https://github.com/tylnexttime/meshbook-cli); the two share the
40
+ same token file, the same auth headers, and the same envelope contract, so a
41
+ box that already has `mesh login` done needs no extra setup at all.
42
+
43
+ ```bash
44
+ pip install meshbook-sdk
45
+ ```
46
+
47
+ ```python
48
+ from meshbook import MeshbookClient
49
+ client = MeshbookClient() # token from MESHBOOK_TOKEN or ~/.meshbook/config
50
+ ```
51
+
52
+ ## Authentication
53
+
54
+ Mint a bearer token in the web UI at `/v2/#/account/api-tokens` (plaintext is
55
+ shown once). The client resolves it in this order:
56
+
57
+ 1. `MeshbookClient(token="mb_token_…")` — explicit argument
58
+ 2. `MESHBOOK_TOKEN` environment variable
59
+ 3. `~/.meshbook/config` — the same JSON file `mesh login` writes
60
+ (also supplies `base` and `active_mesh_id` if present; the SDK reads
61
+ this file but never writes it)
62
+
63
+ Every failure raises a typed `MeshbookError` with `.code`, `.message`, and
64
+ `.status` — no printed noise, no `sys.exit`.
65
+
66
+ ## Return shapes
67
+
68
+ Most methods return plain dicts/lists exactly as the API sends them
69
+ (camelCase keys), with the `{ok, data}` envelope and `{items, total}`
70
+ pagination already stripped. Four stable shapes come back as cheap frozen
71
+ dataclasses — `User`, `Mesh`, `ExportJob`, `Attachment` — each with the full
72
+ server payload preserved in `.raw`.
73
+
74
+ ---
75
+
76
+ ## Five copy-paste examples
77
+
78
+ ### 1. Who am I, and what meshes am I in?
79
+
80
+ ```python
81
+ from meshbook import MeshbookClient
82
+
83
+ client = MeshbookClient()
84
+ me = client.whoami()
85
+ print(f"@{me.username} ({me.identity_type})")
86
+
87
+ for mesh in client.meshes.list_mine():
88
+ print(f" {mesh.name} [{mesh.member_role}] {mesh.id}")
89
+
90
+ client.meshes.use("Tyl Mesh") # by name or UUID; sets X-Active-Mesh-Id
91
+ ```
92
+
93
+ ### 2. CRM: create a contact, list leads, move one down the pipeline
94
+
95
+ ```python
96
+ client = MeshbookClient(active_mesh_id="your-mesh-uuid")
97
+
98
+ contact = client.contacts.create(
99
+ "Ada", "Lovelace",
100
+ email="ada@example.org",
101
+ company="Analytical Engines Ltd", # free text, resolved server-side
102
+ )
103
+ print(contact["id"], contact.get("primaryCompanyName"))
104
+
105
+ for lead in client.leads.list(limit=10):
106
+ print(lead["title"], lead.get("stageName"))
107
+
108
+ client.leads.move_stage(lead_id="…", stage_id="…")
109
+ ```
110
+
111
+ ### 3. Chat: post to the mesh room, then to a channel, with a file
112
+
113
+ ```python
114
+ client = MeshbookClient()
115
+ client.meshes.use("Tyl Mesh")
116
+
117
+ msg = client.chat.post("Nightly build is green ✅")
118
+ client.chat.attach(msg["id"], "build-report.txt")
119
+
120
+ client.channels.post("#bugs", "Repro steps attached above.")
121
+ for m in client.channels.read("#bugs", limit=5):
122
+ print(m["author"]["displayName"], "—", m["bodyMd"][:80])
123
+ ```
124
+
125
+ ### 4. Tasks: what's on my plate, and mark one done
126
+
127
+ ```python
128
+ client = MeshbookClient()
129
+ client.meshes.use("Tyl Mesh")
130
+
131
+ for task in client.tasks.list_mine(status="InProgress"):
132
+ print(f"[{task['status']}] {task['title']} {task['id']}")
133
+
134
+ client.tasks.done("task-uuid") # PATCH → status=Done
135
+ client.tasks.done("task-uuid", "Cancelled") # or another terminal status
136
+ ```
137
+
138
+ ### 5. Full mesh export (admin): start, poll, download
139
+
140
+ ```python
141
+ import time
142
+ from meshbook import MeshbookClient
143
+
144
+ client = MeshbookClient()
145
+ mesh_id = client.meshes.use("Tyl Mesh").id
146
+
147
+ job = client.exports.start(mesh_id)
148
+ while job.status in ("pending", "running"):
149
+ time.sleep(5)
150
+ job = client.exports.list(mesh_id)[0]
151
+
152
+ if job.status == "ready":
153
+ path = client.exports.download(job.id, "backup.zip")
154
+ print(f"Saved {path} ({job.byte_size:,} bytes)")
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Escape hatch
160
+
161
+ Anything the namespaces don't cover yet:
162
+
163
+ ```python
164
+ payload = client.request("GET", "/api/saved-views", params={"entityType": "leads"})
165
+ ```
166
+
167
+ ## Gotchas worth knowing
168
+
169
+ - **Always the apex domain.** `www.meshbook.org` 301-redirects and the
170
+ redirect downgrades POST to GET. The default base is already correct;
171
+ don't "fix" it.
172
+ - **User-Agent matters.** Cloudflare blocks default library UAs; the SDK
173
+ sends `meshbook-sdk/0.1.0` on every request.
174
+ - **Active mesh.** Most CRM/chat surfaces are mesh-scoped and need the
175
+ `X-Active-Mesh-Id` header — set it via the constructor, the config file,
176
+ or `client.meshes.use(...)`.
177
+
178
+ ## Related
179
+
180
+ - [meshbook-cli](https://github.com/tylnexttime/meshbook-cli) — the shell
181
+ counterpart (`pip install meshbook-cli`), same auth, same endpoints.
182
+ - `docs/typescript-sdk-plan.md` — the build plan for `@meshbook/sdk` (TS).
183
+
184
+ MIT © 2026 Christopher Tyl & the mesh
@@ -0,0 +1,6 @@
1
+ meshbook/__init__.py,sha256=d4ycLtsMeqGUWtNh1foDvu765ueOJHhNjITmiz4A704,665
2
+ meshbook/client.py,sha256=lHY11NgUwB7oEhx5wCold46IPbKn9EBWOKRYJ7NkjU0,28430
3
+ meshbook_sdk-0.1.0.dist-info/METADATA,sha256=W1EAhjXdXZfKlcyjk8srNss8H9__kQWuRQ_yukvsRh4,6113
4
+ meshbook_sdk-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
5
+ meshbook_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=hjAUju9t7OmssgaXBDmXRy3gzfqI84-dh2KdZXml_4k,1083
6
+ meshbook_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christopher Tyl & the mesh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.