1
0
Fork 0

fix: revert old oauth behavior and new default email provider

This commit is contained in:
Arthur K. 2026-03-02 19:10:50 +03:00
parent 0af7179596
commit 6dd26ad3d8
Signed by: wzray
GPG key ID: B97F30FDC4636357
7 changed files with 439 additions and 23 deletions

View file

@ -52,6 +52,11 @@ Behavior:
4. When usage reaches `CHATGPT_PREPARE_THRESHOLD`, service prepares `next_account`.
5. When usage reaches `CHATGPT_SWITCH_THRESHOLD`, service switches active account to `next_account`.
## Disposable Email Provider
- Default provider is `mail.tm` API (`MailTmProvider`) and does not use browser automation.
- Flow: fetch domains -> create account with random address/password -> get JWT token -> poll messages.
## Startup Behavior
On startup, service ensures active token exists and is usable.

View file

@ -0,0 +1,55 @@
import argparse
import asyncio
import json
import logging
import browser
import server
class _FakeRequest:
def __init__(self, provider: str):
self.match_info = {"provider": provider}
self.method = "GET"
self.path_qs = f"/{provider}/token"
def _enable_headed_browser() -> bool:
if "--no-startup-window" in browser.CHROME_FLAGS:
browser.CHROME_FLAGS.remove("--no-startup-window")
return True
return False
async def _run(provider: str) -> int:
patched = _enable_headed_browser()
logging.info("Headed mode patch applied: %s", patched)
request = _FakeRequest(provider)
response = await server.token_handler(request)
payload = json.loads(response.body.decode("utf-8"))
logging.info("Response status: %s", response.status)
logging.info("Response body: %s", json.dumps(payload, indent=2))
return 0 if response.status == 200 else 1
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Run the same token refresh/issue flow as server /{provider}/token "
"in headed browser mode (non-headless)."
)
)
parser.add_argument("--provider", default="chatgpt")
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return asyncio.run(_run(args.provider))
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,5 +1,11 @@
from .base import BaseProvider
from .mail_tm import MailTmProvider
from .ten_minute_mail import TenMinuteMailProvider
from .temp_mail_org import TempMailOrgProvider
__all__ = ["BaseProvider", "TenMinuteMailProvider", "TempMailOrgProvider"]
__all__ = [
"BaseProvider",
"MailTmProvider",
"TenMinuteMailProvider",
"TempMailOrgProvider",
]

View file

@ -0,0 +1,232 @@
import asyncio
import logging
import os
import secrets
import string
from typing import Any
import aiohttp
from playwright.async_api import BrowserContext
from .base import BaseProvider
logger = logging.getLogger(__name__)
_API_BASE = os.environ.get("MAIL_TM_API_BASE", "https://api.mail.tm")
_TIMEOUT_SECONDS = 20
_FIRST_NAMES = [
"james",
"john",
"robert",
"michael",
"david",
"william",
"joseph",
"thomas",
"daniel",
"mark",
"paul",
"kevin",
]
_LAST_NAMES = [
"smith",
"johnson",
"williams",
"brown",
"jones",
"miller",
"davis",
"wilson",
"anderson",
"taylor",
"martin",
"thompson",
]
def _generate_local_part() -> str:
first = secrets.choice(_FIRST_NAMES)
last = secrets.choice(_LAST_NAMES)
digits = "".join(secrets.choice(string.digits) for _ in range(8))
return f"{first}{last}{digits}"
def _generate_password(length: int = 24) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
class MailTmProvider(BaseProvider):
def __init__(self, browser_session: BrowserContext):
super().__init__(browser_session)
self._address: str | None = None
self._password: str | None = None
self._token: str | None = None
async def _request(
self,
method: str,
path: str,
*,
token: str | None = None,
json_body: dict[str, Any] | None = None,
) -> tuple[int, dict[str, Any] | list[Any] | None]:
url = f"{_API_BASE.rstrip('/')}{path}"
headers: dict[str, str] = {}
if token:
headers["Authorization"] = f"Bearer {token}"
timeout = aiohttp.ClientTimeout(total=_TIMEOUT_SECONDS)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.request(
method,
url,
headers=headers,
json=json_body,
) as resp:
status = resp.status
try:
payload = await resp.json()
except aiohttp.ContentTypeError:
payload = None
return status, payload
except aiohttp.ClientError as e:
logger.warning("[mail.tm] request failed %s %s: %s", method, path, e)
return 0, None
async def _get_domains(self) -> list[str]:
status, payload = await self._request("GET", "/domains")
if status != 200 or not isinstance(payload, dict):
raise RuntimeError("mail.tm domains request failed")
members = payload.get("hydra:member")
if not isinstance(members, list):
raise RuntimeError("mail.tm domains response has unexpected format")
domains: list[str] = []
for item in members:
if not isinstance(item, dict):
continue
domain = item.get("domain")
is_active = bool(item.get("isActive", True))
if isinstance(domain, str) and domain and is_active:
domains.append(domain)
if not domains:
raise RuntimeError("mail.tm returned no active domains")
return domains
async def _create_account(self, address: str, password: str) -> bool:
status, _ = await self._request(
"POST",
"/accounts",
json_body={"address": address, "password": password},
)
if status in (200, 201):
return True
return False
async def _create_token(self, address: str, password: str) -> str | None:
status, payload = await self._request(
"POST",
"/token",
json_body={"address": address, "password": password},
)
if status != 200 or not isinstance(payload, dict):
return None
token = payload.get("token")
if isinstance(token, str) and token:
return token
return None
async def get_new_email(self) -> str:
domains = await self._get_domains()
for _ in range(8):
domain = secrets.choice(domains)
address = f"{_generate_local_part()}@{domain}"
password = _generate_password()
created = await self._create_account(address, password)
if not created:
continue
token = await self._create_token(address, password)
if not token:
continue
self._address = address
self._password = password
self._token = token
logger.info("[mail.tm] New mailbox acquired: %s", address)
return address
raise RuntimeError("mail.tm could not create account")
async def _list_messages(self) -> list[dict[str, Any]]:
if not self._token:
return []
status, payload = await self._request(
"GET",
"/messages",
token=self._token,
)
if status == 401 and self._address and self._password:
token = await self._create_token(self._address, self._password)
if token:
self._token = token
status, payload = await self._request(
"GET",
"/messages",
token=self._token,
)
if status != 200 or not isinstance(payload, dict):
return []
members = payload.get("hydra:member")
if not isinstance(members, list):
return []
return [item for item in members if isinstance(item, dict)]
async def _get_message_text(self, message_id: str) -> str | None:
if not self._token:
return None
status, payload = await self._request(
"GET",
f"/messages/{message_id}",
token=self._token,
)
if status != 200 or not isinstance(payload, dict):
return None
parts = [
payload.get("subject"),
payload.get("intro"),
payload.get("text"),
payload.get("html"),
]
text = "\n".join(str(part) for part in parts if part)
return text or None
async def get_latest_message(self, email: str) -> str | None:
del email
if not self._token:
raise RuntimeError("mail.tm provider is not initialized with mailbox token")
for _ in range(45):
messages = await self._list_messages()
if messages:
latest = messages[0]
message_id = latest.get("id")
if isinstance(message_id, str) and message_id:
full_message = await self._get_message_text(message_id)
if full_message:
logger.info("[mail.tm] Latest message received")
return full_message
await asyncio.sleep(2)
logger.warning("[mail.tm] No messages received within timeout")
return None

View file

@ -7,7 +7,7 @@ from typing import Callable
from playwright.async_api import BrowserContext
from email_providers import BaseProvider
from email_providers import TempMailOrgProvider
from email_providers import MailTmProvider
from providers.base import Provider, ProviderTokens
from .tokens import (
clear_next_tokens,
@ -34,7 +34,7 @@ class ChatGPTProvider(Provider):
self,
email_provider_factory: Callable[[BrowserContext], BaseProvider] | None = None,
):
self.email_provider_factory = email_provider_factory or TempMailOrgProvider
self.email_provider_factory = email_provider_factory or MailTmProvider
self._token_write_lock = asyncio.Lock()
async def _register_with_retries(self) -> bool:

View file

@ -281,7 +281,34 @@ async def click_continue(page: Page, timeout_ms: int = 10000):
await btn.click()
async def click_any_visible_button(
async def oauth_needs_email_check(page: Page) -> bool:
marker = page.get_by_text("Check your inbox", exact=False)
return await marker.count() > 0
async def fill_oauth_code_if_present(page: Page, code: str) -> bool:
candidates = [
page.get_by_placeholder("Code"),
page.get_by_label("Code"),
page.locator(
'input[name*="code" i], input[id*="code" i], '
'input[autocomplete="one-time-code"], input[inputmode="numeric"]'
),
]
for locator in candidates:
if await locator.count() == 0:
continue
try:
await locator.first.wait_for(state="visible", timeout=1500)
await locator.first.fill(code)
return True
except PlaywrightError:
continue
return False
async def click_first_visible_button(
page: Page,
labels: list[str],
timeout_ms: int = 2000,
@ -415,44 +442,67 @@ async def register_chatgpt_account(
oauth_page.on("request", handle_request)
await oauth_page.goto(authorize_url, wait_until="domcontentloaded")
await oauth_page.locator(
'input[type="email"], input[name="email"]'
).first.wait_for(state="visible", timeout=20000)
email_input = oauth_page.locator('input[type="email"], input[name="email"]')
if await email_input.count() > 0:
await email_input.first.wait_for(state="visible", timeout=10000)
await email_input.first.fill(email)
await click_any_visible_button(
oauth_page, ["Continue"], timeout_ms=4000
continue_button = oauth_page.get_by_role("button", name="Continue")
if await continue_button.count() > 0:
await continue_button.first.click()
await oauth_page.locator('input[type="password"]').first.wait_for(
state="visible", timeout=20000
)
password_input = oauth_page.locator('input[type="password"]')
if await password_input.count() > 0:
await password_input.first.wait_for(state="visible", timeout=10000)
await password_input.first.fill(password)
await click_any_visible_button(
oauth_page, ["Continue"], timeout_ms=4000
)
continue_button = oauth_page.get_by_role("button", name="Continue")
if await continue_button.count() > 0:
await continue_button.first.click()
for _ in range(6):
last_oauth_email_code = code
oauth_deadline = asyncio.get_running_loop().time() + 60
while asyncio.get_running_loop().time() < oauth_deadline:
if redirect_url_captured:
break
clicked = await click_any_visible_button(
oauth_page,
["Continue", "Allow", "Authorize"],
timeout_ms=2000,
)
if clicked:
await asyncio.sleep(0.4)
else:
await asyncio.sleep(0.4)
if not redirect_url_captured:
if await oauth_needs_email_check(oauth_page):
logger.info("OAuth requested email confirmation code")
new_code = await get_latest_code(email_provider, email)
if new_code and new_code != last_oauth_email_code:
filled = await fill_oauth_code_if_present(oauth_page, new_code)
if filled:
last_oauth_email_code = new_code
logger.info("Filled OAuth email confirmation code")
else:
logger.warning(
"OAuth inbox challenge detected but code field not found"
)
try:
current_url = oauth_page.url
if "localhost:1455" in current_url and "code=" in current_url:
redirect_url_captured = current_url
logger.info("Captured OAuth redirect from page URL")
except PlaywrightError:
break
except Exception:
pass
clicked = await click_first_visible_button(
oauth_page,
["Continue", "Allow", "Authorize", "Verify"],
timeout_ms=2000,
)
if clicked:
await oauth_page.wait_for_timeout(500)
else:
await oauth_page.wait_for_timeout(1000)
if not redirect_url_captured:
raise AutomationError(
"oauth", "OAuth redirect with code was not captured", oauth_page

68
uv.lock generated
View file

@ -83,6 +83,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
@ -158,6 +167,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "megapt"
version = "0.1.0"
@ -168,12 +186,19 @@ dependencies = [
{ name = "playwright" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = "==3.13.3" },
{ name = "pkce", specifier = "==1.0.3" },
{ name = "playwright", specifier = "==1.58.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
]
provides-extras = ["dev"]
[[package]]
name = "multidict"
@ -220,6 +245,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pkce"
version = "1.0.3"
@ -248,6 +282,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "propcache"
version = "0.4.1"
@ -299,6 +342,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"