← Files OneSignalARCHIVED FILE

skills/setup/scripts/onesignal_api.py

11.4 KB · Sep 30, 2026 · 22:50 UTC

↓ Download file

#!/usr/bin/env python3
"""Deterministic OneSignal API probes for the setup / credentials / verify skills.

This encodes the fiddly, verified details that models keep getting wrong:
  - the web sync probe is CDN-cached ~1h, so we always append ?fresh=<ts>
  - android_params must be polled AFTER credentials upload (caller's job to
    order; this just polls with a timeout and never primes an empty cache)
  - response codes carry meaning: 409 = already configured (relay, don't retry),
    404 = feature flag off (fall back to dashboard), 401 = wrong app key
  - delivery stat `failed` counts UNSUBSCRIBED targets, not errors

Prefer the OneSignal MCP server's tools when connected (better auth story);
this script is the generic curl-equivalent fallback that needs no MCP.

Endpoint hosts are taken from references/api-reference.md and skills/verify/SKILL.md
(the subscriber-presence probe path and its Key→Basic auth fallback are specified
in verify/SKILL.md). Keys are read from --key or the environment; never printed back.

Usage:
    onesignal_api.py web-probe <app_id>
    onesignal_api.py android-params <app_id> [--timeout 60]
    onesignal_api.py notification-stats <notif_id> <app_id> --key KEY
    onesignal_api.py app <app_id> --key KEY
    onesignal_api.py subscribers <app_id> --key KEY

Key sources (in order): --key, $ONESIGNAL_REST_API_KEY, $ONESIGNAL_SETUP_TOKEN
Exit: 0 ok (see JSON `status`), 2 usage error, 3 network error, 4 auth missing
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request

API = "https://api.onesignal.com"


def _get(url, key=None, timeout=20, scheme="Key"):
    req = urllib.request.Request(url)
    if key:
        req.add_header("Authorization", f"{scheme} {key}")
    req.add_header("Accept", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            body = r.read().decode("utf-8", "replace")
            return r.status, body
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode("utf-8", "replace")
    except Exception as e:  # noqa: BLE001
        sys.stderr.write(f"ERROR: network failure: {e}\n")
        sys.exit(3)


def _json(body):
    try:
        return json.loads(body)
    except Exception:
        return None


def _resolve_key(args):
    return (getattr(args, "key", None)
            or os.environ.get("ONESIGNAL_REST_API_KEY")
            or os.environ.get("ONESIGNAL_SETUP_TOKEN"))


def _require_key(args):
    k = _resolve_key(args)
    if not k:
        sys.stderr.write(
            "ERROR: no API key. Pass --key, or export ONESIGNAL_REST_API_KEY / "
            "ONESIGNAL_SETUP_TOKEN. Never paste the key into committed files.\n"
        )
        sys.exit(4)
    return k


def cmd_web_probe(args):
    # Always cache-bust: the plain URL (and its code-2 error) is CDN-cached ~1h.
    url = f"{API}/sync/{args.app_id}/web?fresh={int(time.time())}"
    status, body = _get(url)
    data = _json(body) or {}
    if data.get("success") is True:
        result = {"status": "provisioned", "detail": "Web platform is live."}
    elif data.get("code") == 2:
        result = {"status": "not_configured",
                  "detail": "Web push NOT provisioned. The dashboard web-platform "
                            "step (Site URL) never happened — most common web wall."}
    elif data.get("code") == 1:
        result = {"status": "no_such_app", "detail": "No app with that ID."}
    else:
        result = {"status": "unknown", "detail": f"HTTP {status}", "raw": data}
    result.update({"probe": "web", "app_id": args.app_id, "http": status, "cache_busted": True})
    print(json.dumps(result, indent=2))


def cmd_android_params(args):
    # Poll only AFTER credentials upload. android_sender_id present => FCM live.
    deadline = time.time() + args.timeout
    url = f"{API}/apps/{args.app_id}/android_params.js"
    attempt = 0
    while True:
        attempt += 1
        status, body = _get(url)
        data = _json(body) or {}
        if data.get("android_sender_id"):
            print(json.dumps({"probe": "android_params", "app_id": args.app_id,
                              "http": status, "status": "fcm_live", "attempts": attempt}, indent=2))
            return
        if time.time() >= deadline:
            # Distinguish "endpoint answered, sender_id absent" (keep waiting) from
            # "endpoint erroring" (bad app id / outage). Reporting a non-2xx as
            # not_live sends the caller into an endless wait on a request failure.
            if not (200 <= status < 300):
                print(json.dumps({"probe": "android_params", "app_id": args.app_id,
                                  "http": status, "status": "error",
                                  "detail": f"HTTP {status} on every poll — not an FCM-not-live "
                                            "signal but a request failure (bad app id, or the "
                                            "route is unavailable). Do NOT keep waiting on this.",
                                  "attempts": attempt}, indent=2))
                return
            print(json.dumps({"probe": "android_params", "app_id": args.app_id,
                              "http": status, "status": "not_live",
                              "detail": "android_sender_id absent — FCM v1 credential "
                                        "not yet on the app (or still propagating).",
                              "attempts": attempt}, indent=2))
            return
        time.sleep(min(5, max(1, args.timeout // 12)))


def cmd_notification_stats(args):
    key = _require_key(args)
    url = f"{API}/notifications/{args.notif_id}?app_id={args.app_id}"
    status, body = _get(url, key=key)
    data = _json(body) or {}
    if status in (401, 403):
        print(json.dumps({"probe": "notification_stats", "app_id": args.app_id,
                          "notif_id": args.notif_id, "http": status, "status": "auth_error",
                          "detail": "Key does not belong to this app (or lacks access)."}, indent=2)); return
    if not (200 <= status < 300):
        # Never print null stat fields as if they were real numbers on an error.
        print(json.dumps({"probe": "notification_stats", "app_id": args.app_id,
                          "notif_id": args.notif_id, "http": status, "status": "error",
                          "detail": f"HTTP {status} — no stats returned (bad notif/app id, or route "
                                    "unavailable). Do NOT read the numbers below as real.",
                          "raw": data}, indent=2)); return
    out = {
        "probe": "notification_stats", "app_id": args.app_id, "notif_id": args.notif_id,
        "http": status, "status": "ok",
        "successful": data.get("successful"),
        "errored": data.get("errored"),
        "failed_unsubscribed": data.get("failed"),  # NOT delivery errors
        "converted_clicks": data.get("converted"),
        "received_confirmed": data.get("received"),
        "note": "`failed` = unsubscribed targets, not delivery failures. "
                "`errored` = real delivery errors (dashboard 'Failed').",
    }
    print(json.dumps(out, indent=2))


def cmd_app(args):
    key = _require_key(args)
    status, body = _get(f"{API}/api/v1/apps/{args.app_id}", key=key)
    data = _json(body) or {}
    if status in (401, 403):
        print(json.dumps({"probe": "app", "app_id": args.app_id, "http": status, "status": "auth_error",
                          "detail": "Key does not belong to this app (or lacks access)."}, indent=2)); return
    if status == 404:
        print(json.dumps({"probe": "app", "app_id": args.app_id, "http": status, "status": "not_found",
                          "detail": "No app with that ID (or route unavailable)."}, indent=2)); return
    if not (200 <= status < 300):
        print(json.dumps({"probe": "app", "app_id": args.app_id, "http": status, "status": "error",
                          "detail": f"HTTP {status}", "raw": data}, indent=2)); return
    # Surface which platforms look configured without asserting exact schema.
    out = {"probe": "app", "app_id": args.app_id, "http": status, "status": "ok",
           "keys_present": sorted(data.keys()) if isinstance(data, dict) else None}
    print(json.dumps(out, indent=2))


def cmd_subscribers(args):
    key = _require_key(args)
    # Presence pattern: limit 1, non-empty => a subscriber exists.
    # Path + auth per verify/SKILL.md: /players is the legacy Devices API; try the
    # current `Key` scheme first and fall back to legacy `Basic` if it is rejected,
    # so a Basic-only app is not misdiagnosed as a wrong key.
    url = f"{API}/players?app_id={args.app_id}&limit=1"
    status, body = _get(url, key=key)
    if status in (401, 403):
        status, body = _get(url, key=key, scheme="Basic")
    data = _json(body) or {}
    if status in (401, 403):
        print(json.dumps({"probe": "subscribers", "app_id": args.app_id, "http": status,
                          "status": "auth_error",
                          "detail": "Rejected under both Key and Basic auth — the key does not "
                                    "belong to this app, or lacks Devices-API access."}, indent=2)); return
    if not (200 <= status < 300):
        # A non-2xx here must NOT read as "no subscriber" — that sends verify into an
        # endless "keep waiting" loop. Report the error and let the caller fall back.
        print(json.dumps({"probe": "subscribers", "app_id": args.app_id, "http": status,
                          "status": "error", "has_subscriber": None,
                          "detail": f"HTTP {status} — presence unknown, not zero. Fall back to the "
                                    "dashboard (Audience → Subscriptions) to confirm.",
                          "raw": data}, indent=2)); return
    total = data.get("total_count")
    players = data.get("players") or []
    nt = players[0].get("notification_types") if players else None
    out = {"probe": "subscribers", "app_id": args.app_id, "http": status, "status": "ok",
           "has_subscriber": bool(players) or bool(total),
           "total_count": total,
           "first_subscription_id": players[0].get("id") if players else None,
           "notification_types": nt,
           "first_subscription_opted_in": bool(nt is not None and nt >= 1),
           "note": "notification_types >= 1 = opted in (the activation-ladder bar); "
                   "< 1 = registered but not opted in (pre-permission or unsubscribed); "
                   "do not test-send to it yet."}
    print(json.dumps(out, indent=2))


def main():
    ap = argparse.ArgumentParser(description="OneSignal API probes (generic curl fallback; prefer MCP if connected).")
    sub = ap.add_subparsers(dest="cmd", required=True)

    p = sub.add_parser("web-probe"); p.add_argument("app_id"); p.set_defaults(fn=cmd_web_probe)
    p = sub.add_parser("android-params"); p.add_argument("app_id"); p.add_argument("--timeout", type=int, default=60); p.set_defaults(fn=cmd_android_params)
    p = sub.add_parser("notification-stats"); p.add_argument("notif_id"); p.add_argument("app_id"); p.add_argument("--key"); p.set_defaults(fn=cmd_notification_stats)
    p = sub.add_parser("app"); p.add_argument("app_id"); p.add_argument("--key"); p.set_defaults(fn=cmd_app)
    p = sub.add_parser("subscribers"); p.add_argument("app_id"); p.add_argument("--key"); p.set_defaults(fn=cmd_subscribers)

    args = ap.parse_args()
    args.fn(args)


if __name__ == "__main__":
    main()

SHA-256: f867527260e250ba2f243f1ddd7582d82c1a73124fa718b11ff71c5911923276