""" Flowroute Canadian DID provisioning service. Production client for searching and purchasing BC DIDs via the Flowroute API. Called by the CRTC pipeline after domain provisioning. Environment variables: FLOWROUTE_ACCESS_KEY 743f4c49 FLOWROUTE_SECRET_KEY 0b10c6d5... Usage: from scripts.workers.services.flowroute import provision_bc_did result = provision_bc_did(order_number="CA-2026-XXXXX") """ from __future__ import annotations import logging import os from typing import Optional import requests LOG = logging.getLogger("workers.flowroute") API_BASE = "https://api.flowroute.com" # BC area codes in preference order BC_AREA_CODES = ["1604", "1778", "1236", "1250"] _access_key = os.environ.get("FLOWROUTE_ACCESS_KEY", "") _secret_key = os.environ.get("FLOWROUTE_SECRET_KEY", "") def _session() -> requests.Session: s = requests.Session() s.auth = (_access_key, _secret_key) s.headers["Accept"] = "application/json" return s def ping() -> bool: """Verify API credentials.""" try: s = _session() r = s.get(f"{API_BASE}/v2/numbers", params={"limit": 1}, timeout=10) ok = r.status_code == 200 LOG.info("Flowroute ping: %s", "OK" if ok else f"FAIL {r.status_code}") return ok except Exception as e: LOG.error("Flowroute ping failed: %s", e) return False def search_available_dids( starts_with: str = "", limit: int = 5, number_type: str = "standard", ) -> list[dict]: """ Search for available Canadian DIDs across all BC area codes. Tries 604 → 778 → 236 → 250 and stops at the first code with results. Returns: List of {"did": "16045551234", "rate_center": "...", "monthly_cost": "..."} """ s = _session() prefixes = [starts_with] if starts_with else BC_AREA_CODES for prefix in prefixes: try: r = s.get( f"{API_BASE}/v2/numbers/available", params={"starts_with": prefix, "limit": limit, "number_type": number_type}, timeout=15, ) if r.status_code != 200: LOG.warning("Flowroute search %s: HTTP %d", prefix, r.status_code) continue results = [] for item in r.json().get("data", []): attrs = item.get("attributes", {}) results.append({ "did": item.get("id", ""), "rate_center": attrs.get("rate_center", ""), "state": attrs.get("state", ""), "monthly_cost": attrs.get("monthly_cost", ""), }) if results: LOG.info("Flowroute search '%s': %d results", prefix, len(results)) return results LOG.info("Flowroute search '%s': 0 results", prefix) except Exception as e: LOG.error("Flowroute search error for %s: %s", prefix, e) LOG.warning("No BC DIDs available in any area code") return [] def purchase_did(did: str) -> dict: """ Purchase a DID from Flowroute. Args: did: Phone number to purchase (e.g. "16045551234") Returns: {"success": bool, "did": str, "monthly_cost": str, "error": str} """ s = _session() try: r = s.post(f"{API_BASE}/v2/numbers/{did}", timeout=15) if r.status_code in (200, 201): LOG.info("Flowroute: purchased DID %s", did) return {"success": True, "did": did} else: msg = r.text[:200] LOG.error("Flowroute purchase failed for %s: %d %s", did, r.status_code, msg) return {"success": False, "did": did, "error": msg} except Exception as e: LOG.error("Flowroute purchase error for %s: %s", did, e) return {"success": False, "did": did, "error": str(e)} def create_route( value: str, route_type: str = "host", alias: str = "", ) -> Optional[str]: """ Create a SIP route on Flowroute. Args: value: SIP URI or IP address (e.g. "sip:trunk@pbx.example.com" or "203.0.113.50") route_type: "host" (SIP) — Flowroute only supports host routes alias: Friendly name for the route Returns: Route ID string, or None on failure """ s = _session() try: r = s.post(f"{API_BASE}/v2/routes", json={ "data": { "type": "route", "attributes": { "route_type": route_type, "value": value, "alias": alias, }, }, }, timeout=15) if r.status_code in (200, 201): route_id = r.json()["data"]["id"] LOG.info("Flowroute: created route %s → %s (id=%s)", alias, value, route_id) return route_id else: LOG.error("Flowroute create route failed: %d %s", r.status_code, r.text[:200]) return None except Exception as e: LOG.error("Flowroute create route error: %s", e) return None def set_primary_route(did: str, route_id: str) -> bool: """Assign a route as the primary route for a DID.""" s = _session() # Strip leading + if present clean_did = did.lstrip("+") try: r = s.patch( f"{API_BASE}/v2/numbers/{clean_did}/relationships/primary_route", json={"data": {"type": "route", "id": route_id}}, timeout=15, ) if r.status_code == 204: LOG.info("Flowroute: set primary route for %s → route %s", did, route_id) return True else: LOG.error("Flowroute set route failed for %s: %d %s", did, r.status_code, r.text[:200]) return False except Exception as e: LOG.error("Flowroute set route error for %s: %s", did, e) return False def configure_routing( did: str, routing_type: str = "later", forward_number: str = "", sip_uri: str = "", sip_ip: str = "", order_number: str = "", ) -> dict: """ Configure call routing for a purchased DID. Flowroute uses SIP "host" routes for all routing. For call forwarding to a PSTN number, the forward number is set as the SIP route value (Flowroute handles the SIP→PSTN gateway internally). Args: did: The DID to configure (e.g. "+16045551234") routing_type: "forward", "sip", or "later" forward_number: Phone number to forward to (for routing_type="forward") sip_uri: SIP URI (for routing_type="sip") sip_ip: IP address (for routing_type="sip", fallback) order_number: For logging context Returns: {"success": bool, "route_id": str, "error": str} """ if routing_type == "later": LOG.info("[%s] DID %s routing set to 'later' — no routing configured", order_number, did) return {"success": True, "route_id": "", "note": "Customer will configure routing later"} if routing_type == "forward" and forward_number: # Create a host route with the forward number # Flowroute routes PSTN calls when value is a phone number clean_fwd = forward_number.lstrip("+").replace("-", "").replace(" ", "") route_id = create_route( value=clean_fwd, alias=f"fwd-{order_number}-{clean_fwd[-4:]}", ) if route_id: if set_primary_route(did, route_id): LOG.info("[%s] DID %s forwarding to %s", order_number, did, forward_number) return {"success": True, "route_id": route_id} return {"success": False, "route_id": route_id, "error": "Route created but could not assign to DID"} return {"success": False, "route_id": "", "error": "Could not create forward route"} if routing_type == "sip": # Use SIP URI if provided, fall back to IP value = sip_uri or sip_ip if not value: return {"success": False, "route_id": "", "error": "No SIP URI or IP provided"} route_id = create_route( value=value, alias=f"sip-{order_number}", ) if route_id: if set_primary_route(did, route_id): LOG.info("[%s] DID %s routed to SIP %s", order_number, did, value) return {"success": True, "route_id": route_id} return {"success": False, "route_id": route_id, "error": "Route created but could not assign to DID"} return {"success": False, "route_id": "", "error": "Could not create SIP route"} return {"success": False, "route_id": "", "error": f"Unknown routing type: {routing_type}"} def release_did(did: str) -> bool: """Release a purchased DID (for test cleanup).""" s = _session() try: r = s.delete(f"{API_BASE}/v2/numbers/{did}", timeout=15) return r.status_code in (200, 204) except Exception: return False def provision_bc_did(order_number: str = "") -> dict: """ Full BC DID provisioning workflow: 1. Search for available BC DIDs (tries 604 → 778 → 236 → 250) 2. Purchase the first available one 3. Return the provisioned DID Args: order_number: For logging context Returns: {"success": bool, "did": str, "rate_center": str, "monthly_cost": str, "error": str} """ LOG.info("[%s] Provisioning BC DID...", order_number) # Step 1: Search available = search_available_dids(limit=3) if not available: return {"success": False, "did": "", "error": "No BC DIDs available in any area code"} # Step 2: Purchase the first available target = available[0] result = purchase_did(target["did"]) if result["success"]: # Format as +1XXXXXXXXXX did = target["did"] if not did.startswith("+"): did = f"+{did}" result["did"] = did result["rate_center"] = target.get("rate_center", "") result["monthly_cost"] = target.get("monthly_cost", "") LOG.info("[%s] Provisioned DID: %s (%s)", order_number, did, target.get("rate_center")) return result