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,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.
|