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
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
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)
|
||||
@@ -0,0 +1,179 @@
|
||||
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)
|
||||
@@ -0,0 +1,260 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user