Add minimal Companion-to-Nextcloud-Health bridge (weight only, MVP)
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal bridge: Life Dashboard Companion webhook -> Nextcloud Health v3.
|
||||
|
||||
MVP scope: only the `weight` array of the Health Connect payload is forwarded,
|
||||
since that is the one OpenScale-relevant metric already built into Health v3.
|
||||
Everything else in the payload is ignored for now.
|
||||
|
||||
Run:
|
||||
NC_WEBHOOK_SECRET=... python server.py
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from hashlib import sha256
|
||||
from hmac import compare_digest, new as hmac_new
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from flask import Flask, request
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("bridge")
|
||||
|
||||
CREDENTIALS_FILE = Path(__file__).parent / "credentials.json"
|
||||
try:
|
||||
WEBHOOK_SECRET: str = os.environ["NC_WEBHOOK_SECRET"]
|
||||
except KeyError:
|
||||
raise SystemExit("NC_WEBHOOK_SECRET must be set (the Companion app's HMAC signing secret)")
|
||||
|
||||
credentials = json.loads(CREDENTIALS_FILE.read_text())
|
||||
NC_SERVER = credentials["server"]
|
||||
NC_AUTH = (credentials["login_name"], credentials["app_password"])
|
||||
ENTRIES_URL = f"{NC_SERVER}/ocs/v2.php/apps/health/api/v2/entries"
|
||||
# Identifies this bridge in Nextcloud's server logs and admin UI instead of the
|
||||
# default "python-requests/x.y.z".
|
||||
USER_AGENT = "PrivateHealthBridge"
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def verify_signature(raw_body: bytes, header_value: str | None) -> bool:
|
||||
if not header_value or not header_value.startswith("sha256="):
|
||||
return False
|
||||
expected = "sha256=" + hmac_new(WEBHOOK_SECRET.encode(), raw_body, sha256).hexdigest()
|
||||
return compare_digest(expected, header_value)
|
||||
|
||||
|
||||
def as_operation_id(record_uuid: str | None) -> str | None:
|
||||
if not record_uuid:
|
||||
return None
|
||||
try:
|
||||
uuid.UUID(record_uuid)
|
||||
return record_uuid
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def forward_weight_entry(record: dict) -> None:
|
||||
payload = {
|
||||
"metricKey": "weight",
|
||||
"numericValue": record["kilograms"],
|
||||
"optionValue": None,
|
||||
"context": "api",
|
||||
"source": "api",
|
||||
"recordedAt": record["time"],
|
||||
"note": None,
|
||||
}
|
||||
operation_id = as_operation_id(record.get("uuid"))
|
||||
if operation_id:
|
||||
payload["operationId"] = operation_id
|
||||
|
||||
resp = requests.post(
|
||||
ENTRIES_URL,
|
||||
json=payload,
|
||||
auth=NC_AUTH,
|
||||
headers={
|
||||
"OCS-APIRequest": "true",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
params={"format": "json"},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
@app.post("/webhook/health-connect")
|
||||
def health_connect_webhook():
|
||||
raw_body = request.get_data()
|
||||
if not verify_signature(raw_body, request.headers.get("X-Signature")):
|
||||
log.warning("Rejected webhook: invalid or missing signature")
|
||||
return "invalid signature", 401
|
||||
|
||||
body = request.get_json(force=True)
|
||||
weight_records = body.get("weight", [])
|
||||
log.info("Received %d weight record(s)", len(weight_records))
|
||||
|
||||
failures = 0
|
||||
for record in weight_records:
|
||||
try:
|
||||
forward_weight_entry(record)
|
||||
except requests.RequestException:
|
||||
log.exception("Failed to forward weight record %s", record.get("uuid"))
|
||||
failures += 1
|
||||
|
||||
if failures:
|
||||
# Non-2xx makes the Companion app retry the whole payload later.
|
||||
return f"{failures} record(s) failed to forward", 502
|
||||
|
||||
return "ok", 200
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8080)
|
||||
Reference in New Issue
Block a user