Files
plumbing/servicem8_webhook_receiver.py
T
2026-04-28 09:44:22 +10:00

256 lines
7.9 KiB
Python
Executable File

import json
import logging
import os
import sqlite3
from contextlib import closing
from datetime import datetime, timezone
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse
DB_PATH = os.getenv("WEBHOOK_DB_PATH", "./servicem8_webhooks.db")
APP_HOST = os.getenv("WEBHOOK_HOST", "0.0.0.0")
APP_PORT = int(os.getenv("WEBHOOK_PORT", "18354"))
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("servicem8-webhook-receiver")
app = FastAPI(title="ServiceM8 Webhook Receiver", version="1.2.1")
def get_conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
with closing(get_conn()) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS webhook_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at TEXT NOT NULL,
client_host TEXT,
method TEXT NOT NULL,
path TEXT NOT NULL,
headers_json TEXT,
payload_json TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS webhook_objects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at TEXT NOT NULL,
client_host TEXT,
method TEXT NOT NULL,
path TEXT NOT NULL,
object_type TEXT,
object_uuid TEXT,
changed_fields_json TEXT,
object_time_utc TEXT,
resource_url TEXT,
headers_json TEXT,
payload_json TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS webhook_form_responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
received_at TEXT NOT NULL,
client_host TEXT,
method TEXT NOT NULL,
path TEXT NOT NULL,
headers_json TEXT,
payload_json TEXT NOT NULL
)
"""
)
conn.commit()
@app.on_event("startup")
async def startup_event():
init_db()
logger.info("SQLite DB ready at %s", DB_PATH)
@app.get("/health")
async def health():
return {"ok": True}
async def parse_request_payload(request: Request):
content_type = request.headers.get("content-type", "").lower()
if "application/json" in content_type:
try:
return await request.json()
except Exception:
body = await request.body()
return {"_raw_body": body.decode("utf-8", errors="replace")}
if "application/x-www-form-urlencoded" in content_type or "multipart/form-data" in content_type:
try:
form = await request.form()
return dict(form)
except Exception:
body = await request.body()
return {"_raw_body": body.decode("utf-8", errors="replace")}
try:
return await request.json()
except Exception:
body = await request.body()
return {"_raw_body": body.decode("utf-8", errors="replace")}
def maybe_handle_challenge(payload, client_host):
if isinstance(payload, dict) and payload.get("mode") == "subscribe" and payload.get("challenge"):
challenge = str(payload["challenge"])
logger.info("ServiceM8 webhook verification received from %s", client_host)
return PlainTextResponse(challenge, status_code=200)
return None
def store_simple_event(table_name, request: Request, client_host: str, received_at: str, headers: dict, payload):
with closing(get_conn()) as conn:
conn.execute(
f"""
INSERT INTO {table_name} (
received_at,
client_host,
method,
path,
headers_json,
payload_json
) VALUES (?, ?, ?, ?, ?, ?)
""",
(
received_at,
client_host,
request.method,
request.url.path,
json.dumps(headers),
json.dumps(payload),
),
)
conn.commit()
@app.post("/webhooks/servicem8-job-updated")
async def servicem8_event_webhook(request: Request):
payload = await parse_request_payload(request)
headers = dict(request.headers)
client_host = request.client.host if request.client else None
received_at = datetime.now(timezone.utc).isoformat()
challenge_response = maybe_handle_challenge(payload, client_host)
if challenge_response is not None:
return challenge_response
store_simple_event("webhook_events", request, client_host, received_at, headers, payload)
logger.info("Event webhook received from %s and stored", client_host)
return PlainTextResponse("OK", status_code=200)
@app.post("/webhooks/servicem8/form-response")
async def servicem8_form_response_webhook(request: Request):
payload = await parse_request_payload(request)
headers = dict(request.headers)
client_host = request.client.host if request.client else None
received_at = datetime.now(timezone.utc).isoformat()
challenge_response = maybe_handle_challenge(payload, client_host)
if challenge_response is not None:
return challenge_response
store_simple_event("webhook_form_responses", request, client_host, received_at, headers, payload)
logger.info("Form response webhook received from %s and stored", client_host)
return PlainTextResponse("OK", status_code=200)
@app.post("/webhooks/servicem8-object")
async def servicem8_object_webhook(request: Request):
payload = await parse_request_payload(request)
headers = dict(request.headers)
client_host = request.client.host if request.client else None
received_at = datetime.now(timezone.utc).isoformat()
challenge_response = maybe_handle_challenge(payload, client_host)
if challenge_response is not None:
return challenge_response
object_type = None
object_uuid = None
changed_fields = None
object_time_utc = None
resource_url = None
if isinstance(payload, dict):
object_type = payload.get("object")
resource_url = payload.get("resource_url")
entry = payload.get("entry")
if isinstance(entry, list) and len(entry) > 0 and isinstance(entry[0], dict):
first_entry = entry[0]
object_uuid = first_entry.get("uuid")
changed_fields = first_entry.get("changed_fields")
object_time_utc = first_entry.get("time")
with closing(get_conn()) as conn:
conn.execute(
"""
INSERT INTO webhook_objects (
received_at,
client_host,
method,
path,
object_type,
object_uuid,
changed_fields_json,
object_time_utc,
resource_url,
headers_json,
payload_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
received_at,
client_host,
request.method,
request.url.path,
object_type,
object_uuid,
json.dumps(changed_fields) if changed_fields is not None else None,
object_time_utc,
resource_url,
json.dumps(headers),
json.dumps(payload),
),
)
conn.commit()
logger.info(
"Object webhook received from %s: object=%s uuid=%s",
client_host,
object_type,
object_uuid,
)
return PlainTextResponse("OK", status_code=200)
if __name__ == "__main__":
import uvicorn
uvicorn.run("servicem8_webhook_receiver:app", host=APP_HOST, port=APP_PORT, reload=False)