Spaces:
Running
Running
Download app/api/webhooks.py from Shurem/mz_chatbot: direct link, hf CLI and curl.
- Browser
- Download file 7.41 kB
-
https://huggingface.co/spaces/Shurem/mz_chatbot/resolve/main/app/api/webhooks.py
- Command line
-
hf download hf://spaces/Shurem/mz_chatbot/app/api/webhooks.py
-
curl -L -o webhooks.py https://huggingface.co/spaces/Shurem/mz_chatbot/resolve/main/app/api/webhooks.py
7.41 kB
| """ | |
| webhooks.py — SignalHire contact-reveal callback. | |
| This is the only route in the service that accepts an unauthenticated POST from | |
| the public internet. SignalHire cannot send an X-API-Key header, so the checks | |
| are: | |
| 1. an unguessable path token, compared in constant time | |
| 2. a candidate uid that must match a reveal WE started and have not yet completed | |
| 3. a body size cap, enforced before the body is parsed | |
| 4. a strict Pydantic model over the envelope, with the payload treated purely | |
| as data | |
| Confirmed against a live callback (2026-09-11): the body is a JSON array, one | |
| entry per requested candidate, shaped like | |
| [{"status": "success", "item": "<uid>", "candidate": {"uid": "<uid>", ...}}] | |
| "item" is the candidate uid this app originally submitted in `items: [uid]` | |
| to POST /candidate/search -- NOT the requestId that call returned. So the | |
| correlation key back to our own `reveal_requests` row is `signalhire_uid`, | |
| not `request_id` (which remains only as this app's own primary key / audit | |
| trail for that search call). | |
| Everything it writes goes through crud.contacts, which merges additively. A | |
| replayed or unrecognized uid is answered 404 and changes nothing. | |
| Deliberately NOT mounted behind require_api_key -- see main.py. | |
| """ | |
| from __future__ import annotations | |
| import hmac | |
| import json | |
| import logging | |
| from fastapi import APIRouter, Depends, HTTPException, Request, status | |
| from sqlalchemy.orm import Session | |
| from app.api.deps import get_db | |
| from app.config import settings | |
| from app.crud import contacts as crud_contacts | |
| from app.crud import enrichment_log as crud_enrich | |
| from app.crud import leads as crud_leads | |
| from app.services import contact_ingest, signalhire | |
| router = APIRouter(prefix="/webhooks", tags=["webhooks"]) | |
| log = logging.getLogger("outreach.webhooks") | |
| # SignalHire reveal payloads are small. Anything larger is not a reveal. | |
| MAX_CALLBACK_BYTES = 256 * 1024 | |
| async def signalhire_reveal_callback( | |
| token: str, | |
| request: Request, | |
| db: Session = Depends(get_db), | |
| ) -> dict: | |
| """ | |
| Receive revealed contact details and merge them onto the lead that paid. | |
| Returns a deliberately bland body: a public endpoint should never echo back | |
| what it was sent, nor reveal whether a given request_id exists beyond the | |
| status code. | |
| """ | |
| expected = (settings.signalhire_webhook_token or "").strip() | |
| if not expected or not hmac.compare_digest(token, expected): | |
| # 404 rather than 401 so probing cannot distinguish "no token configured" | |
| # from "wrong token". | |
| raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") | |
| raw = await request.body() | |
| if len(raw) > MAX_CALLBACK_BYTES: | |
| raise HTTPException( | |
| # Literal 413 rather than a starlette constant: the old | |
| # HTTP_413_REQUEST_ENTITY_TOO_LARGE name is deprecated and the new | |
| # HTTP_413_CONTENT_TOO_LARGE does not exist on older versions. | |
| status_code=413, | |
| detail="Callback body too large", | |
| ) | |
| try: | |
| payload = json.loads(raw or b"null") | |
| except ValueError: | |
| log.warning("signalhire callback body is not JSON (%d bytes)", len(raw)) | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Body is not JSON") from None | |
| uid = _uid_from(payload) | |
| if not uid: | |
| # Don't log the payload itself -- it carries the candidate's full | |
| # profile (name, employer, skills, phone...). Shape mismatches are | |
| # rare enough now that this app's own kind/keys are enough to debug. | |
| kind = type(payload).__name__ | |
| keys = list(payload[0].keys()) if isinstance(payload, list) and payload and isinstance(payload[0], dict) else ( | |
| list(payload.keys()) if isinstance(payload, dict) else [] | |
| ) | |
| log.warning("signalhire callback has no recognizable uid (payload=%s, keys=%s)", kind, keys) | |
| raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No candidate uid in callback") | |
| row = crud_contacts.get_reveal_request_by_uid(db, uid) | |
| if row is None: | |
| # Not a reveal we started (or already completed). Never create state | |
| # from an unrecognized uid. | |
| raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Unknown request") | |
| revealed = signalhire.parse_reveal_callback(payload) | |
| merged = 0 | |
| if revealed: | |
| # Attribute the result to the person we asked about when the payload omits it. | |
| for item in revealed: | |
| if not item.get("uid"): | |
| item["uid"] = uid | |
| merged = crud_contacts.upsert_contacts(db, row.lead_id, revealed) | |
| crud_contacts.promote_primary(db, row.lead_id) | |
| else: | |
| log.warning("signalhire callback for uid %s carried no email or phone", uid) | |
| # SignalHire answered, but the field that actually matters (phone) can | |
| # still be blank on its own -- e.g. it delivered only an email. Try | |
| # ContactOut for the gap before giving up. See | |
| # contact_ingest.fill_contact_via_contactout for the other two call sites | |
| # (the initial POST failing outright, and a reveal that never gets a | |
| # callback at all). | |
| contact = crud_contacts.get_contact(db, row.contact_id) if row.contact_id else None | |
| if contact is not None and not (contact.phone or "").strip(): | |
| lead = crud_leads.get_lead(db, row.lead_id) | |
| if lead is not None and contact_ingest.fill_contact_via_contactout(db, lead, contact): | |
| merged += 1 | |
| delivered = bool(merged or revealed) | |
| got_phone = any((r.get("phone") or "").strip() for r in (revealed or [])) | |
| got_email = any((r.get("email") or "").strip() for r in (revealed or [])) | |
| crud_enrich.record( | |
| db, row.lead_id, "signalhire", "reveal", "hit" if got_phone else "miss", | |
| contact_id=row.contact_id, | |
| note="" if got_phone else ("email only" if got_email else "callback carried nothing"), | |
| ) | |
| crud_contacts.complete_reveal( | |
| db, row, | |
| "delivered" if delivered else "failed", | |
| "" if delivered else "callback carried no email or phone", | |
| ) | |
| return {"ok": True, "contacts": merged} | |
| def _uid_from(payload: object) -> str: | |
| """ | |
| Find the candidate uid wherever SignalHire puts it. | |
| Confirmed live shape: a list of `{"item": "<uid>", "candidate": {"uid": ...}}`. | |
| Still tolerant of a bare dict (single result posted directly) and of a | |
| couple of alternate key spellings, in case SignalHire's other endpoints | |
| or a future version shape it differently. | |
| """ | |
| def _from_entry(entry: dict) -> str: | |
| for key in ("item", "requestId", "request_id", "id"): | |
| val = entry.get(key) | |
| if isinstance(val, (str, int)) and str(val).strip(): | |
| return str(val).strip()[:64] | |
| candidate = entry.get("candidate") | |
| if isinstance(candidate, dict): | |
| val = candidate.get("uid") | |
| if isinstance(val, (str, int)) and str(val).strip(): | |
| return str(val).strip()[:64] | |
| return "" | |
| if isinstance(payload, list): | |
| for entry in payload: | |
| if isinstance(entry, dict): | |
| found = _from_entry(entry) | |
| if found: | |
| return found | |
| elif isinstance(payload, dict): | |
| return _from_entry(payload) | |
| return "" | |