#!/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") # --- per-domain feature resources (live-verified shapes, 2026-06-22) --- def callqueues(self, domain: str) -> Any: """ACD call queues in a domain (agents, dispatch type, live queued count).""" return self.request("GET", f"domains/{urllib.parse.quote(domain)}/callqueues") def timeframes(self, domain: str) -> Any: """Time-based routing schedules (business hours / holidays) for a domain.""" return self.request("GET", f"domains/{urllib.parse.quote(domain)}/timeframes") def sites(self, domain: str) -> Any: """Multi-site definitions within a domain.""" return self.request("GET", f"domains/{urllib.parse.quote(domain)}/sites") def contacts(self, domain: str) -> Any: """Shared/domain contacts (address book).""" return self.request("GET", f"domains/{urllib.parse.quote(domain)}/contacts") def autoattendants(self, domain: str) -> Any: """Auto-attendants (IVR menus) in a domain.""" return self.request("GET", f"domains/{urllib.parse.quote(domain)}/autoattendants") def billing(self, domain: str) -> Any: """Domain billing/limits snapshot: max + current counts (users, queues, AAs, calls).""" return self.request("GET", f"domains/{urllib.parse.quote(domain)}/billing") # ====================================================================== # 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 ) # --- call queue writes (live-verified on arizonacomputerguru, 2026-06-22) --- # Create body requires {"synchronous":"yes","callqueue":""} (+ optional config). def create_callqueue(self, domain: str, body: dict) -> Any: return self.request( "POST", f"domains/{urllib.parse.quote(domain)}/callqueues", json_body=body ) def update_callqueue(self, domain: str, callqueue: str, body: dict) -> Any: d, c = urllib.parse.quote(domain), urllib.parse.quote(callqueue) return self.request("PUT", f"domains/{d}/callqueues/{c}", json_body=body) def delete_callqueue(self, domain: str, callqueue: str) -> Any: d, c = urllib.parse.quote(domain), urllib.parse.quote(callqueue) return self.request("DELETE", f"domains/{d}/callqueues/{c}") def add_callqueue_agent(self, domain: str, callqueue: str, body: dict) -> Any: # body requires {"callqueue-agent-id": "@"}. d, c = urllib.parse.quote(domain), urllib.parse.quote(callqueue) return self.request("POST", f"domains/{d}/callqueues/{c}/agents", json_body=body) def update_callqueue_agent(self, domain: str, callqueue: str, agent_id: str, body: dict) -> Any: d, c, a = (urllib.parse.quote(domain), urllib.parse.quote(callqueue), urllib.parse.quote(agent_id)) return self.request("PUT", f"domains/{d}/callqueues/{c}/agents/{a}", json_body=body) def remove_callqueue_agent(self, domain: str, callqueue: str, agent_id: str) -> Any: d, c, a = (urllib.parse.quote(domain), urllib.parse.quote(callqueue), urllib.parse.quote(agent_id)) return self.request("DELETE", f"domains/{d}/callqueues/{c}/agents/{a}") # --- timeframe writes --- # Same path, body-discriminated variants (NetSapiens routes PUT/DELETE by body): # create: {"synchronous":"yes","timeframe-name":"X","timeframe-type":"days-of-week|specific-dates|holiday|custom|always"} # update PUT body carries the type-specific array(s); pass --body verbatim. # delete with no body = delete the whole timeframe; with {"child_id":N} = delete one entry. def create_timeframe(self, domain: str, body: dict) -> Any: return self.request( "POST", f"domains/{urllib.parse.quote(domain)}/timeframes", json_body=body ) def update_timeframe(self, domain: str, tf_id: str, body: dict) -> Any: d, t = urllib.parse.quote(domain), urllib.parse.quote(tf_id) return self.request("PUT", f"domains/{d}/timeframes/{t}", json_body=body) def delete_timeframe(self, domain: str, tf_id: str, body: Optional[dict] = None) -> Any: d, t = urllib.parse.quote(domain), urllib.parse.quote(tf_id) return self.request("DELETE", f"domains/{d}/timeframes/{t}", json_body=body) # --- DID (phonenumber) update/delete (completes create-did + read) --- def update_phonenumber(self, domain: str, number: str, body: dict) -> Any: d, n = urllib.parse.quote(domain), urllib.parse.quote(number) return self.request("PUT", f"domains/{d}/phonenumbers/{n}", json_body=body) def delete_phonenumber(self, domain: str, number: str) -> Any: d, n = urllib.parse.quote(domain), urllib.parse.quote(number) return self.request("DELETE", f"domains/{d}/phonenumbers/{n}") # --- per-user SIP devices (write; read via devices()) --- def create_device(self, domain: str, user: str, body: dict) -> Any: d, u = urllib.parse.quote(domain), urllib.parse.quote(user) return self.request("POST", f"domains/{d}/users/{u}/devices", json_body=body) def update_device(self, domain: str, user: str, device: str, body: dict) -> Any: d, u, v = (urllib.parse.quote(domain), urllib.parse.quote(user), urllib.parse.quote(device)) return self.request("PUT", f"domains/{d}/users/{u}/devices/{v}", json_body=body) def delete_device(self, domain: str, user: str, device: str) -> Any: d, u, v = (urllib.parse.quote(domain), urllib.parse.quote(user), urllib.parse.quote(device)) return self.request("DELETE", f"domains/{d}/users/{u}/devices/{v}") # --- E911 addresses (read + write) --- def addresses(self, domain: str) -> Any: return self.request("GET", f"domains/{urllib.parse.quote(domain)}/addresses") def create_address(self, domain: str, body: dict) -> Any: return self.request("POST", f"domains/{urllib.parse.quote(domain)}/addresses", json_body=body) def update_address(self, domain: str, address_id: str, body: dict) -> Any: d, a = urllib.parse.quote(domain), urllib.parse.quote(address_id) return self.request("PUT", f"domains/{d}/addresses/{a}", json_body=body) def delete_address(self, domain: str, address_id: str) -> Any: d, a = urllib.parse.quote(domain), urllib.parse.quote(address_id) return self.request("DELETE", f"domains/{d}/addresses/{a}") # --- domain contacts (write; read via contacts()) --- def create_contact(self, domain: str, body: dict) -> Any: return self.request("POST", f"domains/{urllib.parse.quote(domain)}/contacts", json_body=body) def update_contact(self, domain: str, contact_id: str, body: dict) -> Any: d, c = urllib.parse.quote(domain), urllib.parse.quote(contact_id) return self.request("PUT", f"domains/{d}/contacts/{c}", json_body=body) def delete_contact(self, domain: str, contact_id: str) -> Any: d, c = urllib.parse.quote(domain), urllib.parse.quote(contact_id) return self.request("DELETE", f"domains/{d}/contacts/{c}") # --- sites (write; read via sites()) --- def create_site(self, domain: str, body: dict) -> Any: return self.request("POST", f"domains/{urllib.parse.quote(domain)}/sites", json_body=body) def update_site(self, domain: str, site: str, body: dict) -> Any: d, s = urllib.parse.quote(domain), urllib.parse.quote(site) return self.request("PUT", f"domains/{d}/sites/{s}", json_body=body) # --- auto-attendants (create; read via autoattendants()) --- def create_autoattendant(self, domain: str, body: dict) -> Any: return self.request("POST", f"domains/{urllib.parse.quote(domain)}/autoattendants", json_body=body) # --- SMS numbers (read + write) --- def smsnumbers(self, domain: str) -> Any: return self.request("GET", f"domains/{urllib.parse.quote(domain)}/smsnumbers") def create_smsnumber(self, domain: str, body: dict) -> Any: return self.request("POST", f"domains/{urllib.parse.quote(domain)}/smsnumbers", json_body=body) def update_smsnumber(self, domain: str, smsnumber: str, body: dict) -> Any: d, s = urllib.parse.quote(domain), urllib.parse.quote(smsnumber) return self.request("PUT", f"domains/{d}/smsnumbers/{s}", json_body=body) def delete_smsnumber(self, domain: str, smsnumber: str) -> Any: d, s = urllib.parse.quote(domain), urllib.parse.quote(smsnumber) return self.request("DELETE", f"domains/{d}/smsnumbers/{s}") # --- blocked-number filters (read + add/remove) --- def blocked_numbers(self, domain: str) -> Any: return self.request("GET", f"domains/{urllib.parse.quote(domain)}/number-filters") def block_numbers(self, domain: str, body: dict) -> Any: return self.request("POST", f"domains/{urllib.parse.quote(domain)}/number-filters", json_body=body) def unblock_numbers(self, domain: str, body: dict) -> Any: return self.request("DELETE", f"domains/{urllib.parse.quote(domain)}/number-filters", json_body=body) # --- music on hold (read + TTS create/delete; uploads are multipart -> raw) --- def moh(self, domain: str) -> Any: return self.request("GET", f"domains/{urllib.parse.quote(domain)}/moh") def create_moh(self, domain: str, body: dict) -> Any: return self.request("POST", f"domains/{urllib.parse.quote(domain)}/moh", json_body=body) def delete_moh(self, domain: str, index: str) -> Any: d, i = urllib.parse.quote(domain), urllib.parse.quote(index) return self.request("DELETE", f"domains/{d}/moh/{i}") # --- dial rules / call data (read) --- def dialrules(self, domain: str, dialplan: str) -> Any: d, p = urllib.parse.quote(domain), urllib.parse.quote(dialplan) return self.request("GET", f"domains/{d}/dialplans/{p}/dialrules") def recording(self, domain: str, callid: str) -> Any: d, c = urllib.parse.quote(domain), urllib.parse.quote(callid) return self.request("GET", f"domains/{d}/recordings/{c}") def transcriptions(self, domain: str, **filters) -> Any: return self.request( "GET", f"domains/{urllib.parse.quote(domain)}/transcriptions", params=filters or None ) # ====================================================================== # ORCHESTRATION — onboard a new client domain (mirrors the GUI "Add a Domain" # wizard: Basic + Defaults + Limitations -> POST /domains; Emergency tab -> # validate (generates the pidflo) -> create E911 address). Live-proven against # vwp.91912.service 2026-06-22. # ====================================================================== def onboard_domain(self, body: dict) -> dict: """Create a domain and (optionally) its E911 address in one flow. body = the POST /domains fields PLUS an optional "emergency" sub-object (the address fields). Returns {"domain":..., "address_validate":..., "address":...}. Requires body["domain"] (full, e.g. vwp.91912.service). """ body = dict(body) emergency = body.pop("emergency", None) body.setdefault("synchronous", "yes") domain = body.get("domain") if not domain: raise PacketDialError("onboard_domain: body must include 'domain' (e.g. acme.91912.service)") result = {"domain": self.create_domain(body)} if emergency: d = urllib.parse.quote(domain) v = self.request("POST", f"domains/{d}/addresses/validate", json_body=dict(emergency)) vobj = v[0] if isinstance(v, list) and v else v addr = dict(emergency) addr["address-formatted-pidflo"] = vobj.get("address-formatted-pidflo") addr["emergency-address-id"] = vobj.get("emergency-address-id") result["address_validate"] = {"status": (vobj or {}).get("ValidationStatus") or (vobj.get("address-formatted-pidflo") or {}).get("ValidationStatus") if isinstance(vobj, dict) else None} result["address"] = self.create_address(domain, addr) return result