From b5f718a8852d30ae22434dce29debd765c83c17a Mon Sep 17 00:00:00 2001 From: operator-main Date: Sun, 4 Oct 2026 01:23:22 +0000 Subject: [PATCH] muse-chat-api.py: upload subcommand (CDP file attach) Attach a file to the agent chat composer via DOM.setFileInputFiles. --dry-run stages the attachment and verifies the preview chip without sending; otherwise sends (optional --message caption) and verifies via read-back. Supports the box console upload flow (v0.2). Session: sidechat/box-console --- bin/muse-chat-api.py | 125 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/bin/muse-chat-api.py b/bin/muse-chat-api.py index 00c62c5..ef9e0e5 100755 --- a/bin/muse-chat-api.py +++ b/bin/muse-chat-api.py @@ -15,6 +15,7 @@ Usage: muse-chat-api.py --account messages [n] muse-chat-api.py --account wait [timeout] muse-chat-api.py --account approvals # check pending approvals + muse-chat-api.py --account upload [--message txt] [--dry-run] """ import json, urllib.request, websocket, time, sys, argparse, importlib.util @@ -217,11 +218,120 @@ def cmd_sidechat_list(ws): })()""") print(chats[:500]) + +def cdp_call(ws, method, params=None): + """Raw CDP method call for non-Runtime domains (DOM, Page, ...). + The ev() helper only speaks Runtime.evaluate; uploads need DOM.""" + cdp_call.counter += 1 + ws.send(json.dumps({"id": cdp_call.counter, "method": method, + "params": params or {}})) + resp = json.loads(ws.recv()) + if "error" in resp: + raise RuntimeError("CDP %s failed: %s" % (method, resp["error"])) + return resp.get("result", {}) + + +cdp_call.counter = 100 + + +def cmd_upload(ws, filepath, message=None, dry_run=False): + """Attach a file to the chat composer via CDP DOM.setFileInputFiles. + + Dry-run stages the attachment and verifies the preview chip without + sending. Otherwise sends (with optional caption) and verifies via + read-back — never trust the CDP result alone. + """ + import os + if not os.path.isfile(filepath): + print("ERROR: file not found: %s" % filepath, file=sys.stderr) + sys.exit(1) + actions = check_approvals(ws) + for dialog, trusted, action in actions: + if not trusted: + print("APPROVAL_NEEDED: %s" % dialog[:80], file=sys.stderr) + sys.exit(2) + # The file input may only render after the attach button is clicked. + node_id = 0 + for _ in range(2): + doc = cdp_call(ws, "DOM.getDocument") + root = doc["root"]["nodeId"] + q = cdp_call(ws, "DOM.querySelector", + {"nodeId": root, "selector": "input[type=file]"}) + node_id = q.get("nodeId", 0) + if node_id: + break + ev(ws, """(() => { + const b = [...document.querySelectorAll('button')].find(x => + (x.getAttribute('aria-label')||'').toLowerCase().includes('attach')); + if (b) { b.click(); return 'clicked'; } + return 'notfound'; + })()""") + time.sleep(2) + if not node_id: + print("ERROR: no file input in composer", file=sys.stderr) + sys.exit(1) + cdp_call(ws, "DOM.setFileInputFiles", + {"nodeId": node_id, "files": [os.path.abspath(filepath)]}) + time.sleep(2) + # Read-back: the attachment preview must appear. + base = os.path.basename(filepath) + preview = ev(ws, """(() => { + const needle = %s; + const hits = [...document.querySelectorAll('*')].filter(e => { + const t = e.innerText || ''; + return t.length < 300 && t.includes(needle); + }).map(e => e.innerText.slice(0, 120)); + return JSON.stringify(hits.slice(0, 5)); + })()""" % json.dumps(base)) + print("preview: %s" % preview) + try: + hits = json.loads(preview) if preview else [] + except Exception: + hits = [] + if not hits: + print("ERROR: attachment preview not visible after attach", + file=sys.stderr) + sys.exit(1) + if dry_run: + print("DRY-RUN OK: '%s' staged in composer, not sent." % base) + return + if message: + msg_esc = message.replace('\\', '\\\\').replace('`', '\\`').replace('$', '\\$') + ev(ws, """(async()=>{ + const input = document.querySelector('[contenteditable="true"]') || + document.querySelector('textarea[placeholder*="Message"]') || + [...document.querySelectorAll('div[role="textbox"]')][0]; + if (!input) return 'NOINPUT'; + input.focus(); + document.execCommand('insertText', false, `%s`); + return 'caption-set'; + })()""" % msg_esc, True) + time.sleep(1) + sent = ev(ws, """(async()=>{ + const send = [...document.querySelectorAll('button')].find(b => + b.getAttribute('aria-label') && + b.getAttribute('aria-label').toLowerCase().includes('send')); + if (send) { send.click(); return 'sent'; } + return 'NOSEND'; + })()""", True) + print("send: %s" % sent) + time.sleep(5) + recent = ev(ws, """(() => { + const ps = [...document.querySelectorAll('p')].slice(-10) + .map(p => p.innerText.slice(0,200)); + return ps.join('\\n---\\n'); + })()""") + if base in (recent or ''): + print("VERIFIED: attachment '%s' present in chat." % base) + else: + print("WARNING: attachment not found in read-back; " + "check the composer manually.") + def main(): p = argparse.ArgumentParser() p.add_argument('--account', required=True, choices=list(ACCOUNTS.keys()), help='Agent name (matches node, profile, ACCOUNTS.md)') - p.add_argument('command', choices=['send', 'messages', 'wait', 'approvals', 'sidechat']) + p.add_argument('command', choices=['send', 'messages', 'wait', 'approvals', 'sidechat', 'upload']) p.add_argument('arg', nargs='*', default=[]) args = p.parse_args() @@ -260,6 +370,19 @@ def main(): else: print(f"ERROR: unknown sidechat subcommand: {sub}", file=sys.stderr) sys.exit(1) + elif args.command == 'upload': + if not args.arg: + print("ERROR: upload requires a filepath", file=sys.stderr) + sys.exit(1) + fpath = args.arg[0] + rest = args.arg[1:] + dry = '--dry-run' in rest + msg = None + if '--message' in rest: + i = rest.index('--message') + if i + 1 < len(rest): + msg = rest[i + 1] + cmd_upload(ws, fpath, message=msg, dry_run=dry) finally: ws.close()