forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtwilio_service.py
More file actions
259 lines (204 loc) · 7.63 KB
/
Copy pathtwilio_service.py
File metadata and controls
259 lines (204 loc) · 7.63 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
import logging
import os
from typing import Any, Dict, List, Optional, cast
from twilio.rest import Client
TwilioRestException: type[BaseException]
try:
from twilio.base.exceptions import TwilioRestException as _TwilioRestException
TwilioRestException = _TwilioRestException
except ImportError:
class _FallbackTwilioRestException(Exception):
status: int | None
code: int | None
def __init__(self, *args: object, status: int | None = None, code: int | None = None) -> None:
super().__init__(*args)
self.status = status
self.code = code
TwilioRestException = _FallbackTwilioRestException
from twilio.jwt.access_token import AccessToken
from twilio.jwt.access_token.grants import VoiceGrant
from twilio.request_validator import RequestValidator
from database import phone_calls as phone_calls_db
logger = logging.getLogger(__name__)
account_sid = os.getenv('TWILIO_ACCOUNT_SID')
auth_token = os.getenv('TWILIO_AUTH_TOKEN')
api_key_sid = os.getenv('TWILIO_API_KEY_SID')
api_key_secret = os.getenv('TWILIO_API_KEY_SECRET')
twiml_app_sid = os.getenv('TWILIO_TWIML_APP_SID')
_client = None
_TWILIO_NOT_FOUND_CODES = {20404}
def _get_client() -> Client:
global _client
if _client is None:
if not account_sid or not auth_token:
raise ValueError("TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN must be set")
_client = Client(account_sid, auth_token)
return _client
def generate_access_token(uid: str, ttl: int = 3600) -> Dict[str, Any]:
"""
Generate a Twilio Access Token with Voice grant for the given user.
Args:
uid: User ID used as the token identity
ttl: Token time-to-live in seconds (default 1 hour)
Returns:
dict with access_token, ttl, and identity
"""
if not api_key_sid or not api_key_secret:
raise ValueError("TWILIO_API_KEY_SID and TWILIO_API_KEY_SECRET must be set")
if not twiml_app_sid:
raise ValueError("TWILIO_TWIML_APP_SID must be set")
token = AccessToken(
account_sid,
api_key_sid,
api_key_secret,
identity=uid,
ttl=ttl,
)
voice_grant = VoiceGrant(
outgoing_application_sid=twiml_app_sid,
incoming_allow=False,
)
token.add_grant(voice_grant) # type: ignore[reportUnknownMemberType] # twilio add_grant untyped
return {
'access_token': token.to_jwt(), # type: ignore[reportUnknownMemberType] # twilio to_jwt untyped
'ttl': ttl,
'identity': uid,
}
def start_caller_id_verification(phone_number: str) -> Dict[str, Any]:
"""
Start the caller ID verification process via Twilio.
Twilio will call the user's phone with a verification code.
Args:
phone_number: Phone number in E.164 format
Returns:
dict with verification_sid and status
"""
client = _get_client()
validation_request = client.validation_requests.create(
friendly_name=phone_number,
phone_number=phone_number,
)
return {
'verification_sid': validation_request.call_sid,
'phone_number': phone_number,
'validation_code': validation_request.validation_code,
'status': 'pending',
}
def check_caller_id_verified(phone_number: str) -> bool:
"""
Check if a phone number has been verified as a caller ID.
Args:
phone_number: Phone number in E.164 format
Returns:
True if the number is verified
"""
client = _get_client()
outgoing_caller_ids = client.outgoing_caller_ids.list(phone_number=phone_number)
return len(outgoing_caller_ids) > 0
def get_caller_id(phone_number: str) -> Optional[Dict[str, Any]]:
"""
Get the caller ID record for a verified phone number.
Args:
phone_number: Phone number in E.164 format
Returns:
dict with sid, phone_number, friendly_name or None
"""
client = _get_client()
outgoing_caller_ids = client.outgoing_caller_ids.list(phone_number=phone_number)
if not outgoing_caller_ids:
return None
cid = outgoing_caller_ids[0]
return {
'sid': cid.sid,
'phone_number': cid.phone_number,
'friendly_name': cid.friendly_name,
}
def _delete_caller_id_status(sid: str) -> str:
try:
client = _get_client()
client.outgoing_caller_ids(sid).delete()
return 'deleted'
except TwilioRestException as e:
twilio_error = cast(Any, e)
status = getattr(twilio_error, 'status', None)
code = getattr(twilio_error, 'code', None)
if status == 404 or code in _TWILIO_NOT_FOUND_CODES:
return 'already_deleted'
logger.warning(f'delete_caller_id: twilio error sid={sid} status={status} code={code}')
return 'failed'
except Exception as e:
logger.warning(f'delete_caller_id: unexpected error sid={sid}: {e}')
return 'failed'
def delete_caller_id(sid: str) -> bool:
"""
Delete a verified caller ID.
Args:
sid: The Twilio SID of the outgoing caller ID
Returns:
True if deleted successfully
"""
return _delete_caller_id_status(sid) in ('deleted', 'already_deleted')
def _delete_user_caller_ids(uid: str, *, strict: bool) -> int:
try:
numbers = phone_calls_db.get_phone_numbers(uid)
except Exception as e:
logger.error(f'delete_user_caller_ids: list phone_numbers failed: {e}')
if strict:
raise
return 0
cleaned = 0
failures = []
for number in numbers:
sid = number.get('twilio_sid')
if not sid:
continue
status = _delete_caller_id_status(sid)
if status in ('deleted', 'already_deleted'):
cleaned += 1
continue
failures.append(sid)
logger.warning(f'delete_user_caller_ids: twilio delete failed for sid={sid}')
if failures and strict:
raise RuntimeError(f'twilio caller-id delete failed for {len(failures)} caller id(s)')
return cleaned
def delete_user_caller_ids(uid: str) -> int:
# Best-effort caller-ID cleanup for non-compliance paths.
return _delete_user_caller_ids(uid, strict=False)
def delete_user_caller_ids_strict(uid: str) -> int:
"""Delete every verified Twilio caller ID owned by ``uid``.
Unlike ``delete_user_caller_ids``, this account-deletion variant raises if
listing or deleting any caller ID fails. The account deletion worker must
keep Firestore metadata until Twilio cleanup has either succeeded or can be
retried with the phone-number documents still present.
"""
return _delete_user_caller_ids(uid, strict=True)
def list_caller_ids() -> List[Dict[str, Any]]:
"""
List all verified outgoing caller IDs for the account.
Returns:
List of caller ID records
"""
client = _get_client()
caller_ids = client.outgoing_caller_ids.list()
return [
{
'sid': cid.sid,
'phone_number': cid.phone_number,
'friendly_name': cid.friendly_name,
}
for cid in caller_ids
]
def validate_twilio_signature(url: str, params: Dict[str, Any], signature: str) -> bool:
"""
Validate that a request originated from Twilio using the X-Twilio-Signature header.
Args:
url: The full URL of the request
params: The POST parameters
signature: The X-Twilio-Signature header value
Returns:
True if the signature is valid
"""
if not auth_token:
return False
validator = RequestValidator(auth_token)
return validator.validate(url, params, signature) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] # twilio RequestValidator.validate untyped