Add minimal Companion-to-Nextcloud-Health bridge (weight only, MVP)

This commit is contained in:
Matthias Jacob
2026-09-17 02:02:34 +02:00
parent d376d5ee95
commit 37f3399953
6 changed files with 609 additions and 0 deletions
+115
View File
@@ -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)