mirror of
https://github.com/prdlk/jobyssey.git
synced 2026-11-02 21:22:50 +00:00
100 lines
4.3 KiB
Python
100 lines
4.3 KiB
Python
"""Minimal Twenty CRM REST client (stdlib only).
|
|||
|
|
|
||
|
|
Shared by tools/twenty_enrich.py and tools/gather_recent_context.py.
|
||
|
|
|
||
|
|
Auth: set TWENTY_API_KEY (workspace API key from Settings -> API & Webhooks)
|
||
|
|
and optionally TWENTY_BASE_URL (default: https://crm.prad.nu).
|
||
|
|
|
||
|
|
Both consumer scripts also run keyless: they compute a *plan* JSON from local
|
||
|
|
data plus an optional CRM snapshot, and the /sync-twenty or
|
||
|
|
/gather-recent-context command applies the plan through the Twenty MCP
|
||
|
|
server, which is session-authenticated via OAuth. The REST path exists so the
|
||
|
|
whole loop can also run headless (cron, CI) with an API key.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import time
|
||
|
|
import urllib.error
|
||
|
|
import urllib.parse
|
||
|
|
import urllib.request
|
||
|
|
|
||
|
|
DEFAULT_BASE_URL = "https://crm.prad.nu"
|
||
|
|
PAGE_LIMIT = 60 # Twenty REST caps page size at 60
|
||
|
|
|
||
|
|
|
||
|
|
class TwentyError(RuntimeError):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
class TwentyClient:
|
||
|
|
def __init__(self, base_url: str | None = None, api_key: str | None = None):
|
||
|
|
self.base_url = (base_url or os.environ.get("TWENTY_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
||
|
|
self.api_key = api_key or os.environ.get("TWENTY_API_KEY")
|
||
|
|
if not self.api_key:
|
||
|
|
raise TwentyError(
|
||
|
|
"TWENTY_API_KEY is not set. Either export it, or run without --apply "
|
||
|
|
"to emit a plan JSON and apply it through the Twenty MCP session."
|
||
|
|
)
|
||
|
|
|
||
|
|
def _request(self, method: str, path: str, body: dict | None = None,
|
||
|
|
params: dict | None = None, retries: int = 3):
|
||
|
|
url = f"{self.base_url}{path}"
|
||
|
|
if params:
|
||
|
|
url += "?" + urllib.parse.urlencode(params)
|
||
|
|
data = json.dumps(body).encode() if body is not None else None
|
||
|
|
last_err = None
|
||
|
|
for attempt in range(retries):
|
||
|
|
req = urllib.request.Request(url, data=data, method=method, headers={
|
||
|
|
"Authorization": f"Bearer {self.api_key}",
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
"Accept": "application/json",
|
||
|
|
})
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||
|
|
return json.loads(resp.read().decode())
|
||
|
|
except urllib.error.HTTPError as e: # 4xx never retried: same input, same answer
|
||
|
|
detail = e.read().decode(errors="replace")[:500]
|
||
|
|
if e.code == 429 or e.code >= 500:
|
||
|
|
last_err = TwentyError(f"{method} {path} -> {e.code}: {detail}")
|
||
|
|
time.sleep(1.5 * (attempt + 1))
|
||
|
|
continue
|
||
|
|
raise TwentyError(f"{method} {path} -> {e.code}: {detail}") from e
|
||
|
|
except urllib.error.URLError as e:
|
||
|
|
last_err = TwentyError(f"{method} {path} -> {e.reason}")
|
||
|
|
time.sleep(1.5 * (attempt + 1))
|
||
|
|
raise last_err
|
||
|
|
|
||
|
|
# -- records ------------------------------------------------------------
|
||
|
|
|
||
|
|
def list_all(self, plural: str, order_by: str = "id") -> list[dict]:
|
||
|
|
"""Every record of an object, cursor-paged."""
|
||
|
|
out: list[dict] = []
|
||
|
|
cursor = None
|
||
|
|
while True:
|
||
|
|
params = {"limit": PAGE_LIMIT, "order_by": order_by}
|
||
|
|
if cursor:
|
||
|
|
params["starting_after"] = cursor
|
||
|
|
payload = self._request("GET", f"/rest/{plural}", params=params)
|
||
|
|
page = payload.get("data", {}).get(plural, [])
|
||
|
|
out.extend(page)
|
||
|
|
page_info = payload.get("pageInfo", {})
|
||
|
|
if not page_info.get("hasNextPage") or not page:
|
||
|
|
break
|
||
|
|
cursor = page_info.get("endCursor") or page[-1]["id"]
|
||
|
|
return out
|
||
|
|
|
||
|
|
def create_batch(self, plural: str, records: list[dict]) -> list[dict]:
|
||
|
|
"""Create records in chunks of PAGE_LIMIT via the batch endpoint."""
|
||
|
|
created: list[dict] = []
|
||
|
|
for i in range(0, len(records), PAGE_LIMIT):
|
||
|
|
payload = self._request("POST", f"/rest/batch/{plural}",
|
||
|
|
body=records[i:i + PAGE_LIMIT])
|
||
|
|
created.extend(payload.get("data", {}).get(plural, []))
|
||
|
|
return created
|
||
|
|
|
||
|
|
def update_one(self, plural: str, record_id: str, patch: dict) -> dict:
|
||
|
|
payload = self._request("PATCH", f"/rest/{plural}/{record_id}", body=patch)
|
||
|
|
data = payload.get("data", {})
|
||
|
|
return next(iter(data.values())) if data else payload
|