Spaces:
Sleeping
Sleeping
| """ | |
| Twilio Campaign Outreach — FastAPI Application (HF Spaces Deployment) | |
| Exposes endpoints to initiate calls, serve TwiML, capture DTMF responses, and poll status. | |
| """ | |
| from fastapi import FastAPI, HTTPException, Form, Query, Response | |
| from pydantic import BaseModel | |
| from typing import Optional | |
| from campaign_service import ( | |
| generate_call_id, | |
| store_campaign, | |
| initiate_call, | |
| update_response, | |
| get_campaign, | |
| # SMS functions | |
| generate_sms_id, | |
| store_sms, | |
| send_sms, | |
| get_sms, | |
| ) | |
| app = FastAPI(title="Campaign Outreach Service", version="1.0.0") | |
| # --- Pydantic Schemas --- | |
| class CampaignInitiateRequest(BaseModel): | |
| message: str | |
| class CampaignInitiateResponse(BaseModel): | |
| call_id: str | |
| status: str | |
| phone: str | |
| message_preview: str | |
| class SMSRequest(BaseModel): | |
| message: str | |
| class SMSResponse(BaseModel): | |
| sms_id: str | |
| status: str | |
| phone: str | |
| message_preview: str | |
| # --- Endpoints --- | |
| def health(): | |
| return {"status": "ok", "service": "Campaign Outreach"} | |
| def initiate_phonecall(number: str, body: CampaignInitiateRequest): | |
| """ | |
| Initiate an outbound voice call to the given phone number. | |
| The personalized message in the request body will be read aloud to the lead. | |
| The lead responds with DTMF: 0 = Accept meeting, 1 = Reject meeting. | |
| """ | |
| if not body.message or not body.message.strip(): | |
| raise HTTPException(status_code=400, detail="Message cannot be empty.") | |
| call_id = generate_call_id() | |
| # Store the campaign record | |
| store_campaign(call_id=call_id, phone=number, message=body.message.strip()) | |
| # Trigger the Twilio outbound call | |
| try: | |
| initiate_call(phone_number=number, call_id=call_id) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=503, detail=str(e)) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Twilio call failed: {e}") | |
| return CampaignInitiateResponse( | |
| call_id=call_id, | |
| status="initiated", | |
| phone=number, | |
| message_preview=body.message.strip()[:120] + ("..." if len(body.message.strip()) > 120 else ""), | |
| ) | |
| def twiml_webhook(call_id: str): | |
| """ | |
| Twilio webhook: called when the lead picks up the phone. | |
| Returns TwiML that reads the personalized message and gathers a DTMF keypress. | |
| """ | |
| record = get_campaign(call_id) | |
| if record is None: | |
| raise HTTPException(status_code=404, detail="Campaign not found.") | |
| message_text = record["message"] | |
| # Build TwiML XML response | |
| twiml = f"""<?xml version="1.0" encoding="UTF-8"?> | |
| <Response> | |
| <Say voice="Polly.Joanna">{message_text}</Say> | |
| <Gather numDigits="1" action="/campaign/response?call_id={call_id}" method="POST" timeout="10"> | |
| <Say voice="Polly.Joanna">Press 0 to accept a meeting. Press 1 to decline.</Say> | |
| </Gather> | |
| <Say voice="Polly.Joanna">We didn't receive your input. Goodbye.</Say> | |
| </Response>""" | |
| return Response(content=twiml, media_type="application/xml") | |
| def response_webhook(call_id: str = Query(...), Digits: str = Form(default="")): | |
| """ | |
| Twilio webhook: called when the lead presses a digit. | |
| Records the lead's response (0 = Accept, 1 = Reject). | |
| """ | |
| record = get_campaign(call_id) | |
| if record is None: | |
| raise HTTPException(status_code=404, detail="Campaign not found.") | |
| # Update the store | |
| update_response(call_id=call_id, digits=Digits) | |
| # Generate thank-you TwiML | |
| if Digits == "0": | |
| reply = "Thank you! Our team will reach out shortly to confirm your meeting. Goodbye!" | |
| elif Digits == "1": | |
| reply = "Understood. Thank you for your time. Have a great day. Goodbye!" | |
| else: | |
| reply = "Invalid input received. Goodbye." | |
| twiml = f"""<?xml version="1.0" encoding="UTF-8"?> | |
| <Response> | |
| <Say voice="Polly.Joanna">{reply}</Say> | |
| <Hangup/> | |
| </Response>""" | |
| return Response(content=twiml, media_type="application/xml") | |
| def call_status_webhook(call_id: str): | |
| """ | |
| Twilio status callback: receives call lifecycle events (ringing, in-progress, completed, etc). | |
| Updates the campaign record if the call failed or was not answered. | |
| """ | |
| record = get_campaign(call_id) | |
| if record and record["status"] == "initiated": | |
| # If we get a status callback but no DTMF was captured, mark as no-answer | |
| pass # Twilio sends multiple status updates; we only care about final state | |
| return {"status": "received"} | |
| def campaign_status(call_id: str): | |
| """ | |
| Agent-facing endpoint: poll the outcome of an initiated campaign call. | |
| """ | |
| record = get_campaign(call_id) | |
| if record is None: | |
| raise HTTPException(status_code=404, detail=f"Campaign with call_id '{call_id}' not found.") | |
| return { | |
| "call_id": record["call_id"], | |
| "phone": record["phone"], | |
| "status": record["status"], | |
| "lead_response": record["lead_response"], | |
| "message_used": record["message"], | |
| "initiated_at": record["initiated_at"], | |
| "responded_at": record["responded_at"], | |
| } | |
| # ================================================================ | |
| # SMS ENDPOINTS | |
| # ================================================================ | |
| def initiate_sms(number: str, body: SMSRequest): | |
| """ | |
| Send a personalized SMS to the given phone number. | |
| The appointment confirmation footer is automatically appended. | |
| """ | |
| if not body.message or not body.message.strip(): | |
| raise HTTPException(status_code=400, detail="Message cannot be empty.") | |
| sms_id = generate_sms_id() | |
| # Store the SMS campaign record | |
| store_sms(sms_id=sms_id, phone=number, message=body.message.strip()) | |
| # Send via Twilio | |
| try: | |
| send_sms(phone_number=number, message=body.message.strip(), sms_id=sms_id) | |
| except RuntimeError as e: | |
| raise HTTPException(status_code=503, detail=str(e)) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"SMS delivery failed: {e}") | |
| return SMSResponse( | |
| sms_id=sms_id, | |
| status="sent", | |
| phone=number, | |
| message_preview=body.message.strip()[:120] + ("..." if len(body.message.strip()) > 120 else ""), | |
| ) | |
| def sms_status(sms_id: str): | |
| """ | |
| Agent-facing endpoint: poll the outcome of an initiated SMS campaign. | |
| """ | |
| record = get_sms(sms_id) | |
| if record is None: | |
| raise HTTPException(status_code=404, detail=f"SMS campaign '{sms_id}' not found.") | |
| return { | |
| "sms_id": record["sms_id"], | |
| "phone": record["phone"], | |
| "status": record["status"], | |
| "message_sent": record["message"], | |
| "sent_at": record["sent_at"], | |
| } | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run("main:app", host="0.0.0.0", port=7860) | |