141 lines
5.3 KiB
Python
Executable File
141 lines
5.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
muse-auth.py: Muse.ai authentication state detection and flows.
|
|
|
|
Handles the tricky parts of automation:
|
|
- Sign IN (existing account) vs Sign UP (new account) have DIFFERENT verification flows
|
|
- Meta account selection has UI quirks (+1 for extra accounts)
|
|
- Login can get stuck in incomplete states that need detection and restart
|
|
|
|
STATES (detectable via CDP):
|
|
LANDING: Title == "muse.ai", not logged in
|
|
ACCOUNT_SELECTION: Body contains "Your email matches multiple accounts"
|
|
UI QUIRK: Meta shows "+1" for extra accounts. The "+1" option is NOT
|
|
the right one — find the actual account name (e.g., "Nico Parada",
|
|
not "auxfate"). The +1 is a collapsed list, not a selectable account.
|
|
OTP_PROMPT: Body contains "To log in, enter the code"
|
|
LOGGED_OUT: Body contains "Log in" button
|
|
NOT_IN_CHAT: API messages times out (no chat UI to read)
|
|
LOGGED_IN: Title contains "Chat —" or "Muse —", body has "Main chat"/"Side chats",
|
|
message input present, API returns actual content.
|
|
|
|
SIGN IN vs SIGN UP:
|
|
Sign IN (existing account):
|
|
1. Enter email/phone
|
|
2. OTP prompt (code sent to email/phone)
|
|
3. Meta account selection (if multiple accounts match)
|
|
4. Chat UI
|
|
|
|
Sign UP (new account):
|
|
1. Enter email/phone
|
|
2. OTP prompt (code sent to email/phone)
|
|
3. **DIFFERENT:** May require additional verification:
|
|
- CAPTCHA
|
|
- Phone verification (if email used)
|
|
- Email verification (if phone used)
|
|
- Terms acceptance
|
|
- Profile setup (name, etc.)
|
|
4. **DIFFERENT:** May NOT have Meta account selection (new account = no existing Meta link)
|
|
5. Chat UI (possibly with onboarding tour)
|
|
|
|
The sign-up flow is less predictable and may require human intervention
|
|
for CAPTCHAs and other anti-bot measures. Sign-in is more automatable.
|
|
|
|
RESTART LOGIC:
|
|
If state != LOGGED_IN, restart from scratch:
|
|
- Clear cookies/session
|
|
- Navigate to muse.ai
|
|
- Begin sign-in flow
|
|
- Don't try to resume a stuck flow (OTP codes expire, sessions timeout)
|
|
"""
|
|
|
|
import json
|
|
import urllib.request
|
|
import websocket
|
|
import time
|
|
|
|
def get_state(cdp_url):
|
|
"""
|
|
Detect the current authentication state via CDP.
|
|
Returns: (state, details)
|
|
"""
|
|
try:
|
|
with urllib.request.urlopen(cdp_url, timeout=5) as r:
|
|
ts = json.load(r)
|
|
pages = [t for t in ts if t.get("type") == "page"]
|
|
if not pages:
|
|
return ("NO_PAGE", "No browser page found")
|
|
p = pages[0]
|
|
ws = websocket.create_connection(p["webSocketDebuggerUrl"], timeout=10)
|
|
except Exception as e:
|
|
return ("CDP_ERROR", str(e))
|
|
|
|
def ev(expr):
|
|
try:
|
|
ws.send(json.dumps({
|
|
"id": 1, "method": "Runtime.evaluate",
|
|
"params": {"expression": expr, "returnByValue": True}
|
|
}))
|
|
r = json.loads(ws.recv())
|
|
return r["result"]["result"].get("value", "")
|
|
except:
|
|
return ""
|
|
|
|
title = ev("document.title") or ""
|
|
body = ev("document.body.innerText.slice(0,2000)") or ""
|
|
ws.close()
|
|
|
|
# Check states in order of specificity
|
|
if "Your email matches multiple accounts" in body:
|
|
return ("ACCOUNT_SELECTION", "Meta account selection - watch for +1 quirk")
|
|
if "To log in, enter the code" in body:
|
|
return ("OTP_PROMPT", "Waiting for OTP code")
|
|
if "Main chat" in body and "Side chats" in body:
|
|
return ("LOGGED_IN", "In chat UI")
|
|
if title.strip() == "muse.ai":
|
|
return ("LANDING", "On landing page, not logged in")
|
|
if "Log in" in body:
|
|
return ("LOGGED_OUT", "Logged out or not started")
|
|
|
|
return ("UNKNOWN", f"Title: {title[:50]}")
|
|
|
|
def is_logged_in(cdp_url):
|
|
"""Quick check: are we in the chat?"""
|
|
state, _ = get_state(cdp_url)
|
|
return state == "LOGGED_IN"
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
cdp = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:9440/json/list"
|
|
state, details = get_state(cdp)
|
|
print(f"State: {state}")
|
|
print(f"Details: {details}")
|
|
|
|
# Holdings logging: record what the Chromebox holds for each node
|
|
# Logs to ~/Projects/NetVM/logs/auth-holdings.log
|
|
# Format: timestamp | node | state | email_hint | account_name | notes
|
|
# This lets us know which Proton address was used without asking the user again.
|
|
|
|
import os
|
|
from datetime import datetime
|
|
|
|
def log_holdings(node, state, email_hint="", account_name="", notes=""):
|
|
"""Log the Chromebox holdings for a node. Call after auth state detection."""
|
|
log_dir = os.path.expanduser("~/Projects/NetVM/logs")
|
|
os.makedirs(log_dir, exist_ok=True)
|
|
log_file = os.path.join(log_dir, "auth-holdings.log")
|
|
ts = datetime.now().isoformat()
|
|
# Never log full email or secrets — use hint (e.g., domain or first char)
|
|
line = f"{ts} | {node} | {state} | {email_hint} | {account_name} | {notes}\n"
|
|
with open(log_file, "a") as f:
|
|
f.write(line)
|
|
|
|
def get_holdings(node):
|
|
"""Get last known holdings for a node."""
|
|
log_file = os.path.expanduser("~/Projects/NetVM/logs/auth-holdings.log")
|
|
if not os.path.exists(log_file):
|
|
return None
|
|
with open(log_file) as f:
|
|
lines = [l.strip() for l in f if l.strip() and f"| {node} |" in l]
|
|
return lines[-1] if lines else None
|