Files

246 lines
16 KiB
Python

#!/usr/bin/env python3
"""Continue OpenAI add-phone verification with 5sim until SMS succeeds.
Designed for codex-oauth-plus-onboarding runs on BOSS infra.
Reads secrets from ~/.hermes/env/codex_oauth_onboarding.env and never prints them.
Emits compact JSONL and tracks cumulative phone-number usage.
Environment knobs:
CDP_PORT=9226
START_USED_NUMBERS=57
MAX_NEW_NUMBERS=300 # 0 means unlimited; keep a guard unless BOSS explicitly approves
PHONE_POLL_SECONDS=75
PER_COUNTRY_ROUND=3
"""
import json, urllib.request, urllib.error, websocket, itertools, time, pathlib, re, shlex, sys, os
CDP_PORT=os.environ.get('CDP_PORT','9226')
CDP=f'http://127.0.0.1:{CDP_PORT}'
POLL_SECONDS=int(os.environ.get('PHONE_POLL_SECONDS','75'))
START_USED=int(os.environ.get('START_USED_NUMBERS','0'))
MAX_NEW_NUMBERS=int(os.environ.get('MAX_NEW_NUMBERS','300'))
PER_COUNTRY_ROUND=int(os.environ.get('PER_COUNTRY_ROUND','3'))
COUNTRIES=[
('argentina','AR','阿根廷','54'), ('netherlands','NL','荷兰','31'), ('germany','DE','德国','49'),
('indonesia','ID','印度尼西亚','62'), ('vietnam','VN','越南','84'), ('italy','IT','意大利','39'),
('poland','PL','波兰','48'), ('spain','ES','西班牙','34'), ('england','GB','英国','44'),
('france','FR','法国','33'), ('malaysia','MY','马来西亚','60'), ('thailand','TH','泰国','66'),
('philippines','PH','菲律宾','63'), ('colombia','CO','哥伦比亚','57'), ('mexico','MX','墨西哥','52'),
('brazil','BR','巴西','55'), ('peru','PE','秘鲁','51'), ('turkey','TR','土耳其','90'),
('kazakhstan','KZ','哈萨克斯坦','7'), ('chile','CL','智利','56'), ('portugal','PT','葡萄牙','351'),
('romania','RO','罗马尼亚','40'),
]
FIVESIM_TO_LUBAN_COUNTRY={
'argentina':'Argentina','netherlands':'Netherlands','germany':'Germany','indonesia':'Indonesia','vietnam':'Vietnam',
'italy':'Italy','poland':'Poland','spain':'Spain','england':'United Kingdom/England','france':'France',
'malaysia':'Malaysia','thailand':'Thailand','philippines':'Philippines','colombia':'Colombia','mexico':'Mexico',
'brazil':'Brazil','peru':'Peru','turkey':'Turkey','kazakhstan':'Kazakhstan','chile':'Chile','portugal':'Portugal','romania':'Romania'
}
ENV=pathlib.Path.home()/'.hermes/env/codex_oauth_onboarding.env'; env={}
for line in ENV.read_text().splitlines():
line=line.strip()
if not line or line.startswith('#') or '=' not in line: continue
if line.startswith('export '): line=line[7:].strip()
k,v=line.split('=',1); k=k.strip(); v=v.strip()
if re.match(r'^[A-Za-z_][A-Za-z0-9_]*$',k):
try:
if v and v[0] in '"\'': v=shlex.split(v)[0]
except Exception: pass
env[k]=v.strip('"\'')
PHONE_SMS_PROVIDER=os.environ.get('PHONE_SMS_PROVIDER') or env.get('PHONE_SMS_PROVIDER','5sim').lower()
FIVE_SIM_KEY=env.get('FIVE_SIM_API_KEY')
LUBAN_KEY=env.get('LUBAN_SMS_API_KEY') or env.get('LUBAN_API_KEY')
LUBAN_BASE=(env.get('LUBAN_SMS_API_BASE') or 'https://lubansms.com/v2/api').rstrip('/')
LUBAN_SERVICE_QUERY=env.get('LUBAN_SMS_SERVICE_QUERY','OpenAI')
LUBAN_LANGUAGE=env.get('LUBAN_SMS_LANGUAGE','en')
if PHONE_SMS_PROVIDER == 'luban' and not LUBAN_KEY:
raise SystemExit('PHONE_SMS_PROVIDER=luban but LUBAN_SMS_API_KEY missing')
if PHONE_SMS_PROVIDER != 'luban' and not FIVE_SIM_KEY:
raise SystemExit('FIVE_SIM_API_KEY missing')
STATE_FILE=pathlib.Path('/tmp/phone_verify_until_success_state.json')
def log(obj): obj.setdefault('ts', int(time.time())); print(json.dumps(obj,ensure_ascii=False), flush=True)
def save_state(**kw):
cur={}
if STATE_FILE.exists():
try: cur=json.loads(STATE_FILE.read_text())
except Exception: cur={}
cur.update(kw); STATE_FILE.write_text(json.dumps(cur,ensure_ascii=False,indent=2))
def strict_code(text):
m=re.search(r'(?<!\\d)(\\d{6})(?!\\d)', text or '')
return m.group(1) if m else None
def http_json(url, headers=None, timeout=30):
req=urllib.request.Request(url,headers=headers or {'Accept':'application/json'})
try:
with urllib.request.urlopen(req,timeout=timeout) as r: txt=r.read().decode(); st=r.status
except urllib.error.HTTPError as e: txt=e.read().decode(errors='ignore'); st=e.code
except Exception as e: return 0, {'error':type(e).__name__+': '+str(e)}
try: data=json.loads(txt)
except Exception: data={'raw':txt[:300]}
return st,data
def fivesim(path):
return http_json('https://5sim.net/v1'+path, headers={'Authorization':'Bearer '+FIVE_SIM_KEY,'Accept':'application/json'})
def luban(endpoint, **params):
params={k:v for k,v in params.items() if v is not None}; params['apikey']=LUBAN_KEY
url=LUBAN_BASE+'/'+endpoint.lstrip('/')+'?'+urllib.parse.urlencode(params)
return http_json(url, headers={'Accept':'application/json','User-Agent':'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'})
def luban_services(country=None, service=None, page=1):
return luban('List', country=country, service=service or LUBAN_SERVICE_QUERY, language=LUBAN_LANGUAGE, page=page)
def luban_resolve_service(country_en=None):
candidates=[]
for page in range(1,4):
st,data=luban_services(country_en, LUBAN_SERVICE_QUERY, page)
if st==200 and data.get('code')==0 and isinstance(data.get('msg'),list):
for r in data['msg']:
if not isinstance(r,dict) or not r.get('service_id'): continue
name=str(r.get('service_name') or '')
ctry=str(r.get('country_name_en') or '')
if LUBAN_SERVICE_QUERY.lower() in name.lower() or 'openai' in name.lower() or 'chatgpt' in name.lower():
candidates.append(r)
if not candidates: return None
def cost(r):
try: return float(r.get('cost'))
except Exception: return 999999.0
return sorted(candidates,key=cost)[0]
def luban_buy(country_en=None):
svc=luban_resolve_service(country_en)
if not svc: return 200, {'code':400,'msg':'no_luban_service'}
st,data=luban('getNumber', service_id=svc['service_id'])
if isinstance(data,dict): data['_service']=svc
return st,data
def sms_buy(country):
if PHONE_SMS_PROVIDER == 'luban': return luban_buy(FIVESIM_TO_LUBAN_COUNTRY.get(country,country))
return fivesim(f'/user/buy/activation/{country}/any/openai')
def sms_check(act):
if PHONE_SMS_PROVIDER == 'luban': return luban('getSms', request_id=act)
return fivesim(f'/user/check/{act}')
def sms_cancel(act):
if PHONE_SMS_PROVIDER == 'luban': return luban('setStatus', request_id=act, status='reject')
return fivesim(f'/user/cancel/{act}')
def sms_finish(act):
if PHONE_SMS_PROVIDER == 'luban': return 200, {'code':0,'msg':'success'}
return fivesim(f'/user/finish/{act}')
def cdp():
tabs=json.load(urllib.request.urlopen(CDP+'/json/list', timeout=10))
pages=[t for t in tabs if t.get('type')=='page' and 'auth.openai.com' in t.get('url','')] or [t for t in tabs if t.get('type')=='page']
page=pages[0]
ws=websocket.create_connection(page['webSocketDebuggerUrl'], timeout=10, suppress_origin=True); c=itertools.count(1)
def call(m,p=None,timeout=30):
i=next(c); ws.send(json.dumps({'id':i,'method':m,'params':p or {}})); end=time.time()+timeout
while time.time()<end:
msg=json.loads(ws.recv())
if msg.get('id')==i:
if 'error' in msg: raise RuntimeError(msg['error'])
return msg.get('result')
raise TimeoutError(m)
def ev(e,timeout=90): return call('Runtime.evaluate',{'expression':e,'returnByValue':True,'awaitPromise':True},timeout).get('result',{})
call('Runtime.enable'); return ws,call,ev
def navigate(url, wait=6):
ws,call,ev=cdp()
try: call('Page.enable'); call('Page.navigate',{'url':url}); time.sleep(wait)
finally:
try: ws.close()
except Exception: pass
def page_state():
ws,call,ev=cdp()
try: return ev("({href:location.href,title:document.title,body:(document.body.innerText||'').slice(0,1500)})").get('value')
finally:
try: ws.close()
except Exception: pass
def ensure_add_phone():
st=page_state() or {}; href=st.get('href',''); body=st.get('body','')
if 'account_deactivated' in body.lower() or 'deactivated' in body.lower(): log({'phase':'account_deactivated_stop','href':href}); sys.exit(4)
if 'add-phone' in href: return True
navigate('https://auth.openai.com/add-phone', 8)
st=page_state() or {}; ok='add-phone' in st.get('href','')
if not ok: log({'phase':'recover_add_phone_fail','state':st})
return ok
def select_country(iso, cname):
if not ensure_add_phone(): return False, {'error':'not_add_phone'}
ws,call,ev=cdp()
try:
gate=ev(f"""(async()=>{{const sel=document.querySelector('select'); if(sel){{sel.value={json.dumps(iso)}; sel.dispatchEvent(new Event('input',{{bubbles:true}})); sel.dispatchEvent(new Event('change',{{bubbles:true}})); await new Promise(r=>setTimeout(r,900));}} const tel=document.querySelector('input[type=tel]'); if(tel) tel.focus(); return {{href:location.href,countryValue:sel?.value,countryText:sel?.options?.[sel.selectedIndex]?.text,body:(document.body.innerText||'').slice(0,1200)}};}})()""").get('value')
for typ,key,code,wvc in [('keyDown','a','KeyA',65),('keyUp','a','KeyA',65),('keyDown','Backspace','Backspace',8),('keyUp','Backspace','Backspace',8)]:
call('Input.dispatchKeyEvent',{'type':typ,'key':key,'code':code,'windowsVirtualKeyCode':wvc,'nativeVirtualKeyCode':wvc,'modifiers':2 if key=='a' else 0})
finally:
try: ws.close()
except Exception: pass
ok=gate and 'add-phone' in gate.get('href','') and gate.get('countryValue')==iso and cname in (gate.get('body','')+gate.get('countryText',''))
return ok,gate
def submit_phone(iso,phone):
digits=re.sub(r'\D','',str(phone)); dial=next((d for _,i,_,d in COUNTRIES if i==iso),''); local=digits
if dial and local.startswith(dial): local=local[len(dial):]
if local.startswith('0'): local=local[1:]
ws,call,ev=cdp()
try:
ev("document.querySelector('input[type=tel]')?.focus()"); call('Input.insertText',{'text':local}); time.sleep(.2)
return ev("""(async()=>{const sel=document.querySelector('select'); const tel=document.querySelector('input[type=tel]'); const btn=[...document.querySelectorAll('button')].find(b=>/继续|Continue|Next/.test(b.innerText)); const before={countryValue:sel?.value,countryText:sel?.options?.[sel.selectedIndex]?.text,tel_len:tel?.value?.length}; btn?.click(); await new Promise(r=>setTimeout(r,8500)); return {before,href:location.href,title:document.title,body:(document.body.innerText||'').slice(0,2200)};})()""",140).get('value')
finally:
try: ws.close()
except Exception: pass
def classify_submit(res):
body=res.get('body','') if res else ''; href=res.get('href','') if res else ''
rejected=any(x in body for x in ['无法向此电话号码发送验证码','电话号码无效']) or any(x in body.lower() for x in ['invalid phone','unable to send','try again later'])
sms_page=('phone-verification' in href) or ('验证码' in body and '重新发送' in body and not rejected)
deact='account_deactivated' in body.lower() or 'deactivated' in body.lower()
return rejected,sms_page,deact
def poll_sms(act):
end=time.time()+POLL_SECONDS; last={}
while time.time()<end:
st,data=sms_check(act); last=data if isinstance(data,dict) else {}
if PHONE_SMS_PROVIDER == 'luban':
msg=str(last.get('msg','')).lower()
if msg == 'success' and last.get('sms_code'):
return str(last.get('sms_code')), last
code=strict_code(json.dumps(last,ensure_ascii=False))
if code: return code,last
sms=last.get('sms') or []
if sms:
text=' '.join(str(x) for item in sms for x in (item.values() if isinstance(item,dict) else [item]))
m=re.search(r'(?<!\d)(\d{4,8})(?!\d)', text); return m.group(1) if m else 'SMS_PRESENT_NO_CODE', last
time.sleep(5)
return None,last
def submit_code(code):
ws,call,ev=cdp()
try:
ev("document.querySelector('input[name=code],input[autocomplete=one-time-code],input[type=text]')?.focus()"); call('Input.insertText',{'text':code}); time.sleep(.2)
return ev("""(async()=>{const btn=[...document.querySelectorAll('button')].find(b=>/继续|Continue|Next|验证|Verify/.test(b.innerText)); btn?.click(); await new Promise(r=>setTimeout(r,12000)); return {href:location.href,title:document.title,body:(document.body.innerText||'').slice(0,2000)};})()""",150).get('value')
finally:
try: ws.close()
except Exception: pass
new_total=0; rejected_total=0; sms0_total=0; nofree_count=0; country_counts={c:0 for c,_,_,_ in COUNTRIES}; round_no=0
log({'phase':'until_success_start','start_used_numbers':START_USED,'max_new_numbers':MAX_NEW_NUMBERS or 'unlimited','poll_seconds':POLL_SECONDS})
while True:
round_no += 1
for country,iso,cname,dial in COUNTRIES:
for _ in range(PER_COUNTRY_ROUND):
if MAX_NEW_NUMBERS and new_total >= MAX_NEW_NUMBERS:
log({'phase':'safety_max_reached_no_success','start_used_numbers':START_USED,'new_numbers':new_total,'total_numbers':START_USED+new_total,'rejected_total':rejected_total,'sms0_total':sms0_total,'nofree_count':nofree_count,'counts':country_counts}); sys.exit(3)
ok,gate=select_country(iso,cname)
if not ok: log({'phase':'country_gate_fail','country':country,'iso':iso,'gate':gate}); break
st,data=sms_buy(country)
act=(data.get('request_id') or data.get('id')) if isinstance(data,dict) else None; phone=(data.get('number') or data.get('phone')) if isinstance(data,dict) else None
if not(act and phone):
nofree_count+=1; body=(data.get('raw') or data.get('error') or str(data))[:120] if isinstance(data,dict) else str(data)[:120]
log({'phase':'buy_no_activation','country':country,'http':st,'body':body,'nofree_count':nofree_count}); break
new_total+=1; country_counts[country]+=1; save_state(current_activation_id=act,new_numbers=new_total,total_numbers=START_USED+new_total,country=country); pathlib.Path('/tmp/current_fivesim_activation_id.txt').write_text(str(act))
log({'phase':'activation_bought','new_numbers':new_total,'total_numbers':START_USED+new_total,'country':country,'activation_id':act})
try: res=submit_phone(iso,phone)
except Exception as e:
st2,_=sms_cancel(act); log({'phase':'submit_exception_cancel','activation_id':act,'error':type(e).__name__,'http':st2,'ok':200<=st2<300}); navigate('https://auth.openai.com/add-phone',6); continue
rejected,sms_page,deact=classify_submit(res)
log({'phase':'phone_submitted','new_numbers':new_total,'total_numbers':START_USED+new_total,'country':country,'activation_id':act,'href':res.get('href') if res else None,'rejected':rejected,'sms_page':sms_page,'deactivated':deact})
if deact: sms_cancel(act); log({'phase':'account_deactivated_stop','activation_id':act,'total_numbers':START_USED+new_total}); sys.exit(4)
if rejected:
rejected_total+=1; st2,_=sms_cancel(act); log({'phase':'activation_cancel_rejected','activation_id':act,'http':st2,'ok':200<=st2<300}); continue
code,sdata=poll_sms(act)
if code and code!='SMS_PRESENT_NO_CODE':
pathlib.Path('/tmp/current_phone_sms_code.txt').write_text(code); log({'phase':'sms_received','new_numbers':new_total,'total_numbers':START_USED+new_total,'country':country,'activation_id':act})
res2=submit_code(code); log({'phase':'sms_code_submitted','href':res2.get('href'),'title':res2.get('title'),'total_numbers':START_USED+new_total}); sms_finish(act); save_state(success=True,success_activation_id=act,total_numbers=START_USED+new_total,country=country,provider=PHONE_SMS_PROVIDER,href=res2.get('href')); sys.exit(0)
sms0_total+=1; st2,_=sms_cancel(act); log({'phase':'sms_timeout_cancel','new_numbers':new_total,'total_numbers':START_USED+new_total,'country':country,'activation_id':act,'http':st2,'ok':200<=st2<300}); navigate('https://auth.openai.com/add-phone',6)
log({'phase':'round_done_no_success','round':round_no,'new_numbers':new_total,'total_numbers':START_USED+new_total,'rejected_total':rejected_total,'sms0_total':sms0_total,'nofree_count':nofree_count,'counts':country_counts})