diff --git a/integrations/ovhcloud/manifest.yaml b/integrations/ovhcloud/manifest.yaml index 4c5ef18..406fc25 100644 --- a/integrations/ovhcloud/manifest.yaml +++ b/integrations/ovhcloud/manifest.yaml @@ -1,8 +1,8 @@ id: ovhcloud name: OVHcloud -version: 1.2.1 +version: 1.2.2 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.2.1 — The Test button now probes what the instance is FOR: with telephony coordinates filled in it reads the call queue, which proves the credentials, the IAM policy, the billing account, the service and the queue at once. It used to read /me unconditionally, so a service account correctly narrowed to /telephony failed its own test with a 403 on account:apiovh:me/get — credentials that were perfect, reported as broken. | 1.2.0 — Accept a SERVICE ACCOUNT (OAuth2 client credentials) as well as the historical application keys, because that is what the control panel offers on accounts where application keys are not available. Which scheme is used is decided by what you filled in, not by a mode selector, and an existing instance keeps working untouched. The API base is corrected to /v1 for a service account, since a Bearer token sent to the legacy /1.0 spelling fails in a way that reads as bad credentials. | 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." +changelog: "1.2.2 — Find the call queue under either product. OVH files a queue under easyHunting or under ovhPabx depending on the number's offer, and the control panel says \"File d'appels\" for both — so assuming the first answered 404 \"This service does not exist\", which sounds like a wrong number and is in fact a wrong product. The product is now discovered once per run, and a service found under neither says which names DO exist. | 1.2.1 — The Test button now probes what the instance is FOR: with telephony coordinates filled in it reads the call queue, which proves the credentials, the IAM policy, the billing account, the service and the queue at once. It used to read /me unconditionally, so a service account correctly narrowed to /telephony failed its own test with a 403 on account:apiovh:me/get — credentials that were perfect, reported as broken. | 1.2.0 — Accept a SERVICE ACCOUNT (OAuth2 client credentials) as well as the historical application keys, because that is what the control panel offers on accounts where application keys are not available. Which scheme is used is decided by what you filled in, not by a mode selector, and an existing instance keeps working untouched. The API base is corrected to /v1 for a service account, since a Bearer token sent to the legacy /1.0 spelling fails in a way that reads as bad credentials. | 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 @@ -49,7 +49,7 @@ config_schema: 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" + description: "The call queue's service name — the number it is attached to, in 0033xxxxxxxxx form. Works under either easyHunting or ovhPabx; the product is discovered." telephony_queue_id: type: string description: "Queue id, only needed when the service holds more than one queue" diff --git a/integrations/ovhcloud/scripts/get_oncall_ladder.py b/integrations/ovhcloud/scripts/get_oncall_ladder.py index 3f0e1c8..724d816 100644 --- a/integrations/ovhcloud/scripts/get_oncall_ladder.py +++ b/integrations/ovhcloud/scripts/get_oncall_ladder.py @@ -166,9 +166,40 @@ def _telephony(cfg): return ba, service +# The call queue lives under ONE OF TWO PRODUCTS, and which one is not something an +# operator can see: the control panel says "File d'appels" for both. OVH files them +# under easyHunting or under ovhPabx depending on the offer the number carries, and +# the hunting routes underneath are identical either way. +# +# Assuming easyHunting is what this did first, and the symptom was a 404 reading +# "This service does not exist" — which sounds like a wrong number and is in fact a +# wrong product. So it is DISCOVERED, once per process, and a failure says what does +# exist rather than what does not. +_PRODUCTS = ("easyHunting", "ovhPabx") + + +def _product(cfg): + if _CACHE.get("product"): + return _CACHE["product"] + ba, service = _telephony(cfg) + found = {} + for product in _PRODUCTS: + names = request("GET", "/telephony/" + urllib.parse.quote(ba, safe="") + "/" + product, cfg) or [] + found[product] = [str(n) for n in names] + if service in found[product]: + _CACHE["product"] = product + return product + raise Exception( + "no call queue named %s on %s. easyHunting holds [%s]; ovhPabx holds [%s]. " + "Set telephony_service to one of those names, or configure the number as a " + "call queue in the control panel first." + % (service, ba, ", ".join(found.get("easyHunting") or ["-"]), ", ".join(found.get("ovhPabx") or ["-"]))) + + def _base(cfg): ba, service = _telephony(cfg) - return "/telephony/" + urllib.parse.quote(ba, safe="") + "/easyHunting/" + urllib.parse.quote(service, safe="") + return ("/telephony/" + urllib.parse.quote(ba, safe="") + "/" + _product(cfg) + + "/" + urllib.parse.quote(service, safe="")) def _queue_id(cfg): diff --git a/integrations/ovhcloud/scripts/list_calls.py b/integrations/ovhcloud/scripts/list_calls.py index 2b7c643..34d05ab 100644 --- a/integrations/ovhcloud/scripts/list_calls.py +++ b/integrations/ovhcloud/scripts/list_calls.py @@ -166,9 +166,40 @@ def _telephony(cfg): return ba, service +# The call queue lives under ONE OF TWO PRODUCTS, and which one is not something an +# operator can see: the control panel says "File d'appels" for both. OVH files them +# under easyHunting or under ovhPabx depending on the offer the number carries, and +# the hunting routes underneath are identical either way. +# +# Assuming easyHunting is what this did first, and the symptom was a 404 reading +# "This service does not exist" — which sounds like a wrong number and is in fact a +# wrong product. So it is DISCOVERED, once per process, and a failure says what does +# exist rather than what does not. +_PRODUCTS = ("easyHunting", "ovhPabx") + + +def _product(cfg): + if _CACHE.get("product"): + return _CACHE["product"] + ba, service = _telephony(cfg) + found = {} + for product in _PRODUCTS: + names = request("GET", "/telephony/" + urllib.parse.quote(ba, safe="") + "/" + product, cfg) or [] + found[product] = [str(n) for n in names] + if service in found[product]: + _CACHE["product"] = product + return product + raise Exception( + "no call queue named %s on %s. easyHunting holds [%s]; ovhPabx holds [%s]. " + "Set telephony_service to one of those names, or configure the number as a " + "call queue in the control panel first." + % (service, ba, ", ".join(found.get("easyHunting") or ["-"]), ", ".join(found.get("ovhPabx") or ["-"]))) + + def _base(cfg): ba, service = _telephony(cfg) - return "/telephony/" + urllib.parse.quote(ba, safe="") + "/easyHunting/" + urllib.parse.quote(service, safe="") + return ("/telephony/" + urllib.parse.quote(ba, safe="") + "/" + _product(cfg) + + "/" + urllib.parse.quote(service, safe="")) def _queue_id(cfg): diff --git a/integrations/ovhcloud/scripts/set_oncall_ladder.py b/integrations/ovhcloud/scripts/set_oncall_ladder.py index 4d2ab46..19c1fa1 100644 --- a/integrations/ovhcloud/scripts/set_oncall_ladder.py +++ b/integrations/ovhcloud/scripts/set_oncall_ladder.py @@ -166,9 +166,40 @@ def _telephony(cfg): return ba, service +# The call queue lives under ONE OF TWO PRODUCTS, and which one is not something an +# operator can see: the control panel says "File d'appels" for both. OVH files them +# under easyHunting or under ovhPabx depending on the offer the number carries, and +# the hunting routes underneath are identical either way. +# +# Assuming easyHunting is what this did first, and the symptom was a 404 reading +# "This service does not exist" — which sounds like a wrong number and is in fact a +# wrong product. So it is DISCOVERED, once per process, and a failure says what does +# exist rather than what does not. +_PRODUCTS = ("easyHunting", "ovhPabx") + + +def _product(cfg): + if _CACHE.get("product"): + return _CACHE["product"] + ba, service = _telephony(cfg) + found = {} + for product in _PRODUCTS: + names = request("GET", "/telephony/" + urllib.parse.quote(ba, safe="") + "/" + product, cfg) or [] + found[product] = [str(n) for n in names] + if service in found[product]: + _CACHE["product"] = product + return product + raise Exception( + "no call queue named %s on %s. easyHunting holds [%s]; ovhPabx holds [%s]. " + "Set telephony_service to one of those names, or configure the number as a " + "call queue in the control panel first." + % (service, ba, ", ".join(found.get("easyHunting") or ["-"]), ", ".join(found.get("ovhPabx") or ["-"]))) + + def _base(cfg): ba, service = _telephony(cfg) - return "/telephony/" + urllib.parse.quote(ba, safe="") + "/easyHunting/" + urllib.parse.quote(service, safe="") + return ("/telephony/" + urllib.parse.quote(ba, safe="") + "/" + _product(cfg) + + "/" + urllib.parse.quote(service, safe="")) def _queue_id(cfg): diff --git a/integrations/ovhcloud/scripts/test_connection.py b/integrations/ovhcloud/scripts/test_connection.py index 38c3b0a..4315e7a 100644 --- a/integrations/ovhcloud/scripts/test_connection.py +++ b/integrations/ovhcloud/scripts/test_connection.py @@ -166,9 +166,40 @@ def _telephony(cfg): return ba, service +# The call queue lives under ONE OF TWO PRODUCTS, and which one is not something an +# operator can see: the control panel says "File d'appels" for both. OVH files them +# under easyHunting or under ovhPabx depending on the offer the number carries, and +# the hunting routes underneath are identical either way. +# +# Assuming easyHunting is what this did first, and the symptom was a 404 reading +# "This service does not exist" — which sounds like a wrong number and is in fact a +# wrong product. So it is DISCOVERED, once per process, and a failure says what does +# exist rather than what does not. +_PRODUCTS = ("easyHunting", "ovhPabx") + + +def _product(cfg): + if _CACHE.get("product"): + return _CACHE["product"] + ba, service = _telephony(cfg) + found = {} + for product in _PRODUCTS: + names = request("GET", "/telephony/" + urllib.parse.quote(ba, safe="") + "/" + product, cfg) or [] + found[product] = [str(n) for n in names] + if service in found[product]: + _CACHE["product"] = product + return product + raise Exception( + "no call queue named %s on %s. easyHunting holds [%s]; ovhPabx holds [%s]. " + "Set telephony_service to one of those names, or configure the number as a " + "call queue in the control panel first." + % (service, ba, ", ".join(found.get("easyHunting") or ["-"]), ", ".join(found.get("ovhPabx") or ["-"]))) + + def _base(cfg): ba, service = _telephony(cfg) - return "/telephony/" + urllib.parse.quote(ba, safe="") + "/easyHunting/" + urllib.parse.quote(service, safe="") + return ("/telephony/" + urllib.parse.quote(ba, safe="") + "/" + _product(cfg) + + "/" + urllib.parse.quote(service, safe="")) def _queue_id(cfg): @@ -212,15 +243,16 @@ def main(cfg, inputs): service = str(cfg.get("telephony_service") or "").strip() scheme = "service account" if _oauth(cfg) else "application key" if ba and service: + product = _product(cfg) queues = request("GET", _base(cfg) + "/hunting/queue", cfg) if not isinstance(queues, list) or not queues: # Reached the API and found the service, but it holds no queue. NOT a # failure of the credentials, and saying so plainly is the difference # between "go and create a queue" and an evening spent on authentication. - return {"ok": True, "auth": scheme, "telephony": True, "queues": 0, + return {"ok": True, "auth": scheme, "telephony": True, "product": product, "queues": 0, "detail": "Authenticated, and the service was found, but it holds no call queue yet. " "Create one in the control panel before publishing an on-call ladder."} - return {"ok": True, "auth": scheme, "telephony": True, + return {"ok": True, "auth": scheme, "telephony": True, "product": product, "queues": len(queues), "queue_ids": [str(q) for q in queues]} resp = request("GET", "/me", cfg) return {"ok": True, "auth": scheme, "nichandle": resp.get("nichandle")}