Files

144 lines
5.4 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
Robust sidechat management for muse-chat-api.py.
Provides:
- ensure_sidebar(ws): Opens sidebar if closed, with retry
- wait_for_chat_list(ws, name=None): Polls until sidebar list content loads
- list_sidechats(ws): Returns list of side chat names, with retry
- All operations logged for audit
The sidebar UI is flaky — sometimes the open button isn't found,
sometimes content takes >2s to load. This module handles that.
"""
import time
import json
def _ev(ws, js, await_result=True):
"""Evaluate JS via CDP, return result.
Matches muse-chat-api.py's ev() exactly (id=1, returnByValue).
Fixed 2026-10-04: was using id=100, missing returnByValue.
"""
import json as _json
ws.send(_json.dumps({
"id": 1, "method": "Runtime.evaluate",
"params": {"expression": js, "returnByValue": True, "awaitPromise": await_result}
}))
resp = _json.loads(ws.recv())
result = resp.get("result", {}).get("result", {})
return result.get("value")
def ensure_sidebar(ws, max_retries=3):
"""
Ensure the chat panel is open. Returns True if open, False otherwise.
Checks for + button presence (not text - avoids false-positive on DM text
containing 'Side chats').
Fixed 2026-10-04: was checking innerText.includes('Side chats') which matched
DM message text, not the UI. Now checks for [data-testid="hatch-chat-compose"].
"""
for attempt in range(max_retries):
# Check if panel is open via + button presence (reliable)
is_open = _ev(ws, """(() => {
return !!document.querySelector('[data-testid="hatch-chat-compose"]');
})()""")
if is_open:
return True
# Open via switcher trigger (idempotent)
_ev(ws, """(() => {
const btn = document.querySelector('[data-testid="hatch-chat-switcher-trigger"]');
if (btn) btn.click();
return !!btn;
})()""")
time.sleep(2 * (attempt + 1)) # Exponential backoff: 2s, 4s, 6s
# Final check
return _ev(ws, """(() => {
return !!document.querySelector('[data-testid="hatch-chat-compose"]');
})()""")
def wait_for_chat_list(ws, name=None, timeout_s=15):
"""Poll until the sidebar chat list content has loaded (and optionally
contains `name`, case-insensitive substring match).
Readiness requires at least one plausible chat-title line under the
"Side chats" header -- the header itself renders before items populate,
and the compose button that ensure_sidebar() keys on renders earlier
still. Clicking a chat title before the list is ready is a known
nav-miss contributor (the click lands nowhere and the SPA stays on /).
Per-poll CDP errors are treated as not-ready (browser may be mid-render
or mid-flap). Returns True when ready, False on timeout. Note: an
account with genuinely zero sidechats will always time out here.
"""
import re as _re
want = (name or "").strip().lower()
deadline = time.time() + timeout_s
while time.time() < deadline:
try:
text = _ev(ws, "document.body.innerText") or ""
except Exception:
text = ""
idx = text.find("Side chats")
if idx != -1:
section = text[idx:idx + 2000].lower()
lines = [l.strip() for l in section.split("\n")]
titles = [l for l in lines[1:21]
if 5 < len(l) < 80
and not _re.fullmatch(r"\d+[mh]", l)
and l != "unread updates"]
if titles and (not want or want in section):
return True
time.sleep(1)
return False
def list_sidechats(ws):
"""
List side chat names. Returns list of strings.
Ensures sidebar is open first, then waits for the list content to
populate before scraping (the open signal fires before content loads).
"""
if not ensure_sidebar(ws):
return []
if not wait_for_chat_list(ws):
return []
result = _ev(ws, """(() => {
const text = document.body.innerText;
const idx = text.indexOf('Side chats');
if (idx === -1) return JSON.stringify([]);
// Extract chat names (heuristic: lines after "Side chats" that look like titles)
const section = text.slice(idx, idx + 2000);
const lines = section.split('\\n').map(l => l.trim()).filter(l => l.length > 0);
// Skip header, collect likely chat titles (skip timestamps like "1m", "2m")
const chats = [];
for (let i = 1; i < lines.length && chats.length < 20; i++) {
const line = lines[i];
if (/^\\d+[mh]$/.test(line)) continue; // timestamp
if (line === 'Unread updates') continue;
if (line.length > 5 && line.length < 80) chats.push(line);
}
return JSON.stringify(chats);
})()""")
try:
return json.loads(result) if result else []
except:
return []
def log_sidechat_op(agent, operation, details=""):
"""Log sidechat operations for audit."""
import os
from datetime import datetime, timezone
log_file = "/home/super/Projects/NetVM/sidechat-log.jsonl"
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"agent": agent,
"op": operation,
"details": details[:200]
}
try:
with open(log_file, "a") as f:
f.write(json.dumps(entry) + "\n")
except:
pass