mirror of
https://github.com/prdlk/jobyssey.git
synced 2026-11-04 05:57:48 +00:00
feat(tools): add Twenty CRM sync scripts and update .gitignore rules
This commit is contained in:
1 parent
b552b70783
commit
39694ee2e5
4 files changed
+612
-7
No files matched your search
@@ -0,0 +1,100 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user