Initial commit

This commit is contained in:
2026-04-28 09:44:22 +10:00
commit 87bfe26890
11 changed files with 1252 additions and 0 deletions
+255
View File
@@ -0,0 +1,255 @@
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)