From dd414c424aceffe95ec1429776f838f48fb038b4 Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Tue, 2 Jun 2026 15:04:01 -0700 Subject: [PATCH] sync: auto-sync from HOWARD-HOME at 2026-06-02 15:03:53 Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-06-02 15:03:53 --- .claude/skills/packetdial/SKILL.md | 127 ++++++ .claude/skills/packetdial/references/api.md | 82 ++++ .claude/skills/packetdial/scripts/ns.py | 169 ++++++++ .../skills/packetdial/scripts/ns_client.py | 381 ++++++++++++++++++ 4 files changed, 759 insertions(+) create mode 100644 .claude/skills/packetdial/SKILL.md create mode 100644 .claude/skills/packetdial/references/api.md create mode 100644 .claude/skills/packetdial/scripts/ns.py create mode 100644 .claude/skills/packetdial/scripts/ns_client.py diff --git a/.claude/skills/packetdial/SKILL.md b/.claude/skills/packetdial/SKILL.md new file mode 100644 index 0000000..f7c3e9c --- /dev/null +++ b/.claude/skills/packetdial/SKILL.md @@ -0,0 +1,127 @@ +--- +name: packetdial +description: >- + Manage the Arizona Computer Guru (ACG) PacketDial / OITVOIP hosted-VoIP + platform via the NetSapiens SNAPsolution API v2 (pbx.packetdial.com, + v44.4). List and inspect domains, users, devices/phones, DIDs (phone + numbers), resellers, and pull CDRs (call detail records). Provision new + customer domains, users, SIP devices, and phone numbers (all writes gated + behind --confirm). Read-only by default. Invoke for: "packetdial", + "oitvoip", "oit voip", "netsapiens", "voip portal", "pbx portal", "voip + domain", "voip user", "voip extension", "provision phone", "add did", + "phone number on voip", "call detail records", "cdr", "voip.packetdial", + "pbx.packetdial". NOTE: voip.packetdial.com is the customer-facing portal + (the fax/UC dashboard, e.g. Cascades account 28598) and has no API — the + programmable surface is pbx.packetdial.com. This skill talks to the LIVE + production reseller PBX; treat writes conservatively. +--- + +# PacketDial / NetSapiens (OITVOIP) Skill + +Standalone CLI client for the NetSapiens SNAPsolution **API v2** that backs +ACG's hosted-VoIP offering through OITVOIP / PacketDial. Read-only by default; +every write (create / update / delete) is gated behind `--confirm`. + +## The two hostnames (important) + +| Host | What it is | API? | +|---|---|---| +| `voip.packetdial.com` | Customer-facing white-label portal / UC & fax dashboard (e.g. Cascades fax account **28598**). Login-gated UI. | **No** | +| `pbx.packetdial.com` | Reseller PBX platform — NetSapiens v44.4. | **Yes** — this skill targets it | + +- API base: `https://pbx.packetdial.com/ns-api/v2` +- Token endpoint: `https://pbx.packetdial.com/ns-api/v2/tokens` +- Live OpenAPI spec: `https://pbx.packetdial.com/ns-api/webroot/openapi/openapi.json` +- Live Swagger UI: `https://pbx.packetdial.com/ns-api/openapi` +- Vendor docs: https://docs.ns-api.com/ (login) and https://voipdocs.io/oitvoip-access-platform-apis + +## Credentials — ONE-TIME SETUP (not yet provisioned) + +As of this skill's creation **no API key exists yet** — the vault entry +`msp-tools/oitvoip.sops.yaml` is empty/absent, so every command will fail with a +clear "No credentials found" error until you do this once: + +1. Log into `pbx.packetdial.com` -> **Admin > API Keys** and create a + reseller-scoped key (prefix `nsr_`). If self-service key creation is not + available, reply to **Darwin Escaro (OITVOIP)** for reseller OAuth client + credentials. +2. Store it in the SOPS vault. Preferred (static bearer key): + ``` + # msp-tools/oitvoip.sops.yaml + credentials: + api_key: nsr_xxxxxxxxxxxxxxxx + ``` + Or, for OAuth2 password-grant credentials: + ``` + credentials: + client_id: + client_secret: + username: + password: + ``` +3. That's it — the client auto-detects which shape is present. + +The client never hardcodes secrets. Resolution order: `PACKETDIAL_API_KEY` env +-> `PACKETDIAL_CLIENT_ID`+friends env -> vault `credentials.api_key` -> vault +OAuth fields. Env overrides exist for quick testing without touching the vault. + +## Running the CLI + +This machine's Python launcher is `py` (per identity.json); `python` / `python3` +also work. Run from the scripts dir so the two modules resolve. + +```bash +cd C:/claudetools/.claude/skills/packetdial/scripts + +py ns.py status # API version + authenticated key identity +py ns.py domains # list all domains +py ns.py domain # one domain's config +py ns.py users # users / extensions in a domain +py ns.py user +py ns.py phones # SIP devices registered in a domain +py ns.py dids # phone numbers (DIDs) on a domain +py ns.py devices +py ns.py cdrs --domain --start 2026-06-01 --end 2026-06-02 +py ns.py resellers +``` + +## Writes (gated) + +Every mutating command prints a `[DRY RUN]` line and exits non-zero unless you +pass `--confirm`. Bodies are raw JSON matching the NetSapiens v2 schema. + +```bash +py ns.py create-domain --body '{"domain":"acme","description":"Acme Inc"}' --confirm +py ns.py create-user acme --body '{"user":"101","name-first-name":"Jane"}' --confirm +py ns.py create-phone acme --body '{...}' --confirm +py ns.py create-did acme --body '{"phonenumber":"15205551234"}' --confirm +py ns.py update-user acme 101 --body '{"name-last-name":"Doe"}' --confirm +py ns.py delete-user acme 101 --confirm +``` + +## Raw escape hatch (any of the 239 v2 paths) + +The named commands cover the common surface; for anything else, hit the path +directly. Non-GET methods still require `--confirm`. + +```bash +py ns.py raw GET domains/acme/users/101/answerrules +py ns.py raw POST domains/acme/users --body '{...}' --confirm +``` + +## Standard provisioning flow (new customer) + +1. `create-domain` -> dial plan auto-generates +2. `create-user` per extension +3. `create-phone` per SIP device (MAC-provisioned) +4. `create-did` to attach DIDs and route them to users +5. Log the work back to the Syncro ticket + +## Notes + +- This is the LIVE production reseller PBX. A bad `create-domain` or + `delete-user` affects real customers — confirm the target domain first with a + read command before any write. +- CDR queries can be large; always pass `--start`/`--end` and a `--limit`. +- Reference detail (auth shapes, full endpoint inventory) lives in + `references/api.md`. diff --git a/.claude/skills/packetdial/references/api.md b/.claude/skills/packetdial/references/api.md new file mode 100644 index 0000000..9aaf62f --- /dev/null +++ b/.claude/skills/packetdial/references/api.md @@ -0,0 +1,82 @@ +# NetSapiens SNAPsolution API v2 — Reference (PacketDial / OITVOIP) + +Source of truth: the live OpenAPI spec at +`https://pbx.packetdial.com/ns-api/webroot/openapi/openapi.json` +(title "NetSapiens API v2", version 44.4.10 as of 2026-06-02). + +## Hosts + +- **API host:** `pbx.packetdial.com` — `https://pbx.packetdial.com/ns-api/v2` +- **Customer portal (no API):** `voip.packetdial.com` — login-gated UI; the + Cascades fax/UC dashboard lives here (account 28598). Hitting `/ns-api/*` on + this host returns `errors/not_found`. + +## Authentication (`bearerAuth`) + +All calls send `Authorization: Bearer `. Two ways to get the token: + +1. **Static API key** — created in `pbx.packetdial.com` Admin > API Keys. + Prefix encodes scope: `nsr_` = reseller, `nss_` = system, `nsd_` = domain. + Use the key string directly as the bearer token (no exchange step). +2. **OAuth2 password grant** — `POST /ns-api/v2/tokens` form-encoded: + `grant_type=password&client_id=..&client_secret=..&username=..&password=..` + Response includes `access_token` (JWT) + `expires_in`. The client caches it + and refreshes ~60s before expiry. + +## Endpoint inventory (239 paths, grouped) + +Top-level resource groups and the methods present: + +| Resource | Methods | Notes | +|---|---|---| +| `/version` | GET | platform version | +| `/apikeys`, `/apikeys/{id}`, `/apikeys/~` | GET POST PUT DELETE | `~` = current key | +| `/tokens`, `/jwt` | POST (+ GET/DELETE on jwt) | auth / token mgmt | +| `/resellers`, `/resellers/{reseller}` | GET POST PUT DELETE | | +| `/domains`, `/domains/{domain}` | GET POST PUT PATCH DELETE | `/domains/count` for totals | +| `/domains/{domain}/users`, `/users/{user}` | GET POST PUT DELETE | extensions | +| `/domains/{domain}/users/{user}/devices` | GET POST PUT DELETE | per-user SIP devices | +| `/domains/{domain}/users/{user}/answerrules` | GET POST | call routing | +| `/domains/{domain}/users/{user}/cdrs` | GET | per-user CDRs | +| `/domains/{domain}/phones` | GET POST PUT DELETE | domain device list | +| `/domains/{domain}/phonenumbers`, `/{phonenumber}` | GET POST PUT DELETE | DIDs | +| `/domains/{domain}/addresses`, `/addresses/endpoints` | GET POST PUT DELETE | emergency/E911 addrs | +| `/domains/{domain}/cdrs`, `/cdrs` | GET | call detail records (`/count` variants) | +| `/phonenumbers` | GET | global DID search | +| `/smsnumbers` | GET | | +| `/subscriptions` | GET POST PUT DELETE | event/webhook subs | +| `/connections`, `/routes`, `/routecon` | GET POST PUT DELETE | SIP trunks / routing | +| `/configurations`, `/config-definitions`, `/templates` | GET POST PUT DELETE | | +| `/certificates`, `/images` | GET POST PUT DELETE | | +| `/meetings`, `/video`, `/firebase` | GET POST | UC / video | +| `/sipflow` | GET | SIP trace | +| `/holidays`, `/cdrs` | GET | | +| `/backup`, `/restore` | POST / GET PUT | platform backup | + +Many resources also expose `/count` (totals) and `#1/#2/#3` openapi-dedup +variants (same path, different query-param shapes). Use the Swagger UI for the +exact request/response schema of any specific path. + +## Common query params + +- List endpoints accept `limit`, `offset`, and resource-specific filters. +- CDR endpoints accept `start-date` / `end-date` (and `domain`, `user` scoping + via the nested path). Always bound CDR queries — they can be large. + +## Provisioning flow (new customer domain) + +1. `POST /domains` — creates the domain; dial plan auto-generates. +2. `POST /domains/{domain}/users` — one per extension. +3. `POST /domains/{domain}/users/{user}/devices` or + `POST /domains/{domain}/phones` — SIP devices (MAC provisioning). +4. `POST /domains/{domain}/phonenumbers` — attach DIDs, route to users. +5. Configure `answerrules` as needed. + +## History + +- 2026-04-20: API researched (session log `session-logs/2026-04-20-session.md`, + "OITVOIP / NetSapiens API Research"). Platform identified, auth shapes + documented. **No API key created yet** — provisioning the key + storing it in + `msp-tools/oitvoip.sops.yaml` was the open TODO. +- 2026-06-02: `packetdial` skill created wrapping the v2 API (read-by-default, + gated writes). Confirmed `voip.` is portal-only and `pbx.` is the API host. diff --git a/.claude/skills/packetdial/scripts/ns.py b/.claude/skills/packetdial/scripts/ns.py new file mode 100644 index 0000000..f3431bb --- /dev/null +++ b/.claude/skills/packetdial/scripts/ns.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""CLI for the packetdial skill — NetSapiens SNAPsolution API v2. + +Read subcommands run freely. Write subcommands (create-*, update-*, delete-*, +raw with a non-GET method) refuse to run unless --confirm is passed; without it +they print what they WOULD do and exit non-zero. + +Output: --json emits raw JSON (default for most commands); a few commands render +a readable table when --json is omitted. + +Read examples: + python ns.py status + python ns.py whoami + python ns.py domains + python ns.py domain + python ns.py users + python ns.py user + python ns.py phones + python ns.py dids + python ns.py devices + python ns.py cdrs --domain --start 2026-06-01 --end 2026-06-02 + python ns.py resellers + +Write examples (all require --confirm): + python ns.py create-domain --body '{"domain":"acme","description":"Acme Inc"}' --confirm + python ns.py create-user --body '{"user":"101","name-first-name":"Jane"}' --confirm + python ns.py delete-user --confirm + +Escape hatch (raw request against any of the 239 v2 paths): + python ns.py raw GET domains/acme/users + python ns.py raw POST domains/acme/users --body '{...}' --confirm +""" +from __future__ import annotations + +import argparse +import json +import sys + +from ns_client import NetSapiensClient, PacketDialError + + +def _emit(obj) -> None: + print(json.dumps(obj, indent=2, ensure_ascii=False, default=str)) + + +def _parse_body(raw: str | None) -> dict | None: + if raw is None: + return None + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise SystemExit(f"--body is not valid JSON: {exc}") + if not isinstance(parsed, dict): + raise SystemExit("--body must be a JSON object") + return parsed + + +def _require_confirm(args, action: str, detail: str) -> None: + if not getattr(args, "confirm", False): + print(f"[DRY RUN] Would {action}: {detail}") + print("Refusing to perform a write without --confirm. Re-run with --confirm.") + raise SystemExit(2) + + +def main(argv=None) -> int: + p = argparse.ArgumentParser(prog="ns.py", description="PacketDial / NetSapiens v2 CLI") + p.add_argument("--json", action="store_true", help="emit raw JSON (default for most)") + sub = p.add_subparsers(dest="cmd", required=True) + + # --- read --- + sub.add_parser("status", help="API version + authenticated key identity") + sub.add_parser("whoami", help="details of the authenticated API key") + sub.add_parser("domains", help="list all domains") + sp = sub.add_parser("domain", help="one domain"); sp.add_argument("domain") + sp = sub.add_parser("users", help="users in a domain"); sp.add_argument("domain") + sp = sub.add_parser("user", help="one user"); sp.add_argument("domain"); sp.add_argument("user") + sp = sub.add_parser("phones", help="phones (devices) in a domain"); sp.add_argument("domain") + sp = sub.add_parser("dids", help="phone numbers in a domain"); sp.add_argument("domain") + sp = sub.add_parser("devices", help="devices for a user"); sp.add_argument("domain"); sp.add_argument("user") + sub.add_parser("resellers", help="list resellers") + sp = sub.add_parser("cdrs", help="call detail records") + sp.add_argument("--domain"); sp.add_argument("--start"); sp.add_argument("--end") + sp.add_argument("--limit", type=int, default=100) + + # --- write (gated) --- + sp = sub.add_parser("create-domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") + sp = sub.add_parser("create-user"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") + sp = sub.add_parser("update-user"); sp.add_argument("domain"); sp.add_argument("user"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") + sp = sub.add_parser("delete-user"); sp.add_argument("domain"); sp.add_argument("user"); sp.add_argument("--confirm", action="store_true") + sp = sub.add_parser("create-phone"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") + sp = sub.add_parser("create-did"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") + + # --- raw escape hatch --- + sp = sub.add_parser("raw", help="raw request against any v2 path") + sp.add_argument("method", choices=["GET", "POST", "PUT", "PATCH", "DELETE"]) + sp.add_argument("path", help="relative path, e.g. domains/acme/users") + sp.add_argument("--body"); sp.add_argument("--confirm", action="store_true") + + args = p.parse_args(argv) + client = NetSapiensClient() + + try: + if args.cmd == "status": + _emit({"version": client.version(), "apiKey": client.whoami()}) + elif args.cmd == "whoami": + _emit(client.whoami()) + elif args.cmd == "domains": + _emit(client.domains()) + elif args.cmd == "domain": + _emit(client.domain(args.domain)) + elif args.cmd == "users": + _emit(client.users(args.domain)) + elif args.cmd == "user": + _emit(client.user(args.domain, args.user)) + elif args.cmd == "phones": + _emit(client.phones(args.domain)) + elif args.cmd == "dids": + _emit(client.phonenumbers(args.domain)) + elif args.cmd == "devices": + _emit(client.devices(args.domain, args.user)) + elif args.cmd == "resellers": + _emit(client.resellers()) + elif args.cmd == "cdrs": + filters = {"limit": args.limit} + if args.start: + filters["start-date"] = args.start + if args.end: + filters["end-date"] = args.end + _emit(client.cdrs(domain=args.domain, **filters)) + + elif args.cmd == "create-domain": + body = _parse_body(args.body) + _require_confirm(args, "create domain", json.dumps(body)) + _emit(client.create_domain(body)) + elif args.cmd == "create-user": + body = _parse_body(args.body) + _require_confirm(args, "create user", f"{args.domain}: {json.dumps(body)}") + _emit(client.create_user(args.domain, body)) + elif args.cmd == "update-user": + body = _parse_body(args.body) + _require_confirm(args, "update user", f"{args.domain}/{args.user}: {json.dumps(body)}") + _emit(client.update_user(args.domain, args.user, body)) + elif args.cmd == "delete-user": + _require_confirm(args, "DELETE user", f"{args.domain}/{args.user}") + _emit(client.delete_user(args.domain, args.user)) + elif args.cmd == "create-phone": + body = _parse_body(args.body) + _require_confirm(args, "create phone", f"{args.domain}: {json.dumps(body)}") + _emit(client.create_phone(args.domain, body)) + elif args.cmd == "create-did": + body = _parse_body(args.body) + _require_confirm(args, "create phone number", f"{args.domain}: {json.dumps(body)}") + _emit(client.create_phonenumber(args.domain, body)) + + elif args.cmd == "raw": + body = _parse_body(args.body) + if args.method != "GET": + _require_confirm(args, f"{args.method} {args.path}", json.dumps(body)) + _emit(client.request(args.method, args.path, json_body=body)) + else: + p.error(f"unknown command {args.cmd}") + except PacketDialError as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/packetdial/scripts/ns_client.py b/.claude/skills/packetdial/scripts/ns_client.py new file mode 100644 index 0000000..f91c75c --- /dev/null +++ b/.claude/skills/packetdial/scripts/ns_client.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +"""Client for the packetdial skill — NetSapiens SNAPsolution API v2. + +Talks to the live PacketDial / OITVOIP reseller PBX at pbx.packetdial.com +(NetSapiens v44.4). voip.packetdial.com is the customer-facing white-label +portal and is NOT the API host — the programmable surface lives on pbx. + +Auth: Bearer token. Two credential shapes are supported, tried in this order: + 1. Static API key (`nsr_` reseller-scoped or `nss_` system) used directly as + the bearer token. Preferred for machine-to-machine. + 2. OAuth2 password grant: client_id + client_secret + username + password + POSTed to /tokens, which returns a short-lived access_token (JWT). + +Credentials are NEVER hardcoded. They are loaded at runtime from the SOPS vault +entry `msp-tools/oitvoip.sops.yaml`, or from environment overrides. As of the +skill's creation that vault entry does not yet exist — see SKILL.md for the +one-time provisioning steps. + +Transport: prefers httpx if installed, else falls back to stdlib urllib so the +skill works on a bare Python install. +""" +from __future__ import annotations + +import json +import os +import subprocess +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Optional + +try: + import httpx # type: ignore + + _HAS_HTTPX = True +except ImportError: # pragma: no cover + _HAS_HTTPX = False + +SKILL_DIR = Path(__file__).resolve().parent.parent # .../.claude/skills/packetdial +ERROR_BODY_MAX_CHARS = 1500 +DEFAULT_TIMEOUT = 60.0 +DEFAULT_CONNECT_TIMEOUT = 15.0 + +# pbx host is the API host; voip host is the customer portal (no API). +API_BASE_URL = os.environ.get( + "PACKETDIAL_API_BASE_URL", "https://pbx.packetdial.com/ns-api/v2" +) +TOKEN_URL = os.environ.get( + "PACKETDIAL_TOKEN_URL", "https://pbx.packetdial.com/ns-api/v2/tokens" +) + +VAULT_ENTRY = "msp-tools/oitvoip.sops.yaml" + + +class PacketDialError(Exception): + """Any failure talking to the NetSapiens API or loading credentials.""" + + +# --- repo-root + credential loading ------------------------------------------- +def _resolve_claudetools_root() -> Path: + """Resolve the ClaudeTools repo root: env var, then identity.json, then derived. + + Final fallback is derived from this file's location so it works on the + Mac/Linux fleet, not only the Windows default. + """ + # SKILL_DIR = .../.claude/skills/packetdial ; root is three levels up. + derived_root = SKILL_DIR.parent.parent.parent + + env_root = os.environ.get("CLAUDETOOLS_ROOT") + if env_root: + return Path(env_root) + + identity_path = derived_root / ".claude" / "identity.json" + if identity_path.exists(): + try: + data = json.loads(identity_path.read_text(encoding="utf-8")) + root = data.get("claudetools_root") + if root: + return Path(root) + except (json.JSONDecodeError, OSError): + pass + + return derived_root + + +def _vault_field(field: str) -> Optional[str]: + """Read a single field from the oitvoip vault entry. Returns None if absent. + + Soft failure: a missing field (vault exits non-zero) returns None so the + caller can try the next credential shape. A missing vault wrapper or bash + raises, since that is an environment problem the user must fix. + """ + root = _resolve_claudetools_root() + vault_script = root / ".claude" / "scripts" / "vault.sh" + if not vault_script.exists(): + raise PacketDialError( + f"vault wrapper not found at {vault_script}; set PACKETDIAL_API_KEY " + "or PACKETDIAL_* OAuth env vars instead." + ) + try: + completed = subprocess.run( + ["bash", str(vault_script), "get-field", VAULT_ENTRY, field], + capture_output=True, + text=True, + timeout=60, + ) + except FileNotFoundError as exc: + raise PacketDialError( + "'bash' not found on PATH. Install Git Bash or set PACKETDIAL_API_KEY." + ) from exc + except subprocess.TimeoutExpired as exc: + raise PacketDialError("vault call timed out.") from exc + + if completed.returncode != 0: + return None + value = completed.stdout.strip() + return value or None + + +def load_credentials() -> dict: + """Resolve NetSapiens credentials. Returns a dict describing the auth mode. + + Returns either: + {"mode": "apikey", "api_key": "nsr_..."} + {"mode": "oauth", "client_id": ..., "client_secret": ..., + "username": ..., "password": ...} + + Resolution order: + 1. PACKETDIAL_API_KEY env -> apikey mode + 2. PACKETDIAL_CLIENT_ID + ... env -> oauth mode + 3. vault credentials.api_key -> apikey mode + 4. vault credentials.{client_id,client_secret,username,password} -> oauth + + Raises PacketDialError with provisioning guidance if nothing resolves. + """ + env_key = os.environ.get("PACKETDIAL_API_KEY") + if env_key: + return {"mode": "apikey", "api_key": env_key.strip()} + + env_client = os.environ.get("PACKETDIAL_CLIENT_ID") + if env_client: + return { + "mode": "oauth", + "client_id": env_client.strip(), + "client_secret": (os.environ.get("PACKETDIAL_CLIENT_SECRET") or "").strip(), + "username": (os.environ.get("PACKETDIAL_USERNAME") or "").strip(), + "password": (os.environ.get("PACKETDIAL_PASSWORD") or "").strip(), + } + + api_key = _vault_field("credentials.api_key") + if api_key: + return {"mode": "apikey", "api_key": api_key} + + client_id = _vault_field("credentials.client_id") + if client_id: + return { + "mode": "oauth", + "client_id": client_id, + "client_secret": _vault_field("credentials.client_secret") or "", + "username": _vault_field("credentials.username") or "", + "password": _vault_field("credentials.password") or "", + } + + raise PacketDialError( + "No PacketDial / NetSapiens credentials found.\n" + f" Expected vault entry: {VAULT_ENTRY} with either:\n" + " credentials.api_key (nsr_ reseller bearer key) -- preferred\n" + " or OAuth password-grant fields:\n" + " credentials.client_id\n" + " credentials.client_secret\n" + " credentials.username\n" + " credentials.password\n" + " Provision a key in pbx.packetdial.com -> Admin > API Keys, then store it.\n" + " See .claude/skills/packetdial/SKILL.md for the full setup steps." + ) + + +# --- client ------------------------------------------------------------------- +class NetSapiensClient: + def __init__( + self, + api_base_url: str = API_BASE_URL, + token_url: str = TOKEN_URL, + timeout: float = DEFAULT_TIMEOUT, + connect_timeout: float = DEFAULT_CONNECT_TIMEOUT, + ): + self.api_base_url = api_base_url.rstrip("/") + self.token_url = token_url + self.timeout = timeout + self.connect_timeout = connect_timeout + self._creds: Optional[dict] = None + self._bearer: Optional[str] = None + self._bearer_expiry: float = 0.0 + + # -- auth ------------------------------------------------------------------ + @property + def creds(self) -> dict: + if self._creds is None: + self._creds = load_credentials() + return self._creds + + def _bearer_token(self) -> str: + """Return a valid bearer token, fetching/refreshing an OAuth one if needed.""" + creds = self.creds + if creds["mode"] == "apikey": + return creds["api_key"] + + # oauth: reuse cached token until ~60s before expiry + if self._bearer and time.monotonic() < self._bearer_expiry - 60: + return self._bearer + + form = { + "grant_type": "password", + "client_id": creds["client_id"], + "client_secret": creds["client_secret"], + "username": creds["username"], + "password": creds["password"], + } + body = self._http( + "POST", + self.token_url, + data=urllib.parse.urlencode(form).encode("utf-8"), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + auth_header=None, + ) + if not isinstance(body, dict) or "access_token" not in body: + raise PacketDialError(f"OAuth token response missing access_token: {body}") + self._bearer = body["access_token"] + expires_in = float(body.get("expires_in", 3600)) + self._bearer_expiry = time.monotonic() + expires_in + return self._bearer + + # -- core transport -------------------------------------------------------- + def request( + self, + method: str, + path: str, + params: Optional[dict] = None, + json_body: Optional[dict] = None, + ) -> Any: + """One REST call against the API base. `path` is relative (e.g. 'domains').""" + url = f"{self.api_base_url}/{path.lstrip('/')}" + if params: + url = f"{url}?{urllib.parse.urlencode(params, doseq=True)}" + data = json.dumps(json_body).encode("utf-8") if json_body is not None else None + headers = {"Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + return self._http( + method, url, data=data, headers=headers, + auth_header=f"Bearer {self._bearer_token()}", + ) + + def _http( + self, + method: str, + url: str, + data: Optional[bytes] = None, + headers: Optional[dict] = None, + auth_header: Optional[str] = "__use_bearer__", + ) -> Any: + hdrs = dict(headers or {}) + if auth_header and auth_header != "__use_bearer__": + hdrs["Authorization"] = auth_header + + if _HAS_HTTPX: + try: + timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout) + with httpx.Client(timeout=timeout) as client: + resp = client.request(method, url, content=data, headers=hdrs) + resp.raise_for_status() + return self._parse(resp.content) + except httpx.TimeoutException as exc: + raise PacketDialError(f"request timed out: {exc}") from exc + except httpx.HTTPStatusError as exc: + detail = (exc.response.text or "")[:ERROR_BODY_MAX_CHARS] + raise PacketDialError( + f"HTTP {exc.response.status_code} {method} {url}: {detail}" + ) from exc + except httpx.HTTPError as exc: + raise PacketDialError(f"request failed: {exc}") from exc + + # stdlib fallback + req = urllib.request.Request(url, data=data, method=method, headers=hdrs) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + return self._parse(resp.read()) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS] + raise PacketDialError(f"HTTP {exc.code} {method} {url}: {detail}") from exc + except urllib.error.URLError as exc: + raise PacketDialError(f"request failed: {exc}") from exc + + @staticmethod + def _parse(raw: bytes) -> Any: + if not raw: + return None + try: + return json.loads(raw.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + return raw.decode("utf-8", errors="replace") + + # ====================================================================== + # READ METHODS (safe — always live) + # ====================================================================== + def version(self) -> Any: + return self.request("GET", "version") + + def whoami(self) -> Any: + """Details of the currently authenticated API key.""" + return self.request("GET", "apikeys/~") + + def domains(self, limit: int = 1000) -> Any: + return self.request("GET", "domains", params={"limit": limit}) + + def domain(self, domain: str) -> Any: + return self.request("GET", f"domains/{urllib.parse.quote(domain)}") + + def users(self, domain: str) -> Any: + return self.request("GET", f"domains/{urllib.parse.quote(domain)}/users") + + def user(self, domain: str, user: str) -> Any: + d, u = urllib.parse.quote(domain), urllib.parse.quote(user) + return self.request("GET", f"domains/{d}/users/{u}") + + def phones(self, domain: str) -> Any: + return self.request("GET", f"domains/{urllib.parse.quote(domain)}/phones") + + def devices(self, domain: str, user: str) -> Any: + d, u = urllib.parse.quote(domain), urllib.parse.quote(user) + return self.request("GET", f"domains/{d}/users/{u}/devices") + + def phonenumbers(self, domain: str) -> Any: + return self.request("GET", f"domains/{urllib.parse.quote(domain)}/phonenumbers") + + def resellers(self) -> Any: + return self.request("GET", "resellers") + + def cdrs(self, domain: Optional[str] = None, **filters) -> Any: + if domain: + return self.request( + "GET", f"domains/{urllib.parse.quote(domain)}/cdrs", params=filters or None + ) + return self.request("GET", "cdrs", params=filters or None) + + def apikeys(self) -> Any: + return self.request("GET", "apikeys/~") + + def subscriptions(self) -> Any: + return self.request("GET", "subscriptions") + + # ====================================================================== + # WRITE METHODS (gated — the CLI requires --confirm before calling these) + # ====================================================================== + def create_domain(self, body: dict) -> Any: + return self.request("POST", "domains", json_body=body) + + def create_user(self, domain: str, body: dict) -> Any: + return self.request( + "POST", f"domains/{urllib.parse.quote(domain)}/users", json_body=body + ) + + def update_user(self, domain: str, user: str, body: dict) -> Any: + d, u = urllib.parse.quote(domain), urllib.parse.quote(user) + return self.request("PUT", f"domains/{d}/users/{u}", json_body=body) + + def delete_user(self, domain: str, user: str) -> Any: + d, u = urllib.parse.quote(domain), urllib.parse.quote(user) + return self.request("DELETE", f"domains/{d}/users/{u}") + + def create_phone(self, domain: str, body: dict) -> Any: + return self.request( + "POST", f"domains/{urllib.parse.quote(domain)}/phones", json_body=body + ) + + def create_phonenumber(self, domain: str, body: dict) -> Any: + return self.request( + "POST", f"domains/{urllib.parse.quote(domain)}/phonenumbers", json_body=body + )