Spaces:
Sleeping
Sleeping
Upload 4 files
Browse files- integrations/__init__.py +14 -0
- integrations/crm.py +159 -0
- integrations/sip.py +186 -0
- integrations/whatsapp.py +145 -0
integrations/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Enterprise integrations for the Hausa Voice AI Agent.
|
| 3 |
+
|
| 4 |
+
This file is what makes `integrations` an explicit package. Without it,
|
| 5 |
+
imports work locally (Python 3.3+ namespace packages) but break in containers
|
| 6 |
+
where the working directory is not what you expect β which is exactly the
|
| 7 |
+
HuggingFace Spaces failure mode.
|
| 8 |
+
|
| 9 |
+
Submodules are NOT imported here on purpose: `sip` imports twilio and `crm`
|
| 10 |
+
reads environment variables, so eagerly importing them would make a missing
|
| 11 |
+
optional dependency crash the whole app at startup.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
__all__ = ["crm", "sip", "whatsapp"]
|
integrations/crm.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CRM / Ticketing Integration
|
| 3 |
+
============================
|
| 4 |
+
Supports Zendesk out of the box; stub adapters for Freshdesk, HubSpot,
|
| 5 |
+
Salesforce, and a generic REST endpoint.
|
| 6 |
+
|
| 7 |
+
Environment variables (set in HF Space secrets or .env):
|
| 8 |
+
CRM_PROVIDER = zendesk | freshdesk | hubspot | generic
|
| 9 |
+
ZENDESK_SUBDOMAIN = yourcompany
|
| 10 |
+
ZENDESK_EMAIL = agent@yourcompany.com
|
| 11 |
+
ZENDESK_API_TOKEN = xxxxx
|
| 12 |
+
CRM_GENERIC_URL = https://your-crm.example.com/api/tickets
|
| 13 |
+
CRM_GENERIC_TOKEN = bearer_token
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
import uuid
|
| 18 |
+
import logging
|
| 19 |
+
from datetime import datetime
|
| 20 |
+
from typing import Optional
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class CRMClient:
|
| 26 |
+
|
| 27 |
+
def __init__(self):
|
| 28 |
+
self.provider = os.getenv("CRM_PROVIDER", "demo")
|
| 29 |
+
self._demo = self.provider == "demo"
|
| 30 |
+
|
| 31 |
+
# ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 32 |
+
|
| 33 |
+
def create_ticket(self, subject: str, description: str,
|
| 34 |
+
requester_name: str = "Voice User",
|
| 35 |
+
requester_phone: str = "",
|
| 36 |
+
priority: str = "normal",
|
| 37 |
+
tags: list[str] = None) -> dict:
|
| 38 |
+
"""Create a support ticket and return {ticket_id, url, status}."""
|
| 39 |
+
tags = tags or ["hausa-voice-agent", "auto-created"]
|
| 40 |
+
payload = {
|
| 41 |
+
"subject": subject,
|
| 42 |
+
"description": description,
|
| 43 |
+
"requester_name": requester_name,
|
| 44 |
+
"requester_phone": requester_phone,
|
| 45 |
+
"priority": priority,
|
| 46 |
+
"tags": tags,
|
| 47 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 48 |
+
"channel": "voice",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
if self._demo:
|
| 52 |
+
return self._demo_ticket(payload)
|
| 53 |
+
if self.provider == "zendesk":
|
| 54 |
+
return self._zendesk_create(payload)
|
| 55 |
+
return self._generic_create(payload)
|
| 56 |
+
|
| 57 |
+
def get_ticket(self, ticket_id: str) -> Optional[dict]:
|
| 58 |
+
if self._demo:
|
| 59 |
+
return {"ticket_id": ticket_id, "status": "open",
|
| 60 |
+
"created_at": datetime.utcnow().isoformat()}
|
| 61 |
+
if self.provider == "zendesk":
|
| 62 |
+
return self._zendesk_get(ticket_id)
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
def update_ticket(self, ticket_id: str, comment: str,
|
| 66 |
+
status: str = "pending") -> dict:
|
| 67 |
+
if self._demo:
|
| 68 |
+
logger.info(f"[DEMO] Update ticket {ticket_id}: {comment}")
|
| 69 |
+
return {"ticket_id": ticket_id, "status": status}
|
| 70 |
+
if self.provider == "zendesk":
|
| 71 |
+
return self._zendesk_update(ticket_id, comment, status)
|
| 72 |
+
return {}
|
| 73 |
+
|
| 74 |
+
def assign_to_human(self, ticket_id: str,
|
| 75 |
+
agent_group: str = "frontline") -> dict:
|
| 76 |
+
"""Transfer ticket to human agent queue."""
|
| 77 |
+
comment = f"[Voice AI] Escalated to human agent. Group: {agent_group}"
|
| 78 |
+
return self.update_ticket(ticket_id, comment, status="open")
|
| 79 |
+
|
| 80 |
+
# ββ Demo stub βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
+
|
| 82 |
+
@staticmethod
|
| 83 |
+
def _demo_ticket(payload: dict) -> dict:
|
| 84 |
+
ticket_id = "TKT-" + str(uuid.uuid4())[:8].upper()
|
| 85 |
+
logger.info(f"[DEMO] CRM ticket created: {ticket_id} | {payload['subject']}")
|
| 86 |
+
return {
|
| 87 |
+
"ticket_id": ticket_id,
|
| 88 |
+
"url": f"https://demo.zendesk.com/tickets/{ticket_id}",
|
| 89 |
+
"status": "created",
|
| 90 |
+
"provider": "demo",
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
# ββ Zendesk βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 94 |
+
|
| 95 |
+
def _zendesk_create(self, payload: dict) -> dict:
|
| 96 |
+
import requests
|
| 97 |
+
subdomain = os.environ["ZENDESK_SUBDOMAIN"]
|
| 98 |
+
email = os.environ["ZENDESK_EMAIL"]
|
| 99 |
+
token = os.environ["ZENDESK_API_TOKEN"]
|
| 100 |
+
|
| 101 |
+
url = f"https://{subdomain}.zendesk.com/api/v2/tickets.json"
|
| 102 |
+
body = {
|
| 103 |
+
"ticket": {
|
| 104 |
+
"subject": payload["subject"],
|
| 105 |
+
"comment": {"body": payload["description"]},
|
| 106 |
+
"requester": {"name": payload["requester_name"]},
|
| 107 |
+
"priority": payload["priority"],
|
| 108 |
+
"tags": payload["tags"],
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
r = requests.post(url, json=body,
|
| 112 |
+
auth=(f"{email}/token", token), timeout=10)
|
| 113 |
+
r.raise_for_status()
|
| 114 |
+
data = r.json()["ticket"]
|
| 115 |
+
return {
|
| 116 |
+
"ticket_id": str(data["id"]),
|
| 117 |
+
"url": data["url"],
|
| 118 |
+
"status": "created",
|
| 119 |
+
"provider": "zendesk",
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
def _zendesk_get(self, ticket_id: str) -> dict:
|
| 123 |
+
import requests
|
| 124 |
+
subdomain = os.environ["ZENDESK_SUBDOMAIN"]
|
| 125 |
+
email = os.environ["ZENDESK_EMAIL"]
|
| 126 |
+
token = os.environ["ZENDESK_API_TOKEN"]
|
| 127 |
+
url = f"https://{subdomain}.zendesk.com/api/v2/tickets/{ticket_id}.json"
|
| 128 |
+
r = requests.get(url, auth=(f"{email}/token", token), timeout=10)
|
| 129 |
+
r.raise_for_status()
|
| 130 |
+
data = r.json()["ticket"]
|
| 131 |
+
return {"ticket_id": ticket_id, "status": data["status"],
|
| 132 |
+
"subject": data["subject"]}
|
| 133 |
+
|
| 134 |
+
def _zendesk_update(self, ticket_id: str, comment: str, status: str) -> dict:
|
| 135 |
+
import requests
|
| 136 |
+
subdomain = os.environ["ZENDESK_SUBDOMAIN"]
|
| 137 |
+
email = os.environ["ZENDESK_EMAIL"]
|
| 138 |
+
token = os.environ["ZENDESK_API_TOKEN"]
|
| 139 |
+
url = f"https://{subdomain}.zendesk.com/api/v2/tickets/{ticket_id}.json"
|
| 140 |
+
body = {"ticket": {"status": status,
|
| 141 |
+
"comment": {"body": comment, "public": False}}}
|
| 142 |
+
r = requests.put(url, json=body,
|
| 143 |
+
auth=(f"{email}/token", token), timeout=10)
|
| 144 |
+
r.raise_for_status()
|
| 145 |
+
return {"ticket_id": ticket_id, "status": status}
|
| 146 |
+
|
| 147 |
+
# ββ Generic REST ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 148 |
+
|
| 149 |
+
def _generic_create(self, payload: dict) -> dict:
|
| 150 |
+
import requests
|
| 151 |
+
url = os.environ["CRM_GENERIC_URL"]
|
| 152 |
+
token = os.environ.get("CRM_GENERIC_TOKEN", "")
|
| 153 |
+
headers = {"Authorization": f"Bearer {token}",
|
| 154 |
+
"Content-Type": "application/json"}
|
| 155 |
+
r = requests.post(url, json=payload, headers=headers, timeout=10)
|
| 156 |
+
r.raise_for_status()
|
| 157 |
+
data = r.json()
|
| 158 |
+
return {"ticket_id": str(data.get("id", "?")), "status": "created",
|
| 159 |
+
"provider": "generic"}
|
integrations/sip.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Phone / SIP Integration (Twilio + Bandwidth stubs)
|
| 3 |
+
=====================================================
|
| 4 |
+
Handles inbound calls β streams audio β runs pipeline β streams TTS back.
|
| 5 |
+
|
| 6 |
+
For a real deployment, use:
|
| 7 |
+
- Twilio Media Streams (WebSocket) + <Stream> TwiML verb
|
| 8 |
+
- Bandwidth BXML + WebSocket audio streaming
|
| 9 |
+
- Vonage Voice API + WebSocket
|
| 10 |
+
|
| 11 |
+
Environment variables:
|
| 12 |
+
TWILIO_ACCOUNT_SID = ACxxxx
|
| 13 |
+
TWILIO_AUTH_TOKEN = xxxx
|
| 14 |
+
TWILIO_PHONE_NUMBER = +1234567890
|
| 15 |
+
SIP_PROVIDER = twilio | bandwidth | demo
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import logging
|
| 20 |
+
from typing import Callable, Optional
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TwilioAdapter:
|
| 26 |
+
"""
|
| 27 |
+
Twilio Media Streams WebSocket adapter.
|
| 28 |
+
|
| 29 |
+
Flow:
|
| 30 |
+
1. Inbound call β Twilio webhook β /voice endpoint
|
| 31 |
+
2. Return TwiML with <Connect><Stream> β Twilio opens WS
|
| 32 |
+
3. WebSocket handler receives mulaw 8kHz chunks
|
| 33 |
+
4. Chunks accumulated β ASR β NLU β TTS β send back over WS
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def __init__(self):
|
| 37 |
+
self.sid = os.getenv("TWILIO_ACCOUNT_SID", "DEMO")
|
| 38 |
+
self.token = os.getenv("TWILIO_AUTH_TOKEN", "DEMO")
|
| 39 |
+
self.phone = os.getenv("TWILIO_PHONE_NUMBER", "+0000000000")
|
| 40 |
+
self._demo = self.sid == "DEMO"
|
| 41 |
+
|
| 42 |
+
def incoming_call_twiml(self, websocket_url: str) -> str:
|
| 43 |
+
"""
|
| 44 |
+
Returns TwiML that Twilio will execute when a call arrives.
|
| 45 |
+
websocket_url: wss://yourserver.com/ws/audio
|
| 46 |
+
"""
|
| 47 |
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
| 48 |
+
<Response>
|
| 49 |
+
<Say language="ha-NG">Sannu, barka da zuwa PlotWeaver. Muna jiraβ¦</Say>
|
| 50 |
+
<Connect>
|
| 51 |
+
<Stream url="{websocket_url}">
|
| 52 |
+
<Parameter name="language" value="hausa"/>
|
| 53 |
+
</Stream>
|
| 54 |
+
</Connect>
|
| 55 |
+
</Response>"""
|
| 56 |
+
|
| 57 |
+
def handle_ws_message(self, message: dict,
|
| 58 |
+
on_audio_chunk: Callable[[bytes], None]) -> None:
|
| 59 |
+
"""
|
| 60 |
+
Called for each WebSocket message from Twilio.
|
| 61 |
+
Twilio sends: start, media (base64 mulaw), stop events.
|
| 62 |
+
"""
|
| 63 |
+
import base64
|
| 64 |
+
event = message.get("event")
|
| 65 |
+
if event == "media":
|
| 66 |
+
chunk = base64.b64decode(message["media"]["payload"])
|
| 67 |
+
on_audio_chunk(chunk)
|
| 68 |
+
elif event == "stop":
|
| 69 |
+
logger.info(f"Call ended: {message.get('stop', {}).get('callSid')}")
|
| 70 |
+
|
| 71 |
+
def send_audio_twiml(self, call_sid: str, audio_url: str) -> dict:
|
| 72 |
+
"""
|
| 73 |
+
Interrupt the current call and play synthesised audio.
|
| 74 |
+
Production: POST to Twilio API to update call.
|
| 75 |
+
"""
|
| 76 |
+
if self._demo:
|
| 77 |
+
logger.info(f"[DEMO] Would play {audio_url} on call {call_sid}")
|
| 78 |
+
return {"status": "demo"}
|
| 79 |
+
from twilio.rest import Client
|
| 80 |
+
client = Client(self.sid, self.token)
|
| 81 |
+
call = client.calls(call_sid).update(
|
| 82 |
+
twiml=f'<Response><Play>{audio_url}</Play></Response>'
|
| 83 |
+
)
|
| 84 |
+
return {"status": call.status}
|
| 85 |
+
|
| 86 |
+
def make_outbound_call(self, to: str, message_en: str,
|
| 87 |
+
message_ha: str = "") -> dict:
|
| 88 |
+
"""Outbound IVR call with TTS message."""
|
| 89 |
+
twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
| 90 |
+
<Response>
|
| 91 |
+
<Say language="ha-NG">{message_ha or message_en}</Say>
|
| 92 |
+
</Response>"""
|
| 93 |
+
if self._demo:
|
| 94 |
+
logger.info(f"[DEMO] Outbound to {to}: {message_en[:60]}β¦")
|
| 95 |
+
return {"status": "demo_queued", "to": to}
|
| 96 |
+
from twilio.rest import Client
|
| 97 |
+
client = Client(self.sid, self.token)
|
| 98 |
+
call = client.calls.create(
|
| 99 |
+
to=to, from_=self.phone, twiml=twiml
|
| 100 |
+
)
|
| 101 |
+
return {"sid": call.sid, "status": call.status}
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
def mulaw_to_pcm(mulaw_bytes: bytes) -> bytes:
|
| 105 |
+
"""
|
| 106 |
+
Convert 8kHz G.711 mu-law to 16-bit PCM at 16kHz for Whisper.
|
| 107 |
+
|
| 108 |
+
Implemented in numpy rather than the stdlib `audioop` module, which
|
| 109 |
+
was removed in Python 3.13. Keeping this dependency-free means the
|
| 110 |
+
telephony path works on any modern image.
|
| 111 |
+
"""
|
| 112 |
+
import numpy as np
|
| 113 |
+
|
| 114 |
+
u = np.frombuffer(mulaw_bytes, dtype=np.uint8).astype(np.int32)
|
| 115 |
+
u = ~u & 0xFF # mu-law is stored inverted
|
| 116 |
+
sign = u & 0x80
|
| 117 |
+
exponent = (u >> 4) & 0x07
|
| 118 |
+
mantissa = u & 0x0F
|
| 119 |
+
# ITU-T G.711: t = ((mantissa << 3) + BIAS) << exponent, BIAS = 0x84
|
| 120 |
+
t = ((mantissa << 3) + 0x84) << exponent
|
| 121 |
+
pcm8k = np.where(sign != 0, 0x84 - t, t - 0x84).astype(np.int16)
|
| 122 |
+
|
| 123 |
+
# 8kHz β 16kHz (linear interpolation; the band-limited content of a
|
| 124 |
+
# phone call makes a higher-order filter unnecessary here)
|
| 125 |
+
if len(pcm8k) == 0:
|
| 126 |
+
return b""
|
| 127 |
+
x = np.arange(len(pcm8k))
|
| 128 |
+
xi = np.arange(len(pcm8k) * 2) / 2.0 # exact 2x: 0, 0.5, 1, 1.5, β¦
|
| 129 |
+
pcm16k = np.interp(xi, x, pcm8k).astype(np.int16)
|
| 130 |
+
return pcm16k.tobytes()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class BandwidthAdapter:
|
| 134 |
+
"""Bandwidth BXML + WebSocket audio streaming (stub)."""
|
| 135 |
+
|
| 136 |
+
def __init__(self):
|
| 137 |
+
self.account_id = os.getenv("BANDWIDTH_ACCOUNT_ID", "DEMO")
|
| 138 |
+
self.api_token = os.getenv("BANDWIDTH_API_TOKEN", "DEMO")
|
| 139 |
+
self._demo = self.account_id == "DEMO"
|
| 140 |
+
|
| 141 |
+
def incoming_call_bxml(self, websocket_url: str) -> str:
|
| 142 |
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
| 143 |
+
<Response>
|
| 144 |
+
<SpeakSentence locale="ha-NG">Sannu da zuwa PlotWeaver.</SpeakSentence>
|
| 145 |
+
<StartStream url="{websocket_url}" streamEventUrl="{websocket_url}/events"/>
|
| 146 |
+
</Response>"""
|
| 147 |
+
|
| 148 |
+
def send_tts(self, call_id: str, text: str, locale: str = "ha-NG") -> dict:
|
| 149 |
+
if self._demo:
|
| 150 |
+
logger.info(f"[DEMO] Bandwidth TTS on call {call_id}: {text[:60]}β¦")
|
| 151 |
+
return {"status": "demo"}
|
| 152 |
+
# Production: PATCH /calls/{callId} with BXML
|
| 153 |
+
raise NotImplementedError
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
class SIPRouter:
|
| 157 |
+
"""
|
| 158 |
+
Routes a call to the correct adapter based on SIP_PROVIDER env var.
|
| 159 |
+
Also manages human-agent transfer via SIP REFER.
|
| 160 |
+
"""
|
| 161 |
+
|
| 162 |
+
PROVIDERS = {"twilio": TwilioAdapter, "bandwidth": BandwidthAdapter}
|
| 163 |
+
|
| 164 |
+
def __init__(self):
|
| 165 |
+
provider = os.getenv("SIP_PROVIDER", "demo").lower()
|
| 166 |
+
if provider in self.PROVIDERS:
|
| 167 |
+
self.adapter = self.PROVIDERS[provider]()
|
| 168 |
+
else:
|
| 169 |
+
self.adapter = TwilioAdapter() # demo mode
|
| 170 |
+
logger.info(f"SIP provider: {provider}")
|
| 171 |
+
|
| 172 |
+
def transfer_to_human(self, call_sid: str,
|
| 173 |
+
agent_extension: str = "+0000000001") -> dict:
|
| 174 |
+
"""
|
| 175 |
+
REFER / warm transfer to human agent queue.
|
| 176 |
+
In demo mode just logs.
|
| 177 |
+
"""
|
| 178 |
+
logger.info(f"[SIP] Transferring {call_sid} β agent {agent_extension}")
|
| 179 |
+
if isinstance(self.adapter, TwilioAdapter) and not self.adapter._demo:
|
| 180 |
+
from twilio.rest import Client
|
| 181 |
+
client = Client(self.adapter.sid, self.adapter.token)
|
| 182 |
+
call = client.calls(call_sid).update(
|
| 183 |
+
url=f"http://twimlets.com/forward?PhoneNumber={agent_extension}"
|
| 184 |
+
)
|
| 185 |
+
return {"status": call.status, "agent": agent_extension}
|
| 186 |
+
return {"status": "demo_transfer", "agent": agent_extension}
|
integrations/whatsapp.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WhatsApp Business API Integration
|
| 3 |
+
===================================
|
| 4 |
+
Handles inbound webhook messages and outbound replies via the
|
| 5 |
+
Meta WhatsApp Cloud API (v18+).
|
| 6 |
+
|
| 7 |
+
In the POC demo, all send/receive calls are stubbed and logged.
|
| 8 |
+
In production, set:
|
| 9 |
+
WHATSAPP_TOKEN = Bearer token from Meta Business Portal
|
| 10 |
+
WHATSAPP_PHONE_ID = Phone Number ID
|
| 11 |
+
WHATSAPP_VERIFY_TOKEN = Webhook verification token
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import json
|
| 16 |
+
import logging
|
| 17 |
+
import hashlib
|
| 18 |
+
from datetime import datetime
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
WHATSAPP_API_URL = "https://graph.facebook.com/v18.0/{phone_id}/messages"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class WhatsAppClient:
|
| 27 |
+
|
| 28 |
+
def __init__(self):
|
| 29 |
+
self.token = os.getenv("WHATSAPP_TOKEN", "DEMO_TOKEN")
|
| 30 |
+
self.phone_id = os.getenv("WHATSAPP_PHONE_ID", "DEMO_PHONE_ID")
|
| 31 |
+
self.verify = os.getenv("WHATSAPP_VERIFY_TOKEN", "plotweaver_verify")
|
| 32 |
+
self._demo = self.token == "DEMO_TOKEN"
|
| 33 |
+
|
| 34 |
+
# ββ Outbound βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
|
| 36 |
+
def send_text(self, to: str, body: str) -> dict:
|
| 37 |
+
"""Send a plain text message."""
|
| 38 |
+
payload = {
|
| 39 |
+
"messaging_product": "whatsapp",
|
| 40 |
+
"recipient_type": "individual",
|
| 41 |
+
"to": to,
|
| 42 |
+
"type": "text",
|
| 43 |
+
"text": {"preview_url": False, "body": body},
|
| 44 |
+
}
|
| 45 |
+
return self._post(payload)
|
| 46 |
+
|
| 47 |
+
def send_audio(self, to: str, audio_url: str) -> dict:
|
| 48 |
+
"""Send a voice note (ogg/opus recommended by Meta)."""
|
| 49 |
+
payload = {
|
| 50 |
+
"messaging_product": "whatsapp",
|
| 51 |
+
"recipient_type": "individual",
|
| 52 |
+
"to": to,
|
| 53 |
+
"type": "audio",
|
| 54 |
+
"audio": {"link": audio_url},
|
| 55 |
+
}
|
| 56 |
+
return self._post(payload)
|
| 57 |
+
|
| 58 |
+
def send_interactive_buttons(self, to: str, body: str,
|
| 59 |
+
buttons: list[dict]) -> dict:
|
| 60 |
+
"""
|
| 61 |
+
Quick-reply buttons for disambiguation.
|
| 62 |
+
buttons = [{"id": "yes", "title": "Yes β"}, {"id": "no", "title": "No β"}]
|
| 63 |
+
"""
|
| 64 |
+
payload = {
|
| 65 |
+
"messaging_product": "whatsapp",
|
| 66 |
+
"to": to,
|
| 67 |
+
"type": "interactive",
|
| 68 |
+
"interactive": {
|
| 69 |
+
"type": "button",
|
| 70 |
+
"body": {"text": body},
|
| 71 |
+
"action": {
|
| 72 |
+
"buttons": [
|
| 73 |
+
{"type": "reply", "reply": btn} for btn in buttons
|
| 74 |
+
]
|
| 75 |
+
},
|
| 76 |
+
},
|
| 77 |
+
}
|
| 78 |
+
return self._post(payload)
|
| 79 |
+
|
| 80 |
+
# ββ Inbound webhook ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 81 |
+
|
| 82 |
+
def verify_webhook(self, mode: str, token: str, challenge: str) -> Optional[str]:
|
| 83 |
+
"""GET verification handshake from Meta."""
|
| 84 |
+
if mode == "subscribe" and token == self.verify:
|
| 85 |
+
logger.info("WhatsApp webhook verified.")
|
| 86 |
+
return challenge
|
| 87 |
+
return None
|
| 88 |
+
|
| 89 |
+
def parse_inbound(self, raw_body: dict) -> Optional[dict]:
|
| 90 |
+
"""
|
| 91 |
+
Extract relevant fields from inbound webhook payload.
|
| 92 |
+
Returns normalized event dict or None if not a user message.
|
| 93 |
+
"""
|
| 94 |
+
try:
|
| 95 |
+
entry = raw_body["entry"][0]
|
| 96 |
+
changes = entry["changes"][0]["value"]
|
| 97 |
+
message = changes["messages"][0]
|
| 98 |
+
contact = changes["contacts"][0]
|
| 99 |
+
|
| 100 |
+
event = {
|
| 101 |
+
"from": message["from"],
|
| 102 |
+
"name": contact["profile"]["name"],
|
| 103 |
+
"timestamp": datetime.fromtimestamp(int(message["timestamp"])).isoformat(),
|
| 104 |
+
"msg_id": message["id"],
|
| 105 |
+
"type": message["type"],
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
if message["type"] == "text":
|
| 109 |
+
event["text"] = message["text"]["body"]
|
| 110 |
+
elif message["type"] == "audio":
|
| 111 |
+
event["audio_id"] = message["audio"]["id"]
|
| 112 |
+
event["mime"] = message["audio"].get("mime_type", "audio/ogg")
|
| 113 |
+
elif message["type"] == "interactive":
|
| 114 |
+
event["button_id"] = message["interactive"]["button_reply"]["id"]
|
| 115 |
+
|
| 116 |
+
return event
|
| 117 |
+
except (KeyError, IndexError) as e:
|
| 118 |
+
logger.warning(f"Could not parse WhatsApp payload: {e}")
|
| 119 |
+
return None
|
| 120 |
+
|
| 121 |
+
def download_media(self, media_id: str) -> Optional[bytes]:
|
| 122 |
+
"""Fetch audio bytes for voice messages."""
|
| 123 |
+
if self._demo:
|
| 124 |
+
logger.info(f"[DEMO] Would download media: {media_id}")
|
| 125 |
+
return b"" # Return empty bytes in demo mode
|
| 126 |
+
# Production: GET https://graph.facebook.com/v18.0/{media_id}
|
| 127 |
+
# then fetch the returned URL with Bearer auth
|
| 128 |
+
raise NotImplementedError("Set WHATSAPP_TOKEN in production.")
|
| 129 |
+
|
| 130 |
+
# ββ Internals βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 131 |
+
|
| 132 |
+
def _post(self, payload: dict) -> dict:
|
| 133 |
+
if self._demo:
|
| 134 |
+
logger.info(f"[DEMO] WhatsApp β {payload['to']}: "
|
| 135 |
+
f"{json.dumps(payload)[:120]} β¦")
|
| 136 |
+
return {"status": "demo_ok",
|
| 137 |
+
"message_id": "demo_" + hashlib.md5(
|
| 138 |
+
json.dumps(payload).encode()).hexdigest()[:8]}
|
| 139 |
+
import requests
|
| 140 |
+
url = WHATSAPP_API_URL.format(phone_id=self.phone_id)
|
| 141 |
+
headers = {"Authorization": f"Bearer {self.token}",
|
| 142 |
+
"Content-Type": "application/json"}
|
| 143 |
+
r = requests.post(url, headers=headers, json=payload, timeout=10)
|
| 144 |
+
r.raise_for_status()
|
| 145 |
+
return r.json()
|