diff --git a/webhook_receiver_rev.py b/webhook_receiver_rev.py index b0d7108..492710e 100644 --- a/webhook_receiver_rev.py +++ b/webhook_receiver_rev.py @@ -102,25 +102,60 @@ def _parse_data_list(raw: str) -> list[dict]: async def _parse_request(request: Request) -> tuple[str, list[dict]]: """Extract token + data_list from either a JSON body or form-encoded body. - Jimi's integration push API sends: + Jimi's integration push has been observed to use form-encoding in + production: + Content-Type: application/x-www-form-urlencoded + Body: msgType=&data= + + However the Jimi documentation describes a JSON variant: Content-Type: application/json Body: {"token": "...", "data_list": [{...}, ...]} - Some older/configured endpoints may still use form-encoded. This helper - handles both so each endpoint doesn't need to know which format arrived. + [BUG-04] Branch on Content-Type so a JSON push is not silently dropped + by `request.form()` returning empty. Form path remains the default + fallback when the header is missing or unrecognised, matching the live + Jimi behaviour. """ body = await request.body() if not body: return "", [] - # Jimi integration push format (observed live): - # Content-Type: application/x-www-form-urlencoded - # Body: msgType=&data= + content_type = (request.headers.get("content-type") or "").lower() + + # JSON path: parse body as a single JSON document with {token, data_list|data}. + if content_type.startswith("application/json"): + try: + payload = json.loads(body) + except (json.JSONDecodeError, TypeError): + log.warning("push: JSON body parse failed — %.200s", body[:200]) + return "", [] + if not isinstance(payload, dict): + log.warning("push: JSON body is not an object — got %s", type(payload).__name__) + return "", [] + token = str(payload.get("token", "")) + raw_data = payload.get("data_list") if payload.get("data_list") is not None else payload.get("data") + if raw_data is None: + return token, [] + # data_list / data may itself be a JSON string OR a parsed list/object. + if isinstance(raw_data, str): + try: + raw_data = json.loads(raw_data) + except (json.JSONDecodeError, TypeError): + log.warning("push: JSON data field parse failed — %.200s", raw_data) + return token, [] + items = raw_data if isinstance(raw_data, list) else [raw_data] + if len(items) > MAX_ITEMS_PER_POST: + log.warning("push: truncated %d → %d items", len(items), MAX_ITEMS_PER_POST) + items = items[:MAX_ITEMS_PER_POST] + return token, items + + # Form-encoded path (observed live): + # msgType=&data= # The `data` field holds a single JSON object per event, not an array. try: form = await request.form() except Exception: - log.warning("push: form parse failed", exc_info=True) + log.warning("push: form parse failed (content-type=%s)", content_type, exc_info=True) return "", [] token = str(form.get("token", "")) @@ -225,6 +260,14 @@ async def push_obd(request: Request): obd = json.loads(obd) except json.JSONDecodeError: obd = {} + # [BUG-05] obdJson may parse to a list ([{...}]) on some + # devices. Without this guard the later obd.get() raises + # AttributeError and the item is silently dropped by the + # outer except. Take the first dict element when present. + if isinstance(obd, list): + obd = next((e for e in obd if isinstance(e, dict)), {}) + if not isinstance(obd, dict): + obd = {} # [BUG-01] Try unix epoch first, fall back to ISO string. event_time = (