| import os, hmac, hashlib, uuid |
| from datetime import datetime, timezone |
| from urllib.parse import urlencode |
|
|
| import requests |
| from fastapi import FastAPI, Query, HTTPException |
| from pydantic import BaseModel, Field |
|
|
| app = FastAPI(title="Binrushd Lead Tracking API") |
|
|
| FORM_PUBLIC_URL = os.getenv( |
| "FORM_PUBLIC_URL", |
| "https://mrhelp92.github.io/Binrushd-CRM-Automation-Inquires_Form/" |
| ) |
| GAS_WEBAPP_URL = os.getenv( |
| "GAS_WEBAPP_URL", |
| "https://script.google.com/macros/s/AKfycby8IZQ_YBQEiB4cwTcQORhAQmWtwOUAkb02M0Fu1wtDiiDVRwLpxV-rxsr4lSjAIKKt/exec" |
| ) |
| SIGNING_SECRET = os.getenv("SIGNING_SECRET", "CHANGE_ME") |
|
|
|
|
| |
| |
| |
| def _b64url(data: bytes) -> str: |
| import base64 |
| return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=") |
|
|
| def _hmac_sig(message: str) -> str: |
| sig = hmac.new(SIGNING_SECRET.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).digest() |
| return _b64url(sig) |
|
|
| def make_sig(lead_id: str, branch_id: str) -> str: |
| return _hmac_sig(f"{lead_id}|{branch_id}") |
|
|
| def verify_sig(lead_id: str, branch_id: str, sig: str) -> bool: |
| return hmac.compare_digest(make_sig(lead_id, branch_id), sig) |
|
|
| def gas_post(action: str, data: dict, timeout_sec: int = 25) -> dict: |
| """ |
| - دايمًا يرجّع dict فيه ok |
| - يمسك أي Timeout/Network Error بدل ما يوقع السيرفر |
| """ |
| payload = {"action": action, **data} |
| try: |
| r = requests.post(GAS_WEBAPP_URL, json=payload, timeout=timeout_sec) |
| except requests.exceptions.Timeout: |
| return {"ok": False, "error": f"GAS timeout after {timeout_sec}s", "action": action} |
| except requests.exceptions.RequestException as e: |
| return {"ok": False, "error": f"GAS request error: {str(e)}", "action": action} |
|
|
| try: |
| j = r.json() |
| except Exception: |
| return {"ok": False, "error": f"GAS invalid JSON: {r.text[:200]}", "action": action} |
|
|
| |
| if not isinstance(j, dict): |
| return {"ok": False, "error": "GAS returned non-dict JSON", "action": action} |
|
|
| return j |
|
|
|
|
| |
| |
| |
| class LinkResponse(BaseModel): |
| ok: bool = True |
| form_url: str |
| lead_id: str |
| branch_id: str |
| sig: str |
|
|
| class SubmitBody(BaseModel): |
| lead_id: str |
| branch_id: str |
| sig: str |
| contacted_at: str = Field(..., description="datetime-local from browser") |
| notes: str |
|
|
|
|
| |
| |
| |
| @app.get("/lead/link", response_model=LinkResponse) |
| def build_form_link( |
| lead_name: str = Query(..., min_length=1), |
| lead_phone: str = Query(..., min_length=6), |
| due_date: str = Query(..., min_length=4), |
| branch_email: str = Query(..., min_length=5), |
| ): |
| if "@" not in branch_email: |
| raise HTTPException(status_code=400, detail="Invalid branch_email") |
|
|
| lead_id = uuid.uuid4().hex |
| branch_id = uuid.uuid4().hex |
| sig = make_sig(lead_id, branch_id) |
|
|
| gas_res = gas_post("createLead", { |
| "created_at": datetime.now(timezone.utc).isoformat(), |
| "lead_id": lead_id, |
| "branch_id": branch_id, |
| "sig": sig, |
| "lead_name": lead_name, |
| "lead_phone": lead_phone, |
| "due_date": due_date, |
| "branch_email": branch_email, |
| }) |
|
|
| if not gas_res.get("ok"): |
| raise HTTPException(status_code=502, detail=f"GAS createLead failed: {gas_res.get('error','unknown')}") |
|
|
| qs = urlencode({"lead_id": lead_id, "branch_id": branch_id, "sig": sig}) |
| form_url = f"{FORM_PUBLIC_URL}?{qs}" |
|
|
| return LinkResponse(form_url=form_url, lead_id=lead_id, branch_id=branch_id, sig=sig) |
|
|
|
|
| |
| |
| |
| @app.get("/form/open") |
| def form_open( |
| lead_id: str = Query(...), |
| branch_id: str = Query(...), |
| sig: str = Query(...), |
| ua: str | None = Query(default=None), |
| ): |
| if not verify_sig(lead_id, branch_id, sig): |
| return {"ok": False, "error": "الرابط غير صالح."} |
|
|
| lead = gas_post("getLead", {"lead_id": lead_id, "branch_id": branch_id}) |
| if not lead.get("ok"): |
| |
| raise HTTPException(status_code=502, detail=f"GAS getLead failed: {lead.get('error','unknown')}") |
|
|
| data = lead.get("data") or {} |
| stored_sig = data.get("sig", "") |
| if stored_sig and not hmac.compare_digest(stored_sig, sig): |
| return {"ok": False, "error": "الرابط غير صالح."} |
|
|
| |
| open_res = gas_post("logOpen", { |
| "opened_at": datetime.now(timezone.utc).isoformat(), |
| "lead_id": lead_id, |
| "branch_id": branch_id, |
| "sig": sig, |
| "user_agent": ua or "" |
| }) |
|
|
| |
| if not open_res.get("ok"): |
| return {"ok": True, "data": data, "warning": f"logOpen failed: {open_res.get('error','unknown')}"} |
|
|
| return {"ok": True, "data": data} |
|
|
|
|
| |
| |
| |
| @app.post("/form/submit") |
| def form_submit(body: SubmitBody): |
| if not verify_sig(body.lead_id, body.branch_id, body.sig): |
| return {"ok": False, "error": "الرابط غير صالح."} |
|
|
| lead = gas_post("getLead", {"lead_id": body.lead_id, "branch_id": body.branch_id}) |
| if not lead.get("ok"): |
| raise HTTPException(status_code=502, detail=f"GAS getLead failed: {lead.get('error','unknown')}") |
|
|
| data = lead.get("data") or {} |
| stored_sig = data.get("sig", "") |
| if stored_sig and not hmac.compare_digest(stored_sig, body.sig): |
| return {"ok": False, "error": "الرابط غير صالح."} |
|
|
| res = gas_post("submitFollowup", { |
| "submitted_at": datetime.now(timezone.utc).isoformat(), |
| "lead_id": body.lead_id, |
| "branch_id": body.branch_id, |
| "sig": body.sig, |
| "contacted_at": body.contacted_at, |
| "notes": body.notes |
| }) |
|
|
| if not res.get("ok"): |
| raise HTTPException(status_code=502, detail=f"GAS submitFollowup failed: {res.get('error','unknown')}") |
|
|
| return {"ok": True} |
|
|