Repository navigation
feat: add ChatGPT file uploads #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,8 +12,10 @@ | |
| import asyncio | ||
| import json | ||
| import logging | ||
| import tempfile | ||
| import time | ||
| import uuid | ||
| from pathlib import Path | ||
|
|
||
| from aiohttp import web | ||
|
|
||
|
|
@@ -27,6 +29,7 @@ | |
| ) | ||
| from .config import Config | ||
| from .cross_process_lock import LockAcquisitionError | ||
| from .file_upload import UploadError, validate_upload_files | ||
| from .lock_resolver import MutationLock, OwnedTabRequiredError, resolve_mutation_lock | ||
| from .resilience import retry_on_rate_limit | ||
|
|
||
|
|
@@ -230,17 +233,65 @@ async def _handle_projects(self, request: web.Request) -> web.Response: | |
| projects = [] | ||
| return web.json_response({"object": "list", "data": projects}) | ||
|
|
||
| async def _parse_chat_request(self, request: web.Request) -> tuple[dict, list[Path]]: | ||
| """Parse JSON or multipart chat input and materialize uploaded files.""" | ||
| content_type = getattr(request, "content_type", "") | ||
| if not isinstance(content_type, str) or not content_type.startswith("multipart/"): | ||
| return await request.json(), [] | ||
|
|
||
| reader = await request.multipart() | ||
| fields: dict[str, str] = {} | ||
| upload_paths: list[Path] = [] | ||
| try: | ||
| while part := await reader.next(): | ||
| if part.name in {"file", "files"} and part.filename: | ||
| suffix = Path(part.filename).suffix | ||
| handle = tempfile.NamedTemporaryFile( | ||
| prefix="chatgpt-web2api-upload-", suffix=suffix, delete=False | ||
| ) | ||
| path = Path(handle.name) | ||
| try: | ||
| while chunk := await part.read_chunk(): | ||
| handle.write(chunk) | ||
| finally: | ||
| handle.close() | ||
| upload_paths.append(path) | ||
| else: | ||
| fields[part.name] = await part.text() | ||
| if "messages" in fields: | ||
| body = {"messages": json.loads(fields["messages"])} | ||
| for name in ("model", "stream", "project_id", "gizmo_id", "conversation_id"): | ||
| if name in fields: | ||
| value = fields[name] | ||
| body[name] = json.loads(value) if name == "stream" else value | ||
| elif "body" in fields: | ||
| body = json.loads(fields["body"]) | ||
| else: | ||
| raise UploadError("Multipart requests must include a JSON 'messages' field") | ||
| if upload_paths: | ||
| validate_upload_files(upload_paths) | ||
| return body, upload_paths | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [LOW] Temp-file cleanup misses CancelledError (BaseException) paths Both cleanup sites use |
||
| except Exception: | ||
| for path in upload_paths: | ||
| path.unlink(missing_ok=True) | ||
| raise | ||
|
|
||
| @staticmethod | ||
| def _cleanup_uploads(paths: list[Path]) -> None: | ||
| for path in paths: | ||
| path.unlink(missing_ok=True) | ||
|
|
||
| async def _handle_chat(self, request: web.Request) -> web.Response: | ||
| if err := self._check_auth(request): | ||
| return err | ||
|
|
||
| self._request_count += 1 | ||
|
|
||
| try: | ||
| body = await request.json() | ||
| except json.JSONDecodeError: | ||
| body, upload_paths = await self._parse_chat_request(request) | ||
| except (json.JSONDecodeError, UploadError, ValueError) as exc: | ||
| return web.json_response( | ||
| {"error": {"message": "Invalid JSON", "type": "invalid_request_error"}}, | ||
| {"error": {"message": str(exc), "type": "invalid_request_error"}}, | ||
| status=400, | ||
| ) | ||
|
|
||
|
|
@@ -387,12 +438,18 @@ async def _handle_chat(self, request: web.Request) -> web.Response: | |
| await self._driver.navigate_new_chat(gizmo_id=project_id) | ||
| self._last_project_id = project_id | ||
|
|
||
| if upload_paths: | ||
| await self._driver.upload_files([str(path) for path in upload_paths]) | ||
| if stream: | ||
| return await self._stream_response(request, model_slug, full_text, timeout) | ||
| response = await self._stream_response(request, model_slug, full_text, timeout) | ||
| else: | ||
| return await self._full_response(request, model_slug, full_text, timeout) | ||
| response = await self._full_response(request, model_slug, full_text, timeout) | ||
| self._cleanup_uploads(upload_paths) | ||
| upload_paths = [] | ||
| return response | ||
|
|
||
| except Exception as e: | ||
| self._cleanup_uploads(upload_paths) | ||
| logger.error("Chat error: %s", e, exc_info=True) | ||
| self._last_error = f"{type(e).__name__}: {e}" | ||
| return self._error_response(e) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -450,20 +450,83 @@ async def click_send(self) -> None: | |
| break | ||
| await asyncio.sleep(SEND_BUTTON_POLL_INTERVAL_S) | ||
|
|
||
| result = await d._js( | ||
| "(function() {" | ||
| f" var btn = document.querySelector('{SEND_BUTTON_SELECTOR}')" | ||
| f" || document.querySelector('{SEND_BUTTON_FALLBACK_SELECTOR}')" | ||
| f" || document.querySelector('{SEND_BUTTON_BROAD_SELECTOR}');" | ||
| " if (!btn) return 'no send button';" | ||
| " if (btn.disabled) return 'button disabled';" | ||
| " var evts = ['pointerdown','mousedown','pointerup','mouseup','click'];" | ||
| " for (var i = 0; i < evts.length; i++) {" | ||
| " btn.dispatchEvent(new MouseEvent(evts[i], {bubbles:true, cancelable:true, view:window}));" | ||
| " }" | ||
| " return 'sent';" | ||
| "})()" | ||
| ) | ||
| if getattr(d, "_file_upload_active", False) is True: | ||
| rect_raw = await d._js( | ||
| """(function() { | ||
| var btn = document.querySelector('button[aria-label*="Send" i]:not([data-testid="stop-button"])') | ||
| || document.querySelector('button[data-testid="send-button"]'); | ||
| if (!btn) return ''; | ||
| var r = btn.getBoundingClientRect(); | ||
| return JSON.stringify({x:r.left + r.width / 2, y:r.top + r.height / 2}); | ||
| })()""" | ||
| ) | ||
| try: | ||
| rect = json.loads(rect_raw) | ||
| mouse_params = { | ||
| "x": rect["x"], | ||
| "y": rect["y"], | ||
| "button": "left", | ||
| "clickCount": 1, | ||
| } | ||
| for event_type in ("mousePressed", "mouseReleased"): | ||
| await d._cdp( | ||
| "Input.dispatchMouseEvent", | ||
| {"type": event_type, "buttons": 1 if event_type == "mousePressed" else 0, **mouse_params}, | ||
| ) | ||
|
|
||
| # With attachments, ChatGPT starts the upload on the first send | ||
| # click. The button remains enabled, but the UI briefly exposes | ||
| # "File upload pending" and ignores that click as a submission. | ||
| # Wait for that transition and click again only after it clears. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [LOW] click_send upload path stalls ~30s when 'File upload pending' never appears, and flag reset is skipped on error In the |
||
| saw_pending = False | ||
| for _ in range(60): | ||
| state = await d._js( | ||
| """(function() { | ||
| return /file upload pending/i.test((document.body && document.body.innerText) || '') | ||
| ? 'pending' : 'ready'; | ||
| })()""" | ||
| ) | ||
| if state == "pending": | ||
| saw_pending = True | ||
| elif saw_pending: | ||
| break | ||
| await asyncio.sleep(0.5) | ||
| if saw_pending: | ||
| rect_raw = await d._js( | ||
| """(function() { | ||
| var btn = document.querySelector('button[aria-label*="Send" i]:not([data-testid="stop-button"])') | ||
| || document.querySelector('button[data-testid="send-button"]'); | ||
| if (!btn) return ''; | ||
| var r = btn.getBoundingClientRect(); | ||
| return JSON.stringify({x:r.left + r.width / 2, y:r.top + r.height / 2}); | ||
| })()""" | ||
| ) | ||
| rect = json.loads(rect_raw) | ||
| mouse_params.update({"x": rect["x"], "y": rect["y"]}) | ||
| for event_type in ("mousePressed", "mouseReleased"): | ||
| await d._cdp( | ||
| "Input.dispatchMouseEvent", | ||
| {"type": event_type, "buttons": 1 if event_type == "mousePressed" else 0, **mouse_params}, | ||
| ) | ||
| d._file_upload_active = False | ||
| result = "sent" | ||
| except (json.JSONDecodeError, KeyError, TypeError): | ||
| result = "send button coordinates unavailable" | ||
| else: | ||
| result = await d._js( | ||
| "(function() {" | ||
| f" var btn = document.querySelector('{SEND_BUTTON_SELECTOR}')" | ||
| f" || document.querySelector('{SEND_BUTTON_FALLBACK_SELECTOR}')" | ||
| f" || document.querySelector('{SEND_BUTTON_BROAD_SELECTOR}');" | ||
| " if (!btn) return 'no send button';" | ||
| " if (btn.disabled) return 'button disabled';" | ||
| " var evts = ['pointerdown','mousedown','pointerup','mouseup','click'];" | ||
| " for (var i = 0; i < evts.length; i++) {" | ||
| " btn.dispatchEvent(new MouseEvent(evts[i], {bubbles:true, cancelable:true, view:window}));" | ||
| " }" | ||
| " return 'sent';" | ||
| "})()" | ||
| ) | ||
| if result != "sent": | ||
| await d._capture_selector_diagnostic("send-button (click_send)") | ||
| if d._breakers: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[LOW] Multipart uploads are fully written to disk before the size limit is enforced
_parse_chat_request streams every
filepart to a NamedTemporaryFile with no bound, and validate_upload_files (which enforces W2A_MAX_UPLOAD_BYTES) runs only after the entire body has been materialized. A single authenticated request with one oversized part (or a very large number of parts, since there is no count cap) fills the temp filesystem before the server returns 400. Fix: enforce the limit inside the read_chunk loop — track bytes written per part (and total), abort and delete the partial file as soon as it exceeds max_upload_bytes(), and cap the number of file parts. Also note handle.write(chunk) is synchronous and blocks the event loop per chunk for large uploads; consider asyncio.to_thread for the write.