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
This commit is contained in:
+124
-1
@@ -15,6 +15,7 @@ Usage:
|
|||||||
muse-chat-api.py --account <agent> messages [n]
|
muse-chat-api.py --account <agent> messages [n]
|
||||||
muse-chat-api.py --account <agent> wait [timeout]
|
muse-chat-api.py --account <agent> wait [timeout]
|
||||||
muse-chat-api.py --account <agent> approvals # check pending approvals
|
muse-chat-api.py --account <agent> approvals # check pending approvals
|
||||||
|
muse-chat-api.py --account <agent> upload <file> [--message txt] [--dry-run]
|
||||||
"""
|
"""
|
||||||
import json, urllib.request, websocket, time, sys, argparse, importlib.util
|
import json, urllib.request, websocket, time, sys, argparse, importlib.util
|
||||||
|
|
||||||
@@ -217,11 +218,120 @@ def cmd_sidechat_list(ws):
|
|||||||
})()""")
|
})()""")
|
||||||
print(chats[:500])
|
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():
|
def main():
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
p.add_argument('--account', required=True, choices=list(ACCOUNTS.keys()),
|
p.add_argument('--account', required=True, choices=list(ACCOUNTS.keys()),
|
||||||
help='Agent name (matches node, profile, ACCOUNTS.md)')
|
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=[])
|
p.add_argument('arg', nargs='*', default=[])
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
@@ -260,6 +370,19 @@ def main():
|
|||||||
else:
|
else:
|
||||||
print(f"ERROR: unknown sidechat subcommand: {sub}", file=sys.stderr)
|
print(f"ERROR: unknown sidechat subcommand: {sub}", file=sys.stderr)
|
||||||
sys.exit(1)
|
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:
|
finally:
|
||||||
ws.close()
|
ws.close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user