import json, os, sys, hashlib, urllib.parse, urllib.request, urllib.error def _cfg(): return json.loads(os.environ.get("INTEGRATION_SECRETS", "{}")) def _inputs(): return json.loads(os.environ.get("INTEGRATION_INPUTS", "{}")) def _endpoint(cfg): return (str(cfg.get("endpoint") or "https://eu.api.ovh.com/1.0")).rstrip("/") def _server_time(cfg): req = urllib.request.Request(_endpoint(cfg) + "/auth/time", method="GET") with urllib.request.urlopen(req, timeout=30) as r: return r.read().decode("utf-8", "replace").strip() def request(method, path, cfg, body=None): url = _endpoint(cfg) + path app_key = str(cfg.get("application_key", "")) app_secret = str(cfg.get("application_secret", "")) consumer = str(cfg.get("consumer_key", "")) ts = _server_time(cfg) body_str = json.dumps(body) if body is not None else "" to_sign = app_secret + "+" + consumer + "+" + method.upper() + "+" + url + "+" + body_str + "+" + ts signature = "$1$" + hashlib.sha1(to_sign.encode("utf-8")).hexdigest() headers = { "X-Ovh-Application": app_key, "X-Ovh-Consumer": consumer, "X-Ovh-Timestamp": ts, "X-Ovh-Signature": signature, "Accept": "application/json", } data = None if body is not None: data = body_str.encode("utf-8") headers["Content-Type"] = "application/json" req = urllib.request.Request(url, data=data, headers=headers, method=method) with urllib.request.urlopen(req, timeout=60) as r: raw = r.read() return json.loads(raw) if raw else {} def _run(fn): try: print(json.dumps(fn(_cfg(), _inputs()))) except urllib.error.HTTPError as e: print(json.dumps({"error": "HTTP " + str(e.code), "detail": e.read().decode("utf-8", "replace")})) sys.exit(1) except Exception as e: print(json.dumps({"error": str(e)})) sys.exit(1) # ── the call queue ────────────────────────────────────────────────────────── # # THE SAME COORDINATES FOR EVERY ON-CALL COMMAND, hence this block: the billing # account, the service (the number the queue hangs from) and the queue itself. # Repeated in each script rather than imported, because the marketplace collects # loose *.py files and every command has to stand on its own. def _telephony(cfg): ba = str(cfg.get("telephony_billing_account") or "").strip() service = str(cfg.get("telephony_service") or "").strip() if not ba or not service: raise Exception( "this instance has no telephony coordinates: set telephony_billing_account " "(e.g. fr-xxxxx-1) and telephony_service (the call queue's number, 0033xxxxxxxxx)") return ba, service def _base(cfg): ba, service = _telephony(cfg) return "/telephony/" + urllib.parse.quote(ba, safe="") + "/easyHunting/" + urllib.parse.quote(service, safe="") def _queue_id(cfg): """The queue to act on. Configured explicitly, or discovered when the service holds exactly one, which is the normal shape. AMBIGUITY IS REFUSED RATHER THAN GUESSED: picking the first of several would publish the on-call ladder into whichever queue OVH happened to list first, and the symptom is a number that rings the wrong team at 03:00. """ configured = str(cfg.get("telephony_queue_id") or "").strip() if configured: return configured ids = request("GET", _base(cfg) + "/hunting/queue", cfg) if not isinstance(ids, list) or not ids: raise Exception("this service holds no call queue; create one in the control panel first") if len(ids) > 1: raise Exception("this service holds %d queues (%s) - set telephony_queue_id to say which one" % (len(ids), ", ".join(str(i) for i in ids))) return str(ids[0]) def _ovh_number(raw): """OVH writes external numbers in 00-prefixed international form. A "+" is accepted nowhere in a phoneNumber field, so a ladder handed straight from Riposte - which speaks E.164 - would be refused number by number, and the on-call ladder would silently stay as it was. """ s = "".join(ch for ch in str(raw) if ch.isdigit() or ch == "+").strip() if s.startswith("+"): return "00" + s[1:] return s def _e164(raw): """The inverse, so a readback can be compared with what Riposte published.""" s = "".join(ch for ch in str(raw) if ch.isdigit() or ch == "+").strip() if s.startswith("+"): return s if s.startswith("00"): return "+" + s[2:] return s def _agents(cfg): """Every agent of the service, as {agentId: agent}.""" out = {} for agent_id in request("GET", _base(cfg) + "/hunting/agent", cfg) or []: out[str(agent_id)] = request("GET", _base(cfg) + "/hunting/agent/" + str(agent_id), cfg) return out # ── list_calls ────────────────────────────────────────────────────────────── # # The calls the carrier answered on the on-call number, so the pager's journal can # be filled after the fact. # # AFTER THE FACT IS THE POINT AND THE LIMIT. In this mode the carrier answers, so # Riposte learns nothing while the call is happening: the journal is a # rapatriation, minutes to hours late, and it cannot say which rung took the call — # a consumption record knows the number dialled and how long it lasted, not which # agent the queue reached. Honest and useful; not the live chain the Asterisk mode # writes. def main(cfg, inputs): ba, service = _telephony(cfg) limit = int(inputs.get("limit") or 50) limit = max(1, min(limit, 200)) since = str(inputs.get("since") or "").strip() if not since: # A day, which is the window somebody actually asks about ("nobody called # back last night"). import datetime since = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%S+00:00") path = ("/telephony/" + urllib.parse.quote(ba, safe="") + "/service/" + urllib.parse.quote(service, safe="") + "/voiceConsumption") query = urllib.parse.urlencode({"creationDatetime.from": since, "wayType": "incoming"}) ids = request("GET", path + "?" + query, cfg) or [] # BOUNDED, and the truncation is REPORTED. A silent top-N on a journal reads as # "this is everything that happened", which is the one sentence a journal must # never say by accident. truncated = len(ids) > limit calls = [] for consumption_id in ids[:limit]: c = request("GET", path + "/" + str(consumption_id), cfg) calls.append({ "id": str(c.get("consumptionId") or consumption_id), "from_number": _e164(c.get("calling")), "did": _e164(c.get("called") or c.get("dialed")), "started_at": c.get("creationDatetime"), "duration": c.get("duration"), # The carrier's own word for how it ended. Kept verbatim rather than # translated into ours: it is what their support will ask about. "hangup_nature": c.get("hangupNature"), }) return {"ok": True, "calls": calls, "count": len(calls), "total": len(ids), "truncated": truncated, "since": since} _run(main)