#!/usr/bin/env python3 """LFCS WhatsApp Cloud webhook — crew site-diary capture. Meta pushes each inbound WhatsApp message here. This service: - answers Meta's GET verification handshake, - on POST, logs the message + fetches any photo/voice media (saved to disk), - sends a ONE-LINE nudge when a photo lands with no caption ("what task?"). Quiet by design: it only speaks the nudge, nothing else — the group is shared, everyone's watching. Config (incl. the access token) lives in wa_config.json (chmod 600), NEVER in this file. Runs as a systemd service behind nginx (/wa-hook/ -> 127.0.0.1:8450). Stage: v1 = receive + save media + nudge. Diary write (transcribe/vision -> Airtable Daily Record) is the next layer, built on real captured data. """ import json, os, datetime, urllib.request, urllib.parse from http.server import BaseHTTPRequestHandler, HTTPServer CONF_PATH = "/root/.hermes/wa_config.json" LOG = "/root/.hermes/logs/wa_webhook.log" MEDIA_DIR = "/root/.hermes/wa_media" PORT = 8450 os.makedirs(MEDIA_DIR, exist_ok=True) EXT = {"image": "jpg", "audio": "ogg", "voice": "ogg", "video": "mp4", "document": "bin"} # --- Airtable Tools & Materials register (base appE43UvTyARe5oJs) --- AT_BASE = "appE43UvTyARe5oJs" AT_MOVEMENT = "tbl8dT8HcPzFu3HlS" # Movement Log AT_NOTES = "fldrE1otZ6pBZaB7I" # Notes (text) — v1 raw capture lands here HERMES_CONFIG_YAML = "/root/.hermes/config.yaml" def conf(): return json.load(open(CONF_PATH)) def airtable_pat(): import yaml return yaml.safe_load(open(HERMES_CONFIG_YAML))["mcp_servers"]["airtable"]["env"]["AIRTABLE_API_KEY"] def _env(k): for line in open("/root/.hermes/.env"): if line.startswith(k + "="): return line.split("=", 1)[1].strip() return "" PARSE_SYS = ( "You are a construction tools and materials clerk for LFCS. Extract a JSON object " "from the site message. Schema: {\"job\": string|null (LFCS job number like \"2631\" " "if present), \"direction\": \"out\"|\"in\"|null (out=drawn/taken to job, in=returned " "to store), \"items\":[{\"item\": string, \"qty\": number|null, \"unit\": string|null}]}. " "Tradie knowledge: 4x2=90x45 timber, ply=plywood sheet, gen=generator, drop saw=mitre saw. " "Return ONLY JSON." ) def parse_tm(msg): """Low-sensitivity T&M parse via DeepSeek (OpenAI-compatible). Returns dict or None. T&M is not commercial-sensitive (no bids/rates/margins), so the cheap lane is fine.""" key = _env("DEEPSEEK_API_KEY") if not key: return None body = json.dumps({"model": "deepseek-chat", "temperature": 0, "response_format": {"type": "json_object"}, "messages": [{"role": "system", "content": PARSE_SYS}, {"role": "user", "content": msg}]}).encode() r = urllib.request.Request("https://api.deepseek.com/v1/chat/completions", data=body, headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"}) try: out = json.loads(urllib.request.urlopen(r, timeout=30).read().decode()) return json.loads(out["choices"][0]["message"]["content"]) except Exception as e: log("PARSE ERR %s" % e) return None def log_movement(note): """v1 T&M capture — raw text into a Movement Log row's Notes. The office (or a later smart-parse layer) structures it into tool/material/qty. Marked unverified.""" try: body = json.dumps({"fields": {AT_NOTES: note}, "typecast": True}).encode() r = urllib.request.Request("https://api.airtable.com/v0/%s/%s" % (AT_BASE, AT_MOVEMENT), data=body, method="POST", headers={"Authorization": "Bearer " + airtable_pat(), "Content-Type": "application/json"}) res = json.loads(urllib.request.urlopen(r, timeout=25).read().decode()) log("MOVEMENT row %s" % res.get("id")) except Exception as e: log("MOVEMENT ERR %s" % e) def log(msg): line = "%s %s" % (datetime.datetime.now().isoformat(timespec="seconds"), msg) with open(LOG, "a") as f: f.write(line + "\n") def graph_get(url, token): r = urllib.request.Request(url, headers={"Authorization": "Bearer " + token}) return urllib.request.urlopen(r, timeout=30).read() def fetch_media(media_id, mtype, token, gv): meta = json.loads(graph_get("https://graph.facebook.com/%s/%s" % (gv, media_id), token)) data = graph_get(meta["url"], token) stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") path = os.path.join(MEDIA_DIR, "%s_%s.%s" % (stamp, media_id, EXT.get(mtype, "bin"))) with open(path, "wb") as f: f.write(data) return path, len(data) def send_text(to, body, c): if not c.get("access_token"): log("NUDGE skipped (no token yet) -> %s" % to) return payload = json.dumps({"messaging_product": "whatsapp", "to": to, "type": "text", "text": {"body": body}}).encode() url = "https://graph.facebook.com/%s/%s/messages" % (c["graph_version"], c["phone_number_id"]) r = urllib.request.Request(url, data=payload, method="POST", headers={"Authorization": "Bearer " + c["access_token"], "Content-Type": "application/json"}) try: urllib.request.urlopen(r, timeout=30) log("NUDGE sent -> %s" % to) except Exception as e: log("NUDGE ERR %s" % e) def handle(payload, c): for entry in payload.get("entry", []): for ch in entry.get("changes", []): v = ch.get("value", {}) for m in v.get("messages", []): frm = m.get("from") mtype = m.get("type") ts = m.get("timestamp") text = caption = "" media_id = None if mtype == "text": text = m.get("text", {}).get("body", "") elif mtype in EXT: node = m.get(mtype, {}) media_id = node.get("id") caption = node.get("caption", "") saved = "" if media_id and c.get("access_token"): try: p, n = fetch_media(media_id, mtype, c["access_token"], c["graph_version"]) saved = " saved=%s(%dB)" % (p, n) except Exception as e: saved = " MEDIA_ERR=%s" % e log("MSG from=%s type=%s ts=%s text=%r caption=%r media=%s%s" % (frm, mtype, ts, text, caption, media_id, saved)) # T&M capture (v1.5): LLM-parse tradie shorthand into a clean structured note. # We DO NOT write to the Movement Log's structured link fields (thin catalogue + # ambiguous job/location fields) — the parse lands legible in Notes marked CHECK, # for the office to promote. Source-of-truth: no guessed field writes. raw = text or caption or ("[%s]" % mtype) photo = ("\nphoto on box: " + saved.split("saved=")[-1].split("(")[0] if saved.startswith(" saved=") else "") parsed = parse_tm(raw) if (text or caption) else None if parsed: items = "; ".join( (("%s " % it["qty"] if it.get("qty") is not None else "") + ((it.get("unit") + " ") if it.get("unit") else "") + (it.get("item") or "")).strip() for it in parsed.get("items", [])) or "(no items read)" job = parsed.get("job") or "??" dirn = {"out": "OUT (drawn to job)", "in": "IN (returned)"}.get( parsed.get("direction"), "?") note = ("AI-parsed (CHECK) — Job %s | %s | %s\nraw: %s | from +%s%s" % (job, dirn, items, raw, frm, photo)) else: note = ("WhatsApp T&M capture (unverified) — from +%s\n%s%s" % (frm, raw, photo)) log_movement(note) # quiet single nudge: missing job first, else captionless photo if parsed and not parsed.get("job"): send_text(frm, "Got it. Which job's it for?", c) elif mtype in ("image", "video") and not caption: send_text(frm, c.get("nudge_text", "What job + tool/material is this?"), c) class Handler(BaseHTTPRequestHandler): def log_message(self, *a): pass # silence default stderr logging def do_GET(self): q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) mode = q.get("hub.mode", [""])[0] tok = q.get("hub.verify_token", [""])[0] ch = q.get("hub.challenge", [""])[0] if mode == "subscribe" and tok == conf().get("verify_token"): self.send_response(200) self.end_headers() self.wfile.write(ch.encode()) log("VERIFY ok") else: self.send_response(403) self.end_headers() log("VERIFY fail (tok mismatch)") def do_POST(self): n = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(n) self.send_response(200) # ack fast — Meta retries on non-200 self.end_headers() try: handle(json.loads(body), conf()) except Exception as e: log("POST ERR %s" % e) if __name__ == "__main__": log("wa_webhook starting on :%d" % PORT) HTTPServer(("127.0.0.1", PORT), Handler).serve_forever()