From a2b076e9a1663330008f891653ae0dbd93a92703 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Tue, 13 Oct 2026 02:01:13 +0530 Subject: [PATCH] token --- backend/app/main.py | 2 + backend/app/models.py | 2 + backend/app/routes_whatsapp.py | 35 +++++++- backend/app/whatsapp_onboarding.py | 81 ++++++++++++++++++- .../features/whatsapp/WhatsAppTokenPage.tsx | 8 ++ frontend/src/lib/api.ts | 2 +- 6 files changed, 122 insertions(+), 8 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 8df506d..977d57b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -73,6 +73,8 @@ async def lifespan(_: FastAPI): conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS user_id UUID")) conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS phone_number_id VARCHAR(64)")) conn.execute(text("ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS contact_name VARCHAR(200)")) + conn.execute(text("ALTER TABLE whatsapp_meta_connections ADD COLUMN IF NOT EXISTS meta_app_id VARCHAR(64)")) + conn.execute(text("ALTER TABLE whatsapp_meta_connections ADD COLUMN IF NOT EXISTS meta_app_secret TEXT")) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_user_id ON whatsapp_messages (user_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS ix_whatsapp_messages_phone_number_id ON whatsapp_messages (phone_number_id)")) yield diff --git a/backend/app/models.py b/backend/app/models.py index da58a9b..3c19cbe 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -554,6 +554,8 @@ class WhatsAppMetaConnection(Base): business_name: Mapped[str | None] = mapped_column(String(200), nullable=True) granted_scopes: Mapped[str | None] = mapped_column(String(500), nullable=True) access_token: Mapped[str | None] = mapped_column(Text, nullable=True) + meta_app_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + meta_app_secret: Mapped[str | None] = mapped_column(Text, nullable=True) last_error: Mapped[str | None] = mapped_column(String(500), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( diff --git a/backend/app/routes_whatsapp.py b/backend/app/routes_whatsapp.py index 181935b..1e5f1e9 100644 --- a/backend/app/routes_whatsapp.py +++ b/backend/app/routes_whatsapp.py @@ -9,7 +9,13 @@ from sqlalchemy.orm import Session from .auth import current_user from .config import settings from .database import get_db -from .models import UserAccount, WhatsAppBusinessAccount, WhatsAppPhoneAsset, WhatsAppWebhookEvent +from .models import ( + UserAccount, + WhatsAppBusinessAccount, + WhatsAppMetaConnection, + WhatsAppPhoneAsset, + WhatsAppWebhookEvent, +) from .whatsapp_graph_client import WhatsAppGraphClient, WhatsAppGraphError from .whatsapp_config import resolved_whatsapp_app_secret from .whatsapp_onboarding import ( @@ -40,6 +46,25 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/whatsapp", tags=["whatsapp"]) +def _webhook_secrets(db: Session) -> list[str]: + """Platform secret for the admin app, plus each customer's own Meta app secret.""" + found: list[str] = [] + primary = resolved_whatsapp_app_secret(db).strip() + if primary: + found.append(primary) + extras = db.scalars( + select(WhatsAppMetaConnection.meta_app_secret).where( + WhatsAppMetaConnection.meta_app_secret.is_not(None), + WhatsAppMetaConnection.status != "disconnected", + ) + ).all() + for item in extras: + value = (item or "").strip() + if value and value not in found: + found.append(value) + return found + + def _serialize_event(event: WhatsAppWebhookEvent) -> dict: return { "id": str(event.id), @@ -84,10 +109,10 @@ async def receive_webhook( ) -> dict[str, str]: """Receive inbound WhatsApp Business Platform webhook payloads.""" raw_body = await request.body() - app_secret = resolved_whatsapp_app_secret(db) + secrets = _webhook_secrets(db) - if app_secret: - if not verify_meta_signature(app_secret, raw_body, x_hub_signature_256): + if secrets: + if not any(verify_meta_signature(secret, raw_body, x_hub_signature_256) for secret in secrets): logger.warning("WhatsApp webhook signature validation failed") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid signature") elif settings.environment == "production": @@ -179,6 +204,7 @@ class ManualTokenRequest(BaseModel): access_token: str = Field(min_length=1, max_length=4096) waba_id: str = Field(min_length=1, max_length=64) phone_number_id: str | None = Field(default=None, max_length=64) + app_secret: str | None = Field(default=None, max_length=256) class SignupCompleteRequest(BaseModel): @@ -227,6 +253,7 @@ async def whatsapp_connect_token( payload.access_token, payload.waba_id, payload.phone_number_id, + payload.app_secret, ) diff --git a/backend/app/whatsapp_onboarding.py b/backend/app/whatsapp_onboarding.py index ec756a3..80e05c7 100644 --- a/backend/app/whatsapp_onboarding.py +++ b/backend/app/whatsapp_onboarding.py @@ -283,6 +283,47 @@ async def _subscribe(token: str, waba_id: str) -> bool: return False +def _customer_callback() -> dict[str, str] | None: + """Public address where a customer's own Meta app should deliver messages.""" + url = settings.whatsapp_webhook_url + verify = settings.whatsapp_verify_token.strip() + if not url.startswith("https://") or not verify or len(url) > 200: + return None + return {"override_callback_uri": url, "verify_token": verify} + + +async def _token_app_id(token: str) -> str | None: + try: + data = await _graph("GET", "app", token=token, params={"fields": "id"}) + except HTTPException: + return None + app_id = str(data.get("id") or "").strip() + return app_id or None + + +async def _attach_customer_webhook(token: str, waba_id: str, phone_ids: list[str]) -> bool: + """Send this account's messages to the workspace, using the customer's token.""" + callback = _customer_callback() + if callback is None: + return False + try: + await _graph("POST", f"{waba_id}/subscribed_apps", token=token, json_body=callback) + except HTTPException: + logger.warning("WhatsApp webhook override failed for waba %s", waba_id[-4:]) + return False + for phone_id in phone_ids: + try: + await _graph( + "POST", + phone_id, + token=token, + json_body={"webhook_configuration": callback}, + ) + except HTTPException: + logger.info("Phone webhook override failed for %s", phone_id[-4:]) + return True + + async def _template_count(token: str, waba_id: str) -> tuple[int, bool]: try: data = await _graph( @@ -732,7 +773,12 @@ async def _save_token_account( if previous_connection_id and previous_connection_id != connection.id: previous = db.get(WhatsAppMetaConnection, previous_connection_id) if previous is not None and previous.user_id == user.id: + if not connection.meta_app_secret and previous.meta_app_secret: + connection.meta_app_secret = previous.meta_app_secret + if not connection.meta_app_id and previous.meta_app_id: + connection.meta_app_id = previous.meta_app_id previous.access_token = None + previous.meta_app_secret = None account.name = name account.last_error = None account.management_status = "active" @@ -740,14 +786,35 @@ async def _save_token_account( phones = await _phones_for_token(token, waba_id, phone_id) if phones: _apply_phones(db, user, account, phones, phone_id) - subscribed = await _subscribe(token, waba_id) + app_id = await _token_app_id(token) + if app_id: + connection.meta_app_id = app_id + platform_app = settings.meta_app_id.strip() + same_app = bool(app_id and platform_app and app_id == platform_app) + phone_ids = [phone.phone_number_id for phone in account.phones] + if same_app or connection.meta_app_secret: + receiving = await _attach_customer_webhook(token, waba_id, phone_ids) + if not receiving and same_app: + receiving = await _subscribe(token, waba_id) + else: + await _subscribe(token, waba_id) + receiving = False template_count, templates_ok = await _template_count(token, waba_id) account.template_count = template_count account.templates_status = "active" if templates_ok else "unavailable" - account.webhooks_status = "active" if subscribed else "unavailable" + account.webhooks_status = "active" if receiving else "unavailable" if account.phones: account.status = "connected" account.messaging_status = "active" + if receiving: + account.last_error = None + elif same_app: + account.last_error = "Sending is on. Meta did not accept the delivery address for replies." + else: + account.last_error = ( + "Sending uses this token. Save it again with the App Secret from the Meta app that created the token " + "so replies arrive in this workspace." + ) _audit(db, user, "token_connected", "WhatsApp token saved for this account", connection_id=connection.id, waba_id=waba_id) else: account.status = "incomplete" @@ -763,6 +830,7 @@ async def connect_with_token( access_token: str, waba_id: str, phone_number_id: str | None = None, + app_secret: str | None = None, ) -> dict[str, Any]: """Store a customer-supplied WhatsApp token. Does not use Embedded Signup.""" token = _clean_token(access_token) @@ -780,12 +848,19 @@ async def connect_with_token( status_code=status.HTTP_400_BAD_REQUEST, detail="Phone number ID should be the number Meta shows for that phone.", ) + secret = "".join(str(app_secret or "").split()) + if secret and len(secret) < 8: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="App secret should be the value from the Meta app that created this token.", + ) connection = WhatsAppMetaConnection( user_id=user.id, status="pending", granted_scopes="manual", access_token=token, + meta_app_secret=secret or None, ) db.add(connection) db.flush() @@ -802,7 +877,7 @@ async def connect_with_token( db.refresh(account) if account.status == "connected": overall = "connected" - message = "This WhatsApp account is saved for the workspace." + message = account.last_error or "This WhatsApp account is saved. Messages send and arrive in this workspace." elif account.status == "incomplete": overall = "incomplete" message = account.last_error or "The token is saved. Add the phone number ID to finish." diff --git a/frontend/src/features/whatsapp/WhatsAppTokenPage.tsx b/frontend/src/features/whatsapp/WhatsAppTokenPage.tsx index c46dff7..3b9b0cc 100644 --- a/frontend/src/features/whatsapp/WhatsAppTokenPage.tsx +++ b/frontend/src/features/whatsapp/WhatsAppTokenPage.tsx @@ -7,6 +7,7 @@ export function WhatsAppTokenPage() { const [token, setToken] = useState('') const [wabaId, setWabaId] = useState('') const [phoneNumberId, setPhoneNumberId] = useState('') + const [appSecret, setAppSecret] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState('') const [result, setResult] = useState(null) @@ -21,9 +22,11 @@ export function WhatsAppTokenPage() { access_token: token.trim(), waba_id: wabaId.trim(), phone_number_id: phoneNumberId.trim() || null, + app_secret: appSecret.trim() || null, }) setResult(payload) setToken('') + setAppSecret('') if (payload.status === 'failed' || payload.status === 'expired') setError(payload.message) } catch (reason) { setError(reason instanceof Error ? reason.message : 'This token could not be saved') @@ -61,6 +64,11 @@ export function WhatsAppTokenPage() { Optional. Use it when the account has more than one number. setPhoneNumberId(event.target.value)} inputMode="numeric" /> +