Files
riposte-marketplace/integrations/ovhcloud/scripts/get_oncall_ladder.py
T
f3nris 864300d7c5 feat(ovhcloud): hold Riposte's on-call ladder in a VoIP call queue (1.1.0)
Riposte's pager can hand its on-call ladder to a marketplace instance, so that a
number published to clients rings whoever is on duty without the call ever
crossing the SOC's own trunk — and without spending one of its simultaneous
calls, which are sold in blocks and are the reason the PBX-side mode is too
expensive for a small deployment.

Three commands, and they are a contract rather than this integration's own
vocabulary: set_oncall_ladder (ordered numbers + ring_seconds), get_oncall_ladder
(what the carrier ACTUALLY holds, so a comparison can be honest) and list_calls
(the journal, after the fact). The pager offers whichever instances declare the
first one; the README now documents the contract for whoever writes the next
router.

Extends the existing ovhcloud integration rather than adding a second one: it
already carries the signed-request authentication, and making an operator enter
the same application key twice would serve nobody. Three telephony_* config
fields are added; every existing command is untouched, and an instance that
leaves them empty is exactly the OVHcloud reader it was before.

set_oncall_ladder RECONCILES rather than rebuilds. Agents are created when a
number has none, reordered by position, and anybody who drops off the ladder is
LOGGED OUT and removed from the queue — never deleted, because deleting throws
away whatever else the operator configured on that agent and they are on the rota
again next week. It also enforces the sequential strategy: publishing an ordered
list into a queue set to ring everybody at once is a lie that is invisible from
Riposte.

Two details that would each have cost an evening: OVH accepts no "+" in a
phoneNumber field, so E.164 is converted to the 00-prefixed form both ways; and a
service holding several queues is REFUSED rather than guessed, because guessing
publishes the on-call ladder into whichever queue the API happened to list first.

Verified against a stubbed API for the first publication and for a rota handover.
The signature, the error codes and the exact number format are only proved by the
first real call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TN1R4EnNFUV2Xwtd9q98tj
2026-09-19 18:00:33 +00:00

177 lines
7.0 KiB
Python

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
# ── get_oncall_ladder ───────────────────────────────────────────────────────
#
# What the queue ACTUALLY holds, in its order. Riposte's test button shows it
# beside the ladder the rota resolves to, which is the only way to catch a
# publication that failed hours ago or a change somebody made by hand.
#
# Reads only. It places no call and writes nothing.
def main(cfg, inputs):
base = _base(cfg)
queue_id = _queue_id(cfg)
queue = request("GET", base + "/hunting/queue/" + str(queue_id), cfg)
rungs = []
for agent_id in request("GET", base + "/hunting/queue/" + str(queue_id) + "/agent", cfg) or []:
membership = request("GET", base + "/hunting/queue/" + str(queue_id) + "/agent/" + str(agent_id), cfg)
agent = request("GET", base + "/hunting/agent/" + str(agent_id), cfg)
rungs.append({
"position": membership.get("position") or 0,
"number": _e164(agent.get("number")),
"status": agent.get("status"),
"timeout": agent.get("timeout"),
})
rungs.sort(key=lambda r: r["position"])
# ONLY THE AVAILABLE AGENTS ARE THE LADDER. A logged-out agent is somebody who
# stepped off it and whose row we keep; returning them would make every readback
# disagree with what Riposte published, which is exactly the alarm this command
# exists to raise honestly.
numbers = [r["number"] for r in rungs if r.get("status") == "available" and r["number"]]
timeouts = [r["timeout"] for r in rungs if r.get("status") == "available" and r.get("timeout")]
return {
"ok": True,
"numbers": numbers,
"ring_seconds": timeouts[0] if timeouts else None,
"queue_id": queue_id,
"strategy": queue.get("strategy"),
# Said plainly rather than left to be inferred from the strategy string: a
# queue ringing everybody at once takes the same list and ignores its order,
# and the ladder Riposte published would be a fiction.
"sequential": queue.get("strategy") == "sequentiallyByAgentOrder",
"rungs": rungs,
}
_run(main)