forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathphone_calls.py
More file actions
226 lines (196 loc) · 7.86 KB
/
Copy pathphone_calls.py
File metadata and controls
226 lines (196 loc) · 7.86 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
"""
Phone call quota resolution + gate.
Paid-plan users always pass. Free-plan users are metered against
``phone_call_config``'s ``free_plan`` block and their monthly usage counter
in ``phone_call_usage``.
Setting ``free_plan.monthly_call_limit`` to 0 makes the feature paid-only
again (same behavior as before this module existed); the quota snapshot
returned to the client in that case reports ``has_access = False``.
"""
from typing import Any, Dict, FrozenSet, List, Optional
from fastapi import HTTPException
import database.phone_call_usage as phone_call_usage_db
import database.users as users_db
from database.phone_call_config import get_config_for_plan, is_paid_phone_call_plan
from models.users import PlanType
# Minimal E.164 prefix → ISO-2 mapping. Intentionally covers the cheap/common
# destinations; anything not on the list falls through to an empty match and
# is treated as "unknown country" — the allowlist check then rejects it
# (fail-safe against toll-fraud on high-cost international routes).
#
# A prefix maps to one-or-more ISO codes because the NANP pool (+1) is shared
# by US and Canada (plus other territories we intentionally don't allowlist).
# Without a proper libphonenumber parse we can't distinguish US from CA by
# area code here, so the allowlist check treats +1 as matching if *either*
# code is allowed. Ops who want to separate US and CA should run the check
# outside this module.
_E164_PREFIX_TO_ISO2: list[tuple[str, FrozenSet[str]]] = [
('+1', frozenset({'US', 'CA'})), # NANP: US and CA share +1
('+44', frozenset({'GB'})),
('+61', frozenset({'AU'})),
('+64', frozenset({'NZ'})),
('+33', frozenset({'FR'})),
('+49', frozenset({'DE'})),
('+34', frozenset({'ES'})),
('+39', frozenset({'IT'})),
('+31', frozenset({'NL'})),
('+46', frozenset({'SE'})),
('+47', frozenset({'NO'})),
('+45', frozenset({'DK'})),
('+358', frozenset({'FI'})),
('+353', frozenset({'IE'})),
('+41', frozenset({'CH'})),
('+43', frozenset({'AT'})),
('+32', frozenset({'BE'})),
('+351', frozenset({'PT'})),
('+81', frozenset({'JP'})),
('+82', frozenset({'KR'})),
]
def countries_from_e164(number: str) -> FrozenSet[str]:
"""Best-effort ISO-2 lookup from an E.164 number.
Returns the set of ISO-2 codes that share the matched dial prefix. The
number passes an allowlist check if any element of that set is allowed.
Empty set means "unknown" — fail-safe, always blocked when an allowlist
is configured.
"""
if not number or not number.startswith('+'):
return frozenset()
for prefix, iso_codes in _E164_PREFIX_TO_ISO2:
if number.startswith(prefix):
return iso_codes
return frozenset()
def country_from_e164(number: str) -> Optional[str]:
"""Back-compat: return a representative ISO-2 for ``number`` if known.
Prefer ``countries_from_e164`` when checking allowlists — it returns the
full set so shared dial prefixes (notably +1 for US + CA) don't silently
pick one country over another.
"""
matches = countries_from_e164(number)
if not matches:
return None
return next(iter(matches))
class QuotaSnapshot:
__slots__ = (
'plan',
'is_paid',
'monthly_limit',
'monthly_used',
'max_duration_seconds',
'allowed_countries',
'reset_at',
)
def __init__(
self,
plan: Optional[PlanType],
is_paid: bool,
monthly_limit: Optional[int],
monthly_used: int,
max_duration_seconds: Optional[int],
allowed_countries: List[str],
reset_at: int,
):
self.plan = plan
self.is_paid = is_paid
self.monthly_limit = monthly_limit
self.monthly_used = monthly_used
self.max_duration_seconds = max_duration_seconds
self.allowed_countries = allowed_countries or []
self.reset_at = reset_at
@property
def has_access(self) -> bool:
"""True iff the user can currently place a call under this plan."""
if self.is_paid:
return True
if self.monthly_limit is None:
return True
if self.monthly_limit <= 0:
return False
return self.monthly_used < self.monthly_limit
@property
def remaining(self) -> Optional[int]:
if self.is_paid or self.monthly_limit is None:
return None
return max(0, self.monthly_limit - self.monthly_used)
def to_client_dict(self) -> Dict[str, Any]:
return {
'has_access': self.has_access,
'is_paid': self.is_paid,
'monthly_limit': self.monthly_limit,
'monthly_used': self.monthly_used,
'remaining': self.remaining,
'max_duration_seconds': self.max_duration_seconds,
'allowed_countries': self.allowed_countries,
'reset_at': self.reset_at,
}
def get_quota_snapshot(uid: str) -> QuotaSnapshot:
"""Resolve the user's plan + config + current usage into a snapshot."""
subscription = users_db.get_user_valid_subscription(uid)
plan = subscription.plan if subscription else None
paid = is_paid_phone_call_plan(plan)
config = get_config_for_plan(plan)
used, reset_at = phone_call_usage_db.get_current_month_count(uid)
return QuotaSnapshot(
plan=plan,
is_paid=paid,
monthly_limit=config.get('monthly_call_limit'),
monthly_used=used,
max_duration_seconds=config.get('max_duration_seconds'),
allowed_countries=config.get('allowed_countries') or [],
reset_at=reset_at,
)
def reserve_phone_call_quota(uid: str) -> QuotaSnapshot:
"""Resolve plan/config and reserve one free-tier call slot atomically."""
subscription = users_db.get_user_valid_subscription(uid)
plan = subscription.plan if subscription else None
paid = is_paid_phone_call_plan(plan)
config = get_config_for_plan(plan)
monthly_limit = config.get('monthly_call_limit')
if paid or monthly_limit is None:
used, reset_at = phone_call_usage_db.get_current_month_count(uid)
else:
_, used, reset_at = phone_call_usage_db.reserve_current_month_slot(uid, monthly_limit)
return QuotaSnapshot(
plan=plan,
is_paid=paid,
monthly_limit=monthly_limit,
monthly_used=used,
max_duration_seconds=config.get('max_duration_seconds'),
allowed_countries=config.get('allowed_countries') or [],
reset_at=reset_at,
)
def check_call_access(uid: str) -> QuotaSnapshot:
"""Raise 402/403 if the user cannot access the phone call feature.
Returns the snapshot so callers can reuse it (e.g. for max-duration
enforcement on the TwiML response).
"""
snapshot = get_quota_snapshot(uid)
if snapshot.has_access:
return snapshot
if not snapshot.is_paid and (snapshot.monthly_limit or 0) <= 0:
# Feature disabled for the free tier.
raise HTTPException(status_code=403, detail="Phone calls require a paid subscription")
# Free tier enabled but quota exhausted.
raise HTTPException(
status_code=402,
detail={
'error': 'phone_call_quota_exceeded',
'monthly_limit': snapshot.monthly_limit,
'monthly_used': snapshot.monthly_used,
'reset_at': snapshot.reset_at,
},
)
def check_destination_allowed(snapshot: QuotaSnapshot, to_number: str) -> None:
"""Reject destinations outside the allowlist. No-op if allowlist is empty
or the caller is on a paid plan."""
if snapshot.is_paid:
return
allowed = snapshot.allowed_countries
if not allowed:
return
allowed_set = {c.upper() for c in allowed}
matches = countries_from_e164(to_number)
if not matches or matches.isdisjoint(allowed_set):
raise HTTPException(
status_code=403,
detail="This destination is not available on the free plan",
)