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 # ── set_oncall_ladder ─────────────────────────────────────────────────────── # # Publishes the ordered on-call numbers into the call queue: the agents are the # rungs, their position is the order, and the queue rings them one after another. # # RECONCILES, NEVER REBUILDS. Agents are created when a number has none, updated # when it has one, and merely LOGGED OUT when somebody drops off the ladder. The # tempting version - delete every agent, recreate the ladder - throws away whatever # else the operator set on those agents, and does it every time the rota turns. # # IDEMPOTENT, because Riposte republishes the same ladder on a schedule to heal an # edit made in the carrier's own console. STRATEGY = "sequentiallyByAgentOrder" def _apply_strategy(cfg, base, queue_id): """Make the queue ring its agents IN ORDER. Enforced rather than reported, because the order is the entire content of what we publish: a queue left on ringAll or random takes the same list and rings everybody at once, which is not the escalation an operator arranged and is invisible from Riposte. Choosing this instance as the on-call router IS the instruction to ring sequentially. """ queue = request("GET", base + "/hunting/queue/" + str(queue_id), cfg) if queue.get("strategy") == STRATEGY: return False request("PUT", base + "/hunting/queue/" + str(queue_id), cfg, {"strategy": STRATEGY}) return True def _place(cfg, base, queue_id, agent_id, position): """Put an agent at a position in the queue, joining it if it is not a member.""" path = base + "/hunting/agent/" + str(agent_id) + "/queue/" + str(queue_id) try: request("GET", path, cfg) except urllib.error.HTTPError as e: if e.code != 404: raise request("POST", base + "/hunting/agent/" + str(agent_id) + "/queue", cfg, {"queueId": int(queue_id), "position": position}) return request("PUT", path, cfg, {"position": position}) def main(cfg, inputs): numbers = [n for n in (inputs.get("numbers") or []) if str(n).strip()] if not numbers: # An empty ladder would log every agent out and send every client to a dead # line. Riposte never sends one; refusing here means a bug upstream cannot # take the on-call number down. raise Exception("refusing to publish an empty ladder: nobody would answer the on-call number") ring = int(inputs.get("ring_seconds") or 15) if ring < 5 or ring > 30: # Bounded for the reason the whole mode exists to work around: a carrier's # queue cannot ask for a keypress, so a rung ringing past a mobile's voicemail # pickup (about 20 s) ends the search on an answering machine. ring = 15 base = _base(cfg) queue_id = _queue_id(cfg) strategy_changed = _apply_strategy(cfg, base, queue_id) wanted = [_ovh_number(n) for n in numbers] existing = _agents(cfg) by_number = {} for agent_id, agent in existing.items(): by_number.setdefault(_ovh_number(agent.get("number")), agent_id) created, published = 0, [] for position, number in enumerate(wanted, start=1): agent_id = by_number.get(number) if agent_id is None: agent = request("POST", base + "/hunting/agent", cfg, { "number": number, "status": "available", "timeout": ring, # One call at a time: a rung already talking to a client must not be # handed the next one, it must be stepped over. "simultaneousLines": 1, "wrapUpTime": 0, "description": "Riposte on-call", }) agent_id = str(agent.get("agentId")) by_number[number] = agent_id created += 1 else: request("PUT", base + "/hunting/agent/" + str(agent_id), cfg, {"status": "available", "timeout": ring}) _place(cfg, base, queue_id, agent_id, position) published.append(_e164(number)) # Everybody else steps off the ladder. LOGGED OUT, NOT DELETED: deleting loses # whatever the operator configured on that agent, and the person is on the rota # again next week. # # They also leave the QUEUE, which is a different thing from being logged out and # is what keeps positions unique. A departing agent that kept its position leaves # two rungs numbered 1 — harmless while it is logged out, and an order nobody can # read in the control panel, which is where somebody will go to check this at # 03:00. logged_out = [] for agent_id, agent in existing.items(): number = _ovh_number(agent.get("number")) if number in wanted: continue if agent.get("status") == "loggedOut": continue request("PUT", base + "/hunting/agent/" + str(agent_id), cfg, {"status": "loggedOut"}) try: request("DELETE", base + "/hunting/agent/" + str(agent_id) + "/queue/" + str(queue_id), cfg) except urllib.error.HTTPError as e: # Already out of the queue. Not a failure: the agent is logged out either # way, which is the part that decides whether their phone rings. if e.code != 404: raise logged_out.append(_e164(number)) return { "ok": True, "queue_id": queue_id, "ladder": published, "created": created, "logged_out": logged_out, "ring_seconds": ring, "strategy_changed": strategy_changed, } _run(main)