187 lines
8.0 KiB
Python
187 lines
8.0 KiB
Python
"""
|
|||
|
|
Copyright 2024-2026 ChatterMate
|
||
|
|
|
||
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
|
you may not use this file except in compliance with the License.
|
||
|
|
You may obtain a copy of the License at
|
||
|
|
|
||
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
|
||
|
|
Unless required by applicable law or agreed to in writing, software
|
||
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
|
See the License for the specific language governing permissions and
|
||
|
|
limitations under the License.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import hmac
|
||
|
|
from typing import Callable, Optional
|
||
|
|
from uuid import UUID
|
||
|
|
|
||
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||
|
|
from sqlalchemy.orm import Session
|
||
|
|
|
||
|
|
from app.api.webhooks.common import is_duplicate_message
|
||
|
|
from app.channels import get_adapter
|
||
|
|
from app.channels.meta_base import signed_by_any, verify_meta_signature, verify_challenge
|
||
|
|
from app.services.meta_own_app import is_own_app, own_app_secret, same_app_accounts
|
||
|
|
from app.core.logger import get_logger
|
||
|
|
from app.database import get_db
|
||
|
|
from app.models.channels import ChannelAccount, ChannelType
|
||
|
|
from app.repositories.channels import ChannelAccountRepository
|
||
|
|
from app.services.channel_chat import process_channel_message
|
||
|
|
from app.services.channel_status import process_status_event
|
||
|
|
|
||
|
|
router = APIRouter()
|
||
|
|
logger = get_logger(__name__)
|
||
|
|
|
||
|
|
# One Meta app subscribes WhatsApp + Messenger + Instagram to this single
|
||
|
|
# callback; the payload's top-level `object` says which product it is.
|
||
|
|
_OBJECT_TO_CHANNEL = {
|
||
|
|
"whatsapp_business_account": ChannelType.WHATSAPP.value,
|
||
|
|
"page": ChannelType.MESSENGER.value,
|
||
|
|
"instagram": ChannelType.INSTAGRAM.value,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("")
|
||
|
|
async def meta_webhook_verify(
|
||
|
|
hub_mode: str = Query(None, alias="hub.mode"),
|
||
|
|
hub_verify_token: str = Query(None, alias="hub.verify_token"),
|
||
|
|
hub_challenge: str = Query(None, alias="hub.challenge"),
|
||
|
|
):
|
||
|
|
"""Webhook verification handshake (App Dashboard → Webhooks → Verify)."""
|
||
|
|
challenge = verify_challenge(hub_mode, hub_verify_token, hub_challenge)
|
||
|
|
if challenge is None:
|
||
|
|
raise HTTPException(status_code=403, detail="Verification failed")
|
||
|
|
# Meta expects the raw challenge echoed as text/plain
|
||
|
|
return Response(content=challenge, media_type="text/plain")
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("")
|
||
|
|
async def meta_webhook(
|
||
|
|
request: Request,
|
||
|
|
background_tasks: BackgroundTasks,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
):
|
||
|
|
"""Receive a Meta webhook for any of WhatsApp / Messenger / Instagram.
|
||
|
|
|
||
|
|
Verifies the app-level signature, routes on the `object` field, resolves
|
||
|
|
the connected account per message, acks immediately, and processes in the
|
||
|
|
background.
|
||
|
|
"""
|
||
|
|
raw_body = await request.body()
|
||
|
|
if not verify_meta_signature(raw_body, request.headers.get("x-hub-signature-256", "")):
|
||
|
|
# Logged so a rejected delivery is distinguishable from one that never
|
||
|
|
# arrived; the body is untrusted and unlogged.
|
||
|
|
logger.warning(f"Meta webhook rejected: bad signature ({len(raw_body)} bytes)")
|
||
|
|
raise HTTPException(status_code=403, detail="Invalid signature")
|
||
|
|
|
||
|
|
payload = await request.json()
|
||
|
|
channel_type = _OBJECT_TO_CHANNEL.get(payload.get("object"))
|
||
|
|
if channel_type is None:
|
||
|
|
# Unknown product subscription — ack so Meta doesn't retry forever
|
||
|
|
return {"status": "ignored"}
|
||
|
|
|
||
|
|
account_repo = ChannelAccountRepository(db)
|
||
|
|
_queue_events(background_tasks, channel_type, payload,
|
||
|
|
lambda external_id: account_repo.get_by_external_id(channel_type, external_id))
|
||
|
|
return {"status": "ok"}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get("/{account_id}")
|
||
|
|
async def own_app_webhook_verify(
|
||
|
|
account_id: UUID,
|
||
|
|
hub_mode: str = Query(None, alias="hub.mode"),
|
||
|
|
hub_verify_token: str = Query(None, alias="hub.verify_token"),
|
||
|
|
hub_challenge: str = Query(None, alias="hub.challenge"),
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
):
|
||
|
|
"""Verification handshake for an account connected from the customer's
|
||
|
|
own Meta app: its verify token is the account's webhook secret."""
|
||
|
|
account = _own_app_account(db, account_id)
|
||
|
|
if (account is None or hub_mode != "subscribe" or not hub_verify_token
|
||
|
|
or not hmac.compare_digest(hub_verify_token.encode(), account.webhook_secret.encode())):
|
||
|
|
raise HTTPException(status_code=403, detail="Verification failed")
|
||
|
|
return Response(content=hub_challenge or "", media_type="text/plain")
|
||
|
|
|
||
|
|
|
||
|
|
@router.post("/{account_id}")
|
||
|
|
async def own_app_webhook(
|
||
|
|
account_id: UUID,
|
||
|
|
request: Request,
|
||
|
|
background_tasks: BackgroundTasks,
|
||
|
|
db: Session = Depends(get_db),
|
||
|
|
):
|
||
|
|
"""A webhook for an account connected from the customer's own Meta app.
|
||
|
|
|
||
|
|
Checked with that app's secret only, and only taken for this
|
||
|
|
organization's accounts from the same app: the secret is one the customer
|
||
|
|
chose, so it must never vouch for anyone else's.
|
||
|
|
"""
|
||
|
|
raw_body = await request.body()
|
||
|
|
account = _own_app_account(db, account_id)
|
||
|
|
secret = _readable_secret(account)
|
||
|
|
if secret is None:
|
||
|
|
# Disconnected, never ours, or its secret can't be read: ack, so Meta
|
||
|
|
# doesn't disable the customer's webhook over retries — nothing is acted on
|
||
|
|
return {"status": "ignored"}
|
||
|
|
if not signed_by_any(raw_body, request.headers.get("x-hub-signature-256", ""), (secret,)):
|
||
|
|
logger.warning(f"Meta webhook for account {account_id} rejected: bad signature ({len(raw_body)} bytes)")
|
||
|
|
raise HTTPException(status_code=403, detail="Invalid signature")
|
||
|
|
|
||
|
|
payload = await request.json()
|
||
|
|
if _OBJECT_TO_CHANNEL.get(payload.get("object")) != account.channel_type:
|
||
|
|
return {"status": "ignored"}
|
||
|
|
_queue_events(background_tasks, account.channel_type, payload, same_app_accounts(db, account))
|
||
|
|
return {"status": "ok"}
|
||
|
|
|
||
|
|
|
||
|
|
def _own_app_account(db: Session, account_id: UUID) -> Optional[ChannelAccount]:
|
||
|
|
account = ChannelAccountRepository(db).get_by_id(account_id)
|
||
|
|
return account if account is not None and account.is_active and is_own_app(account) else None
|
||
|
|
|
||
|
|
|
||
|
|
def _readable_secret(account: Optional[ChannelAccount]) -> Optional[str]:
|
||
|
|
if account is None:
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
return own_app_secret(account)
|
||
|
|
except Exception:
|
||
|
|
logger.exception(f"Could not read the app secret of Meta account {account.id}")
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _queue_events(background_tasks: BackgroundTasks, channel_type: str, payload: dict,
|
||
|
|
resolve: Callable[[str], Optional[ChannelAccount]]) -> None:
|
||
|
|
"""Hand each message and receipt in a webhook to its account's processor;
|
||
|
|
`resolve` names the account an external id belongs to, if any."""
|
||
|
|
adapter = get_adapter(channel_type)
|
||
|
|
# Meta batches many events per number; resolve each account once
|
||
|
|
accounts = {}
|
||
|
|
|
||
|
|
def account_for(external_id: str) -> Optional[ChannelAccount]:
|
||
|
|
if external_id not in accounts:
|
||
|
|
accounts[external_id] = resolve(external_id)
|
||
|
|
return accounts[external_id]
|
||
|
|
|
||
|
|
for inbound in adapter.parse_inbound(payload):
|
||
|
|
account = account_for(inbound.external_account_id)
|
||
|
|
if account is None or not account.is_active:
|
||
|
|
logger.info(f"No active {channel_type} account for {inbound.external_account_id}")
|
||
|
|
continue
|
||
|
|
if is_duplicate_message(channel_type,
|
||
|
|
f"{account.id}:{inbound.external_message_id}"):
|
||
|
|
logger.info(f"Skipping duplicate {channel_type} message {inbound.external_message_id}")
|
||
|
|
continue
|
||
|
|
background_tasks.add_task(process_channel_message, account.id, inbound)
|
||
|
|
|
||
|
|
for event in adapter.parse_status_events(payload):
|
||
|
|
account = account_for(event.external_account_id)
|
||
|
|
if account is None or not account.is_active:
|
||
|
|
continue
|
||
|
|
# Meta redelivers receipts; one per message and state is enough.
|
||
|
|
if is_duplicate_message(channel_type, f"{account.id}:{event.external_message_id}:"
|
||
|
|
f"{event.status.value}"):
|
||
|
|
continue
|
||
|
|
background_tasks.add_task(process_status_event, account.id, event)
|