victor34593993 commited on
Commit
c3692d2
·
verified ·
1 Parent(s): 4ebc6e6

feat: one-click whatsapp onboarding

Browse files
GUIA_WHATSAPP.md CHANGED
@@ -66,6 +66,33 @@ teléfono y firma de seguridad. Solo falta el papeleo de Meta (esta guía).
66
  - El número de prueba de Meta solo habla con hasta 5 números que registres tú
67
  (perfecto para probar). El salto a número real es la Parte 4.
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  ## Problemas típicos
70
 
71
  - **"Verify and save" falla:** el verify token no coincide con el del Space, o
 
66
  - El número de prueba de Meta solo habla con hasta 5 números que registres tú
67
  (perfecto para probar). El salto a número real es la Parte 4.
68
 
69
+ ## Alta de un CLIENTE nuevo (1 clic desde tu admin)
70
+
71
+ El webhook es global (configurado una vez): **no se toca nunca más**. Para cada
72
+ tienda nueva:
73
+
74
+ 1. **Registra su número** en tu WhatsApp Manager (Business Manager → WhatsApp
75
+ Manager → Añadir número). El cliente solo te pasa el código SMS que le llega.
76
+ Tu negocio verificado permite hasta 20 números; cada cliente puede tener su
77
+ propio WABA bajo tu portfolio (su nombre de empresa sale en WhatsApp).
78
+ 2. En el **admin del bot** → su tienda → sección WhatsApp → pega **WABA ID** +
79
+ **Phone Number ID** (+ token si esa tienda usa uno propio; vacío = el global)
80
+ → **Conectar número**. El backend suscribe el WABA, valida el número y lo
81
+ deja contestando. Eso es todo.
82
+
83
+ > Con un **token permanente de system user** de TU Business Manager (Parte 4)
84
+ > configurado como global, las tiendas bajo tu portfolio no necesitan token
85
+ > propio: el alta es literalmente pegar 2 IDs y pulsar un botón.
86
+
87
+ ### Futuro: autoservicio total (Embedded Signup)
88
+
89
+ Para que el cliente se conecte SOLO (botón "Conectar WhatsApp" → login con su
90
+ Facebook → listo, como Twilio/360dialog): requiere **Verificación de acceso**
91
+ (proveedor de tecnología; Configuración básica → Verificación de acceso, ~5
92
+ días) + acceso avanzado a `whatsapp_business_management` y
93
+ `whatsapp_business_messaging` (App Review). Cuando Meta lo apruebe, se monta el
94
+ botón sobre el mismo endpoint de conexión que ya existe.
95
+
96
  ## Problemas típicos
97
 
98
  - **"Verify and save" falla:** el verify token no coincide con el del Space, o
app/admin_ui/index.html CHANGED
@@ -114,11 +114,15 @@
114
  </div>
115
  <p class="muted" id="sh-state"></p>
116
  <h2 style="margin-top:14px;">WhatsApp (Meta Cloud API) — opcional</h2>
 
117
  <div class="row">
 
118
  <div><label>Phone Number ID</label><input id="wa-phone" placeholder="ID del número (Meta)" /></div>
119
- <div><label>Access Token</label><input id="wa-token" type="password" placeholder="(se guarda cifrado)" /></div>
120
  </div>
121
- <button style="margin-top:8px;" onclick="saveWhatsapp()">Guardar WhatsApp</button>
 
 
 
122
  <p class="muted" id="wa-state"></p>
123
  <h2 style="margin-top:14px;">Acciones que puede hacer el bot (opt-in)</h2>
124
  <p class="muted">El bot solo ejecuta estas acciones tras verificar la identidad del cliente. Actívalas bajo tu responsabilidad.</p>
@@ -245,12 +249,20 @@
245
  await api("/tenants/" + slug(), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
246
  $("sh-secret").value = ""; flash("Shopify guardado."); onTenant();
247
  }
248
- async function saveWhatsapp() {
249
- var body = { whatsapp_phone_id: $("wa-phone").value.trim() };
250
- var tok = $("wa-token").value.trim(); if (tok) body.whatsapp_token = tok;
251
- await api("/tenants/" + slug(), { method: "PUT", headers: { "Content-Type": "application/json" },
 
252
  body: JSON.stringify(body) });
253
- $("wa-token").value = ""; flash("WhatsApp guardado."); onTenant();
 
 
 
 
 
 
 
254
  }
255
  $("token").addEventListener("change", loadTenants);
256
  </script>
 
114
  </div>
115
  <p class="muted" id="sh-state"></p>
116
  <h2 style="margin-top:14px;">WhatsApp (Meta Cloud API) — opcional</h2>
117
+ <p class="muted">Alta en 1 clic: suscribe el WABA del cliente a la app, valida el número y lo conecta a esta tienda. El webhook ya es global (no hay que tocarlo).</p>
118
  <div class="row">
119
+ <div><label>WABA ID</label><input id="wa-waba" placeholder="WhatsApp Business Account ID" /></div>
120
  <div><label>Phone Number ID</label><input id="wa-phone" placeholder="ID del número (Meta)" /></div>
 
121
  </div>
122
+ <div class="row" style="margin-top:8px;">
123
+ <div><label>Access Token</label><input id="wa-token" type="password" placeholder="(se guarda cifrado; vacío = usar el ya guardado/global)" /></div>
124
+ <button onclick="connectWhatsapp()">Conectar número</button>
125
+ </div>
126
  <p class="muted" id="wa-state"></p>
127
  <h2 style="margin-top:14px;">Acciones que puede hacer el bot (opt-in)</h2>
128
  <p class="muted">El bot solo ejecuta estas acciones tras verificar la identidad del cliente. Actívalas bajo tu responsabilidad.</p>
 
249
  await api("/tenants/" + slug(), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
250
  $("sh-secret").value = ""; flash("Shopify guardado."); onTenant();
251
  }
252
+ async function connectWhatsapp() {
253
+ var body = { waba_id: $("wa-waba").value.trim(), phone_number_id: $("wa-phone").value.trim() };
254
+ var tok = $("wa-token").value.trim(); if (tok) body.token = tok;
255
+ var r = await api("/tenants/" + slug() + "/whatsapp/connect", {
256
+ method: "POST", headers: { "Content-Type": "application/json" },
257
  body: JSON.stringify(body) });
258
+ if (r.ok) {
259
+ var d = await r.json();
260
+ flash("WhatsApp conectado: " + (d.display_phone_number || "OK"));
261
+ } else {
262
+ var e = await r.json().catch(function () { return {}; });
263
+ flash("Error al conectar: " + (e.detail || r.status));
264
+ }
265
+ $("wa-token").value = ""; onTenant();
266
  }
267
  $("token").addEventListener("change", loadTenants);
268
  </script>
app/routes/admin.py CHANGED
@@ -14,12 +14,20 @@ from fastapi.responses import HTMLResponse
14
  from sqlalchemy import delete, select
15
  from sqlalchemy.ext.asyncio import AsyncSession
16
 
 
 
17
  from app.db import get_session
18
  from app.deps import require_admin
19
  from app.models import ChatMessage, ChatSession, KnowledgeChunk, KnowledgeSource, Tenant
20
  from app.rag import index
21
- from app.schemas import SourceIn, SourceOut, TenantIn, TenantOut, TenantUpdate
22
- from app.tenancy import create_tenant, get_tenant_by_slug, list_tenants, update_tenant
 
 
 
 
 
 
23
 
24
  router = APIRouter(prefix="/admin")
25
 
@@ -92,6 +100,47 @@ async def put_tenant(
92
  return TenantOut.from_tenant(tenant)
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  @router.delete("/tenants/{slug}", dependencies=[Depends(require_admin)])
96
  async def delete_tenant(
97
  slug: str, db: AsyncSession = Depends(get_session)
 
14
  from sqlalchemy import delete, select
15
  from sqlalchemy.ext.asyncio import AsyncSession
16
 
17
+ from app import wa
18
+ from app.config import Settings, get_settings
19
  from app.db import get_session
20
  from app.deps import require_admin
21
  from app.models import ChatMessage, ChatSession, KnowledgeChunk, KnowledgeSource, Tenant
22
  from app.rag import index
23
+ from app.schemas import SourceIn, SourceOut, TenantIn, TenantOut, TenantUpdate, WhatsAppConnectIn
24
+ from app.tenancy import (
25
+ create_tenant,
26
+ get_tenant_by_slug,
27
+ list_tenants,
28
+ tenant_whatsapp_token,
29
+ update_tenant,
30
+ )
31
 
32
  router = APIRouter(prefix="/admin")
33
 
 
100
  return TenantOut.from_tenant(tenant)
101
 
102
 
103
+ @router.post("/tenants/{slug}/whatsapp/connect", dependencies=[Depends(require_admin)])
104
+ async def connect_whatsapp(
105
+ slug: str,
106
+ body: WhatsAppConnectIn,
107
+ db: AsyncSession = Depends(get_session),
108
+ settings: Settings = Depends(get_settings),
109
+ ) -> dict[str, str]:
110
+ """One-click WhatsApp onboarding for a store: subscribe its WABA to our app
111
+ (the app-level webhook is already global), validate the number, and save
112
+ everything on the tenant. After this, the number answers automatically."""
113
+ tenant = await _require_tenant(db, slug)
114
+ token = (
115
+ body.token
116
+ or tenant_whatsapp_token(tenant)
117
+ or settings.whatsapp_access_token
118
+ )
119
+ if not token:
120
+ raise HTTPException(status_code=422, detail="no access token (body, tenant or global)")
121
+ result = await wa.connect_number(
122
+ waba_id=body.waba_id,
123
+ phone_number_id=body.phone_number_id,
124
+ token=token,
125
+ graph_base=settings.whatsapp_graph_base,
126
+ api_version=settings.whatsapp_api_version,
127
+ )
128
+ if not result["ok"]:
129
+ raise HTTPException(status_code=502, detail=result["error"])
130
+ await update_tenant(
131
+ db,
132
+ tenant,
133
+ whatsapp_token=body.token, # only stored when provided (None = keep)
134
+ whatsapp_phone_id=body.phone_number_id,
135
+ )
136
+ await db.commit()
137
+ return {
138
+ "status": "connected",
139
+ "display_phone_number": result["display_phone_number"],
140
+ "verified_name": result["verified_name"],
141
+ }
142
+
143
+
144
  @router.delete("/tenants/{slug}", dependencies=[Depends(require_admin)])
145
  async def delete_tenant(
146
  slug: str, db: AsyncSession = Depends(get_session)
app/schemas.py CHANGED
@@ -81,6 +81,12 @@ class TenantUpdate(BaseModel):
81
  whatsapp_token: str | None = None
82
 
83
 
 
 
 
 
 
 
84
  class TenantOut(BaseModel):
85
  id: int
86
  slug: str
 
81
  whatsapp_token: str | None = None
82
 
83
 
84
+ class WhatsAppConnectIn(BaseModel):
85
+ waba_id: str = Field(min_length=1)
86
+ phone_number_id: str = Field(min_length=1)
87
+ token: str | None = None # stored encrypted on the tenant when provided
88
+
89
+
90
  class TenantOut(BaseModel):
91
  id: int
92
  slug: str
app/wa.py CHANGED
@@ -152,3 +152,45 @@ class WhatsAppSender:
152
  except httpx.HTTPError:
153
  log.exception("whatsapp send error")
154
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  except httpx.HTTPError:
153
  log.exception("whatsapp send error")
154
  return False
155
+
156
+
157
+ # ── one-click client onboarding (Graph API) ──────────────────────────────
158
+
159
+ async def connect_number(
160
+ *,
161
+ waba_id: str,
162
+ phone_number_id: str,
163
+ token: str,
164
+ graph_base: str = "https://graph.facebook.com",
165
+ api_version: str = "v25.0",
166
+ ) -> dict[str, Any]:
167
+ """Subscribe a client's WABA to our app and validate the phone number.
168
+
169
+ The app-level webhook is configured once globally, so after this call the
170
+ number's incoming messages reach /whatsapp/webhook with no further setup.
171
+ Returns {"ok": True, "display_phone_number": ...} or {"ok": False, "error": ...}.
172
+ """
173
+ base = graph_base.rstrip("/")
174
+ headers = {"Authorization": f"Bearer {token}"}
175
+ try:
176
+ async with httpx.AsyncClient(timeout=20.0) as client:
177
+ sub = await client.post(
178
+ f"{base}/{api_version}/{waba_id}/subscribed_apps", headers=headers
179
+ )
180
+ if sub.status_code >= 400:
181
+ return {"ok": False, "error": f"subscribe_failed: {sub.text[:300]}"}
182
+ info = await client.get(
183
+ f"{base}/{api_version}/{phone_number_id}",
184
+ params={"fields": "display_phone_number,verified_name"},
185
+ headers=headers,
186
+ )
187
+ if info.status_code >= 400:
188
+ return {"ok": False, "error": f"phone_check_failed: {info.text[:300]}"}
189
+ data = info.json()
190
+ except httpx.HTTPError as exc:
191
+ return {"ok": False, "error": f"network: {exc}"}
192
+ return {
193
+ "ok": True,
194
+ "display_phone_number": data.get("display_phone_number", ""),
195
+ "verified_name": data.get("verified_name", ""),
196
+ }
tests/routes/test_whatsapp_connect.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+ import respx
5
+
6
+ AUTH = {"Authorization": "Bearer test-admin-token"}
7
+
8
+ GRAPH = "https://graph.facebook.com/v21.0" # default test settings version
9
+
10
+
11
+ async def _tenant(client, slug="cliente1"):
12
+ r = await client.post("/admin/tenants", headers=AUTH, json={"slug": slug})
13
+ assert r.status_code == 201
14
+
15
+
16
+ @respx.mock
17
+ async def test_connect_subscribes_validates_and_saves(app_client):
18
+ _app, client = app_client
19
+ await _tenant(client)
20
+
21
+ sub = respx.post(f"{GRAPH}/WABA1/subscribed_apps").mock(
22
+ return_value=httpx.Response(200, json={"success": True})
23
+ )
24
+ respx.get(f"{GRAPH}/PHONE1").mock(
25
+ return_value=httpx.Response(
26
+ 200, json={"display_phone_number": "+34 600 000 000",
27
+ "verified_name": "Cliente 1", "id": "PHONE1"}
28
+ )
29
+ )
30
+
31
+ r = await client.post(
32
+ "/admin/tenants/cliente1/whatsapp/connect", headers=AUTH,
33
+ json={"waba_id": "WABA1", "phone_number_id": "PHONE1", "token": "tok-123"},
34
+ )
35
+ assert r.status_code == 200, r.text
36
+ body = r.json()
37
+ assert body["status"] == "connected"
38
+ assert body["display_phone_number"] == "+34 600 000 000"
39
+ assert sub.called
40
+ # Bearer token used on the Graph calls
41
+ assert sub.calls[0].request.headers["Authorization"] == "Bearer tok-123"
42
+
43
+ # Saved on the tenant (phone id visible, token encrypted not echoed)
44
+ t = [x for x in (await client.get("/admin/tenants", headers=AUTH)).json()
45
+ if x["slug"] == "cliente1"][0]
46
+ assert t["whatsapp_phone_id"] == "PHONE1"
47
+ assert t["has_whatsapp_token"] is True
48
+
49
+
50
+ @respx.mock
51
+ async def test_connect_surfaces_graph_error(app_client):
52
+ _app, client = app_client
53
+ await _tenant(client, "cliente2")
54
+ respx.post(f"{GRAPH}/WABA2/subscribed_apps").mock(
55
+ return_value=httpx.Response(401, json={"error": {"message": "bad token"}})
56
+ )
57
+
58
+ r = await client.post(
59
+ "/admin/tenants/cliente2/whatsapp/connect", headers=AUTH,
60
+ json={"waba_id": "WABA2", "phone_number_id": "P", "token": "expired"},
61
+ )
62
+ assert r.status_code == 502
63
+ assert "subscribe_failed" in r.json()["detail"]
64
+
65
+ # nothing saved on failure
66
+ t = [x for x in (await client.get("/admin/tenants", headers=AUTH)).json()
67
+ if x["slug"] == "cliente2"][0]
68
+ assert t["whatsapp_phone_id"] == ""
69
+
70
+
71
+ async def test_connect_requires_some_token(app_client):
72
+ _app, client = app_client
73
+ await _tenant(client, "cliente3")
74
+ r = await client.post(
75
+ "/admin/tenants/cliente3/whatsapp/connect", headers=AUTH,
76
+ json={"waba_id": "W", "phone_number_id": "P"},
77
+ )
78
+ assert r.status_code == 422
79
+
80
+
81
+ async def test_connect_requires_admin(app_client):
82
+ _app, client = app_client
83
+ r = await client.post(
84
+ "/admin/tenants/x/whatsapp/connect",
85
+ json={"waba_id": "W", "phone_number_id": "P"},
86
+ )
87
+ assert r.status_code == 401