#!/usr/bin/env python3 """chat-state-check.py — one-shot CDP chat-state probe for a single node. Runs INSIDE the node's netns (CDP listens on 127.0.0.1 there). Usage: chat-state-check.py [name] modes: main | list | sidechat Prints one JSON object to stdout. Honest limits (see CHATSTATE_SPEC.md): only the DOM-visible message window is captured (React virtualization); author attribution is best-effort and may be null; checked_at is the report time, not per-message times. """ import base64 import json import sys import time import urllib.request import websocket MSG_N = 10 MSG_WIDTH = 500 def connect(port): with urllib.request.urlopen( "http://127.0.0.1:%s/json/list" % port, timeout=5) as r: targets = json.load(r) pages = [t for t in targets if t.get("type") == "page"] if not pages: return None return websocket.create_connection( pages[0]["webSocketDebuggerUrl"], timeout=15) def ev1(ws, expr, await_p=False, reads=30): """Runtime.evaluate that skips CDP event chatter while awaiting ours.""" ws.send(json.dumps({ "id": 1, "method": "Runtime.evaluate", "params": {"expression": expr, "returnByValue": True, "awaitPromise": await_p}})) for _ in range(reads): resp = json.loads(ws.recv()) if resp.get("id") != 1: continue if "error" in resp: raise RuntimeError("CDP evaluate failed: %s" % resp["error"]) res = resp.get("result", {}).get("result", {}) return res.get("value") raise RuntimeError("CDP evaluate: no response") def read_current(ws): title = ev1(ws, "document.title") or "" url = ev1(ws, "window.location.href") or "" msgs = ev1(ws, """(() => { const ps = [...document.querySelectorAll('p')].slice(-%d) .map(p => (p.innerText||'').slice(0,%d)).filter(t => t.trim()); return ps; })()""" % (MSG_N, MSG_WIDTH)) or [] return {"title": title, "url": url, "messages": [{"text": t} for t in msgs]} def _wait_for(ws, expr, timeout_s=12): """Poll a JS truthiness expression until true or timeout.""" deadline = time.time() + timeout_s while time.time() < deadline: try: if ev1(ws, expr): return True except Exception: # noqa: BLE001 pass time.sleep(1) return False def ensure_sidebar_open(ws): """The toggle closes an open sidebar — only click when closed. Detected structurally via the 'New side chat' button (text matching 'Side chats' is unreliable: main-chat messages can contain the phrase). Waits for React to render the panel after opening. """ is_open = ev1(ws, """(() => { return !!document.querySelector('button[aria-label="New side chat"]'); })()""") if not is_open: ev1(ws, """(() => { const btn = [...document.querySelectorAll('button')].find(b => (b.textContent||'').includes('Open chat and side chats')); if (btn) btn.click(); })()""") _wait_for(ws, """(() => { return !!document.querySelector( 'button[aria-label="New side chat"]'); })()""") # The row list renders a beat after the panel; wait for it. _wait_for(ws, """(() => { return document.querySelectorAll( 'button[aria-label="More thread actions"]').length > 0; })()""", timeout_s=10) _SIDEBAR_JS = """(() => { const anchor = document.querySelector('button[aria-label="New side chat"]'); if (!anchor) return 'NOANCHOR'; let sec = anchor.parentElement; for (let i = 0; i < 8 && sec; i++) { if (sec.querySelectorAll( 'button[aria-label="More thread actions"]').length) break; sec = sec.parentElement; } if (!sec) return 'NOSEC'; return JSON.stringify({ok: true}); })()""" def _sidebar_section(ws): """Return True when the side-chat list section is addressable.""" raw = ev1(ws, _SIDEBAR_JS) try: return json.loads(raw or "").get("ok", False) except (ValueError, TypeError): return False _LIST_JS = """(() => { const T = (el) => (el.innerText || '').trim(); const TS = /^(\\d+[smhd]|just now|Yesterday|Today|[A-Z][a-z]{2} \\d{1,2}.*)$/; const Y = (el) => el.getBoundingClientRect().top; const h3s = [...document.querySelectorAll('h3')]; const head = h3s.find(h => h.textContent.trim() === 'Side chats'); if (!head) return '[]'; // Scroll the list to top so the section's rows sit above the next // (sticky) header; without this, rows below the fold are missed. let sc = head.parentElement; for (let i = 0; i < 8 && sc; i++) { if (sc.scrollHeight > sc.clientHeight + 10) break; sc = sc.parentElement; } if (sc) sc.scrollTop = 0; const y0 = Y(head); const isStopText = (t) => t === 'Unread updates' || t === 'Chats' || t === 'Unread chats'; let stopY = null; for (const h of h3s) { const y = Y(h); if (y > y0 + 5 && (stopY === null || y < stopY)) stopY = y; } // Non-H3 section headers ("Unread updates", ...) also bound the list. for (const el of document.querySelectorAll('*')) { if (el.children.length !== 0) continue; if (!isStopText((el.textContent || '').trim())) continue; const y = Y(el); if (y > y0 + 5 && (stopY === null || y < stopY)) stopY = y; } const names = []; for (const el of document.querySelectorAll('div')) { const cn = (el.className || '').toString(); if (cn.indexOf('nav-row') === -1) continue; const y = Y(el); if (y <= y0 + 5) continue; if (stopY !== null && y >= stopY - 5) continue; const m = T(el).match(/^([^\\n]+)\\n\\n(.+)$/); if (m && TS.test(m[2].trim()) && names.indexOf(m[1].trim()) === -1) { names.push(m[1].trim()); if (names.length >= 8) break; } } return JSON.stringify(names); })()""" def _list_once(ws): raw = ev1(ws, _LIST_JS) try: return json.loads(raw or "[]") except (ValueError, TypeError): return [] def list_sidechats(ws): ensure_sidebar_open(ws) if not _sidebar_section(ws): return [] # React renders rows progressively after navigation; poll until the # list stabilizes instead of trusting the first paint. prev = None for _ in range(4): names = _list_once(ws) if names and names == prev: return names prev = names time.sleep(2) return prev or [] def open_sidechat(ws, name): """Open the side chat by clicking its row div inside the sidebar section. Clicking the row (not a text search over the whole document) avoids hitting message text that happens to contain the chat name. """ ensure_sidebar_open(ws) b64 = base64.b64encode(name.encode()).decode() return ev1(ws, """(async () => { const nm = atob('%s'); const T = (el) => (el.innerText || '').trim(); const target = [...document.querySelectorAll('div')].find(el => { const cn = (el.className || '').toString(); if (cn.indexOf('nav-row') === -1) return false; const m = T(el).match(/^([^\\n]+)\\n\\n(.+)$/); return m && m[1].trim() === nm; }); if (!target) return 'NOTFOUND'; target.click(); await new Promise(r => setTimeout(r, 4000)); const href = window.location.href; if (href.indexOf('/thread/') === -1) return 'NOTFOUND'; return href; })()""" % b64, await_p=True) def navigate_main(ws): ws.send(json.dumps({"id": 2, "method": "Page.navigate", "params": {"url": "https://muse.ai/"}})) for _ in range(20): resp = json.loads(ws.recv()) if resp.get("id") == 2: break time.sleep(6) def main(): if len(sys.argv) < 3: print(json.dumps({"ok": False, "error": "usage"})) sys.exit(1) port, mode = sys.argv[1], sys.argv[2] name = sys.argv[3] if len(sys.argv) > 3 else "" try: ws = connect(port) except Exception as e: # noqa: BLE001 print(json.dumps({"ok": False, "error": "cdp_connect_failed", "detail": str(e)[:120]})) return if ws is None: print(json.dumps({"ok": False, "error": "no_page"})) return try: if mode == "list": print(json.dumps({"ok": True, "sidechats": list_sidechats(ws)})) elif mode == "sidechat": href = open_sidechat(ws, name) if href == "NOTFOUND": print(json.dumps({"ok": False, "error": "sidechat_not_found", "name": name})) else: cur = read_current(ws) cur.update({"ok": True, "context": "sidechat", "name": name}) print(json.dumps(cur)) else: navigate_main(ws) cur = read_current(ws) cur.update({"ok": True, "context": "main", "name": "Main chat"}) print(json.dumps(cur)) except Exception as e: # noqa: BLE001 print(json.dumps({"ok": False, "error": "probe_failed", "detail": str(e)[:200]})) finally: try: ws.close() except Exception: # noqa: BLE001 pass main()