browser-workspace 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRIVACY.md +21 -0
- package/README.md +138 -0
- package/SKILL.md +301 -0
- package/agent-workspace/agent_helpers.py +718 -0
- package/bin/__pycache__/browser-workspacecpython-311.pyc +0 -0
- package/bin/browser-workspace +75 -0
- package/extension/manifest.json +26 -0
- package/extension/service-worker.mjs +126 -0
- package/extension/workspace-manager.mjs +680 -0
- package/extension/workspace-page.mjs +7 -0
- package/extension/workspace.html +8 -0
- package/interaction-skills/connection.md +52 -0
- package/interaction-skills/cookies.md +3 -0
- package/interaction-skills/cross-origin-iframes.md +3 -0
- package/interaction-skills/dialogs.md +64 -0
- package/interaction-skills/downloads.md +3 -0
- package/interaction-skills/drag-and-drop.md +3 -0
- package/interaction-skills/dropdowns.md +3 -0
- package/interaction-skills/iframes.md +3 -0
- package/interaction-skills/network-requests.md +3 -0
- package/interaction-skills/print-as-pdf.md +3 -0
- package/interaction-skills/profile-sync.md +90 -0
- package/interaction-skills/screenshots.md +17 -0
- package/interaction-skills/scrolling.md +22 -0
- package/interaction-skills/shadow-dom.md +3 -0
- package/interaction-skills/tabs.md +73 -0
- package/interaction-skills/uploads.md +1 -0
- package/interaction-skills/viewport.md +16 -0
- package/licenses/browser-harness-MIT.txt +21 -0
- package/managed_chrome.py +244 -0
- package/node/admin.mjs +44 -0
- package/package.json +36 -0
- package/platform_runner.py +187 -0
- package/platforms/chatgpt/README.md +60 -0
- package/platforms/chatgpt/actions/_bootstrap_start.py +58 -0
- package/platforms/chatgpt/actions/_lifecycle_common.py +119 -0
- package/platforms/chatgpt/actions/_mcp_app.py +80 -0
- package/platforms/chatgpt/actions/_new_thread.py +85 -0
- package/platforms/chatgpt/actions/_new_turn.py +33 -0
- package/platforms/chatgpt/actions/_project_setup.py +353 -0
- package/platforms/chatgpt/actions/_readiness.py +52 -0
- package/platforms/chatgpt/actions/_submit_thread.py +129 -0
- package/platforms/chatgpt/actions/_temporary_submit.py +571 -0
- package/platforms/chatgpt/actions/_thread_turn.py +362 -0
- package/platforms/chatgpt/manifest.yaml +34 -0
- package/platforms/chatgpt/tests/test_contract.py +176 -0
- package/platforms/chrome-web-store/README.md +49 -0
- package/platforms/chrome-web-store/actions/_chrome_web_store.py +425 -0
- package/platforms/chrome-web-store/flows/check-status.md +7 -0
- package/platforms/chrome-web-store/flows/open-item.md +10 -0
- package/platforms/chrome-web-store/flows/submit-review.md +10 -0
- package/platforms/chrome-web-store/flows/update-listing.md +18 -0
- package/platforms/chrome-web-store/flows/upload-package.md +11 -0
- package/platforms/chrome-web-store/flows/verify-published.md +9 -0
- package/platforms/chrome-web-store/manifest.yaml +33 -0
- package/platforms/chrome-web-store/tests/test_contract.py +83 -0
- package/platforms/microsoft-teams/README.md +32 -0
- package/platforms/microsoft-teams/actions/_meeting.py +210 -0
- package/platforms/microsoft-teams/flows/instant-meeting.md +21 -0
- package/platforms/microsoft-teams/manifest.yaml +25 -0
- package/platforms/microsoft-teams/tests/test_contract.py +24 -0
- package/platforms/tiktok/README.md +10 -0
- package/platforms/tiktok/actions/_post.py +180 -0
- package/platforms/tiktok/manifest.yaml +99 -0
- package/platforms/wechat-channels/README.md +10 -0
- package/platforms/wechat-channels/actions/_common.py +163 -0
- package/platforms/wechat-channels/actions/_manage.py +130 -0
- package/platforms/wechat-channels/actions/_post.py +232 -0
- package/platforms/wechat-channels/manifest.yaml +118 -0
- package/platforms/wechat-channels/tests/test_contract.py +36 -0
- package/platforms/xhs/README.md +10 -0
- package/platforms/xhs/actions/_manage.py +355 -0
- package/platforms/xhs/actions/_post.py +192 -0
- package/platforms/xhs/manifest.yaml +98 -0
- package/platforms/youtube/README.md +10 -0
- package/platforms/youtube/actions/_post.py +100 -0
- package/platforms/youtube/manifest.yaml +94 -0
- package/pyproject.toml +29 -0
- package/session_client.py +97 -0
- package/session_daemon.py +325 -0
- package/src/browser_harness/__init__.py +2 -0
- package/src/browser_harness/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/_ipc.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/admin.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/auth.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/daemon.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/helpers.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/paths.cpython-311.pyc +0 -0
- package/src/browser_harness/__pycache__/recorder.cpython-311.pyc +0 -0
- package/src/browser_harness/_ipc.py +201 -0
- package/src/browser_harness/admin.py +1588 -0
- package/src/browser_harness/auth.py +546 -0
- package/src/browser_harness/daemon.py +894 -0
- package/src/browser_harness/helpers.py +668 -0
- package/src/browser_harness/macos.py +131 -0
- package/src/browser_harness/mcp_cli.py +16 -0
- package/src/browser_harness/paths.py +49 -0
- package/src/browser_harness/recorder.py +343 -0
- package/src/browser_harness/run.py +413 -0
- package/src/browser_harness/telemetry.py +308 -0
- package/src/browser_harness/video-template.html +970 -0
- package/src/browser_harness/video.py +749 -0
- package/src/browser_harness/video_render.py +524 -0
- package/src/browser_workspace.egg-info/PKG-INFO +10 -0
- package/src/browser_workspace.egg-info/SOURCES.txt +28 -0
- package/src/browser_workspace.egg-info/dependency_links.txt +1 -0
- package/src/browser_workspace.egg-info/requires.txt +5 -0
- package/src/browser_workspace.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from urllib.parse import quote, urlsplit
|
|
3
|
+
import _lifecycle_common as lifecycle_common
|
|
4
|
+
|
|
5
|
+
lifecycle_common.js = js
|
|
6
|
+
lifecycle_common.fill_input = fill_input
|
|
7
|
+
wait_for_idle = lifecycle_common.wait_for_idle
|
|
8
|
+
submit = lifecycle_common.submit
|
|
9
|
+
|
|
10
|
+
CFG=json.load(open("__CFG_PATH__",encoding="utf-8"))
|
|
11
|
+
project_id=str(CFG.get("project_id") or "").strip() or None
|
|
12
|
+
thread_id=str(CFG.get("thread_id") or "").strip()
|
|
13
|
+
message=str(CFG.get("message") or "").strip()
|
|
14
|
+
if not thread_id: raise RuntimeError("thread_id is required")
|
|
15
|
+
if not message: raise RuntimeError("message is required")
|
|
16
|
+
if project_id:
|
|
17
|
+
thread_url=f"https://chatgpt.com/g/{quote(project_id,safe='')}/c/{quote(thread_id,safe='')}"
|
|
18
|
+
else:
|
|
19
|
+
thread_url=f"https://chatgpt.com/c/{quote(thread_id,safe='')}"
|
|
20
|
+
goto_url(thread_url)
|
|
21
|
+
wait_for_load()
|
|
22
|
+
if urlsplit(page_info().get("url","")).path.rstrip("/") != urlsplit(thread_url).path.rstrip("/"):
|
|
23
|
+
raise RuntimeError("ChatGPT thread redirect mismatch")
|
|
24
|
+
wait_for_idle(timeout=int(CFG.get("wait_timeout",300)))
|
|
25
|
+
receipt=submit(message)
|
|
26
|
+
print(json.dumps({
|
|
27
|
+
"status":"submitted",
|
|
28
|
+
"mode":"new-turn",
|
|
29
|
+
"project_id":project_id,
|
|
30
|
+
"thread_id":thread_id,
|
|
31
|
+
"verified_by":receipt["verified_by"],
|
|
32
|
+
"user_message_id":receipt["turn"].get("id") or None,
|
|
33
|
+
},ensure_ascii=False),flush=True)
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import time
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
|
|
8
|
+
|
|
9
|
+
def _norm(value):
|
|
10
|
+
return re.sub(r"\s+", " ", str(value or "")).strip()
|
|
11
|
+
|
|
12
|
+
def _candidate(selector, patterns, root_expr="document"):
|
|
13
|
+
wanted=[p.lower() for p in patterns]
|
|
14
|
+
return js(f"""(() => {{
|
|
15
|
+
const root={root_expr};
|
|
16
|
+
const wanted={json.dumps(wanted)};
|
|
17
|
+
const nodes=[...root.querySelectorAll({json.dumps(selector)})];
|
|
18
|
+
const e=nodes.find(el=>{{
|
|
19
|
+
const text=((el.innerText||el.textContent||el.getAttribute('aria-label')||'')+'').replace(/\\s+/g,' ').trim().toLowerCase();
|
|
20
|
+
return wanted.some(p=>text===p||text.includes(p));
|
|
21
|
+
}});
|
|
22
|
+
if(!e) return false; e.click(); return true;
|
|
23
|
+
}})()""")
|
|
24
|
+
|
|
25
|
+
def _wait(predicate, label, timeout=15):
|
|
26
|
+
deadline=time.time()+timeout
|
|
27
|
+
last=None
|
|
28
|
+
while time.time()<deadline:
|
|
29
|
+
try:
|
|
30
|
+
last=predicate()
|
|
31
|
+
if last:
|
|
32
|
+
return last
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
time.sleep(.15)
|
|
36
|
+
raise RuntimeError(f"{label} not found")
|
|
37
|
+
|
|
38
|
+
def _project_from_url(url):
|
|
39
|
+
m=re.search(r"/g/(g-p-[A-Za-z0-9]{32})(?:-[^/]+)?(?:/|$)", url or "")
|
|
40
|
+
return m.group(1) if m else None
|
|
41
|
+
|
|
42
|
+
def _project_links():
|
|
43
|
+
return js("""(() => [...document.querySelectorAll('a[href]')].map(a=>({
|
|
44
|
+
text:(a.innerText||a.textContent||'').replace(/\s+/g,' ').trim(),
|
|
45
|
+
href:new URL(a.getAttribute('href'),location.origin).href
|
|
46
|
+
})).filter(x=>/\/g\/g-p-[A-Za-z0-9]{32}/.test(x.href)))()""") or []
|
|
47
|
+
|
|
48
|
+
def _ensure_project(name):
|
|
49
|
+
wanted=_norm(name).lower()
|
|
50
|
+
|
|
51
|
+
# ChatGPT hydrates the sidebar asynchronously after the main page reports loaded.
|
|
52
|
+
# Open it when needed, then give the requested existing Project time to appear
|
|
53
|
+
# before treating the always-earlier "Add new project" control as proof of absence.
|
|
54
|
+
def project_surface():
|
|
55
|
+
return js("""(() => {
|
|
56
|
+
const rows=[...document.querySelectorAll('[data-app-action-sidebar-project-row]')];
|
|
57
|
+
if(rows.length) return 'rows';
|
|
58
|
+
const add=[...document.querySelectorAll('button,[role="button"]')].find(e=>(e.getAttribute('aria-label')||'').toLowerCase().includes('add new project'));
|
|
59
|
+
if(add) return 'add';
|
|
60
|
+
const show=[...document.querySelectorAll('button,[role="button"]')].find(e=>(e.getAttribute('aria-label')||'').toLowerCase()==='show sidebar');
|
|
61
|
+
if(show){ show.click(); return 'opening'; }
|
|
62
|
+
return '';
|
|
63
|
+
})()""")
|
|
64
|
+
_wait(project_surface, "ChatGPT Projects sidebar", 20)
|
|
65
|
+
|
|
66
|
+
def existing_project():
|
|
67
|
+
return js(f"""(() => {{
|
|
68
|
+
const wanted={json.dumps(wanted)};
|
|
69
|
+
const rows=[...document.querySelectorAll('[data-app-action-sidebar-project-row]')];
|
|
70
|
+
const row=rows.find(e=>(e.getAttribute('data-app-action-sidebar-project-label')||'').replace(/\\s+/g,' ').trim().toLowerCase()===wanted);
|
|
71
|
+
return row ? {{
|
|
72
|
+
projectId:row.getAttribute('data-app-action-sidebar-project-id')||'',
|
|
73
|
+
label:row.getAttribute('data-app-action-sidebar-project-label')||''
|
|
74
|
+
}} : null;
|
|
75
|
+
}})()""")
|
|
76
|
+
|
|
77
|
+
existing=None
|
|
78
|
+
deadline=time.time()+10
|
|
79
|
+
while time.time()<deadline:
|
|
80
|
+
existing=existing_project()
|
|
81
|
+
if existing and existing.get("projectId"):
|
|
82
|
+
break
|
|
83
|
+
time.sleep(.2)
|
|
84
|
+
if existing and existing.get("projectId"):
|
|
85
|
+
project_url=f"https://chatgpt.com/g/{existing['projectId']}/project"
|
|
86
|
+
goto_url(project_url)
|
|
87
|
+
wait_for_load()
|
|
88
|
+
return {"project_id":existing["projectId"],"project_url":page_info().get("url",project_url),"reused":True}
|
|
89
|
+
|
|
90
|
+
# Legacy fallback for older ChatGPT Project sidebar surfaces.
|
|
91
|
+
for item in _project_links():
|
|
92
|
+
if _norm(item.get("text")).lower()==wanted:
|
|
93
|
+
pid=_project_from_url(item["href"])
|
|
94
|
+
project_url=f"https://chatgpt.com/g/{pid}/project" if pid else item["href"]
|
|
95
|
+
goto_url(project_url)
|
|
96
|
+
wait_for_load()
|
|
97
|
+
return {"project_id":pid,"project_url":page_info().get("url",project_url),"reused":True}
|
|
98
|
+
|
|
99
|
+
clicked=bool(js("""(() => {
|
|
100
|
+
const b=[...document.querySelectorAll('button')].find(b=>(b.getAttribute('aria-label')||'').toLowerCase().includes('add new project'));
|
|
101
|
+
if(!b)return false;b.click();return true;
|
|
102
|
+
})()"""))
|
|
103
|
+
if not clicked:
|
|
104
|
+
clicked=_candidate('button,a,[role="button"]',['new project','create project'])
|
|
105
|
+
if not clicked:
|
|
106
|
+
raise RuntimeError(f"ChatGPT New Project control was not found for {name}")
|
|
107
|
+
|
|
108
|
+
selector=_wait(lambda: js("""(() => {
|
|
109
|
+
const visible=e=>{const r=e.getBoundingClientRect();return !!(r.width&&r.height)};
|
|
110
|
+
const e=[...document.querySelectorAll('input[type="text"],input:not([type]),textarea')]
|
|
111
|
+
.find(e=>visible(e)&&/project name|copenhagen trip/i.test((e.getAttribute('aria-label')||'')+' '+(e.getAttribute('placeholder')||''))) ||
|
|
112
|
+
[...document.querySelectorAll('input[type="text"],input:not([type]),textarea')].find(visible);
|
|
113
|
+
if(!e)return null;
|
|
114
|
+
e.setAttribute('data-neoy-project-name','1');
|
|
115
|
+
return '[data-neoy-project-name="1"]';
|
|
116
|
+
})()"""), "ChatGPT project name field")
|
|
117
|
+
fill_input(selector,name,clear_first=True)
|
|
118
|
+
|
|
119
|
+
# New Project dialog defaults to account memory. Choose Project-only when offered.
|
|
120
|
+
js("""(() => {
|
|
121
|
+
const visible=e=>{const r=e.getBoundingClientRect();return !!(r.width&&r.height)};
|
|
122
|
+
const fire=e=>{for(const type of ['pointerdown','mousedown','pointerup','mouseup','click']) e.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,view:window,button:0,buttons:type.includes('down')?1:0}));};
|
|
123
|
+
const b=[...document.querySelectorAll('button,[role="button"]')].filter(visible).find(e=>/default memory/i.test((e.innerText||e.textContent||e.getAttribute('aria-label')||'')));
|
|
124
|
+
if(b){fire(b);return true;} return false;
|
|
125
|
+
})()""")
|
|
126
|
+
time.sleep(.15)
|
|
127
|
+
js("""(() => {
|
|
128
|
+
const fire=e=>{for(const type of ['pointerdown','mousedown','pointerup','mouseup','click']) e.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,view:window,button:0,buttons:type.includes('down')?1:0}));};
|
|
129
|
+
const nodes=[...document.querySelectorAll('[role="menuitem"],button,[role="button"],label')];
|
|
130
|
+
const e=nodes.find(e=>/project[- ]only/i.test((e.innerText||e.textContent||e.getAttribute('aria-label')||'').replace(/\s+/g,' ').trim()));
|
|
131
|
+
if(!e)return false;fire(e);return true;
|
|
132
|
+
})()""")
|
|
133
|
+
|
|
134
|
+
ok=js("""(() => {
|
|
135
|
+
const visible=e=>{const r=e.getBoundingClientRect();return !!(r.width&&r.height)};
|
|
136
|
+
const buttons=[...document.querySelectorAll('button,[role="button"]')].filter(visible);
|
|
137
|
+
const b=buttons.find(e=>{
|
|
138
|
+
const t=(e.innerText||e.textContent||e.getAttribute('aria-label')||'').replace(/\s+/g,' ').trim().toLowerCase();
|
|
139
|
+
return t==='create project'||t==='create'||t==='continue'||t==='done';
|
|
140
|
+
});
|
|
141
|
+
if(!b||b.disabled||b.getAttribute('aria-disabled')==='true')return false;b.click();return true;
|
|
142
|
+
})()""")
|
|
143
|
+
if not ok:
|
|
144
|
+
raise RuntimeError("ChatGPT Create Project button not found")
|
|
145
|
+
|
|
146
|
+
deadline=time.time()+30
|
|
147
|
+
while time.time()<deadline:
|
|
148
|
+
info=page_info()
|
|
149
|
+
pid=_project_from_url(info.get("url",""))
|
|
150
|
+
if pid:
|
|
151
|
+
project_url=f"https://chatgpt.com/g/{pid}/project"
|
|
152
|
+
if "/project" not in urlsplit(info.get("url","")).path:
|
|
153
|
+
goto_url(project_url); wait_for_load()
|
|
154
|
+
return {"project_id":pid,"project_url":page_info().get("url",project_url),"reused":False}
|
|
155
|
+
created=js(f"""(() => {{
|
|
156
|
+
const wanted={json.dumps(wanted)};
|
|
157
|
+
const rows=[...document.querySelectorAll('[data-app-action-sidebar-project-row]')];
|
|
158
|
+
const row=rows.find(e=>(e.getAttribute('data-app-action-sidebar-project-label')||'').replace(/\\s+/g,' ').trim().toLowerCase()===wanted);
|
|
159
|
+
return row ? row.getAttribute('data-app-action-sidebar-project-id')||'' : '';
|
|
160
|
+
}})()""")
|
|
161
|
+
if created:
|
|
162
|
+
project_url=f"https://chatgpt.com/g/{created}/project"
|
|
163
|
+
goto_url(project_url); wait_for_load()
|
|
164
|
+
return {"project_id":created,"project_url":page_info().get("url",project_url),"reused":False}
|
|
165
|
+
time.sleep(.25)
|
|
166
|
+
raise RuntimeError(f"ChatGPT Project {name} was not created")
|
|
167
|
+
|
|
168
|
+
def _settings_panel():
|
|
169
|
+
already=js("""(() => {
|
|
170
|
+
const d=document.querySelector('[role="dialog"]');
|
|
171
|
+
return !!d && /project settings/i.test((d.innerText||d.textContent||''));
|
|
172
|
+
})()""")
|
|
173
|
+
if already:
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
opened=_wait(lambda: js("""(() => {
|
|
177
|
+
const fire=e=>{for(const type of ['pointerdown','mousedown','pointerup','mouseup','click']) e.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,view:window,button:0,buttons:type.includes('down')?1:0}));};
|
|
178
|
+
const b=[...document.querySelectorAll('button')].find(b=>(b.getAttribute('aria-label')||'')==='Project actions');
|
|
179
|
+
if(!b)return false;fire(b);return true;
|
|
180
|
+
})()"""), "ChatGPT Project actions control",15)
|
|
181
|
+
|
|
182
|
+
def open_settings_menu_item():
|
|
183
|
+
return js("""(() => {
|
|
184
|
+
const fire=e=>{for(const type of ['pointerdown','mousedown','pointerup','mouseup','click']) e.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,view:window,button:0,buttons:type.includes('down')?1:0}));};
|
|
185
|
+
const nodes=[...document.querySelectorAll('[role="menuitem"],button,[role="button"],a')];
|
|
186
|
+
const e=nodes.find(e=>{
|
|
187
|
+
const text=(e.innerText||e.textContent||e.getAttribute('aria-label')||'').replace(/\\s+/g,' ').trim().toLowerCase();
|
|
188
|
+
return text.includes('project settings') || text.includes('edit project') || text.includes('project instructions');
|
|
189
|
+
});
|
|
190
|
+
if(e){fire(e);return true;}
|
|
191
|
+
const b=[...document.querySelectorAll('button')].find(b=>(b.getAttribute('aria-label')||'')==='Project actions');
|
|
192
|
+
if(b) fire(b);
|
|
193
|
+
return false;
|
|
194
|
+
})()""")
|
|
195
|
+
_wait(open_settings_menu_item, "ChatGPT Project settings menu item",8)
|
|
196
|
+
|
|
197
|
+
_wait(lambda: js("""(() => {
|
|
198
|
+
const d=document.querySelector('[role="dialog"]');
|
|
199
|
+
return !!d && /project settings/i.test((d.innerText||d.textContent||''));
|
|
200
|
+
})()"""), "ChatGPT Project settings dialog",5)
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
def _close_settings_panel():
|
|
204
|
+
js("""(() => {
|
|
205
|
+
const d=document.querySelector('[role="dialog"]');
|
|
206
|
+
if(!d)return true;
|
|
207
|
+
const b=[...d.querySelectorAll('button,[role="button"]')].find(e=>(e.getAttribute('aria-label')||'')==='Close dialog'||/close dialog/i.test((e.innerText||e.textContent||'')));
|
|
208
|
+
if(!b)return false;
|
|
209
|
+
const fire=e=>{for(const type of ['pointerdown','mousedown','pointerup','mouseup','click']) e.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,view:window,button:0,buttons:type.includes('down')?1:0}));};
|
|
210
|
+
fire(b);return true;
|
|
211
|
+
})()""")
|
|
212
|
+
time.sleep(.2)
|
|
213
|
+
|
|
214
|
+
def _set_field_value(field_selector,value):
|
|
215
|
+
ok=js(f"""(() => {{
|
|
216
|
+
const e=document.querySelector({json.dumps(field_selector)});
|
|
217
|
+
if(!e)return false;
|
|
218
|
+
e.focus();
|
|
219
|
+
if(e instanceof HTMLTextAreaElement || e instanceof HTMLInputElement){{
|
|
220
|
+
const p=e instanceof HTMLTextAreaElement?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;
|
|
221
|
+
const setter=Object.getOwnPropertyDescriptor(p,'value')?.set;
|
|
222
|
+
setter?.call(e,{json.dumps(value)});
|
|
223
|
+
}}else{{
|
|
224
|
+
const sel=window.getSelection(),r=document.createRange();r.selectNodeContents(e);sel.removeAllRanges();sel.addRange(r);
|
|
225
|
+
document.execCommand('delete',false,null);
|
|
226
|
+
document.execCommand('insertText',false,{json.dumps(value)});
|
|
227
|
+
sel.removeAllRanges();
|
|
228
|
+
}}
|
|
229
|
+
e.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'insertText',data:{json.dumps(value)}}}));
|
|
230
|
+
e.dispatchEvent(new Event('change',{{bubbles:true}}));
|
|
231
|
+
return true;
|
|
232
|
+
}})()""")
|
|
233
|
+
if not ok: raise RuntimeError("Could not update Project Instructions field")
|
|
234
|
+
|
|
235
|
+
def _apply_instructions(value):
|
|
236
|
+
value=str(value or "").strip()
|
|
237
|
+
if not value: raise RuntimeError("Project Instructions are empty")
|
|
238
|
+
_settings_panel()
|
|
239
|
+
selector=_wait(lambda: js("""(() => {
|
|
240
|
+
const root=document.querySelector('[role="dialog"]');
|
|
241
|
+
if(!root||!/project settings/i.test((root.innerText||root.textContent||'')))return null;
|
|
242
|
+
const field=root.querySelector('textarea');
|
|
243
|
+
if(!field)return null;
|
|
244
|
+
field.setAttribute('data-neoy-project-instructions','1');
|
|
245
|
+
return '[data-neoy-project-instructions="1"]';
|
|
246
|
+
})()"""), "ChatGPT Project Instructions field")
|
|
247
|
+
_set_field_value(selector,value)
|
|
248
|
+
# Current ChatGPT Project settings auto-save Instructions.
|
|
249
|
+
time.sleep(1.0)
|
|
250
|
+
|
|
251
|
+
def _set_project_only_memory():
|
|
252
|
+
_settings_panel()
|
|
253
|
+
current=js("""(() => {
|
|
254
|
+
const d=document.querySelector('[role="dialog"]');
|
|
255
|
+
if(!d)return 'missing';
|
|
256
|
+
const text=(d.innerText||d.textContent||'').replace(/\s+/g,' ').trim().toLowerCase();
|
|
257
|
+
if(text.includes('project-only')) return 'project-only';
|
|
258
|
+
const controls=[...d.querySelectorAll('button,[role="button"]')];
|
|
259
|
+
const memory=controls.find(e=>/default memory|memory/i.test((e.innerText||e.textContent||e.getAttribute('aria-label')||'').replace(/\s+/g,' ').trim().toLowerCase()));
|
|
260
|
+
if(!memory)return 'unavailable';
|
|
261
|
+
const fire=e=>{for(const type of ['pointerdown','mousedown','pointerup','mouseup','click']) e.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,view:window,button:0,buttons:type.includes('down')?1:0}));};
|
|
262
|
+
fire(memory);return 'opened';
|
|
263
|
+
})()""")
|
|
264
|
+
if current=="project-only":
|
|
265
|
+
_close_settings_panel()
|
|
266
|
+
return {"memory":"project-only"}
|
|
267
|
+
if current=="opened":
|
|
268
|
+
_wait(lambda: js("""(() => {
|
|
269
|
+
const nodes=[...document.querySelectorAll('[role="menuitem"],button,[role="button"],label')];
|
|
270
|
+
const e=nodes.find(e=>/project[- ]only/i.test((e.innerText||e.textContent||e.getAttribute('aria-label')||'').replace(/\s+/g,' ').trim()));
|
|
271
|
+
if(!e)return false;
|
|
272
|
+
const fire=e=>{for(const type of ['pointerdown','mousedown','pointerup','mouseup','click']) e.dispatchEvent(new MouseEvent(type,{bubbles:true,cancelable:true,view:window,button:0,buttons:type.includes('down')?1:0}));};
|
|
273
|
+
fire(e);return true;
|
|
274
|
+
})()"""), "Project-only memory option",3)
|
|
275
|
+
time.sleep(.7)
|
|
276
|
+
_close_settings_panel()
|
|
277
|
+
return {"memory":"project-only"}
|
|
278
|
+
_close_settings_panel()
|
|
279
|
+
return {"memory":"unavailable"}
|
|
280
|
+
|
|
281
|
+
def _composer_selector():
|
|
282
|
+
return js("""(() => {
|
|
283
|
+
const sels=['#prompt-textarea','[contenteditable="true"][data-composer-markdown]','[contenteditable="true"][data-lexical-editor="true"]','textarea'];
|
|
284
|
+
for(const s of sels){const e=document.querySelector(s);if(!e)continue;const r=e.getBoundingClientRect();if(r.width&&r.height)return s;}
|
|
285
|
+
const visible=[...document.querySelectorAll('[contenteditable="true"]')].filter(e=>{const r=e.getBoundingClientRect();return r.width&&r.height;});
|
|
286
|
+
if(visible.length===1){visible[0].setAttribute('data-neoy-composer','1');return '[data-neoy-composer="1"]';}
|
|
287
|
+
return null;
|
|
288
|
+
})()""")
|
|
289
|
+
|
|
290
|
+
def _fill_composer(text):
|
|
291
|
+
sel=_wait(_composer_selector,"ChatGPT Project composer",30)
|
|
292
|
+
editable=bool(js(f"""(() => document.querySelector({json.dumps(sel)})?.getAttribute('contenteditable')==='true')()"""))
|
|
293
|
+
if editable:
|
|
294
|
+
_set_field_value(sel,text)
|
|
295
|
+
else:
|
|
296
|
+
fill_input(sel,text,clear_first=True)
|
|
297
|
+
return sel
|
|
298
|
+
|
|
299
|
+
def _send_initial(text):
|
|
300
|
+
before=page_info().get("url","")
|
|
301
|
+
_fill_composer(text)
|
|
302
|
+
button=_wait(lambda: js("""(() => {
|
|
303
|
+
const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"],form button[type="submit"]');
|
|
304
|
+
if(!b||b.disabled||b.getAttribute('aria-disabled')==='true')return null;
|
|
305
|
+
b.setAttribute('data-neoy-send','1');return '[data-neoy-send="1"]';
|
|
306
|
+
})()"""), "enabled ChatGPT Send button",20)
|
|
307
|
+
clicked=js("""(() => {const b=document.querySelector('[data-neoy-send="1"]');if(!b)return false;b.click();return true;})()""")
|
|
308
|
+
if not clicked: raise RuntimeError("Initial Project thread Send failed")
|
|
309
|
+
deadline=time.time()+90
|
|
310
|
+
observed_thread=False
|
|
311
|
+
while time.time()<deadline:
|
|
312
|
+
current=page_info().get("url","")
|
|
313
|
+
path=urlsplit(current).path
|
|
314
|
+
if "/c/" in path:
|
|
315
|
+
observed_thread=True
|
|
316
|
+
# ChatGPT first exposes a client-local local-chatgpt id, then replaces
|
|
317
|
+
# it with the durable server thread id. Never persist the temporary URL.
|
|
318
|
+
if "local-chatgpt" not in current:
|
|
319
|
+
assistant_visible=bool(js("""(() => [...document.querySelectorAll('h4')].some(e => (e.innerText || '').trim() === 'ChatGPT said:') || document.querySelector('[data-message-author-role="assistant"]'))()"""))
|
|
320
|
+
if assistant_visible:
|
|
321
|
+
return current
|
|
322
|
+
time.sleep(.25)
|
|
323
|
+
if observed_thread:
|
|
324
|
+
raise RuntimeError("ChatGPT Project thread stayed on a temporary local-chatgpt URL")
|
|
325
|
+
raise RuntimeError(f"ChatGPT did not create a Project thread from {before}")
|
|
326
|
+
|
|
327
|
+
target_id=new_tab("https://chatgpt.com/")
|
|
328
|
+
wait_for_load()
|
|
329
|
+
project=_ensure_project(CFG["project_name"])
|
|
330
|
+
if project.get("project_url") and page_info().get("url","")!=project["project_url"]:
|
|
331
|
+
goto_url(project["project_url"]); wait_for_load()
|
|
332
|
+
_apply_instructions(CFG["instructions"])
|
|
333
|
+
memory_state=_set_project_only_memory()
|
|
334
|
+
|
|
335
|
+
# Return to the Project root after settings operations if the UI changed location.
|
|
336
|
+
project_url=project["project_url"]
|
|
337
|
+
if "/project" not in urlsplit(page_info().get("url","")).path:
|
|
338
|
+
goto_url(project_url); wait_for_load()
|
|
339
|
+
|
|
340
|
+
initial=str(CFG.get("initial_prompt") or "").strip()
|
|
341
|
+
if not initial:
|
|
342
|
+
initial="Initialize this learner's Family Tutor thread from the Project Instructions and reply only with READY."
|
|
343
|
+
thread_url=_send_initial(initial)
|
|
344
|
+
print(json.dumps({
|
|
345
|
+
"status":"completed",
|
|
346
|
+
"project_id":project["project_id"],
|
|
347
|
+
"project_url":project_url,
|
|
348
|
+
"project_reused":project["reused"],
|
|
349
|
+
"memory":memory_state["memory"],
|
|
350
|
+
"thread_url":thread_url.split("?",1)[0],
|
|
351
|
+
"thread_id":urlsplit(thread_url).path.rstrip("/").rsplit("/",1)[-1],
|
|
352
|
+
"target_id":target_id,
|
|
353
|
+
},ensure_ascii=False),flush=True)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Bounded polling helpers for ChatGPT submit readiness."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
def wait_until_stable(read_state, ready, *, timeout, interval=0.25, stable_samples=2, phase):
|
|
8
|
+
deadline = time.monotonic() + timeout
|
|
9
|
+
stable = 0
|
|
10
|
+
last_state = {}
|
|
11
|
+
while time.monotonic() < deadline:
|
|
12
|
+
try:
|
|
13
|
+
last_state = read_state()
|
|
14
|
+
except Exception:
|
|
15
|
+
stable = 0
|
|
16
|
+
time.sleep(interval)
|
|
17
|
+
continue
|
|
18
|
+
if ready(last_state):
|
|
19
|
+
stable += 1
|
|
20
|
+
if stable >= stable_samples:
|
|
21
|
+
return last_state
|
|
22
|
+
else:
|
|
23
|
+
stable = 0
|
|
24
|
+
time.sleep(interval)
|
|
25
|
+
raise RuntimeError(f"ChatGPT {phase} was not observed ready")
|
|
26
|
+
|
|
27
|
+
def prompt_text_matches(observed, expected):
|
|
28
|
+
def normalize(value):
|
|
29
|
+
text=re.sub(r"\s+", " ", value or "").strip()
|
|
30
|
+
text=re.sub(r">\s+", ">", text)
|
|
31
|
+
text=re.sub(r"\s+<", "<", text)
|
|
32
|
+
return text
|
|
33
|
+
observed_n = normalize(observed)
|
|
34
|
+
expected_n = normalize(expected)
|
|
35
|
+
if expected_n in observed_n:
|
|
36
|
+
return True
|
|
37
|
+
if not expected_n:
|
|
38
|
+
return not observed_n
|
|
39
|
+
span = min(256, max(48, len(expected_n) // 8))
|
|
40
|
+
return (
|
|
41
|
+
len(observed_n) >= int(len(expected_n) * 0.9)
|
|
42
|
+
and expected_n[:span] in observed_n
|
|
43
|
+
and expected_n[-span:] in observed_n
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def submission_receipt(turns, before_count, prompt, composer_text):
|
|
47
|
+
for turn in turns[before_count:]:
|
|
48
|
+
if prompt_text_matches(turn.get("text"), prompt):
|
|
49
|
+
return {"verified_by": "user-turn", "turn": turn}
|
|
50
|
+
if not (composer_text or "").strip():
|
|
51
|
+
return {"verified_by": "composer-cleared", "turn": {"id": None, "text": prompt}}
|
|
52
|
+
return None
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
from _readiness import wait_until_stable, submission_receipt, prompt_text_matches
|
|
6
|
+
|
|
7
|
+
CFG=json.load(open("__CFG_PATH__",encoding="utf-8"))
|
|
8
|
+
project_id=str(CFG.get("project_id") or "").strip()
|
|
9
|
+
thread_id=str(CFG.get("thread_id") or "").strip()
|
|
10
|
+
prompt=str(CFG.get("prompt") or "")
|
|
11
|
+
files=list(CFG.get("file") or [])
|
|
12
|
+
app_name=str(CFG.get("app") or "").strip()
|
|
13
|
+
if not project_id or not thread_id: raise RuntimeError("project_id and thread_id are required")
|
|
14
|
+
if not prompt and not files: raise RuntimeError("prompt or file is required")
|
|
15
|
+
thread_url=f"https://chatgpt.com/g/{quote(project_id,safe='')}/c/{quote(thread_id,safe='')}"
|
|
16
|
+
goto_url(thread_url+"?prompt="+quote(prompt,safe=''))
|
|
17
|
+
wait_for_load()
|
|
18
|
+
|
|
19
|
+
def composer_state():
|
|
20
|
+
return js("""(() => {
|
|
21
|
+
const selectors=['#prompt-textarea','[contenteditable="true"][data-composer-markdown]','[contenteditable="true"][data-lexical-editor="true"]','textarea'];
|
|
22
|
+
const elements=[...new Set(selectors.flatMap(s=>[...document.querySelectorAll(s)]))];
|
|
23
|
+
const e=elements.find(node=>{const r=node.getBoundingClientRect();return !node.disabled&&r.width>0&&r.height>0});
|
|
24
|
+
if(!e) return {present:false,text:'',sendEnabled:false};
|
|
25
|
+
const form=e.closest('form');
|
|
26
|
+
const b=form?.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"]');
|
|
27
|
+
return {present:true,text:(e.innerText||e.value||e.textContent||'').trim(),sendEnabled:!!b&&!b.disabled&&b.getAttribute('aria-disabled')!=='true'};
|
|
28
|
+
})()""") or {"present":False,"text":"","sendEnabled":False}
|
|
29
|
+
|
|
30
|
+
def visible_composer_text():
|
|
31
|
+
return composer_state().get("text","")
|
|
32
|
+
|
|
33
|
+
def click_visible_send():
|
|
34
|
+
return bool(js("""(() => {
|
|
35
|
+
const selectors=['#prompt-textarea','[contenteditable="true"][data-composer-markdown]','[contenteditable="true"][data-lexical-editor="true"]','textarea'];
|
|
36
|
+
const elements=[...new Set(selectors.flatMap(s=>[...document.querySelectorAll(s)]))];
|
|
37
|
+
const e=elements.find(node=>{const r=node.getBoundingClientRect();return !node.disabled&&r.width>0&&r.height>0});
|
|
38
|
+
if(!e) return false;
|
|
39
|
+
const form=e.closest('form');
|
|
40
|
+
const b=form?.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"],button[aria-label="Send"]');
|
|
41
|
+
if(!b||b.disabled||b.getAttribute('aria-disabled')==='true') return false;
|
|
42
|
+
b.click(); return true;
|
|
43
|
+
})()"""))
|
|
44
|
+
|
|
45
|
+
def visible_composer_element_js():
|
|
46
|
+
return """(() => { const selectors=['#prompt-textarea','[contenteditable=\"true\"][data-composer-markdown]','[contenteditable=\"true\"][data-lexical-editor=\"true\"]','textarea']; const elements=[...new Set(selectors.flatMap(s=>[...document.querySelectorAll(s)]))]; return elements.find(node=>{const r=node.getBoundingClientRect();return !node.disabled&&r.width>0&&r.height>0})||null; })()"""
|
|
47
|
+
|
|
48
|
+
def attach_app_and_restore_prompt(name, prompt, timeout=12):
|
|
49
|
+
if not name: return False
|
|
50
|
+
ok=js("""(() => { const selectors=['#prompt-textarea','[contenteditable=\"true\"][data-composer-markdown]','[contenteditable=\"true\"][data-lexical-editor=\"true\"]','textarea']; const els=[...new Set(selectors.flatMap(s=>[...document.querySelectorAll(s)]))]; const e=els.find(node=>{const r=node.getBoundingClientRect();return !node.disabled&&r.width>0&&r.height>0}); if(!e||e.getAttribute('contenteditable')!=='true') return false; e.focus(); const sel=window.getSelection(); const range=document.createRange(); range.selectNodeContents(e); sel.removeAllRanges(); sel.addRange(range); document.execCommand('delete',false,null); sel.removeAllRanges(); return true; })()""")
|
|
51
|
+
if not ok: raise RuntimeError("ChatGPT app mention requires a visible contenteditable composer")
|
|
52
|
+
session=cdp("Target.attachToTarget",targetId=target_id,flatten=True)["sessionId"]
|
|
53
|
+
cdp("Input.insertText",session_id=session,text="@")
|
|
54
|
+
wanted=name.lower()
|
|
55
|
+
deadline=time.time()+timeout
|
|
56
|
+
while time.time()<deadline:
|
|
57
|
+
clicked=js(f"""(() => {{ const wanted={json.dumps(wanted)}; const candidates=[...document.querySelectorAll('button,[role=\"option\"],[role=\"menuitem\"]')]; const e=candidates.find(node=>{{const text=(node.innerText||node.textContent||'').replace(/\\s+/g,'').trim().toLowerCase(); return text===wanted.replace(/\\s+/g,'') || text===(wanted+wanted).replace(/\\s+/g,'');}}); if(!e)return false;e.click();return true; }})()""")
|
|
58
|
+
if clicked: break
|
|
59
|
+
time.sleep(.1)
|
|
60
|
+
else: raise RuntimeError(f"ChatGPT app '{name}' was not offered by the composer")
|
|
61
|
+
deadline=time.time()+timeout
|
|
62
|
+
while time.time()<deadline:
|
|
63
|
+
attached=js(f"""(() => {{ const selectors=['#prompt-textarea','[contenteditable=\"true\"][data-composer-markdown]','[contenteditable=\"true\"][data-lexical-editor=\"true\"]']; const els=[...new Set(selectors.flatMap(s=>[...document.querySelectorAll(s)]))]; const e=els.find(node=>{{const r=node.getBoundingClientRect();return r.width>0&&r.height>0}}); return !!e && !![...e.querySelectorAll('[app-mention-name]')].find(n=>(n.getAttribute('app-mention-name')||'').toLowerCase()==={json.dumps(wanted)}); }})()""")
|
|
64
|
+
if attached: break
|
|
65
|
+
time.sleep(.1)
|
|
66
|
+
else: raise RuntimeError(f"ChatGPT app '{name}' mention did not attach")
|
|
67
|
+
for offset in range(0,len(prompt),256):
|
|
68
|
+
chunk=prompt[offset:offset+256]
|
|
69
|
+
inserted=js(f"""(() => {{ const selectors=['#prompt-textarea','[contenteditable=\"true\"][data-composer-markdown]','[contenteditable=\"true\"][data-lexical-editor=\"true\"]']; const els=[...new Set(selectors.flatMap(s=>[...document.querySelectorAll(s)]))]; const e=els.find(node=>{{const r=node.getBoundingClientRect();return r.width>0&&r.height>0}}); if(!e)return false;e.focus();const sel=window.getSelection();const range=document.createRange();range.selectNodeContents(e);range.collapse(false);sel.removeAllRanges();sel.addRange(range);const ok=document.execCommand('insertText',false,{json.dumps(chunk)});e.dispatchEvent(new InputEvent('input',{{bubbles:true,inputType:'insertText',data:{json.dumps(chunk)}}}));return ok; }})()""")
|
|
70
|
+
if not inserted: raise RuntimeError("ChatGPT composer rejected prompt after app mention")
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
def attachment_state():
|
|
74
|
+
return js(r"""(() => { const names=[...document.querySelectorAll('button[aria-label^="Remove "]')].map(b => (b.getAttribute('aria-label')||'').replace(/^Remove file\s+\d+:\s*/,'').replace(/^Remove\s+/,'')).filter(Boolean); const visible=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0}; const pending=[...document.querySelectorAll('[role="progressbar"],[aria-busy="true"],[data-state="loading"]')].some(visible); return {names,pending}; })()""") or {"names":[],"pending":False}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def user_turns():
|
|
78
|
+
return js("""(() => { const legacy=[...document.querySelectorAll('[data-message-author-role="user"]')].map(e=>({text:(e.innerText||'').trim(),id:e.getAttribute('data-message-id')||''})).filter(x=>x.text); if(legacy.length) return legacy; return [...document.querySelectorAll('h4')].filter(e=>(e.innerText||'').trim()==='You said:').map((e,index)=>({text:(e.parentElement?.innerText||'').replace(/^You said:\\s*/,'').trim(),id:`dom-user-${index}`})).filter(x=>x.text); })()""") or []
|
|
79
|
+
|
|
80
|
+
def upload_files(paths):
|
|
81
|
+
if not paths: return []
|
|
82
|
+
selector=js("""(() => { for(const s of ['#upload-files','#upload-media','input[name="upload-media"]','input[type="file"]']) if(document.querySelector(s)) return s; return null; })()""")
|
|
83
|
+
if not selector:
|
|
84
|
+
js("""(() => { const b=[...document.querySelectorAll('button')].find(b=>(b.getAttribute('aria-label')||'').trim()==='Add files and more'); if(!b)return false;b.click();return true; })()""")
|
|
85
|
+
deadline=time.time()+5
|
|
86
|
+
while time.time()<deadline and not selector:
|
|
87
|
+
selector=js("""(() => { for(const s of ['#upload-files','#upload-media','input[name="upload-media"]','input[type="file"]']) if(document.querySelector(s)) return s; return null; })()""")
|
|
88
|
+
if not selector: time.sleep(.1)
|
|
89
|
+
if not selector: raise RuntimeError("ChatGPT file input was not observed")
|
|
90
|
+
expected=[]
|
|
91
|
+
for item in paths:
|
|
92
|
+
if not os.path.isfile(item): raise RuntimeError(f"attachment does not exist: {item}")
|
|
93
|
+
upload_file(selector,item); expected.append(os.path.basename(item))
|
|
94
|
+
wait_until_stable(attachment_state,lambda state:set(expected).issubset(state["names"]) and not state["pending"],timeout=120,phase="attachment readiness")
|
|
95
|
+
return expected
|
|
96
|
+
|
|
97
|
+
wait_until_stable(
|
|
98
|
+
composer_state,
|
|
99
|
+
lambda state: state["present"],
|
|
100
|
+
timeout=20,
|
|
101
|
+
phase="composer readiness",
|
|
102
|
+
)
|
|
103
|
+
wait_until_stable(
|
|
104
|
+
composer_state,
|
|
105
|
+
lambda state: prompt_text_matches(state["text"],prompt),
|
|
106
|
+
timeout=20,
|
|
107
|
+
phase="prefilled thread readiness",
|
|
108
|
+
)
|
|
109
|
+
app_attached=attach_app_and_restore_prompt(app_name,prompt) if app_name else False
|
|
110
|
+
if app_attached:
|
|
111
|
+
wait_until_stable(composer_state,lambda state: prompt_text_matches(state["text"],prompt),timeout=15,phase="app prompt readiness")
|
|
112
|
+
attachments=upload_files(files)
|
|
113
|
+
before_users=len(user_turns())
|
|
114
|
+
wait_until_stable(
|
|
115
|
+
lambda:{"composer":composer_state(),"attachments":attachment_state()},
|
|
116
|
+
lambda state: prompt_text_matches(state["composer"]["text"],prompt) and set(attachments).issubset(state["attachments"]["names"]) and not state["attachments"]["pending"] and state["composer"]["sendEnabled"],
|
|
117
|
+
timeout=45 if attachments else 20,
|
|
118
|
+
phase="send readiness",
|
|
119
|
+
)
|
|
120
|
+
if not click_visible_send(): raise RuntimeError("ChatGPT send button changed before click")
|
|
121
|
+
deadline=time.time()+15
|
|
122
|
+
receipt=None
|
|
123
|
+
while time.time()<deadline:
|
|
124
|
+
receipt=submission_receipt(user_turns(),before_users,prompt,visible_composer_text())
|
|
125
|
+
if receipt: break
|
|
126
|
+
time.sleep(.1)
|
|
127
|
+
if not receipt: raise RuntimeError("ChatGPT submission verification did not observe an accepted submission")
|
|
128
|
+
|
|
129
|
+
print(json.dumps({"status":"submitted","project_id":project_id,"thread_id":thread_id,"verified_by":receipt["verified_by"],"user_message_id":receipt["turn"].get("id") or None,"app":app_name or None},ensure_ascii=False),flush=True)
|