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:
@@ -202,6 +202,39 @@ named by field, evaluated against the rule's last captured samples.
|
||||
|
||||
---
|
||||
|
||||
## Well-known commands: the on-call router
|
||||
|
||||
Most command ids are an integration's own business. **Three are a contract**, and
|
||||
declaring them is how an integration becomes something Riposte's pager can hand its
|
||||
on-call ladder to — a telephony carrier that will ring the on-call team when a
|
||||
client calls the number you published to them.
|
||||
|
||||
| Command | Inputs | What it must do |
|
||||
|---|---|---|
|
||||
| `set_oncall_ladder` | `numbers` (ordered, E.164), `ring_seconds` | Ring those numbers, **in that order**, one after another. A carrier that can only hold ONE destination takes `numbers[0]` and says so in its description — a single rung that follows the rota still beats a redirect a human has to edit. Must be idempotent: Riposte republishes on a schedule. |
|
||||
| `get_oncall_ladder` | — | Answer `{ numbers: [...] }`: what the carrier **actually holds**, not what was last sent. Optional, and what makes Riposte's comparison honest. |
|
||||
| `list_calls` | `since`, `limit` | Answer `{ calls: [...] }` with `from_number`, `did`, `started_at`, `duration`. Optional; it fills the pager journal after the fact. |
|
||||
|
||||
**The id is the whole contract.** There is no registry, no manifest field to set and
|
||||
no vendor list anywhere in Riposte: the pager's settings screen offers whichever
|
||||
instances declare `set_oncall_ladder`.
|
||||
|
||||
**Declarative, never reactive.** Riposte says *who* to ring; the carrier decides
|
||||
*when*. A contract shaped the other way round — the carrier calling us as each call
|
||||
arrives — is a webhook, and a self-hosted SOAR on a VM with no inbound path from the
|
||||
internet cannot receive one.
|
||||
|
||||
Two things a router should be honest about in its description, because they decide
|
||||
whether an operator should use it at all: whether the carrier can **ask the answerer
|
||||
to press a key** before connecting the caller (most cannot, so a mobile's voicemail
|
||||
ends the search as though a person had answered), and whether ringing a rung costs
|
||||
anything on the operator's own trunk.
|
||||
|
||||
`integrations/ovhcloud` implements all three against a VoIP call queue; read it
|
||||
before writing another.
|
||||
|
||||
---
|
||||
|
||||
## Adding this catalog to Riposte
|
||||
|
||||
In Riposte → **Integrations → Marketplace → Add source**:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
id: ovhcloud
|
||||
name: OVHcloud
|
||||
version: 1.0.0
|
||||
description: "OVHcloud (API v1) — cloud account and infrastructure visibility: read the account, list public cloud projects, and list/read dedicated servers. Signed application-key authentication; stdlib-only, no extra Python dependencies. (French vendor.)"
|
||||
changelog: "1.0.0 — Initial release: get account, list cloud projects, list/get dedicated servers."
|
||||
version: 1.1.0
|
||||
description: "OVHcloud (API v1) — cloud account and infrastructure visibility, plus VoIP on-call routing: read the account, list public cloud projects, list/read dedicated servers, and hold Riposte's on-call ladder in a telephony call queue so a client number reaches whoever is on duty without any channel on your own PBX. Signed application-key authentication; stdlib-only, no extra Python dependencies. (French vendor.)"
|
||||
changelog: "1.1.0 — On-call routing: set_oncall_ladder / get_oncall_ladder / list_calls, backed by a VoIP call queue (easyHunting). Riposte publishes the ordered on-call numbers and the carrier rings them in that order, so an inbound client call never crosses your own trunk and spends no simultaneous call on it. Needs the three telephony_* config fields; every other command is unchanged. | 1.0.0 — Initial release: get account, list cloud projects, list/get dedicated servers."
|
||||
category: cloud
|
||||
|
||||
# Per-instance configuration. Requests are signed (SHA-1) with the application
|
||||
@@ -24,6 +24,19 @@ config_schema:
|
||||
type: string
|
||||
description: "Consumer key"
|
||||
x-soar-sensitive: true
|
||||
# ── VoIP on-call routing ─────────────────────────────────────────────
|
||||
#
|
||||
# Only the three on-call commands read these. Leave them empty and this
|
||||
# integration is exactly what it was before: an OVHcloud reader.
|
||||
telephony_billing_account:
|
||||
type: string
|
||||
description: "Telephony billing account, e.g. fr-xxxxx-1 (Telecom > VoIP in the control panel)"
|
||||
telephony_service:
|
||||
type: string
|
||||
description: "The call queue's service name — the number it is attached to, in 0033xxxxxxxxx form"
|
||||
telephony_queue_id:
|
||||
type: string
|
||||
description: "Queue id, only needed when the service holds more than one queue"
|
||||
required:
|
||||
- application_key
|
||||
- application_secret
|
||||
@@ -72,3 +85,63 @@ commands:
|
||||
properties: {}
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
|
||||
# ── On-call routing ───────────────────────────────────────────────────────
|
||||
#
|
||||
# THE THREE COMMANDS RIPOSTE'S PAGER LOOKS FOR. An integration becomes an
|
||||
# on-call router by declaring set_oncall_ladder and nothing else: there is no
|
||||
# registry, no vendor list, and the other two are optional.
|
||||
#
|
||||
# What they act on is a VoIP CALL QUEUE (easyHunting): its agents are the rungs,
|
||||
# their position is the order, and the queue rings them one after another. The
|
||||
# call is answered by the carrier, so it never reaches your PBX and spends none
|
||||
# of its simultaneous calls — which is the whole reason to route an on-call
|
||||
# number this way rather than through Asterisk.
|
||||
#
|
||||
# WHAT THIS CANNOT DO, said here because it decides whether the trade is worth
|
||||
# it: a carrier's queue cannot ask the analyst to press a key before connecting
|
||||
# the client. A mobile's voicemail answering therefore ends the search as though
|
||||
# a person had picked up. Keeping ring_seconds under 20 is the only mitigation.
|
||||
- id: set_oncall_ladder
|
||||
name: ovh-set-oncall-ladder
|
||||
description: "Publish the ordered on-call numbers to the call queue. Agents are created as needed, ordered by position, and anybody no longer on the ladder is logged out — never deleted."
|
||||
# DESTRUCTIVE rather than safe_write, and deliberately above its blast radius:
|
||||
# nothing is destroyed, but this decides which phone rings at 03:00. Riposte's
|
||||
# pager calls it directly; the tier is what stops an AI agent rerouting an
|
||||
# on-call number unattended.
|
||||
risk: destructive
|
||||
inputs_schema:
|
||||
properties:
|
||||
numbers:
|
||||
type: array
|
||||
description: "Ordered numbers to ring (E.164 or 00-prefixed). Rung 1 first."
|
||||
ring_seconds:
|
||||
type: number
|
||||
description: "How long each rung rings before the next one, 5 to 30"
|
||||
required: [numbers]
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
- id: get_oncall_ladder
|
||||
name: ovh-get-oncall-ladder
|
||||
description: "Read back the ladder the queue actually holds, in its order. Reads only; places no call."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties: {}
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
- id: list_calls
|
||||
name: ovh-list-oncall-calls
|
||||
description: "Recent inbound calls on the on-call number, for the pager journal."
|
||||
risk: read
|
||||
inputs_schema:
|
||||
properties:
|
||||
since:
|
||||
type: string
|
||||
description: "ISO-8601 instant to read from (default: 24 hours ago)"
|
||||
limit:
|
||||
type: number
|
||||
description: "Maximum calls to detail (default 50, max 200)"
|
||||
required: []
|
||||
outputs_schema: { properties: {} }
|
||||
|
||||
@@ -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