extendvcc-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- extendvcc/__init__.py +46 -0
- extendvcc/_exit_codes.py +24 -0
- extendvcc/_jsonl.py +35 -0
- extendvcc/_paths.py +28 -0
- extendvcc/auth.py +900 -0
- extendvcc/cards.py +761 -0
- extendvcc/cli.py +883 -0
- extendvcc/client.py +491 -0
- extendvcc/imap_otp.py +170 -0
- extendvcc/ledger.py +535 -0
- extendvcc/models.py +74 -0
- extendvcc/py.typed +0 -0
- extendvcc_cli-0.1.0.dist-info/METADATA +179 -0
- extendvcc_cli-0.1.0.dist-info/RECORD +17 -0
- extendvcc_cli-0.1.0.dist-info/WHEEL +4 -0
- extendvcc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- extendvcc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
extendvcc/client.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""HTTP client for Extend's private web API.
|
|
2
|
+
|
|
3
|
+
This module intentionally treats anti-bot, WAF, and verification responses as
|
|
4
|
+
account-risk events. A detected risk writes the disabled-state file and all
|
|
5
|
+
subsequent network paths fail closed before requesting or refreshing tokens.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
from urllib.parse import urlencode, urlparse
|
|
18
|
+
|
|
19
|
+
import impit
|
|
20
|
+
|
|
21
|
+
BASE_URL = "https://api.paywithextend.com"
|
|
22
|
+
VAULT_BASE_URL = "https://v.paywithextend.com"
|
|
23
|
+
|
|
24
|
+
RATE_LIMIT_REMAINING_HEADER = "x-rate-limit-remaining"
|
|
25
|
+
RATE_LIMIT_BACKOFF_SECONDS = 1.0
|
|
26
|
+
RATE_LIMIT_LOW_WATERMARK = 10
|
|
27
|
+
RATE_LIMIT_MAX_BACKOFF_SECONDS = 30.0
|
|
28
|
+
|
|
29
|
+
USER_AGENT = (
|
|
30
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
31
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
32
|
+
"Chrome/125.0.0.0 Safari/537.36"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
EXTEND_BRAND = os.environ.get("EXTENDVCC_BRAND_ID", "br_2F0trP1UmE59x1ZkNIAqsg")
|
|
36
|
+
|
|
37
|
+
EXTEND_HEADERS = {
|
|
38
|
+
"Accept": "application/vnd.paywithextend.v2021-03-12+json",
|
|
39
|
+
"x-extend-app-id": "app.paywithextend.com",
|
|
40
|
+
"x-extend-brand": EXTEND_BRAND,
|
|
41
|
+
"x-extend-platform": "web",
|
|
42
|
+
"x-extend-platform-version": USER_AGENT,
|
|
43
|
+
"User-Agent": USER_AGENT,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_JSON_CONTENT_TYPE = "application/json"
|
|
47
|
+
# PAN-shaped digit runs (13-19) and obvious secret key names — scrubbed from any
|
|
48
|
+
# error payload so a card number / CVC echoed in a 4xx body can never reach a log.
|
|
49
|
+
_PAN_RUN_RE = re.compile(r"\d(?:[ -]?\d){12,18}")
|
|
50
|
+
_SENSITIVE_PAYLOAD_KEYS = ("cardnumber", "cvc", "cvv", "cvn", "securitycode", "pan", "vcn")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _scrub_payload(value: Any) -> Any:
|
|
54
|
+
"""Recursively mask PAN-shaped runs and sensitive-keyed values in an error payload."""
|
|
55
|
+
if isinstance(value, dict):
|
|
56
|
+
scrubbed: dict[Any, Any] = {}
|
|
57
|
+
for key, item in value.items():
|
|
58
|
+
compact = str(key).replace("_", "").replace("-", "").lower()
|
|
59
|
+
if any(marker in compact for marker in _SENSITIVE_PAYLOAD_KEYS):
|
|
60
|
+
scrubbed[key] = "[redacted]"
|
|
61
|
+
else:
|
|
62
|
+
scrubbed[key] = _scrub_payload(item)
|
|
63
|
+
return scrubbed
|
|
64
|
+
if isinstance(value, list):
|
|
65
|
+
return [_scrub_payload(item) for item in value]
|
|
66
|
+
if isinstance(value, str):
|
|
67
|
+
return _PAN_RUN_RE.sub("[redacted]", value)
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_HTML_MARKERS = (
|
|
72
|
+
"<!doctype html",
|
|
73
|
+
"<html",
|
|
74
|
+
"cloudflare",
|
|
75
|
+
"cf-chl",
|
|
76
|
+
"attention required",
|
|
77
|
+
"just a moment",
|
|
78
|
+
"checking your browser",
|
|
79
|
+
)
|
|
80
|
+
_VERIFICATION_MARKERS = (
|
|
81
|
+
"email_otp",
|
|
82
|
+
"otp required",
|
|
83
|
+
"verification required",
|
|
84
|
+
"verify your",
|
|
85
|
+
"verify identity",
|
|
86
|
+
"confirm your identity",
|
|
87
|
+
"multi-factor",
|
|
88
|
+
"mfa",
|
|
89
|
+
"captcha",
|
|
90
|
+
"recaptcha",
|
|
91
|
+
"challenge required",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _disabled_state_path() -> Path:
|
|
96
|
+
from extendvcc._paths import state_dir
|
|
97
|
+
|
|
98
|
+
return state_dir() / "paywithextend_disabled.json"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PayWithExtendError(RuntimeError):
|
|
102
|
+
"""Base PayWithExtend client error."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PayWithExtendDisabled(PayWithExtendError):
|
|
106
|
+
"""Raised when automation is disabled by the account-risk kill switch."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AccountRiskDetected(PayWithExtendDisabled):
|
|
110
|
+
"""Raised when a response trips the account-risk kill switch."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class PayWithExtendAPIError(PayWithExtendError):
|
|
114
|
+
"""Raised when Extend returns a JSON error response."""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
message: str,
|
|
119
|
+
*,
|
|
120
|
+
status_code: int,
|
|
121
|
+
path: str,
|
|
122
|
+
payload: Any = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
super().__init__(message)
|
|
125
|
+
self.status_code = status_code
|
|
126
|
+
self.path = path
|
|
127
|
+
self.payload = payload
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class PayWithExtendNonJSONError(PayWithExtendAPIError):
|
|
131
|
+
"""Raised when Extend returns a response that cannot be decoded as JSON."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def disabled_status(disabled_path: Path | None = None) -> dict[str, Any] | None:
|
|
135
|
+
path = disabled_path or _disabled_state_path()
|
|
136
|
+
if not path.exists():
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
loaded = json.loads(path.read_text())
|
|
140
|
+
except (OSError, json.JSONDecodeError):
|
|
141
|
+
return {
|
|
142
|
+
"disabled": True,
|
|
143
|
+
"reason": "disabled-state file exists but could not be decoded",
|
|
144
|
+
"path": str(path),
|
|
145
|
+
}
|
|
146
|
+
if isinstance(loaded, dict):
|
|
147
|
+
return loaded
|
|
148
|
+
return {
|
|
149
|
+
"disabled": True,
|
|
150
|
+
"reason": "disabled-state file did not contain an object",
|
|
151
|
+
"path": str(path),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def assert_not_disabled(disabled_path: Path | None = None) -> None:
|
|
156
|
+
status = disabled_status(disabled_path)
|
|
157
|
+
if status is None:
|
|
158
|
+
return
|
|
159
|
+
reason = status.get("reason", "PayWithExtend automation is disabled")
|
|
160
|
+
raise PayWithExtendDisabled(str(reason))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def disable_paywithextend(
|
|
164
|
+
reason: str,
|
|
165
|
+
*,
|
|
166
|
+
disabled_path: Path | None = None,
|
|
167
|
+
now: Callable[[], datetime] | None = None,
|
|
168
|
+
) -> dict[str, Any]:
|
|
169
|
+
path = disabled_path or _disabled_state_path()
|
|
170
|
+
timestamp = (now or _utc_now)().isoformat().replace("+00:00", "Z")
|
|
171
|
+
payload = {
|
|
172
|
+
"disabled": True,
|
|
173
|
+
"reason": reason,
|
|
174
|
+
"timestamp": timestamp,
|
|
175
|
+
}
|
|
176
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
177
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
178
|
+
tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
179
|
+
tmp_path.replace(path)
|
|
180
|
+
return payload
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def clear_disabled(*, manual: bool = False, disabled_path: Path | None = None) -> bool:
|
|
184
|
+
if not manual:
|
|
185
|
+
raise ValueError("clear_disabled requires manual=True")
|
|
186
|
+
path = disabled_path or _disabled_state_path()
|
|
187
|
+
if not path.exists():
|
|
188
|
+
return False
|
|
189
|
+
path.unlink()
|
|
190
|
+
return True
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _utc_now() -> datetime:
|
|
194
|
+
return datetime.now(timezone.utc)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _ensure_valid_token() -> str:
|
|
198
|
+
assert_not_disabled()
|
|
199
|
+
from .auth import ensure_valid_token
|
|
200
|
+
|
|
201
|
+
return ensure_valid_token()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _refresh_tokens() -> Any:
|
|
205
|
+
assert_not_disabled()
|
|
206
|
+
from .auth import refresh_tokens
|
|
207
|
+
|
|
208
|
+
return refresh_tokens()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _headers_get(headers: Any, name: str, default: str | None = None) -> str | None:
|
|
212
|
+
if hasattr(headers, "get"):
|
|
213
|
+
return headers.get(name, default)
|
|
214
|
+
return default
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _response_text(response: Any) -> str:
|
|
218
|
+
text = getattr(response, "text", None)
|
|
219
|
+
if isinstance(text, str):
|
|
220
|
+
return text
|
|
221
|
+
content = getattr(response, "content", b"")
|
|
222
|
+
if isinstance(content, bytes):
|
|
223
|
+
return content.decode("utf-8", errors="replace")
|
|
224
|
+
return str(content)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _json_or_none(response: Any) -> Any:
|
|
228
|
+
try:
|
|
229
|
+
return response.json()
|
|
230
|
+
except (TypeError, ValueError):
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _contains_marker(value: Any, markers: tuple[str, ...]) -> bool:
|
|
235
|
+
if isinstance(value, dict):
|
|
236
|
+
return any(_contains_marker(item, markers) for item in value.values())
|
|
237
|
+
if isinstance(value, list):
|
|
238
|
+
return any(_contains_marker(item, markers) for item in value)
|
|
239
|
+
if isinstance(value, str):
|
|
240
|
+
lowered = value.lower()
|
|
241
|
+
return any(marker in lowered for marker in markers)
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _is_html_response(response: Any) -> bool:
|
|
246
|
+
content_type = (_headers_get(response.headers, "content-type", "") or "").lower()
|
|
247
|
+
if "text/html" in content_type:
|
|
248
|
+
return True
|
|
249
|
+
lowered = _response_text(response).lstrip().lower()
|
|
250
|
+
return any(marker in lowered for marker in _HTML_MARKERS)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _risk_reason(response: Any, path: str) -> str | None:
|
|
254
|
+
status_code = int(getattr(response, "status_code", 0))
|
|
255
|
+
if status_code == 403:
|
|
256
|
+
return f"403 response from Extend for {path}"
|
|
257
|
+
if _is_html_response(response):
|
|
258
|
+
return f"HTML/WAF challenge response from Extend for {path}"
|
|
259
|
+
payload = _json_or_none(response)
|
|
260
|
+
if payload is not None and _contains_marker(payload, _VERIFICATION_MARKERS):
|
|
261
|
+
return f"Unexpected verification prompt from Extend for {path}"
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def inspect_account_risk(
|
|
266
|
+
response: Any,
|
|
267
|
+
path: str,
|
|
268
|
+
*,
|
|
269
|
+
disable_writer: Callable[[str], Any] = disable_paywithextend,
|
|
270
|
+
) -> None:
|
|
271
|
+
reason = _risk_reason(response, path)
|
|
272
|
+
if reason is None:
|
|
273
|
+
return
|
|
274
|
+
disable_writer(reason)
|
|
275
|
+
raise AccountRiskDetected(reason)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class PayWithExtendClient:
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
*,
|
|
282
|
+
http_client: Any | None = None,
|
|
283
|
+
base_url: str = BASE_URL,
|
|
284
|
+
token_getter: Callable[[], str] = _ensure_valid_token,
|
|
285
|
+
token_refresher: Callable[[], Any] = _refresh_tokens,
|
|
286
|
+
disabled_checker: Callable[[], None] = assert_not_disabled,
|
|
287
|
+
disable_writer: Callable[[str], Any] = disable_paywithextend,
|
|
288
|
+
sleeper: Callable[[float], None] = time.sleep,
|
|
289
|
+
rate_limit_low_watermark: int = RATE_LIMIT_LOW_WATERMARK,
|
|
290
|
+
rate_limit_backoff_seconds: float = RATE_LIMIT_BACKOFF_SECONDS,
|
|
291
|
+
rate_limit_max_backoff_seconds: float = RATE_LIMIT_MAX_BACKOFF_SECONDS,
|
|
292
|
+
) -> None:
|
|
293
|
+
self._client = http_client or impit.Client(browser="chrome")
|
|
294
|
+
self._base_url = base_url.rstrip("/")
|
|
295
|
+
self._token_getter = token_getter
|
|
296
|
+
self._token_refresher = token_refresher
|
|
297
|
+
self._disabled_checker = disabled_checker
|
|
298
|
+
self._disable_writer = disable_writer
|
|
299
|
+
self._sleeper = sleeper
|
|
300
|
+
self._rate_limit_low_watermark = rate_limit_low_watermark
|
|
301
|
+
self._rate_limit_backoff_seconds = rate_limit_backoff_seconds
|
|
302
|
+
self._rate_limit_max_backoff_seconds = rate_limit_max_backoff_seconds
|
|
303
|
+
self._rate_limit_hits = 0
|
|
304
|
+
|
|
305
|
+
def get(
|
|
306
|
+
self,
|
|
307
|
+
path: str,
|
|
308
|
+
*,
|
|
309
|
+
params: dict[str, Any] | None = None,
|
|
310
|
+
headers: dict[str, str] | None = None,
|
|
311
|
+
timeout: float = 30,
|
|
312
|
+
) -> Any:
|
|
313
|
+
return self.request("GET", path, params=params, headers=headers, timeout=timeout)
|
|
314
|
+
|
|
315
|
+
def post(
|
|
316
|
+
self,
|
|
317
|
+
path: str,
|
|
318
|
+
*,
|
|
319
|
+
json_body: Any | None = None,
|
|
320
|
+
headers: dict[str, str] | None = None,
|
|
321
|
+
timeout: float = 30,
|
|
322
|
+
) -> Any:
|
|
323
|
+
return self.request("POST", path, json_body=json_body, headers=headers, timeout=timeout)
|
|
324
|
+
|
|
325
|
+
def put(
|
|
326
|
+
self,
|
|
327
|
+
path: str,
|
|
328
|
+
*,
|
|
329
|
+
json_body: Any | None = None,
|
|
330
|
+
headers: dict[str, str] | None = None,
|
|
331
|
+
timeout: float = 30,
|
|
332
|
+
) -> Any:
|
|
333
|
+
return self.request("PUT", path, json_body=json_body, headers=headers, timeout=timeout)
|
|
334
|
+
|
|
335
|
+
def patch(
|
|
336
|
+
self,
|
|
337
|
+
path: str,
|
|
338
|
+
*,
|
|
339
|
+
json_body: Any | None = None,
|
|
340
|
+
headers: dict[str, str] | None = None,
|
|
341
|
+
timeout: float = 30,
|
|
342
|
+
) -> Any:
|
|
343
|
+
return self.request("PATCH", path, json_body=json_body, headers=headers, timeout=timeout)
|
|
344
|
+
|
|
345
|
+
def delete(
|
|
346
|
+
self,
|
|
347
|
+
path: str,
|
|
348
|
+
*,
|
|
349
|
+
headers: dict[str, str] | None = None,
|
|
350
|
+
timeout: float = 30,
|
|
351
|
+
) -> Any:
|
|
352
|
+
return self.request("DELETE", path, headers=headers, timeout=timeout)
|
|
353
|
+
|
|
354
|
+
def request(
|
|
355
|
+
self,
|
|
356
|
+
method: str,
|
|
357
|
+
path: str,
|
|
358
|
+
*,
|
|
359
|
+
params: dict[str, Any] | None = None,
|
|
360
|
+
json_body: Any | None = None,
|
|
361
|
+
content: bytes | str | None = None,
|
|
362
|
+
headers: dict[str, str] | None = None,
|
|
363
|
+
timeout: float = 30,
|
|
364
|
+
) -> Any:
|
|
365
|
+
response = self._request_once(
|
|
366
|
+
method,
|
|
367
|
+
path,
|
|
368
|
+
params=params,
|
|
369
|
+
json_body=json_body,
|
|
370
|
+
content=content,
|
|
371
|
+
headers=headers,
|
|
372
|
+
timeout=timeout,
|
|
373
|
+
)
|
|
374
|
+
# A routine 401 (expired token) must get one refresh-and-retry BEFORE the
|
|
375
|
+
# account-risk inspection runs — otherwise a 401 body that happens to
|
|
376
|
+
# contain a verification phrase would permanently trip the kill switch
|
|
377
|
+
# instead of simply refreshing. Only the final response is inspected.
|
|
378
|
+
if int(getattr(response, "status_code", 0)) == 401:
|
|
379
|
+
self._disabled_checker()
|
|
380
|
+
self._token_refresher()
|
|
381
|
+
response = self._request_once(
|
|
382
|
+
method,
|
|
383
|
+
path,
|
|
384
|
+
params=params,
|
|
385
|
+
json_body=json_body,
|
|
386
|
+
content=content,
|
|
387
|
+
headers=headers,
|
|
388
|
+
timeout=timeout,
|
|
389
|
+
)
|
|
390
|
+
self._inspect_response(response, path)
|
|
391
|
+
return self._decode_response(response, path)
|
|
392
|
+
|
|
393
|
+
def _request_once(
|
|
394
|
+
self,
|
|
395
|
+
method: str,
|
|
396
|
+
path: str,
|
|
397
|
+
*,
|
|
398
|
+
params: dict[str, Any] | None,
|
|
399
|
+
json_body: Any | None,
|
|
400
|
+
content: bytes | str | None,
|
|
401
|
+
headers: dict[str, str] | None,
|
|
402
|
+
timeout: float,
|
|
403
|
+
) -> Any:
|
|
404
|
+
self._disabled_checker()
|
|
405
|
+
request_headers = self._auth_headers(headers)
|
|
406
|
+
request_content = content
|
|
407
|
+
if json_body is not None:
|
|
408
|
+
request_headers.setdefault("Content-Type", _JSON_CONTENT_TYPE)
|
|
409
|
+
request_content = json.dumps(json_body, separators=(",", ":")).encode()
|
|
410
|
+
url = self._url(path, params)
|
|
411
|
+
return self._client.request(
|
|
412
|
+
method,
|
|
413
|
+
url,
|
|
414
|
+
headers=request_headers,
|
|
415
|
+
content=request_content,
|
|
416
|
+
timeout=timeout,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def _auth_headers(self, extra_headers: dict[str, str] | None) -> dict[str, str]:
|
|
420
|
+
self._disabled_checker()
|
|
421
|
+
token = self._token_getter()
|
|
422
|
+
headers = {**EXTEND_HEADERS, "Authorization": f"Bearer {token}"}
|
|
423
|
+
if extra_headers:
|
|
424
|
+
headers.update(extra_headers)
|
|
425
|
+
# Re-assert the freshly-minted token so caller headers cannot override it.
|
|
426
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
427
|
+
return headers
|
|
428
|
+
|
|
429
|
+
def _url(self, path: str, params: dict[str, Any] | None) -> str:
|
|
430
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
431
|
+
parsed_url = urlparse(path)
|
|
432
|
+
parsed_base = urlparse(self._base_url)
|
|
433
|
+
if parsed_url.scheme != parsed_base.scheme or parsed_url.netloc != parsed_base.netloc:
|
|
434
|
+
raise ValueError("PayWithExtendClient refuses absolute URLs outside the Extend API host")
|
|
435
|
+
url = path
|
|
436
|
+
else:
|
|
437
|
+
url = f"{self._base_url}/{path.lstrip('/')}"
|
|
438
|
+
if not params:
|
|
439
|
+
return url
|
|
440
|
+
separator = "&" if "?" in url else "?"
|
|
441
|
+
return f"{url}{separator}{urlencode(params, doseq=True)}"
|
|
442
|
+
|
|
443
|
+
def _inspect_response(self, response: Any, path: str) -> None:
|
|
444
|
+
inspect_account_risk(response, path, disable_writer=self._disable_writer)
|
|
445
|
+
self._backoff_for_rate_limit(response)
|
|
446
|
+
|
|
447
|
+
def _backoff_for_rate_limit(self, response: Any) -> None:
|
|
448
|
+
remaining_header = _headers_get(response.headers, RATE_LIMIT_REMAINING_HEADER)
|
|
449
|
+
remaining: int | None = None
|
|
450
|
+
if remaining_header is not None:
|
|
451
|
+
try:
|
|
452
|
+
remaining = int(remaining_header)
|
|
453
|
+
except ValueError:
|
|
454
|
+
remaining = None
|
|
455
|
+
|
|
456
|
+
status_code = int(getattr(response, "status_code", 0))
|
|
457
|
+
should_backoff = status_code == 429 or (remaining is not None and remaining <= self._rate_limit_low_watermark)
|
|
458
|
+
if not should_backoff:
|
|
459
|
+
self._rate_limit_hits = 0
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
self._rate_limit_hits += 1
|
|
463
|
+
delay = min(
|
|
464
|
+
self._rate_limit_backoff_seconds * (2 ** (self._rate_limit_hits - 1)),
|
|
465
|
+
self._rate_limit_max_backoff_seconds,
|
|
466
|
+
)
|
|
467
|
+
self._sleeper(delay)
|
|
468
|
+
|
|
469
|
+
def _decode_response(self, response: Any, path: str) -> Any:
|
|
470
|
+
status_code = int(getattr(response, "status_code", 0))
|
|
471
|
+
try:
|
|
472
|
+
payload = response.json()
|
|
473
|
+
except ValueError as exc:
|
|
474
|
+
raise PayWithExtendNonJSONError(
|
|
475
|
+
f"Extend API returned a non-JSON response: {status_code} {path}",
|
|
476
|
+
status_code=status_code,
|
|
477
|
+
path=path,
|
|
478
|
+
) from exc
|
|
479
|
+
if status_code >= 400:
|
|
480
|
+
raise PayWithExtendAPIError(
|
|
481
|
+
f"Extend API request failed: {status_code} {path}",
|
|
482
|
+
status_code=status_code,
|
|
483
|
+
path=path,
|
|
484
|
+
payload=_scrub_payload(payload),
|
|
485
|
+
)
|
|
486
|
+
return payload
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def vault_client(**kwargs: Any) -> PayWithExtendClient:
|
|
490
|
+
"""Return a PayWithExtendClient configured for the vault host (v.paywithextend.com)."""
|
|
491
|
+
return PayWithExtendClient(base_url=VAULT_BASE_URL, **kwargs)
|
extendvcc/imap_otp.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""IMAP-based OTP retrieval for PayWithExtend Cognito EMAIL_OTP challenges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import calendar
|
|
6
|
+
import email
|
|
7
|
+
import imaplib
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from email.header import decode_header
|
|
14
|
+
|
|
15
|
+
DEFAULT_IMAP_SERVER = "imap.gmail.com"
|
|
16
|
+
DEFAULT_IMAP_PORT = 993
|
|
17
|
+
|
|
18
|
+
EXTEND_SENDER = "paywithextend.com"
|
|
19
|
+
|
|
20
|
+
MAX_WAIT_SECONDS = 60
|
|
21
|
+
POLL_INTERVAL_SECONDS = 2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read_imap_credentials() -> tuple[str, str, str] | None:
|
|
25
|
+
"""Read IMAP credentials from env vars. Returns (email, app_password, server) or None."""
|
|
26
|
+
imap_email = os.environ.get("EXTENDVCC_IMAP_USER", "")
|
|
27
|
+
imap_password = os.environ.get("EXTENDVCC_IMAP_PASSWORD", "")
|
|
28
|
+
if not imap_email or not imap_password:
|
|
29
|
+
return None
|
|
30
|
+
imap_server = os.environ.get("EXTENDVCC_IMAP_HOST", DEFAULT_IMAP_SERVER)
|
|
31
|
+
return imap_email, imap_password, imap_server
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extract_code(text: str) -> str | None:
|
|
35
|
+
"""Extract a 6-digit verification code from email body text."""
|
|
36
|
+
clean = re.sub(r"<[^>]+>", " ", text)
|
|
37
|
+
clean = re.sub(r"&\w+;", " ", clean)
|
|
38
|
+
clean = re.sub(r"\s+", " ", clean)
|
|
39
|
+
|
|
40
|
+
patterns = [
|
|
41
|
+
r"verification\s+code[:\s]*(\d{6})",
|
|
42
|
+
r"Enter\s+this\s+verification\s+code[^:]*:\s*(\d{6})",
|
|
43
|
+
r"sign[- ]?in\s+code[:\s]*(\d{6})",
|
|
44
|
+
r"security\s+code[:\s]*(\d{6})",
|
|
45
|
+
r"one[- ]?time\s+(?:code|password)[:\s]*(\d{6})",
|
|
46
|
+
r"your\s+code\s+is[:\s]*(\d{6})",
|
|
47
|
+
r"code[:\s]+(\d{6})",
|
|
48
|
+
]
|
|
49
|
+
for pattern in patterns:
|
|
50
|
+
match = re.search(pattern, clean, re.IGNORECASE)
|
|
51
|
+
if match:
|
|
52
|
+
return match.group(1)
|
|
53
|
+
|
|
54
|
+
# Fallback: bold standalone 6-digit number (the Extend email format)
|
|
55
|
+
bold_match = re.search(r"<b>\s*(\d{6})\s*</b>", text, re.IGNORECASE)
|
|
56
|
+
if bold_match:
|
|
57
|
+
return bold_match.group(1)
|
|
58
|
+
|
|
59
|
+
# Last resort: any standalone 6-digit number
|
|
60
|
+
fallback = re.search(r"\b(\d{6})\b", clean)
|
|
61
|
+
return fallback.group(1) if fallback else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_body(msg: email.message.Message) -> str:
|
|
65
|
+
"""Extract text body from an email message."""
|
|
66
|
+
if msg.is_multipart():
|
|
67
|
+
html_body = ""
|
|
68
|
+
for part in msg.walk():
|
|
69
|
+
ct = part.get_content_type()
|
|
70
|
+
payload = part.get_payload(decode=True)
|
|
71
|
+
if payload is None:
|
|
72
|
+
continue
|
|
73
|
+
decoded = payload.decode(errors="replace")
|
|
74
|
+
if ct == "text/plain":
|
|
75
|
+
return decoded
|
|
76
|
+
if ct == "text/html" and not html_body:
|
|
77
|
+
html_body = decoded
|
|
78
|
+
return html_body
|
|
79
|
+
payload = msg.get_payload(decode=True)
|
|
80
|
+
return payload.decode(errors="replace") if payload else ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fetch_otp(
|
|
84
|
+
since_timestamp: float,
|
|
85
|
+
*,
|
|
86
|
+
max_wait: int = MAX_WAIT_SECONDS,
|
|
87
|
+
poll_interval: int = POLL_INTERVAL_SECONDS,
|
|
88
|
+
_credentials: tuple[str, str, str] | None = None,
|
|
89
|
+
) -> str | None:
|
|
90
|
+
"""Poll IMAP inbox for an Extend OTP email and return the 6-digit code."""
|
|
91
|
+
creds = _credentials or read_imap_credentials()
|
|
92
|
+
if creds is None:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
imap_email, imap_password, imap_server = creds
|
|
96
|
+
deadline = time.monotonic() + max_wait
|
|
97
|
+
|
|
98
|
+
conn = imaplib.IMAP4_SSL(imap_server, DEFAULT_IMAP_PORT)
|
|
99
|
+
try:
|
|
100
|
+
conn.login(imap_email, imap_password)
|
|
101
|
+
conn.select("INBOX")
|
|
102
|
+
|
|
103
|
+
since_date = time.strftime("%d-%b-%Y", time.localtime(since_timestamp - 86400))
|
|
104
|
+
|
|
105
|
+
while time.monotonic() < deadline:
|
|
106
|
+
conn.noop()
|
|
107
|
+
conn.select("INBOX")
|
|
108
|
+
_, data = conn.search(None, f'(SINCE {since_date} FROM "{EXTEND_SENDER}")')
|
|
109
|
+
msg_ids = data[0].split() if data[0] else []
|
|
110
|
+
|
|
111
|
+
for msg_id in reversed(msg_ids):
|
|
112
|
+
_, date_data = conn.fetch(msg_id, "(INTERNALDATE)")
|
|
113
|
+
internal_date = imaplib.Internaldate2tuple(date_data[0])
|
|
114
|
+
if internal_date is not None:
|
|
115
|
+
msg_epoch = calendar.timegm(internal_date)
|
|
116
|
+
if msg_epoch < since_timestamp - 5:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
_, msg_data = conn.fetch(msg_id, "(RFC822)")
|
|
120
|
+
raw = msg_data[0][1]
|
|
121
|
+
msg = email.message_from_bytes(raw)
|
|
122
|
+
|
|
123
|
+
subject_raw = decode_header(msg.get("Subject", ""))[0][0]
|
|
124
|
+
if isinstance(subject_raw, bytes):
|
|
125
|
+
subject_raw = subject_raw.decode(errors="replace")
|
|
126
|
+
subject = str(subject_raw).lower()
|
|
127
|
+
|
|
128
|
+
if "code" not in subject and "verification" not in subject:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
body = _get_body(msg)
|
|
132
|
+
code = extract_code(body)
|
|
133
|
+
if code:
|
|
134
|
+
conn.store(msg_id, "+FLAGS", "\\Seen")
|
|
135
|
+
return code
|
|
136
|
+
|
|
137
|
+
time.sleep(poll_interval)
|
|
138
|
+
|
|
139
|
+
return None
|
|
140
|
+
finally:
|
|
141
|
+
try:
|
|
142
|
+
conn.logout()
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _prompt_stdin(prompt: str) -> str:
|
|
148
|
+
"""Read a line from stdin after writing the prompt to stderr.
|
|
149
|
+
|
|
150
|
+
Keeps stdout clean: under ``--json`` only structured data may reach stdout,
|
|
151
|
+
so interactive prompt text must go to stderr.
|
|
152
|
+
"""
|
|
153
|
+
print(prompt, end="", file=sys.stderr, flush=True)
|
|
154
|
+
return input()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def make_otp_callback() -> Callable[[str], str]:
|
|
158
|
+
"""Return an OTP callback: IMAP auto-retrieval if configured, stdin prompt if not."""
|
|
159
|
+
creds = read_imap_credentials()
|
|
160
|
+
if creds is None:
|
|
161
|
+
return _prompt_stdin
|
|
162
|
+
|
|
163
|
+
def _imap_callback(_prompt: str) -> str:
|
|
164
|
+
since = time.time() - 300
|
|
165
|
+
code = fetch_otp(since, _credentials=creds)
|
|
166
|
+
if code is None:
|
|
167
|
+
return _prompt_stdin("IMAP retrieval timed out. Enter OTP manually: ")
|
|
168
|
+
return code
|
|
169
|
+
|
|
170
|
+
return _imap_callback
|