Toadoum commited on
Commit
9c6a172
Β·
verified Β·
1 Parent(s): 0e520ae

Upload 4 files

Browse files
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()