""" Copyright 2024-2026 ReplyPier 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 json as jsonlib 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.email import verify_webhook_token from app.channels.sms import get_provider, SmsWebhookRequest from app.channels.sms.adapter import account_provider_name from app.core.config import settings from app.core.logger import get_logger from app.database import get_db from app.models.channels import ChannelType from app.repositories.channels import ChannelAccountRepository from app.services.channel_chat import process_channel_message router = APIRouter() logger = get_logger(__name__) # Twilio expects TwiML back; empty means "no auto-reply" EMPTY_TWIML = '' def _public_url(provider: str, account_id: UUID) -> str: return f"{settings.BACKEND_URL.rstrip('/')}{settings.API_V1_STR}/webhooks/sms/{provider}/{account_id}" async def _build_request(request: Request, provider: str, account_id: UUID) -> SmsWebhookRequest: raw_body = await request.body() content_type = request.headers.get("content-type", "") json_body = None param_pairs = [] params = {} if "application/json" in content_type or (raw_body[:1] in (b"{", b"[")): try: json_body = jsonlib.loads(raw_body or b"{}") except ValueError: json_body = None if json_body is None: form = await request.form() param_pairs = [(k, str(v)) for k, v in form.multi_items()] params = dict(param_pairs) return SmsWebhookRequest( headers={k.lower(): v for k, v in request.headers.items()}, params=params, param_pairs=param_pairs, json_body=json_body, raw_body=raw_body, url=_public_url(provider, account_id), ) @router.post("/{provider}/{account_id}") async def sms_webhook( provider: str, account_id: UUID, request: Request, background_tasks: BackgroundTasks, token: str = Query(""), db: Session = Depends(get_db), ): """Inbound SMS for any provider. The provider in the path selects the parser/verifier; it must match the account's configured provider.""" account = ChannelAccountRepository(db).get_by_id(account_id) if account is None or account.channel_type != ChannelType.SMS.value: raise HTTPException(status_code=404, detail="Unknown account") if account_provider_name(account) != provider: raise HTTPException(status_code=404, detail="Provider mismatch") sms_provider = get_provider(provider) if sms_provider is None: raise HTTPException(status_code=404, detail="Unknown SMS provider") # Providers that do not sign their webhooks (Brevo, or Vonage with no signature # secret) carry no proof of origin, so the route enforces the per-account URL # token - the same protection the email webhook uses. The account id in the path # is not a secret: it is part of the URL handed to the provider. if not sms_provider.signs_webhook and not verify_webhook_token(account, token): raise HTTPException(status_code=403, detail="Invalid token") sms_req = await _build_request(request, provider, account_id) if not await sms_provider.verify_webhook(sms_req, account): raise HTTPException(status_code=403, detail="Invalid signature") # Control callbacks (e.g. SNS subscription confirmation) are handled here if await sms_provider.handle_control(sms_req, account): return {"status": "ok"} for inbound in sms_provider.parse_inbound(sms_req, account): # Only dedupe when the provider supplied a real message id — an empty # id means "unknown", and deduping on text would drop repeat replies. if inbound.external_message_id and is_duplicate_message( ChannelType.SMS.value, f"{account.id}:{inbound.external_message_id}"): continue background_tasks.add_task(process_channel_message, account.id, inbound) # Twilio wants TwiML; others accept any 200 if provider == "twilio": return Response(content=EMPTY_TWIML, media_type="text/xml") return {"status": "ok"}