diff --git a/Dockerfile b/Dockerfile index 0d87ffa3d7f5111f5805f76ffc82de0f807545de..39fb218624ca2d5667cb69cc67e8d1e32a286b4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,14 @@ FROM akashyadav758/chrome:latest USER root +# Copy extensions into the container +COPY chatgpt-free-api/gpt-extension /opt/gpt-extension +COPY free-gemini-api/extension /opt/gemini-extension +COPY flow-agent/extension /opt/flow-extension + +# Set correct permissions +RUN chmod -R 755 /opt/gpt-extension /opt/gemini-extension /opt/flow-extension + # Replace the start script with the non-root version COPY --chmod=755 start_hf.sh /start.sh diff --git a/chatgpt-free-api/.gitignore b/chatgpt-free-api/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..19c58277dc65fa6e5df70015b799d6b1805d53ca --- /dev/null +++ b/chatgpt-free-api/.gitignore @@ -0,0 +1,13 @@ +# Binaries +agent +agent.exe + +# Private Session Cookies +cookies.json + +# Logs & Debug +*.log +debug_poll.json + +# Local Output Files +output/ diff --git a/chatgpt-free-api/README.md b/chatgpt-free-api/README.md new file mode 100644 index 0000000000000000000000000000000000000000..0360524f7c1faf95a192f3cf6a638e80b5ca13da --- /dev/null +++ b/chatgpt-free-api/README.md @@ -0,0 +1,56 @@ +# ChatGPT Free API Agent + +A flat-structure Go server that connects with the Chrome extension (`gpt-extension`) to execute ChatGPT backend API requests directly from your authenticated browser session. + +## Setup & Running + +1. **Build & Run the Server:** + ```bash + go build -o agent . + ./agent + ``` +2. **Load Chrome Extension:** + - Open `chrome://extensions` in Chrome. + - Enable **Developer mode** (top-right). + - Click **Load unpacked** and select the `gpt-extension` folder (in the parent directory). +3. **Login to ChatGPT:** + - Open `https://chatgpt.com/` and log in. The extension will automatically connect to the running Go agent (green status badge = connected). + +--- + +## API Usage + +### 1. Send Chat Request +```bash +curl -X POST "http://127.0.0.1:9225/api/chat" \ + -H "Content-Type: application/json" \ + -d '{"prompt": "Why is sky blue?", "conversation_id": ""}' +``` + +### 2. Bulk Sequential Requests +```bash +curl -X POST "http://127.0.0.1:9225/api/chat/bulk" \ + -H "Content-Type: application/json" \ + -d '{"prompts": ["hello", "how are you"]}' +``` + +### 3. Edit Existing Message +```bash +curl -X POST "http://127.0.0.1:9225/api/chat/edit" \ + -H "Content-Type: application/json" \ + -d '{"prompt": "updated prompt", "conversation_id": "conv-id", "message_id": "msg-id", "parent_id": "parent-id"}' +``` + +### 4. Health & Cookie Status +- **Health Check:** `GET http://127.0.0.1:9225/health` +- **Cookie Status:** `GET http://127.0.0.1:9225/api/cookies/status` + +--- + +## Testing + +Run the integration tests (requires agent running on port 9225): +```bash +./test.sh # Run all tests (text, thread, bulk, image gen/edit) +./test_image_only.sh # Run only image generation/editing tests +``` diff --git a/chatgpt-free-api/agent.go b/chatgpt-free-api/agent.go new file mode 100644 index 0000000000000000000000000000000000000000..f703321bf2f1bf9bb859a1d715ac23fc86434b64 --- /dev/null +++ b/chatgpt-free-api/agent.go @@ -0,0 +1,144 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "sync" + "time" +) + +var ( + activeConvMu sync.Mutex + activeConvID string +) + +func getActiveConversationID() string { + activeConvMu.Lock() + defer activeConvMu.Unlock() + return activeConvID +} + +func setActiveConversationID(id string) { + activeConvMu.Lock() + activeConvID = id + activeConvMu.Unlock() +} + +func clearActiveConversationID() { + activeConvMu.Lock() + activeConvID = "" + activeConvMu.Unlock() +} + +func Start() { + loadConfig() + LoadCookiesFromFile() + + promptFlag := flag.String("prompt", "", "Prompt to send to ChatGPT after the extension connects") + onceFlag := flag.Bool("once", false, "Send the prompt, print the response, then exit") + timeoutFlag := flag.Duration("timeout", cfg.Timeout(), "Maximum time to wait for extension/response") + flag.Parse() + + http.HandleFunc("/", handleWS) + http.HandleFunc("/health", handleHealth) + http.HandleFunc("/api/ext/callback", handleCallback) + http.HandleFunc("/api/ext/reload", func(w http.ResponseWriter, r *http.Request) { + extMu.Lock() + conn := extConn + extMu.Unlock() + if conn == nil { + writeJSONStatus(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "error": "extension not connected"}) + return + } + err := conn.WriteJSON(WSMessage{ + Method: "reload_extension", + }) + if err != nil { + writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, map[string]any{"ok": true, "message": "reload request sent"}) + }) + http.HandleFunc("/api/conversation", func(w http.ResponseWriter, r *http.Request) { + convID := r.URL.Query().Get("conversation_id") + if convID == "" { + writeJSONStatus(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "conversation_id required"}) + return + } + token, err := getSessionToken() + if err != nil { + writeJSONStatus(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "error": err.Error()}) + return + } + res, err := callChatGPTAPI(apiCallParams{ + URL: "https://chatgpt.com/backend-api/conversation/" + convID, + Method: "GET", + Headers: map[string]string{ + "Authorization": "Bearer " + token, + }, + }, cfg.APITimeout()) + if err != nil { + writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(res.Status) + w.Write([]byte(res.Body)) + }) + http.HandleFunc("/api/chat", handleChat) + http.HandleFunc("/api/chat/bulk", handleChatBulk) + http.HandleFunc("/api/chat/edit", handleChatEdit) + http.HandleFunc("/api/chatgpt/test", handleChat) + http.HandleFunc("/api/sniffs", handleSniffs) + http.HandleFunc("/api/download", handleDownload) + http.HandleFunc("/v1/chat/completions", handleOpenAIChat) + http.HandleFunc("/api/cookies/status", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{ + "ok": true, + "has_cookies": HasCookies(), + "last_sync": GetLastCookieSync().Format(time.RFC3339), + "cookie_header_len": len(GetCachedCookieHeader()), + "extension_connected": isExtensionConnected(), + }) + }) + + if *promptFlag != "" { + go runPromptFromCLI(*promptFlag, *timeoutFlag, *onceFlag) + } + + log.Printf("ChatGPT Agent listening on http://%s", cfg.ListenAddr) + log.Println("Reload the ChatGPT Browser Bridge extension if it is not connected.") + log.Println("Endpoints: GET/POST /api/chat, POST /api/chat/bulk, POST /api/chat/edit, GET /api/sniffs, GET /health") + if err := http.ListenAndServe(cfg.ListenAddr, nil); err != nil { + log.Fatal(err) + } +} + +func runPromptFromCLI(prompt string, timeout time.Duration, once bool) { + log.Println("Waiting for extension API bridge connection...") + if !waitForExtension(timeout) { + log.Println("Extension did not connect before timeout") + if once { + os.Exit(1) + } + return + } + + log.Printf("Sending prompt: %s", prompt) + text, _, err := sendChat(prompt) + if err != nil { + log.Printf("ChatGPT request failed: %v", err) + if once { + os.Exit(1) + } + return + } + + fmt.Println(text) + if once { + os.Exit(0) + } +} diff --git a/chatgpt-free-api/api_bridge.go b/chatgpt-free-api/api_bridge.go new file mode 100644 index 0000000000000000000000000000000000000000..e7583df29f0104fd0b939c653ba9755a594a5e49 --- /dev/null +++ b/chatgpt-free-api/api_bridge.go @@ -0,0 +1,136 @@ +package main + +import ( + "log" + "os" + "regexp" + "strings" + "time" +) + +var fileIDRegexp = regexp.MustCompile(`(file_[0-9a-fA-F]{32}|file-[a-zA-Z0-9_-]{24,36})`) + +func sendChat(prompt string) (string, string, error) { + return sendChatWithConversation(prompt, "", cfg.DefaultModel, cfg.DefaultThinkingEffort) +} + +func sendChatWithConversation(prompt, conversationID, model, thinkingEffort string) (string, string, error) { + return sendChatWithConversationAndAttachments(prompt, conversationID, model, thinkingEffort, nil) +} + +func sendChatWithConversationAndAttachments(prompt, conversationID, model, thinkingEffort string, attachments []FileAttachment) (string, string, error) { + var response, rawText, newConvID string + var err error + + // ─── Try 1: Direct Go HTTP call using synced cookies (fastest, no extension needed) ─── + if HasCookies() && len(attachments) == 0 { + log.Println("[chat] Attempting direct API call using synced cookies...") + response, rawText, newConvID, err = SendChatDirect(prompt, conversationID, model, thinkingEffort) + if err == nil { + log.Println("[chat] ✅ Direct API call succeeded!") + return processResponse(response, rawText, newConvID, prompt, attachments) + } + log.Printf("[chat] ⚠️ Direct API call failed: %v — falling back to extension", err) + } + + // ─── Try 2: Extension full_conversation method (browser-based) ─── + log.Println("[chat] Using extension full_conversation method...") + response, rawText, newConvID, err = sendChatViaFullConversation(prompt, conversationID, model, thinkingEffort, attachments...) + if err != nil { + return "", "", err + } + + return processResponse(response, rawText, newConvID, prompt, attachments) +} + +// processResponse handles post-processing: auto-download images, poll for async images +func processResponse(response, rawText, newConvID, prompt string, attachments []FileAttachment) (string, string, error) { + // Auto-download any files found in the rawText or response + var rawFileIDs []string + rawFileIDs = append(rawFileIDs, fileIDRegexp.FindAllString(rawText, -1)...) + rawFileIDs = append(rawFileIDs, fileIDRegexp.FindAllString(response, -1)...) + + // Dedup and filter file IDs (excluding any attachment IDs we uploaded) + uploadedIDs := make(map[string]bool) + for _, att := range attachments { + uploadedIDs[att.ID] = true + } + seen := make(map[string]bool) + var fileIDs []string + for _, id := range rawFileIDs { + if !seen[id] && !uploadedIDs[id] { + seen[id] = true + fileIDs = append(fileIDs, id) + } + } + + log.Printf("[chat] Found %d new file ID(s) to download: %v", len(fileIDs), fileIDs) + + if len(fileIDs) > 0 { + for _, id := range fileIDs { + registerFilePrompt(id, prompt) + name := getPromptFilename(prompt, id) + log.Printf("[auto-download] Detected image %s (%s) in response, downloading...", id, name) + + // Retry download up to 10 times because the image might still be generating/saving on OpenAI backend + var data []byte + var err error + for attempt := 1; attempt <= 10; attempt++ { + data, err = downloadChatGPTFile(id) + if err == nil { + break + } + log.Printf("[auto-download] Image %s not ready yet, retrying in 2 seconds... (attempt %d/10)", id, attempt) + time.Sleep(2 * time.Second) + } + + if err == nil { + _ = os.MkdirAll("output", 0755) + localPath := "output/" + name + ".png" + err = os.WriteFile(localPath, data, 0644) + if err != nil { + log.Printf("[auto-download] Error saving file %s: %v", localPath, err) + } else { + log.Printf("[auto-download] Successfully saved image to %s", localPath) + } + } else { + log.Printf("[auto-download] Error downloading image %s: %v", id, err) + } + } + } + + shouldPoll := newConvID != "" && (strings.Contains(response, "Processing image") || + strings.Contains(response, "creating images") || + strings.Contains(response, "generating your image") || + len(attachments) > 0 || + isPromptForImage(prompt)) + + if shouldPoll { + log.Printf("[chat] detected async image generation/edit in conversation %s, starting poll...", newConvID) + var excludeIDs []string + for _, att := range attachments { + excludeIDs = append(excludeIDs, att.ID) + } + polledResponse, pollErr := pollForImage(prompt, newConvID, 150*time.Second, excludeIDs...) + if pollErr == nil { + return polledResponse, newConvID, nil + } + log.Printf("[chat] image poll failed: %v, returning original response", pollErr) + } + return response, newConvID, nil +} + +func isPromptForImage(prompt string) bool { + p := strings.ToLower(prompt) + keywords := []string{ + "generate", "create", "draw", "make", "edit", "add", "remove", + "change", "modify", "paint", "cartoon", "3d", "render", + "picture", "photo", "illustration", "dall-e", "dalle", + } + for _, kw := range keywords { + if strings.Contains(p, kw) { + return true + } + } + return false +} diff --git a/chatgpt-free-api/chatgpt.go b/chatgpt-free-api/chatgpt.go new file mode 100644 index 0000000000000000000000000000000000000000..5ca7857a39cda5b4a99130fee975e106dc607b5f --- /dev/null +++ b/chatgpt-free-api/chatgpt.go @@ -0,0 +1,1009 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +// FileAttachment represents an uploaded file to attach to a conversation +type FileAttachment struct { + ID string `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +var ( + fileIDToPromptMu sync.RWMutex + fileIDToPrompt = make(map[string]string) +) + +func registerFilePrompt(fileID, prompt string) { + fileIDToPromptMu.Lock() + fileIDToPrompt[fileID] = prompt + fileIDToPromptMu.Unlock() +} + +func getFilePrompt(fileID string) string { + fileIDToPromptMu.RLock() + defer fileIDToPromptMu.RUnlock() + return fileIDToPrompt[fileID] +} + +// ─── Browser-context API proxy ────────────────────────────── + +type apiCallParams struct { + URL string + Method string + Headers map[string]string + Body any + ResponseType string +} + +type apiCallResult struct { + OK bool `json:"ok"` + Status int `json:"status"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` + ConversationID string `json:"conversation_id,omitempty"` + FinalURL string `json:"finalUrl"` + Error string `json:"error"` + IsBase64 bool `json:"isBase64,omitempty"` + RawText string `json:"rawText,omitempty"` +} + +func callChatGPTAPI(p apiCallParams, timeout time.Duration) (*apiCallResult, error) { + if !waitForExtension(cfg.ExtensionWait()) { + return nil, fmt.Errorf("extension not connected") + } + extMu.Lock() + conn := extConn + extMu.Unlock() + if conn == nil { + return nil, fmt.Errorf("extension not connected") + } + + id := uuid.NewString() + ch := make(chan WSMessage, 1) + pendingMu.Lock() + pending[id] = ch + pendingMu.Unlock() + defer func() { + pendingMu.Lock() + delete(pending, id) + pendingMu.Unlock() + }() + + // Route internal methods directly + wsMethod := "api_request" + if strings.HasPrefix(p.URL, "__internal__/") { + switch { + case strings.Contains(p.URL, "solve_pow"): + wsMethod = "solve_pow" + case strings.Contains(p.URL, "solve_turnstile"): + wsMethod = "solve_turnstile" + } + } + + msg := WSMessage{ + ID: id, + Method: wsMethod, + Params: map[string]any{ + "url": p.URL, + "method": p.Method, + "headers": p.Headers, + "body": p.Body, + "responseType": p.ResponseType, + }, + } + + extMu.Lock() + err := conn.WriteJSON(msg) + extMu.Unlock() + if err != nil { + return nil, err + } + + select { + case resp := <-ch: + if resp.Error != "" { + return nil, fmt.Errorf("%s", resp.Error) + } + raw, _ := json.Marshal(resp.Result) + var r apiCallResult + if err := json.Unmarshal(raw, &r); err != nil { + return nil, fmt.Errorf("decode api result: %w", err) + } + return &r, nil + case <-time.After(cfg.ChatTimeout()): + return nil, fmt.Errorf("timeout waiting for extension api_request") + } +} + +// ─── ChatGPT internal API client ───────────────────────────── + +type chatGPTRequirements struct { + Token string `json:"token"` + Proofofwork struct { + Required bool `json:"required"` + Seed string `json:"seed"` + Difficulty string `json:"difficulty"` + } `json:"proofofwork"` + Turnstile struct { + Required bool `json:"required"` + } `json:"turnstile"` +} + +func getSessionToken() (string, error) { + r, err := callChatGPTAPI(apiCallParams{ + URL: "https://chatgpt.com/api/auth/session", + Method: "GET", + }, cfg.APITimeout()) + if err != nil { + return "", err + } + if r.Status != 200 { + return "", fmt.Errorf("session http %d: %s", r.Status, snippet(r.Body, 200)) + } + var s struct { + AccessToken string `json:"accessToken"` + } + if err := json.Unmarshal([]byte(r.Body), &s); err != nil { + return "", fmt.Errorf("session decode: %w", err) + } + if s.AccessToken == "" { + return "", fmt.Errorf("no access token (not logged in to chatgpt.com?)") + } + return s.AccessToken, nil +} + +func getChatRequirements(token string) (*chatGPTRequirements, error) { + r, err := callChatGPTAPI(apiCallParams{ + URL: "https://chatgpt.com/backend-api/sentinel/chat-requirements", + Method: "POST", + Headers: map[string]string{ + "Authorization": "Bearer " + token, + }, + Body: map[string]any{"p": ""}, + }, cfg.APITimeout()) + if err != nil { + return nil, err + } + if r.Status != 200 { + return nil, fmt.Errorf("requirements http %d: %s", r.Status, snippet(r.Body, 200)) + } + var cr chatGPTRequirements + if err := json.Unmarshal([]byte(r.Body), &cr); err != nil { + return nil, fmt.Errorf("requirements decode: %w", err) + } + return &cr, nil +} + +func solveTurnstile() (string, error) { + // Delegate to extension — it executes in chatgpt.com tab where turnstile widget exists + r, err := callChatGPTAPI(apiCallParams{ + URL: "__internal__/solve_turnstile", + Method: "POST", + }, cfg.APITimeout()) + if err != nil { + return "", err + } + if r.Error != "" { + return "", fmt.Errorf("%s", r.Error) + } + var result struct { + Token string `json:"token"` + } + if err := json.Unmarshal([]byte(r.Body), &result); err != nil { + return strings.TrimSpace(r.Body), nil + } + return result.Token, nil +} + +func solveProofOfWork(seed, difficulty string) (string, error) { + // Delegate PoW to extension which runs it in browser JS context + r, err := callChatGPTAPI(apiCallParams{ + URL: "__internal__/solve_pow", + Method: "POST", + Body: map[string]any{"seed": seed, "difficulty": difficulty}, + }, cfg.ChatTimeout()) + if err != nil { + return "", fmt.Errorf("pow solve: %w", err) + } + if r.Error != "" { + return "", fmt.Errorf("pow solve error: %s", r.Error) + } + var result struct { + Token string `json:"token"` + } + if err := json.Unmarshal([]byte(r.Body), &result); err != nil { + // Body itself might be the token + return strings.TrimSpace(r.Body), nil + } + if result.Token != "" { + return result.Token, nil + } + return strings.TrimSpace(r.Body), nil +} + +// sendChatViaFullConversation uses extension's full_conversation method. +// This does EVERYTHING inside the browser tab context (session, requirements, PoW, turnstile, conversation) +// which avoids 403 issues because all tokens are generated natively. +func sendChatViaFullConversation(prompt, conversationID, model, thinkingEffort string, attachments ...FileAttachment) (string, string, string, error) { + log.Println("[chat-full] sending full conversation via extension...") + if !waitForExtension(cfg.ExtensionWait()) { + return "", "", "", fmt.Errorf("extension not connected") + } + extMu.Lock() + conn := extConn + extMu.Unlock() + if conn == nil { + return "", "", "", fmt.Errorf("extension not connected") + } + + id := uuid.NewString() + ch := make(chan WSMessage, 1) + pendingMu.Lock() + pending[id] = ch + pendingMu.Unlock() + defer func() { + pendingMu.Lock() + delete(pending, id) + pendingMu.Unlock() + }() + + params := map[string]any{ + "prompt": prompt, + "model": model, + "conversation_id": conversationID, + "thinking_effort": thinkingEffort, + } + if len(attachments) > 0 { + params["attachments"] = attachments + } + + msg := WSMessage{ + ID: id, + Method: "full_conversation", + Params: params, + } + + extMu.Lock() + err := conn.WriteJSON(msg) + extMu.Unlock() + if err != nil { + return "", "", "", err + } + + select { + case resp := <-ch: + if resp.Error != "" { + return "", "", "", fmt.Errorf("%s", resp.Error) + } + raw, _ := json.Marshal(resp.Result) + var r apiCallResult + if err := json.Unmarshal(raw, &r); err != nil { + return "", "", "", fmt.Errorf("decode result: %w", err) + } + if r.Body == "" { + return "", "", "", fmt.Errorf("empty response from full_conversation") + } + log.Printf("[chat-full] got response (%d chars), conversation_id: %s", len(r.Body), r.ConversationID) + return r.Body, r.RawText, r.ConversationID, nil + case <-time.After(cfg.ChatTimeout()): + return "", "", "", fmt.Errorf("timeout waiting for full_conversation") + } +} + +func sendChatViaAPI(prompt string) (string, error) { + log.Println("[chat] getting session token...") + token, err := getSessionToken() + if err != nil { + return "", fmt.Errorf("session: %w", err) + } + log.Println("[chat] getting chat requirements...") + cr, err := getChatRequirements(token) + if err != nil { + return "", fmt.Errorf("requirements: %w", err) + } + + headers := map[string]string{ + "Authorization": "Bearer " + token, + "openai-sentinel-chat-requirements-token": cr.Token, + "Accept": "text/event-stream", + } + + if cr.Proofofwork.Required { + log.Printf("[chat] solving proof-of-work (seed=%s, diff=%s)", cr.Proofofwork.Seed, cr.Proofofwork.Difficulty) + powToken, err := solveProofOfWork(cr.Proofofwork.Seed, cr.Proofofwork.Difficulty) + if err != nil { + return "", fmt.Errorf("proof-of-work: %w", err) + } + headers["openai-sentinel-proof-token"] = powToken + } + if cr.Turnstile.Required { + log.Println("[chat] solving turnstile...") + tsToken, err := solveTurnstile() + if err != nil { + log.Printf("[chat] turnstile failed: %v, attempting without it", err) + } else if tsToken != "" { + headers["openai-sentinel-turnstile-token"] = tsToken + } + } + + msgID := uuid.NewString() + parentID := uuid.NewString() + body := map[string]any{ + "action": "next", + "messages": []any{ + map[string]any{ + "id": msgID, + "author": map[string]any{"role": "user"}, + "content": map[string]any{"content_type": "text", "parts": []string{prompt}}, + "metadata": map[string]any{}, + "create_time": float64(time.Now().Unix()), + }, + }, + "parent_message_id": parentID, + "model": cfg.DefaultModel, + "timezone_offset_min": cfg.TimezoneOffsetMin, + "history_and_training_disabled": false, + "force_paragen": false, + "force_rate_limit": false, + "websocket_request_id": uuid.NewString(), + "conversation_mode": map[string]any{"kind": "primary_assistant"}, + "suggestions": []any{}, + } + + log.Println("[chat] sending conversation request...") + r, err := callChatGPTAPI(apiCallParams{ + URL: "https://chatgpt.com/backend-api/conversation", + Method: "POST", + Headers: headers, + Body: body, + }, cfg.ChatTimeout()) + if err != nil { + return "", err + } + if r.Status != 200 { + return "", fmt.Errorf("conversation http %d: %s", r.Status, snippet(r.Body, 400)) + } + out := parseSSEFinal(r.Body) + if out == "" { + return "", fmt.Errorf("empty assistant response (raw len=%d): %s", len(r.Body), snippet(r.Body, 200)) + } + log.Printf("[chat] got response (%d chars)", len(out)) + return out, nil +} + +func parseSSEFinal(raw string) string { + var snapshot string + var delta strings.Builder + fileIDs := make(map[string]bool) + var currentContentType string + + var processDelta func(ev map[string]any) + processDelta = func(ev map[string]any) { + vVal, hasV := ev["v"] + if !hasV { + return + } + path, hasPath := ev["p"].(string) + op, _ := ev["o"].(string) + + if hasPath && strings.Contains(path, "/message/content/parts/0") { + if op == "replace" { + if s, ok := vVal.(string); ok { + delta.Reset() + delta.WriteString(s) + } + } else if op == "" || op == "append" { + if s, ok := vVal.(string); ok { + delta.WriteString(s) + } + } + } else if !hasPath { + if currentContentType == "text" || currentContentType == "multimodal_text" || currentContentType == "" { + if s, ok := vVal.(string); ok { + delta.WriteString(s) + } + } + } else if op == "patch" { + if subList, ok := vVal.([]any); ok { + for _, subVal := range subList { + if subMap, ok := subVal.(map[string]any); ok { + processDelta(subMap) + } + } + } + } + } + + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data:") { + continue + } + payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if payload == "" || payload == "[DONE]" { + continue + } + var ev map[string]any + if err := json.Unmarshal([]byte(payload), &ev); err != nil { + continue + } + if vVal, ok := ev["v"].(map[string]any); ok { + if msgObj, ok := vVal["message"].(map[string]any); ok { + if contentObj, ok := msgObj["content"].(map[string]any); ok { + if ct, ok := contentObj["content_type"].(string); ok { + currentContentType = ct + } + } + } + } else if msgObj, ok := ev["message"].(map[string]any); ok { + if contentObj, ok := msgObj["content"].(map[string]any); ok { + if ct, ok := contentObj["content_type"].(string); ok { + currentContentType = ct + } + } + } + if s := extractParts0(ev, fileIDs); s != "" { + snapshot = s + } + if v, ok := ev["v"]; ok { + if vm, ok := v.(map[string]any); ok { + if s := extractParts0(vm, fileIDs); s != "" { + snapshot = s + } + } + } + processDelta(ev) + } + out := snapshot + deltaStr := delta.String() + if delta.Len() > 0 { + isJSONBlock := strings.HasPrefix(deltaStr, `{"`) || strings.HasPrefix(deltaStr, `":"`) + if !isJSONBlock { + if delta.Len() > len(snapshot) { + out = deltaStr + } else if !strings.Contains(out, deltaStr) { + out += deltaStr + } + } + } + if len(fileIDs) > 0 { + var imageMarkdown strings.Builder + imageMarkdown.WriteString("\n\n") + for fileID := range fileIDs { + imageMarkdown.WriteString(fmt.Sprintf("![Generated Image](http://%s/api/download?file_id=%s)\n", cfg.ListenAddr, fileID)) + } + out += imageMarkdown.String() + } + return out; +} + +func extractParts0(ev map[string]any, fileIDs map[string]bool) string { + msg, ok := ev["message"].(map[string]any) + if !ok { + return "" + } + content, ok := msg["content"].(map[string]any) + if !ok { + return "" + } + parts, ok := content["parts"].([]any) + if !ok || len(parts) == 0 { + return "" + } + for _, part := range parts { + if partMap, ok := part.(map[string]any); ok { + if partMap["content_type"] == "image_asset_pointer" { + if assetPtr, ok := partMap["asset_pointer"].(string); ok { + if strings.HasPrefix(assetPtr, "file-service://") { + fID := strings.TrimPrefix(assetPtr, "file-service://") + fileIDs[fID] = true + } else if strings.HasPrefix(assetPtr, "sediment://") { + fID := strings.TrimPrefix(assetPtr, "sediment://") + fileIDs[fID] = true + } + } + } + } + } + s, _ := parts[0].(string) + return s +} + +func snippet(s string, n int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) > n { + s = s[:n] + "..." + } + return s +} + +// downloadChatGPTFile fetches the signed download URL from chatgpt.com backend API, +// requests it via the browser bridge (forcing base64 response), and decodes it back to raw bytes. +func downloadChatGPTFile(fileID string) ([]byte, error) { + log.Printf("[download] requesting download url for file %s...", fileID) + token, err := getSessionToken() + if err != nil { + return nil, fmt.Errorf("session token: %w", err) + } + + downloadURL := fmt.Sprintf("https://chatgpt.com/backend-api/files/%s/download", fileID) + r, err := callChatGPTAPI(apiCallParams{ + URL: downloadURL, + Method: "GET", + Headers: map[string]string{ + "Authorization": "Bearer " + token, + }, + }, cfg.APITimeout()) + if err != nil { + return nil, fmt.Errorf("file download api call: %w", err) + } + if r.Status != 200 { + return nil, fmt.Errorf("file download http %d: %s", r.Status, snippet(r.Body, 200)) + } + + var dlInfo struct { + Status string `json:"status"` + DownloadURL string `json:"download_url"` + } + if err := json.Unmarshal([]byte(r.Body), &dlInfo); err != nil { + return nil, fmt.Errorf("decode file download info: %w", err) + } + if dlInfo.DownloadURL == "" { + return nil, fmt.Errorf("empty download url returned: %s", r.Body) + } + + log.Printf("[download] downloading binary from signed url: %s", snippet(dlInfo.DownloadURL, 100)) + rBin, err := callChatGPTAPI(apiCallParams{ + URL: dlInfo.DownloadURL, + Method: "GET", + ResponseType: "base64", + }, cfg.ChatTimeout()) + if err != nil { + return nil, fmt.Errorf("fetch binary: %w", err) + } + if rBin.Status != 200 { + return nil, fmt.Errorf("fetch binary http %d: %s", rBin.Status, snippet(rBin.Body, 200)) + } + + if rBin.IsBase64 { + data, err := base64.StdEncoding.DecodeString(rBin.Body) + if err != nil { + return nil, fmt.Errorf("decode base64 response: %w", err) + } + return data, nil + } + + return []byte(rBin.Body), nil +} + +type conversationResponse struct { + CurrentNode string `json:"current_node"` + Mapping map[string]struct { + Message *struct { + ID string `json:"id"` + Author struct { + Role string `json:"role"` + } `json:"author"` + Status string `json:"status"` + Content *struct { + ContentType string `json:"content_type"` + Parts []any `json:"parts"` + } `json:"content"` + } `json:"message"` + } `json:"mapping"` +} + +func extractImageFileIDs(data []byte) []string { + var resp conversationResponse + var ids []string + if err := json.Unmarshal(data, &resp); err != nil { + return nil + } + for _, node := range resp.Mapping { + if node.Message == nil || node.Message.Content == nil { + continue + } + if node.Message.Content.ContentType == "multimodal_text" { + for _, part := range node.Message.Content.Parts { + if partMap, ok := part.(map[string]any); ok { + if partMap["content_type"] == "image_asset_pointer" { + if assetPtr, ok := partMap["asset_pointer"].(string); ok { + var id string + if strings.HasPrefix(assetPtr, "file-service://") { + id = strings.TrimPrefix(assetPtr, "file-service://") + } else if strings.HasPrefix(assetPtr, "sediment://") { + id = strings.TrimPrefix(assetPtr, "sediment://") + } + if id != "" { + ids = append(ids, id) + } + } + } + } + } + } + } + if len(ids) == 0 { + var regex = regexp.MustCompile(`(file_[0-9a-fA-F]{32}|file-[a-zA-Z0-9_-]{24,36})`) + matches := regex.FindAllString(string(data), -1) + seen := make(map[string]bool) + for _, m := range matches { + if !seen[m] { + seen[m] = true + ids = append(ids, m) + } + } + } + return ids +} + +var sanitizeRegexp = regexp.MustCompile(`[^a-zA-Z0-9\s-_]`) +var spaceRegexp = regexp.MustCompile(`\s+`) + +func getPromptFilename(prompt, fileID string) string { + // 1. Lowercase + s := strings.ToLower(prompt) + + // 2. Remove common command/generation prefixes + prefixes := []string{ + "generate an image:", + "generate a image:", + "generate image:", + "create an image:", + "create a image:", + "create image:", + "draw an image:", + "draw a image:", + "draw image:", + "make an image:", + "make a image:", + "make image:", + "generate a drawing of", + "generate an image of", + "generate drawing of", + "generate image of", + "generate a", + "generate an", + "generate", + "create an image of", + "create a drawing of", + "create image of", + "create drawing of", + "create a", + "create", + "draw a", + "draw", + } + for _, prefix := range prefixes { + if strings.HasPrefix(s, prefix) { + s = strings.TrimPrefix(s, prefix) + break + } + } + s = strings.TrimSpace(s) + + // 2b. Strip leading articles (a, an, the) for cleaner names + articles := []string{"a ", "an ", "the "} + for _, art := range articles { + if strings.HasPrefix(s, art) { + s = strings.TrimPrefix(s, art) + break + } + } + s = strings.TrimSpace(s) + + // 3. Remove non-alphanumeric characters + s = sanitizeRegexp.ReplaceAllString(s, "") + + // 4. Replace spaces with underscores + s = spaceRegexp.ReplaceAllString(s, "_") + + // 5. Keep only first 3 words for a clean short name + parts := strings.SplitN(s, "_", 4) + if len(parts) > 3 { + parts = parts[:3] + } + s = strings.Join(parts, "_") + + if s == "" { + s = "image" + } + + // 6. Append short file suffix for uniqueness (last 8 characters of ID) + suffix := fileID + if len(suffix) > 8 { + suffix = suffix[len(suffix)-8:] + } + + return fmt.Sprintf("%s_%s", s, suffix) +} + +func pollForImage(prompt, conversationID string, timeout time.Duration, excludeIDs ...string) (string, error) { + deadline := time.Now().Add(timeout) + + excludeMap := make(map[string]bool) + for _, id := range excludeIDs { + excludeMap[id] = true + } + + // Track all generated image IDs we've found and processed + foundImages := make(map[string]bool) + var generatedIDs []string + + for time.Now().Before(deadline) { + token, err := getSessionToken() + if err != nil { + time.Sleep(1 * time.Second) + continue + } + res, err := callChatGPTAPI(apiCallParams{ + URL: "https://chatgpt.com/backend-api/conversation/" + conversationID, + Method: "GET", + Headers: map[string]string{ + "Authorization": "Bearer " + token, + }, + }, cfg.APITimeout()) + if err != nil { + log.Printf("[poll] ❌ callChatGPTAPI failed: %v", err) + time.Sleep(1 * time.Second) + continue + } + log.Printf("[poll] Polled conversation %s, status=%d, bodyLen=%d", conversationID, res.Status, len(res.Body)) + if res.Status == 200 { + _ = os.WriteFile("debug_poll.json", []byte(res.Body), 0644) + + var pollResp conversationResponse + if err := json.Unmarshal([]byte(res.Body), &pollResp); err == nil { + ids := extractImageFileIDs([]byte(res.Body)) + + // Find any new generated image IDs we haven't seen yet + var newIDs []string + for _, id := range ids { + if !excludeMap[id] && !foundImages[id] { + foundImages[id] = true + newIDs = append(newIDs, id) + generatedIDs = append(generatedIDs, id) + } + } + + // Download any new images immediately + for _, id := range newIDs { + registerFilePrompt(id, prompt) + name := getPromptFilename(prompt, id) + log.Printf("[auto-download] Auto-downloading generated image %s (%s)...", id, name) + + var data []byte + var dlErr error + // Try to download with retries (in case the file metadata is created but binary isn't fully ready yet on OpenAI backend) + for attempt := 1; attempt <= 5; attempt++ { + data, dlErr = downloadChatGPTFile(id) + if dlErr == nil { + break + } + time.Sleep(1 * time.Second) + } + if dlErr == nil { + _ = os.MkdirAll("output", 0755) + localPath := "output/" + name + ".png" + dlErr = os.WriteFile(localPath, data, 0644) + if dlErr != nil { + log.Printf("[auto-download] Error saving file %s locally: %v", localPath, dlErr) + } else { + log.Printf("[auto-download] Successfully saved image to %s", localPath) + } + } else { + log.Printf("[auto-download] Error downloading image %s: %v", id, dlErr) + } + } + + // Break early if we found new generated images + if len(generatedIDs) > 0 { + log.Printf("[poll] Found generated images: %v. Stopping poll.", generatedIDs) + break + } + + // Check if the conversation leaf is finished and not generating + if leaf, exists := pollResp.Mapping[pollResp.CurrentNode]; exists && leaf.Message != nil { + role := leaf.Message.Author.Role + status := leaf.Message.Status + + isFinishedAssistant := role == "assistant" && status == "finished_successfully" + isFinishedToolWithImage := role == "tool" && status == "finished_successfully" && + leaf.Message.Content != nil && hasImagePointer(leaf.Message.Content.Parts) + + if isFinishedAssistant || isFinishedToolWithImage { + log.Printf("[poll] Conversation leaf is finished (%s, %s). Stopping poll.", role, status) + break + } + } + } + } + time.Sleep(1500 * time.Millisecond) + } + + // Compile and return the markdown response for all generated images + if len(generatedIDs) > 0 { + var markdown strings.Builder + markdown.WriteString("Here is your generated image:\n\n") + for _, id := range generatedIDs { + markdown.WriteString(fmt.Sprintf("![Generated Image](http://127.0.0.1:9225/api/download?file_id=%s&prompt=%s)\n", id, url.QueryEscape(prompt))) + } + return markdown.String(), nil + } + + return "", fmt.Errorf("timeout waiting for image generation in conversation %s", conversationID) +} + +func hasImagePointer(parts []any) bool { + for _, part := range parts { + if partMap, ok := part.(map[string]any); ok { + if partMap["content_type"] == "image_asset_pointer" { + return true + } + } + } + return false +} + +// uploadFileToChatGPT uploads a local file to ChatGPT via the backend API and returns a FileAttachment. +// It uses the extension's api_request method to call /backend-api/files with proper auth. +func uploadFileToChatGPT(filePath string) (*FileAttachment, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + + fileName := filepath.Base(filePath) + mimeType := "image/png" + ext := strings.ToLower(filepath.Ext(fileName)) + switch ext { + case ".jpg", ".jpeg": + mimeType = "image/jpeg" + case ".webp": + mimeType = "image/webp" + case ".gif": + mimeType = "image/gif" + } + + fileSize := int64(len(data)) + + log.Printf("[upload] Uploading file %s (%d bytes, %s) to ChatGPT...", fileName, fileSize, mimeType) + + // Step 1: Create file upload via backend API + token, err := getSessionToken() + if err != nil { + return nil, fmt.Errorf("get session token: %w", err) + } + + createBody := map[string]any{ + "file_name": fileName, + "file_size": fileSize, + "use_case": "multimodal", + } + bodyJSON, _ := json.Marshal(createBody) + res, err := callChatGPTAPI(apiCallParams{ + URL: "https://chatgpt.com/backend-api/files", + Method: "POST", + Headers: map[string]string{ + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + }, + Body: string(bodyJSON), + }, cfg.APITimeout()) + if err != nil { + return nil, fmt.Errorf("create file: %w", err) + } + if res.Status != 200 { + return nil, fmt.Errorf("create file status %d: %s", res.Status, res.Body[:min(len(res.Body), 500)]) + } + + var createResp struct { + FileID string `json:"file_id"` + UploadURL string `json:"upload_url"` + Status string `json:"status"` + } + if err := json.Unmarshal([]byte(res.Body), &createResp); err != nil { + return nil, fmt.Errorf("parse create response: %w", err) + } + + log.Printf("[upload] File created with ID: %s, upload URL: %s", createResp.FileID, createResp.UploadURL[:min(len(createResp.UploadURL), 80)]) + + // Step 2: Upload file data to the upload URL via extension (browser context for proper auth) + b64Data := base64.StdEncoding.EncodeToString(data) + uploadRes, err := callChatGPTAPI(apiCallParams{ + URL: createResp.UploadURL, + Method: "PUT", + Headers: map[string]string{ + "Content-Type": mimeType, + "X-Ms-Blob-Type": "BlockBlob", + "X-Ms-Version": "2020-04-08", + }, + Body: b64Data, + }, cfg.APITimeout()) + if err != nil { + return nil, fmt.Errorf("upload file data: %w", err) + } + if uploadRes.Status >= 300 { + return nil, fmt.Errorf("upload file data status %d: %s", uploadRes.Status, uploadRes.Body[:min(len(uploadRes.Body), 200)]) + } + + log.Printf("[upload] File data uploaded successfully, marking as uploaded...") + + // Step 3: Mark file as uploaded + markBody := map[string]any{} + markJSON, _ := json.Marshal(markBody) + markRes, err := callChatGPTAPI(apiCallParams{ + URL: fmt.Sprintf("https://chatgpt.com/backend-api/files/%s/uploaded", createResp.FileID), + Method: "POST", + Headers: map[string]string{ + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + }, + Body: string(markJSON), + }, cfg.APITimeout()) + if err != nil { + log.Printf("[upload] Warning: mark uploaded failed: %v", err) + } else { + log.Printf("[upload] Mark uploaded response: status=%d", markRes.Status) + } + + // Step 4: Wait for processing + for i := 0; i < 10; i++ { + time.Sleep(1 * time.Second) + checkRes, err := callChatGPTAPI(apiCallParams{ + URL: fmt.Sprintf("https://chatgpt.com/backend-api/files/%s", createResp.FileID), + Method: "GET", + Headers: map[string]string{ + "Authorization": "Bearer " + token, + }, + }, cfg.APITimeout()) + if err != nil { + continue + } + var fileStatus struct { + Status string `json:"status"` + FileID string `json:"file_id"` + } + if err := json.Unmarshal([]byte(checkRes.Body), &fileStatus); err == nil { + log.Printf("[upload] File %s status: %s", createResp.FileID, fileStatus.Status) + if fileStatus.Status == "success" || fileStatus.Status == "ready" { + break + } + } + } + + log.Printf("[upload] File upload complete: %s (%s)", createResp.FileID, fileName) + + return &FileAttachment{ + ID: createResp.FileID, + Name: fileName, + Size: fileSize, + MimeType: mimeType, + }, nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + diff --git a/chatgpt-free-api/config.go b/chatgpt-free-api/config.go new file mode 100644 index 0000000000000000000000000000000000000000..f3348ad83b3df2ad95bc34de5f00eb0fdf805623 --- /dev/null +++ b/chatgpt-free-api/config.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/json" + "log" + "os" + "path/filepath" + "time" +) + +// AppConfig holds all configurable values for the agent. +type AppConfig struct { + ListenAddr string `json:"listen_addr"` + DefaultModel string `json:"default_model"` + DefaultThinkingEffort string `json:"default_thinking_effort"` + TimeoutSeconds int `json:"timeout_seconds"` + ExtensionWaitSeconds int `json:"extension_wait_seconds"` + ChatTimeoutSeconds int `json:"chat_timeout_seconds"` + APITimeoutSeconds int `json:"api_timeout_seconds"` + MaxSniffs int `json:"max_sniffs"` + BulkDelaySeconds int `json:"bulk_delay_seconds"` + BrowserWakeCooldownSecs int `json:"browser_wake_cooldown_seconds"` + TimezoneOffsetMin int `json:"timezone_offset_min"` + RawSSEOutputPath string `json:"raw_sse_output_path"` +} + +// Convenience duration helpers +func (c *AppConfig) Timeout() time.Duration { + return time.Duration(c.TimeoutSeconds) * time.Second +} + +func (c *AppConfig) ExtensionWait() time.Duration { + return time.Duration(c.ExtensionWaitSeconds) * time.Second +} + +func (c *AppConfig) ChatTimeout() time.Duration { + return time.Duration(c.ChatTimeoutSeconds) * time.Second +} + +func (c *AppConfig) APITimeout() time.Duration { + return time.Duration(c.APITimeoutSeconds) * time.Second +} + +func (c *AppConfig) BrowserWakeCooldown() time.Duration { + return time.Duration(c.BrowserWakeCooldownSecs) * time.Second +} + +// cfg is the global config instance used throughout the app. +var cfg = defaultConfig() + +func defaultConfig() AppConfig { + return AppConfig{ + ListenAddr: "127.0.0.1:9224", + DefaultModel: "auto", + DefaultThinkingEffort: "", + TimeoutSeconds: 300, + ExtensionWaitSeconds: 10, + ChatTimeoutSeconds: 240, + APITimeoutSeconds: 30, + MaxSniffs: 250, + BulkDelaySeconds: 2, + BrowserWakeCooldownSecs: 60, + TimezoneOffsetMin: -330, + RawSSEOutputPath: "", + } +} + +// loadConfig reads config.json from the same directory as the executable. +// If the file doesn't exist, defaults are used silently. +func loadConfig() { + // Try config.json next to the binary first, then in CWD + paths := []string{} + + if exe, err := os.Executable(); err == nil { + paths = append(paths, filepath.Join(filepath.Dir(exe), "config.json")) + } + paths = append(paths, "config.json") + + var data []byte + var loadedPath string + for _, p := range paths { + d, err := os.ReadFile(p) + if err == nil { + data = d + loadedPath = p + break + } + } + + if data == nil { + log.Println("[config] No config.json found, using defaults") + return + } + + if err := json.Unmarshal(data, &cfg); err != nil { + log.Printf("[config] Error parsing %s: %v — using defaults", loadedPath, err) + cfg = defaultConfig() + return + } + + log.Printf("[config] Loaded from %s", loadedPath) +} diff --git a/chatgpt-free-api/config.json b/chatgpt-free-api/config.json new file mode 100644 index 0000000000000000000000000000000000000000..ee7475ad4a10fabb267f45dc04eab2e80a1707c3 --- /dev/null +++ b/chatgpt-free-api/config.json @@ -0,0 +1,14 @@ +{ + "listen_addr": "127.0.0.1:9225", + "default_model": "gpt-5-5", + "default_thinking_effort": "", + "timeout_seconds": 300, + "extension_wait_seconds": 10, + "chat_timeout_seconds": 240, + "api_timeout_seconds": 30, + "max_sniffs": 250, + "bulk_delay_seconds": 2, + "browser_wake_cooldown_seconds": 60, + "timezone_offset_min": -330, + "raw_sse_output_path": "/Users/akashyadav/.gemini/antigravity-ide/scratch/raw_sse.txt" +} diff --git a/chatgpt-free-api/cookies.go b/chatgpt-free-api/cookies.go new file mode 100644 index 0000000000000000000000000000000000000000..79450f1f45c5428ea2175eb3712228df99c6cea5 --- /dev/null +++ b/chatgpt-free-api/cookies.go @@ -0,0 +1,477 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +// CookieObject represents a browser cookie from the extension +type CookieObject struct { + Domain string `json:"domain"` + ExpirationDate float64 `json:"expirationDate,omitempty"` + HostOnly bool `json:"hostOnly,omitempty"` + HttpOnly bool `json:"httpOnly,omitempty"` + Name string `json:"name"` + Path string `json:"path"` + SameSite string `json:"sameSite,omitempty"` + Secure bool `json:"secure,omitempty"` + Session bool `json:"session,omitempty"` + StoreId string `json:"storeId,omitempty"` + Value string `json:"value"` +} + +type CookiePayloadMessage struct { + Type string `json:"type"` + Cookies []CookieObject `json:"cookies"` +} + +const CookiesFile = "cookies.json" + +var ( + cachedCookies []CookieObject + cachedCookiesMu sync.RWMutex + lastCookieSync time.Time +) + +// GetCachedCookieHeader returns a cookie header string for HTTP requests to chatgpt.com +func GetCachedCookieHeader() string { + cachedCookiesMu.RLock() + defer cachedCookiesMu.RUnlock() + var parts []string + for _, ck := range cachedCookies { + parts = append(parts, fmt.Sprintf("%s=%s", ck.Name, ck.Value)) + } + return strings.Join(parts, "; ") +} + +// GetCookieValue returns the value of a specific cookie by name +func GetCookieValue(name string) string { + cachedCookiesMu.RLock() + defer cachedCookiesMu.RUnlock() + for _, ck := range cachedCookies { + if ck.Name == name { + return ck.Value + } + } + return "" +} + +// HasCookies returns true if we have synced cookies +func HasCookies() bool { + cachedCookiesMu.RLock() + defer cachedCookiesMu.RUnlock() + return len(cachedCookies) > 0 +} + +// GetLastCookieSync returns the time of the last cookie sync +func GetLastCookieSync() time.Time { + cachedCookiesMu.RLock() + defer cachedCookiesMu.RUnlock() + return lastCookieSync +} + +// LoadCookiesFromFile loads cookies from cookies.json at startup +func LoadCookiesFromFile() { + data, err := os.ReadFile(CookiesFile) + if err != nil { + log.Printf("[cookies] No existing cookies.json found: %v", err) + return + } + var cookies []CookieObject + if err := json.Unmarshal(data, &cookies); err != nil { + log.Printf("[cookies] Error parsing cookies.json: %v", err) + return + } + cachedCookiesMu.Lock() + cachedCookies = cookies + lastCookieSync = time.Now() + cachedCookiesMu.Unlock() + log.Printf("[cookies] Loaded %d cookies from disk", len(cookies)) +} + +// HandleCookiePayload processes cookies received from the extension +func HandleCookiePayload(cookies []CookieObject) { + log.Printf("🍪 Received %d cookies from extension", len(cookies)) + + // Save to cookies.json + data, err := json.MarshalIndent(cookies, "", " ") + if err != nil { + log.Printf("❌ Failed to marshal cookies: %v", err) + return + } + + if err := os.WriteFile(CookiesFile, data, 0644); err != nil { + log.Printf("❌ Failed to save cookies.json: %v", err) + return + } + + // Update in-memory cache + cachedCookiesMu.Lock() + cachedCookies = cookies + lastCookieSync = time.Now() + cachedCookiesMu.Unlock() + + log.Printf("🍪 Cached %d cookies for direct API calls", len(cookies)) +} + +// ─── Direct HTTP client using cookies ─────────────────────────── + +// DirectHTTPResponse holds the response from a direct HTTP request +type DirectHTTPResponse struct { + Status int `json:"status"` + Body string `json:"body"` + Headers map[string]string `json:"headers"` +} + +// DirectHTTPRequest makes an HTTP request to chatgpt.com using synced cookies +func DirectHTTPRequest(method, urlStr string, headers map[string]string, body string) (*DirectHTTPResponse, error) { + if !HasCookies() { + return nil, fmt.Errorf("no cookies available — extension has not synced yet") + } + + var bodyReader io.Reader + if body != "" { + bodyReader = strings.NewReader(body) + } + + req, err := http.NewRequest(method, urlStr, bodyReader) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + // Set cookies + req.Header.Set("Cookie", GetCachedCookieHeader()) + + // Set default headers for ChatGPT + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Language", "en-US,en;q=0.9") + req.Header.Set("Origin", "https://chatgpt.com") + req.Header.Set("Referer", "https://chatgpt.com/") + req.Header.Set("Sec-Fetch-Dest", "empty") + req.Header.Set("Sec-Fetch-Mode", "cors") + req.Header.Set("Sec-Fetch-Site", "same-origin") + req.Header.Set("sec-ch-ua", `"Not(A:Brand";v="8", "Chromium";v="146", "Google Chrome";v="146"`) + req.Header.Set("sec-ch-ua-mobile", "?0") + req.Header.Set("sec-ch-ua-platform", `"macOS"`) + + // Set oai-did from cookies + oaiDid := GetCookieValue("oai-did") + if oaiDid != "" { + req.Header.Set("OAI-Device-Id", oaiDid) + } + req.Header.Set("OAI-Language", "en-US") + + // Override with custom headers + for k, v := range headers { + req.Header.Set(k, v) + } + + client := &http.Client{ + Timeout: 120 * time.Second, + } + + log.Printf("[direct-http] Requesting %s %s...", method, urlStr) + resp, err := client.Do(req) + if err != nil { + log.Printf("[direct-http] ❌ Connection error: %v", err) + return nil, fmt.Errorf("http request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("[direct-http] ❌ Read response error: %v", err) + return nil, fmt.Errorf("read response: %w", err) + } + + log.Printf("[direct-http] Response status: %d (body length: %d bytes)", resp.StatusCode, len(respBody)) + + respHeaders := make(map[string]string) + for k, v := range resp.Header { + if len(v) > 0 { + respHeaders[k] = v[0] + } + } + + return &DirectHTTPResponse{ + Status: resp.StatusCode, + Body: string(respBody), + Headers: respHeaders, + }, nil +} + +// ─── Direct ChatGPT API Functions ─────────────────────────────── + +// GetSessionTokenDirect gets the session token directly using cookies (no extension needed) +func GetSessionTokenDirect() (string, error) { + resp, err := DirectHTTPRequest("GET", "https://chatgpt.com/api/auth/session", nil, "") + if err != nil { + return "", fmt.Errorf("session request: %w", err) + } + if resp.Status != 200 { + return "", fmt.Errorf("session http %d: %s", resp.Status, snippet(resp.Body, 200)) + } + var s struct { + AccessToken string `json:"accessToken"` + } + if err := json.Unmarshal([]byte(resp.Body), &s); err != nil { + return "", fmt.Errorf("session decode: %w", err) + } + if s.AccessToken == "" { + return "", fmt.Errorf("no access token (not logged in)") + } + return s.AccessToken, nil +} + +// GetChatRequirementsDirect gets chat requirements directly using cookies +func GetChatRequirementsDirect(token string) (*chatGPTRequirements, error) { + resp, err := DirectHTTPRequest("POST", "https://chatgpt.com/backend-api/sentinel/chat-requirements", + map[string]string{ + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + }, + `{"p":""}`, + ) + if err != nil { + return nil, err + } + if resp.Status != 200 { + return nil, fmt.Errorf("requirements http %d: %s", resp.Status, snippet(resp.Body, 200)) + } + var cr chatGPTRequirements + if err := json.Unmarshal([]byte(resp.Body), &cr); err != nil { + return nil, fmt.Errorf("requirements decode: %w", err) + } + return &cr, nil +} + +// SendChatDirect sends a conversation request directly to ChatGPT using cookies (no extension needed) +// Returns: response text, rawSSE, conversation_id, error +func SendChatDirect(prompt, conversationID, model, thinkingEffort string) (string, string, string, error) { + if !HasCookies() { + return "", "", "", fmt.Errorf("no cookies — extension has not synced") + } + + log.Println("[direct] Getting session token...") + token, err := GetSessionTokenDirect() + if err != nil { + log.Printf("[direct] ❌ Session token fetch failed: %v", err) + return "", "", "", fmt.Errorf("session: %w", err) + } + + log.Println("[direct] Getting chat requirements...") + cr, err := GetChatRequirementsDirect(token) + if err != nil { + log.Printf("[direct] ❌ Chat requirements fetch failed: %v", err) + return "", "", "", fmt.Errorf("requirements: %w", err) + } + + headers := map[string]string{ + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + "Accept": "text/event-stream", + "openai-sentinel-chat-requirements-token": cr.Token, + } + + // PoW solving + if cr.Proofofwork.Required { + log.Printf("[direct] Solving proof-of-work (seed=%s, diff=%s)", cr.Proofofwork.Seed, cr.Proofofwork.Difficulty) + powToken := SolveFNVPow(cr.Proofofwork.Seed, cr.Proofofwork.Difficulty) + if powToken != "" { + headers["openai-sentinel-proof-token"] = powToken + } else { + log.Println("[direct] PoW solve failed, proceeding without it") + } + } + + // Turnstile — can't solve from Go, skip + if cr.Turnstile.Required { + log.Println("[direct] ⚠️ Turnstile required — cannot solve from Go, falling back to extension") + return "", "", "", fmt.Errorf("TURNSTILE_REQUIRED") + } + + // Resolve parent message ID + parentID := "client-created-root" + if conversationID != "" { + convResp, err := DirectHTTPRequest("GET", + "https://chatgpt.com/backend-api/conversation/"+conversationID, + map[string]string{"Authorization": "Bearer " + token}, + "", + ) + if err == nil && convResp.Status == 200 { + var convData struct { + CurrentNode string `json:"current_node"` + } + if json.Unmarshal([]byte(convResp.Body), &convData) == nil && convData.CurrentNode != "" { + parentID = convData.CurrentNode + } + } + } + + // Build conversation body + actualModel := model + var resolvedThinkingEffort string + if thinkingEffort != "" { + resolvedThinkingEffort = thinkingEffort + } + if strings.Contains(model, "thinking") { + if strings.Contains(model, "-extended") { + resolvedThinkingEffort = "extended" + actualModel = strings.Replace(model, "-extended", "", 1) + } else if strings.Contains(model, "-standard") { + resolvedThinkingEffort = "standard" + actualModel = strings.Replace(model, "-standard", "", 1) + } else if resolvedThinkingEffort == "" { + resolvedThinkingEffort = "standard" + } + } + + convBody := map[string]any{ + "action": "next", + "messages": []any{ + map[string]any{ + "id": uuid.NewString(), + "author": map[string]any{"role": "user"}, + "content": map[string]any{ + "content_type": "text", + "parts": []string{prompt}, + }, + "metadata": map[string]any{}, + "create_time": float64(time.Now().Unix()), + }, + }, + "parent_message_id": parentID, + "model": actualModel, + "timezone_offset_min": cfg.TimezoneOffsetMin, + "timezone": "Asia/Kolkata", + "history_and_training_disabled": false, + "fork_from_shared_post": false, + "force_paragen": false, + "force_rate_limit": false, + "conversation_mode": map[string]any{"kind": "primary_assistant"}, + "enable_message_followups": true, + "system_hints": []any{}, + "supports_buffering": true, + "supported_encodings": []string{"v1"}, + "paragen_cot_summary_display_override": "allow", + "force_parallel_switch": "auto", + "websocket_request_id": uuid.NewString(), + "client_contextual_info": map[string]any{ + "is_dark_mode": false, + "time_since_loaded": 0, + "page_height": 800, + "page_width": 1200, + "pixel_ratio": 1, + "screen_height": 1080, + "screen_width": 1920, + }, + } + if conversationID != "" { + convBody["conversation_id"] = conversationID + } + if resolvedThinkingEffort != "" { + convBody["thinking_effort"] = resolvedThinkingEffort + } + + bodyJSON, _ := json.Marshal(convBody) + + log.Println("[direct] Sending conversation request...") + resp, err := DirectHTTPRequest("POST", "https://chatgpt.com/backend-api/conversation", headers, string(bodyJSON)) + if err != nil { + log.Printf("[direct] ❌ Conversation request failed: %v", err) + return "", "", "", fmt.Errorf("conversation request: %w", err) + } + if resp.Status != 200 { + log.Printf("[direct] ❌ Conversation HTTP %d: %s", resp.Status, snippet(resp.Body, 400)) + return "", "", "", fmt.Errorf("conversation http %d: %s", resp.Status, snippet(resp.Body, 400)) + } + + // Parse SSE response + text := parseSSEFinal(resp.Body) + if text == "" { + return "", resp.Body, "", fmt.Errorf("empty assistant response (raw len=%d)", len(resp.Body)) + } + + // Extract conversation_id from SSE + newConvID := extractConversationID(resp.Body) + + // Save raw SSE if configured + if cfg.RawSSEOutputPath != "" { + _ = os.WriteFile(cfg.RawSSEOutputPath, []byte(resp.Body), 0644) + } + + log.Printf("[direct] Got response (%d chars), conversation_id: %s", len(text), newConvID) + return text, resp.Body, newConvID, nil +} + +// extractConversationID pulls conversation_id from SSE stream +func extractConversationID(raw string) string { + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data:") { + continue + } + payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if payload == "" || payload == "[DONE]" { + continue + } + var ev map[string]any + if err := json.Unmarshal([]byte(payload), &ev); err != nil { + continue + } + if cid, ok := ev["conversation_id"].(string); ok && cid != "" { + return cid + } + if v, ok := ev["v"].(map[string]any); ok { + if cid, ok := v["conversation_id"].(string); ok && cid != "" { + return cid + } + } + } + return "" +} + +// ─── FNV PoW Solver ───────────────────────────────────────────── + +// SolveFNVPow solves ChatGPT's FNV-based proof of work natively in Go +func SolveFNVPow(seed, difficulty string) string { + for nonce := 0; nonce < 500000; nonce++ { + // Build a config array matching the browser's format + config := fmt.Sprintf(`[3000,"%s",4294705152,%d,"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",null,"","en-US","en-US",%d,"hardwareConcurrency−8","location","self",0,"%s","",8,%d]`, + time.Now().String(), nonce, nonce, uuid.NewString(), time.Now().UnixMilli()) + + encoded := base64.StdEncoding.EncodeToString([]byte(config)) + input := seed + encoded + + hash := fnvHash(input) + if len(hash) >= len(difficulty) && hash[:len(difficulty)] <= difficulty { + return "gAAAAAB" + encoded + "~S" + } + } + return "" +} + +func fnvHash(input string) string { + h := uint32(2166136261) + for i := 0; i < len(input); i++ { + h ^= uint32(input[i]) + h *= 16777619 + } + h ^= h >> 16 + h *= 2246822507 + h ^= h >> 13 + h *= 3266489909 + h ^= h >> 16 + return fmt.Sprintf("%08x", h) +} diff --git a/chatgpt-free-api/cookies_test.go b/chatgpt-free-api/cookies_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5e510198ed82b0b460413f1e6b10231830b3b768 --- /dev/null +++ b/chatgpt-free-api/cookies_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +func TestCookiesLifecycle(t *testing.T) { + // 1. Prepare dummy cookies + dummyCookies := []CookieObject{ + { + Domain: ".chatgpt.com", + Name: "oai-did", + Value: "test-device-id-123", + }, + { + Domain: ".chatgpt.com", + Name: "__Secure-next-auth.session-token", + Value: "test-session-token-abc", + }, + } + + // 2. Write dummy cookies to test file path (temporarily override CookiesFile or use the function) + // Since CookiesFile is a constant ("cookies.json"), we should back up existing cookies.json if it exists, + // write our dummy data, run the test, and restore it. + const testFile = "cookies.json" + var backupData []byte + backupExists := false + + if _, err := os.Stat(testFile); err == nil { + backupData, err = os.ReadFile(testFile) + if err == nil { + backupExists = true + } + } + + // Clean up after test + defer func() { + if backupExists { + _ = os.WriteFile(testFile, backupData, 0644) + } else { + _ = os.Remove(testFile) + } + }() + + // Write test cookies + data, err := json.Marshal(dummyCookies) + if err != nil { + t.Fatalf("Failed to marshal dummy cookies: %v", err) + } + if err := os.WriteFile(testFile, data, 0644); err != nil { + t.Fatalf("Failed to write test cookies.json: %v", err) + } + + // 3. Test LoadCookiesFromFile + LoadCookiesFromFile() + + if !HasCookies() { + t.Errorf("Expected HasCookies() to be true, got false") + } + + // 4. Test GetCookieValue + oaiDid := GetCookieValue("oai-did") + if oaiDid != "test-device-id-123" { + t.Errorf("Expected oai-did to be 'test-device-id-123', got '%s'", oaiDid) + } + + // 5. Test GetCachedCookieHeader + header := GetCachedCookieHeader() + if !strings.Contains(header, "oai-did=test-device-id-123") { + t.Errorf("Expected header to contain 'oai-did=test-device-id-123', got '%s'", header) + } + if !strings.Contains(header, "__Secure-next-auth.session-token=test-session-token-abc") { + t.Errorf("Expected header to contain session token, got '%s'", header) + } + + // 6. Test HandleCookiePayload directly + newDummyCookies := []CookieObject{ + { + Domain: ".chatgpt.com", + Name: "oai-did", + Value: "updated-device-id", + }, + } + HandleCookiePayload(newDummyCookies) + + updatedOaiDid := GetCookieValue("oai-did") + if updatedOaiDid != "updated-device-id" { + t.Errorf("Expected updated oai-did to be 'updated-device-id', got '%s'", updatedOaiDid) + } +} + +func TestSolveFNVPow(t *testing.T) { + // Verify that the solver functions and can find a solution for a very easy difficulty + // Or at least it doesn't crash. + // Difficulty is a hex string prefix. Let's pass a very high/easy difficulty threshold + // to make sure it finishes quickly. "ffff" means any hash <= "ffff" (which is almost all of them since it's 8 hex chars max). + // Let's use "9" as difficulty which is easy enough to solve in a few nonces. + token := SolveFNVPow("0.abcdef123456", "9") + t.Logf("Solved PoW token: %s", token) + // It should either solve it or exit. Let's make sure it doesn't crash. +} diff --git a/chatgpt-free-api/extension.go b/chatgpt-free-api/extension.go new file mode 100644 index 0000000000000000000000000000000000000000..eaf93c58d4dfb94ab7675006b081a0713858e240 --- /dev/null +++ b/chatgpt-free-api/extension.go @@ -0,0 +1,161 @@ +package main + +import ( + "encoding/json" + "log" + "os" + "os/exec" + "runtime" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +type WSMessage struct { + Type string `json:"type,omitempty"` + Method string `json:"method,omitempty"` + ID string `json:"id,omitempty"` + Params map[string]any `json:"params,omitempty"` + Result any `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +var ( + extConn *websocket.Conn + extMu sync.Mutex + reloadTriggered bool + pendingMu sync.Mutex + pending = map[string]chan WSMessage{} + extInfo map[string]any + lastBrowserOpenMu sync.Mutex + lastBrowserOpen time.Time +) + +func isExtensionConnected() bool { + extMu.Lock() + defer extMu.Unlock() + return extConn != nil +} + +func getExtensionInfo() map[string]any { + extMu.Lock() + defer extMu.Unlock() + if extInfo == nil { + return nil + } + out := map[string]any{} + for k, v := range extInfo { + out[k] = v + } + return out +} + +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + case "darwin": + cmd = exec.Command("open", url) + default: // "linux", "freebsd", etc. + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} + +func wakeUpExtension() { + log.Println("[agent] Extension not connected. Waiting for background connection...") +} + +func waitForExtension(timeout time.Duration) bool { + if isExtensionConnected() { + return true + } + wakeUpExtension() + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if isExtensionConnected() { + return true + } + time.Sleep(250 * time.Millisecond) + } + return false +} + +func handleExtensionMessage(msg WSMessage) { + if msg.Result != nil { + if m, ok := msg.Result.(map[string]any); ok { + if raw, ok := m["rawText"].(string); ok { + if cfg.RawSSEOutputPath == "" { + return + } + err := os.WriteFile(cfg.RawSSEOutputPath, []byte(raw), 0644) + if err != nil { + log.Printf("ERROR WRITING RAW SSE: %v", err) + } + } + } + } + + if msg.ID == "" { + if msg.Type == "extension_ready" { + extMu.Lock() + extInfo = msg.Params + extMu.Unlock() + log.Printf("[ws] extension ready: %v", msg.Params) + return + } + if msg.Type == "cookies_payload" { + // Extract cookies from params + if cookiesRaw, ok := msg.Params["cookies"]; ok { + raw, _ := json.Marshal(cookiesRaw) + var cookies []CookieObject + if err := json.Unmarshal(raw, &cookies); err == nil && len(cookies) > 0 { + HandleCookiePayload(cookies) + } else { + log.Printf("[ws] Failed to parse cookies_payload: %v", err) + } + } + return + } + if msg.Type == "sniffed_chat_request" { + sniff := sniffFromMessage(msg) + addSniff(sniff) + log.Printf("🔍 SNIFFED[%s/%s]: %s %s status=%d", sniff.Source, sniff.Phase, sniff.Method, sniff.URL, sniff.Status) + if len(sniff.Headers) > 0 { + log.Printf(" Headers:") + for k, v := range sniff.Headers { + log.Printf(" %s: %v", k, v) + } + } + if sniff.Payload != "" { + log.Printf(" Payload: %s", sniff.Payload) + } + if sniff.Response != "" { + log.Printf(" Response: %s", sniff.Response) + } + if sniff.Error != "" { + log.Printf(" Error: %s", sniff.Error) + } + } + return + } + + pendingMu.Lock() + ch := pending[msg.ID] + pendingMu.Unlock() + if ch != nil { + if msg.Error != "" { + log.Printf("[ws] response id=%s error=%s", msg.ID[:8], msg.Error) + } else { + log.Printf("[ws] response id=%s ok", msg.ID[:8]) + } + ch <- msg + return + } + + b, _ := json.MarshalIndent(msg, "", " ") + log.Printf("[ws] unmatched message: %s", b) +} diff --git a/chatgpt-free-api/go.mod b/chatgpt-free-api/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..208e154390d65b073d1f9d5b85d12c5500aedd8a --- /dev/null +++ b/chatgpt-free-api/go.mod @@ -0,0 +1,8 @@ +module chatgpt-agent + +go 1.26 + +require ( + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 +) diff --git a/chatgpt-free-api/go.sum b/chatgpt-free-api/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..73bbf576b9cdd082ae92cf9567c0e9ee672621cd --- /dev/null +++ b/chatgpt-free-api/go.sum @@ -0,0 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/chatgpt-free-api/gpt-extension/background.js b/chatgpt-free-api/gpt-extension/background.js new file mode 100644 index 0000000000000000000000000000000000000000..3c7ccf33ef2d2531277fbd2fae2151e28f1dd95a --- /dev/null +++ b/chatgpt-free-api/gpt-extension/background.js @@ -0,0 +1,1030 @@ +/** + * GPT Agent Sync — Background Service Worker + * Auto-syncs ChatGPT cookies to Go backend and handles background API/PoW fallbacks. + */ + +const AGENT_WS_URL = 'ws://127.0.0.1:9225'; +const CHATGPT_URL = 'https://chatgpt.com/'; +const CHATGPT_TAB_URLS = ['https://chatgpt.com/*', 'https://chat.openai.com/*']; + +let ws = null; +let lastCookieSyncTime = null; +let hasCookieSynced = false; +let cookieSyncDebounceTimeout = null; +let isRefreshingSession = false; +let extensionState = 'off'; + +// ChatGPT cookies to sync +const CHATGPT_COOKIE_DOMAINS = ['.chatgpt.com', 'chatgpt.com', '.chat.openai.com', 'chat.openai.com']; +const CHATGPT_COOKIE_NAMES = new Set([ + '__Secure-next-auth.session-token', + '__Host-next-auth.csrf-token', + '__Secure-next-auth.callback-url', + 'oai-did', + '_puid', + '__cf_bm', + 'cf_clearance', + '_cfuvid', + 'oai-sc', + 'oai-hlib', + '__cflb', + 'intercom-id-dgkjq2bp', + 'intercom-device-id-dgkjq2bp', + 'intercom-session-dgkjq2bp', +]); + +// Initialize alarms +chrome.runtime.onInstalled.addListener(init); +chrome.runtime.onStartup.addListener(init); + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name === 'reconnect') connectToBackend(); + if (alarm.name === 'keepAlive') keepAlive(); + if (alarm.name === 'sessionKeepAlive') { + console.log('[ChatGPT Sync] Running periodic session keep-alive refresh...'); + ensureChatGPTTabAndSync(true); + } +}); + +async function init() { + connectToBackend(); + // Keep-alive ping every 24 seconds (no cookies payload) + chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 }); + // Proactively refresh ChatGPT session tab every 15 minutes to rotate cookies + chrome.alarms.create('sessionKeepAlive', { periodInMinutes: 15 }); + + const data = await chrome.storage.local.get(['lastCookieSyncTime']); + if (data.lastCookieSyncTime) lastCookieSyncTime = data.lastCookieSyncTime; + setState('off'); +} + +// Badge and State Management +function setState(newState) { + extensionState = newState; + const badges = { idle: '●', running: '▶', off: '○' }; + const colors = { idle: '#10b981', running: '#f59e0b', off: '#ef4444' }; + + chrome.action.setBadgeText({ text: badges[extensionState] || '' }); + chrome.action.setBadgeBackgroundColor({ color: colors[extensionState] || '#000' }); + + // Notify popup if it's open + chrome.runtime.sendMessage({ type: 'COOKIE_SYNC_UPDATE' }).catch(() => {}); +} + +// WebSocket Connection Management +function connectToBackend() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { + return; + } + + console.log('[ChatGPT Sync] Connecting to local backend at:', AGENT_WS_URL); + hasCookieSynced = false; + setState('off'); + + try { + ws = new WebSocket(AGENT_WS_URL); + } catch (e) { + console.error('[ChatGPT Sync] WS Connection Error:', e); + scheduleReconnect(); + return; + } + + ws.onopen = () => { + console.log('[ChatGPT Sync] Connected to Go Backend!'); + chrome.alarms.clear('reconnect'); + setState('idle'); + performCookieSync(); + }; + + ws.onmessage = async (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.method === 'api_request') { + await handleApiRequest(msg); + } else if (msg.method === 'full_conversation') { + await handleFullConversation(msg); + } else if (msg.method === 'open_chatgpt') { + chrome.tabs.create({ url: CHATGPT_URL }); + } else if (msg.method === 'solve_pow') { + await handleSolvePow(msg); + } else if (msg.method === 'solve_turnstile') { + await handleSolveTurnstile(msg); + } else if (msg.method === 'trigger_sync') { + console.log('[ChatGPT Sync] Backend requested fresh cookies. Activating session refresh...'); + ensureChatGPTTabAndSync(false); + } else if (msg.method === 'reload_extension') { + chrome.runtime.reload(); + } + } catch (e) { + console.error('[ChatGPT Sync] Error handling message:', e); + } + }; + + ws.onclose = () => { + console.log('[ChatGPT Sync] Connection closed. Reconnecting...'); + setState('off'); + scheduleReconnect(); + }; + + ws.onerror = (err) => { + console.error('[ChatGPT Sync] WebSocket Error:', err); + setState('off'); + }; +} + +function scheduleReconnect() { + chrome.alarms.create('reconnect', { delayInMinutes: 0.083 }); // ~5s +} + +function keepAlive() { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } else { + connectToBackend(); + } +} + +function sendToAgent(msg) { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)); + } +} + +// Cookie Sync Logic +function performCookieSync() { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + chrome.cookies.getAll({}, (cookies) => { + const chatgptCookies = cookies.filter(c => { + return CHATGPT_COOKIE_DOMAINS.some(d => c.domain === d || c.domain.endsWith(d)); + }); + + const formatted = chatgptCookies.map(c => { + let exp = c.expirationDate; + if (!exp || c.session) { + exp = Math.floor(Date.now() / 1000) + 31536000; // Default 1 year + } + let sameSite = c.sameSite || 'unspecified'; + if (sameSite === 'no_restriction') sameSite = 'none'; + + return { + domain: c.domain, + expirationDate: exp, + hostOnly: c.hostOnly, + httpOnly: c.httpOnly, + name: c.name, + path: c.path, + sameSite: sameSite, + secure: c.secure, + session: c.session, + storeId: c.storeId || '0', + value: c.value + }; + }); + + console.log(`[ChatGPT Sync] Syncing ${formatted.length} cookies to backend`); + ws.send(JSON.stringify({ + type: 'cookies_payload', + params: { + cookies: formatted + } + })); + + hasCookieSynced = true; + lastCookieSyncTime = Date.now(); + chrome.storage.local.set({ lastCookieSyncTime }); + setState('idle'); + }); +} + +// Proactive refresh mechanism: opens or reloads ChatGPT tab to rotate cookies +async function ensureChatGPTTabAndSync(quietMode = false) { + if (isRefreshingSession) return; + isRefreshingSession = true; + setState('running'); + + try { + const tabs = await chrome.tabs.query({ url: CHATGPT_TAB_URLS }); + if (tabs.length > 0) { + console.log('[ChatGPT Sync] ChatGPT tab exists. Reloading to rotate cookies...'); + await chrome.tabs.reload(tabs[0].id); + } else { + console.log('[ChatGPT Sync] No ChatGPT tab found. Launching background session...'); + await chrome.tabs.create({ url: CHATGPT_URL, active: false }); + } + + setTimeout(() => { + if (isRefreshingSession) { + isRefreshingSession = false; + setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off'); + } + }, 15000); + } catch (e) { + console.error('[ChatGPT Sync] Session refresh error:', e); + isRefreshingSession = false; + setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off'); + performCookieSync(); + } +} + +// Tab updates to trigger cookie sync on complete load +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + const isGptTab = tab.url && CHATGPT_TAB_URLS.some(pattern => { + const regex = new RegExp(pattern.replace(/\./g, '\\.').replace(/\*/g, '.*')); + return regex.test(tab.url); + }); + if (changeInfo.status === 'complete' && isGptTab) { + console.log('[ChatGPT Sync] ChatGPT tab loaded completely. Performing cookie sync...'); + performCookieSync(); + isRefreshingSession = false; + setState('idle'); + } +}); + +// Real-Time Cookie Changed Listener +chrome.cookies.onChanged.addListener((changeInfo) => { + const cookie = changeInfo.cookie; + const isTarget = CHATGPT_COOKIE_DOMAINS.some(d => cookie.domain === d || cookie.domain.endsWith(d)); + + if (isTarget && CHATGPT_COOKIE_NAMES.has(cookie.name)) { + if (changeInfo.removed) return; + + console.log(`[ChatGPT Sync] Real-time cookie updated: ${cookie.name}. Scheduling sync...`); + if (cookieSyncDebounceTimeout) clearTimeout(cookieSyncDebounceTimeout); + cookieSyncDebounceTimeout = setTimeout(() => { + console.log('[ChatGPT Sync] Running debounced real-time cookie sync...'); + performCookieSync(); + }, 1500); + } +}); + +// Communication with Popup +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg.type === 'GET_STATUS') { + sendResponse({ + connected: ws && ws.readyState === WebSocket.OPEN, + lastSyncTime: lastCookieSyncTime, + hasSyncedOnce: hasCookieSynced, + state: extensionState + }); + } + if (msg.type === 'FORCE_SYNC') { + ensureChatGPTTabAndSync(false); + sendResponse({ ok: true }); + } + return true; +}); + +// Metadata Helpers +async function getOaiDeviceId() { + try { + const cookie = await chrome.cookies.get({ url: 'https://chatgpt.com', name: 'oai-did' }); + return cookie ? cookie.value : ''; + } catch (e) { + return ''; + } +} + +let cachedBuildNumber = null; +let cachedBuildId = null; +let lastCacheTime = 0; + +async function getClientMetadata() { + const now = Date.now(); + if (cachedBuildNumber && cachedBuildId && (now - lastCacheTime < 3600000)) { + return { buildNumber: cachedBuildNumber, buildId: cachedBuildId }; + } + try { + const resp = await fetch('https://chatgpt.com/', { credentials: 'omit' }); + if (resp.ok) { + const text = await resp.text(); + const buildNumberMatch = text.match(/meta name="build-number" content="([^"]+)"/); + const buildIdMatch = text.match(/"buildId":"([^"]+)"/); + if (buildNumberMatch) cachedBuildNumber = buildNumberMatch[1]; + if (buildIdMatch) cachedBuildId = buildIdMatch[1]; + lastCacheTime = now; + } + } catch (e) { + console.error('[ChatGPT Sync] Metadata fetch error:', e); + } + return { + buildNumber: cachedBuildNumber || 'main-build-latest', + buildId: cachedBuildId || 'latest' + }; +} + +async function getNativeHeaders() { + const headers = {}; + const oaiDid = await getOaiDeviceId(); + if (oaiDid) headers['OAI-Device-Id'] = oaiDid; + headers['OAI-Language'] = self.navigator.language || 'en-US'; + + const meta = await getClientMetadata(); + if (meta.buildNumber) headers['OAI-Client-Build-Number'] = meta.buildNumber; + if (meta.buildId) headers['OAI-Client-Version'] = meta.buildId; + return headers; +} + +// Background API request handlers +async function handleApiRequest(msg) { + const id = msg.id || `api-${Date.now()}`; + const params = msg.params || {}; + const { url, method = 'GET', headers = {}, body = null, returnHeaders = true, responseType = null } = params; + + // Allow internal methods + if (url && url.startsWith('__internal__')) { + if (url.includes('solve_pow')) { + await handleSolvePow(msg); + return; + } + if (url.includes('solve_turnstile')) { + await handleSolveTurnstile(msg); + return; + } + sendToAgent({ id, error: 'UNKNOWN_INTERNAL_METHOD' }); + return; + } + + if (!url || !/^https:\/\/(chatgpt\.com|chat\.openai\.com|[a-z0-9]+\.oaiusercontent\.com)\//.test(url)) { + sendToAgent({ id, error: 'INVALID_URL: ' + (url || 'empty') }); + return; + } + + setState('running'); + + try { + const tab = await getChatGPTTab(); + await waitForTabReady(tab.id); + const [exec] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + world: 'MAIN', + args: [{ url, method, headers, body, returnHeaders, responseType }], + func: async ({ url, method, headers, body, returnHeaders, responseType }) => { + try { + // Build headers — merge provided headers with native OAI headers + const nativeHeaders = {}; + // Get OAI device ID from cookie + const cookies = document.cookie.split(';').map(c => c.trim()); + const oaiDid = cookies.find(c => c.startsWith('oai-did=')); + if (oaiDid) nativeHeaders['OAI-Device-Id'] = oaiDid.split('=')[1]; + // Add standard OAI headers that ChatGPT frontend sends + nativeHeaders['OAI-Language'] = navigator.language || 'en-US'; + // Try to get build number from page meta or global + try { + const buildMeta = document.querySelector('meta[name="build-number"]'); + if (buildMeta) nativeHeaders['OAI-Client-Build-Number'] = buildMeta.content; + if (window.__NEXT_DATA__?.buildId) nativeHeaders['OAI-Client-Version'] = window.__NEXT_DATA__.buildId; + } catch(e) {} + + const reqHeaders = { ...nativeHeaders, ...headers }; + if (url.includes('/estuary/')) { + for (const key of Object.keys(reqHeaders)) { + if (key.toLowerCase().startsWith('oai-') || key.toLowerCase() === 'authorization') { + delete reqHeaders[key]; + } + } + } + // Strip browser auth headers for external URLs (Azure blob storage) + if (url.includes('.oaiusercontent.com/')) { + for (const key of Object.keys(reqHeaders)) { + if (key.toLowerCase().startsWith('oai-') || key.toLowerCase() === 'authorization') { + delete reqHeaders[key]; + } + } + } + const init = { method, headers: reqHeaders, credentials: url.includes('.oaiusercontent.com/') ? 'omit' : 'include' }; + if (body !== null && body !== undefined && method !== 'GET' && method !== 'HEAD') { + // For binary blob uploads (Azure), convert base64 to Uint8Array + if (url.includes('.oaiusercontent.com/') && typeof body === 'string' && body.length > 1000) { + const binaryStr = atob(body); + const bytes = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i); + } + init.body = bytes.buffer; + } else { + init.body = typeof body === 'string' ? body : JSON.stringify(body); + } + if (typeof body !== 'string' && !Object.keys(init.headers).some((k) => k.toLowerCase() === 'content-type')) { + init.headers['Content-Type'] = 'application/json'; + } + } + const resp = await fetch(url, init); + const contentType = resp.headers.get('content-type') || ''; + let respBody; + let isBase64 = false; + + if ( + responseType === 'base64' || + contentType.startsWith('image/') || + contentType.startsWith('audio/') || + contentType.startsWith('video/') || + contentType.startsWith('application/octet-stream') + ) { + const blob = await resp.blob(); + respBody = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + const parts = reader.result.split(','); + resolve(parts[1] || parts[0]); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + isBase64 = true; + } else { + respBody = await resp.text(); + } + + const respHeaders = {}; + if (returnHeaders) { + resp.headers.forEach((v, k) => { respHeaders[k] = v; }); + } + return { ok: true, status: resp.status, headers: respHeaders, body: respBody, isBase64, finalUrl: resp.url }; + } catch (e) { + return { ok: false, error: String(e && e.message || e) }; + } + }, + }); + const result = exec?.result; + if (!result || result.ok === false) { + sendToAgent({ id, error: result?.error || 'FETCH_FAILED' }); + return; + } + sendToAgent({ id, result }); + } catch (e) { + sendToAgent({ id, error: e.message || 'API_REQUEST_FAILED' }); + } finally { + setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off'); + } +} + +// PoW Solvers +async function handleSolvePow(msg) { + const id = msg.id || `pow-${Date.now()}`; + const params = msg.params || {}; + const { seed, difficulty } = params.body || params; + if (!seed || !difficulty) { + sendToAgent({ id, error: 'MISSING_SEED_OR_DIFFICULTY' }); + return; + } + setState('running'); + try { + const token = await solveShaPow(seed, difficulty); + sendToAgent({ id, result: { ok: true, status: 200, body: JSON.stringify({ token }), headers: {} } }); + } catch (e) { + sendToAgent({ id, error: e.message || 'POW_SOLVE_FAILED' }); + } finally { + setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off'); + } +} + +async function handleSolveTurnstile(msg) { + const id = msg.id || `turnstile-${Date.now()}`; + sendToAgent({ id, error: 'TURNSTILE_SOLVE_NOT_SUPPORTED_IN_BACKGROUND' }); +} + +async function solveShaPow(seed, diff) { + const diffNum = parseInt(diff, 16) || parseInt(diff); + const prefix = '0'.repeat(Math.ceil(Math.log2(diffNum + 1) / 4)); + const encoder = new TextEncoder(); + for (let nonce = 0; nonce < 1000000; nonce++) { + const input = `${seed}${nonce}`; + const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(input)); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + if (hashHex.startsWith(prefix)) { + return `gAAAAAB${btoa(input)}`; + } + } + return `gAAAAAB${btoa(seed + '0')}`; +} + +async function getChatGPTTab() { + const tabs = await chrome.tabs.query({ url: CHATGPT_TAB_URLS }); + if (tabs.length > 0) return tabs[0]; + return await chrome.tabs.create({ url: CHATGPT_URL, active: false }); +} + +async function handleFullConversation(msg) { + const id = msg.id || `conv-${Date.now()}`; + const params = msg.params || {}; + const { prompt, model = 'auto', conversation_id, thinking_effort, attachments } = params; + if (!prompt) { + sendToAgent({ id, error: 'MISSING_PROMPT' }); + return; + } + setState('running'); + try { + const tab = await getChatGPTTab(); + const targetUrl = conversation_id ? `https://chatgpt.com/c/${conversation_id}` : CHATGPT_URL; + const isTargetConv = !!conversation_id; + const isTabConv = tab.url && tab.url.includes('/c/'); + let needsNavigation = false; + if (isTargetConv) { + if (!tab.url || !tab.url.startsWith(targetUrl)) { + needsNavigation = true; + } + } else { + if (!tab.url || isTabConv || !tab.url.startsWith(CHATGPT_URL)) { + needsNavigation = true; + } + } + + if (needsNavigation) { + console.log(`[ChatGPT Bridge] Navigating tab ${tab.id} to ${targetUrl}`); + await chrome.tabs.update(tab.id, { url: targetUrl }); + await waitForTabReady(tab.id); + await sleep(2000); // Allow time for conversation DOM to load historical message IDs + } else { + await waitForTabReady(tab.id); + } + await sleep(250); + const [exec] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + world: 'MAIN', + args: [{ prompt, model, conversation_id, attachments }], + func: async ({ prompt, model, conversation_id, attachments }) => { + const previousBridgeFlag = window.__CHATGPT_BRIDGE_ACTIVE__; + window.__CHATGPT_BRIDGE_ACTIVE__ = true; + try { + // Step 1: Get access token from session + const sessResp = await fetch('/api/auth/session', { credentials: 'include' }); + if (!sessResp.ok) return { ok: false, error: `session_http_${sessResp.status}` }; + const sessData = await sessResp.json(); + const accessToken = sessData.accessToken; + if (!accessToken) return { ok: false, error: 'NO_ACCESS_TOKEN' }; + + // Build native headers (Device-Id, Language, Build version) + const nativeHeaders = {}; + const cookies = document.cookie.split(';').map(c => c.trim()); + const oaiDid = cookies.find(c => c.startsWith('oai-did=')); + if (oaiDid) nativeHeaders['OAI-Device-Id'] = oaiDid.split('=')[1]; + nativeHeaders['OAI-Language'] = navigator.language || 'en-US'; + try { + const buildMeta = document.querySelector('meta[name="build-number"]'); + if (buildMeta) nativeHeaders['OAI-Client-Build-Number'] = buildMeta.content; + if (window.__NEXT_DATA__?.buildId) nativeHeaders['OAI-Client-Version'] = window.__NEXT_DATA__.buildId; + } catch(e) {} + + const encodeConfig = (cfg) => btoa(unescape(encodeURIComponent(JSON.stringify(cfg)))); + + // Step 2: Build browser config for requirements/PoW tokens + const buildConfig = () => { + const d = new Date(); + const dateStr = d.toString(); + const getUserMedia = navigator.webkitGetUserMedia || navigator.getUserMedia; + const mediaSig = getUserMedia ? `webkitGetUserMedia−${String(getUserMedia)}` : `hardwareConcurrency−${navigator.hardwareConcurrency}`; + + return [ + screen.width + screen.height, + dateStr, + 4294705152, + 0, + navigator.userAgent, + null, + window.__NEXT_DATA__?.buildId || "", + navigator.language || "en-US", + (navigator.languages || [navigator.language]).join(','), + 0, + mediaSig, + 'location', + 'self', + performance.now() * 1000, + crypto.randomUUID(), + '', + navigator.hardwareConcurrency, + Date.now() - performance.now(), + ]; + }; + + const generatePToken = () => { + const cfg = buildConfig(); + const started = Date.now(); + cfg[3] = 1; + cfg[9] = Date.now() - started; + return { token: 'gAAAAAC' + encodeConfig(cfg), config: cfg }; + }; + + const generated = generatePToken(); + const pToken = generated.token; + + // Step 3: Get chat requirements + const reqHeaders = { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...nativeHeaders, + }; + const reqResp = await fetch('/backend-api/sentinel/chat-requirements', { + method: 'POST', + headers: reqHeaders, + credentials: 'include', + body: JSON.stringify({ p: pToken }), + }); + if (!reqResp.ok) return { ok: false, error: `requirements_http_${reqResp.status}` }; + const reqData = await reqResp.json(); + const chatToken = reqData.token; + + // Step 4: Solve PoW if required + let proofToken = null; + if (reqData.proofofwork?.required) { + const seed = reqData.proofofwork.seed; + const difficulty = reqData.proofofwork.difficulty; + const fnvHash = (input) => { + let h = 2166136261 >>> 0; + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + h ^= h >>> 16; + h = Math.imul(h, 2246822507) >>> 0; + h ^= h >>> 13; + h = Math.imul(h, 3266489909) >>> 0; + h ^= h >>> 16; + return (h >>> 0).toString(16).padStart(8, '0'); + }; + const powStarted = Date.now(); + const config = generated.config.slice(); + for (let nonce = 0; nonce < 500000; nonce++) { + config[3] = nonce; + config[9] = Date.now() - powStarted; + const encoded = encodeConfig(config); + if (fnvHash(seed + encoded).slice(0, difficulty.length) <= difficulty) { + proofToken = 'gAAAAAB' + encoded + '~S'; + break; + } + } + if (!proofToken) { + return { ok: false, error: 'POW_SOLVE_FAILED' }; + } + } + + // Step 5: Solve turnstile if required + let turnstileToken = null; + if (reqData.turnstile?.required) { + if (typeof turnstile !== 'undefined') { + try { turnstileToken = turnstile.getResponse(); } catch(e) {} + } + if (!turnstileToken) { + const el = document.querySelector('[name="cf-turnstile-response"]'); + if (el) turnstileToken = el.value; + } + // If still no token, try to render a new turnstile + if (!turnstileToken && typeof turnstile !== 'undefined' && reqData.turnstile.dx) { + try { + const container = document.createElement('div'); + container.style.display = 'none'; + document.body.appendChild(container); + await new Promise((resolve) => { + turnstile.render(container, { + sitekey: reqData.turnstile.sitekey || '0x4AAAAAAAx1CyDNL8zOEPe7', + callback: (token) => { turnstileToken = token; resolve(); }, + 'error-callback': () => resolve(), + timeout: 10000, + }); + setTimeout(resolve, 15000); + }); + container.remove(); + } catch(e) {} + } + } + + // Resolve target conversation ID + let resolvedConversationId = conversation_id || null; + + // Resolve parent message ID + let parentId = 'client-created-root'; + if (resolvedConversationId) { + try { + const convUrl = `/backend-api/conversation/${resolvedConversationId}`; + const convDetailResp = await fetch(convUrl, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + ...nativeHeaders, + }, + credentials: 'include', + }); + if (convDetailResp.ok) { + const convData = await convDetailResp.json(); + if (convData.current_node) { + parentId = convData.current_node; + console.log(`[ChatGPT Bridge] Resolved parent message ID from API: ${parentId}`); + } + } + } catch (e) { + console.error('[ChatGPT Bridge] Failed to fetch conversation detail:', e); + } + + if (parentId === 'client-created-root') { + const msgEls = Array.from(document.querySelectorAll('[data-message-id]')); + if (msgEls.length > 0) { + const lastId = msgEls[msgEls.length - 1].getAttribute('data-message-id'); + if (lastId) { + parentId = lastId; + console.log(`[ChatGPT Bridge] Resolved parent message ID from DOM: ${parentId}`); + } + } + } + } + + return { + ok: true, + accessToken, + nativeHeaders, + chatToken, + proofToken, + turnstileToken, + parentId, + resolvedConversationId + }; + } catch (e) { + return { ok: false, error: e.message || 'UNKNOWN_ERROR' }; + } finally { + window.__CHATGPT_BRIDGE_ACTIVE__ = previousBridgeFlag; + } + }, + }); + const prep = exec?.result; + if (!prep || !prep.ok) { + sendToAgent({ id, error: prep?.error || 'CONVERSATION_PREPARATION_FAILED', result: prep }); + return; + } + + const echoStart = Math.max(1000, Math.floor(performance.now())); + const echoEnd = echoStart + 1000 + Math.floor(Math.random() * 500); + const convHeaders = { + 'Authorization': `Bearer ${prep.accessToken}`, + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + 'openai-sentinel-chat-requirements-token': prep.chatToken, + 'oai-echo-logs': `0,${echoStart},1,${echoEnd}`, + ...prep.nativeHeaders, + }; + if (prep.proofToken) convHeaders['openai-sentinel-proof-token'] = prep.proofToken; + if (prep.turnstileToken) convHeaders['openai-sentinel-turnstile-token'] = prep.turnstileToken; + + let actualModel = model; + let resolvedThinkingEffort = thinking_effort || null; + if (model && model.includes('thinking')) { + if (model.includes('-extended')) { + resolvedThinkingEffort = 'extended'; + actualModel = model.replace('-extended', ''); + } else if (model.includes('-standard')) { + resolvedThinkingEffort = 'standard'; + actualModel = model.replace('-standard', ''); + } else if (!resolvedThinkingEffort) { + resolvedThinkingEffort = 'standard'; + } + } + + const commonConversationFields = { + action: 'next', + model: actualModel, + timezone_offset_min: new Date().getTimezoneOffset(), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + history_and_training_disabled: false, + fork_from_shared_post: false, + force_paragen: false, + force_rate_limit: false, + conversation_mode: { kind: 'primary_assistant' }, + enable_message_followups: true, + system_hints: [], + supports_buffering: true, + supported_encodings: ['v1'], + paragen_cot_summary_display_override: 'allow', + force_parallel_switch: 'auto', + }; + if (resolvedThinkingEffort) { + commonConversationFields.thinking_effort = resolvedThinkingEffort; + } + + const prepareBody = { + ...commonConversationFields, + parent_message_id: prep.parentId, + }; + if (prep.resolvedConversationId) { + prepareBody.conversation_id = prep.resolvedConversationId; + } + + // Build message content - support multimodal (text + image attachments) + const messageParts = [prompt]; + const messageAttachments = []; + if (attachments && attachments.length > 0) { + for (const att of attachments) { + messageAttachments.push({ + id: att.id, + name: att.name || 'image.png', + size: att.size || 0, + mime_type: att.mime_type || 'image/png', + width: att.width || null, + height: att.height || null, + }); + } + } + + const messageContent = attachments && attachments.length > 0 + ? { content_type: 'multimodal_text', parts: messageParts } + : { content_type: 'text', parts: [prompt] }; + + const userMessage = { + id: crypto.randomUUID(), + author: { role: 'user' }, + content: messageContent, + metadata: { + selected_github_repos: [], + selected_all_github_repos: false, + serialization_metadata: { custom_symbol_offsets: [] }, + }, + create_time: Math.round(Date.now()) / 1000, + }; + if (messageAttachments.length > 0) { + userMessage.metadata.attachments = messageAttachments; + } + + const convBody = { + ...commonConversationFields, + messages: [userMessage], + parent_message_id: prep.parentId, + websocket_request_id: crypto.randomUUID(), + client_contextual_info: { + is_dark_mode: false, + time_since_loaded: 0, + page_height: 800, + page_width: 1200, + pixel_ratio: 1, + screen_height: 1080, + screen_width: 1920, + }, + }; + if (prep.resolvedConversationId) { + convBody.conversation_id = prep.resolvedConversationId; + } + + const prepareHeaders = { + ...convHeaders, + 'Accept': 'application/json', + 'x-conduit-token': 'no-token', + }; + const prepareResp = await fetch('https://chatgpt.com/backend-api/f/conversation/prepare', { + method: 'POST', + headers: prepareHeaders, + credentials: 'include', + body: JSON.stringify(prepareBody), + }); + if (!prepareResp.ok) { + const errText = await prepareResp.text(); + sendToAgent({ id, error: `prepare_http_${prepareResp.status}: ${errText.slice(0, 500)}` }); + return; + } + const prepareText = await prepareResp.text(); + let conduitToken = 'no-token'; + try { + const prepared = JSON.parse(prepareText); + conduitToken = prepared.conduit_token || prepared.conduitToken || prepared.token || conduitToken; + } catch(e) {} + + const finalHeaders = { + ...convHeaders, + 'x-conduit-token': conduitToken, + }; + const convResp = await fetch('https://chatgpt.com/backend-api/f/conversation', { + method: 'POST', + headers: finalHeaders, + credentials: 'include', + body: JSON.stringify(convBody), + }); + + if (!convResp.ok) { + const errText = await convResp.text(); + sendToAgent({ id, error: `conversation_http_${convResp.status}: ${errText.slice(0, 500)}` }); + return; + } + + const text = await convResp.text(); + let convId = ''; + const parseSSEFinal = (raw) => { + let snapshot = ''; + let delta = ''; + let fileIds = new Set(); + let currentContentType = ''; + let currentRole = ''; + const extractParts0 = (obj) => { + const role = obj?.message?.author?.role; + if (role !== 'assistant' && role !== 'tool') return ''; + const parts = obj?.message?.content?.parts; + if (!Array.isArray(parts)) return ''; + for (const part of parts) { + if (part && typeof part === 'object' && part.content_type === 'image_asset_pointer' && part.asset_pointer) { + const match = part.asset_pointer.match(/(?:file-service|sediment):\/\/(file[_-][\w-]+)/); + if (match && match[1]) { + const fileId = match[1]; + const isAttachment = attachments && attachments.some(att => att.id === fileId); + if (!isAttachment) { + fileIds.add(fileId); + } + } + } + } + return typeof parts[0] === 'string' ? parts[0] : ''; + }; + + const processDeltaObj = (ev) => { + if ( + typeof ev.p === 'string' && + ev.p.includes('/message/content/parts/0') + ) { + if (ev.o === 'replace' && typeof ev.v === 'string') { + delta = ev.v; + } else if ((ev.o === undefined || ev.o === 'append') && typeof ev.v === 'string') { + delta += ev.v; + } + } else if (ev.p === undefined && typeof ev.v === 'string') { + if (currentContentType === 'text' || currentContentType === 'multimodal_text' || currentContentType === '') { + delta += ev.v; + } + } else if (ev.o === 'patch' && Array.isArray(ev.v)) { + for (const sub of ev.v) { + processDeltaObj(sub); + } + } + }; + + const lines = raw.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('data:')) continue; + const data = trimmed.slice(5).trim(); + if (!data || data === '[DONE]') continue; + try { + const parsed = JSON.parse(data); + if (parsed.conversation_id) { + convId = parsed.conversation_id; + } + if (parsed.v && typeof parsed.v === 'object') { + if (parsed.v.conversation_id) { + convId = parsed.v.conversation_id; + } + if (parsed.v.message) { + currentContentType = parsed.v.message.content?.content_type || ''; + currentRole = parsed.v.message.author?.role || ''; + } + } else if (parsed.message) { + currentContentType = parsed.message.content?.content_type || ''; + currentRole = parsed.message.author?.role || ''; + } + const direct = extractParts0(parsed); + if (direct) snapshot = direct; + if (parsed.v && typeof parsed.v === 'object') { + const nested = extractParts0(parsed.v); + if (nested) snapshot = nested; + } + processDeltaObj(parsed); + } catch(e) {} + } + let out = snapshot; + if (delta) { + if (delta.length > snapshot.length) { + out = delta; + } else if (snapshot && !snapshot.includes(delta)) { + out = snapshot + delta; + } + } + if (fileIds.size > 0) { + let imageMarkdown = '\n\n'; + for (const fileId of fileIds) { + imageMarkdown += `![Generated Image](http://127.0.0.1:9225/api/download?file_id=${fileId}&prompt=${encodeURIComponent(prompt)})\n`; + } + out += imageMarkdown; + } + return out; + }; + const assistantText = parseSSEFinal(text); + if (!assistantText) { + sendToAgent({ + id, + error: `EMPTY_ASSISTANT_RESPONSE raw_len=${text.length} raw=${text.replace(/\s+/g, ' ').slice(0, 500)}`, + result: { rawText: text } + }); + return; + } + + sendToAgent({ id, result: { ok: true, status: 200, body: assistantText, rawText: text, conversation_id: convId || prep.resolvedConversationId, headers: {} } }); + } catch (e) { + sendToAgent({ id, error: e.message || 'FULL_CONVERSATION_FAILED' }); + } finally { + setState(ws && ws.readyState === WebSocket.OPEN ? 'idle' : 'off'); + } +} + +async function waitForTabReady(tabId, timeout = 30000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + const tab = await chrome.tabs.get(tabId); + if (tab.status === 'complete') return; + await sleep(300); + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/chatgpt-free-api/gpt-extension/icon128.png b/chatgpt-free-api/gpt-extension/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..c30770cc24ca1378fdc3bc2fd08c8f3d97bd12b9 Binary files /dev/null and b/chatgpt-free-api/gpt-extension/icon128.png differ diff --git a/chatgpt-free-api/gpt-extension/icon16.png b/chatgpt-free-api/gpt-extension/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..d6c4d0fe7e66cea16622dd3f5a137a40b1c9ce7d Binary files /dev/null and b/chatgpt-free-api/gpt-extension/icon16.png differ diff --git a/chatgpt-free-api/gpt-extension/icon48.png b/chatgpt-free-api/gpt-extension/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..d3bc805499811238ce8b7e9383a4f07280427f70 Binary files /dev/null and b/chatgpt-free-api/gpt-extension/icon48.png differ diff --git a/chatgpt-free-api/gpt-extension/icon_large.png b/chatgpt-free-api/gpt-extension/icon_large.png new file mode 100644 index 0000000000000000000000000000000000000000..068f7f9755e950f25c47aed8fce1a5e09cdd7e78 Binary files /dev/null and b/chatgpt-free-api/gpt-extension/icon_large.png differ diff --git a/chatgpt-free-api/gpt-extension/manifest.json b/chatgpt-free-api/gpt-extension/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..ccf6000e5e2a573a9c10a4693f08bfc24e97304b --- /dev/null +++ b/chatgpt-free-api/gpt-extension/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": 3, + "name": "Gpt Agent Api", + "version": "1.0.0", + "description": "Auto-syncs ChatGPT cookies to your local ChatGPT Free API server", + "icons": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + }, + "permissions": ["cookies", "storage", "alarms", "tabs", "scripting"], + "host_permissions": [ + "https://chatgpt.com/*", + "https://chat.openai.com/*" + ], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_title": "GPT Agent Sync", + "default_icon": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } + } +} diff --git a/chatgpt-free-api/gpt-extension/popup.html b/chatgpt-free-api/gpt-extension/popup.html new file mode 100644 index 0000000000000000000000000000000000000000..f4a8ae5b67c8d90b02a51f8e6edc15aa2c702410 --- /dev/null +++ b/chatgpt-free-api/gpt-extension/popup.html @@ -0,0 +1,158 @@ + + + + + + + +
+
+
+

Gpt Agent Api

+
+ +
+
+ Server Connection + + + Disconnected + +
+
+ Last Sync + Never +
+
+ + +
+ + + diff --git a/chatgpt-free-api/gpt-extension/popup.js b/chatgpt-free-api/gpt-extension/popup.js new file mode 100644 index 0000000000000000000000000000000000000000..5ffb9865c4139041b15f0557295d69001388c25e --- /dev/null +++ b/chatgpt-free-api/gpt-extension/popup.js @@ -0,0 +1,56 @@ +document.addEventListener('DOMContentLoaded', () => { + const statusBadge = document.getElementById('status-badge'); + const statusText = document.getElementById('status-text'); + const lastSync = document.getElementById('last-sync'); + const syncBtn = document.getElementById('sync-btn'); + + function updateUI() { + chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (response) => { + if (chrome.runtime.lastError) return; + if (!response) return; + + if (response.state === 'running') { + statusBadge.className = 'badge running'; + statusText.innerText = 'Active'; + statusText.style.color = '#f59e0b'; + } else if (response.connected) { + statusBadge.className = 'badge connected'; + statusText.innerText = 'Connected'; + statusText.style.color = '#10b981'; + } else { + statusBadge.className = 'badge disconnected'; + statusText.innerText = 'Disconnected'; + statusText.style.color = '#ef4444'; + } + + if (response.lastSyncTime) { + const date = new Date(response.lastSyncTime); + lastSync.innerText = date.toLocaleTimeString(); + } else { + lastSync.innerText = 'Never'; + } + }); + } + + // Initial update + updateUI(); + + // Listen for real-time updates from background worker + chrome.runtime.onMessage.addListener((msg) => { + if (msg.type === 'COOKIE_SYNC_UPDATE') { + updateUI(); + } + }); + + syncBtn.addEventListener('click', () => { + syncBtn.disabled = true; + syncBtn.innerText = 'Syncing...'; + chrome.runtime.sendMessage({ type: 'FORCE_SYNC' }, () => { + setTimeout(() => { + syncBtn.disabled = false; + syncBtn.innerText = 'Force Sync Cookies'; + updateUI(); + }, 800); + }); + }); +}); diff --git a/chatgpt-free-api/handlers.go b/chatgpt-free-api/handlers.go new file mode 100644 index 0000000000000000000000000000000000000000..5bca49c658819860d0373e19ce788aef966e1948 --- /dev/null +++ b/chatgpt-free-api/handlers.go @@ -0,0 +1,483 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type ChatRequest struct { + Prompt string `json:"prompt"` + WaitForResponse bool `json:"wait_for_response"` + ConversationID string `json:"conversation_id"` + Model string `json:"model"` + ThinkingEffort string `json:"thinking_effort"` +} + +type BulkChatRequest struct { + Prompts []string `json:"prompts"` + WaitForResponse bool `json:"wait_for_response"` + DelaySeconds int `json:"delay_seconds"` +} + +type ChatResult struct { + Prompt string `json:"prompt"` + Response string `json:"response,omitempty"` + Error string `json:"error,omitempty"` +} + +type OpenAIChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type OpenAIChatCompletionRequest struct { + Model string `json:"model"` + Messages []OpenAIChatMessage `json:"messages"` + Stream bool `json:"stream"` +} + +type OpenAIChoice struct { + Index int `json:"index"` + Message OpenAIChatMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type OpenAIChatCompletionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAIChoice `json:"choices"` +} + +var testUpgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + +func handleWS(w http.ResponseWriter, r *http.Request) { + conn, err := testUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WS upgrade error: %v", err) + return + } + + extMu.Lock() + extConn = conn + if !reloadTriggered { + reloadTriggered = true + log.Println("[ws] Sending reload_extension request to sync extension files from disk...") + _ = conn.WriteJSON(WSMessage{ + Method: "reload_extension", + }) + } + extMu.Unlock() + log.Println("[ws] Extension connected from", r.RemoteAddr) + + defer func() { + extMu.Lock() + if extConn == conn { + extConn = nil + } + extMu.Unlock() + conn.Close() + log.Println("Extension disconnected") + + // Fail all pending channels + pendingMu.Lock() + for id, ch := range pending { + ch <- WSMessage{ + ID: id, + Error: "extension disconnected", + } + } + pending = map[string]chan WSMessage{} + pendingMu.Unlock() + }() + + for { + var msg WSMessage + if err := conn.ReadJSON(&msg); err != nil { + log.Printf("[ws] read closed: %v", err) + return + } + if msg.Type != "" { + log.Printf("[ws] event: type=%s", msg.Type) + } + handleExtensionMessage(msg) + } +} + +func handleHealth(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, map[string]any{"ok": true, "mode": "chatgpt-api-bridge", "extensionConnected": isExtensionConnected(), "extension": getExtensionInfo()}) +} + +func handleCallback(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var msg WSMessage + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + handleExtensionMessage(msg) + writeJSON(w, map[string]any{"ok": true}) +} + +func handleChat(w http.ResponseWriter, r *http.Request) { + req := ChatRequest{WaitForResponse: true} + if r.Method == http.MethodGet { + req.Prompt = r.URL.Query().Get("prompt") + req.ConversationID = r.URL.Query().Get("conversation_id") + req.Model = r.URL.Query().Get("model") + req.ThinkingEffort = r.URL.Query().Get("thinking_effort") + } else if r.Method == http.MethodPost { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } else { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if req.Prompt == "" { + http.Error(w, `{"error":"prompt required"}`, http.StatusBadRequest) + return + } + if req.Model == "" { + req.Model = cfg.DefaultModel + } + + if req.ConversationID == "new" { + req.ConversationID = "" + clearActiveConversationID() + } + + log.Printf("[chat] sending prompt via API mode (model=%s, thinking_effort=%s, conversation_id=%s): %s", req.Model, req.ThinkingEffort, req.ConversationID, snippet(req.Prompt, 50)) + response, newConvID, err := sendChatWithConversation(req.Prompt, req.ConversationID, req.Model, req.ThinkingEffort) + if err != nil { + writeJSONStatus(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()}) + return + } + + if newConvID != "" { + setActiveConversationID(newConvID) + } + + // Scan the response for download links to include in the JSON + var images []string + var fileIDRegexp = regexp.MustCompile(`(file_[0-9a-fA-F]{32}|file-[a-zA-Z0-9_-]{24,36})`) + matches := fileIDRegexp.FindAllString(response, -1) + if len(matches) > 0 { + seen := make(map[string]bool) + for _, id := range matches { + if !seen[id] { + seen[id] = true + name := getPromptFilename(req.Prompt, id) + images = append(images, "output/"+name+".png") + } + } + } + + writeJSON(w, map[string]any{ + "ok": true, + "response": response, + "conversation_id": newConvID, + "images": images, + }) +} + +func handleChatBulk(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + req := BulkChatRequest{WaitForResponse: true, DelaySeconds: cfg.BulkDelaySeconds} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(req.Prompts) == 0 { + http.Error(w, `{"error":"prompts required"}`, http.StatusBadRequest) + return + } + results := make([]ChatResult, 0, len(req.Prompts)) + for i, prompt := range req.Prompts { + text, _, err := sendChat(prompt) + item := ChatResult{Prompt: prompt, Response: text} + if err != nil { + item.Error = err.Error() + } + results = append(results, item) + if i < len(req.Prompts)-1 && req.DelaySeconds > 0 { + time.Sleep(time.Duration(req.DelaySeconds) * time.Second) + } + } + writeJSON(w, map[string]any{"ok": true, "count": len(results), "results": results}) +} + +func handleChatEdit(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse multipart form (max 32MB) + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Error(w, `{"error":"invalid multipart form: `+err.Error()+`"}`, http.StatusBadRequest) + return + } + + prompt := r.FormValue("prompt") + if prompt == "" { + http.Error(w, `{"error":"prompt required"}`, http.StatusBadRequest) + return + } + + conversationID := r.FormValue("conversation_id") + model := r.FormValue("model") + if model == "" { + model = cfg.DefaultModel + } + + if conversationID == "new" { + conversationID = "" + clearActiveConversationID() + } + + var attachments []FileAttachment + + // Handle image upload if present + file, header, err := r.FormFile("image") + if err == nil { + defer file.Close() + + // Save to temp file + _ = os.MkdirAll("output/tmp", 0755) + defer os.Remove("output/tmp") + tmpPath := filepath.Join("output", "tmp", header.Filename) + out, err := os.Create(tmpPath) + if err != nil { + writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": "save temp file: " + err.Error()}) + return + } + if _, err := io.Copy(out, file); err != nil { + out.Close() + writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": "copy file: " + err.Error()}) + return + } + out.Close() + defer os.Remove(tmpPath) + + log.Printf("[chat-edit] Uploading image %s to ChatGPT...", header.Filename) + att, err := uploadFileToChatGPT(tmpPath) + if err != nil { + writeJSONStatus(w, http.StatusBadGateway, map[string]any{"ok": false, "error": "upload image: " + err.Error()}) + return + } + attachments = append(attachments, *att) + log.Printf("[chat-edit] Image uploaded: %s (file_id: %s)", header.Filename, att.ID) + } + + log.Printf("[chat-edit] sending prompt with %d attachment(s): %s", len(attachments), snippet(prompt, 50)) + + response, newConvID, err := sendChatWithConversationAndAttachments(prompt, conversationID, model, cfg.DefaultThinkingEffort, attachments) + if err != nil { + writeJSONStatus(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()}) + return + } + + if newConvID != "" { + setActiveConversationID(newConvID) + } + + // Scan response for image downloads + var images []string + var editFileIDRegexp = regexp.MustCompile(`(file_[0-9a-fA-F]{32}|file-[a-zA-Z0-9_-]{24,36})`) + matches := editFileIDRegexp.FindAllString(response, -1) + if len(matches) > 0 { + uploadedIDs := make(map[string]bool) + for _, att := range attachments { + uploadedIDs[att.ID] = true + } + seen := make(map[string]bool) + for _, id := range matches { + if !seen[id] && !uploadedIDs[id] { + seen[id] = true + name := getPromptFilename(prompt, id) + images = append(images, "output/"+name+".png") + } + } + } + + writeJSON(w, map[string]any{ + "ok": true, + "response": response, + "conversation_id": newConvID, + "images": images, + }) +} + +func handleSniffs(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + sniffMu.Lock() + out := append([]SniffedRequest(nil), sniffs...) + sniffMu.Unlock() + writeJSON(w, map[string]any{"ok": true, "count": len(out), "sniffs": out}) + case http.MethodDelete: + sniffMu.Lock() + sniffs = nil + sniffMu.Unlock() + writeJSON(w, map[string]any{"ok": true, "cleared": true}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleOpenAIChat(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req OpenAIChatCompletionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if len(req.Messages) == 0 { + http.Error(w, `{"error":{"message":"messages array is empty"}}`, http.StatusBadRequest) + return + } + + // Extract prompt from messages (last user message) + var prompt string + for i := len(req.Messages) - 1; i >= 0; i-- { + if req.Messages[i].Role == "user" { + prompt = req.Messages[i].Content + break + } + } + if prompt == "" { + prompt = req.Messages[len(req.Messages)-1].Content + } + + model := req.Model + if model == "" { + model = cfg.DefaultModel + } + + conversationID := getActiveConversationID() + + log.Printf("[openai-chat] received OpenAI request (model=%s, conversation_id=%s), sending prompt: %s", model, conversationID, snippet(prompt, 50)) + response, newConvID, err := sendChatWithConversation(prompt, conversationID, model, cfg.DefaultThinkingEffort) + if err != nil { + writeJSONStatus(w, http.StatusBadGateway, map[string]any{ + "error": map[string]any{ + "message": err.Error(), + "type": "api_error", + }, + }) + return + } + + if newConvID != "" { + setActiveConversationID(newConvID) + } + + resp := OpenAIChatCompletionResponse{ + ID: "chatcmpl-" + uuid.NewString()[:12], + Object: "chat.completion", + Created: time.Now().Unix(), + Model: model, + Choices: []OpenAIChoice{ + { + Index: 0, + Message: OpenAIChatMessage{ + Role: "assistant", + Content: response, + }, + FinishReason: "stop", + }, + }, + } + + writeJSON(w, resp) +} + +func handleDownload(w http.ResponseWriter, r *http.Request) { + fileID := r.URL.Query().Get("file_id") + if fileID == "" { + http.Error(w, `{"error":"file_id required"}`, http.StatusBadRequest) + return + } + + // 1. Determine local path based on prompt mapping + prompt := getFilePrompt(fileID) + // Also check if prompt is passed in query string as backup + if prompt == "" { + prompt = r.URL.Query().Get("prompt") + } + + var name string + var localPath string + if prompt != "" { + name = getPromptFilename(prompt, fileID) + localPath = fmt.Sprintf("output/%s.png", name) + } else { + localPath = fmt.Sprintf("output/%s.png", fileID) + } + + var data []byte + var err error + + // 2. Check if file already exists locally + if _, err = os.Stat(localPath); err == nil { + log.Printf("[download] File %s already exists locally, serving from disk", localPath) + data, err = os.ReadFile(localPath) + if err != nil { + log.Printf("[download] Error reading local file %s: %v, will redownload", localPath, err) + data = nil + } + } + + // 3. If not found or failed to read, download it + if len(data) == 0 { + data, err = downloadChatGPTFile(fileID) + if err != nil { + log.Printf("[download] error downloading file %s: %v", fileID, err) + writeJSONStatus(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()}) + return + } + + // Save file locally to output folder + _ = os.MkdirAll("output", 0755) + err = os.WriteFile(localPath, data, 0644) + if err != nil { + log.Printf("[download] error saving file locally to %s: %v", localPath, err) + } else { + log.Printf("[download] file saved successfully to %s", localPath) + } + } + + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) + + headerFilename := fileID + ".png" + if name != "" { + headerFilename = name + ".png" + } + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", headerFilename)) + w.Write(data) +} diff --git a/chatgpt-free-api/helpers.go b/chatgpt-free-api/helpers.go new file mode 100644 index 0000000000000000000000000000000000000000..c105b91a033562df19fecffe38c8f1fde7cd0347 --- /dev/null +++ b/chatgpt-free-api/helpers.go @@ -0,0 +1,49 @@ +package main + +import ( + "encoding/json" + "net/http" + "strings" +) + +func intFromAny(v any) int { + switch x := v.(type) { + case int: + return x + case float64: + return int(x) + case json.Number: + i, _ := x.Int64() + return int(i) + default: + return 0 + } +} + +func boolFromAny(v any) (bool, bool) { + switch x := v.(type) { + case bool: + return x, true + default: + return false, false + } +} + +func redactHeaderValue(key string, value any) any { + switch strings.ToLower(key) { + case "authorization", "openai-sentinel-chat-requirements-token", "openai-sentinel-proof-token", "openai-sentinel-turnstile-token": + return "[redacted]" + default: + return value + } +} + +func writeJSON(w http.ResponseWriter, v any) { + writeJSONStatus(w, http.StatusOK, v) +} + +func writeJSONStatus(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} diff --git a/chatgpt-free-api/main.go b/chatgpt-free-api/main.go new file mode 100644 index 0000000000000000000000000000000000000000..52cefc883d1ce4d9fa8d808108863b530a86cb67 --- /dev/null +++ b/chatgpt-free-api/main.go @@ -0,0 +1,5 @@ +package main + +func main() { + Start() +} diff --git a/chatgpt-free-api/sniff.go b/chatgpt-free-api/sniff.go new file mode 100644 index 0000000000000000000000000000000000000000..7aed70e5bfb12d45517b049c9900ed9cc3f5dd3f --- /dev/null +++ b/chatgpt-free-api/sniff.go @@ -0,0 +1,73 @@ +package main + +import ( + "sync" + "time" +) + +type SniffedRequest struct { + Time string `json:"time"` + Source string `json:"source,omitempty"` + Phase string `json:"phase,omitempty"` + Method string `json:"method,omitempty"` + URL string `json:"url,omitempty"` + Status int `json:"status,omitempty"` + OK *bool `json:"ok,omitempty"` + Headers map[string]any `json:"headers,omitempty"` + Payload string `json:"payload,omitempty"` + Response string `json:"response,omitempty"` + Error string `json:"error,omitempty"` +} + +var ( + sniffMu sync.Mutex + sniffs []SniffedRequest +) + +func sniffFromMessage(msg WSMessage) SniffedRequest { + source, _ := msg.Params["source"].(string) + phase, _ := msg.Params["phase"].(string) + url, _ := msg.Params["url"].(string) + payload, _ := msg.Params["payload"].(string) + response, _ := msg.Params["response"].(string) + errText, _ := msg.Params["error"].(string) + status := intFromAny(msg.Params["status"]) + headers := map[string]any{} + if headersMap, ok := msg.Params["headers"].(map[string]any); ok { + for k, v := range headersMap { + headers[k] = redactHeaderValue(k, v) + } + } + var okPtr *bool + if okVal, ok := boolFromAny(msg.Params["ok"]); ok { + okPtr = &okVal + } + if source == "" { + source = "unknown" + } + if phase == "" { + phase = "request" + } + return SniffedRequest{ + Time: time.Now().Format(time.RFC3339), + Source: source, + Phase: phase, + Method: msg.Method, + URL: url, + Status: status, + OK: okPtr, + Headers: headers, + Payload: payload, + Response: response, + Error: errText, + } +} + +func addSniff(sniff SniffedRequest) { + sniffMu.Lock() + defer sniffMu.Unlock() + sniffs = append([]SniffedRequest{sniff}, sniffs...) + if len(sniffs) > cfg.MaxSniffs { + sniffs = sniffs[:cfg.MaxSniffs] + } +} diff --git a/chatgpt-free-api/test.sh b/chatgpt-free-api/test.sh new file mode 100755 index 0000000000000000000000000000000000000000..354eb930a8c4ebe019f52e7ab7c309eec3779d5c --- /dev/null +++ b/chatgpt-free-api/test.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +# Unset proxy variables for local communication +unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY no_proxy + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}==================================================${NC}" +echo -e "${BLUE} ChatGPT Free API Integration Test ${NC}" +echo -e "${BLUE}==================================================${NC}" + +# Check if agent is running on port 9225 +echo "🔍 Checking if agent is running on port 9225..." +if ! curl -s --connect-timeout 2 http://127.0.0.1:9225/health >/dev/null; then + echo -e "${RED}❌ Error: Agent is not running on port 9225!${NC}" + echo -e "💡 Please start the server separately first by running: ${YELLOW}./agent${NC} or ${YELLOW}go run main.go${NC}" + exit 1 +fi +echo -e "${GREEN}✅ Agent detected on port 9225! Running tests...${NC}" + +# Test 1: Check cookie status +echo -e "\n${BLUE}[Test 1] Checking cookie status...${NC}" +STATUS_RESP=$(curl -s http://127.0.0.1:9225/api/cookies/status) +echo "Response: $STATUS_RESP" +if [[ "$STATUS_RESP" == *"has_cookies\":true"* ]]; then + TEST1_STATUS="${GREEN}PASSED${NC}" +else + TEST1_STATUS="${RED}FAILED (No cookies sync)${NC}" +fi + +# Test 2: Send basic chat test +echo -e "\n${BLUE}[Test 2] Sending basic text chat prompt...${NC}" +CHAT_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat \ + -H "Content-Type: application/json" \ + -d '{"prompt": "Tell me a short programmer joke."}') +echo "Response: $CHAT_RESP" + +CONV_ID=$(echo "$CHAT_RESP" | grep -o '"conversation_id":"[^"]*' | cut -d'"' -f4) +if [ -n "$CONV_ID" ]; then + TEST2_STATUS="${GREEN}PASSED${NC}" +else + TEST2_STATUS="${RED}FAILED (No response or conversation ID)${NC}" +fi + +# Test 3: Conversation thread continuity +if [ -n "$CONV_ID" ]; then + echo -e "\n${BLUE}[Test 3] Testing thread continuity...${NC}" + THREAD_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat \ + -H "Content-Type: application/json" \ + -d "{\"prompt\": \"Explain that joke.\", \"conversation_id\": \"$CONV_ID\"}") + echo "Response: $THREAD_RESP" + if [[ "$THREAD_RESP" == *"ok\":true"* ]]; then + TEST3_STATUS="${GREEN}PASSED${NC}" + else + TEST3_STATUS="${RED}FAILED (Thread reply failed)${NC}" + fi +else + TEST3_STATUS="${YELLOW}SKIPPED (No conversation ID)${NC}" +fi + +# Test 4: Bulk chat request +echo -e "\n${BLUE}[Test 4] Testing bulk chat request...${NC}" +BULK_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat/bulk \ + -H "Content-Type: application/json" \ + -d '{"prompts": ["What is 2+2?", "What is Go?"], "wait_for_response": true}') +echo "Response: $BULK_RESP" +if [[ "$BULK_RESP" == *"ok\":true"* ]]; then + TEST4_STATUS="${GREEN}PASSED${NC}" +else + TEST4_STATUS="${RED}FAILED${NC}" +fi + +# Test 5: Image generation (GPT-2) +echo -e "\n${BLUE}[Test 5] Testing GPT-2 image generation (Cute baby dragon)...${NC}" +echo "⏳ Polling for GPT-2 generation, this will take 15-35 seconds..." +GEN_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat \ + -H "Content-Type: application/json" \ + -d '{"prompt": "Create a cute 3D cartoon baby dragon sitting on a small chest of gold. Directly generate the image now without asking any questions or confirmation."}') +echo "Response: $GEN_RESP" + +# Extract generated image path +GEN_IMAGE_PATH=$(echo "$GEN_RESP" | grep -o '"images":\["[^"]*' | cut -d'"' -f4) +if [ -n "$GEN_IMAGE_PATH" ] && [ -f "$GEN_IMAGE_PATH" ]; then + TEST5_STATUS="${GREEN}PASSED ($GEN_IMAGE_PATH)${NC}" +else + TEST5_STATUS="${RED}FAILED (Image file not generated/saved)${NC}" +fi + +# Test 6: Image editing (GPT-2 Edit/Vision) +if [ -n "$GEN_IMAGE_PATH" ] && [ -f "$GEN_IMAGE_PATH" ]; then + echo -e "\n${BLUE}[Test 6] Testing GPT-2 image editing (Adding wizard hat)...${NC}" + echo "⏳ Uploading source image and polling for edit, this will take 15-35 seconds..." + EDIT_RESP=$(curl -s -X POST http://127.0.0.1:9225/api/chat/edit \ + -F "prompt=Add a small glowing wizard hat to the head of this dragon." \ + -F "image=@$GEN_IMAGE_PATH") + echo "Response: $EDIT_RESP" + + EDIT_IMAGE_PATH=$(echo "$EDIT_RESP" | grep -o '"images":\["[^"]*' | cut -d'"' -f4) + if [ -n "$EDIT_IMAGE_PATH" ] && [ -f "$EDIT_IMAGE_PATH" ]; then + TEST6_STATUS="${GREEN}PASSED ($EDIT_IMAGE_PATH)${NC}" + else + TEST6_STATUS="${RED}FAILED (Edited image file not generated/saved)${NC}" + fi +else + TEST6_STATUS="${YELLOW}SKIPPED (No source image generated in Test 5)${NC}" +fi + +echo -e "\n${BLUE}==================================================${NC}" +echo -e "${BLUE} Test Summary ${NC}" +echo -e "${BLUE}==================================================${NC}" +echo -e "1. Cookie Status: $TEST1_STATUS" +echo -e "2. Basic Chat: $TEST2_STATUS" +echo -e "3. Thread Continuity: $TEST3_STATUS" +echo -e "4. Bulk Chat: $TEST4_STATUS" +echo -e "5. GPT-2 Gen: $TEST5_STATUS" +echo -e "6. GPT-2 Image Editing: $TEST6_STATUS" +echo -e "${BLUE}==================================================${NC}" + +echo -e "\n${GREEN}✅ Tests completed!${NC}\n" diff --git a/flow-agent/.gitignore b/flow-agent/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..612715386468a314f1aa5e947311804661ebed78 --- /dev/null +++ b/flow-agent/.gitignore @@ -0,0 +1,15 @@ +*.mp4 +*.avi +*.mov +__pycache__/ +*.pyc +.env +venv/ +.DS_Store +sniffed.json +sniffed_all.json +media_ids.json +output/ +chunks/ +*.mp4 +__pycache__/ diff --git a/flow-agent/README.md b/flow-agent/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c1d3018984dcc859b07354bc4cfb3cd9d3001f6e --- /dev/null +++ b/flow-agent/README.md @@ -0,0 +1,468 @@ +
+ +# ⚡ Flow Agent + +### Generate AI videos & images via HTTP/HTTPS API Server or CLI — no API key, no limits. + +**Omni Flash** for cinematic video generation · **Nano Banana 2** for unlimited image creation +FastAPI Integration · Auto watermark removal · Reference-based editing · Zero setup. + +--- + +🎬 `T2V` `V2V` `I2V` — Video generation with auto watermark clean *(uses credits)* +🖼️ `T2I` `I2I` — Unlimited image generation with reference support *(no credits needed)* +🔑 Uses your Google account via Chrome extension — **no API key required** +🌐 FastAPI HTTP/HTTPS Server — **perfect for n8n & external automation** + +
+ + +## ✅ Features & Status + +| Feature | What it does | Time | Status | +|---------|-------------|------|--------| +| **T2V** | Generate video from text prompt | ~44s | ✅ Working | +| **T2I** | Generate image from text prompt | ~10-30s | ✅ Working | +| **V2V** | Edit/restyle existing video | ~3min | ✅ Working | +| **I2I** | Edit image with reference | ~10-30s | ✅ Working | +| **I2V** | Animate a still image into video | ~44s | ✅ Working | +| **FL** | First + Last frame video control | ~44s | ✅ Working | +| **R2V** | Reference-based video generation | ~44s | ✅ Working | +| **Upload** | Upload video/image to Flow | ~12s | ✅ Working | +| **Watermark Remove** | Auto-remove Gemini watermark (~1s) | ~1s | ✅ Auto | +| **Auto-Retry** | Auto-open/refresh Flow tab for token | auto | ✅ Built-in | +| **API Sniffer** | Discover new endpoints/payloads | - | ✅ Working | + +--- + +## 📋 Prerequisites + +| Requirement | Details | +|-------------|---------| +| **Python** | 3.9 or higher | +| **Chrome** | Latest version | +| **Google Account** | Logged into Flow | +| **ffmpeg** | Only for V2V merge (optional) | + +--- + +## 🛠️ Installation (Step by Step) + +### Step 1: Clone the repo + +```bash +git clone https://github.com/kodelyx/flow-agent.git +cd flow-agent +``` + +### Step 2: Install Python dependencies + +```bash +pip install -r requirements.txt +``` + +This installs `websockets`, `opencv-python-headless`, and `numpy`. + +### Step 3: Install the Chrome Extension + +1. Open Chrome browser +2. Go to `chrome://extensions` in the address bar +3. Toggle **"Developer mode"** ON (top-right corner) +4. Click **"Load unpacked"** +5. Select the `extension/` folder from this repo +6. You should see the **Flow Agent** extension appear + +### Step 4: Open Google Flow + +1. Open [labs.google/fx/tools/flow](https://labs.google/fx/tools/flow) in Chrome +2. Make sure you're **logged into your Google account** +3. The extension icon should show a **green badge** = connected +4. The extension auto-opens this tab when needed + +> ⚠️ The Flow tab auto-opens when you run a command. No manual tab management needed! + +--- + +## 🚀 Usage + +### Text → Video (T2V) + +Generate a new video from a text description. + +```bash +# Basic (portrait 9:16, 10 seconds) +python -m cli.generate "A samurai drawing his katana on a cliff at golden sunset" + +# Landscape mode (16:9) +python -m cli.generate "Eagle soaring over snowy mountains" --aspect landscape + +# Custom output file +python -m cli.generate "A dragon breathing fire" -o dragon.mp4 + +# Shorter duration (4/6/8/10 seconds) +python -m cli.generate "Dog playing in the park" --duration 6 + +# Generate multiple variations +python -m cli.generate "Cyberpunk city at night" --count 4 + +# I2V — animate a still image +python -m cli.generate "Character comes alive" --start photo.png + +# FL — First + Last frame (controlled transition) +python -m cli.generate "Person walks forward" --start start.png --end end.png + +# R2V — Reference images (character consistency) +python -m cli.generate "Character in new scene" --ref char1.png char2.png +``` + +**CLI Options:** +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--output` | `-o` | `omni_output.mp4` | Output filename | +| `--aspect` | `-a` | `portrait` | `portrait` or `landscape` | +| `--duration` | `-d` | `10` | `4`, `6`, `8`, or `10` seconds | +| `--count` | `-c` | `1` | Generate 1-4 videos | +| `--edit` | `-e` | - | Pass media_id for V2V edit mode | +| `--start` | `-s` | - | Start frame image (I2V / FL mode) | +| `--end` | | - | End frame image (use with --start for FL) | +| `--ref` | `-r` | - | Reference image(s) for R2V mode | +| `--no-clean` | | - | Skip auto watermark removal | + +--- + +### Text → Image (T2I) + +Generate images from a text description. + +```bash +# Basic (portrait 9:16) +python -m cli.image "A dragon breathing fire in a cyberpunk city" + +# Landscape +python -m cli.image "Mountain sunset" --aspect landscape -o sunset.png + +# Square (for logos, icons) +python -m cli.image "Minimal logo design" --aspect square + +# Generate 4 variations +python -m cli.image "Abstract art" --count 4 + +# I2I: Edit with reference image +python -m cli.image "Make it anime style" --ref original.png -o anime.png +``` + +**CLI Options:** +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--output` | `-o` | `output/image.png` | Output filename | +| `--aspect` | `-a` | `portrait` | `portrait`, `landscape`, `square`, `4x3`, `3x4` | +| `--count` | `-c` | `1` | Generate 1-4 variations | +| `--ref` | `-r` | - | Reference image(s) for I2I | + +### Upload Video + +Upload a local video to Google Flow. Returns a `media_id` needed for V2V editing. + +```bash +# Single video +python -m cli.upload my_video.mp4 + +# Batch upload all .mp4 in a folder +python -m cli.upload chunks/ --batch +``` + +The `media_id` is **automatically saved** to `media-id.js`: +``` +my_video.mp4 : 49f7d936-01e3-41ad-917a-2f9bb6ead00b +``` + +--- + +### Video → Video Edit (V2V) + +Apply style changes to an uploaded video (e.g., convert to anime). + +```bash +# Step 1: Upload your video +python -m cli.upload my_video.mp4 +# → media_id saved to media-id.js + +# Step 2: Edit with style prompt +python -m cli.edit "Transform into vibrant anime style, Studio Ghibli aesthetic" \ + --media-id 49f7d936-01e3-41ad-917a-2f9bb6ead00b \ + --video-file my_video.mp4 \ + --output output_anime/ \ + --merge + +# Without local video file (specify duration manually) +python -m cli.edit "Make it look cyberpunk neon" \ + -m MEDIA_ID \ + --total-seconds 30 \ + -o output_cyber/ +``` + +**How it works:** Long videos are automatically split into 10s segments, each processed in parallel, then merged with ffmpeg. + +**CLI Options:** +| Flag | Short | Required | Description | +|------|-------|----------|-------------| +| `--media-id` | `-m` | Yes | Flow media ID (from upload) | +| `--video-file` | `-v` | No | Local file (auto-detects duration/fps) | +| `--total-seconds` | `-t` | No | Duration if no local file | +| `--output` | `-o` | No | Output directory (default: `output/`) | +| `--aspect` | `-a` | No | `portrait` or `landscape` | +| `--merge` | | No | Merge segments with ffmpeg | + +--- + +### Image → Video (I2V) + +Animate a still image into a video. Requires Python scripting (no CLI yet): + +```python +import asyncio +from omniflash import ( + ExtensionBridge, upload_image, generate_video_i2v, + poll_status, download_video, ASPECTS, DEFAULT_PROJECT, +) + +async def main(): + # Connect to extension + bridge = ExtensionBridge() + await bridge.start() + await bridge.wait_for_extension(30) + + # Upload your image + img_id = await upload_image(bridge, "my_image.png") + print(f"Image uploaded: {img_id}") + + # Generate video from image + media_ids = await generate_video_i2v( + bridge, + prompt="The character comes alive, dramatic movement, cinematic", + aspect=ASPECTS['portrait'], # or ASPECTS['landscape'] + project_id=DEFAULT_PROJECT, + image_media_id=img_id, + duration=8, # 4, 6, 8, or 10 seconds + ) + + # Wait for video to finish + if media_ids: + await poll_status(bridge, media_ids[0], DEFAULT_PROJECT) + await download_video(bridge, media_ids[0], "output_i2v.mp4") + + await bridge.close() + +asyncio.run(main()) +``` + +--- + +### API Sniffer + +Capture all API requests made by the Flow UI. Useful for discovering new endpoints or debugging. + +```bash +# Start sniffer, then use Flow UI normally +python -m cli.sniff + +# Save captured requests to file +python -m cli.sniff --save sniffed.json +``` + +See [SNIFFING.md](SNIFFING.md) for the full API discovery guide. + +--- + +## 🌐 HTTP/HTTPS API Server (for n8n & external integrations) + +A FastAPI-based API server is included to trigger all Flow Agent features remotely (e.g., from n8n HTTP Request nodes, custom webhooks, or automation workflows). + +### Start the API Server + +Run the server from the project directory: +```bash +# Standard HTTP (defaults to port 8000) +venv/bin/python -m cli.api --host 0.0.0.0 --port 8000 + +# Optional HTTPS (auto-generates self-signed SSL certificates) +venv/bin/python -m cli.api --host 0.0.0.0 --port 8443 --ssl +``` + +### Core API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| **GET** | `/health` | Check Extension Bridge health and token status. | +| **POST** | `/generate/video` | T2V, I2V, FL, R2V video generation. Supports query `?download=true` to stream the binary `.mp4` file. | +| **POST** | `/generate/image` | T2I, I2I image generation. Supports `?download=true` to stream the binary `.png` file. | +| **POST** | `/upload/image` | Upload an image to Flow (via file upload or local file path). | +| **POST** | `/upload/video` | Upload a video to Flow (via file upload or local file path). | +| **POST** | `/edit/video` | V2V video editing (restyling segment duration). | +| **GET** | `/download/{filename}` | Download generated image/video files from the `output/` folder. | + +### Verify with Integration Tests + +A comprehensive integration test script is provided to verify all endpoints: +```bash +venv/bin/python test_api.py +``` + +See [error.md](error.md) for detailed troubleshooting instructions if you encounter port conflicts or proxy network issues. + +--- + +## 🐍 Python API (for developers) + +Use the `omniflash` package directly in your own scripts: + +```python +from omniflash import ( + ExtensionBridge, # WebSocket bridge to Chrome extension + generate_video, # T2V: text → video + edit_video, # V2V: video → video + upload_image, # Upload image → get media_id + generate_video_i2v, # I2V: image → video + poll_status, # Poll until video is ready + download_video, # Download finished video + ASPECTS, # {'portrait': '...', 'landscape': '...'} + DEFAULT_PROJECT, # Your default project ID +) +from omniflash.upload import upload_video # Upload video file +from omniflash import media_store # Read/write media-id.js + +# media_store examples: +media_store.save("video.mp4", "uuid-here") # Save entry +mid = media_store.get("video.mp4") # Get media_id +all_entries = media_store.read_entries() # Get all entries +``` + +> **Backward compatible:** `from omni import ExtensionBridge, generate_video, ...` still works. + +--- + +## 📁 Project Structure + +``` +flow-agent/ +├── omniflash/ # Core Python package +│ ├── __init__.py # Public API exports +│ ├── bridge.py # ExtensionBridge (WS + HTTP + auto-retry) +│ ├── config.py # All config hardcoded here +│ ├── media_store.py # media-id.js read/write +│ ├── upload.py # Video upload (GCS resumable) +│ ├── watermark.py # Auto watermark removal (embedded assets) +│ └── generators/ # API functions +│ ├── common.py # poll_status, download_video +│ ├── t2v.py # Text → Video +│ ├── t2i.py # Text → Image + I2I +│ ├── v2v.py # Video → Video (edit) +│ └── i2v.py # Image → Video + upload_image +├── cli/ # CLI entry points +│ ├── generate.py # python -m cli.generate +│ ├── image.py # python -m cli.image +│ ├── upload.py # python -m cli.upload +│ ├── edit.py # python -m cli.edit +│ └── sniff.py # python -m cli.sniff +├── extension/ # Chrome extension +│ ├── manifest.json # Extension manifest +│ ├── background.js # WS client, API proxy +│ ├── content.js # Page ↔ background bridge +│ └── injected.js # Fetch interceptor, reCAPTCHA +├── omni.py # Backward-compatible wrapper +├── .gitignore # Git ignore rules +├── media-id.js # Auto-updated filename → media_id +├── requirements.txt # Python dependencies +├── SNIFFING.md # API discovery guide +└── README.md +``` + +--- + +## ⚙️ How It Works + +``` +┌─────────────────────────────────┐ +│ Your Terminal / Python Script │ +│ python -m cli.generate "..." │ +└──────────┬──────────────────────┘ + │ import omniflash + ▼ +┌─────────────────────────────────┐ +│ omniflash package │ +│ ExtensionBridge (WS + HTTP) │ +└──────────┬──────────────────────┘ + │ WebSocket (:9222) + │ HTTP callback (:8100) + ▼ +┌─────────────────────────────────┐ +│ Chrome Extension (Flow Agent) │ +│ Auth token + reCAPTCHA solving │ +└──────────┬──────────────────────┘ + │ HTTPS (browser cookies) + ▼ +┌─────────────────────────────────┐ +│ Google Omni API (aisandbox) │ +│ Video generation / editing │ +└─────────────────────────────────┘ +``` + +1. Python starts a WebSocket server + HTTP callback server +2. Chrome extension auto-connects and provides authentication +3. Script sends API requests through the extension +4. Extension solves reCAPTCHA and proxies with browser cookies +5. Script polls for completion, then downloads the result + +--- + +## 🎯 Models & Endpoints + +| Model | Key | Duration | Type | +|-------|-----|----------|------| +| Omni Flash T2V 4s | `abra_t2v_4s` | 4 sec | Text → Video | +| Omni Flash T2V 6s | `abra_t2v_6s` | 6 sec | Text → Video | +| Omni Flash T2V 8s | `abra_t2v_8s` | 8 sec | Text → Video | +| Omni Flash T2V 10s | `abra_t2v_10s` | 10 sec | Text → Video | +| Omni Flash Edit | `abra_edit` | 10 sec | Video → Video | + +| Endpoint | Path | +|----------|------| +| T2V | `/v1/video:batchAsyncGenerateVideoText` | +| I2V | `/v1/video:batchAsyncGenerateVideoStartImage` | +| V2V Edit | `/v1/video:batchAsyncGenerateVideoEditVideo` | +| Upload Image | `/v1/flow/uploadImage` | +| Poll Status | `/v1/video:batchCheckAsyncVideoGenerationStatus` | +| Get Media | `/v1/video/media/{media_id}` | + +--- + +## 🔧 Troubleshooting + +| Problem | Solution | +|---------|----------| +| Extension not connecting | Make sure Flow tab is open and you're logged in | +| `Address already in use` | Another script is using port 9222/8100. Kill it first | +| `TIMEOUT` error | Extension may have disconnected. Reload Flow tab | +| `reCAPTCHA failed` | Reload Flow tab, wait a few seconds, try again | +| `No media in response` | Check your prompt. Some prompts get blocked | +| `curl failed` | Upload too large or network issue. Retry | +| Video quality poor | Use longer duration (10s) and detailed prompts | +| V2V merge fails | Install ffmpeg: `brew install ffmpeg` | + +--- + +## ⚠️ Important Notes + +- **Flow tab auto-opens** — no manual tab management needed +- Uses your Google account's **free Flow credits** (check remaining in Flow UI) +- Extension auto-reconnects and auto-retries (3 attempts) +- **Watermark auto-removed** on every generated video (~1s) +- `media-id.js` auto-updates on every upload (video or image) +- All generated videos save to the `output/` directory by default +- Old `from omni import ...` syntax still works (backward compatible) + +--- + +## 📄 License + +MIT diff --git a/flow-agent/SNIFFING.md b/flow-agent/SNIFFING.md new file mode 100644 index 0000000000000000000000000000000000000000..43ecca02ef51a0fa6082ab5120f4ad0e8a3e1270 --- /dev/null +++ b/flow-agent/SNIFFING.md @@ -0,0 +1,205 @@ +# 🔍 API Sniffing Guide + +How to discover new Google Flow API endpoints using the Chrome extension's built-in request sniffer. + +## How It Works + +``` +Flow UI (browser) + ↓ user clicks "Generate" / "Upload" etc. + ↓ +fetch() call to aisandbox-pa.googleapis.com + ↓ intercepted by injected.js (monkey-patched fetch) + ↓ +postMessage → content.js → background.js + ↓ +HTTP POST → http://127.0.0.1:8100/api/ext/callback + ↓ +Your sniff server logs the URL + payload +``` + +The extension's `injected.js` monkey-patches `window.fetch` to intercept ALL outgoing requests to `aisandbox-pa.googleapis.com`. Every request's URL, method, and body are forwarded to your local server. + +## Quick Start + +### 1. Start the Sniff Server + +```python +python sniff.py +``` + +This starts: +- WebSocket server on `ws://127.0.0.1:9222` (extension connects here) +- HTTP server on `http://127.0.0.1:8100` (receives sniffed data) + +### 2. Open Flow UI + +Go to [labs.google/fx/tools/flow](https://labs.google/fx/tools/flow) in Chrome. +The extension will auto-connect to your sniff server. + +### 3. Perform the Action + +Do whatever you want to discover the API for: +- Upload an image/video +- Generate a video +- Change settings +- Click any button + +### 4. Read the Logs + +The sniff server prints every intercepted request: +``` +🔍 SNIFFED: https://aisandbox-pa.googleapis.com/v1/video:batchAsyncGenerateVideoText + Method: POST + Payload: {"mediaGenerationContext":{"batchId":"..."},"clientContext":{...},"requests":[...]} +``` + +## Sniff Server Code + +Save this as `sniff.py`: + +```python +#!/usr/bin/env python3 +"""Sniff server — captures all Flow UI API requests.""" + +import asyncio, json, websockets, logging +from http.server import HTTPServer, BaseHTTPRequestHandler +import threading + +logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s', datefmt='%H:%M:%S') +log = logging.getLogger('sniff') + +# Store all sniffed requests +all_requests = [] + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get('Content-Length', 0)) + body = json.loads(self.rfile.read(length)) if length else {} + + if body.get('type') == 'sniffed_video_request': + url = body.get('url', '') + method = body.get('method', '?') + payload = body.get('payload', '') + + log.info('🔍 %s %s', method, url) + if payload: + log.info(' %s', str(payload)[:1000]) + + all_requests.append({ + 'url': url, + 'method': method, + 'payload': payload, + 'timestamp': body.get('timestamp'), + }) + + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + self.wfile.write(b'{"ok":true}') + + def do_OPTIONS(self): + self.send_response(200) + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'POST') + self.send_header('Access-Control-Allow-Headers', 'Content-Type') + self.end_headers() + + def log_message(self, *a): pass + +# Start HTTP server +srv = HTTPServer(('127.0.0.1', 8100), Handler) +threading.Thread(target=srv.serve_forever, daemon=True).start() +log.info('HTTP callback on :8100') + +async def on_connect(ws): + log.info('✅ Extension connected!') + async for raw in ws: + data = json.loads(raw) + if data.get('type') == 'token_captured': + log.info('🔑 Token captured') + +async def main(): + async with websockets.serve(on_connect, '127.0.0.1', 9222): + log.info('⚡ WS server on :9222') + log.info('👉 Open Flow UI and perform any action...') + await asyncio.Future() + +asyncio.run(main()) +``` + +## What Gets Captured + +| Action in Flow UI | API Endpoint | +|-------------------|-------------| +| Generate video (T2V) | `/v1/video:batchAsyncGenerateVideoText` | +| Generate video (I2V) | `/v1/video:batchAsyncGenerateVideoStartImage` | +| Generate video (Edit) | `/v1/video:batchAsyncGenerateVideoEditVideo` | +| Poll video status | `/v1/video:batchCheckAsyncVideoGenerationStatus` | +| Upload image | `/v1/flow/uploadImage` | +| Generate image | `/v1/projects/{id}/flowMedia:batchGenerateImages` | +| Get credits | `/v1/credits` | +| Get media | `/v1/media/{media_id}` | +| Upscale video | `/v1/video:batchAsyncGenerateVideoUpsampleVideo` | + +## How to Find New Endpoints + +### Example: Finding Video Upload + +1. Start `sniff.py` +2. Open Flow UI +3. Drag & drop a video file into Flow +4. Check logs — you'll see the upload URL and payload format +5. Add the new endpoint to `models.json` + +### Example: Finding Model Keys + +1. Start `sniff.py` +2. Open Flow UI +3. Select different model (e.g., Omni Flash 4s) +4. Click Generate +5. Check logs — look for `videoModelKey` in the payload + +```json +"requests": [{ + "videoModelKey": "abra_t2v_4s", ← this is what you need + ... +}] +``` + +## Architecture + +``` +┌─────────────────────────────────────────────┐ +│ injected.js (MAIN world) │ +│ - Monkey-patches window.fetch │ +│ - Captures URL + body of every request │ +│ - Posts to content.js via postMessage │ +└──────────────────┬──────────────────────────┘ + │ postMessage +┌──────────────────▼──────────────────────────┐ +│ content.js (ISOLATED world) │ +│ - Listens for __FLOWKIT_SNIFF__ messages │ +│ - Forwards to background.js │ +└──────────────────┬──────────────────────────┘ + │ chrome.runtime.sendMessage +┌──────────────────▼──────────────────────────┐ +│ background.js (Service Worker) │ +│ - Receives SNIFFED_AISANDBOX_REQUEST │ +│ - POSTs to http://127.0.0.1:8100/callback │ +└──────────────────┬──────────────────────────┘ + │ HTTP POST +┌──────────────────▼──────────────────────────┐ +│ sniff.py (Your server) │ +│ - Logs every request │ +│ - Saves URL + method + payload │ +└─────────────────────────────────────────────┘ +``` + +## Tips + +- **Filter by keyword**: Modify the sniff server to only log URLs containing specific words (e.g., `upload`, `video`, `generate`) +- **Save to file**: Add `json.dump(all_requests, open('sniffed.json', 'w'))` to save all captured requests +- **Compare payloads**: Run the same action with different settings and diff the payloads to find which fields control what +- **Telemetry noise**: Ignore URLs containing `batchLog`, `fetchUserRecommendations`, `frontendEvents` — these are analytics, not API calls diff --git a/flow-agent/cli/api.py b/flow-agent/cli/api.py new file mode 100644 index 0000000000000000000000000000000000000000..e78370c09009c80c788ae33f38f65c359968ac11 --- /dev/null +++ b/flow-agent/cli/api.py @@ -0,0 +1,617 @@ +#!/usr/bin/env python3 +"""FastAPI Server for Flow Agent — expose CLI functionality via HTTP/HTTPS. + +Allows n8n and other remote systems to trigger video/image generation and upload assets. +""" + +import os +import sys +import uuid +import time +import shutil +import base64 +import logging +import asyncio +from typing import List, Optional +from contextlib import asynccontextmanager + +# Add parent dir to sys.path so omniflash can be imported +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Query +from fastapi.responses import FileResponse, JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +from omniflash import ( + ExtensionBridge, generate_video, edit_video, + poll_status, download_video, ASPECTS, DEFAULT_PROJECT, +) +from omniflash.generators.i2v import upload_image, generate_video_i2v, generate_video_fl, generate_video_r2v +from omniflash.generators.t2i import generate_image, download_image, IMAGE_ASPECTS +from omniflash.upload import upload_video + +# Setup logging +log = logging.getLogger("omniflash.api") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S") + +# Ensure required directories exist +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +OUTPUT_DIR = os.path.join(ROOT_DIR, "output") +TEMP_DIR = os.path.join(OUTPUT_DIR, ".temp") + +def ensure_temp_dir(): + os.makedirs(TEMP_DIR, exist_ok=True) + +def cleanup_temp_dir(): + try: + if os.path.exists(TEMP_DIR) and not os.listdir(TEMP_DIR): + os.rmdir(TEMP_DIR) + except Exception: + pass + +# Global ExtensionBridge instance +bridge: Optional[ExtensionBridge] = None + +@asynccontextmanager +async def lifespan(app: FastAPI): + global bridge + log.info("🚀 Starting Flow Agent Extension Bridge...") + bridge = ExtensionBridge() + await bridge.start() + + # Run extension connection in background so the API server starts immediately + asyncio.create_task(bridge.wait_for_extension(timeout=30)) + + yield + + log.info("🔌 Closing Flow Agent Extension Bridge...") + if bridge: + await bridge.close() + cleanup_temp_dir() + +app = FastAPI( + title="Flow Agent API", + description="API Server to trigger Google Flow AI video and image generation", + version="1.0.0", + lifespan=lifespan +) + +# Enable CORS for convenience +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Helper function to check/reconnect the bridge +async def get_active_bridge() -> ExtensionBridge: + global bridge + if not bridge: + raise HTTPException(status_code=503, detail="Extension bridge is not initialized") + + # Try a quick health check + is_healthy = await bridge.health_check() + if not is_healthy: + log.info("🔄 Bridge health check failed. Re-waiting for extension connection...") + # Attempt to reconnect / grab flowKey + connected = await bridge.wait_for_extension(timeout=10, max_retries=1) + if not connected: + raise HTTPException( + status_code=503, + detail="Google Flow extension is not connected or unauthorized. Make sure Google Flow tab is open in Chrome." + ) + return bridge + +# Helper to process image inputs (local path, media_id, or base64 data) +async def resolve_image_input(active_bridge: ExtensionBridge, path_or_id_or_b64: str, project_id: str) -> str: + if not path_or_id_or_b64: + return "" + + # Case 1: Base64 data (e.g. data:image/png;base64,... or raw base64) + if path_or_id_or_b64.startswith("data:") or len(path_or_id_or_b64) > 500: + try: + if "," in path_or_id_or_b64: + base64_data = path_or_id_or_b64.split(",", 1)[1] + else: + base64_data = path_or_id_or_b64 + + img_bytes = base64.b64decode(base64_data) + temp_filename = f"b64_{uuid.uuid4().hex}.png" + ensure_temp_dir() + temp_path = os.path.join(TEMP_DIR, temp_filename) + with open(temp_path, "wb") as f: + f.write(img_bytes) + + mid = await upload_image(active_bridge, temp_path, project_id) + try: + os.remove(temp_path) + except OSError: + pass + cleanup_temp_dir() + + if not mid: + raise HTTPException(status_code=400, detail="Failed to upload base64 image reference") + return mid + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed parsing base64 image: {str(e)}") + + # Case 2: Local file path + if os.path.exists(path_or_id_or_b64): + mid = await upload_image(active_bridge, path_or_id_or_b64, project_id) + if not mid: + raise HTTPException(status_code=400, detail=f"Failed to upload local image path: {path_or_id_or_b64}") + return mid + + # Case 3: Already a Media ID (UUID or similar format) + return path_or_id_or_b64 + + +# Request Models +class VideoGenerationRequest(BaseModel): + prompt: str = Field(..., description="Text prompt for video generation") + aspect: str = Field("portrait", description="Aspect ratio: 'portrait' or 'landscape'") + duration: int = Field(10, description="Duration in seconds: 4, 6, 8, or 10") + count: int = Field(1, description="Number of variations (1-4)") + project_id: str = Field(DEFAULT_PROJECT, description="Flow project ID") + start: Optional[str] = Field(None, description="Start frame image (file path, media_id, or base64)") + end: Optional[str] = Field(None, description="End frame image (use with start for FL mode)") + ref: Optional[List[str]] = Field(None, description="Reference image(s) (file path, media_id, or base64)") + edit: Optional[str] = Field(None, description="Flow video media_id for video editing (V2V)") + no_clean: bool = Field(False, description="Skip watermark removal") + + +class ImageGenerationRequest(BaseModel): + prompt: str = Field(..., description="Text prompt for image generation") + aspect: str = Field("portrait", description="Aspect ratio: 'portrait', 'landscape', 'square', '4x3', '3x4'") + count: int = Field(1, description="Number of variations (1-4)") + ref: Optional[List[str]] = Field(None, description="Reference image(s) (file path, media_id, or base64)") + project_id: str = Field(DEFAULT_PROJECT, description="Flow project ID") + + +class VideoEditRequest(BaseModel): + prompt: str = Field(..., description="Restyle/edit text prompt") + video_media_id: str = Field(..., description="Original video media_id") + aspect: str = Field("portrait", description="Aspect ratio: 'portrait' or 'landscape'") + fps: int = Field(24, description="FPS of source video") + duration: int = Field(10, description="Duration of segment to edit") + start_frame: int = Field(0, description="Start frame index") + end_frame: Optional[int] = Field(None, description="End frame index") + project_id: str = Field(DEFAULT_PROJECT, description="Flow project ID") + download: bool = Field(False, description="Directly download binary video stream") + + +# API Routes + +@app.get("/health") +async def health(): + """Check API server connection and Chrome extension authorization.""" + global bridge + if not bridge: + return {"status": "starting", "extension_connected": False, "has_flow_key": False} + + is_healthy = await bridge.health_check() + return { + "status": "healthy" if is_healthy else "disconnected_or_unauthorized", + "extension_connected": bridge._ws is not None, + "has_flow_key": bridge._flow_key is not None + } + + +@app.post("/upload/image") +async def api_upload_image( + file: Optional[UploadFile] = File(None), + path: Optional[str] = Form(None), + project_id: str = Form(DEFAULT_PROJECT) +): + """Upload an image to Google Flow. Accepts multipart file upload or local file path.""" + active_bridge = await get_active_bridge() + + temp_path = None + if file: + temp_filename = f"upload_{uuid.uuid4().hex}_{file.filename}" + ensure_temp_dir() + temp_path = os.path.join(TEMP_DIR, temp_filename) + with open(temp_path, "wb") as f: + shutil.copyfileobj(file.file, f) + upload_path = temp_path + elif path: + if not os.path.exists(path): + raise HTTPException(status_code=404, detail=f"Local file not found: {path}") + upload_path = path + else: + raise HTTPException(status_code=400, detail="Must provide 'file' (multipart) or 'path' (form parameter)") + + try: + media_id = await upload_image(active_bridge, upload_path, project_id) + if not media_id: + raise HTTPException(status_code=500, detail="Flow image upload failed") + return {"success": True, "media_id": media_id} + finally: + if temp_path and os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + cleanup_temp_dir() + + +@app.post("/upload/video") +async def api_upload_video( + file: Optional[UploadFile] = File(None), + path: Optional[str] = Form(None), + project_id: str = Form(DEFAULT_PROJECT) +): + """Upload a video to Google Flow. Accepts multipart file upload or local file path.""" + active_bridge = await get_active_bridge() + + temp_path = None + if file: + temp_filename = f"upload_{uuid.uuid4().hex}_{file.filename}" + ensure_temp_dir() + temp_path = os.path.join(TEMP_DIR, temp_filename) + with open(temp_path, "wb") as f: + shutil.copyfileobj(file.file, f) + upload_path = temp_path + elif path: + if not os.path.exists(path): + raise HTTPException(status_code=404, detail=f"Local file not found: {path}") + upload_path = path + else: + raise HTTPException(status_code=400, detail="Must provide 'file' (multipart) or 'path' (form parameter)") + + try: + result = await upload_video(upload_path, project_id, active_bridge) + media_id = result.get("mediaId") or result.get("name") or result.get("id") + if not media_id and isinstance(result.get("media"), dict): + media_id = result["media"].get("name") or result["media"].get("mediaId") + + if not media_id: + raise HTTPException(status_code=500, detail=f"Flow video upload failed: {result}") + return {"success": True, "media_id": media_id, "data": result} + finally: + if temp_path and os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + cleanup_temp_dir() + + +@app.post("/generate/video") +async def api_generate_video(req: VideoGenerationRequest, download: bool = Query(False)): + """Generate or edit video via text prompt and optional references (T2V, I2V, FL, R2V, V2V).""" + active_bridge = await get_active_bridge() + aspect = ASPECTS.get(req.aspect, "VIDEO_ASPECT_RATIO_PORTRAIT") + + # 1. Resolve starting image (I2V / FL) + start_id = None + if req.start: + start_id = await resolve_image_input(active_bridge, req.start, req.project_id) + + # 2. Resolve end image (FL) + end_id = None + if req.end: + end_id = await resolve_image_input(active_bridge, req.end, req.project_id) + + # 3. Resolve reference images (R2V) + ref_ids = [] + if req.ref: + for r in req.ref: + mid = await resolve_image_input(active_bridge, r, req.project_id) + if mid: + ref_ids.append(mid) + + # 4. Trigger generation + media_ids = None + if start_id and end_id: + media_ids = await generate_video_fl( + active_bridge, req.prompt, aspect, req.project_id, + start_image_id=start_id, end_image_id=end_id, duration=req.duration + ) + elif start_id: + media_ids = await generate_video_i2v( + active_bridge, req.prompt, aspect, req.project_id, + image_media_id=start_id, duration=req.duration + ) + elif ref_ids: + media_ids = await generate_video_r2v( + active_bridge, req.prompt, aspect, req.project_id, + ref_media_ids=ref_ids, duration=req.duration + ) + elif req.edit: + media_ids = await edit_video( + active_bridge, req.prompt, aspect, req.project_id, + video_media_id=req.edit, duration=req.duration + ) + else: + media_ids = await generate_video( + active_bridge, req.prompt, aspect, req.project_id, + duration=req.duration, count=req.count + ) + + if not media_ids: + raise HTTPException(status_code=500, detail="Failed to initiate video generation") + + outputs = [] + timestamp = int(time.time()) + + # 5. Poll and Download + for i, media_id in enumerate(media_ids): + log.info(f"Polling video [{i+1}/{len(media_ids)}] ID: {media_id}") + if not await poll_status(active_bridge, media_id, req.project_id): + log.error(f"Polling failed for media ID: {media_id}") + continue + + unique_id = uuid.uuid4().hex[:6] + filename = f"omni_{timestamp}_{unique_id}_{i+1}.mp4" + out_path = os.path.join(OUTPUT_DIR, filename) + ensure_temp_dir() + temp_path = os.path.join(TEMP_DIR, filename) + + if await download_video(active_bridge, media_id, temp_path): + if not req.no_clean: + try: + from omniflash.watermark import remove_watermark_video + remove_watermark_video(temp_path, out_path) + try: + os.remove(temp_path) + except OSError: + pass + except Exception as e: + log.warning(f"Watermark removal failed: {e}. Fallback to raw video.") + os.replace(temp_path, out_path) + else: + os.replace(temp_path, out_path) + + outputs.append({ + "media_id": media_id, + "filename": filename, + "local_path": out_path, + "download_url": f"/download/{filename}" + }) + + cleanup_temp_dir() + if not outputs: + raise HTTPException(status_code=500, detail="Failed to download generated video(s)") + + # Return binary directly if requested and single file + if download and len(outputs) == 1: + return FileResponse( + path=outputs[0]["local_path"], + filename=outputs[0]["filename"], + media_type="video/mp4" + ) + + return { + "success": True, + "outputs": outputs + } + + +@app.post("/generate/image") +async def api_generate_image(req: ImageGenerationRequest, download: bool = Query(False)): + """Generate image using text prompt and optional reference images (T2I, I2I).""" + active_bridge = await get_active_bridge() + aspect = req.aspect + + # Resolve reference images if any + ref_ids = [] + if req.ref: + for r in req.ref: + mid = await resolve_image_input(active_bridge, r, req.project_id) + if mid: + ref_ids.append(mid) + + results = await generate_image( + active_bridge, req.prompt, aspect, req.project_id, + count=req.count, ref_media_ids=ref_ids or None + ) + + if not results: + raise HTTPException(status_code=500, detail="Failed to generate image") + + outputs = [] + timestamp = int(time.time()) + + for i, r in enumerate(results): + url = r.get("image_url") + media_id = r.get("media_id") + if not url: + continue + + unique_id = uuid.uuid4().hex[:6] + filename = f"img_{timestamp}_{unique_id}_{i+1}.png" + out_path = os.path.join(OUTPUT_DIR, filename) + + download_success = await download_image(active_bridge, url, out_path) + + outputs.append({ + "media_id": media_id, + "filename": filename, + "local_path": out_path if download_success else None, + "download_url": f"/download/{filename}" if download_success else None, + "remote_url": url, + "downloaded": download_success + }) + + # Return binary directly if requested, single image, and it was successfully downloaded + if download and len(outputs) == 1 and outputs[0]["downloaded"]: + return FileResponse( + path=outputs[0]["local_path"], + filename=outputs[0]["filename"], + media_type="image/png" + ) + + return { + "success": True, + "outputs": outputs + } + + +@app.post("/edit/video") +async def api_edit_video(req: VideoEditRequest): + """Submit V2V edit request.""" + active_bridge = await get_active_bridge() + aspect = ASPECTS.get(req.aspect, "VIDEO_ASPECT_RATIO_PORTRAIT") + + media_ids = await edit_video( + active_bridge, req.prompt, aspect, req.project_id, + video_media_id=req.video_media_id, fps=req.fps, + duration=req.duration, start_frame=req.start_frame, + end_frame=req.end_frame + ) + + if not media_ids: + raise HTTPException(status_code=500, detail="Failed to submit V2V edit request") + + outputs = [] + timestamp = int(time.time()) + + for i, media_id in enumerate(media_ids): + log.info(f"Polling edited video [{i+1}/{len(media_ids)}] ID: {media_id}") + if not await poll_status(active_bridge, media_id, req.project_id): + continue + + unique_id = uuid.uuid4().hex[:6] + filename = f"edit_{timestamp}_{unique_id}_{i+1}.mp4" + out_path = os.path.join(OUTPUT_DIR, filename) + ensure_temp_dir() + temp_path = os.path.join(TEMP_DIR, filename) + + if await download_video(active_bridge, media_id, temp_path): + # V2V edited segments might also have watermarks + try: + from omniflash.watermark import remove_watermark_video + remove_watermark_video(temp_path, out_path) + try: + os.remove(temp_path) + except OSError: + pass + except Exception as e: + log.warning(f"Watermark removal failed: {e}. Fallback to raw video.") + os.replace(temp_path, out_path) + + outputs.append({ + "media_id": media_id, + "filename": filename, + "local_path": out_path, + "download_url": f"/download/{filename}" + }) + + cleanup_temp_dir() + if not outputs: + raise HTTPException(status_code=500, detail="Failed to download edited video(s)") + + if req.download and len(outputs) == 1: + return FileResponse( + path=outputs[0]["local_path"], + filename=outputs[0]["filename"], + media_type="video/mp4" + ) + + return { + "success": True, + "outputs": outputs + } + + +@app.get("/download/{filename}") +async def api_download_file(filename: str): + """Download generated assets from output folder.""" + file_path = os.path.join(OUTPUT_DIR, filename) + if not os.path.exists(file_path): + raise HTTPException(status_code=404, detail="Requested file not found") + + # Standardize content types + media_type = "application/octet-stream" + if filename.endswith(".mp4"): + media_type = "video/mp4" + elif filename.endswith(".png"): + media_type = "image/png" + elif filename.endswith(".jpg") or filename.endswith(".jpeg"): + media_type = "image/jpeg" + + return FileResponse(path=file_path, filename=filename, media_type=media_type) + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description="Flow Agent API Server") + parser.add_argument("--host", default="127.0.0.1", help="Host address") + parser.add_argument("--port", type=int, default=8000, help="Port to run on") + parser.add_argument("--ssl", action="store_true", help="Enable self-signed SSL certificate") + parser.add_argument("--ssl-certfile", help="SSL certificate file path") + parser.add_argument("--ssl-keyfile", help="SSL private key file path") + args = parser.parse_args() + + ssl_keyfile = args.ssl_keyfile + ssl_certfile = args.ssl_certfile + + if args.ssl and not (ssl_keyfile and ssl_certfile): + try: + from cryptography import x509 + from cryptography.x509.oid import NameOID + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.hazmat.primitives import serialization + import datetime + + # Generate RSA key + key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + # Create self-signed cert info + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, u"localhost"), + ]) + cert = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.datetime.utcnow() + ).not_valid_after( + datetime.datetime.utcnow() + datetime.timedelta(days=365) + ).add_extension( + x509.SubjectAlternativeName([x509.DNSName(u"localhost")]), + critical=False, + ).sign(key, hashes.SHA256()) + + ssl_dir = os.path.join(OUTPUT_DIR, ".ssl") + os.makedirs(ssl_dir, exist_ok=True) + ssl_keyfile = os.path.join(ssl_dir, "key.pem") + ssl_certfile = os.path.join(ssl_dir, "cert.pem") + + with open(ssl_keyfile, "wb") as f: + f.write(key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + )) + with open(ssl_certfile, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + + log.info(f"🔒 Generated temporary self-signed SSL certificate in {ssl_dir}") + except ImportError: + log.warning("⚠️ cryptography package not found. Cannot auto-generate self-signed SSL cert.") + log.warning(" Please install it: pip install cryptography") + log.warning(" Falling back to standard HTTP.") + args.ssl = False + + import uvicorn + uvicorn.run( + "cli.api:app", + host=args.host, + port=args.port, + ssl_keyfile=ssl_keyfile if args.ssl or (args.ssl_keyfile and args.ssl_keyfile) else None, + ssl_certfile=ssl_certfile if args.ssl or (args.ssl_keyfile and args.ssl_keyfile) else None, + ) diff --git a/flow-agent/cli/edit.py b/flow-agent/cli/edit.py new file mode 100644 index 0000000000000000000000000000000000000000..e249b87c5d3c50c4e92c15260527a513161ae118 --- /dev/null +++ b/flow-agent/cli/edit.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""CLI — Full video editor (V2V with segmentation + merge). + +Usage: + python -m cli.edit "Make it anime style" -m MEDIA_ID -v video.mp4 + python -m cli.edit "Cyberpunk neon" -m MEDIA_ID --total-seconds 45 -o output/ +""" + +import argparse +import asyncio +import logging +import os +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from omniflash import ExtensionBridge, poll_status, download_video, ASPECTS, DEFAULT_PROJECT +from omniflash.generators.v2v import edit_video +from omniflash.generators.common import build_client_context, build_generation_context +from omniflash.config import ENDPOINTS, CLIENT_CTX, FPS, SEGMENT_DURATION + +import random + +log = logging.getLogger("cli.edit") + + +def get_video_duration(video_path): + """Get video duration in seconds using ffprobe.""" + try: + r = subprocess.run( + ["ffprobe", "-v", "quiet", "-show_entries", "format=duration", + "-of", "csv=p=0", video_path], + capture_output=True, text=True + ) + return float(r.stdout.strip()) + except Exception: + try: + size = os.path.getsize(video_path) + return max(10, size / (1024 * 1024) * 3) + except Exception: + return None + + +def get_video_fps(video_path): + """Get video FPS using ffprobe.""" + try: + r = subprocess.run( + ["ffprobe", "-v", "quiet", "-select_streams", "v:0", + "-show_entries", "stream=r_frame_rate", + "-of", "csv=p=0", video_path], + capture_output=True, text=True + ) + fps_str = r.stdout.strip() + if "/" in fps_str: + num, den = fps_str.split("/") + return float(num) / float(den) + return float(fps_str) + except Exception: + return FPS + + +async def edit_segment(bridge, prompt, aspect, project_id, media_id, + start_frame, end_frame, segment_num, output_dir): + """Edit a single segment and download.""" + body = { + "mediaGenerationContext": build_generation_context("BLOCK_SILENCED_VIDEOS"), + "clientContext": build_client_context(project_id), + "requests": [{ + "aspectRatio": aspect, + "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}}, + "videoModelKey": "abra_edit", + "seed": random.randint(1, 9999), + "metadata": {}, + "videoInput": { + "mediaId": media_id, + "startFrameIndex": start_frame, + "endFrameIndex": end_frame, + }, + }], + } + + start_sec = start_frame / FPS + end_sec = end_frame / FPS + log.info("✂️ Segment %d: %.0fs-%.0fs (frames %d-%d)", + segment_num, start_sec, end_sec, start_frame, end_frame) + + result = await bridge.api_request(ENDPOINTS["generate_edit"], body) + + status = result.get("status", 0) + if status != 200: + err = result.get("data", {}) + if isinstance(err, dict): + err = err.get("error", {}).get("message", result.get("error", "Unknown")) + log.error("❌ Segment %d failed (%s): %s", segment_num, status, err) + return None + + data = result.get("data", {}) + media_list = data.get("media", []) + if not media_list: + log.error("❌ No media for segment %d", segment_num) + return None + + result_media_id = media_list[0].get("name") + credits = data.get("remainingCredits", "?") + log.info("✅ Segment %d submitted! media_id=%s, credits=%s", + segment_num, result_media_id[:12], credits) + + if not await poll_status(bridge, result_media_id, project_id): + return None + + out_path = os.path.join(output_dir, f"segment_{segment_num:03d}.mp4") + temp_dir = os.path.join(output_dir, ".temp") + os.makedirs(temp_dir, exist_ok=True) + temp_path = os.path.join(temp_dir, f"segment_{segment_num:03d}.mp4") + + if await download_video(bridge, result_media_id, temp_path): + # Auto-remove watermark + try: + from omniflash.watermark import remove_watermark_video + remove_watermark_video(temp_path, out_path, show_progress=False) + os.remove(temp_path) + except Exception as e: + os.replace(temp_path, out_path) + log.warning("⚠️ Watermark removal failed for segment %d: %s", segment_num, e) + # Cleanup empty .temp dir + try: + os.rmdir(temp_dir) + except OSError: + pass + return out_path + return None + + +async def run(args): + aspect = ASPECTS.get(args.aspect, "VIDEO_ASPECT_RATIO_PORTRAIT") + + total_seconds = args.total_seconds + fps = FPS + + if args.video_file and os.path.exists(args.video_file): + if not total_seconds: + total_seconds = get_video_duration(args.video_file) + log.info("📹 Video: %s (%.1fs)", args.video_file, total_seconds or 0) + fps = get_video_fps(args.video_file) + log.info("📹 FPS: %.1f", fps) + + if not total_seconds: + log.error("❌ Can't determine video duration. Use --total-seconds") + return + + os.makedirs(args.output, exist_ok=True) + + # Calculate segments + segments = [] + current = 0 + seg_num = 1 + while current < total_seconds: + start_frame = int(current * fps) + end_frame = int(min(current + SEGMENT_DURATION, total_seconds) * fps) + if end_frame <= start_frame: + break + segments.append((seg_num, start_frame, end_frame)) + current += SEGMENT_DURATION + seg_num += 1 + + log.info("📋 Total: %.1fs → %d segments of %ds each", + total_seconds, len(segments), SEGMENT_DURATION) + log.info("─" * 50) + + bridge = ExtensionBridge() + await bridge.start() + if not await bridge.wait_for_extension(timeout=30): + return + + # Process segments (max 5 concurrent) + semaphore = asyncio.Semaphore(5) + results = [None] * len(segments) + + async def process_segment(idx, seg_num, start_frame, end_frame): + async with semaphore: + out = await edit_segment( + bridge, args.prompt, aspect, args.project_id, + args.media_id, start_frame, end_frame, seg_num, args.output + ) + results[idx] = out + + tasks = [ + asyncio.create_task(process_segment(idx, sn, sf, ef)) + for idx, (sn, sf, ef) in enumerate(segments) + ] + await asyncio.gather(*tasks) + await bridge.close() + + saved = [r for r in results if r] + + log.info("─" * 50) + log.info("🎉 Done! %d/%d segments saved to %s/", len(saved), len(segments), args.output) + for f in saved: + log.info(" ✅ %s", os.path.basename(f)) + + # Merge with ffmpeg + if len(saved) > 1 and args.merge: + merge_path = os.path.join(args.output, "merged_output.mp4") + log.info("🔗 Merging %d segments...", len(saved)) + try: + concat_file = os.path.join(args.output, "concat.txt") + with open(concat_file, "w") as f: + for s in saved: + f.write(f"file '{os.path.abspath(s)}'\n") + subprocess.run([ + "ffmpeg", "-y", "-f", "concat", "-safe", "0", + "-i", concat_file, "-c", "copy", merge_path + ], capture_output=True) + os.remove(concat_file) + # Auto-remove watermark from merged output + try: + from omniflash.watermark import remove_watermark_video + clean_path = remove_watermark_video(merge_path, show_progress=False) + os.replace(clean_path, merge_path) + except Exception: + pass + log.info("✅ Merged: %s", merge_path) + except Exception as e: + log.warning("⚠️ Merge failed (ffmpeg needed): %s", e) + + +def main(): + parser = argparse.ArgumentParser(description="Omni Flash — Full Video Editor") + parser.add_argument("prompt", help="Edit prompt") + parser.add_argument("--media-id", "-m", required=True, help="Flow media ID") + parser.add_argument("--video-file", "-v", help="Local video (for duration/fps)") + parser.add_argument("--total-seconds", "-t", type=float) + parser.add_argument("--output", "-o", default="output", help="Output directory") + parser.add_argument("--aspect", "-a", choices=["portrait", "landscape"], default="portrait") + parser.add_argument("--merge", action="store_true") + parser.add_argument("--project-id", "-p", default=DEFAULT_PROJECT) + args = parser.parse_args() + asyncio.run(run(args)) + + +if __name__ == "__main__": + main() diff --git a/flow-agent/cli/generate.py b/flow-agent/cli/generate.py new file mode 100644 index 0000000000000000000000000000000000000000..7de5c5bfe8f4437bb6eac7c6698ea41db539bd91 --- /dev/null +++ b/flow-agent/cli/generate.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""CLI — Generate video from text prompt (T2V) or edit existing video (V2V). + +Usage: + python -m cli.generate "A dragon breathing fire" + python -m cli.generate "A dragon breathing fire" --aspect landscape -o dragon.mp4 + python -m cli.generate "Make it anime" --edit MEDIA_ID +""" + +import argparse +import asyncio +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from omniflash import ( + ExtensionBridge, generate_video, edit_video, + poll_status, download_video, ASPECTS, DEFAULT_PROJECT, +) +from omniflash.generators.i2v import upload_image, generate_video_i2v, generate_video_fl, generate_video_r2v + + +async def run(args): + aspect = ASPECTS.get(args.aspect, "VIDEO_ASPECT_RATIO_PORTRAIT") + + bridge = ExtensionBridge() + await bridge.start() + + if not await bridge.wait_for_extension(timeout=30): + return + + # Auto-upload local image files + async def resolve_image(path_or_id): + if os.path.exists(path_or_id): + mid = await upload_image(bridge, path_or_id) + if mid: + print(f"📤 Uploaded: {path_or_id} → {mid[:12]}...") + return mid + return path_or_id + + if args.start and args.end: + # First+Last frame mode + start_id = await resolve_image(args.start) + end_id = await resolve_image(args.end) + if not start_id or not end_id: + await bridge.close() + return + media_ids = await generate_video_fl(bridge, args.prompt, aspect, args.project_id, + start_image_id=start_id, end_image_id=end_id, + duration=args.duration) + elif args.start: + # I2V mode (start image only) + start_id = await resolve_image(args.start) + if not start_id: + await bridge.close() + return + media_ids = await generate_video_i2v(bridge, args.prompt, aspect, args.project_id, + image_media_id=start_id, duration=args.duration) + elif args.ref: + # Reference images mode + ref_ids = [] + for r in args.ref: + mid = await resolve_image(r) + if mid: + ref_ids.append(mid) + if not ref_ids: + await bridge.close() + return + media_ids = await generate_video_r2v(bridge, args.prompt, aspect, args.project_id, + ref_media_ids=ref_ids, duration=args.duration) + elif args.edit: + media_ids = await edit_video(bridge, args.prompt, aspect, args.project_id, + video_media_id=args.edit, duration=args.duration) + else: + media_ids = await generate_video(bridge, args.prompt, aspect, args.project_id, + duration=args.duration, count=args.count) + + if not media_ids: + await bridge.close() + return + + for i, media_id in enumerate(media_ids): + label = f"[{i+1}/{len(media_ids)}] " if len(media_ids) > 1 else "" + print(f"{label}Polling {media_id[:12]}...") + if not await poll_status(bridge, media_id, args.project_id): + continue + + if len(media_ids) == 1: + out_path = args.output + else: + base, ext = os.path.splitext(args.output) + out_path = f"{base}_{i+1}{ext}" + + # Setup temp dir for download + out_dir = os.path.dirname(out_path) or "." + temp_dir = os.path.join(out_dir, ".temp") + os.makedirs(temp_dir, exist_ok=True) + temp_path = os.path.join(temp_dir, os.path.basename(out_path)) + + if await download_video(bridge, media_id, temp_path): + # Auto-remove watermark unless --no-clean + if not args.no_clean: + try: + from omniflash.watermark import remove_watermark_video + remove_watermark_video(temp_path, out_path) + os.remove(temp_path) + print(f"🧹 Watermark removed!") + except Exception as e: + # Fallback: move temp to output as-is + os.replace(temp_path, out_path) + print(f"⚠️ Watermark removal failed: {e}") + else: + os.replace(temp_path, out_path) + + # Cleanup empty .temp dir + try: + os.rmdir(temp_dir) + except OSError: + pass + + print(f"🎉 Done! {out_path}") + + await bridge.close() + + +def main(): + parser = argparse.ArgumentParser(description="Omni Flash — Video Generator") + parser.add_argument("prompt", help="Text prompt for video") + parser.add_argument("--output", "-o", default="omni_output.mp4", help="Output file") + parser.add_argument("--aspect", "-a", choices=["portrait", "landscape"], default="portrait") + parser.add_argument("--duration", "-d", type=int, choices=[4, 6, 8, 10], default=10) + parser.add_argument("--count", "-c", type=int, choices=[1, 2, 3, 4], default=1) + parser.add_argument("--edit", "-e", metavar="MEDIA_ID", + help="Edit existing video (V2V mode)") + parser.add_argument("--start", "-s", metavar="IMAGE", + help="Start frame image (file path or media_id)") + parser.add_argument("--end", metavar="IMAGE", + help="End frame image (use with --start for FL mode)") + parser.add_argument("--ref", "-r", nargs="+", metavar="IMAGE", + help="Reference images for R2V mode") + parser.add_argument("--project-id", "-p", default=DEFAULT_PROJECT) + parser.add_argument("--no-clean", action="store_true", + help="Skip automatic watermark removal") + args = parser.parse_args() + asyncio.run(run(args)) + + +if __name__ == "__main__": + main() diff --git a/flow-agent/cli/image.py b/flow-agent/cli/image.py new file mode 100644 index 0000000000000000000000000000000000000000..e1386f200167767266f0a992e0b56bc319bd98b7 --- /dev/null +++ b/flow-agent/cli/image.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""CLI — Generate image from text prompt (T2I). + +Usage: + python -m cli.image "A cat wearing sunglasses on a beach" + python -m cli.image "Dragon in cyberpunk city" --aspect landscape --count 4 + python -m cli.image "Logo design" --aspect square -o logo.png +""" + +import argparse +import asyncio +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from omniflash import ExtensionBridge, DEFAULT_PROJECT +from omniflash.generators.t2i import generate_image, download_image, IMAGE_ASPECTS + + +async def run(args): + aspect = args.aspect + + bridge = ExtensionBridge() + await bridge.start() + + if not await bridge.wait_for_extension(timeout=30): + return + + # Handle ref images: auto-upload local files + ref_ids = [] + if args.ref: + from omniflash.generators.i2v import upload_image + for ref in args.ref: + if os.path.exists(ref): + print(f"📤 Uploading reference: {ref}") + mid = await upload_image(bridge, ref) + if mid: + ref_ids.append(mid) + print(f" media_id={mid[:12]}...") + else: + # Assume it's already a media_id + ref_ids.append(ref) + + results = await generate_image( + bridge, args.prompt, aspect, args.project_id, + count=args.count, ref_media_ids=ref_ids or None + ) + + if not results: + await bridge.close() + return + + os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) + + for i, r in enumerate(results): + if not r.get("image_url"): + print(f"⚠️ Image {i+1}: no URL") + continue + + if len(results) == 1: + out_path = args.output + else: + base, ext = os.path.splitext(args.output) + out_path = f"{base}_{i+1}{ext}" + + if await download_image(bridge, r["image_url"], out_path): + print(f"🎉 Done! {out_path}") + + await bridge.close() + + +def main(): + parser = argparse.ArgumentParser(description="Flow Agent — Image Generator") + parser.add_argument("prompt", help="Text prompt for image") + parser.add_argument("--output", "-o", default="output/image.png", help="Output file") + parser.add_argument("--aspect", "-a", + choices=list(IMAGE_ASPECTS.keys()), + default="portrait", + help="Aspect ratio") + parser.add_argument("--count", "-c", type=int, choices=[1, 2, 3, 4], default=1, + help="Generate 1-4 variations") + parser.add_argument("--ref", "-r", nargs="+", metavar="IMAGE", + help="Reference image(s): file path or media_id") + parser.add_argument("--project-id", "-p", default=DEFAULT_PROJECT) + args = parser.parse_args() + asyncio.run(run(args)) + + +if __name__ == "__main__": + main() diff --git a/flow-agent/cli/sniff.py b/flow-agent/cli/sniff.py new file mode 100644 index 0000000000000000000000000000000000000000..aead6ffb35c18432c968de749c300208502b2108 --- /dev/null +++ b/flow-agent/cli/sniff.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""CLI — API request sniffer. + +Captures all Flow UI API requests for endpoint discovery. + +Usage: + python -m cli.sniff + python -m cli.sniff --save sniffed.json +""" + +import asyncio +import argparse +import json +import logging +import os +import sys +from http.server import HTTPServer, BaseHTTPRequestHandler +import threading + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +for _pkg in ["websockets"]: + try: + __import__(_pkg) + except ImportError: + os.system(f"{sys.executable} -m pip install {_pkg} -q") + +import websockets + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S") +log = logging.getLogger("cli.sniff") + +all_requests = [] + +IGNORE = {"batchLog", "frontendEvents", "fetchUserRecommendations", "flowAgent/applets", + "savedSharedApplets", "models/statuses"} + + +def make_handler(save_file): + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) if length else {} + + if body.get("type") == "sniffed_video_request": + url = body.get("url", "") + if not any(n in url for n in IGNORE): + method = body.get("method", "?") + payload = body.get("payload", "") + + log.info("─" * 60) + log.info("🔍 %s %s", method, url.split("?")[0]) + if payload and payload != "(empty)": + try: + parsed = json.loads(payload) + log.info(" %s", json.dumps(parsed, indent=2)[:2000]) + except (json.JSONDecodeError, TypeError): + log.info(" %s", str(payload)[:1000]) + + entry = { + "url": url, + "method": method, + "payload": payload, + "timestamp": body.get("timestamp"), + } + all_requests.append(entry) + + if save_file: + with open(save_file, "w") as f: + json.dump(all_requests, f, indent=2) + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(b'{"ok":true}') + + def do_OPTIONS(self): + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "POST") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def log_message(self, *a): + pass + + return Handler + + +async def on_connect(ws): + log.info("✅ Extension connected!") + async for raw in ws: + data = json.loads(raw) + if data.get("type") == "token_captured": + log.info("🔑 Token captured") + + +async def run(args): + Handler = make_handler(args.save) + srv = HTTPServer(("127.0.0.1", args.port), Handler) + threading.Thread(target=srv.serve_forever, daemon=True).start() + + log.info("⚡ WS server on ws://127.0.0.1:%d", args.ws_port) + log.info("⚡ HTTP callback on http://127.0.0.1:%d", args.port) + if args.save: + log.info("💾 Saving to: %s", args.save) + log.info("👉 Open Flow UI and perform any action...") + log.info("─" * 60) + + async with websockets.serve(on_connect, "127.0.0.1", args.ws_port): + await asyncio.Future() + + +def main(): + parser = argparse.ArgumentParser(description="Flow API Sniffer") + parser.add_argument("--save", "-s", help="Save to JSON file") + parser.add_argument("--port", type=int, default=8100) + parser.add_argument("--ws-port", type=int, default=9222) + args = parser.parse_args() + asyncio.run(run(args)) + + +if __name__ == "__main__": + main() diff --git a/flow-agent/cli/upload.py b/flow-agent/cli/upload.py new file mode 100644 index 0000000000000000000000000000000000000000..3e6f54e37d6b6ae2bf668c42e9d4ee0bf539f50f --- /dev/null +++ b/flow-agent/cli/upload.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""CLI — Upload video or batch upload directory. + +Usage: + python -m cli.upload video.mp4 + python -m cli.upload chunks/ --batch +""" + +import argparse +import asyncio +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from omniflash.upload import upload_video + + +async def run(args): + if args.batch or os.path.isdir(args.path): + # Batch upload all mp4 in directory + directory = args.path + chunks = sorted([f for f in os.listdir(directory) if f.endswith(".mp4")]) + if not chunks: + print(f"❌ No .mp4 files found in {directory}") + return + print(f"📁 Found {len(chunks)} videos in {directory}") + for i, chunk in enumerate(chunks, 1): + path = os.path.join(directory, chunk) + print(f"\n{'─' * 50}") + print(f"[{i}/{len(chunks)}] {chunk}") + try: + await upload_video(path, args.project_id) + except Exception as e: + print(f"❌ {chunk} failed: {e}") + else: + # Single file upload + result = await upload_video(args.path, args.project_id) + print(json.dumps(result, indent=2)) + + +def main(): + parser = argparse.ArgumentParser(description="Omni Flash — Upload Video") + parser.add_argument("path", help="Video file or directory of videos") + parser.add_argument("--batch", "-b", action="store_true", + help="Batch upload all .mp4 in directory") + parser.add_argument("--project-id", "-p", + default="ff92d5cc-8a03-41d2-b59e-e0774d17bcf6") + args = parser.parse_args() + asyncio.run(run(args)) + + +if __name__ == "__main__": + main() diff --git a/flow-agent/error.md b/flow-agent/error.md new file mode 100644 index 0000000000000000000000000000000000000000..557e9f36829bd7ef80caee589ae56cc694660543 --- /dev/null +++ b/flow-agent/error.md @@ -0,0 +1,74 @@ +# 🛠️ Flow Agent API Troubleshooting Guide (error.md) + +This document contains standard error scenarios and how to resolve them quickly. + +--- + +## 1. Error: `OSError: [Errno 48] Address already in use` +* **Symptom**: The server fails to start and exits with: `OSError: [Errno 48] Address already in use` (typically for port `8000`, `8100`, or `9222`). +* **Cause**: Another instance of the API server or a background flow-agent process is already running and occupying the port. +* **Resolution**: + Run the following commands in your terminal to find and kill the process: + ```bash + # Clear API server port (8000) + kill -9 $(lsof -t -i:8000) 2>/dev/null || true + + # Clear Extension bridge HTTP callback port (8100) + kill -9 $(lsof -t -i:8100) 2>/dev/null || true + + # Clear Extension bridge WebSocket port (9222) + kill -9 $(lsof -t -i:9222) 2>/dev/null || true + ``` + +--- + +## 2. Error: `Internal Server Error (500) - RuntimeError: curl failed` +* **Symptom**: Step `Uploading Video to Flow` fails with Status Code `500` and the server console prints: `RuntimeError: curl failed: ...` +* **Cause**: Your terminal session has sandbox/proxy environment variables active (`http_proxy`, `https_proxy`, `HTTP_PROXY`, or `HTTPS_PROXY` might be set by the AI agent sandbox). This causes `curl` to route all Google Cloud Storage uploads through a proxy that blocks the connection. +* **Resolution**: + Clear the proxy variables in your terminal window before starting the server and running the test script: + ```bash + # 1. Unset the proxy variables + unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY + + # 2. Restart the API server in this terminal + venv/bin/python -m cli.api --port 8000 + ``` + *(Make sure to also run `unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY` in your testing/client terminal window as well).* + +--- + +## 3. Error: `Google Flow extension is not connected or unauthorized` +* **Symptom**: `/health` returns `has_flow_key: false` and generation calls return: `"Google Flow extension is not connected or unauthorized. Make sure Google Flow tab is open in Chrome."` +* **Cause**: The Extension Bridge WS is connected, but the Chrome extension is unable to capture the auth token (`flowKey`) because Google Flow is either not open, has gone idle, or your Google account has logged out. +* **Resolution**: + 1. Open Chrome. + 2. Make sure you are logged into your Google account at **[labs.google/fx/tools/flow](https://labs.google/fx/tools/flow)**. + 3. Reload the page. The extension icon in your extension bar should show a green indicator. + 4. Once logged in, the extension will automatically push the token to the server and heal the state. + +--- + +## 4. Error: `Failed (0): TIMEOUT` +* **Symptom**: Request fails after 90 seconds with `TIMEOUT`. +* **Cause**: Google Flow took too long to respond, or there is an active **reCAPTCHA challenge** popped up on your Chrome browser that requires manual verification. +* **Resolution**: + 1. Open Chrome and inspect the Google Flow tab. + 2. If a reCAPTCHA prompt is present, solve it. + 3. Reload the tab to refresh the connection, wait 5 seconds, and try your request again. + +--- + +## 5. Error: `zsh: no such file or directory: venv/bin/python` +* **Symptom**: Running server or test script returns: `zsh: no such file or directory: venv/bin/python` or similar file errors. +* **Cause**: The command is run from the parent workspace folder (`N8N-Agent`) instead of the cloned `flow-agent` project folder where the virtual environment (`venv`) resides. +* **Resolution**: + Always change your directory to the `flow-agent` folder before running commands, or use the absolute paths: + ```bash + # Go to the correct directory first + cd /path/to/flow-agent + + # Then run the command + venv/bin/python -m cli.api --port 8000 + ``` + diff --git a/flow-agent/extension/_metadata/generated_indexed_rulesets/_ruleset1 b/flow-agent/extension/_metadata/generated_indexed_rulesets/_ruleset1 new file mode 100644 index 0000000000000000000000000000000000000000..47c6d85305240268eedbd1dad1dc9427bfd7d3a1 Binary files /dev/null and b/flow-agent/extension/_metadata/generated_indexed_rulesets/_ruleset1 differ diff --git a/flow-agent/extension/background.js b/flow-agent/extension/background.js new file mode 100644 index 0000000000000000000000000000000000000000..e6ac8238bbcd97168b22c1e85285b4c9dc2915be --- /dev/null +++ b/flow-agent/extension/background.js @@ -0,0 +1,879 @@ +/** + * Flow Agent — Chrome Extension Background Service Worker + * + * Connects to local Python agent via WebSocket (agent runs WS server). + * Captures bearer token, solves reCAPTCHA, proxies API calls through browser. + */ + +const AGENT_WS_URL = 'ws://127.0.0.1:9222'; +// NOTE: This is a browser-restricted public API key — safe to ship in extension bundles. +const API_KEY = 'AIzaSyBtrm0o5ab1c-Ec8ZuLcGt3oJAA5VWt3pY'; + +let ws = null; +let flowKey = null; +let callbackSecret = null; // Auth secret for HTTP callback, received from server on WS connect +let state = 'off'; // off | idle | running +let manualDisconnect = false; +let metrics = { + tokenCapturedAt: null, + requestCount: 0, // captcha-consuming requests only (gen image/video/upscale) + successCount: 0, + failedCount: 0, + lastError: null, +}; + +// ─── URL → Log Type Classifier ───────────────────────────── + +// Visible log types — only these appear in the request log +const _VISIBLE_TYPES = new Set(['GEN_IMG', 'GEN_VID', 'GEN_VID_REF', 'UPSCALE', 'TRACKING', 'URL_REFRESH']); + +function _classifyApiUrl(url) { + if (url.includes('uploadImage')) return 'UPLOAD'; + if (url.includes('batchGenerateImages')) return 'GEN_IMG'; + if (url.includes('UpsampleVideo')) return 'UPSCALE'; + if (url.includes('ReferenceImages')) return 'GEN_VID_REF'; + if (url.includes('batchAsyncGenerateVideo')) return 'GEN_VID'; + if (url.includes('batchCheckAsync')) return 'POLL'; + if (url.includes('upsampleImage')) return 'UPS_IMG'; + if (url.includes('/media/')) return 'MEDIA'; + if (url.includes('/credits')) return 'CREDITS'; + return 'API'; +} + +// ─── Request Log ──────────────────────────────────────────── + +let requestLog = []; + +function addRequestLog(entry) { + requestLog.unshift(entry); + if (requestLog.length > 100) requestLog.pop(); + broadcastRequestLog(); +} + +function updateRequestLog(id, updates) { + const entry = requestLog.find((e) => e.id === id); + if (entry) Object.assign(entry, updates); + broadcastRequestLog(); +} + +function broadcastRequestLog() { + chrome.runtime.sendMessage({ type: 'REQUEST_LOG_UPDATE', log: requestLog }).catch(() => {}); +} + +// ─── Startup ──────────────────────────────────────────────── + +chrome.runtime.onInstalled.addListener(init); +chrome.runtime.onStartup.addListener(init); +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name === 'reconnect') connectToAgent(); + if (alarm.name === 'keepAlive') keepAlive(); + if (alarm.name === 'token-refresh') { + await captureTokenFromFlowTab(); + } +}); + +async function init() { + const data = await chrome.storage.local.get(['flowKey', 'metrics', 'callbackSecret']); + if (data.flowKey) flowKey = data.flowKey; + if (data.metrics) Object.assign(metrics, data.metrics); + if (data.callbackSecret) callbackSecret = data.callbackSecret; + connectToAgent(); + chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 }); +} + +// ─── Token Capture ────────────────────────────────────────── + +chrome.webRequest.onBeforeSendHeaders.addListener( + (details) => { + if (!details?.requestHeaders?.length) return; + const authHeader = details.requestHeaders.find( + (h) => h.name?.toLowerCase() === 'authorization', + ); + const value = authHeader?.value || ''; + if (!value.startsWith('Bearer ya29.')) return; + + const token = value.replace(/^Bearer\s+/i, '').trim(); + if (!token) return; + + // Always update — even if same token string, refresh the timestamp + flowKey = token; + metrics.tokenCapturedAt = Date.now(); + chrome.storage.local.set({ flowKey, metrics }); + console.log('[Flow Agent] Bearer token captured'); + + // Notify agent + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'token_captured', flowKey })); + } + }, + { urls: ['https://aisandbox-pa.googleapis.com/*', 'https://labs.google/*'] }, + ['requestHeaders', 'extraHeaders'], +); + +let _openingFlowTab = false; + +async function captureTokenFromFlowTab() { + const tabs = await chrome.tabs.query({ + url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'], + }); + if (!tabs.length) { + if (_openingFlowTab) { + console.log('[Flow Agent] Flow tab already opening, skipping'); + return; + } + _openingFlowTab = true; + try { + console.log('[Flow Agent] No Flow tab found — opening one in background'); + await chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow', active: false }); + await sleep(3000); + const retryTabs = await chrome.tabs.query({ + url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'], + }); + if (!retryTabs.length) { + console.log('[Flow Agent] Flow tab not ready yet after open'); + return; + } + await chrome.scripting.executeScript({ + target: { tabId: retryTabs[0].id }, + files: ['content.js'], + }); + console.log('[Flow Agent] Token refresh triggered on newly opened Flow tab'); + } catch (e) { + console.error('[Flow Agent] Token refresh failed after opening tab:', e); + } finally { + _openingFlowTab = false; + } + return; + } + try { + await chrome.scripting.executeScript({ + target: { tabId: tabs[0].id }, + files: ['content.js'], + }); + console.log('[Flow Agent] Token refresh triggered on Flow tab'); + } catch (e) { + console.error('[Flow Agent] Token refresh failed:', e); + } +} + +// ─── WebSocket to Agent ───────────────────────────────────── + +function connectToAgent() { + if (manualDisconnect) return; + if (ws?.readyState === WebSocket.CONNECTING) return; + if (ws?.readyState === WebSocket.OPEN) return; + + try { + ws = new WebSocket(AGENT_WS_URL); + } catch (e) { + console.error('[Flow Agent] WS connect error:', e); + scheduleReconnect(); + return; + } + + ws.onopen = () => { + console.log('[Flow Agent] Connected to agent'); + chrome.alarms.clear('reconnect'); + setState('idle'); + + // Token refresh alarm — 45 min gives buffer before ~60 min expiry + chrome.alarms.create('token-refresh', { periodInMinutes: 45 }); + + // Send current state + resend token if we have one + ws.send(JSON.stringify({ + type: 'extension_ready', + flowKeyPresent: !!flowKey, + tokenAge: flowKey && metrics.tokenCapturedAt ? Date.now() - metrics.tokenCapturedAt : null, + })); + if (flowKey) { + ws.send(JSON.stringify({ type: 'token_captured', flowKey })); + } + }; + + ws.onmessage = async ({ data }) => { + try { + const msg = JSON.parse(data); + + if (msg.method === 'api_request') { + await handleApiRequest(msg); + } else if (msg.method === 'trpc_request') { + await handleTrpcRequest(msg); + } else if (msg.method === 'upload_video') { + await handleUploadVideo(msg); + } else if (msg.method === 'solve_captcha') { + await handleSolveCaptcha(msg); + } else if (msg.method === 'get_status') { + sendToAgent({ + id: msg.id, + result: { + state, + flowKeyPresent: !!flowKey, + manualDisconnect, + tokenAge: metrics.tokenCapturedAt ? Date.now() - metrics.tokenCapturedAt : null, + metrics, + }, + }); + } else if (msg.method === 'open_flow_tab') { + // Python bridge asks us to open/focus a Flow tab + console.log('[Flow Agent] Agent requested: open Flow tab'); + const tabs = await chrome.tabs.query({ + url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'], + }); + if (tabs.length) { + // Tab exists — refresh it to trigger fresh API calls → token capture + await chrome.tabs.reload(tabs[0].id); + console.log('[Flow Agent] Refreshed existing Flow tab'); + } else { + // No tab — open one (active so it loads properly) + await chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow', active: true }); + console.log('[Flow Agent] Opened new Flow tab'); + } + // Wait for page to load and make API calls that trigger token capture + await sleep(5000); + // If token was captured by webRequest during page load, send it + if (flowKey && ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'token_captured', flowKey })); + console.log('[Flow Agent] Sent stored token after tab open'); + } else { + // Try reading from storage as fallback + const data = await chrome.storage.local.get(['flowKey']); + if (data.flowKey) { + flowKey = data.flowKey; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'token_captured', flowKey })); + console.log('[Flow Agent] Sent token from storage after tab open'); + } + } + } + } else if (msg.method === 'refresh_flow_tab') { + // Python bridge asks us to refresh token + console.log('[Flow Agent] Agent requested: refresh token'); + await captureTokenFromFlowTab(); + await sleep(3000); + // Actively send token if we have one + if (flowKey && ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'token_captured', flowKey })); + console.log('[Flow Agent] Sent token after refresh'); + } else { + const data = await chrome.storage.local.get(['flowKey']); + if (data.flowKey) { + flowKey = data.flowKey; + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'token_captured', flowKey })); + console.log('[Flow Agent] Sent token from storage after refresh'); + } + } + } + } else if (msg.type === 'callback_secret') { + callbackSecret = msg.secret; + chrome.storage.local.set({ callbackSecret: msg.secret }); + console.log('[Flow Agent] Received callback secret'); + } else if (msg.type === 'pong') { + // keepalive response + } + } catch (e) { + console.error('[Flow Agent] Message error:', e); + } + }; + + ws.onclose = () => { + setState('off'); + chrome.alarms.clear('token-refresh'); + if (!manualDisconnect) scheduleReconnect(); + }; + + ws.onerror = (e) => { + console.error('[Flow Agent] WS error:', e); + metrics.lastError = 'WS_ERROR'; + chrome.storage.local.set({ metrics }); + }; +} + +function scheduleReconnect() { + chrome.alarms.create('reconnect', { delayInMinutes: 0.083 }); // ~5s +} + +function keepAlive() { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } else { + connectToAgent(); + } +} + +function sendToAgent(msg) { + // API responses (with msg.id) go via HTTP — immune to WS disconnect + if (msg.id) { + fetch('http://127.0.0.1:8100/api/ext/callback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(msg), + }).catch(() => { + // HTTP failed — fallback to WS + if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); + }); + return; + } + // Non-response messages (ping, status) or no secret yet — use WS + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)); + } +} + +// ─── reCAPTCHA Solving ────────────────────────────────────── + +async function requestCaptchaFromTab(tabId, requestId, pageAction) { + try { + return await chrome.tabs.sendMessage(tabId, { + type: 'GET_CAPTCHA', + requestId, + pageAction, + }); + } catch (error) { + const msg = error?.message || ''; + const shouldInject = + msg.includes('Receiving end does not exist') || + msg.includes('Could not establish connection'); + if (!shouldInject) throw error; + + // Inject content script and retry + await chrome.scripting.executeScript({ + target: { tabId }, + files: ['content.js'], + }); + await sleep(200); + return await chrome.tabs.sendMessage(tabId, { + type: 'GET_CAPTCHA', + requestId, + pageAction, + }); + } +} + +async function solveCaptcha(requestId, captchaAction) { + const tabs = await chrome.tabs.query({ + url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'], + }); + + if (!tabs.length) { + // Auto-open Flow tab and wait briefly before returning error + try { + await chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow', active: false }); + await sleep(3000); + // Retry tab query after opening + const retryTabs = await chrome.tabs.query({ + url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'], + }); + if (!retryTabs.length) return { error: 'NO_FLOW_TAB' }; + const resp = await Promise.race([ + requestCaptchaFromTab(retryTabs[0].id, requestId, captchaAction), + new Promise((_, rej) => setTimeout(() => rej(new Error('CAPTCHA_TIMEOUT')), 30000)), + ]); + return resp; + } catch (e) { + return { error: e.message || 'NO_FLOW_TAB' }; + } + } + + try { + const resp = await Promise.race([ + requestCaptchaFromTab(tabs[0].id, requestId, captchaAction), + new Promise((_, rej) => setTimeout(() => rej(new Error('CAPTCHA_TIMEOUT')), 30000)), + ]); + return resp; + } catch (e) { + return { error: e.message }; + } +} + +async function handleSolveCaptcha(msg) { + const { id, params } = msg; + const result = await solveCaptcha(id, params?.captchaAction || 'VIDEO_GENERATION'); + + // Standalone captcha solve counts as captcha-consuming + metrics.requestCount++; + if (result?.token) { + metrics.successCount++; + } else { + metrics.failedCount++; + metrics.lastError = result?.error || 'NO_TOKEN'; + } + chrome.storage.local.set({ metrics }); + + sendToAgent({ id, result }); +} + +// ─── API Request Proxy ────────────────────────────────────── + +async function handleTrpcRequest(msg) { + const { id, params } = msg; + const { url, method = 'POST', headers = {}, body } = params; + + if (!url || !url.startsWith('https://labs.google/')) { + sendToAgent({ id, error: 'INVALID_TRPC_URL' }); + return; + } + + setState('running'); + // TRPC calls don't consume captcha — don't count in metrics + + const logId = id; + const logType = url.includes('createProject') ? 'CREATE_PROJECT' : 'TRPC'; + // TRPC calls are silent — don't show in request log + + const fetchHeaders = { 'Content-Type': 'application/json', ...headers }; + if (flowKey) { + fetchHeaders['authorization'] = `Bearer ${flowKey}`; + } + + try { + const resp = await fetch(url, { + method, + headers: fetchHeaders, + body: body ? JSON.stringify(body) : undefined, + credentials: 'include', + }); + const data = await resp.json(); + chrome.storage.local.set({ metrics }); + updateRequestLog(logId, { status: 'success' }); + sendToAgent({ id, status: resp.status, data }); + } catch (e) { + console.error('[Flow Agent] tRPC request failed:', e); + chrome.storage.local.set({ metrics }); + updateRequestLog(logId, { status: 'failed', error: e.message || 'TRPC_FETCH_FAILED' }); + sendToAgent({ id, error: e.message || 'TRPC_FETCH_FAILED' }); + } finally { + setState('idle'); + } +} + + +async function handleUploadVideo(msg) { + const { id, params } = msg; + const { videoBase64, projectId, videoSize } = params; + + try { + const tabs = await chrome.tabs.query({ url: '*://labs.google/*' }); + if (!tabs.length) { + sendToAgent({ id, error: 'NO_FLOW_TAB' }); + return; + } + + const size = videoSize || (videoBase64 ? Math.floor(videoBase64.length * 3 / 4) : 0); + + // Get session URL via page context XHR (needs session cookies) + const startResults = await chrome.scripting.executeScript({ + target: { tabId: tabs[0].id }, + world: 'MAIN', + func: (projId, sz) => { + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/fx/api/upload-video?action=start'); + xhr.setRequestHeader('X-Upload-Project-Id', projId); + xhr.setRequestHeader('X-Upload-Content-Type', 'video/mp4'); + xhr.setRequestHeader('X-Upload-Content-Length', sz.toString()); + xhr.withCredentials = true; + xhr.onload = () => { + let data; + try { data = JSON.parse(xhr.responseText); } catch { data = {}; } + resolve({ + sessionUrl: data.sessionUrl || xhr.getResponseHeader('X-Upload-Session-Url') || '', + status: xhr.status, + }); + }; + xhr.onerror = () => resolve({ error: 'POST_FAILED' }); + xhr.send(); + }); + }, + args: [projectId, size], + }); + + const step1 = startResults?.[0]?.result; + if (!step1 || step1.error || !step1.sessionUrl) { + sendToAgent({ id, error: step1?.error || 'NO_SESSION_URL' }); + return; + } + + // Return sessionUrl + token — caller handles PUT + sendToAgent({ + id, + result: { + sessionUrl: step1.sessionUrl, + token: flowKey || '', + }, + }); + } catch (e) { + sendToAgent({ id, error: `UPLOAD_ERROR: ${e.message}` }); + } +} + +async function handleApiRequest(msg) { + const { id, params } = msg; + const { url, method, headers, body, captchaAction } = params; + + if (!url) { + sendToAgent({ id, error: 'MISSING_URL' }); + return; + } + + if (!url.startsWith('https://aisandbox-pa.googleapis.com/')) { + sendToAgent({ id, error: 'INVALID_URL' }); + return; + } + + setState('running'); + const hasCaptcha = !!captchaAction; + if (hasCaptcha) metrics.requestCount++; + + const logId = id; + const logType = _classifyApiUrl(url); + if (_VISIBLE_TYPES.has(logType)) { + const payloadSummary = body ? JSON.stringify(body).slice(0, 200) : null; + addRequestLog({ id: logId, type: logType, time: new Date().toISOString(), status: 'processing', error: null, outputUrl: null, url, payloadSummary }); + } + + try { + // Step 1: Solve captcha if needed + let captchaToken = null; + if (captchaAction) { + const captchaResult = await solveCaptcha(id, captchaAction); + captchaToken = captchaResult?.token || null; + if (!captchaToken) { + // Cannot proceed without captcha — API will 403 + const err = captchaResult?.error || 'CAPTCHA_FAILED'; + console.error(`[Flow Agent] Captcha failed for ${captchaAction}: ${err}`); + sendToAgent({ id, status: 403, error: `CAPTCHA_FAILED: ${err}` }); + if (hasCaptcha) { metrics.failedCount++; metrics.lastError = `CAPTCHA_FAILED: ${err}`; } + chrome.storage.local.set({ metrics }); + updateRequestLog(logId, { status: 'failed', error: `CAPTCHA_FAILED: ${err}` }); + setState('idle'); + return; + } + } + + // Step 2: Inject captcha token into body + let finalBody = body; + if (captchaToken && finalBody) { + finalBody = JSON.parse(JSON.stringify(finalBody)); // deep clone + if (finalBody.clientContext?.recaptchaContext) { + finalBody.clientContext.recaptchaContext.token = captchaToken; + } + if (finalBody.requests && Array.isArray(finalBody.requests)) { + for (const req of finalBody.requests) { + if (req.clientContext?.recaptchaContext) { + req.clientContext.recaptchaContext.token = captchaToken; + } + } + } + } + + // Step 3: Use flowKey for auth + const activeFlowKey = flowKey; + if (!activeFlowKey) { + sendToAgent({ id, status: 503, error: 'NO_FLOW_KEY' }); + if (hasCaptcha) { metrics.failedCount++; metrics.lastError = 'NO_FLOW_KEY'; } + chrome.storage.local.set({ metrics }); + updateRequestLog(logId, { status: 'failed', error: 'NO_FLOW_KEY' }); + setState('idle'); + return; + } + + const fetchHeaders = { ...(headers || {}) }; + fetchHeaders['authorization'] = `Bearer ${activeFlowKey}`; + + // Step 4: Make the API call from browser context + const response = await fetch(url, { + method: method || 'POST', + headers: fetchHeaders, + credentials: 'include', + body: method === 'GET' ? undefined : JSON.stringify(finalBody), + }); + + let responseData; + const responseText = await response.text(); + try { + responseData = JSON.parse(responseText); + } catch { + responseData = responseText; + } + + sendToAgent({ + id, + status: response.status, + data: responseData, + }); + + const responseSummary = responseText ? responseText.slice(0, 300) : null; + if (response.ok) { + if (hasCaptcha) { metrics.successCount++; metrics.lastError = null; } + updateRequestLog(logId, { status: 'success', httpStatus: response.status, responseSummary }); + } else { + if (hasCaptcha) { metrics.failedCount++; metrics.lastError = `API_${response.status}`; } + updateRequestLog(logId, { status: 'failed', error: `API_${response.status}`, httpStatus: response.status, responseSummary }); + } + } catch (e) { + sendToAgent({ + id, + status: 500, + error: e.message || 'API_REQUEST_FAILED', + }); + if (hasCaptcha) { metrics.failedCount++; metrics.lastError = e.message; } + updateRequestLog(logId, { status: 'failed', error: e.message || 'API_REQUEST_FAILED' }); + } + + chrome.storage.local.set({ metrics }); + setState('idle'); +} + +// ─── State & Popup ────────────────────────────────────────── + +function setState(newState) { + state = newState; + const badges = { idle: '●', running: '▶', off: '○' }; + const colors = { idle: '#22c55e', running: '#f59e0b', off: '#6b7280' }; + chrome.action.setBadgeText({ text: badges[state] || '' }); + chrome.action.setBadgeBackgroundColor({ color: colors[state] || '#000' }); + broadcastStatus(); +} + +function broadcastStatus() { + chrome.runtime.sendMessage({ type: 'STATUS_PUSH' }).catch(() => {}); +} + +chrome.runtime.onMessage.addListener((msg, _, reply) => { + if (msg.type === 'STATUS') { + reply({ + connected: ws?.readyState === WebSocket.OPEN, + agentConnected: ws?.readyState === WebSocket.OPEN, + flowKeyPresent: !!flowKey, + manualDisconnect, + tokenAge: metrics.tokenCapturedAt ? Date.now() - metrics.tokenCapturedAt : null, + metrics: { + requestCount: metrics.requestCount, + successCount: metrics.successCount, + failedCount: metrics.failedCount, + lastError: metrics.lastError, + }, + state, + }); + } + + if (msg.type === 'DISCONNECT') { + manualDisconnect = true; + if (ws) ws.close(); + reply({ ok: true }); + return true; + } + + if (msg.type === 'RECONNECT') { + manualDisconnect = false; + connectToAgent(); + reply({ ok: true }); + return true; + } + + if (msg.type === 'REQUEST_LOG') { + reply({ log: requestLog }); + return true; + } + + if (msg.type === 'OPEN_FLOW_TAB') { + chrome.tabs.query({ + url: ['https://labs.google/fx/tools/flow*', 'https://labs.google/fx/*/tools/flow*'], + }).then((tabs) => { + if (tabs.length) { + chrome.tabs.update(tabs[0].id, { active: true }); + reply({ ok: true, tabId: tabs[0].id }); + } else { + chrome.tabs.create({ url: 'https://labs.google/fx/tools/flow' }) + .then((tab) => reply({ ok: true, tabId: tab.id })) + .catch((e) => reply({ error: e.message })); + } + }).catch((e) => reply({ error: e.message })); + return true; + } + + if (msg.type === 'REFRESH_TOKEN') { + captureTokenFromFlowTab() + .then(() => reply({ ok: true })) + .catch((e) => reply({ error: e.message })); + return true; + } + + if (msg.type === 'TEST_CAPTCHA') { + solveCaptcha(`test-${Date.now()}`, msg.pageAction || 'IMAGE_GENERATION') + .then((r) => reply(r)) + .catch((e) => reply({ error: e.message })); + return true; + } + + if (msg.type === 'TRPC_MEDIA_URLS') { + handleTrpcMediaUrls(msg.trpcUrl, msg.body); + reply({ ok: true }); + return true; + } + + if (msg.type === 'SNIFFED_AISANDBOX_REQUEST') { + console.log('[Flow Agent] SNIFFED aisandbox request:', msg.url); + fetch('http://127.0.0.1:8100/api/ext/callback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'sniffed_video_request', + url: msg.url, + method: msg.method, + payload: msg.payload, + timestamp: msg.timestamp, + }), + }).catch((e) => console.error('[Flow Agent] Failed to forward sniffed request:', e)); + reply({ ok: true }); + return true; + } + + return true; +}); + +// ─── TRPC Media URL Extractor ────────────────────────────── + +function handleTrpcMediaUrls(trpcUrl, bodyText) { + try { + // Extract all fresh GCS signed URLs + const urlRegex = /https:\/\/storage\.googleapis\.com\/ai-sandbox-videofx\/(?:image|video)\/[0-9a-f-]{36}\?[^"'\s]+/g; + const matches = bodyText.match(urlRegex) || []; + if (!matches.length) return; + + // Deduplicate and parse + const urlMap = {}; + for (const rawUrl of matches) { + // Unescape JSON-escaped URLs + const url = rawUrl.replace(/\\u0026/g, '&').replace(/\\/g, ''); + const mediaMatch = url.match(/\/(image|video)\/([0-9a-f-]{36})\?/); + if (mediaMatch) { + const [, mediaType, mediaId] = mediaMatch; + // Keep last occurrence (freshest) + urlMap[mediaId] = { mediaType, url, mediaId }; + } + } + + const entries = Object.values(urlMap); + if (!entries.length) return; + + console.log(`[Flow Agent] Captured ${entries.length} fresh media URLs from TRPC`); + // URL refresh is silent — don't show in request log + + // Forward to agent for DB update + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'media_urls_refresh', + urls: entries, + })); + } + } catch (e) { + console.error('[Flow Agent] Failed to extract TRPC media URLs:', e); + } +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +// ─── Human-like Telemetry ────────────────────────────────── +// Periodically send tracking events to Google's analytics endpoints +// to mimic normal browser behavior. + +const _UA = navigator.userAgent; +let _telemetrySessionId = `;${Date.now()}`; + +function _rand(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } + +function _buildBatchLogPayload() { + const events = []; + const types = ['FLOW_IMAGE_LATENCY', 'FLOW_VIDEO_LATENCY']; + const count = _rand(1, 3); + for (let i = 0; i < count; i++) { + events.push({ + event: types[_rand(0, types.length - 1)], + eventProperties: [ + { key: 'CURRENT_TIME_MS', doubleValue: Date.now() }, + { key: 'DURATION_MS', doubleValue: _rand(150, 800) }, + { key: 'USER_AGENT', stringValue: _UA }, + { key: 'IS_DESKTOP', booleanValue: true }, + ], + eventMetadata: { sessionId: _telemetrySessionId }, + eventTime: new Date().toISOString(), + }); + } + return { appEvents: events }; +} + +function _buildFrontendEventsPayload() { + const eventTypes = [ + 'FLOW_IMAGE_LATENCY', 'FLOW_VIDEO_LATENCY', 'GRID_SCROLL_DEPTH', + 'FLOW_PROJECT_OPEN', 'FLOW_SCENE_VIEW', + ]; + const count = _rand(1, 4); + const events = []; + for (let i = 0; i < count; i++) { + const et = eventTypes[_rand(0, eventTypes.length - 1)]; + const params = { + USER_AGENT: { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: _UA }, + IS_DESKTOP: { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: 'true' }, + }; + if (et.includes('LATENCY')) { + params.CURRENT_TIME_MS = { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: String(Date.now()) }; + params.DURATION_MS = { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: String(_rand(100, 600)) }; + } + if (et === 'GRID_SCROLL_DEPTH') { + params.MEDIA_GENERATION_PAYGATE_TIER = { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: 'PAYGATE_TIER_TWO' }; + } + events.push({ + eventType: et, + metadata: { + sessionId: _telemetrySessionId, + createTime: new Date().toISOString(), + additionalParams: params, + }, + }); + } + return { events }; +} + +async function sendTelemetry() { + if (!flowKey || state === 'off') return; + + const headers = { + 'Content-Type': 'text/plain;charset=UTF-8', + 'authorization': `Bearer ${flowKey}`, + }; + + // Telemetry is silent — don't show in request log + try { + if (Math.random() < 0.5) { + await fetch(`https://aisandbox-pa.googleapis.com/v1:batchLog`, { + method: 'POST', headers, credentials: 'include', + body: JSON.stringify(_buildBatchLogPayload()), + }); + } else { + await fetch(`https://aisandbox-pa.googleapis.com/v1/flow:batchLogFrontendEvents`, { + method: 'POST', headers, credentials: 'include', + body: JSON.stringify(_buildFrontendEventsPayload()), + }); + } + } catch {} +} + +// Send telemetry at random intervals (45-120s) to look organic +function scheduleTelemetry() { + const delay = _rand(45, 120) * 1000; + setTimeout(async () => { + await sendTelemetry(); + scheduleTelemetry(); // reschedule with new random interval + }, delay); +} + +// Refresh session ID every ~30min like a real user +setInterval(() => { _telemetrySessionId = `;${Date.now()}`; }, _rand(25, 35) * 60 * 1000); + +scheduleTelemetry(); + +console.log('[Flow Agent] Extension loaded'); diff --git a/flow-agent/extension/content.js b/flow-agent/extension/content.js new file mode 100644 index 0000000000000000000000000000000000000000..8a7e04c63f5c23d7ecdbe6c7337f66d93fb1dc4d --- /dev/null +++ b/flow-agent/extension/content.js @@ -0,0 +1,91 @@ +/** + * Content script — bridge between background.js and injected.js + * Injects injected.js into MAIN world to access window.grecaptcha + */ +(function () { + const s = document.createElement('script'); + s.src = chrome.runtime.getURL('injected.js'); + s.onload = () => s.remove(); + (document.head || document.documentElement).appendChild(s); +})(); + +chrome.runtime.onMessage.addListener((msg, _, reply) => { + if (msg.type !== 'GET_CAPTCHA') return; + + const { requestId, pageAction } = msg; + + const handler = (e) => { + if (e.detail?.requestId === requestId) { + window.removeEventListener('CAPTCHA_RESULT', handler); + clearTimeout(timer); + reply({ token: e.detail.token, error: e.detail.error }); + } + }; + + const timer = setTimeout(() => { + window.removeEventListener('CAPTCHA_RESULT', handler); + reply({ error: 'CONTENT_TIMEOUT' }); + }, 25000); + + window.addEventListener('CAPTCHA_RESULT', handler); + + window.dispatchEvent(new CustomEvent('GET_CAPTCHA', { + detail: { requestId, pageAction }, + })); + + return true; // keep channel open for async reply +}); + +// ─── TRPC Media URL Monitor ───────────────────────────────── +// Forward intercepted TRPC responses with media URLs to background.js +window.addEventListener('TRPC_MEDIA_URLS', (e) => { + const { url, body } = e.detail || {}; + if (!body) return; + chrome.runtime.sendMessage({ + type: 'TRPC_MEDIA_URLS', + trpcUrl: url, + body, + }).catch(() => {}); +}); + +// ─── Aisandbox Request Sniffer (via postMessage from MAIN world) ── +window.addEventListener('message', (e) => { + if (e.data?.type !== '__FLOWKIT_SNIFF__') return; + const { url, body, method } = e.data; + if (!url) return; + chrome.runtime.sendMessage({ + type: 'SNIFFED_AISANDBOX_REQUEST', + url, + method, + payload: body, + timestamp: Date.now(), + }).catch(() => {}); +}); + +// ─── Video Upload Relay ───────────────────────────────────── +chrome.runtime.onMessage.addListener((msg, _, reply) => { + if (msg.type !== 'UPLOAD_VIDEO') return; + + const { requestId, videoBase64, projectId } = msg; + + const handler = (e) => { + if (e.detail?.requestId === requestId) { + window.removeEventListener('UPLOAD_VIDEO_RESULT', handler); + clearTimeout(timer); + reply(e.detail); + } + }; + + const timer = setTimeout(() => { + window.removeEventListener('UPLOAD_VIDEO_RESULT', handler); + reply({ error: 'UPLOAD_TIMEOUT' }); + }, 120000); // 2 min timeout for large uploads + + window.addEventListener('UPLOAD_VIDEO_RESULT', handler); + + window.dispatchEvent(new CustomEvent('UPLOAD_VIDEO', { + detail: { requestId, videoBase64, projectId }, + })); + + return true; // keep channel open for async reply +}); diff --git a/flow-agent/extension/icon128.png b/flow-agent/extension/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..a0add6229bb59214edadc80374582e57f935161e Binary files /dev/null and b/flow-agent/extension/icon128.png differ diff --git a/flow-agent/extension/icon16.png b/flow-agent/extension/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..51bd12095ce39f2404f773e00197fd3cc6d938fe Binary files /dev/null and b/flow-agent/extension/icon16.png differ diff --git a/flow-agent/extension/icon48.png b/flow-agent/extension/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..ed77e3d572389959e1d8265666f17cad1e5931b7 Binary files /dev/null and b/flow-agent/extension/icon48.png differ diff --git a/flow-agent/extension/injected.js b/flow-agent/extension/injected.js new file mode 100644 index 0000000000000000000000000000000000000000..0841ea9e5b36f9585268f2e51c03003dccca05f5 --- /dev/null +++ b/flow-agent/extension/injected.js @@ -0,0 +1,163 @@ +/** + * Injected into MAIN world on labs.google — has access to window.grecaptcha + * Also intercepts TRPC fetch responses to capture fresh signed media URLs. + */ +const SITE_KEY = '6LdsFiUsAAAAAIjVDZcuLhaHiDn5nnHVXVRQGeMV'; + +// ─── XHR Interceptor (for file uploads) ───────────────────── +const _xhrOpen = XMLHttpRequest.prototype.open; +const _xhrSend = XMLHttpRequest.prototype.send; +XMLHttpRequest.prototype.open = function (method, url, ...rest) { + this.__sniffUrl = url; + this.__sniffMethod = method; + return _xhrOpen.call(this, method, url, ...rest); +}; +XMLHttpRequest.prototype.send = function (body) { + try { + const url = this.__sniffUrl || ''; + if (url.includes('googleapis.com') || url.includes('labs.google') || url.includes('storage.google')) { + window.postMessage({ + type: '__FLOWKIT_SNIFF__', + url, + body: typeof body === 'string' ? body : `(binary ${body?.size || body?.byteLength || '?'} bytes)`, + method: this.__sniffMethod || 'POST', + }, '*'); + } + } catch {} + return _xhrSend.call(this, body); +}; + +// ─── TRPC Response Monitor ───────────────────────────────── +// Monkey-patch fetch to intercept TRPC responses containing media URLs. +// Fresh signed GCS URLs are extracted and forwarded to the agent. + +const _originalFetch = window.fetch; +window.fetch = async function (...args) { + try { + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''; + + // ─── SNIFF ALL outgoing requests (catch upload) ───────── + { + let bodyText = ''; + if (args[1]?.body) { + const b = args[1].body; + if (typeof b === 'string') bodyText = b.length > 5000 ? b.slice(0, 200) + `...(${b.length} chars)` : b; + else if (b instanceof FormData) bodyText = `(FormData: ${[...b.keys()].join(', ')})`; + else if (b instanceof Blob) bodyText = `(Blob ${b.size} bytes, type=${b.type})`; + else if (b instanceof ArrayBuffer) bodyText = `(ArrayBuffer ${b.byteLength} bytes)`; + else if (b instanceof ReadableStream) bodyText = '(ReadableStream)'; + else bodyText = JSON.stringify(b)?.slice(0, 2000) || '(unknown)'; + } + window.postMessage({ + type: '__FLOWKIT_SNIFF__', + url, body: bodyText, method: args[1]?.method || 'GET', + }, '*'); + } + } catch {} + + const response = await _originalFetch.apply(this, args); + try { + const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''; + // Only intercept TRPC calls on labs.google that return project/flow data + if (url.includes('/fx/api/trpc/') && response.ok) { + const clone = response.clone(); + clone.text().then(text => { + if (text.includes('storage.googleapis.com/ai-sandbox-videofx/')) { + window.dispatchEvent(new CustomEvent('TRPC_MEDIA_URLS', { + detail: { url, body: text }, + })); + } + }).catch(() => {}); + } + } catch {} + return response; +}; + + +window.addEventListener('GET_CAPTCHA', async ({ detail }) => { + const { requestId, pageAction } = detail; + try { + await waitForGrecaptcha(); + const token = await window.grecaptcha.enterprise.execute(SITE_KEY, { + action: pageAction, + }); + window.dispatchEvent(new CustomEvent('CAPTCHA_RESULT', { + detail: { requestId, token }, + })); + } catch (e) { + window.dispatchEvent(new CustomEvent('CAPTCHA_RESULT', { + detail: { requestId, error: e.message }, + })); + } +}); + +function waitForGrecaptcha(timeout = 10000) { + return new Promise((resolve, reject) => { + const start = Date.now(); + const check = () => { + if (window.grecaptcha?.enterprise?.execute) return resolve(); + if (Date.now() - start > timeout) return reject(new Error('grecaptcha not available')); + setTimeout(check, 200); + }; + check(); + }); +} + +// ─── Video Upload Handler ─────────────────────────────────── +window.addEventListener('UPLOAD_VIDEO', async ({ detail }) => { + const { requestId, videoBase64, projectId } = detail; + try { + // Convert base64 to Blob + const byteChars = atob(videoBase64); + const byteArray = new Uint8Array(byteChars.length); + for (let i = 0; i < byteChars.length; i++) { + byteArray[i] = byteChars.charCodeAt(i); + } + const blob = new Blob([byteArray], { type: 'video/mp4' }); + + // Step 1: POST start — get session URL + const startResp = await _originalFetch('/fx/api/upload-video?action=start', { + method: 'POST', + credentials: 'include', + headers: { + 'X-Upload-Project-Id': projectId || '', + 'X-Upload-Content-Type': 'video/mp4', + 'X-Upload-Content-Length': blob.size.toString(), + }, + }); + const sessionUrl = startResp.headers.get('X-Upload-Session-Url') || ''; + const startData = await startResp.json().catch(() => ({})); + // sessionUrl may be in header OR in response body + const finalSessionUrl = sessionUrl || startData.sessionUrl || ''; + startData._sessionUrl = finalSessionUrl; + startData._status = startResp.status; + + if (!finalSessionUrl) { + window.dispatchEvent(new CustomEvent('UPLOAD_VIDEO_RESULT', { + detail: { requestId, error: 'NO_SESSION_URL', startData }, + })); + return; + } + + // Step 2: PUT directly to GCS session URL with resumable upload headers + const uploadResp = await _originalFetch(finalSessionUrl, { + method: 'PUT', + body: blob, + headers: { + 'Content-Type': 'video/mp4', + 'X-Goog-Upload-Command': 'upload, finalize', + 'X-Goog-Upload-Offset': '0', + }, + }); + const uploadData = await uploadResp.json().catch(() => ({})); + uploadData._status = uploadResp.status; + + window.dispatchEvent(new CustomEvent('UPLOAD_VIDEO_RESULT', { + detail: { requestId, startData, uploadData, status: uploadResp.status }, + })); + } catch (e) { + window.dispatchEvent(new CustomEvent('UPLOAD_VIDEO_RESULT', { + detail: { requestId, error: e.message }, + })); + } +}); diff --git a/flow-agent/extension/manifest.json b/flow-agent/extension/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..5a9d1cf8dedb0fa92b85dda0964ad01748a62653 --- /dev/null +++ b/flow-agent/extension/manifest.json @@ -0,0 +1,59 @@ +{ + "manifest_version": 3, + "name": "Flow Agent", + "version": "1.0.0", + "description": "Automate Google Flow — T2V, V2V, I2V video + T2I, I2I unlimited image generation from terminal", + "icons": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + }, + "permissions": ["storage", "alarms", "tabs", "webRequest", "scripting", "declarativeNetRequest", "sidePanel"], + "host_permissions": [ + "https://labs.google/*", + "https://aisandbox-pa.googleapis.com/*", + "https://aisandbox-pa.sandbox.googleapis.com/*", + "https://storage.googleapis.com/*", + "http://127.0.0.1:8100/*" + ], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [ + "https://labs.google/fx/tools/flow*", + "https://labs.google/fx/*/tools/flow*" + ], + "js": ["content.js"], + "run_at": "document_start" + } + ], + "web_accessible_resources": [ + { + "resources": ["injected.js"], + "matches": ["https://labs.google/*"] + } + ], + "declarative_net_request": { + "rule_resources": [ + { + "id": "referer_rules", + "enabled": true, + "path": "rules.json" + } + ] + }, + "side_panel": { + "default_path": "side_panel.html" + }, + "action": { + "default_popup": "popup.html", + "default_title": "Flow Agent", + "default_icon": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } + } +} diff --git a/flow-agent/extension/popup.html b/flow-agent/extension/popup.html new file mode 100644 index 0000000000000000000000000000000000000000..d5ef775f5a695030ff59b1a66c119244b91a2713 --- /dev/null +++ b/flow-agent/extension/popup.html @@ -0,0 +1,337 @@ + + + + + + Flow Agent + + + + + +
+ +
Flow Agent
+ +
+ +
+ Recent Requests + 0 +
+ +
+
No requests yet
+
+ + + + + + + \ No newline at end of file diff --git a/flow-agent/extension/popup.js b/flow-agent/extension/popup.js new file mode 100644 index 0000000000000000000000000000000000000000..fb737de8dddfa71661ad4b154a09344af6f096b9 --- /dev/null +++ b/flow-agent/extension/popup.js @@ -0,0 +1,138 @@ +const TYPE_LABELS = { + GENERATE_IMAGE: 'GEN IMAGE', + REGENERATE_IMAGE: 'REGEN IMAGE', + EDIT_IMAGE: 'EDIT IMAGE', + GENERATE_CHARACTER_IMAGE: 'GEN REF', + REGENERATE_CHARACTER_IMAGE: 'REGEN REF', + EDIT_CHARACTER_IMAGE: 'EDIT REF', + GENERATE_VIDEO: 'GEN VIDEO', + GENERATE_VIDEO_REFS: 'GEN VIDEO FROM REFS', + UPSCALE_VIDEO: 'UPSCALE VIDEO', + GEN_IMG: 'GEN IMAGE', + GEN_VID: 'GEN VIDEO', + GEN_VID_REF: 'GEN VIDEO FROM REFS', + UPSCALE: 'UPSCALE VIDEO', + TRACKING: 'TRACKING', + URL_REFRESH: 'URL REFRESH', +}; + +function formatType(type) { + if (!type) return '—'; + return TYPE_LABELS[type] || type.slice(0, 12).toUpperCase(); +} + +function formatTime(iso) { + if (!iso) return '—'; + try { + const d = new Date(iso); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; + } catch { + return '—'; + } +} + +function escHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function badgeHtml(status) { + if (status === 'COMPLETED' || status === 'success') { + return '✓ done'; + } else if (status === 'FAILED' || status === 'failed' || (typeof status === 'number' && status >= 400)) { + return '✗ fail'; + } else if (status === 'PROCESSING') { + return '⏳ gen...'; + } else { + return '⏳ sent'; + } +} + +function renderLog(entries) { + const list = document.getElementById('log-list'); + const countEl = document.getElementById('log-count'); + + if (!entries || entries.length === 0) { + list.innerHTML = '
No requests yet
'; + countEl.textContent = '0'; + return; + } + + countEl.textContent = entries.length; + + list.innerHTML = entries.map((entry, i) => { + const shortId = entry.id ? String(entry.id).slice(0, 8) : '—'; + const type = formatType(entry.type || entry.method); + const time = formatTime(entry.time || entry.timestamp); + const status = entry.status || 'pending'; + const error = entry.error || ''; + + const urlDisplay = entry.url + ? `
+
URL
+
${escHtml(entry.url)}
+
` + : ''; + + const payloadDisplay = entry.payloadSummary + ? `
+
Payload
+
${escHtml(entry.payloadSummary)}
+
` + : ''; + + const responseDisplay = entry.responseSummary + ? `
+
Response${entry.httpStatus ? ` (${entry.httpStatus})` : ''}
+
${escHtml(entry.responseSummary)}
+
` + : ''; + + const errorDisplay = error + ? `
+
Error
+
${escHtml(error)}
+
` + : ''; + + const hasDetails = entry.url || entry.payloadSummary || entry.responseSummary || error; + + return `
+
+ ${escHtml(shortId)} + ${escHtml(type)} + ${escHtml(time)} + ${badgeHtml(status)} + ${hasDetails ? '' : ''} +
+ ${hasDetails ? `
${urlDisplay}${payloadDisplay}${responseDisplay}${errorDisplay}
` : ''} +
`; + }).join(''); + + // Toggle expand on row click + list.querySelectorAll('.entry-row').forEach((row) => { + row.addEventListener('click', () => { + const entry = row.closest('.entry'); + if (entry.querySelector('.entry-details')) { + entry.classList.toggle('open'); + } + }); + }); +} + +document.getElementById('btn-panel').addEventListener('click', () => { + chrome.windows.getCurrent((win) => { + chrome.sidePanel.open({ windowId: win.id }); + }); +}); + +chrome.runtime.sendMessage({ type: 'REQUEST_LOG' }, (data) => { + if (chrome.runtime.lastError) return; + if (data && data.log) renderLog(data.log); +}); diff --git a/flow-agent/extension/rules.json b/flow-agent/extension/rules.json new file mode 100644 index 0000000000000000000000000000000000000000..3224d93ae7f45bdee9de4350b41c68db444ae85f --- /dev/null +++ b/flow-agent/extension/rules.json @@ -0,0 +1,25 @@ +[ + { + "id": 1, + "priority": 1, + "action": { + "type": "modifyHeaders", + "requestHeaders": [ + { + "header": "Referer", + "operation": "set", + "value": "https://labs.google/" + }, + { + "header": "Origin", + "operation": "set", + "value": "https://labs.google" + } + ] + }, + "condition": { + "urlFilter": "aisandbox-pa.googleapis.com", + "resourceTypes": ["xmlhttprequest"] + } + } +] diff --git a/flow-agent/extension/side_panel.html b/flow-agent/extension/side_panel.html new file mode 100644 index 0000000000000000000000000000000000000000..053c8c74c24a655afe47c60fd5acd1f90f8205eb --- /dev/null +++ b/flow-agent/extension/side_panel.html @@ -0,0 +1,840 @@ + + + + + + Flow Agent + + + + + + + +
+ +
+
Flow Agent
+
AI Video Automation Agent
+
+
+
+ OFF + +
+
+ + +
+
+
0
+
Total
+
+
+
0
+
Success
+
+
+
0
+
Failed
+
+
+ + +
+ State + off + no token +
+ + +
+
+ Request Log + 0 +
+ +
+ + + + + + + + + + + + + + + +
IDTypeTimeStatusError
No requests yet
+
+
+ + +
+ + +
+ + +
+ + + + Flow Agent · built by kodelyx +
+ + +
+
+
+ Request Detail + +
+
+
+
+ + + + + \ No newline at end of file diff --git a/flow-agent/extension/side_panel.js b/flow-agent/extension/side_panel.js new file mode 100644 index 0000000000000000000000000000000000000000..9c11c57d7a15c8c68ee92b07c8074e05a1caf7b2 --- /dev/null +++ b/flow-agent/extension/side_panel.js @@ -0,0 +1,293 @@ +/** + * Flow Agent — Side Panel + * Displays live connection status, metrics, and request log. + */ + +// ── Type label map ─────────────────────────────────────────── + +const TYPE_LABELS = { + // Worker request types + GENERATE_IMAGE: 'GEN IMAGE', + REGENERATE_IMAGE: 'REGEN IMAGE', + EDIT_IMAGE: 'EDIT IMAGE', + GENERATE_CHARACTER_IMAGE: 'GEN REF', + REGENERATE_CHARACTER_IMAGE: 'REGEN REF', + EDIT_CHARACTER_IMAGE: 'EDIT REF', + GENERATE_VIDEO: 'GEN VIDEO', + GENERATE_VIDEO_REFS: 'GEN VIDEO FROM REFS', + UPSCALE_VIDEO: 'UPSCALE VIDEO', + // Captcha action types + IMAGE_GENERATION: 'GEN IMAGE', + VIDEO_GENERATION: 'GEN VIDEO', + // Extension-classified API types + GEN_IMG: 'GEN IMAGE', + GEN_VID: 'GEN VIDEO', + GEN_VID_REF: 'GEN VIDEO FROM REFS', + UPSCALE: 'UPSCALE VIDEO', + UPS_IMG: 'UPSCALE IMAGE', + POLL: 'CHECK GEN VIDEO', + CREDITS: 'CHECK CREDIT', + CREATE_PROJECT: 'CREATE PROJECT', + UPLOAD: 'UPLOAD IMAGE', + MEDIA: 'READ MEDIA', + TRACKING: 'GOOGLE FLOW TRACK', + URL_REFRESH: 'URL REFRESH', + TRPC: 'TRPC', + API: 'API', +}; + +function formatType(type) { + if (!type) return '—'; + return TYPE_LABELS[type] || type.slice(0, 5).toUpperCase(); +} + +// ── Time formatting ────────────────────────────────────────── + +function formatTime(iso) { + if (!iso) return '—'; + try { + const d = new Date(iso); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; + } catch { + return '—'; + } +} + +// ── Status update ──────────────────────────────────────────── + +function updateStatus(data) { + if (!data) return; + + // Connection dot + const dot = document.getElementById('conn-dot'); + const connected = data.agentConnected; + dot.className = connected ? 'on' : ''; + + // Toggle state + const toggle = document.getElementById('main-toggle'); + const toggleLabel = document.getElementById('toggle-label'); + const isOn = data.state !== 'off'; + toggle.checked = isOn; + toggleLabel.textContent = isOn ? 'ON' : 'OFF'; + + // State badge + const stateBadge = document.getElementById('state-badge'); + const st = data.state || 'off'; + stateBadge.textContent = st; + stateBadge.className = st; // idle | running | off + + // Token status + const tokenEl = document.getElementById('token-status'); + if (data.flowKeyPresent) { + const ageMs = data.tokenAge || 0; + const ageMin = Math.round(ageMs / 60000); + if (ageMs > 3600000) { + tokenEl.textContent = `token expired — open Flow to refresh`; + tokenEl.className = 'warn'; + } else { + tokenEl.textContent = `token synced ${ageMin}m`; + tokenEl.className = 'ok'; + } + // Auto-refresh when token age > 55 min and connected + if (ageMs > 3300000 && data.agentConnected) { + chrome.runtime.sendMessage({ type: 'REFRESH_TOKEN' }); + } + } else { + tokenEl.textContent = 'no token'; + tokenEl.className = 'bad'; + } + + // Metrics + const m = data.metrics || {}; + document.getElementById('m-total').textContent = m.requestCount || 0; + document.getElementById('m-success').textContent = m.successCount || 0; + document.getElementById('m-failed').textContent = m.failedCount || 0; +} + +// ── Request log ────────────────────────────────────────────── + +function updateRequestLog(entries) { + const tbody = document.getElementById('log-body'); + const countEl = document.getElementById('log-count'); + + if (!entries || entries.length === 0) { + tbody.innerHTML = 'No requests yet'; + countEl.textContent = '0'; + return; + } + + countEl.textContent = entries.length; + _logEntries = entries; + + // Render newest first (entries already sorted DESC by background.js) + const rows = entries.map((entry) => { + const shortId = entry.id ? String(entry.id).slice(0, 8) : '—'; + const type = formatType(entry.type || entry.method); + const time = formatTime(entry.time || entry.timestamp || entry.createdAt); + const status = entry.status || entry.state || 'pending'; + const error = entry.error || ''; + + let badgeHtml; + if (status === 'COMPLETED' || status === 'success') { + badgeHtml = '✓ done'; + } else if (status === 'FAILED' || status === 'failed' || (typeof status === 'number' && status >= 400)) { + badgeHtml = '✗ fail'; + } else if (status === 'PROCESSING') { + badgeHtml = '⏳ gen...'; + } else if (status === 200 || status === 'processing') { + badgeHtml = '⏳ sent'; + } else { + badgeHtml = '⏳ sent'; + } + + const errorDisplay = error + ? `${escHtml(truncate(error, 28))}` + : `—`; + + return ` + ${escHtml(shortId)} + ${escHtml(type)} + ${escHtml(time)} + ${badgeHtml} + ${errorDisplay} + `; + }); + + tbody.innerHTML = rows.join(''); + + // Attach click handlers to ID cells + tbody.querySelectorAll('.td-id[data-request-id]').forEach(td => { + td.addEventListener('click', () => { + const reqId = td.getAttribute('data-request-id'); + if (reqId) showRequestDetail(reqId); + }); + }); +} + +function escHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function truncate(str, len) { + if (!str || str.length <= len) return str; + return str.slice(0, len) + '…'; +} + +// ── Request detail modal ──────────────────────────────────── + +let _logEntries = []; + +function showRequestDetail(reqId) { + const entry = _logEntries.find(e => e.id === reqId); + if (!entry) return; + + const overlay = document.getElementById('detail-overlay'); + const title = document.getElementById('detail-title'); + const body = document.getElementById('detail-body'); + + title.textContent = `Request ${String(reqId).slice(0, 12)}`; + + const fields = [ + ['ID', entry.id], + ['Type', formatType(entry.type || entry.method)], + ['Time', formatTime(entry.time || entry.timestamp || entry.createdAt)], + ['Status', entry.status || entry.state || 'pending'], + ['HTTP', entry.httpStatus || '—'], + ['URL', entry.url || '—'], + ['Payload', entry.payloadSummary || '—'], + ['Response', entry.responseSummary || '—'], + ['Error', entry.error || '—'], + ]; + + body.innerHTML = fields.map(([label, value]) => { + let cls = 'detail-value'; + if (label === 'Error' && value && value !== '—') cls += ' error'; + if (label === 'Status' && (value === 'COMPLETED' || value === 'success')) cls += ' ok'; + return `
+
${escHtml(label)}
+
${escHtml(String(value || '—'))}
+
`; + }).join(''); + + overlay.classList.add('open'); +} + +document.getElementById('detail-close').addEventListener('click', () => { + document.getElementById('detail-overlay').classList.remove('open'); +}); + +document.getElementById('detail-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) { + e.currentTarget.classList.remove('open'); + } +}); + +// ── Initial data fetch ─────────────────────────────────────── + +function fetchStatus() { + chrome.runtime.sendMessage({ type: 'STATUS' }, (data) => { + if (chrome.runtime.lastError) return; + updateStatus(data); + }); +} + +function fetchLog() { + chrome.runtime.sendMessage({ type: 'REQUEST_LOG' }, (data) => { + if (chrome.runtime.lastError) return; + if (data && data.log) updateRequestLog(data.log); + }); +} + +// ── Message listener (push updates) ───────────────────────── + +chrome.runtime.onMessage.addListener((msg) => { + if (msg.type === 'STATUS_PUSH') { + fetchStatus(); + } + if (msg.type === 'REQUEST_LOG_UPDATE') { + if (msg.log) updateRequestLog(msg.log); + } +}); + +// ── Toggle (connect / disconnect) ─────────────────────────── + +document.getElementById('main-toggle').addEventListener('change', (e) => { + const msgType = e.target.checked ? 'RECONNECT' : 'DISCONNECT'; + chrome.runtime.sendMessage({ type: msgType }, () => { + if (chrome.runtime.lastError) return; + setTimeout(fetchStatus, 400); + }); +}); + +// ── Action buttons ─────────────────────────────────────────── + +document.getElementById('btn-flow').addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'OPEN_FLOW_TAB' }, () => { + if (chrome.runtime.lastError) return; + }); +}); + +document.getElementById('btn-token').addEventListener('click', () => { + const btn = document.getElementById('btn-token'); + btn.textContent = 'Opening...'; + btn.disabled = true; + chrome.runtime.sendMessage({ type: 'REFRESH_TOKEN' }, () => { + if (chrome.runtime.lastError) { /* ignore */ } + btn.textContent = 'Refresh Token'; + btn.disabled = false; + }); +}); + +// ── Init ───────────────────────────────────────────────────── + +document.addEventListener('DOMContentLoaded', () => { + fetchStatus(); + fetchLog(); +}); diff --git a/flow-agent/media-id.js b/flow-agent/media-id.js new file mode 100644 index 0000000000000000000000000000000000000000..8df590cab1906dace4810ca095636f87af2d84ca --- /dev/null +++ b/flow-agent/media-id.js @@ -0,0 +1,12 @@ +cat_beach.png : cc312ed8-e13f-4b89-a25d-4d45786daf15 +dragon_end.png : 9f7ee671-62cf-4380-8896-38a1cc0ce943 +dragon_start.png : 76690529-192b-42ee-b6d5-ebc003ec7467 +end_frame.png : dfa0816c-dfc5-41bd-bbbf-0ef865a377de +start_frame.png : 41f43cdb-293f-4e41-8bb2-664fd269619b +upload_08eb0364414643f9bede2b4e139375d9_test_t2i.png : a821e119-ebdb-419d-8991-f80ecfa63637 +upload_125349fc756b4e6f8ead3b2a9d6aa036_test_i2v.mp4 : 84e699af-72ba-45db-9a0e-ea60cda3420b +upload_55a0bcc69fa74bf4a85790a23ba0e92a_test_t2i.png : e6dcaab9-266f-4dd1-bf04-63bf8cd5a27b +upload_6fb7eab112a04007b7468ce3b4a7c5d9_test_t2i.png : 0e61aa50-fc25-4c58-95ff-ea2587e66265 +upload_c0e77a6605844f5d93182e9fc6aaaa5e_test_i2v.mp4 : 9f598bab-5f73-403d-b679-7cd223f42523 +upload_cd9aed3690464e06953eb74c5b254619_test_t2i.png : a2ef1c23-c2bb-42a9-8cd2-03264a754623 +upload_ce5bb751ddeb461c9392c617af058186_test_i2v.mp4 : 8eb1f60d-0f6a-44b3-835d-8f268c565818 diff --git a/flow-agent/omni.py b/flow-agent/omni.py new file mode 100644 index 0000000000000000000000000000000000000000..6bf49301424acb72e9b13d48272a3abe3e9cebb4 --- /dev/null +++ b/flow-agent/omni.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Omni Flash — Backward-compatible wrapper. + +This file preserves the old `from omni import ...` interface. +All logic lives in the omniflash/ package now. + +Usage: + python omni.py "A dragon breathing fire" + python omni.py "Eagle soaring" --aspect landscape -o eagle.mp4 +""" + +# Re-export everything from the package +from omniflash import ( + ExtensionBridge, + generate_video, + edit_video, + upload_image, + generate_video_i2v, + poll_status, + download_video, + build_client_context, + upload_video, + media_store, + ASPECTS, + DEFAULT_PROJECT, + ENDPOINTS, + CLIENT_CTX, + API_KEY, + API_BASE, +) + +# CLI entry point +if __name__ == "__main__": + from cli.generate import main + main() diff --git a/flow-agent/omniflash/__init__.py b/flow-agent/omniflash/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7dd8410319b080047603b508ec5038e152ec8678 --- /dev/null +++ b/flow-agent/omniflash/__init__.py @@ -0,0 +1,61 @@ +"""Omni Flash — AI Video Generation & Editing toolkit. + +Usage: + from omniflash import ExtensionBridge, generate_video, poll_status, download_video + from omniflash import edit_video, upload_image, generate_video_i2v + from omniflash.config import ASPECTS, DEFAULT_PROJECT + from omniflash.upload import upload_video + from omniflash import media_store +""" + +# Auto-install dependencies +import os +import sys +for _pkg in ["websockets"]: + try: + __import__(_pkg) + except ImportError: + os.system(f"{sys.executable} -m pip install {_pkg} -q") + +import logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S") + +# ─── Public API ────────────────────────────────────────────── + +from .bridge import ExtensionBridge +from .config import ASPECTS, DEFAULT_PROJECT, ENDPOINTS, CLIENT_CTX, API_KEY, API_BASE +from .generators import ( + generate_video, + edit_video, + upload_image, + generate_video_i2v, + poll_status, + download_video, + build_client_context, +) +from .upload import upload_video +from . import media_store + +__all__ = [ + # Bridge + "ExtensionBridge", + # Generators + "generate_video", + "edit_video", + "upload_image", + "generate_video_i2v", + "poll_status", + "download_video", + "build_client_context", + # Upload + "upload_video", + # Config + "ASPECTS", + "DEFAULT_PROJECT", + "ENDPOINTS", + "CLIENT_CTX", + "API_KEY", + "API_BASE", + # Media store + "media_store", +] diff --git a/flow-agent/omniflash/bridge.py b/flow-agent/omniflash/bridge.py new file mode 100644 index 0000000000000000000000000000000000000000..6c2d3fcc7526fab09d5ed6650acab37951c4f6c8 --- /dev/null +++ b/flow-agent/omniflash/bridge.py @@ -0,0 +1,277 @@ +"""Omni Flash — ExtensionBridge. + +WebSocket + HTTP server that communicates with the Chrome extension. +Handles auth token capture, API proxying, and request/response routing. +""" + +import asyncio +import json +import logging +import random +import threading +import uuid +from http.server import HTTPServer, BaseHTTPRequestHandler + +import websockets + +from .config import ( + WS_PORT, HTTP_PORT, API_BASE, API_KEY, + CLIENT_CTX, USER_AGENTS, +) + +log = logging.getLogger("omniflash.bridge") + + +class ExtensionBridge: + """WebSocket server that Chrome extension connects to.""" + + def __init__(self): + self._ws = None + self._pending: dict[str, asyncio.Future] = {} + self._flow_key = None + self._connected = asyncio.Event() + self._loop = None + + async def start(self): + """Start WS server and HTTP callback server.""" + self._loop = asyncio.get_event_loop() + self._start_http_server() + + self._ws_server = await websockets.serve( + self._on_connect, "127.0.0.1", WS_PORT + ) + log.info("⚡ WebSocket server on ws://127.0.0.1:%d", WS_PORT) + log.info("⚡ HTTP callback on http://127.0.0.1:%d", HTTP_PORT) + log.info("⏳ Waiting for Chrome extension to connect...") + + async def wait_for_extension(self, timeout=90, max_retries=3): + """Wait until extension connects and sends flow key. + + Phase 1: Wait for WebSocket connection from extension. + Phase 2: If no token, auto-open/refresh Flow tab and wait for token. + """ + # Phase 1: Wait for WS connection + try: + await asyncio.wait_for(self._wait_for_ws(), 30) + except asyncio.TimeoutError: + log.error("❌ Extension didn't connect in 30s") + log.error(" Make sure Flow Agent extension is installed and enabled in Chrome") + return False + + # If token already present, we're good + if self._flow_key: + return True + + # Phase 2: Extension connected but no token — auto-fix + log.info("⚠️ Extension connected but no auth token — auto-fixing...") + + for attempt in range(1, max_retries + 1): + log.info("🔄 Attempt %d/%d: Opening/refreshing Flow tab...", attempt, max_retries) + await self._request_flow_tab() + + # Wait for token to arrive (token_captured message) + token_arrived = await self._wait_for_token(20) + if token_arrived: + log.info("✅ Token captured after auto-fix!") + return True + + log.warning("⏳ Token not captured yet...") + + log.error("❌ Could not get auth token after %d retries", max_retries) + log.error(" Make sure you're logged into Google at labs.google/fx/tools/flow") + return False + + async def _wait_for_ws(self): + """Wait until a WebSocket connection is established.""" + while not self._ws: + await asyncio.sleep(0.5) + + async def _wait_for_token(self, timeout): + """Wait until a valid token is captured.""" + self._connected.clear() + try: + await asyncio.wait_for(self._connected.wait(), timeout) + return True + except asyncio.TimeoutError: + return self._flow_key is not None + + async def _request_flow_tab(self): + """Ask extension to open or refresh a Flow tab.""" + if not self._ws: + return + try: + log.info("📂 Requesting extension to open/refresh Flow tab...") + await self._ws.send(json.dumps({"method": "open_flow_tab"})) + # Wait for page to fully load before requesting token refresh + await asyncio.sleep(8) + log.info("🔄 Requesting token refresh from Flow tab...") + await self._ws.send(json.dumps({"method": "refresh_flow_tab"})) + except Exception as e: + log.debug("Failed to request flow tab: %s", e) + + async def health_check(self): + """Quick check if extension is ready with valid token.""" + if not self._ws or not self._flow_key: + return False + try: + req_id = str(uuid.uuid4()) + future = self._loop.create_future() + self._pending[req_id] = future + await self._ws.send(json.dumps({ + "id": req_id, + "method": "get_status", + })) + result = await asyncio.wait_for(future, timeout=5) + self._pending.pop(req_id, None) + return result.get("result", {}).get("flowKeyPresent", False) + except Exception: + self._pending.pop(req_id, None) + return False + + async def _on_connect(self, ws): + self._ws = ws + log.info("✅ Extension connected!") + try: + async for raw in ws: + data = json.loads(raw) + await self._handle_message(data) + except websockets.exceptions.ConnectionClosed: + log.warning("Extension disconnected") + self._ws = None + self._connected.clear() + + async def _handle_message(self, data): + msg_type = data.get("type") + + if msg_type == "token_captured": + self._flow_key = data.get("flowKey") + log.info("🔑 Auth token captured") + self._connected.set() + + elif msg_type == "extension_ready": + log.info("Extension ready (flowKey=%s)", "yes" if data.get("flowKeyPresent") else "no") + if data.get("flowKeyPresent") and self._flow_key: + self._connected.set() + + elif msg_type in ("pong", "ping"): + if msg_type == "ping" and self._ws: + await self._ws.send(json.dumps({"type": "pong"})) + + else: + req_id = data.get("id") + if req_id and req_id in self._pending: + if not self._pending[req_id].done(): + self._pending[req_id].set_result(data) + + def handle_http_callback(self, data): + """Called from HTTP thread when extension sends callback.""" + req_id = data.get("id") + if req_id and req_id in self._pending: + self._loop.call_soon_threadsafe( + self._resolve_pending, req_id, data + ) + return True + if data.get("type") == "token_captured": + self._flow_key = data.get("flowKey") + self._loop.call_soon_threadsafe(self._connected.set) + return True + return False + + def _resolve_pending(self, req_id, data): + if req_id in self._pending and not self._pending[req_id].done(): + self._pending[req_id].set_result(data) + + async def api_request(self, url_path, body, captcha_action="VIDEO_GENERATION", method="POST"): + """Send API request through Chrome extension.""" + if not self._ws: + return {"error": "Extension not connected"} + + req_id = str(uuid.uuid4()) + future = self._loop.create_future() + self._pending[req_id] = future + + url = f"{API_BASE}{url_path}?key={API_KEY}" + ua = random.choice(USER_AGENTS) + platform = '"macOS"' if "Macintosh" in ua else '"Windows"' + + msg = { + "id": req_id, + "method": "api_request", + "params": { + "url": url, + "method": method, + "headers": { + "accept": "*/*", + "content-type": "text/plain;charset=UTF-8", + "origin": CLIENT_CTX["origin"], + "referer": CLIENT_CTX["origin"] + "/", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": platform, + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "cross-site", + "user-agent": ua, + }, + "body": body, + "captchaAction": captcha_action, + }, + } + await self._ws.send(json.dumps(msg)) + + try: + result = await asyncio.wait_for(future, timeout=90) + return result + except asyncio.TimeoutError: + return {"error": "TIMEOUT"} + finally: + self._pending.pop(req_id, None) + + def _start_http_server(self): + """Start HTTP server for extension callbacks (runs in thread).""" + bridge = self + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path == "/api/ext/callback": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) if length else {} + bridge.handle_http_callback(body) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(b'{"ok":true}') + else: + self.send_response(404) + self.end_headers() + + def do_GET(self): + if self.path == "/health": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({ + "status": "ok", + "extension_connected": bridge._ws is not None, + }).encode()) + else: + self.send_response(404) + self.end_headers() + + def do_OPTIONS(self): + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def log_message(self, *args): + pass + + server = HTTPServer(("127.0.0.1", HTTP_PORT), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + async def close(self): + self._ws_server.close() + await self._ws_server.wait_closed() diff --git a/flow-agent/omniflash/config.py b/flow-agent/omniflash/config.py new file mode 100644 index 0000000000000000000000000000000000000000..50a8adb39ef3825c3a70e9e88827329128a67ab8 --- /dev/null +++ b/flow-agent/omniflash/config.py @@ -0,0 +1,84 @@ +"""Flow Agent — Configuration. + +All constants hardcoded. No external config files needed. +""" + +import os + +# ─── Paths ─────────────────────────────────────────────────── + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +MEDIA_ID_FILE = os.path.join(ROOT_DIR, "media-id.js") + +# ─── Project ───────────────────────────────────────────────── + +DEFAULT_PROJECT = "0143adf4-5864-4cb4-abb5-fe4254ad0dc7" + +# Image model: NARWHAL (Imagen 4 / Nano Banana 2), GEM_PIX_2, IMAGEN_4 +IMAGE_MODEL = "NARWHAL" + +# ─── Hardcoded constants (never change) ────────────────────── + +API_KEY = "AIzaSyBtrm0o5ab1c-Ec8ZuLcGt3oJAA5VWt3pY" +API_BASE = "https://aisandbox-pa.googleapis.com" + +CLIENT_CTX = { + "tool": "PINHOLE", + "tier": "PAYGATE_TIER_ONE", + "origin": "https://labs.google", + "recaptcha_app_type": "RECAPTCHA_APPLICATION_TYPE_WEB", +} + +ASPECTS = { + "portrait": "VIDEO_ASPECT_RATIO_PORTRAIT", + "landscape": "VIDEO_ASPECT_RATIO_LANDSCAPE", +} + +ENDPOINTS = { + "generate_t2v": "/v1/video:batchAsyncGenerateVideoText", + "generate_i2v": "/v1/video:batchAsyncGenerateVideoStartImage", + "generate_fl": "/v1/video:batchAsyncGenerateVideoStartAndEndImage", + "generate_r2v": "/v1/video:batchAsyncGenerateVideoReferenceImages", + "generate_edit": "/v1/video:batchAsyncGenerateVideoEditVideo", + "upload_image": "/v1/flow/uploadImage", + "poll_status": "/v1/video:batchCheckAsyncVideoGenerationStatus", + "get_media": "/v1/media/{media_id}", + "get_credits": "/v1/credits", +} + +MODELS = { + "t2v": { + 4: "abra_t2v_4s", + 6: "abra_t2v_6s", + 8: "abra_t2v_8s", + 10: "abra_t2v_10s", + }, + "edit": "abra_edit", +} + +DURATIONS = [4, 6, 8, 10] +DEFAULT_DURATION = 10 +MAX_COUNT = 4 + +CREDITS_PER_VIDEO = { + 4: 5, + 6: 10, + 8: 10, + 10: 15, +} + +# ─── Runtime constants ─────────────────────────────────────── + +WS_PORT = int(os.environ.get("WS_PORT", "9222")) +HTTP_PORT = int(os.environ.get("HTTP_PORT", "8100")) + +POLL_INTERVAL = 10 +POLL_TIMEOUT = 420 + +SEGMENT_DURATION = 10 +FPS = 24 + +USER_AGENTS = [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36", +] diff --git a/flow-agent/omniflash/generators/__init__.py b/flow-agent/omniflash/generators/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..737999ef1165d24eb771cb3d5101ca0a35aa6312 --- /dev/null +++ b/flow-agent/omniflash/generators/__init__.py @@ -0,0 +1,20 @@ +"""Omni Flash — Generator modules.""" + +from .t2v import generate_video +from .v2v import edit_video +from .i2v import upload_image, generate_video_i2v +from .t2i import generate_image, download_image, IMAGE_ASPECTS +from .common import poll_status, download_video, build_client_context + +__all__ = [ + "generate_video", + "edit_video", + "upload_image", + "generate_video_i2v", + "generate_image", + "download_image", + "IMAGE_ASPECTS", + "poll_status", + "download_video", + "build_client_context", +] diff --git a/flow-agent/omniflash/generators/common.py b/flow-agent/omniflash/generators/common.py new file mode 100644 index 0000000000000000000000000000000000000000..e2ed1e293b7467763090c878bb39ff636ffbb6e8 --- /dev/null +++ b/flow-agent/omniflash/generators/common.py @@ -0,0 +1,98 @@ +"""Omni Flash — Common utilities for all generators. + +Shared functions: client context builder, poll_status, download_video. +""" + +import asyncio +import base64 +import logging +import os +import random +import time +import uuid + +from ..config import ( + CLIENT_CTX, ENDPOINTS, POLL_INTERVAL, POLL_TIMEOUT, +) + +log = logging.getLogger("omniflash.generators") + + +def build_client_context(project_id: str) -> dict: + """Build the clientContext dict used by all API requests.""" + return { + "projectId": project_id, + "tool": CLIENT_CTX["tool"], + "userPaygateTier": CLIENT_CTX["tier"], + "sessionId": f";{int(time.time() * 1000)}", + "recaptchaContext": { + "applicationType": CLIENT_CTX["recaptcha_app_type"], + "token": "", + }, + } + + +def build_generation_context(audio_pref: str = None) -> dict: + """Build the mediaGenerationContext dict.""" + ctx = {"batchId": str(uuid.uuid4())} + if audio_pref: + ctx["audioFailurePreference"] = audio_pref + return ctx + + +async def poll_status(bridge, media_id: str, project_id: str) -> bool: + """Poll until video is ready. Returns True on success.""" + body = {"media": [{"name": media_id, "projectId": project_id}]} + start = time.time() + + while time.time() - start < POLL_TIMEOUT: + result = await bridge.api_request(ENDPOINTS["poll_status"], body, captcha_action="") + data = result.get("data", {}) + media = data.get("media", []) + + if media: + meta = media[0].get("mediaMetadata", {}).get("mediaStatus", {}) + status = meta.get("mediaGenerationStatus", "") + + if status == "MEDIA_GENERATION_STATUS_SUCCESSFUL": + elapsed = int(time.time() - start) + log.info("✅ Video ready! (%ds)", elapsed) + return True + elif "FAILED" in status or "BLOCKED" in status: + log.error("❌ Failed: %s", status) + return False + + elapsed = int(time.time() - start) + log.info("⏳ Waiting... (%ds)", elapsed) + await asyncio.sleep(POLL_INTERVAL) + + log.error("❌ Timeout after %ds", POLL_TIMEOUT) + return False + + +async def download_video(bridge, media_id: str, output_path: str) -> bool: + """Download video via get_media API.""" + url_path = ENDPOINTS["get_media"].format(media_id=media_id) + result = await bridge.api_request(url_path, {}, captcha_action="", method="GET") + data = result.get("data", result) + + video_b64 = "" + if isinstance(data, dict): + v = data.get("video", {}) + if isinstance(v, dict): + video_b64 = v.get("encodedVideo", "") + elif isinstance(v, str): + video_b64 = v + + if not video_b64: + log.error("❌ No video data in response") + return False + + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + video_bytes = base64.b64decode(video_b64) + with open(output_path, "wb") as f: + f.write(video_bytes) + + size_mb = len(video_bytes) / (1024 * 1024) + log.info("✅ Saved: %s (%.1f MB)", output_path, size_mb) + return True diff --git a/flow-agent/omniflash/generators/i2v.py b/flow-agent/omniflash/generators/i2v.py new file mode 100644 index 0000000000000000000000000000000000000000..9fe5eb8f5d3a5dcf5f9660761546f1b7ffe536e9 --- /dev/null +++ b/flow-agent/omniflash/generators/i2v.py @@ -0,0 +1,192 @@ +"""Omni Flash — Image to Video (I2V) generator + image upload.""" + +import base64 +import logging +import os +import random + +from ..config import CLIENT_CTX, ENDPOINTS +from .. import media_store +from .common import build_client_context, build_generation_context + +log = logging.getLogger("omniflash.generators.i2v") + + +async def upload_image(bridge, image_path: str, project_id: str = None) -> str | None: + """Upload a local image to Flow. Returns media_id. + + Auto-saves filename → media_id to media-id.js. + """ + from ..config import DEFAULT_PROJECT + project_id = project_id or DEFAULT_PROJECT + + with open(image_path, "rb") as f: + img_data = base64.b64encode(f.read()).decode() + + body = { + "clientContext": {"tool": CLIENT_CTX["tool"], "projectId": project_id}, + "imageBytes": img_data, + } + + log.info("📷 Uploading image: %s", os.path.basename(image_path)) + result = await bridge.api_request(ENDPOINTS["upload_image"], body) + + status = result.get("status", 0) + data = result.get("data", {}) + if status != 200: + err = data.get("error", {}).get("message", "Unknown") if isinstance(data, dict) else str(data) + log.error("❌ Image upload failed (%s): %s", status, err) + return None + + media_id = data.get("mediaId") or data.get("name") + if not media_id and isinstance(data.get("media"), dict): + media_id = data["media"].get("name") + log.info("✅ Image uploaded! media_id=%s", media_id) + + if media_id: + media_store.save(os.path.basename(image_path), media_id) + + return media_id + + +async def generate_video_i2v(bridge, prompt: str, aspect: str, project_id: str, + image_media_id: str, duration: int = 8) -> list[str] | None: + """Generate video from a start image. Returns list of media_ids.""" + model_key = f"abra_t2v_{duration}s" + + body = { + "mediaGenerationContext": build_generation_context(), + "clientContext": build_client_context(project_id), + "requests": [{ + "aspectRatio": aspect, + "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}}, + "videoModelKey": model_key, + "seed": random.randint(1, 9999), + "metadata": {}, + "startImage": {"mediaId": image_media_id}, + }], + } + + log.info('🖼️→🎬 I2V: "%s" [%s] image=%s', prompt[:50], model_key, image_media_id[:12]) + result = await bridge.api_request(ENDPOINTS["generate_i2v"], body) + + status = result.get("status", 0) + if status != 200: + err = result.get("data", {}) + if isinstance(err, dict): + err = err.get("error", {}).get("message", result.get("error", "Unknown")) + log.error("❌ I2V failed (%s): %s", status, err) + return None + + data = result.get("data", {}) + media = data.get("media", []) + if not media: + log.error("❌ No media in response") + return None + + media_ids = [m.get("name") for m in media] + credits = data.get("remainingCredits", "?") + log.info("✅ I2V submitted! %d video(s), credits=%s", len(media_ids), credits) + return media_ids + + +async def generate_video_fl(bridge, prompt: str, aspect: str, project_id: str, + start_image_id: str, end_image_id: str, + duration: int = 8) -> list[str] | None: + """Generate video with First+Last frame control. + + Video transitions smoothly from start_image to end_image. + """ + model_key = f"abra_t2v_{duration}s" + + request = { + "aspectRatio": aspect, + "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}}, + "videoModelKey": model_key, + "seed": random.randint(1, 9999), + "metadata": {}, + "startImage": {"mediaId": start_image_id}, + "endImage": {"mediaId": end_image_id}, + } + + body = { + "mediaGenerationContext": build_generation_context(), + "clientContext": build_client_context(project_id), + "requests": [request], + "useV2ModelConfig": True, + } + + log.info('🎬 FL: "%s" start=%s end=%s', prompt[:40], start_image_id[:12], end_image_id[:12]) + result = await bridge.api_request(ENDPOINTS["generate_fl"], body) + + status = result.get("status", 0) + if status != 200: + err = result.get("data", {}) + if isinstance(err, dict): + err = err.get("error", {}).get("message", result.get("error", "Unknown")) + log.error("❌ FL failed (%s): %s", status, err) + return None + + data = result.get("data", {}) + media = data.get("media", []) + if not media: + log.error("❌ No media in response") + return None + + media_ids = [m.get("name") for m in media] + credits = data.get("remainingCredits", "?") + log.info("✅ FL submitted! %d video(s), credits=%s", len(media_ids), credits) + return media_ids + + +async def generate_video_r2v(bridge, prompt: str, aspect: str, project_id: str, + ref_media_ids: list[str], + duration: int = 8) -> list[str] | None: + """Generate video from reference images (character/style consistency). + + Uses reference images to maintain visual consistency in the generated video. + """ + model_key = f"abra_t2v_{duration}s" + + ref_images = [ + {"mediaId": mid, "imageUsageType": "IMAGE_USAGE_TYPE_ASSET"} + for mid in ref_media_ids + ] + + request = { + "aspectRatio": aspect, + "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}}, + "videoModelKey": model_key, + "seed": random.randint(1, 9999), + "metadata": {}, + "referenceImages": ref_images, + } + + body = { + "mediaGenerationContext": build_generation_context(), + "clientContext": build_client_context(project_id), + "requests": [request], + "useV2ModelConfig": True, + } + + log.info('🎬 R2V: "%s" refs=%d', prompt[:50], len(ref_media_ids)) + result = await bridge.api_request(ENDPOINTS["generate_r2v"], body) + + status = result.get("status", 0) + if status != 200: + err = result.get("data", {}) + if isinstance(err, dict): + err = err.get("error", {}).get("message", result.get("error", "Unknown")) + log.error("❌ R2V failed (%s): %s", status, err) + return None + + data = result.get("data", {}) + media = data.get("media", []) + if not media: + log.error("❌ No media in response") + return None + + media_ids = [m.get("name") for m in media] + credits = data.get("remainingCredits", "?") + log.info("✅ R2V submitted! %d video(s), credits=%s", len(media_ids), credits) + return media_ids diff --git a/flow-agent/omniflash/generators/t2i.py b/flow-agent/omniflash/generators/t2i.py new file mode 100644 index 0000000000000000000000000000000000000000..54d9181221dd113a8da12b690f0e2c7f8d604b6d --- /dev/null +++ b/flow-agent/omniflash/generators/t2i.py @@ -0,0 +1,153 @@ +"""Flow Agent — Text to Image (T2I) generator. + +Ported from virtual-try/go-server/image.go +""" + +import logging +import os +import random +import re +import time + +from ..config import ENDPOINTS, DEFAULT_PROJECT, IMAGE_MODEL +from .common import build_client_context, build_generation_context + +log = logging.getLogger("omniflash.generators.t2i") + +# Image aspect ratios (more options than video) +IMAGE_ASPECTS = { + "landscape": "IMAGE_ASPECT_RATIO_LANDSCAPE", # 16:9 + "4x3": "IMAGE_ASPECT_RATIO_4_3", # 4:3 + "square": "IMAGE_ASPECT_RATIO_SQUARE", # 1:1 + "3x4": "IMAGE_ASPECT_RATIO_3_4", # 3:4 + "portrait": "IMAGE_ASPECT_RATIO_PORTRAIT", # 9:16 +} + +UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + + +def _parse_image_results(data: dict) -> list[dict]: + """Parse all images from batchGenerateImages response. + + Returns list of {"media_id": str, "image_url": str} + """ + results = [] + media_list = data.get("media", []) + + for item in media_list: + r = {"media_id": "", "image_url": ""} + + # media[i].name = mediaId + name = item.get("name", "") + if UUID_RE.match(name): + r["media_id"] = name + + # media[i].image.generatedImage.fifeUrl + img = item.get("image", {}) + gen = img.get("generatedImage", {}) + url = gen.get("fifeUrl", "") or gen.get("imageUri", "") + if url: + r["image_url"] = url + # Fallback: extract mediaId from URL + if not r["media_id"]: + match = UUID_RE.search(url) + if match: + r["media_id"] = match.group() + + results.append(r) + + return results + + +async def generate_image(bridge, prompt: str, aspect: str, project_id: str, + count: int = 1, ref_media_ids: list[str] = None) -> list[dict] | None: + """Generate images from text prompt. + + Args: + bridge: ExtensionBridge instance + prompt: Text prompt for image + aspect: Aspect ratio key (portrait/landscape/square/4x3/3x4) + project_id: Flow project ID + count: Number of variations (1-4) + ref_media_ids: Optional reference image media IDs + + Returns: + List of {"media_id": str, "image_url": str} or None on error + """ + count = max(1, min(4, count)) + ts = int(time.time() * 1000) + + aspect_ratio = IMAGE_ASPECTS.get(aspect, aspect) + + # Build N items in requests array (1 API call = N images) + requests = [] + for i in range(count): + req_item = { + "clientContext": build_client_context(project_id), + "seed": (ts + i * 1000) % 1000000, + "structuredPrompt": {"parts": [{"text": prompt}]}, + "imageAspectRatio": aspect_ratio, + "imageModelName": IMAGE_MODEL, + } + + # Add reference images if provided + if ref_media_ids: + req_item["imageInputs"] = [ + {"name": mid, "imageInputType": "IMAGE_INPUT_TYPE_REFERENCE"} + for mid in ref_media_ids + ] + + requests.append(req_item) + + body = { + "clientContext": build_client_context(project_id), + "requests": requests, + } + + if ref_media_ids: + body["mediaGenerationContext"] = build_generation_context() + body["useNewMedia"] = True + + # Endpoint: /v1/projects/{projectId}/flowMedia:batchGenerateImages + endpoint = f"/v1/projects/{project_id}/flowMedia:batchGenerateImages" + + log.info('🖼️ Generating: "%s" [%s] x%d', prompt[:50], aspect, count) + result = await bridge.api_request(endpoint, body, captcha_action="IMAGE_GENERATION") + + status = result.get("status", 0) + if status != 200: + err = result.get("data", {}) + if isinstance(err, dict): + err = err.get("error", {}).get("message", result.get("error", "Unknown")) + log.error("❌ Failed (%s): %s", status, err) + return None + + data = result.get("data", {}) + results = _parse_image_results(data) + + if not results: + log.error("❌ No images in response") + return None + + credits = data.get("remainingCredits", "?") + log.info("✅ Generated! %d image(s), credits=%s", len(results), credits) + for r in results: + log.info(" media_id=%s", r["media_id"][:12] if r["media_id"] else "?") + + return results + + +async def download_image(bridge, image_url: str, output_path: str) -> bool: + """Download image from fifeUrl via HTTP GET.""" + import urllib.request + + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + + try: + urllib.request.urlretrieve(image_url, output_path) + size_kb = os.path.getsize(output_path) / 1024 + log.info("✅ Saved: %s (%.0f KB)", output_path, size_kb) + return True + except Exception as e: + log.error("❌ Download failed: %s", e) + return False diff --git a/flow-agent/omniflash/generators/t2v.py b/flow-agent/omniflash/generators/t2v.py new file mode 100644 index 0000000000000000000000000000000000000000..85dcbd2e73d91b876228c1e574a689a693373f28 --- /dev/null +++ b/flow-agent/omniflash/generators/t2v.py @@ -0,0 +1,57 @@ +"""Omni Flash — Text to Video (T2V) generator.""" + +import logging +import random +import uuid + +from ..config import ENDPOINTS +from .common import build_client_context, build_generation_context + +log = logging.getLogger("omniflash.generators.t2v") + + +async def generate_video(bridge, prompt: str, aspect: str, project_id: str, + duration: int = 10, count: int = 1) -> list[str] | None: + """Submit T2V generation request. Returns list of media_ids.""" + model_key = f"abra_t2v_{duration}s" + + requests = [] + for _ in range(count): + requests.append({ + "aspectRatio": aspect, + "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}}, + "videoModelKey": model_key, + "seed": random.randint(1, 9999), + "metadata": {}, + }) + + body = { + "mediaGenerationContext": build_generation_context(), + "clientContext": build_client_context(project_id), + "requests": requests, + "useV2ModelConfig": True, + } + + log.info('🎬 Generating: "%s" [%s] %ds x%d', prompt[:50], model_key, duration, count) + result = await bridge.api_request(ENDPOINTS["generate_t2v"], body) + + status = result.get("status", 0) + if status != 200: + err = result.get("data", {}) + if isinstance(err, dict): + err = err.get("error", {}).get("message", result.get("error", "Unknown")) + log.error("❌ Failed (%s): %s", status, err) + return None + + data = result.get("data", {}) + media = data.get("media", []) + if not media: + log.error("❌ No media in response") + return None + + media_ids = [m.get("name") for m in media] + credits = data.get("remainingCredits", "?") + log.info("✅ Submitted! %d video(s), credits=%s", len(media_ids), credits) + for mid in media_ids: + log.info(" media_id=%s", mid) + return media_ids diff --git a/flow-agent/omniflash/generators/v2v.py b/flow-agent/omniflash/generators/v2v.py new file mode 100644 index 0000000000000000000000000000000000000000..331d66354eb3f14dc7d2fd33393d268517c99bf7 --- /dev/null +++ b/flow-agent/omniflash/generators/v2v.py @@ -0,0 +1,56 @@ +"""Omni Flash — Video to Video (V2V) editor.""" + +import logging +import random + +from ..config import ENDPOINTS +from .common import build_client_context, build_generation_context + +log = logging.getLogger("omniflash.generators.v2v") + + +async def edit_video(bridge, prompt: str, aspect: str, project_id: str, + video_media_id: str, fps: int = 24, duration: int = 10, + start_frame: int = 0, end_frame: int = None) -> list[str] | None: + """Submit V2V edit request. Returns list of media_ids.""" + if end_frame is None: + end_frame = fps * duration + + body = { + "mediaGenerationContext": build_generation_context("BLOCK_SILENCED_VIDEOS"), + "clientContext": build_client_context(project_id), + "requests": [{ + "aspectRatio": aspect, + "textInput": {"structuredPrompt": {"parts": [{"text": prompt}]}}, + "videoModelKey": "abra_edit", + "seed": random.randint(1, 9999), + "metadata": {}, + "videoInput": { + "mediaId": video_media_id, + "startFrameIndex": start_frame, + "endFrameIndex": end_frame, + }, + }], + } + + log.info('✂️ Editing: "%s" [abra_edit] media=%s', prompt[:50], video_media_id[:12]) + result = await bridge.api_request(ENDPOINTS["generate_edit"], body) + + status = result.get("status", 0) + if status != 200: + err = result.get("data", {}) + if isinstance(err, dict): + err = err.get("error", {}).get("message", result.get("error", "Unknown")) + log.error("❌ Failed (%s): %s", status, err) + return None + + data = result.get("data", {}) + media = data.get("media", []) + if not media: + log.error("❌ No media in response") + return None + + media_ids = [m.get("name") for m in media] + credits = data.get("remainingCredits", "?") + log.info("✅ Edit submitted! %d video(s), credits=%s", len(media_ids), credits) + return media_ids diff --git a/flow-agent/omniflash/media_store.py b/flow-agent/omniflash/media_store.py new file mode 100644 index 0000000000000000000000000000000000000000..db30b4b341f08a3dbdcefd7dd66c588e0ca5ea5d --- /dev/null +++ b/flow-agent/omniflash/media_store.py @@ -0,0 +1,45 @@ +"""Omni Flash — Media ID store. + +Reads and writes media-id.js (filename → media_id mapping). +Single source of truth — used by upload, upload_image, etc. +""" + +import logging +import os + +from .config import MEDIA_ID_FILE + +log = logging.getLogger("omniflash.media_store") + + +def read_entries() -> dict[str, str]: + """Read all filename → media_id entries from media-id.js.""" + entries = {} + if os.path.exists(MEDIA_ID_FILE): + with open(MEDIA_ID_FILE, "r") as f: + for line in f: + line = line.strip() + if " : " in line: + k, v = line.split(" : ", 1) + entries[k.strip()] = v.strip() + return entries + + +def write_entries(entries: dict[str, str]): + """Write all entries to media-id.js (sorted).""" + with open(MEDIA_ID_FILE, "w") as f: + for k, v in sorted(entries.items()): + f.write(f"{k} : {v}\n") + + +def save(filename: str, media_id: str): + """Add or update a single entry in media-id.js.""" + entries = read_entries() + entries[filename] = media_id + write_entries(entries) + log.info("📝 Updated media-id.js: %s → %s", filename, media_id) + + +def get(filename: str) -> str | None: + """Get media_id for a filename, or None.""" + return read_entries().get(filename) diff --git a/flow-agent/omniflash/upload.py b/flow-agent/omniflash/upload.py new file mode 100644 index 0000000000000000000000000000000000000000..7f4d22ca0cd4fc4a9f89d71de64cd8af4d457813 --- /dev/null +++ b/flow-agent/omniflash/upload.py @@ -0,0 +1,116 @@ +"""Omni Flash — Video upload via GCS resumable upload. + +Uploads a video file to Google Flow using the tRPC session URL +and curl for the binary PUT. +""" + +import asyncio +import json +import logging +import os +import subprocess +import uuid + +from .bridge import ExtensionBridge +from . import media_store + +log = logging.getLogger("omniflash.upload") + +DEFAULT_PROJECT_ID = "ff92d5cc-8a03-41d2-b59e-e0774d17bcf6" + + +async def upload_video(video_path: str, project_id: str = DEFAULT_PROJECT_ID, + bridge: ExtensionBridge = None) -> dict: + """Upload a video file to Google Flow. + + If bridge is provided, uses it (caller manages lifecycle). + Otherwise creates and closes its own bridge. + + Returns dict with mediaId, media, workflow on success. + """ + video_path = os.path.abspath(video_path) + if not os.path.exists(video_path): + raise FileNotFoundError(f"Video not found: {video_path}") + + video_size = os.path.getsize(video_path) + log.info("Video: %s (%d bytes, %.1fMB)", + os.path.basename(video_path), video_size, video_size / 1024 / 1024) + + # Manage bridge lifecycle + own_bridge = bridge is None + if own_bridge: + bridge = ExtensionBridge() + await bridge.start() + if not await bridge.wait_for_extension(timeout=30): + raise ConnectionError("Extension did not connect") + await asyncio.sleep(2) + + # Step 1: Get session URL via extension's trpc_request + token = bridge._flow_key + rid = str(uuid.uuid4())[:8] + fut = asyncio.get_event_loop().create_future() + bridge._pending[rid] = fut + + await bridge._ws.send(json.dumps({ + "id": rid, + "method": "trpc_request", + "params": { + "url": "https://labs.google/fx/api/upload-video?action=start", + "method": "POST", + "headers": { + "X-Upload-Project-Id": project_id, + "X-Upload-Content-Type": "video/mp4", + "X-Upload-Content-Length": str(video_size), + }, + }, + })) + + try: + r = await asyncio.wait_for(fut, timeout=20) + except asyncio.TimeoutError: + if own_bridge: + await bridge.close() + raise TimeoutError("Step 1 timeout") + + if own_bridge: + await bridge.close() + + session_url = r.get("data", {}).get("sessionUrl", "") + auth_token = token or "" + + if not session_url: + raise RuntimeError(f"No session URL: {json.dumps(r)[:500]}") + + log.info("Got session URL") + + # Step 2: PUT via curl + log.info("Uploading %d bytes via curl...", video_size) + proc = subprocess.run([ + "curl", "-X", "PUT", session_url, + "-H", "Content-Type: video/mp4", + "-H", f"Authorization: Bearer {auth_token}", + "-H", "X-Goog-Upload-Command: upload, finalize", + "-H", "X-Goog-Upload-Offset: 0", + "--data-binary", f"@{video_path}", + "--max-time", "120", + ], capture_output=True, text=True) + + if proc.returncode != 0: + raise RuntimeError(f"curl failed: {proc.stderr[:500]}") + + if not proc.stdout.strip(): + raise RuntimeError("Empty response from upload") + + data = json.loads(proc.stdout) + media_id = data.get("mediaId") or data.get("name") or data.get("id") + if not media_id and isinstance(data.get("media"), dict): + media_id = data["media"].get("name") or data["media"].get("mediaId") + + if media_id: + log.info("✅ SUCCESS! media_id = %s", media_id) + media_store.save(os.path.basename(video_path), media_id) + else: + log.warning("Upload succeeded but no media_id found") + log.info("Response: %s", json.dumps(data, indent=2)[:1000]) + + return data diff --git a/flow-agent/omniflash/watermark.py b/flow-agent/omniflash/watermark.py new file mode 100644 index 0000000000000000000000000000000000000000..dcaa66751ae47633826b9a35ecaa6fdb3bc83663 --- /dev/null +++ b/flow-agent/omniflash/watermark.py @@ -0,0 +1,375 @@ +"""Omni Flash — Video Watermark Remover. + +Removes Gemini/Flow watermark from generated videos using reverse alpha blending. +Ported from Gemini-Watermark-Remover-UI (TypeScript → Python). + +Algorithm: + Gemini adds watermark: watermarked = α × logo + (1 - α) × original + Reverse solve: original = (watermarked - α × logo) / (1 - α) +""" + +import logging +import os +import cv2 +import numpy as np +import subprocess +import json +import sys + +log = logging.getLogger("omniflash.watermark") + +# ── Constants ────────────────────────────────────────────── +ALPHA_THRESHOLD = 0.002 # Ignore very small alpha values (noise) +MAX_ALPHA = 0.99 # Avoid division by near-zero values +LOGO_VALUE = 255 # White watermark color value +VIDEO_ALPHA_SCALE = 0.6 # Gemini videos use 60% opacity watermark + + +# ── Embedded watermark assets (base64-encoded PNGs) ── +_BG_48_B64 = "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" + +_BG_96_B64 = "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" + + +def _load_alpha_map(size: int) -> np.ndarray: + """Load alpha map from embedded base64 PNG data. + + Alpha map is the max RGB channel of the watermark on black background, + normalized to [0, 1]. + """ + import base64 + + b64 = _BG_48_B64 if size == 48 else _BG_96_B64 + png_bytes = base64.b64decode(b64) + arr = np.frombuffer(png_bytes, dtype=np.uint8) + bg = cv2.imdecode(arr, cv2.IMREAD_COLOR) + + if bg is None: + raise ValueError(f"Failed to decode embedded bg_{size}.png") + + # Resize to exact size if needed + if bg.shape[0] != size or bg.shape[1] != size: + bg = cv2.resize(bg, (size, size)) + + # Max of RGB channels, normalized to [0, 1] + alpha_map = bg.max(axis=2).astype(np.float32) / 255.0 + return alpha_map + + +# Cache alpha maps +_alpha_cache: dict[int, np.ndarray] = {} + + +def get_alpha_map(size: int) -> np.ndarray: + """Get cached alpha map for given watermark size.""" + if size not in _alpha_cache: + _alpha_cache[size] = _load_alpha_map(size) + return _alpha_cache[size] + + +def detect_watermark_config(width: int, height: int, is_video: bool = True): + """Detect watermark size and position based on dimensions.""" + if is_video: + short_dim = min(width, height) + if short_dim >= 1080: + logo_size = 96 + margin_right = 64 + margin_bottom = 64 + else: + logo_size = 48 + margin_right = 72 + margin_bottom = 72 + else: + if width == 720 and height == 1280: + logo_size = 48 + margin_right = 72 + margin_bottom = 72 + else: + short_dim = min(width, height) + if short_dim > 800: + logo_size = 96 + margin_right = 64 + margin_bottom = 64 + else: + logo_size = 48 + margin_right = 32 + margin_bottom = 32 + + x = width - margin_right - logo_size + y = height - margin_bottom - logo_size + + return { + "logo_size": logo_size, + "x": x, + "y": y, + "width": logo_size, + "height": logo_size, + } + + +def remove_watermark_frame(frame: np.ndarray, is_video: bool = True) -> np.ndarray: + """Remove watermark from a single frame (BGR numpy array). + + Uses reverse alpha blending: + original = (watermarked - α × logo) / (1 - α) + """ + h, w = frame.shape[:2] + config = detect_watermark_config(w, h, is_video) + alpha_map = get_alpha_map(config["logo_size"]) + + x, y = config["x"], config["y"] + size = config["logo_size"] + + # Extract watermark region + region = frame[y:y+size, x:x+size].astype(np.float32) + + # Scale alpha for video + alpha = alpha_map.copy() + if is_video: + alpha = alpha * VIDEO_ALPHA_SCALE + + # Create mask for significant alpha values + mask = alpha >= ALPHA_THRESHOLD + alpha_clamped = np.clip(alpha, 0, MAX_ALPHA) + one_minus_alpha = 1.0 - alpha_clamped + + # Apply reverse alpha blending to each channel + for c in range(3): # BGR + channel = region[:, :, c] + # original = (watermarked - α × LOGO_VALUE) / (1 - α) + original = (channel - alpha_clamped * LOGO_VALUE) / one_minus_alpha + # Only modify pixels where alpha is significant + channel[mask] = original[mask] + region[:, :, c] = np.clip(channel, 0, 255) + + # Write back + frame[y:y+size, x:x+size] = region.astype(np.uint8) + return frame + + +def remove_watermark_yuv_frame(frame_bytes: bytes, width: int, height: int, config: dict) -> bytes: + """Apply YUV watermark reverse blending directly in raw yuv420p buffer using NumPy.""" + frame_arr = np.frombuffer(frame_bytes, dtype=np.uint8).copy() + + y_size = width * height + uv_size = (width // 2) * (height // 2) + + # Reshape planes into 2D views + Y = frame_arr[0:y_size].reshape((height, width)) + U = frame_arr[y_size:y_size + uv_size].reshape((height // 2, width // 2)) + V = frame_arr[y_size + uv_size:].reshape((height // 2, width // 2)) + + x, y = config["x"], config["y"] + size = config["logo_size"] + + # Align coordinates to even numbers for YUV420p subsampling compatibility + x0 = (x // 2) * 2 + y0 = (y // 2) * 2 + x1 = ((x + size + 1) // 2) * 2 + y1 = ((y + size + 1) // 2) * 2 + + # Ensure bounds are within image dimensions (which must also be even) + x0 = max(0, min(x0, width)) + y0 = max(0, min(y0, height)) + x1 = max(0, min(x1, width)) + y1 = max(0, min(y1, height)) + + # Intersect aligned bounds with actual watermark bounding box + w_x0, w_y0 = x, y + w_x1, w_y1 = x + size, y + size + + int_x0 = max(x0, w_x0) + int_y0 = max(y0, w_y0) + int_x1 = min(x1, w_x1) + int_y1 = min(y1, w_y1) + + # Create target alpha map for coordinate-aligned region + alpha_map_full = np.zeros((y1 - y0, x1 - x0), dtype=np.float32) + + if int_x1 > int_x0 and int_y1 > int_y0: + src_x0 = int_x0 - w_x0 + src_y0 = int_y0 - w_y0 + src_x1 = int_x1 - w_x0 + src_y1 = int_y1 - w_y0 + + dst_x0 = int_x0 - x0 + dst_y0 = int_y0 - y0 + dst_x1 = int_x1 - x0 + dst_y1 = int_y1 - y0 + + alpha_map_full[dst_y0:dst_y1, dst_x0:dst_x1] = get_alpha_map(size)[src_y0:src_y1, src_x0:src_x1] + + scaled_alpha = alpha_map_full * VIDEO_ALPHA_SCALE + scaled_alpha = np.clip(scaled_alpha, 0, MAX_ALPHA) + one_minus_alpha = 1.0 - scaled_alpha + mask = alpha_map_full >= ALPHA_THRESHOLD + + # 1. Process Y channel (Luminance) + Y_roi = Y[y0:y1, x0:x1].astype(np.float32) + new_Y = (Y_roi - scaled_alpha * 235.0) / one_minus_alpha + Y[y0:y1, x0:x1] = np.where(mask, np.clip(new_Y, 0, 255).astype(np.uint8), Y[y0:y1, x0:x1]) + + # 2. Process U & V channels (Chroma, subsampled 2x2) + x0_sub, y0_sub = x0 // 2, y0 // 2 + x1_sub, y1_sub = x1 // 2, y1 // 2 + + scaled_alpha_sub = scaled_alpha[::2, ::2] + one_minus_alpha_sub = one_minus_alpha[::2, ::2] + mask_sub = mask[::2, ::2] + + # U Channel + U_roi = U[y0_sub:y1_sub, x0_sub:x1_sub].astype(np.float32) + new_U = (U_roi - 128.0) / one_minus_alpha_sub + 128.0 + U[y0_sub:y1_sub, x0_sub:x1_sub] = np.where(mask_sub, np.clip(new_U, 0, 255).astype(np.uint8), U[y0_sub:y1_sub, x0_sub:x1_sub]) + + # V Channel + V_roi = V[y0_sub:y1_sub, x0_sub:x1_sub].astype(np.float32) + new_V = (V_roi - 128.0) / one_minus_alpha_sub + 128.0 + V[y0_sub:y1_sub, x0_sub:x1_sub] = np.where(mask_sub, np.clip(new_V, 0, 255).astype(np.uint8), V[y0_sub:y1_sub, x0_sub:x1_sub]) + + return frame_arr.tobytes() + + +def remove_watermark_video(input_path: str, output_path: str = None, + show_progress: bool = True) -> str: + """Remove watermark from a video file using ffmpeg YUV420p pipes. + + Args: + input_path: Path to input video file + output_path: Path for output video (default: adds _clean suffix) + show_progress: Show progress log + + Returns: + Path to cleaned video file + """ + input_path = str(input_path) + if output_path is None: + base, ext = os.path.splitext(input_path) + output_path = f"{base}_clean{ext}" + + abs_input = os.path.abspath(input_path) + abs_output = os.path.abspath(output_path) + + # Get video info via ffprobe + probe = subprocess.run( + ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", abs_input], + capture_output=True, text=True + ) + info = json.loads(probe.stdout) + + video_stream = next(s for s in info["streams"] if s["codec_type"] == "video") + width = int(video_stream["width"]) + height = int(video_stream["height"]) + + # Parse fps + fps_str = video_stream.get("r_frame_rate", "24/1") + if "/" in fps_str: + num, den = fps_str.split("/") + fps = float(num) / float(den) + else: + fps = float(fps_str) + + duration = float(info.get("format", {}).get("duration", 0)) + total_frames = int(duration * fps) if duration > 0 else 0 + + log.info("🎬 Processing YUV video: %dx%d @ %.1f fps (%d frames)", width, height, fps, total_frames) + + config = detect_watermark_config(width, height, is_video=True) + + # Temp file for safe rewrite + temp_out = abs_output + ".tmp.mp4" + if os.path.exists(temp_out): + os.remove(temp_out) + + # Dynamic macOS Hardware Acceleration vs standard CPU fallback + if sys.platform == "darwin": + read_cmd = [ + "ffmpeg", "-hwaccel", "videotoolbox", "-i", abs_input, + "-f", "rawvideo", "-pix_fmt", "yuv420p", + "-v", "quiet", "-" + ] + encoder_args = ["-c:v", "h264_videotoolbox", "-b:v", "4000k"] + else: + read_cmd = [ + "ffmpeg", "-i", abs_input, + "-f", "rawvideo", "-pix_fmt", "yuv420p", + "-v", "quiet", "-" + ] + encoder_args = ["-c:v", "libx264", "-preset", "fast", "-crf", "18"] + + write_cmd = [ + "ffmpeg", "-y", + "-f", "rawvideo", "-pix_fmt", "yuv420p", + "-s", f"{width}x{height}", "-r", str(fps_str), + "-i", "-", + "-i", abs_input, + "-map", "0:v", "-map", "1:a?", + ] + encoder_args + [ + "-c:a", "copy", + "-movflags", "+faststart", + "-v", "quiet", + temp_out + ] + + reader = subprocess.Popen(read_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + writer = subprocess.Popen(write_cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL) + + y_size = width * height + uv_size = (width // 2) * (height // 2) + frame_size = y_size + 2 * uv_size + frame_count = 0 + + try: + while True: + raw = reader.stdout.read(frame_size) + if len(raw) < frame_size: + break + + clean_raw = remove_watermark_yuv_frame(raw, width, height, config) + writer.stdin.write(clean_raw) + + frame_count += 1 + if show_progress and frame_count % 30 == 0: + pct = int(frame_count / total_frames * 100) if total_frames > 0 else 0 + log.info(" Processing: %d%% (%d/%d frames)", pct, frame_count, total_frames) + finally: + writer.stdin.close() + writer.wait() + reader.wait() + + if os.path.exists(temp_out): + if os.path.exists(abs_output): + os.remove(abs_output) + os.rename(temp_out, abs_output) + else: + raise RuntimeError("Failed to compile clean watermark-free video") + + size_mb = os.path.getsize(abs_output) / (1024 * 1024) + log.info("✅ Watermark removed: %s (%.1f MB, %d frames)", abs_output, size_mb, frame_count) + return abs_output + + +def remove_watermark_image(input_path: str, output_path: str = None) -> str: + """Remove watermark from a single image file. + + Args: + input_path: Path to input image + output_path: Path for output (default: adds _clean suffix) + + Returns: + Path to cleaned image + """ + input_path = str(input_path) + if output_path is None: + base, ext = os.path.splitext(input_path) + output_path = f"{base}_clean{ext}" + + img = cv2.imread(input_path) + if img is None: + raise ValueError(f"Cannot read image: {input_path}") + + clean = remove_watermark_frame(img, is_video=False) + cv2.imwrite(output_path, clean) + + log.info("✅ Watermark removed: %s", output_path) + return output_path diff --git a/flow-agent/requirements.txt b/flow-agent/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..a50d7969623505af83cb5c9d683a828db693778a --- /dev/null +++ b/flow-agent/requirements.txt @@ -0,0 +1,7 @@ +websockets>=12.0 +opencv-python-headless>=4.8 +numpy>=1.24 +fastapi>=0.100.0 +uvicorn>=0.22.0 +python-multipart>=0.0.6 + diff --git a/flow-agent/test_api.py b/flow-agent/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..ce79b7ca35d1e78abf84f6d8ad5da4913fc4eb2c --- /dev/null +++ b/flow-agent/test_api.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Integration Test Script for Flow Agent API. + +Tests the following end-to-end flow: +1. GET /health +2. POST /generate/image (T2I) +3. POST /upload/image +4. POST /generate/video (I2V) +5. POST /upload/video +6. POST /edit/video (V2V) +""" + +import os +import sys +import time + +# Add virtual environment site-packages to sys.path to run with system python +venv_site_packages = os.path.join(os.path.dirname(os.path.abspath(__file__)), "venv", "lib", "python3.14", "site-packages") +if os.path.exists(venv_site_packages): + sys.path.insert(0, venv_site_packages) + +# Clear proxy environment variables to bypass sandbox proxy for local requests +os.environ.pop("HTTP_PROXY", None) +os.environ.pop("HTTPS_PROXY", None) +os.environ.pop("http_proxy", None) +os.environ.pop("https_proxy", None) + +# Auto-install requests if not present +try: + import requests +except ImportError: + print("📦 Installing 'requests' library for testing...") + os.system(f"{sys.executable} -m pip install requests") + import requests + +API_URL = "http://localhost:8000" + +def log_step(name): + print(f"\n{'=' * 60}") + print(f"🚀 STEP: {name}") + print(f"{'=' * 60}") + +def check_health(): + log_step("Checking Server Health") + try: + r = requests.get(f"{API_URL}/health") + print(f"Status Code: {r.status_code}") + print(f"Response: {r.text}") + if r.status_code != 200: + print("❌ Server health check failed!") + sys.exit(1) + data = r.json() + if not data.get("extension_connected"): + print("❌ Chrome extension is NOT connected. Please check Chrome devtools.") + sys.exit(1) + if not data.get("has_flow_key"): + print("⚠️ Chrome extension is connected but NOT logged in / authorized.") + print(" Please make sure labs.google/fx/tools/flow is open and logged in Chrome.") + sys.exit(1) + print("✅ Health Check Passed!") + except Exception as e: + print(f"❌ Failed to connect to server: {e}") + sys.exit(1) + +def generate_image(): + log_step("Text-to-Image (T2I) Generation") + payload = { + "prompt": "a simple red apple on a clean wooden table, professional studio lighting, 8k resolution", + "aspect": "square", + "count": 1 + } + r = requests.post(f"{API_URL}/generate/image", json=payload) + print(f"Status Code: {r.status_code}") + print(f"Response: {r.text}") + + if r.status_code != 200: + print("❌ Image generation failed!") + sys.exit(1) + + data = r.json() + outputs = data.get("outputs", []) + if not outputs: + print("❌ No outputs returned in image generation!") + sys.exit(1) + + img_info = outputs[0] + remote_url = img_info.get("remote_url") + filename = img_info.get("filename") + + # Download generated image locally for next steps + local_img_path = os.path.join("output", "test_t2i.png") + os.makedirs("output", exist_ok=True) + + print(f"📥 Downloading image from {remote_url}...") + img_r = requests.get(remote_url) + with open(local_img_path, "wb") as f: + f.write(img_r.content) + + print(f"✅ Saved test image to {local_img_path}") + return local_img_path + +def upload_image(image_path): + log_step("Uploading Image to Flow") + with open(image_path, "rb") as f: + files = {"file": (os.path.basename(image_path), f, "image/png")} + r = requests.post(f"{API_URL}/upload/image", files=files) + + print(f"Status Code: {r.status_code}") + print(f"Response: {r.text}") + + if r.status_code != 200: + print("❌ Image upload failed!") + sys.exit(1) + + data = r.json() + media_id = data.get("media_id") + if not media_id: + print("❌ No media_id returned from upload!") + sys.exit(1) + + print(f"✅ Image uploaded successfully! media_id: {media_id}") + return media_id + +def generate_image_i2i(image_media_id): + log_step("Image-to-Image (I2I) Generation") + payload = { + "prompt": "transform the red apple into a vibrant cartoon style, Pixar 3D aesthetic", + "aspect": "square", + "count": 1, + "ref": [image_media_id] + } + r = requests.post(f"{API_URL}/generate/image", json=payload) + print(f"Status Code: {r.status_code}") + print(f"Response: {r.text}") + + if r.status_code != 200: + print("❌ Image-to-Image (I2I) generation failed!") + sys.exit(1) + + data = r.json() + outputs = data.get("outputs", []) + if not outputs: + print("❌ No outputs returned in I2I generation!") + sys.exit(1) + + img_info = outputs[0] + remote_url = img_info.get("remote_url") + + local_i2i_path = os.path.join("output", "test_i2i.png") + print(f"📥 Downloading I2I image from {remote_url}...") + img_r = requests.get(remote_url) + with open(local_i2i_path, "wb") as f: + f.write(img_r.content) + + print(f"✅ Saved I2I image to {local_i2i_path}") + return local_i2i_path + +def generate_video_t2v(): + log_step("Text-to-Video (T2V) Generation") + payload = { + "prompt": "a beautiful golden retriever playing in a park, high quality, 8k, cinematic lighting", + "aspect": "portrait", + "duration": 4, + "count": 1 + } + r = requests.post(f"{API_URL}/generate/video", json=payload) + print(f"Status Code: {r.status_code}") + print(f"Response: {r.text}") + + if r.status_code != 200: + print("❌ T2V video generation failed!") + sys.exit(1) + + data = r.json() + outputs = data.get("outputs", []) + if not outputs: + print("❌ No outputs returned in T2V video generation!") + sys.exit(1) + + video_info = outputs[0] + download_url = video_info.get("download_url") + + local_video_path = os.path.join("output", "test_t2v.mp4") + + print(f"📥 Downloading video from {API_URL}{download_url}...") + video_r = requests.get(f"{API_URL}{download_url}") + with open(local_video_path, "wb") as f: + f.write(video_r.content) + + print(f"✅ Saved T2V video to {local_video_path}") + return local_video_path + +def generate_video_i2v(image_media_id): + log_step("Image-to-Video (I2V) Generation") + payload = { + "prompt": "the apple slowly rolls forward, dynamic cinematic panning, highly realistic", + "aspect": "portrait", + "duration": 4, # Short duration for faster testing + "count": 1, + "start": image_media_id # Animate using the uploaded image + } + r = requests.post(f"{API_URL}/generate/video", json=payload) + print(f"Status Code: {r.status_code}") + print(f"Response: {r.text}") + + if r.status_code != 200: + print("❌ Video generation failed!") + sys.exit(1) + + data = r.json() + outputs = data.get("outputs", []) + if not outputs: + print("❌ No outputs returned in video generation!") + sys.exit(1) + + video_info = outputs[0] + download_url = video_info.get("download_url") + filename = video_info.get("filename") + + local_video_path = os.path.join("output", "test_i2v.mp4") + + print(f"📥 Downloading video from {API_URL}{download_url}...") + video_r = requests.get(f"{API_URL}{download_url}") + with open(local_video_path, "wb") as f: + f.write(video_r.content) + + print(f"✅ Saved test video to {local_video_path}") + return local_video_path + +def upload_video(video_path): + log_step("Uploading Video to Flow") + with open(video_path, "rb") as f: + files = {"file": (os.path.basename(video_path), f, "video/mp4")} + r = requests.post(f"{API_URL}/upload/video", files=files) + + print(f"Status Code: {r.status_code}") + print(f"Response: {r.text}") + + if r.status_code != 200: + print("❌ Video upload failed!") + sys.exit(1) + + data = r.json() + media_id = data.get("media_id") + if not media_id: + print("❌ No media_id returned from upload!") + sys.exit(1) + + print(f"✅ Video uploaded successfully! media_id: {media_id}") + return media_id + +def edit_video_v2v(video_media_id): + log_step("Video-to-Video (V2V) Editing") + payload = { + "prompt": "make it look like an anime sketch style, drawing pencil style", + "video_media_id": video_media_id, + "aspect": "portrait", + "fps": 24, + "duration": 4 + } + r = requests.post(f"{API_URL}/edit/video", json=payload) + print(f"Status Code: {r.status_code}") + print(f"Response: {r.text}") + + if r.status_code != 200: + print("❌ V2V video editing failed!") + sys.exit(1) + + data = r.json() + outputs = data.get("outputs", []) + if not outputs: + print("❌ No outputs returned in video edit!") + sys.exit(1) + + video_info = outputs[0] + download_url = video_info.get("download_url") + + local_edit_path = os.path.join("output", "test_v2v.mp4") + + print(f"📥 Downloading edited video from {API_URL}{download_url}...") + video_r = requests.get(f"{API_URL}{download_url}") + with open(local_edit_path, "wb") as f: + f.write(video_r.content) + + print(f"✅ Saved edited video to {local_edit_path}") + +def main(): + print("🎬 Starting Flow Agent API Integration Test...") + + # 1. Health check + check_health() + + # 2. Text to Image (T2I) + local_image = generate_image() + + # 2b. Text to Video (T2V) + local_t2v_video = generate_video_t2v() + + # 3. Upload Image to GCS/Flow + image_media_id = upload_image(local_image) + + # 4. Image to Image (I2I) + local_i2i_image = generate_image_i2i(image_media_id) + + # 5. Image to Video (I2V) + local_video = generate_video_i2v(image_media_id) + + # 6. Upload Video to GCS/Flow + video_media_id = upload_video(local_video) + + # 7. Video to Video (V2V) + edit_video_v2v(video_media_id) + + print("\n" + "=" * 60) + print("🎉 ALL TESTS PASSED SUCCESSFULLY! Flow Agent API is 100% verified.") + print("=" * 60) + +if __name__ == "__main__": + main() diff --git a/free-gemini-api/.dockerignore b/free-gemini-api/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..32453fb29c0ee0d82a5a84ee2bdb943ad0858802 --- /dev/null +++ b/free-gemini-api/.dockerignore @@ -0,0 +1,8 @@ +dist/ +output/ +cookies.json +.env +.git/ +.DS_Store +goapi +*.exe diff --git a/free-gemini-api/.gitignore b/free-gemini-api/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b1e96daf0098229ae4823f62914494fd0cd20753 --- /dev/null +++ b/free-gemini-api/.gitignore @@ -0,0 +1,22 @@ +# Binaries +goapi +*.exe + +# Environment & Secrets +.env +cookies.json + +# Generated media +output/ + +# IDE +.vscode/ +.idea/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Go +vendor/ diff --git a/free-gemini-api/Dockerfile b/free-gemini-api/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..4c9f360813170cb30de52fc7ec681a1bdbcc2dcc --- /dev/null +++ b/free-gemini-api/Dockerfile @@ -0,0 +1,21 @@ +# Build stage +FROM golang:1.25-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o free-gemini-api . + +# Runtime stage +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates curl ffmpeg +WORKDIR /app +COPY --from=builder /app/free-gemini-api . +RUN mkdir -p /app/output + +EXPOSE 8000 9222 + +ENV WS_PORT="9222" \ + PORT="8000" + +CMD ["./free-gemini-api"] diff --git a/free-gemini-api/README.md b/free-gemini-api/README.md new file mode 100644 index 0000000000000000000000000000000000000000..68991c79a9f3b5ac067b6a1e56927f65e320b611 --- /dev/null +++ b/free-gemini-api/README.md @@ -0,0 +1,401 @@ +
+ +# 🔓 Free Gemini API + +### Unleash the Full Power of Google Gemini 3.5 Flash, Imagen 3, and Gemini Video — Completely Free + +[![Go Version](https://img.shields.io/badge/Go-1.21+-00ADD8?style=for-the-badge&logo=go&logoColor=white)](https://go.dev) +[![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)](LICENSE) +[![Docker](https://img.shields.io/badge/Docker-Hub-2496ED?style=for-the-badge&logo=docker&logoColor=white)](https://hub.docker.com/r/akashyadav758/free-gemini-api) + +**No API Keys. No Token Costs. No Complex Setup. Zero Watermarks.** + +*A high-performance Go reverse-proxy API server wrapping the consumer Gemini portal (`gemini.google.com`). Access text generation, multi-turn chat, image creation (Imagen 3), video creation (Gemini Video), video-to-video styling, and music generation through a simple unified REST interface.* + +[Architectural Benefits](#-architectural-benefits) • [Key Features](#-key-features) • [Quick Start](#-quick-start) • [API Endpoints](#-api-endpoints) • [SDK Integration](#-sdk-integration) • [Architecture](#-architecture) + +
+ +--- + +## 🚀 Architectural Benefits + +This project is engineered to solve the real-world operational challenges of web-scraping and reverse-proxying consumer endpoints, delivering production-grade reliability: + +### 🎨 In-Place Reverse Alpha-Blending Watermark Remover +Google adds visual watermarks to the bottom-right corner of all generated images and videos. The Go API server includes a highly optimized, fully automated native Go implementation ([gemini/watermark.go](file:///Users/akashyadav/Server/Go-Map/Free-gemini-api/free-gemini-api-kodelyx/gemini/watermark.go)) that executes in-process immediately upon download. +* **The Mathematics:** It reverses the blending equation: + $$\text{watermarked} = \alpha \times \text{logo} + (1 - \alpha) \times \text{original}$$ + $${\text{original}} = \frac{\text{watermarked} - \alpha \times \text{logo}}{1 - \alpha}$$ +* **Layout Adaptability:** Dynamically identifies coordinates and dimensions based on the file aspect ratio (Portrait `720x1280` maps to a `48px` watermark; standard landscape/large scales dynamically map to a `96px` watermark), yielding perfectly clean, professional-grade media outputs without black borders or distortion. +* **Zero Dependencies:** Completely native—runs without requiring Python 3, `numpy`, or `opencv-python`! + +### 🔄 403 Forbidden Auto-Redirect Bypasser +Downloading media from Google's content servers (`googleusercontent.com`) directly often results in `403 Forbidden` errors. This client implements a custom redirect follow loop: +* **The Pipeline:** It disables Go's default automatic HTTP redirect handler, manually intercepts HTTP `302/307` locations, and dynamically injects the user's active session cookies on *every* redirect hop (e.g. cross-domain transfers to `lh3.usercontent` and `fife.usercontent`), guaranteeing 100% download success. + +### 🔌 Zero-Spam Chrome Extension WebSocket Bridge +Keeping cookies updated across server restarts is solved via our lightweight Chrome Extension (`/extension`): +* **One-Shot Sync:** Utilizing a persistent WebSocket connection, the extension instantly syncs active cookies on startup or when the browser detects a change, completely avoiding heavy periodic background alarm polls and preserving laptop battery. + +### 🔍 Parser-Level Deduplication & Rapid Refusal Detection +* **Deduplication:** The JSON response parser identifies and filters out duplicate image/video references returned by the Gemini consumer API, saving bandwidth and disk space. +* **Refusal Detection:** Recognizes generation limit responses early (e.g., *“I can't create more videos for you today...”*), immediately terminating the long-polling routine and returning structured JSON failures instead of hanging for minutes. + +--- + +## 🔥 Comparison + +| Feature | Standard Cloud API | **Free Gemini API** | +|:---|:---|:---| +| **Pricing** | Pay per token / image / video | **100% Free** | +| **API Key Required** | Yes | **No** (Uses Cookie Session) | +| **Video Generation** | Highly restricted (Expensive) | **Gemini Video Enabled** (Standard Daily Quota) | +| **Image Generation** | Cost per image | **Imagen 3 Enabled** (Unlimited - Uses Gemini 3.5) | +| **Watermark Removal** | None (Or paid edit) | **Automatic In-Place Clean** | +| **Setup Overhead** | Complex SDK configurations | **Single REST API / Docker Container** | + +--- + +## ✨ Key Features + +* **AI Chat:** Multi-turn conversational memory with Server-Sent Events (SSE) streaming support (OpenAI-compatible `data: [DONE]` format). +* **Imagen 3 Image Gen:** Generate stunning visuals from text prompts, perform image-to-image editing (e.g., altering clothing, backgrounds, or assets), or upload up to 10 images simultaneously for multi-image analysis. +* **Gemini Video Gen:** Generate text-to-video clips, image-to-video reference animations, or perform complex video-to-video visual edits (e.g., turning a beach video loop into a Studio Ghibli hand-drawn anime style). +* **Music Generation:** Produce vocal and instrumental tracks on-demand, returned as local static URL references. +* **Automated Session Hot-Reload:** Hot-reloads session settings and tokens on cookie sync updates, allowing uninterrupted server operation. + +--- + +## 🏁 Quick Start + +### Step 1: Install Chrome Cookie Bridge Extension +1. Open Google Chrome and go to `chrome://extensions/`. +2. Enable **Developer mode** (top-right toggle). +3. Click **Load unpacked** (top-left) and select the `extension` folder located in this repository. +4. Log into [gemini.google.com](https://gemini.google.com) on this browser instance. +5. The extension will automatically detect your active session and sync your cookies to the Go backend WebSocket server (default port `9222`). + +--- + +### Step 2: Run the Server + +Choose your preferred deployment method below: + +#### Option A: Pre-built Binary (Recommended & Easiest) +1. Download the latest release binary matching your operating system from the GitHub Releases. +2. Make the binary executable and launch it: + * **macOS / Linux:** + ```bash + chmod +x free-gemini-api-mac-arm64 + ./free-gemini-api-mac-arm64 + ``` + * **Windows:** Double-click on `free-gemini-api-win-x64.exe` or run it from CMD/PowerShell. + +#### Option B: From Source (For Local Dev) +Ensure you have Go 1.21+ installed on your system. Watermark removal and all endpoints run natively without any Python requirements! +```bash +# Run the Go server +go run . +``` + +#### Option C: Docker Setup +Run the pre-built Docker container: +```bash +docker run -d --name free-gemini-api \ + -e WS_PORT=9222 \ + -e PORT=8000 \ + -p 8000:8000 \ + -p 9222:9222 \ + akashyadav758/free-gemini-api:latest +``` + -e WS_PORT=9222 \ + -e PORT=8000 \ + -p 8000:8000 \ + -p 9222:9222 \ + akashyadav758/free-gemini-api:latest +``` + +--- + +### Step 3: Environment Variables (`.env`) +Create a `.env` file in the root of the project to customize connection configurations: +```env +WS_PORT=8001 # WebSocket cookie bridge port (configured in extension) +PORT=8000 # Port on which this Go API server runs +``` + +--- + +## 📡 API Endpoints + +### 1. `POST /chat` — Unified Interface +Handles text conversations, streaming, image generation, multi-image analysis, and video processing. + +#### parameters + +| Field | Type | Description | +|:---|:---|:---| +| `prompt` | `string` | **Required.** The instruction or text query. | +| `user_id` | `string` | Optional. Isolated session identity. | +| `new_chat` | `bool` | Optional. Clear conversational history context. | +| `stream` | `bool` | Optional. Stream responses back via SSE. | +| `image` | `file(s)` | Optional. Multi-part form image upload (supports up to 10 files). | + +--- + +### End-Point Integrations & Examples + +#### A. Text Generation (Standard JSON) +```bash +curl -X POST http://localhost:8000/chat \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "Explain quantum computing in three clear bullet points.", + "user_id": "test_user_1", + "new_chat": true + }' +``` + +#### B. Streaming Text Chat (Server-Sent Events) +```bash +curl -N -X POST http://localhost:8000/chat \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "Write a short creative story about a developer and an AI assistant.", + "user_id": "test_user_1", + "stream": true + }' +``` + +#### C. Imagen 3 Image Generation (Text → Image) +Prompt the system to generate pictures, which are automatically downloaded, cleaned of watermarks, and saved locally: +```bash +curl -X POST http://localhost:8000/chat \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "Generate a beautiful landscape image of a sunset over snow-covered mountains, highly detailed.", + "user_id": "test_user_1", + "new_chat": true + }' +``` + +#### D. Image Editing & Variations (Image + Text Prompt) +Upload an existing image and tell the AI to edit it in place: +```bash +curl -X POST http://localhost:8000/chat \ + -F "prompt=Change the background of this image to a busy neon cyberpunk street" \ + -F "image=@/path/to/my_photo.png" \ + -F "user_id=test_user_1" \ + -F "new_chat=true" +``` + +#### E. Gemini Video Generation (Image → Video / Text → Video) +Provide an image reference and animate it, or generate fresh video segments from text: +```bash +curl -X POST http://localhost:8000/chat \ + -F "prompt=Animate this scene: make the ocean waves crash dynamically on the shore with smooth cinematic motions" \ + -F "image=@/path/to/beach_scene.png" \ + -F "user_id=test_user_1" \ + -F "new_chat=true" +``` + +#### F. Video-to-Video Visual Styling (Video File Input) +Upload a video and completely rewrite its artistic style: +```bash +curl -X POST http://localhost:8000/chat \ + -F "prompt=Edit this video: Convert the entire visual style of this video into a beautiful Studio Ghibli hand-drawn anime style." \ + -F "image=@/path/to/original_video.mp4;type=video/mp4" \ + -F "user_id=test_user_1" \ + -F "new_chat=true" +``` + +--- + +### 2. `POST /music` — Song & Audio Track Generation +Generates audio tracks from description prompts. +```bash +curl -X POST http://localhost:8000/music \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": "A relaxing lofi chillhop beat with smooth piano and rain sounds", + "user_id": "test_user_1", + "new_chat": true + }' +``` + +--- + +### Response Structure (JSON Example) +```json +{ + "text": "I have successfully generated your media loop based on the prompt...", + "conversation_id": "c_3eeb67f56f0c82f2", + "response_id": "r_5fd71304b0a2190b", + "choice_id": "rc_82705406bcc3a2f7", + "images": [ + "http://localhost:8000/static/img_r_5fd71304b0a2190b_0.png" + ], + "videos": [ + "http://localhost:8000/static/vid_r_5fd71304b0a2190b_0.mp4" + ], + "music": [], + "elapsed": 43.56 +} +``` +*(All generated files served dynamically from `/static/*` have already undergone in-place watermark removal).* + +--- + +## 🐍 SDK Integration + +### Python Example +A production-ready script utilizing Python's `requests` library to interface with the REST server: + +```python +import requests +import json + +BASE_URL = "http://localhost:8000" + +def generate_text(prompt: str, user_id: str = "py_user"): + url = f"{BASE_URL}/chat" + payload = { + "prompt": prompt, + "user_id": user_id, + "new_chat": True + } + headers = {"Content-Type": "application/json"} + + response = requests.post(url, json=payload, headers=headers) + return response.json() + +def edit_image_to_video(prompt: str, image_path: str, user_id: str = "py_user"): + url = f"{BASE_URL}/chat" + files = { + "image": (image_path, open(image_path, "rb"), "image/png") + } + data = { + "prompt": prompt, + "user_id": user_id, + "new_chat": "true" + } + + response = requests.post(url, files=files, data=data) + return response.json() + +if __name__ == "__main__": + # Generate simple text + print("Sending text prompt...") + text_res = generate_text("Explain machine learning in one sentence.") + print("Response:", text_res["text"]) + + # Generate / Edit Image-to-Video + # print(edit_image_to_video("Animate a flying eagle in the clouds", "eagle.png")) +``` + +### Node.js Example (Streaming Support) +An asynchronous ES6 script displaying streaming chunks using standard node-fetch: + +```javascript +const BASE_URL = "http://localhost:8000"; + +async function streamStory(prompt) { + const response = await fetch(`${BASE_URL}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt: prompt, + stream: true, + user_id: "node_user", + new_chat: true + }) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + // Print SSE stream lines + console.log(chunk); + } +} + +streamStory("Write a short futuristic sci-fi paragraph."); +``` + +--- + +## 🏗️ Architecture + +``` + ┌────────────────────────┐ + │ REST Client │ + │ (Python, Node, cURL) │ + └───────────┬────────────┘ + │ + │ HTTP REST / SSE (Port 8000) + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ Go API Gateway (Fiber v3 Server) │ +│ │ +│ ┌────────────────┐ ┌──────────────────┐ ┌────────────────┐ ┌────────────┐ │ +│ │ Unified Chat │ │ Streaming Engine │ │ Media Down- │ │ In-Place │ │ +│ │ Endpoints │ │ (Server-Sent │ │ loader & Cookie│ │ Watermark │ │ +│ │ (POST /chat) │ │ Events) │ │ Injector │ │ Processor │ │ +│ └───────┬────────┘ └────────┬─────────┘ └────────┬───────┘ └──────┬─────┘ │ +└──────────┼───────────────────────┼────────────────────────┼────────────────────┼────────┘ + │ │ │ │ + │ JSON RPC payload │ Chunk Streams │ Follows manual │ Executes + │ │ │ redirects │ watermark.go + ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ Google Gemini Web API Backend │ +│ (consumer.rpc.StreamGenerate / hNvQHb video poller / Imagen 3 Engine) │ +└─────────────────────────────────────────────────────────▲───────────────────────────────┘ + │ + │ Live Cookie Synchronization + │ (WebSocket Bridge Port 9222) + ┌────────┴────────┐ + │ Chrome Extension│ + │ Cookie Bridge │ + └─────────────────┘ +``` + +### Directory Structure +``` +├── main.go # Fiber HTTP engine startup, Universal REST routers +├── .env # Port and WebSocket bridge configs +├── cookies.json # Live cookie storage, written dynamically via extension +├── gemini/ +│ ├── client.go # Session initialization, API calls, redirect tracking +│ ├── models.go # API schemas and structures +│ ├── monitor.go # WebSocket bridge and cookies listener +│ └── watermark.go # Native Go reverse alpha-blending watermark remover +└── extension/ + ├── manifest.json # Chrome browser extension manifest definitions + ├── background.js # WebSocket connection to Go backend, cookie event handlers + ├── popup.html # Interactive developer status panel UI + └── popup.js # Local settings controller +``` + +--- + +## ⚠️ Limits & Notes + +* **Video Quota limits:** Gemini Video generation is limited to standard consumer daily accounts (~3-5 videos daily). The system returns an immediate refusal object with `videos: null` once limits are triggered. +* **WebSocket Cookie Sync:** Ensure you load the extension located under `/extension` in your browser to automatically stream and sync active cookies to the backend Go server on port `9222`. + +--- + +
+ +**⭐ Star this repository if this project helps you! ⭐** + +*Disclaimer: This is an unofficial proxy wrapper built for educational and personal research projects. Adhere to Google Terms of Service.* + +
diff --git a/free-gemini-api/extension/background.js b/free-gemini-api/extension/background.js new file mode 100644 index 0000000000000000000000000000000000000000..fd7d1f156a3b3c2cedef936335052b17a4530059 --- /dev/null +++ b/free-gemini-api/extension/background.js @@ -0,0 +1,248 @@ +/** + * Free Gemini API Sync — Background Service Worker + * Proactively keeps Google Gemini cookies fresh and auto-syncs them on change. + */ + +const LOCAL_WS_URL = 'ws://127.0.0.1:9222'; +let ws = null; +let lastSyncTime = null; +let hasSyncedOnce = false; +let syncDebounceTimeout = null; +let isRefreshingTab = false; + +chrome.runtime.onInstalled.addListener(init); +chrome.runtime.onStartup.addListener(init); + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name === 'reconnect') connectToBackend(); + if (alarm.name === 'keepAlive') keepAlive(); + if (alarm.name === 'sessionKeepAlive') { + console.log('[Gemini Sync] Running periodic session keep-alive refresh...'); + ensureGeminiTabAndSync(true); // Quietly refresh session + } +}); + +async function init() { + connectToBackend(); + // Keep-alive ping every 25 seconds (just ping, no cookies) + chrome.alarms.create('keepAlive', { periodInMinutes: 0.4 }); + // Proactively refresh Gemini session tab every 15 minutes to rotate cookies + chrome.alarms.create('sessionKeepAlive', { periodInMinutes: 15 }); + + const data = await chrome.storage.local.get(['lastSyncTime']); + if (data.lastSyncTime) lastSyncTime = data.lastSyncTime; +} + +function connectToBackend() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { + return; + } + + console.log('[Gemini Sync] Connecting to local backend at:', LOCAL_WS_URL); + hasSyncedOnce = false; + + try { + ws = new WebSocket(LOCAL_WS_URL); + } catch (e) { + console.error('[Gemini Sync] WS Connection Error:', e); + scheduleReconnect(); + return; + } + + ws.onopen = () => { + console.log('[Gemini Sync] Connected to Go Backend!'); + chrome.alarms.clear('reconnect'); + // Sync ONCE on connect + performSync(); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'trigger_sync') { + console.log('[Gemini Sync] Backend requested fresh cookies. Activating session refresh...'); + ensureGeminiTabAndSync(false); + } + } catch (e) { + console.error(e); + } + }; + + ws.onclose = () => { + console.log('[Gemini Sync] Connection closed. Reconnecting...'); + scheduleReconnect(); + }; + + ws.onerror = (err) => { + console.error('[Gemini Sync] WebSocket Error:', err); + }; +} + +function scheduleReconnect() { + chrome.alarms.create('reconnect', { delayInMinutes: 0.083 }); // ~5s +} + +function keepAlive() { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } else { + connectToBackend(); + } +} + +const ALLOWED_COOKIE_NAMES = new Set([ + '__Secure-1PSID', + '__Secure-3PSID', + '__Secure-1PAPISID', + '__Secure-3PAPISID', + '__Secure-1PSIDTS', + '__Secure-3PSIDTS', + '__Secure-1PSIDCC', + '__Secure-3PSIDCC', + 'SID', + 'HSID', + 'SSID', + 'APISID', + 'SAPISID', + 'SIDCC', + 'OSID', + '__Secure-OSID' +]); + +// Performs the actual extraction and WebSocket transfer +function performSync() { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + chrome.cookies.getAll({}, (cookies) => { + const googleCookies = cookies.filter(c => + (c.domain === '.google.com' || c.domain === '.google.co' || c.domain === 'gemini.google.com' || c.domain.includes('googleusercontent.com')) && + (ALLOWED_COOKIE_NAMES.has(c.name) || c.domain.includes('googleusercontent.com')) + ); + + const formatted = googleCookies.map(c => { + let exp = c.expirationDate; + if (!exp || c.session) { + exp = Math.floor(Date.now() / 1000) + 31536000; // Default 1 year + } + + let sameSite = c.sameSite || 'unspecified'; + if (sameSite === 'no_restriction') sameSite = 'none'; + + return { + domain: c.domain, + expirationDate: exp, + hostOnly: c.hostOnly, + httpOnly: c.httpOnly, + name: c.name, + path: c.path, + sameSite: sameSite, + secure: c.secure, + session: c.session, + storeId: c.storeId || '0', + value: c.value + }; + }); + + console.log(`[Gemini Sync] Syncing ${formatted.length} essential cookies to backend`); + ws.send(JSON.stringify({ + type: 'cookies_payload', + cookies: formatted + })); + + hasSyncedOnce = true; + lastSyncTime = Date.now(); + chrome.storage.local.set({ lastSyncTime }); + chrome.runtime.sendMessage({ type: 'SYNC_UPDATE', success: true, count: formatted.length }).catch(() => {}); + }); +} + +// Proactive refresh mechanism: opens or reloads Gemini tab in background to force cookie rotation +async function ensureGeminiTabAndSync(quietMode = false) { + if (isRefreshingTab) return; + isRefreshingTab = true; + + try { + const tabs = await chrome.tabs.query({ url: '*://gemini.google.com/*' }); + + if (tabs.length > 0) { + console.log('[Gemini Sync] Gemini tab exists. Reloading to rotate cookies...'); + await chrome.tabs.reload(tabs[0].id); + if (!quietMode) { + // Force focus on the existing tab to wake it up and ensure quick reload/sync + await chrome.tabs.update(tabs[0].id, { active: true }); + + // Also bring the window to the front if minimized + const tab = tabs[0]; + if (tab.windowId) { + await chrome.windows.update(tab.windowId, { focused: true }); + } + } + } else { + console.log('[Gemini Sync] No Gemini tab found. Launching a session...'); + // Open in foreground (active: true) if not quietMode to guarantee immediate load + await chrome.tabs.create({ url: 'https://gemini.google.com', active: !quietMode }); + } + + // Safety timeout to reset the refreshing flag if something hangs + setTimeout(() => { + isRefreshingTab = false; + }, 15000); + + } catch (e) { + console.error('[Gemini Sync] Error during session tab refresh:', e); + isRefreshingTab = false; + performSync(); // Fallback sync + } +} + +// ─── Tab Load Listener ─────────────────────────────────────────────── +// Listen for Gemini tab loads/reloads to capture fresh cookies automatically +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === 'complete' && tab.url && tab.url.includes('gemini.google.com')) { + console.log('[Gemini Sync] Gemini tab loaded completely. Performing sync...'); + performSync(); + isRefreshingTab = false; + } +}); + + +// ─── Real-Time Cookie Listener ────────────────────────────────────── +// Auto-detect when Google changes or rotates session cookies and push them instantly! +chrome.cookies.onChanged.addListener((changeInfo) => { + const cookie = changeInfo.cookie; + + const isTargetDomain = cookie.domain === '.google.com' || + cookie.domain === '.google.co' || + cookie.domain === 'gemini.google.com' || + cookie.domain.includes('googleusercontent.com'); + + if (isTargetDomain && ALLOWED_COOKIE_NAMES.has(cookie.name)) { + // Skip if it was deleted (unless it is a known rotation) + if (changeInfo.removed) return; + + console.log(`[Gemini Sync] Real-time cookie updated: ${cookie.name}. Scheduling sync...`); + + // Debounce multiple fast updates (since Google updates multiple cookies together) + if (syncDebounceTimeout) clearTimeout(syncDebounceTimeout); + syncDebounceTimeout = setTimeout(() => { + console.log('[Gemini Sync] Running debounced real-time cookie sync...'); + performSync(); + }, 1500); + } +}); + +// Receive message from Popup +chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg.type === 'GET_STATUS') { + sendResponse({ + connected: ws && ws.readyState === WebSocket.OPEN, + lastSyncTime, + hasSyncedOnce + }); + } + if (msg.type === 'FORCE_SYNC') { + ensureGeminiTabAndSync(false); + sendResponse({ ok: true }); + } + return true; +}); diff --git a/free-gemini-api/extension/icon.png b/free-gemini-api/extension/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..58cadc7b72a4959816764e2c979a48d9d3933cc5 Binary files /dev/null and b/free-gemini-api/extension/icon.png differ diff --git a/free-gemini-api/extension/manifest.json b/free-gemini-api/extension/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..70c03237458bddee00e336dd55a509337cf28155 --- /dev/null +++ b/free-gemini-api/extension/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "Gemini Api Agent", + "version": "1.0.0", + "description": "Auto-syncs Google Gemini cookies to your local Free Gemini API server", + "permissions": ["cookies", "storage", "alarms", "tabs"], + "host_permissions": [ + "*://*.google.com/*", + "*://*.google.co/*", + "*://*.googleusercontent.com/*" + ], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_title": "Gemini Api Agent", + "default_icon": "icon.png" + }, + "icons": { + "16": "icon.png", + "48": "icon.png", + "128": "icon.png" + } +} diff --git a/free-gemini-api/extension/popup.html b/free-gemini-api/extension/popup.html new file mode 100644 index 0000000000000000000000000000000000000000..ac3b746637bb72a46860f317b699dd8a4670e3e9 --- /dev/null +++ b/free-gemini-api/extension/popup.html @@ -0,0 +1,159 @@ + + + + + + + +
+
+ +

Gemini Api Agent

+
+ +
+
+ Server Connection + + + Disconnected + +
+
+ Last Sync + Never +
+
+ + + +
+ + + diff --git a/free-gemini-api/extension/popup.js b/free-gemini-api/extension/popup.js new file mode 100644 index 0000000000000000000000000000000000000000..017765ca7fa4a5b3c8a25c830233d89b05253799 --- /dev/null +++ b/free-gemini-api/extension/popup.js @@ -0,0 +1,52 @@ +document.addEventListener('DOMContentLoaded', () => { + const statusBadge = document.getElementById('status-badge'); + const statusText = document.getElementById('status-text'); + const lastSync = document.getElementById('last-sync'); + const syncBtn = document.getElementById('sync-btn'); + + function updateUI() { + chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (response) => { + if (chrome.runtime.lastError) return; + if (!response) return; + + if (response.connected) { + statusBadge.className = 'badge connected'; + statusText.innerText = 'Connected'; + statusText.style.color = '#10b981'; + } else { + statusBadge.className = 'badge disconnected'; + statusText.innerText = 'Disconnected'; + statusText.style.color = '#ef4444'; + } + + if (response.lastSyncTime) { + const date = new Date(response.lastSyncTime); + lastSync.innerText = date.toLocaleTimeString(); + } else { + lastSync.innerText = 'Never'; + } + }); + } + + // Initial update + updateUI(); + + // Listen for real-time updates from background service worker + chrome.runtime.onMessage.addListener((msg) => { + if (msg.type === 'SYNC_UPDATE') { + updateUI(); + } + }); + + syncBtn.addEventListener('click', () => { + syncBtn.disabled = true; + syncBtn.innerText = 'Syncing...'; + chrome.runtime.sendMessage({ type: 'FORCE_SYNC' }, () => { + setTimeout(() => { + syncBtn.disabled = false; + syncBtn.innerText = 'Force Sync Cookies'; + updateUI(); + }, 800); + }); + }); +}); diff --git a/free-gemini-api/gemini/client.go b/free-gemini-api/gemini/client.go new file mode 100644 index 0000000000000000000000000000000000000000..f6d3d671ff4ca384d34889b93c4cee04f934f8e8 --- /dev/null +++ b/free-gemini-api/gemini/client.go @@ -0,0 +1,1203 @@ +package gemini + +import ( + "encoding/json" + "fmt" + "io" + "log" + "math/rand" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + http "github.com/bogdanfinn/fhttp" + tls_client "github.com/bogdanfinn/tls-client" + "github.com/bogdanfinn/tls-client/profiles" +) + +// Flash model header value (hardcoded since we only use Flash) +const flashModelID = "56fdd199312815e2" + +var flashHeaderValue = fmt.Sprintf(`[1,null,null,null,"%s",null,null,0,[4],null,null,2]`, flashModelID) + +type GeminiClient struct { + client tls_client.HttpClient + cookiesFile string + SNlM0e string + FSID string + BL string + ReqID int + ConversationID string + ResponseID string + ChoiceID string + IsInitialized bool + RawCookies string +} + +type CookieObject struct { + Domain string `json:"domain"` + ExpirationDate float64 `json:"expirationDate,omitempty"` + HostOnly bool `json:"hostOnly,omitempty"` + HttpOnly bool `json:"httpOnly,omitempty"` + Name string `json:"name"` + Path string `json:"path"` + SameSite string `json:"sameSite,omitempty"` + Secure bool `json:"secure,omitempty"` + Session bool `json:"session,omitempty"` + StoreId string `json:"storeId,omitempty"` + Value string `json:"value"` +} + +func NewClient(cookiesFile string) (*GeminiClient, error) { + jar := tls_client.NewCookieJar() + options := []tls_client.HttpClientOption{ + tls_client.WithTimeoutSeconds(300), + tls_client.WithClientProfile(profiles.Chrome_146), + tls_client.WithCookieJar(jar), + } + + client, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), options...) + if err != nil { + return nil, err + } + + c := &GeminiClient{ + client: client, + cookiesFile: cookiesFile, + ReqID: rand.Intn(9000000) + 1000000, + } + + if err := c.loadCookies(); err != nil { + return nil, fmt.Errorf("failed to load cookies from %s: %w", cookiesFile, err) + } + + return c, nil +} + +func (c *GeminiClient) loadCookies() error { + data, err := os.ReadFile(c.cookiesFile) + if err != nil { + return err + } + + // JSON Array Format + var cookieList []CookieObject + if err := json.Unmarshal(data, &cookieList); err == nil && len(cookieList) > 0 { + log.Printf("Loading cookies in JSON array format (%d cookies)", len(cookieList)) + var rawParts []string + for _, ck := range cookieList { + domain := strings.TrimPrefix(ck.Domain, ".") + u, _ := url.Parse("https://" + domain) + c.client.GetCookieJar().SetCookies(u, []*http.Cookie{ + { + Name: ck.Name, + Value: ck.Value, + Domain: ck.Domain, + Path: ck.Path, + Secure: ck.Secure, + }, + }) + rawParts = append(rawParts, fmt.Sprintf("%s=%s", ck.Name, ck.Value)) + } + c.RawCookies = strings.Join(rawParts, "; ") + return nil + } + + // Fallback: Legacy Object Format + var cookieData struct { + Cookies string `json:"cookies"` + UpdatedAt float64 `json:"updated_at"` + } + + if err := json.Unmarshal(data, &cookieData); err == nil && cookieData.Cookies != "" { + log.Printf("Loading cookies in legacy string format") + c.RawCookies = cookieData.Cookies + u, _ := url.Parse("https://gemini.google.com") + cookies := parseCookieString(cookieData.Cookies, ".google.com") + c.client.GetCookieJar().SetCookies(u, cookies) + return nil + } + + return fmt.Errorf("unsupported cookie format in %s", c.cookiesFile) +} + +func parseCookieString(raw, domain string) []*http.Cookie { + var cookies []*http.Cookie + parts := strings.Split(raw, "; ") + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) == 2 { + cookies = append(cookies, &http.Cookie{ + Name: strings.TrimSpace(kv[0]), + Value: strings.TrimSpace(kv[1]), + Domain: domain, + Path: "/", + }) + } + } + return cookies +} + +func (c *GeminiClient) InitSession() error { + req, err := http.NewRequest("GET", "https://gemini.google.com/app", nil) + if err != nil { + return err + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") + req.Header.Set("sec-ch-ua", `"Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"`) + req.Header.Set("sec-ch-ua-arch", `"arm"`) + req.Header.Set("sec-ch-ua-bitness", `"64"`) + req.Header.Set("sec-ch-ua-full-version", `"144.0.7559.133"`) + req.Header.Set("sec-ch-ua-platform", `"macOS"`) + req.Header.Set("Referer", "https://gemini.google.com/") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("Gemini app failed: Status %d. Check if cookies are expired.", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + bodyStr := string(body) + + snlm0eRe := regexp.MustCompile(`"SNlM0e":"(.*?)"`) + fsidRe := regexp.MustCompile(`"FdrFJe":"(.*?)"`) + cfb2hRe := regexp.MustCompile(`"cfb2h":"(.*?)"`) + + if m := snlm0eRe.FindStringSubmatch(bodyStr); len(m) > 1 { + c.SNlM0e = m[1] + log.Printf("Session Initialized. Token size: %d", len(c.SNlM0e)) + } else { + if strings.Contains(bodyStr, "ServiceLogin") || strings.Contains(bodyStr, "login.google.com") { + log.Println("⚠️ Session expired detected. Requesting Chrome Extension to proactively rotate cookies...") + BroadcastCookieRefresh() + + log.Println("⏳ Sleeping 5 seconds waiting for the extension to push fresh cookies...") + time.Sleep(5 * time.Second) + + log.Println("🔄 Reloading updated cookies...") + if err := c.loadCookies(); err != nil { + return fmt.Errorf("session expired: Google redirected to login. Failed to reload cookies: %w", err) + } + + log.Println("🔄 Retrying InitSession with fresh cookies...") + return c.retryInitSession() + } + return fmt.Errorf("SNlM0e not found - Google might have changed the UI or blocked the request") + } + + if m := fsidRe.FindStringSubmatch(bodyStr); len(m) > 1 { + c.FSID = m[1] + } + + if m := cfb2hRe.FindStringSubmatch(bodyStr); len(m) > 1 { + c.BL = m[1] + } + + c.IsInitialized = true + return nil +} + +func (c *GeminiClient) retryInitSession() error { + req, err := http.NewRequest("GET", "https://gemini.google.com/app", nil) + if err != nil { + return err + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") + req.Header.Set("sec-ch-ua", `"Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"`) + req.Header.Set("sec-ch-ua-arch", `"arm"`) + req.Header.Set("sec-ch-ua-bitness", `"64"`) + req.Header.Set("sec-ch-ua-full-version", `"144.0.7559.133"`) + req.Header.Set("sec-ch-ua-platform", `"macOS"`) + req.Header.Set("Referer", "https://gemini.google.com/") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("Gemini app failed on retry: Status %d. Check if cookies are expired.", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + bodyStr := string(body) + + snlm0eRe := regexp.MustCompile(`"SNlM0e":"(.*?)"`) + fsidRe := regexp.MustCompile(`"FdrFJe":"(.*?)"`) + cfb2hRe := regexp.MustCompile(`"cfb2h":"(.*?)"`) + + if m := snlm0eRe.FindStringSubmatch(bodyStr); len(m) > 1 { + c.SNlM0e = m[1] + log.Printf("Session Initialized on Retry. Token size: %d", len(c.SNlM0e)) + } else { + if strings.Contains(bodyStr, "ServiceLogin") || strings.Contains(bodyStr, "login.google.com") { + return fmt.Errorf("session expired: Google redirected to login on retry. Please refresh cookies") + } + return fmt.Errorf("SNlM0e not found on retry - Google might have changed the UI or blocked the request") + } + + if m := fsidRe.FindStringSubmatch(bodyStr); len(m) > 1 { + c.FSID = m[1] + } + + if m := cfb2hRe.FindStringSubmatch(bodyStr); len(m) > 1 { + c.BL = m[1] + } + + c.IsInitialized = true + return nil +} + +func (c *GeminiClient) ensureInit() error { + if !c.IsInitialized { + return c.InitSession() + } + return nil +} + +// executeWithRetry executes a request function. If it fails, it refreshes session and retries. +func (c *GeminiClient) executeWithRetry(actionName string, runFunc func() error) error { + err := runFunc() + if err == nil { + return nil + } + + log.Printf("⚠️ [%s] Request failed: %v. Triggering automatic cookie sync and session refresh...", actionName, err) + + // Reset initialized status to force session re-initialization + c.IsInitialized = false + + // InitSession will broadcast cookie refresh, sleep 5s, reload cookies, and retry the app session init + if initErr := c.InitSession(); initErr != nil { + log.Printf("❌ [%s] Session recovery failed: %v", actionName, initErr) + return fmt.Errorf("%s failed and auto-heal session recovery failed: %w", actionName, initErr) + } + + log.Printf("🔄 [%s] Session successfully auto-healed! Retrying request...", actionName) + return runFunc() +} + +func (c *GeminiClient) Ask(prompt string) (*GeminiResponse, error) { + return c.AskWithTool(prompt, "") +} + +// AskStream sends a prompt and streams text chunks via callback as they arrive +func (c *GeminiClient) AskStream(prompt string, onChunk func(text string)) (*GeminiResponse, error) { + start := time.Now() + + var response *GeminiResponse + execErr := c.executeWithRetry("AskStream", func() error { + var err error + response, err = c.executeStreamRequest(prompt, onChunk) + return err + }) + + if execErr != nil { + return nil, execErr + } + + response.Elapsed = time.Since(start).Seconds() + return response, nil +} + +// executeStreamRequest performs the actual streaming call (wrapped by executeWithRetry) +func (c *GeminiClient) executeStreamRequest(prompt string, onChunk func(text string)) (*GeminiResponse, error) { + if err := c.ensureInit(); err != nil { + return nil, err + } + + // Build request (same payload as sendRequest) + c.ReqID += 100 + reqID := fmt.Sprintf("%d", c.ReqID) + + msgInner := []interface{}{prompt, 0, nil, nil, nil, nil, 0} + msgLang := []string{"en-GB"} + msgContext := []interface{}{c.ConversationID, c.ResponseID, c.ChoiceID, nil, nil, nil, nil, nil, nil, ""} + msgStruct := []interface{}{msgInner, msgLang, msgContext} + + msgJSON, _ := json.Marshal(msgStruct) + fReqVal := []interface{}{nil, string(msgJSON)} + fReqJSON, _ := json.Marshal(fReqVal) + + data := url.Values{} + data.Set("f.req", string(fReqJSON)) + data.Set("at", c.SNlM0e) + + urlStr := "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate" + + req, err := http.NewRequest("POST", urlStr, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Add("bl", c.BL) + q.Add("_reqid", reqID) + q.Add("rt", "c") + if c.FSID != "" { + q.Add("f.sid", c.FSID) + } + req.URL.RawQuery = q.Encode() + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("Origin", "https://gemini.google.com") + req.Header.Set("Referer", "https://gemini.google.com/") + req.Header.Set("X-Same-Domain", "1") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("API error: Status %d", resp.StatusCode) + } + + // Read response and stream text chunks + bodyBytes, _ := io.ReadAll(resp.Body) + rawBody := string(bodyBytes) + + response := &GeminiResponse{} + prevText := "" + + // Parse full body for chunks + lines := strings.Split(rawBody, "\n") + for i := 0; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if !strings.HasPrefix(line, "[[\"wrb.fr\"") { + continue + } + + // Parse this chunk + tempResp := &GeminiResponse{} + parseResponse(line, tempResp) + + if tempResp.Text != "" && tempResp.Text != prevText { + // Calculate the new text delta + delta := tempResp.Text + if strings.HasPrefix(delta, prevText) { + delta = delta[len(prevText):] + } + if delta != "" { + onChunk(delta) + } + prevText = tempResp.Text + } + + // Keep updating the response + if tempResp.ConversationID != "" { + response.ConversationID = tempResp.ConversationID + response.ResponseID = tempResp.ResponseID + response.ChoiceID = tempResp.ChoiceID + } + if tempResp.Text != "" { + response.Text = tempResp.Text + } + if len(tempResp.Images) > 0 { + response.Images = tempResp.Images + } + } + + // Final full parse to catch everything + parseResponse(rawBody, response) + + if response.ConversationID != "" { + c.ConversationID = response.ConversationID + c.ResponseID = response.ResponseID + c.ChoiceID = response.ChoiceID + } + + // Video auto-detect + if response.ConversationID != "" && + (strings.Contains(response.Text, "video_gen_chip") || strings.Contains(response.Text, "generating your video")) { + c.pollVideoURL(response) + } + + if response.ConversationID == "" && response.Text == "" { + return nil, fmt.Errorf("empty stream response received") + } + + return response, nil +} + +func (c *GeminiClient) UploadImage(imageBytes []byte, filename string, mimeType string) (string, error) { + if err := c.ensureInit(); err != nil { + return "", err + } + + urlStr := "https://push.clients6.google.com/upload/" + req, err := http.NewRequest("POST", urlStr, strings.NewReader("")) + if err != nil { + return "", err + } + + req.Header.Set("x-goog-upload-protocol", "resumable") + req.Header.Set("x-goog-upload-command", "start") + req.Header.Set("x-tenant-id", "bard-storage") + req.Header.Set("x-goog-upload-header-content-length", fmt.Sprintf("%d", len(imageBytes))) + req.Header.Set("x-goog-upload-header-content-type", mimeType) + req.Header.Set("push-id", "feeds/mcudyrk2a4khkz") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("Origin", "https://gemini.google.com") + req.Header.Set("Referer", "https://gemini.google.com/") + + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("UploadImage start failed: Status %d", resp.StatusCode) + } + + uploadURL := resp.Header.Get("X-Goog-Upload-Url") + if uploadURL == "" { + uploadURL = resp.Header.Get("x-goog-upload-url") + } + if uploadURL == "" { + return "", fmt.Errorf("UploadImage start failed: missing x-goog-upload-url header") + } + + req2, err := http.NewRequest("POST", uploadURL, strings.NewReader(string(imageBytes))) + if err != nil { + return "", err + } + + req2.Header.Set("x-goog-upload-protocol", "resumable") + req2.Header.Set("x-goog-upload-command", "upload, finalize") + req2.Header.Set("x-goog-upload-offset", "0") + req2.Header.Set("x-tenant-id", "bard-storage") + req2.Header.Set("Content-Type", mimeType) + req2.Header.Set("Origin", "https://gemini.google.com") + req2.Header.Set("Referer", "https://gemini.google.com/") + req2.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + + resp2, err := c.client.Do(req2) + if err != nil { + return "", err + } + defer resp2.Body.Close() + + if resp2.StatusCode != 200 { + return "", fmt.Errorf("UploadImage finalize failed: Status %d", resp2.StatusCode) + } + + bodyBytes, _ := io.ReadAll(resp2.Body) + return string(bodyBytes), nil +} + +// ImageInput holds data for a single image to upload +type ImageInput struct { + Data []byte + Filename string + MimeType string +} + +func (c *GeminiClient) AskWithImage(prompt string, imageBytes []byte, filename string, mimeType string) (*GeminiResponse, error) { + return c.AskWithImages(prompt, []ImageInput{{Data: imageBytes, Filename: filename, MimeType: mimeType}}) +} + +func (c *GeminiClient) AskWithImages(prompt string, images []ImageInput) (*GeminiResponse, error) { + start := time.Now() + + if err := c.ensureInit(); err != nil { + return nil, err + } + + // Upload all images and build imageRef array + var imageRef []interface{} + uploadErr := c.executeWithRetry("UploadImages", func() error { + imageRef = nil // Reset list for retry + for i, img := range images { + mediaRefPath, err := c.UploadImage(img.Data, img.Filename, img.MimeType) + if err != nil { + return err + } + mediaRefPath = strings.TrimSpace(mediaRefPath) + log.Printf("📸 Image %d/%d uploaded: ref=[%s]", i+1, len(images), mediaRefPath) + + imageRef = append(imageRef, []interface{}{ + []interface{}{mediaRefPath, 1, nil, img.MimeType}, + img.Filename, + nil, nil, nil, nil, nil, nil, + []int{0}, + }) + } + return nil + }) + + if uploadErr != nil { + return nil, uploadErr + } + + var response *GeminiResponse + var err error + execErr := c.executeWithRetry("AskWithImages", func() error { + _, response, err = c.sendRequest(prompt, "", imageRef) + if err != nil { + return err + } + if response.ConversationID == "" && response.Text == "" { + return fmt.Errorf("empty response received with images (possible session expiry)") + } + return nil + }) + + if execErr != nil { + return nil, execErr + } + + if response.ConversationID != "" { + c.ConversationID = response.ConversationID + c.ResponseID = response.ResponseID + c.ChoiceID = response.ChoiceID + } + + // Auto-detect video generation and poll for video URL + if response.ConversationID != "" && + (strings.Contains(response.Text, "video_gen_chip") || strings.Contains(response.Text, "generating your video") || strings.Contains(response.Text, "video is being generated")) { + c.pollVideoURL(response) + } + + response.Elapsed = time.Since(start).Seconds() + return response, nil +} + +// pollVideoURL polls hNvQHb RPC to find the video download URL +func (c *GeminiClient) pollVideoURL(response *GeminiResponse) { + log.Println("🎬 Polling hNvQHb for video download URL...") + vidRe := regexp.MustCompile(`https?://contribution\.usercontent\.google\.com/download\??[^"\\` + "`" + `\s]+`) + + for i := 0; i < 60; i++ { // 60 × 5s = 5 min max + time.Sleep(5 * time.Second) + log.Printf("🎬 Video Poll %d/60...", i+1) + + hPayload := fmt.Sprintf(`["%s",10,null,1,[1],[4],null,1]`, response.ConversationID) + hBody, err := c.CallRPC("hNvQHb", hPayload) + if err != nil { + log.Printf("⚠️ hNvQHb error: %v", err) + continue + } + + hBody = strings.ReplaceAll(hBody, `\\u0026`, "&") + hBody = strings.ReplaceAll(hBody, `\\u003d`, "=") + hBody = strings.ReplaceAll(hBody, `\u0026`, "&") + hBody = strings.ReplaceAll(hBody, `\u003d`, "=") + + if strings.Contains(hBody, "too many requests") || strings.Contains(hBody, "a lot of requests") || + strings.Contains(hBody, "try again later") || strings.Contains(hBody, "couldn't do that") || + strings.Contains(hBody, "can't create more videos") || strings.Contains(hBody, "can\\'t create more videos") || + strings.Contains(hBody, "I can't create") || strings.Contains(hBody, "find videos from the web") || + strings.Contains(hBody, "और ज़्यादा वीडियो") || strings.Contains(hBody, "नहीं जनरेट कर सकता") || + strings.Contains(hBody, "ढूँढ सकता हूँ") || strings.Contains(hBody, "limit") { + log.Println("❌ Video generation refused (rate limit/quota exhausted)") + response.Text = "Video generation failed: daily limit/quota exhausted." + response.Videos = nil + break + } + + // Find ALL matched URLs + matches := vidRe.FindAllString(hBody, -1) + var chosenURL string + for _, m := range matches { + // Skip reference input videos uploaded by our server (which always contain "vid_") + if strings.Contains(m, "vid_") { + log.Printf("⏭️ Skipping matched reference video URL: %s", m[:min(len(m), 100)]) + continue + } + // Skip non-video assets like protobuf context files (ensure it ends with .mp4 or contains filename=video.mp4) + if !strings.Contains(m, ".mp4") && !strings.Contains(m, "filename=video") { + log.Printf("⏭️ Skipping matched non-mp4 asset URL: %s", m[:min(len(m), 100)]) + continue + } + chosenURL = m + break + } + + if chosenURL != "" { + response.Videos = []string{chosenURL} + log.Println("✅ Video URL found!") + break + } + + if strings.Contains(hBody, "Your video is ready") { + log.Println("📝 Video is ready but URL not found in expected format, continuing...") + } + + // Debug: log tail of response + snippet := hBody + if len(snippet) > 200 { + snippet = hBody[len(hBody)-200:] + } + log.Printf("🔍 Poll response (%d chars): ...%s", len(hBody), snippet) + } +} + +func (c *GeminiClient) AskWithTool(prompt string, tool string) (*GeminiResponse, error) { + start := time.Now() + if err := c.ensureInit(); err != nil { + return nil, err + } + + var response *GeminiResponse + var err error + var rawBody string + execErr := c.executeWithRetry("AskWithTool", func() error { + rawBody, response, err = c.sendRequest(prompt, tool, nil) + if err != nil { + return err + } + if response.ConversationID == "" && response.Text == "" { + return fmt.Errorf("empty response received (possible session expiry)") + } + return nil + }) + + if execErr != nil { + return nil, execErr + } + + if tool == "music_gen" && rawBody != "" { + _ = os.WriteFile("music_raw_body.txt", []byte(rawBody), 0644) + log.Println("📝 Saved raw music body to music_raw_body.txt") + } + + if response.ConversationID != "" { + c.ConversationID = response.ConversationID + c.ResponseID = response.ResponseID + c.ChoiceID = response.ChoiceID + } + + // Music fallback: if URL didn't come in first response, quick poll + if tool == "music_gen" && len(response.Music) == 0 && response.ConversationID != "" { + log.Println("🎵 Music URL not in response, polling...") + rawConv := strings.TrimPrefix(response.ConversationID, "c_") + re := regexp.MustCompile(`https?://contribution\.usercontent\.google\.com/download[^"\\` + "`" + `\s]+`) + + for i := 0; i < 6; i++ { + time.Sleep(5 * time.Second) + body, err := c.pollConversation(rawConv) + if err != nil { + continue + } + body = strings.ReplaceAll(body, `\u0026`, "&") + body = strings.ReplaceAll(body, `\u003d`, "=") + if m := re.FindString(body); m != "" { + track := MusicTrack{DownloadURL: m} + if idx := strings.Index(m, "filename="); idx != -1 { + fname := m[idx+9:] + if ai := strings.Index(fname, "&"); ai != -1 { + fname = fname[:ai] + } + fname = strings.ReplaceAll(fname, "_", " ") + fname = strings.TrimSuffix(strings.TrimSuffix(fname, ".mp3"), ".mp4") + track.Title = fname + } + response.Music = append(response.Music, track) + log.Println("✅ Music URL found via poll") + break + } + } + } + + // Auto-detect video generation and poll + if tool == "" && response.ConversationID != "" && + (strings.Contains(response.Text, "video_gen_chip") || strings.Contains(response.Text, "generating your video")) { + c.pollVideoURL(response) + } + + response.Elapsed = time.Since(start).Seconds() + return response, nil +} + +// AskVideo generates a video via chat prompt and polls hNvQHb for download URL. +// Flow: sendRequest(prompt) → poll hNvQHb(convID) for contribution.usercontent URL +func (c *GeminiClient) AskVideo(prompt string) (*GeminiResponse, error) { + start := time.Now() + if err := c.ensureInit(); err != nil { + return nil, err + } + + // Ensure prompt triggers video generation + lower := strings.ToLower(prompt) + if !strings.Contains(lower, "generate a video") && !strings.Contains(lower, "create a video") && !strings.Contains(lower, "make a video") { + prompt = "Generate a video of: " + prompt + } + + var response *GeminiResponse + var err error + execErr := c.executeWithRetry("AskVideo", func() error { + _, response, err = c.sendRequest(prompt, "", nil) + if err != nil { + return err + } + if response.ConversationID == "" && response.Text == "" { + return fmt.Errorf("empty response received for video (possible session expiry)") + } + return nil + }) + + if execErr != nil { + return nil, execErr + } + + if response.ConversationID != "" { + c.ConversationID = response.ConversationID + c.ResponseID = response.ResponseID + c.ChoiceID = response.ChoiceID + } + + log.Printf("🎬 Video response: conv=%s, text=%s", response.ConversationID, response.Text[:min(len(response.Text), 100)]) + + // If no video_gen_chip in response, video wasn't triggered + if !strings.Contains(response.Text, "video_gen_chip") && !strings.Contains(response.Text, "generating your video") { + log.Println("⚠️ Video generation not triggered — Gemini didn't recognize video intent") + response.Elapsed = time.Since(start).Seconds() + return response, nil + } + + if response.ConversationID == "" { + response.Elapsed = time.Since(start).Seconds() + return response, nil + } + + c.pollVideoURL(response) + + response.Elapsed = time.Since(start).Seconds() + return response, nil +} + +// CallRPC sends a batchexecute request for any given RPC ID +func (c *GeminiClient) CallRPC(rpcID, payload string) (string, error) { + c.ReqID += 100 + reqID := fmt.Sprintf("%d", c.ReqID) + + wrapperJSON, _ := json.Marshal([][][]interface{}{{{rpcID, payload, nil, "generic"}}}) + + body := url.Values{} + body.Set("f.req", string(wrapperJSON)) + body.Set("at", c.SNlM0e) + + urlStr := "https://gemini.google.com/_/BardChatUi/data/batchexecute" + + req, err := http.NewRequest("POST", urlStr, strings.NewReader(body.Encode())) + if err != nil { + return "", err + } + + q := req.URL.Query() + q.Add("bl", c.BL) + q.Add("_reqid", reqID) + q.Add("rt", "c") + if c.FSID != "" { + q.Add("f.sid", c.FSID) + } + q.Add("rpcids", rpcID) + q.Add("source-path", "/app") + req.URL.RawQuery = q.Encode() + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("Origin", "https://gemini.google.com") + req.Header.Set("Referer", "https://gemini.google.com/") + req.Header.Set("X-Same-Domain", "1") + + resp, err := c.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + return string(bodyBytes), nil +} + +// pollConversation calls hNvQHb RPC (music-style payload) for music polling +func (c *GeminiClient) pollConversation(convID string) (string, error) { + payload := fmt.Sprintf(`[null,"%s"]`, convID) + return c.CallRPC("hNvQHb", payload) +} + +func (c *GeminiClient) sendRequest(prompt string, tool string, imageRef []interface{}) (string, *GeminiResponse, error) { + c.ReqID += 100 + reqID := fmt.Sprintf("%d", c.ReqID) + + msgInner := []interface{}{prompt, 0, nil, imageRef, nil, nil, 0} + msgLang := []string{"en-GB"} + msgContext := []interface{}{c.ConversationID, c.ResponseID, c.ChoiceID, nil, nil, nil, nil, nil, nil, ""} + msgStruct := []interface{}{msgInner, msgLang, msgContext} + + if tool != "" { + for len(msgStruct) < 33 { + msgStruct = append(msgStruct, nil) + } + msgStruct = append(msgStruct, []string{tool}) + } + + msgJSON, _ := json.Marshal(msgStruct) + fReqVal := []interface{}{nil, string(msgJSON)} + fReqJSON, _ := json.Marshal(fReqVal) + + data := url.Values{} + data.Set("f.req", string(fReqJSON)) + data.Set("at", c.SNlM0e) + + urlStr := "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate" + + req, err := http.NewRequest("POST", urlStr, strings.NewReader(data.Encode())) + if err != nil { + return "", nil, err + } + + q := req.URL.Query() + q.Add("bl", c.BL) + q.Add("_reqid", reqID) + q.Add("rt", "c") + if c.FSID != "" { + q.Add("f.sid", c.FSID) + } + req.URL.RawQuery = q.Encode() + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("Origin", "https://gemini.google.com") + req.Header.Set("Referer", "https://gemini.google.com/") + req.Header.Set("X-Same-Domain", "1") + req.Header.Set("x-goog-ext-525001261-jspb", flashHeaderValue) + req.Header.Set("x-goog-ext-525005358-jspb", `["DIRECT-API-SESSION",1]`) + req.Header.Set("x-goog-ext-73010989-jspb", `[0]`) + + resp, err := c.client.Do(req) + if err != nil { + return "", nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", nil, fmt.Errorf("API error: Status %d", resp.StatusCode) + } + + bodyBytes, _ := io.ReadAll(resp.Body) + rawBody := string(bodyBytes) + log.Printf("Raw Body Length: %d, Response: %s", len(rawBody), rawBody[:min(len(rawBody), 500)]) + + response := &GeminiResponse{} + parseResponse(rawBody, response) + + // Direct regex scan for music download URLs in raw body + if strings.Contains(rawBody, "contribution.usercontent.google.com") { + musicRe := regexp.MustCompile(`https://contribution\.usercontent\.google\.com/download[^"\\)\}\s]*`) + matches := musicRe.FindAllString(rawBody, -1) + for _, m := range matches { + m = strings.ReplaceAll(m, `\u0026`, "&") + m = strings.ReplaceAll(m, `\\u0026`, "&") + found := false + for _, existing := range response.Music { + if existing.DownloadURL == m { + found = true + break + } + } + if !found { + track := MusicTrack{DownloadURL: m} + if idx := strings.Index(m, "filename="); idx != -1 { + fname := m[idx+9:] + if ampIdx := strings.Index(fname, "&"); ampIdx != -1 { + fname = fname[:ampIdx] + } + fname = strings.ReplaceAll(fname, "_", " ") + fname = strings.TrimSuffix(fname, ".mp3") + fname = strings.TrimSuffix(fname, ".mp4") + track.Title = fname + } + response.Music = append(response.Music, track) + log.Printf("🎵 Found music URL via regex: %s", m[:min(len(m), 100)]) + } + } + } + + return rawBody, response, nil +} + +func parseResponse(raw string, res *GeminiResponse) { + lines := strings.Split(raw, "\n") + for _, line := range lines { + if !strings.HasPrefix(line, "[[") { + continue + } + + var wrapper [][]interface{} + if err := json.Unmarshal([]byte(line), &wrapper); err != nil { + continue + } + + for _, item := range wrapper { + if len(item) < 3 { + continue + } + wrbID, ok := item[0].(string) + if !ok || wrbID != "wrb.fr" { + continue + } + + payloadStr, ok := item[2].(string) + if !ok { + continue + } + + var inner []interface{} + if err := json.Unmarshal([]byte(payloadStr), &inner); err != nil { + continue + } + + if len(inner) > 1 { + if ctxArr, ok := inner[1].([]interface{}); ok && len(ctxArr) >= 2 { + if cid, ok := ctxArr[0].(string); ok { + res.ConversationID = cid + } + if rid, ok := ctxArr[1].(string); ok { + res.ResponseID = rid + } + } + } + + if len(inner) > 4 { + if contentArr, ok := inner[4].([]interface{}); ok && len(contentArr) > 0 { + if msgItem, ok := contentArr[0].([]interface{}); ok && len(msgItem) > 1 { + if contentList, ok := msgItem[1].([]interface{}); ok && len(contentList) > 0 { + if text, ok := contentList[0].(string); ok { + res.Text = text + } + } + if choiceID, ok := msgItem[0].(string); ok { + res.ChoiceID = choiceID + } + + if len(msgItem) > 12 { + if imgData, ok := msgItem[12].([]interface{}); ok && len(imgData) > 7 { + if outerList, ok := imgData[7].([]interface{}); ok && len(outerList) > 0 { + if innerList, ok := outerList[0].([]interface{}); ok { + for _, imgObj := range innerList { + if imgArr, ok := imgObj.([]interface{}); ok && len(imgArr) > 0 { + if subArr, ok := imgArr[0].([]interface{}); ok && len(subArr) > 3 { + if deepArr, ok := subArr[3].([]interface{}); ok && len(deepArr) > 3 { + if imgURL, ok := deepArr[3].(string); ok { + base := strings.Split(imgURL, "=")[0] + imgURL = base + "=s0" + + // Deduplicate: only append if not already in res.Images + isDup := false + for _, existing := range res.Images { + if existing == imgURL { + isDup = true + break + } + } + if !isDup { + res.Images = append(res.Images, imgURL) + } + } + } + } + } + } + } + } + } + } + probeMusicURLs(msgItem, res, 0) + } + } + } + } + } +} + +// probeMusicURLs recursively probes nested data for music download URLs +func probeMusicURLs(data interface{}, res *GeminiResponse, depth int) { + if depth > 20 || data == nil { + return + } + switch v := data.(type) { + case string: + if len(v) > 20 && strings.HasPrefix(v, "http") && + strings.Contains(v, "contribution.usercontent.google.com") { + found := false + for _, m := range res.Music { + if m.DownloadURL == v { + found = true + break + } + } + if !found { + track := MusicTrack{DownloadURL: v} + if idx := strings.Index(v, "filename="); idx != -1 { + fname := v[idx+9:] + if ampIdx := strings.Index(fname, "&"); ampIdx != -1 { + fname = fname[:ampIdx] + } + fname = strings.ReplaceAll(fname, "_", " ") + fname = strings.TrimSuffix(fname, ".mp3") + fname = strings.TrimSuffix(fname, ".mp4") + track.Title = fname + } + res.Music = append(res.Music, track) + } + } + case []interface{}: + for _, item := range v { + probeMusicURLs(item, res, depth+1) + } + case map[string]interface{}: + for _, item := range v { + probeMusicURLs(item, res, depth+1) + } + } +} + +func (c *GeminiClient) DownloadFile(urlStr, savePath string) error { + // Build a separate tls-client that does NOT follow redirects automatically. + // We follow redirects manually so we can inject Cookie header on every hop. + jar := tls_client.NewCookieJar() + dlClient, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(), + tls_client.WithTimeoutSeconds(60), + tls_client.WithClientProfile(profiles.Chrome_146), + tls_client.WithCookieJar(jar), + tls_client.WithNotFollowRedirects(), + ) + if err != nil { + return fmt.Errorf("download client init failed: %w", err) + } + + // Use CachedImageCookies (from extension bridge) if available, else RawCookies + cookieStr := CachedImageCookies + if cookieStr == "" { + cookieStr = c.RawCookies + } + + currentURL := urlStr + for hops := 0; hops < 10; hops++ { + req, err := http.NewRequest("GET", currentURL, nil) + if err != nil { + return err + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") + req.Header.Set("sec-ch-ua", `"Not(A:Brand";v="8", "Chromium";v="146", "Google Chrome";v="146"`) + req.Header.Set("sec-ch-ua-mobile", "?0") + req.Header.Set("sec-ch-ua-platform", `"macOS"`) + req.Header.Set("Referer", "https://gemini.google.com/") + + // Inject cookies on EVERY hop (this is the key fix for 403) + if cookieStr != "" { + req.Header.Set("Cookie", cookieStr) + } + + resp, err := dlClient.Do(req) + if err != nil { + return fmt.Errorf("download request failed: %w", err) + } + + // Handle redirects manually + if resp.StatusCode == 301 || resp.StatusCode == 302 || resp.StatusCode == 303 || resp.StatusCode == 307 || resp.StatusCode == 308 { + location := resp.Header.Get("Location") + resp.Body.Close() + if location == "" { + return fmt.Errorf("redirect with no Location header (status %d)", resp.StatusCode) + } + log.Printf("🔀 Download redirect %d: %s → %s", resp.StatusCode, currentURL[:min(len(currentURL), 60)], location[:min(len(location), 80)]) + currentURL = location + continue + } + + if resp.StatusCode != 200 { + resp.Body.Close() + return fmt.Errorf("download failed: Status %d at %s", resp.StatusCode, currentURL[:min(len(currentURL), 80)]) + } + + // Success — write to file + dir := filepath.Dir(savePath) + if err := os.MkdirAll(dir, 0755); err != nil { + resp.Body.Close() + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + out, err := os.Create(savePath) + if err != nil { + resp.Body.Close() + return err + } + + _, err = io.Copy(out, resp.Body) + resp.Body.Close() + out.Close() + if err != nil { + return err + } + + info, err := os.Stat(savePath) + if err != nil { + return err + } + if info.Size() < 1000 { + return fmt.Errorf("file too small (%d bytes), likely failed download", info.Size()) + } + + // Run watermark remover if file is image or video + ext := strings.ToLower(filepath.Ext(savePath)) + var fileType string + if ext == ".png" || ext == ".jpg" || ext == ".jpeg" { + fileType = "image" + } else if ext == ".mp4" { + fileType = "video" + } + + if fileType != "" { + log.Printf("🧹 Removing watermark from %s (%s) natively in Go...", savePath, fileType) + if err := RemoveWatermark(savePath, fileType); err != nil { + log.Printf("⚠️ Failed to remove watermark natively: %v", err) + } else { + log.Printf("✨ Watermark removed successfully natively in Go: %s", savePath) + } + } + + return nil + } + + return fmt.Errorf("too many redirects downloading %s", urlStr[:min(len(urlStr), 80)]) +} + +// ReloadSession re-reads the cookies file and marks the session to be re-initialized lazily on the next request +func (c *GeminiClient) ReloadSession() error { + if err := c.loadCookies(); err != nil { + return fmt.Errorf("failed to reload cookies: %v", err) + } + + // Mark as uninitialized so the next incoming request will initialize the session with fresh cookies + c.IsInitialized = false + log.Println("♻️ Cookies reloaded in memory. Session marked for lazy re-initialization.") + return nil +} diff --git a/free-gemini-api/gemini/models.go b/free-gemini-api/gemini/models.go new file mode 100644 index 0000000000000000000000000000000000000000..fbc97f804c86a9c88cccd26290fa6f15448bf241 --- /dev/null +++ b/free-gemini-api/gemini/models.go @@ -0,0 +1,32 @@ +package gemini + +// MusicTrack holds metadata for a generated music track +type MusicTrack struct { + Title string `json:"title"` + Album string `json:"album,omitempty"` + Genre string `json:"genre,omitempty"` + Mood string `json:"mood,omitempty"` + DownloadURL string `json:"download_url"` + LocalPath string `json:"local_path,omitempty"` + Duration string `json:"duration,omitempty"` +} + +// GeminiResponse represents the structured response from the API +type GeminiResponse struct { + Text string `json:"text"` + ConversationID string `json:"conversation_id"` + ResponseID string `json:"response_id"` + ChoiceID string `json:"choice_id"` + Images []string `json:"images"` + Videos []string `json:"videos"` + Music []MusicTrack `json:"music,omitempty"` + Elapsed float64 `json:"elapsed"` +} + +// ChatRequest represents the incoming request payload +type ChatRequest struct { + Prompt string `json:"prompt"` + NewChat bool `json:"new_chat"` + UserID string `json:"user_id"` + Stream bool `json:"stream"` +} diff --git a/free-gemini-api/gemini/monitor.go b/free-gemini-api/gemini/monitor.go new file mode 100644 index 0000000000000000000000000000000000000000..49cd10db3538813caa27cdd9cd7133c1df610adb --- /dev/null +++ b/free-gemini-api/gemini/monitor.go @@ -0,0 +1,149 @@ +package gemini + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync" + + "github.com/gorilla/websocket" +) + +// CachedImageCookies holds pre-fetched cookies for image/video downloads +var CachedImageCookies string + +// OnCookiesUpdated is a callback triggered when new cookies are synced +var OnCookiesUpdated func() + +var ( + activeClients []*websocket.Conn + activeClientsMu sync.Mutex +) + +// BroadcastCookieRefresh sends a trigger_sync message to all connected Chrome Extension clients +func BroadcastCookieRefresh() { + activeClientsMu.Lock() + defer activeClientsMu.Unlock() + log.Printf("📢 Broadcasting trigger_sync to %d connected Chrome Extensions...", len(activeClients)) + + payload := map[string]string{"type": "trigger_sync"} + + // We iterate backwards so we can safely remove disconnected clients + for i := len(activeClients) - 1; i >= 0; i-- { + conn := activeClients[i] + err := conn.WriteJSON(payload) + if err != nil { + log.Printf("⚠️ Failed to write to extension client: %v. Removing client.", err) + conn.Close() + activeClients = append(activeClients[:i], activeClients[i+1:]...) + } + } +} + +var wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true // Allow Chrome Extension context + }, +} + +type ExtensionMessage struct { + Type string `json:"type"` + Cookies []CookieObject `json:"cookies"` +} + +// StartCookieWebSocketServer starts a local WebSocket server to receive cookies from the extension +func StartCookieWebSocketServer(port int) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("❌ WS Upgrade failed: %v", err) + return + } + + activeClientsMu.Lock() + activeClients = append(activeClients, conn) + activeClientsMu.Unlock() + + defer func() { + conn.Close() + activeClientsMu.Lock() + for i, c := range activeClients { + if c == conn { + activeClients = append(activeClients[:i], activeClients[i+1:]...) + break + } + } + activeClientsMu.Unlock() + log.Println("🔌 Chrome Extension disconnected") + }() + + log.Println("🔌 Chrome Extension connected to cookie bridge") + + for { + _, msgBytes, err := conn.ReadMessage() + if err != nil { + log.Println("🔌 Chrome Extension disconnected") + break + } + + var msg ExtensionMessage + if err := json.Unmarshal(msgBytes, &msg); err != nil { + log.Printf("❌ Failed to decode extension message: %v", err) + continue + } + + if msg.Type == "ping" { + conn.WriteJSON(map[string]string{"type": "pong"}) + continue + } + + if msg.Type == "cookies_payload" && len(msg.Cookies) > 0 { + log.Printf("🍪 Received %d cookies from Chrome Extension", len(msg.Cookies)) + + // Write to cookies.json + data, err := json.MarshalIndent(msg.Cookies, "", " ") + if err != nil { + log.Printf("❌ Failed to marshal cookies: %v", err) + continue + } + + if err := os.WriteFile("cookies.json", data, 0644); err != nil { + log.Printf("❌ Failed to save cookies.json: %v", err) + continue + } + + // Refresh CachedImageCookies + var parts []string + for _, ck := range msg.Cookies { + parts = append(parts, fmt.Sprintf("%s=%s", ck.Name, ck.Value)) + } + CachedImageCookies = strings.Join(parts, "; ") + log.Printf("🍪 Cached %d cookies for image/video downloading", len(msg.Cookies)) + + // Trigger callback to reload active sessions + if OnCookiesUpdated != nil { + OnCookiesUpdated() + } + } + } + }) + + addr := fmt.Sprintf("127.0.0.1:%d", port) + log.Printf("📡 Cookie WebSocket Server listening on %s", addr) + + server := &http.Server{ + Addr: addr, + Handler: mux, + } + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("❌ Cookie WebSocket Server failed: %v", err) + } +} + diff --git a/free-gemini-api/gemini/watermark.go b/free-gemini-api/gemini/watermark.go new file mode 100644 index 0000000000000000000000000000000000000000..f7780594e1c89a906bc55549ba8f39c4e2418359 --- /dev/null +++ b/free-gemini-api/gemini/watermark.go @@ -0,0 +1,424 @@ +package gemini + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "image/color" + "image/draw" + "image/jpeg" + "image/png" + "io" + "log" + "math" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Constants for mathematical reverse alpha blending +const ( + alphaThreshold = 0.002 + maxAlpha = 0.99 + logoValue = 255.0 + videoAlphaScale = 0.6 +) + +// Embedded base64-encoded PNG assets for the 48px and 96px watermarks +const ( + bg48B64 = "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAGVElEQVR4nMVYvXIbNxD+FvKMWInXmd2dK7MTO7sj9QKWS7qy/Ab2o/gNmCp0JyZ9dHaldJcqTHfnSSF1R7kwlYmwKRYA93BHmkrseMcjgzgA++HbH2BBxhhmBiB/RYgo+hkGSFv/ZOY3b94w89u3b6HEL8JEYCYATCAi2JYiQ8xMDADGWsvMbfVagm6ZLxKGPXr0qN/vJ0mSpqn0RzuU//Wu9MoyPqxmtqmXJYwxxpiAQzBF4x8/fiyN4XDYoZLA5LfEhtg0+glMIGZY6wABMMbs4CaiR8brkYIDwGg00uuEMUTQ1MYqPBRRYZjZ+q42nxEsaYiV5VOapkmSSLvX62VZprUyM0DiQACIGLCAESIAEINAAAEOcQdD4a+2FJqmhDd/YEVkMpmEtrU2igCocNHW13swRBQYcl0enxbHpzEhKo0xSZJEgLIsC4Q5HJaJ2Qg7kKBjwMJyCDciBBcw7fjSO4tQapdi5vF43IZ+cnISdh9Y0At2RoZWFNtLsxr8N6CUTgCaHq3g+Pg4TVO1FACSaDLmgMhYC8sEQzCu3/mQjNEMSTvoDs4b+nXny5cvo4lBJpNJmKj9z81VrtNhikCgTsRRfAklmurxeKx9JZIsy548eeITKJgAQwzXJlhDTAwDgrXkxxCD2GfqgEPa4rnBOlApFUC/39fR1CmTyWQwGAQrR8TonMRNjjYpTmPSmUnC8ODgQHqSJDk7O9uNBkCv15tOp4eHh8SQgBICiCGu49YnSUJOiLGJcG2ydmdwnRcvXuwwlpYkSabTaZS1vyimc7R2Se16z58/f/jw4Z5LA8iy7NmzZ8J76CQ25F2UGsEAJjxo5194q0fn9unp6fHx8f5oRCQ1nJ+fbxtA3HAjAmCMCaGuAQWgh4eH0+k0y7LGvPiU3CVXV1fz+by+WQkCJYaImKzL6SEN6uMpjBVMg8FgOp3GfnNPQADqup79MLv59AlWn75E/vAlf20ibmWg0Pn06dPJZNLr9e6nfLu8//Ahv/gFAEdcWEsgZnYpR3uM9KRpOplMGmb6SlLX9Ww2q29WyjH8+SI+pD0GQJIkJycn/8J/I4mWjaQoijzPb25uJJsjmAwqprIsG4/HbVZ2L/1fpCiKoijKqgTRBlCWZcPhcDQafUVfuZfUdb1cLpfL5cePf9Lr16/3zLz/g9T1quNy+F2FiYjSNB0Oh8Ph8HtRtV6vi6JYLpdVVbmb8t3dnSAbjUbRNfmbSlmWeZ6XHytEUQafEo0xR0dHUdjvG2X3Sd/Fb0We56t6BX8l2mTq6BCVnqOjo7Ozs29hRGGlqqrOr40CIKqeiGg8Hn/xcri/rG/XeZ7/evnrjjGbC3V05YC/BSRJ8urVq36/3zX7Hjaq63o+n19fX/upUqe5VxFok7UBtQ+T6XQ6GAz2Vd6Ssizn8/nt7a3ay1ZAYbMN520XkKenpx0B2E2SLOo+FEWxWPwMgMnC3/adejZMYLLS42r7oH4LGodpsVgURdHQuIcURbFYLDYlVKg9sCk5wpWNiHym9pUAEQGG6EAqSxhilRQWi0VZVmrz23yI5cPV1dX5TwsmWGYrb2TW36OJGjdXhryKxEeHvjR2Fgzz+bu6XnVgaHEmXhytEK0W1aUADJPjAL6CtPZv5rsGSvUKtv7r8/zdj+v1uoOUpsxms7qunT6+g1/TvTQCxE6XR2kBqxjyZo6K66gsAXB1fZ3neQdJSvI8X61WpNaMWCFuKNrkGuGGmMm95fhpvPkn/f6lAgAuLy/LstyGpq7r9+8d4rAr443qaln/ehHt1siv3dvt2B/RDpJms5lGE62gEy9az0XGcQCK3DL4DTPr0pPZEjPAZVlusoCSoihWqzpCHy7ODRXhbUTJly9oDr4fKDaV9NZJUrszPOjsI0a/FzfwNt4eHH+BSyICqK7rqqo0u0VRrFYridyN87L3pBYf7qvq3wqc3DMldJmiK06pgi8uLqQjAAorRG+p+zLUxks+z7rOkOzlIUy8yrAcQFVV3a4/ywBPmJsVMcTM3l/h9xDlLga4I1PDGaD7UNBPuCKBleUfy2gd+DOrPWubGHJJyD+L+LCTjEXEgH//2uSxhu1/Xzocy+VSL+2cUhrqLVZ/jTYL0IMtQEklT3/iWCutzUljDDNXVSVHRFWW7SOtccHag6V/AF1/slVRyOkZAAAAAElFTkSuQmCC" + bg96B64 = "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" +) + +// Global cache for decoded alpha maps +var ( + alphaCache48 []float32 + alphaCache96 []float32 +) + +func init() { + // Pre-decode and cache the watermark grids + var err error + alphaCache48, err = loadAlphaMap(48) + if err != nil { + log.Printf("⚠️ Failed to load 48px alpha map: %v", err) + } + alphaCache96, err = loadAlphaMap(96) + if err != nil { + log.Printf("⚠️ Failed to load 96px alpha map: %v", err) + } +} + +// loadAlphaMap decodes base64 PNGs and extracts the maximum RGB channel as alpha mapping +func loadAlphaMap(size int) ([]float32, error) { + b64Str := bg48B64 + if size == 96 { + b64Str = bg96B64 + } + + data, err := base64.StdEncoding.DecodeString(b64Str) + if err != nil { + return nil, err + } + + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, err + } + + bounds := img.Bounds() + w, h := bounds.Dx(), bounds.Dy() + + // Resize using simple nearest-neighbor if dimensions mismatch + alphaMap := make([]float32, size*size) + for y := 0; y < size; y++ { + srcY := int(float64(y) * float64(h) / float64(size)) + for x := 0; x < size; x++ { + srcX := int(float64(x) * float64(w) / float64(size)) + r, g, b, _ := img.At(bounds.Min.X+srcX, bounds.Min.Y+srcY).RGBA() + + // Max of RGB, scale [0, 65535] down to [0.0, 1.0] + maxVal := r + if g > maxVal { + maxVal = g + } + if b > maxVal { + maxVal = b + } + + alphaMap[y*size+x] = float32(maxVal) / 65535.0 + } + } + + return alphaMap, nil +} + +// WatermarkConfig holds dimensions and offset coordinates +type WatermarkConfig struct { + LogoSize int + X int + Y int +} + +// DetectWatermarkConfig returns position configurations based on dimensions +func DetectWatermarkConfig(width, height int, isVideo bool) WatermarkConfig { + var logoSize, marginRight, marginBottom int + + if isVideo { + shortDim := width + if height < shortDim { + shortDim = height + } + + if shortDim >= 1080 { + logoSize = 96 + marginRight = 64 + marginBottom = 64 + } else { + logoSize = 48 + marginRight = 72 + marginBottom = 72 + } + } else { + // Portrait standard matching + if width == 720 && height == 1280 { + logoSize = 48 + marginRight = 72 + marginBottom = 72 + } else { + shortDim := width + if height < shortDim { + shortDim = height + } + + if shortDim > 800 { + logoSize = 96 + marginRight = 64 + marginBottom = 64 + } else { + logoSize = 48 + marginRight = 32 + marginBottom = 32 + } + } + } + + x := width - marginRight - logoSize + y := height - marginBottom - logoSize + + return WatermarkConfig{ + LogoSize: logoSize, + X: x, + Y: y, + } +} + +// RemoveWatermark native hub. Dispatches based on file extensions. +func RemoveWatermark(savePath string, fileType string) error { + ext := strings.ToLower(filepath.Ext(savePath)) + if ext == ".png" || ext == ".jpg" || ext == ".jpeg" { + return removeWatermarkFromImage(savePath) + } else if ext == ".mp4" { + return removeWatermarkFromVideo(savePath) + } + return fmt.Errorf("unsupported watermark file type: %s", ext) +} + +// removeWatermarkFromImage performs native Go reverse-alpha editing on static images +func removeWatermarkFromImage(imagePath string) error { + file, err := os.Open(imagePath) + if err != nil { + return err + } + defer file.Close() + + img, format, err := image.Decode(file) + if err != nil { + return err + } + file.Close() // Close early for in-place write + + bounds := img.Bounds() + width, height := bounds.Dx(), bounds.Dy() + config := DetectWatermarkConfig(width, height, false) + + // Fetch cached alpha map + alphaMap := alphaCache48 + if config.LogoSize == 96 { + alphaMap = alphaCache96 + } + if len(alphaMap) == 0 { + return fmt.Errorf("alpha map cache for size %d is empty", config.LogoSize) + } + + // Create writable canvas + canvas := image.NewRGBA(bounds) + draw.Draw(canvas, bounds, img, bounds.Min, draw.Src) + + // Apply math formula on watermark region + for dy := 0; dy < config.LogoSize; dy++ { + py := config.Y + dy + if py < bounds.Min.Y || py >= bounds.Max.Y { + continue + } + + for dx := 0; dx < config.LogoSize; dx++ { + px := config.X + dx + if px < bounds.Min.X || px >= bounds.Max.X { + continue + } + + alpha := alphaMap[dy*config.LogoSize+dx] + if alpha < alphaThreshold { + continue + } + + // Clamp alpha to avoid division by zero + if alpha > maxAlpha { + alpha = maxAlpha + } + + r, g, b, a := canvas.At(px, py).RGBA() + + // Scale down values to standard float32 [0.0, 255.0] + fR := float64(r) / 257.0 + fG := float64(g) / 257.0 + fB := float64(b) / 257.0 + + // Solve original = (watermarked - alpha * 255.0) / (1 - alpha) + oneMinusAlpha := 1.0 - float64(alpha) + newR := (fR - float64(alpha)*logoValue) / oneMinusAlpha + newG := (fG - float64(alpha)*logoValue) / oneMinusAlpha + newB := (fB - float64(alpha)*logoValue) / oneMinusAlpha + + // Clamp to [0, 255] + cR := uint8(math.Min(math.Max(newR, 0), 255)) + cG := uint8(math.Min(math.Max(newG, 0), 255)) + cB := uint8(math.Min(math.Max(newB, 0), 255)) + cA := uint8(a / 257) + + canvas.SetRGBA(px, py, color.RGBA{R: cR, G: cG, B: cB, A: cA}) + } + } + + // Save back in-place + outFile, err := os.Create(imagePath) + if err != nil { + return err + } + defer outFile.Close() + + if format == "png" { + return png.Encode(outFile, canvas) + } + return jpeg.Encode(outFile, canvas, &jpeg.Options{Quality: 95}) +} + +// VideoStreamInfo is mapped from ffprobe JSON output +type VideoStreamInfo struct { + Streams []struct { + Width int `json:"width"` + Height int `json:"height"` + CodecType string `json:"codec_type"` + RFrameRate string `json:"r_frame_rate"` + } `json:"streams"` +} + +// removeWatermarkFromVideo runs native FFMPEG byte pipes for zero-Python video watermark removal +func removeWatermarkFromVideo(videoPath string) error { + absPath, err := filepath.Abs(videoPath) + if err != nil { + return err + } + + // 1. Get dimensions using ffprobe + probeCmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", absPath) + probeOut, err := probeCmd.Output() + if err != nil { + return fmt.Errorf("ffprobe failed (is ffmpeg installed?): %w", err) + } + + var info VideoStreamInfo + if err := json.Unmarshal(probeOut, &info); err != nil { + return fmt.Errorf("failed to parse ffprobe json: %w", err) + } + + var width, height int + var fps string + for _, stream := range info.Streams { + if stream.CodecType == "video" { + width = stream.Width + height = stream.Height + fps = stream.RFrameRate + break + } + } + + if width == 0 || height == 0 { + return fmt.Errorf("could not extract video dimensions for: %s", videoPath) + } + + if fps == "" { + fps = "24" + } + + config := DetectWatermarkConfig(width, height, true) + alphaMap := alphaCache48 + if config.LogoSize == 96 { + alphaMap = alphaCache96 + } + + // Setup temp path for safe in-place rewrite + tempOut := absPath + ".tmp.mp4" + defer os.Remove(tempOut) + + // FFMPEG Read command: Decode frames to raw BGR24 on stdout + readCmd := exec.Command("ffmpeg", "-i", absPath, "-f", "rawvideo", "-pix_fmt", "bgr24", "-v", "quiet", "-") + stdout, err := readCmd.StdoutPipe() + if err != nil { + return err + } + + // FFMPEG Write command: Encode BGR24 frames back into H.264 mp4, copying audio from original + writeCmd := exec.Command("ffmpeg", "-y", + "-f", "rawvideo", "-pix_fmt", "bgr24", + "-s", fmt.Sprintf("%dx%d", width, height), "-r", fps, + "-i", "-", + "-i", absPath, // Re-read for audio mapping + "-map", "0:v", "-map", "1:a?", + "-c:v", "libx264", "-preset", "fast", "-crf", "18", + "-c:a", "copy", + "-movflags", "+faststart", + "-v", "quiet", + tempOut, + ) + + stdin, err := writeCmd.StdinPipe() + if err != nil { + return err + } + + if err := readCmd.Start(); err != nil { + return fmt.Errorf("ffmpeg reader start failed: %w", err) + } + if err := writeCmd.Start(); err != nil { + return fmt.Errorf("ffmpeg writer start failed: %w", err) + } + + frameSize := width * height * 3 // BGR24 + frameBuf := make([]byte, frameSize) + + for { + _, err := io.ReadFull(stdout, frameBuf) + if err == io.EOF || err == io.ErrUnexpectedEOF { + break + } + if err != nil { + break + } + + // Apply watermark reverse blending directly in BGR24 byte slice + for dy := 0; dy < config.LogoSize; dy++ { + py := config.Y + dy + if py < 0 || py >= height { + continue + } + + for dx := 0; dx < config.LogoSize; dx++ { + px := config.X + dx + if px < 0 || px >= width { + continue + } + + alpha := alphaMap[dy*config.LogoSize+dx] + if alpha < alphaThreshold { + continue + } + + // Scale down watermark intensity for videos + scaledAlpha := alpha * videoAlphaScale + if scaledAlpha > maxAlpha { + scaledAlpha = maxAlpha + } + + // Offset inside raw BGR24 frame byte slice + pixelOffset := (py*width + px) * 3 + + b := frameBuf[pixelOffset] + g := frameBuf[pixelOffset+1] + r := frameBuf[pixelOffset+2] + + // Solve original = (watermarked - alpha * 255.0) / (1 - alpha) + oneMinusAlpha := 1.0 - float64(scaledAlpha) + + newB := (float64(b) - float64(scaledAlpha)*logoValue) / oneMinusAlpha + newG := (float64(g) - float64(scaledAlpha)*logoValue) / oneMinusAlpha + newR := (float64(r) - float64(scaledAlpha)*logoValue) / oneMinusAlpha + + // Set BGR byte values, clamped to [0, 255] + frameBuf[pixelOffset] = byte(math.Min(math.Max(newB, 0), 255)) + frameBuf[pixelOffset+1] = byte(math.Min(math.Max(newG, 0), 255)) + frameBuf[pixelOffset+2] = byte(math.Min(math.Max(newR, 0), 255)) + } + } + + // Write modified frame to encoder stdin pipe + if _, err := stdin.Write(frameBuf); err != nil { + break + } + } + + stdin.Close() + writeCmd.Wait() + readCmd.Wait() + + // Replace original video with clean copy + if _, err := os.Stat(tempOut); err == nil { + return os.Rename(tempOut, absPath) + } + + return fmt.Errorf("failed to compile clean watermark-free video") +} diff --git a/free-gemini-api/go.mod b/free-gemini-api/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..c6bdc95054193f0aec43eb4eea811a5002456a8b --- /dev/null +++ b/free-gemini-api/go.mod @@ -0,0 +1,36 @@ +module goapi + +go 1.25.7 + +require ( + github.com/bogdanfinn/fhttp v0.6.8 + github.com/bogdanfinn/tls-client v1.14.0 + github.com/gofiber/fiber/v3 v3.0.0 + github.com/gorilla/websocket v1.5.3 + github.com/joho/godotenv v1.5.1 +) + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/bdandy/go-errors v1.2.2 // indirect + github.com/bdandy/go-socks4 v1.2.3 // indirect + github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect + github.com/bogdanfinn/utls v1.7.7-barnius // indirect + github.com/bogdanfinn/websocket v1.5.5-barnius // indirect + github.com/gofiber/schema v1.6.0 // indirect + github.com/gofiber/utils/v2 v2.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect + github.com/tinylib/msgp v1.6.3 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.69.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/free-gemini-api/go.sum b/free-gemini-api/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..7753b3f9b41325e547d4dea8f54eafe3334855ea --- /dev/null +++ b/free-gemini-api/go.sum @@ -0,0 +1,79 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= +github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= +github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= +github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI= +github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4= +github.com/bogdanfinn/fhttp v0.6.8/go.mod h1:A+EKDzMx2hb4IUbMx4TlkoHnaJEiLl8r/1Ss1Y+5e5M= +github.com/bogdanfinn/quic-go-utls v1.0.9-utls h1:tV6eDEiRbRCcepALSzxR94JUVD3N3ACIiRLgyc2Ep8s= +github.com/bogdanfinn/quic-go-utls v1.0.9-utls/go.mod h1:aHph9B9H9yPOt5xnhWKSOum27DJAqpiHzwX+gjvaXcg= +github.com/bogdanfinn/tls-client v1.14.0 h1:vyk7Cn4BIvLAGVuMfb0tP22OqogfO1lYamquQNEZU1A= +github.com/bogdanfinn/tls-client v1.14.0/go.mod h1:LsU6mXVn8MOFDwTkyRfI7V1BZM1p0wf2ZfZsICW/1fM= +github.com/bogdanfinn/utls v1.7.7-barnius h1:OuJ497cc7F3yKNVHRsYPQdGggmk5x6+V5ZlrCR7fOLU= +github.com/bogdanfinn/utls v1.7.7-barnius/go.mod h1:aAK1VZQlpKZClF1WEQeq6kyclbkPq4hz6xTbB5xSlmg= +github.com/bogdanfinn/websocket v1.5.5-barnius h1:bY+qnxpai1qe7Jmjx+Sds/cmOSpuuLoR8x61rWltjOI= +github.com/bogdanfinn/websocket v1.5.5-barnius/go.mod h1:gvvEw6pTKHb7yOiFvIfAFTStQWyrm25BMVCTj5wRSsI= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk= +github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY= +github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY= +github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= +github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM= +github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg= +github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 h1:YqAladjX7xpA6BM04leXMWAEjS0mTZ5kUU9KRBriQJc= +github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5/go.mod h1:2JjD2zLQYH5HO74y5+aE3remJQvl6q4Sn6aWA2wD1Ng= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/free-gemini-api/main.go b/free-gemini-api/main.go new file mode 100644 index 0000000000000000000000000000000000000000..3490cd2202fddcf4a15550e2957af7b4905889fd --- /dev/null +++ b/free-gemini-api/main.go @@ -0,0 +1,451 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "goapi/gemini" + "io" + "log" + "mime/multipart" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/logger" + "github.com/joho/godotenv" +) + +var ( + userSessions sync.Map +) + +const CookiesFile = "cookies.json" + +func downloadAndClean(client *gemini.GeminiClient, urlStr, filename, fileType string) error { + tempDir := "./output/.temp" + os.MkdirAll(tempDir, 0755) + os.MkdirAll("./output", 0755) + + tempPath := filepath.Join(tempDir, filename) + outputPath := filepath.Join("./output", filename) + + // Step 1: Download to temp (which automatically cleans the watermark natively in Go!) + if err := client.DownloadFile(urlStr, tempPath); err != nil { + os.RemoveAll(tempDir) + return err + } + + // Step 2: Move from temp to output folder + var moveErr error + if err := os.Rename(tempPath, outputPath); err != nil { + // Fallback: Copy + Delete if rename fails across different mount points + input, err := os.ReadFile(tempPath) + if err != nil { + moveErr = err + } else if err := os.WriteFile(outputPath, input, 0644); err != nil { + moveErr = err + } + } + + // Clean up entire temp directory + os.RemoveAll(tempDir) + + if moveErr != nil { + return moveErr + } + + log.Printf("✨ Cleaned file moved to output: %s", outputPath) + return nil +} + +func getOrCreateClient(sessionID string) (*gemini.GeminiClient, error) { + if client, ok := userSessions.Load(sessionID); ok { + return client.(*gemini.GeminiClient), nil + } + + if _, err := os.Stat(CookiesFile); os.IsNotExist(err) { + log.Println("⚠️ cookies.json not found. Requesting Chrome Extension to proactively sync cookies...") + gemini.BroadcastCookieRefresh() + + log.Println("⏳ Sleeping 5 seconds waiting for extension to sync cookies...") + time.Sleep(5 * time.Second) + + if _, err := os.Stat(CookiesFile); os.IsNotExist(err) { + return nil, fmt.Errorf("cookies.json not found. Please ensure Chrome Extension is connected and syncs cookies first.") + } + } + + client, err := gemini.NewClient(CookiesFile) + if err != nil { + return nil, err + } + + userSessions.Store(sessionID, client) + log.Printf("New session created for: %s", sessionID) + return client, nil +} + +func startWebSocketBridge() { + // Initialize callback to reload sessions when extension updates cookies + gemini.OnCookiesUpdated = func() { + log.Println("♻️ Extension pushed new cookies. Reloading active sessions...") + userSessions.Range(func(key, value interface{}) bool { + client := value.(*gemini.GeminiClient) + sessionID := key.(string) + + if err := client.ReloadSession(); err != nil { + log.Printf("❌ Failed to reload session for %s: %v", sessionID, err) + } else { + log.Printf("✅ Session %s refreshed", sessionID) + } + return true + }) + } + + wsPortStr := os.Getenv("WS_PORT") + if wsPortStr == "" { + wsPortStr = "9222" // default port + } + wsPort, err := strconv.Atoi(wsPortStr) + if err != nil { + wsPort = 9222 + } + + // Start WebSocket Server + go gemini.StartCookieWebSocketServer(wsPort) +} + +func main() { + if err := godotenv.Load(); err != nil { + log.Println("⚠️ No .env file found") + } + + startWebSocketBridge() + + + app := fiber.New(fiber.Config{ + AppName: "Gemini Go API", + BodyLimit: 50 * 1024 * 1024, // 50MB + }) + + // Add recovery to prevent crashes and logger for debugging + app.Use(logger.New()) + app.Use(func(c fiber.Ctx) error { + defer func() { + if r := recover(); r != nil { + log.Printf("⚠️ RECOVERED from panic: %v", r) + c.Status(500).JSON(fiber.Map{"error": "Internal Server Error - Recovered"}) + } + }() + return c.Next() + }) + + // Unified chat endpoint - handles text, images, video, everything + app.Post("/chat", func(c fiber.Ctx) error { + var prompt, userID string + var newChat, stream bool + var images []gemini.ImageInput + + contentType := string(c.Request().Header.ContentType()) + + if strings.Contains(contentType, "multipart/form-data") { + // Multipart: image upload mode + prompt = c.FormValue("prompt") + userID = c.FormValue("user_id") + newChat = c.FormValue("new_chat") == "true" + stream = c.FormValue("stream") == "true" + + form, err := c.MultipartForm() + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid multipart form"}) + } + + var fileHeaders []*multipart.FileHeader + if files, ok := form.File["image"]; ok { + fileHeaders = append(fileHeaders, files...) + } + if files, ok := form.File["images"]; ok { + fileHeaders = append(fileHeaders, files...) + } + + if len(fileHeaders) > 10 { + return c.Status(400).JSON(fiber.Map{"error": "Maximum 10 images allowed per request"}) + } + + for _, fh := range fileHeaders { + file, err := fh.Open() + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to open image file"}) + } + data, err := io.ReadAll(file) + file.Close() + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "Failed to read image data"}) + } + mime := fh.Header.Get("Content-Type") + if mime == "" { + mime = "image/jpeg" + } + images = append(images, gemini.ImageInput{Data: data, Filename: fh.Filename, MimeType: mime}) + } + } else { + // JSON: text-only mode + var req gemini.ChatRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) + } + prompt = req.Prompt + userID = req.UserID + newChat = req.NewChat + stream = req.Stream + } + + sessionID := userID + if sessionID == "" { + sessionID = c.IP() + } + + if newChat { + userSessions.Delete(sessionID) + } + + client, err := getOrCreateClient(sessionID) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + var resp *gemini.GeminiResponse + + if stream && len(images) == 0 { + // SSE Streaming mode (text-only) + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") + c.Set("X-Accel-Buffering", "no") + + return c.SendStreamWriter(func(w *bufio.Writer) { + var streamResp *gemini.GeminiResponse + var streamErr error + + streamResp, streamErr = client.AskStream(prompt, func(chunk string) { + data, _ := json.Marshal(fiber.Map{"text": chunk}) + fmt.Fprintf(w, "data: %s\n\n", data) + w.Flush() + }) + + if streamErr != nil { + data, _ := json.Marshal(fiber.Map{"error": streamErr.Error()}) + fmt.Fprintf(w, "data: %s\n\n", data) + w.Flush() + return + } + + // Download images locally + if len(streamResp.Images) > 0 { + for i, imgURL := range streamResp.Images { + filename := fmt.Sprintf("img_%s_%d.png", streamResp.ResponseID, i) + if streamResp.ResponseID == "" { + filename = fmt.Sprintf("img_%d_%d.png", time.Now().Unix(), i) + } + if err := downloadAndClean(client, imgURL, filename, "image"); err == nil { + streamResp.Images[i] = fmt.Sprintf("http://localhost:8000/output/%s", filename) + } + } + } + + // Download videos locally + if len(streamResp.Videos) > 0 { + vidURL := streamResp.Videos[0] + if vidURL != "" && len(vidURL) > 50 { + filename := fmt.Sprintf("vid_%s_0.mp4", streamResp.ResponseID) + if streamResp.ResponseID == "" { + filename = fmt.Sprintf("vid_%d_0.mp4", time.Now().Unix()) + } + if err := downloadAndClean(client, vidURL, filename, "video"); err == nil { + streamResp.Videos[0] = fmt.Sprintf("http://localhost:8000/output/%s", filename) + } + } + } + + // Final event with full response + final, _ := json.Marshal(streamResp) + fmt.Fprintf(w, "data: %s\n\n", final) + fmt.Fprintf(w, "data: [DONE]\n\n") + w.Flush() + }) + } + + if len(images) > 0 { + // Image mode: upload + chat (auto video polling inside) + log.Printf("📸 Image request: %d images, prompt: %s", len(images), prompt) + resp, err = client.AskWithImages(prompt, images) + } else { + // Text-only mode + resp, err = client.Ask(prompt) + } + + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + userSessions.Delete(sessionID) + log.Printf("🔄 Session %s auto-reset due to timeout", sessionID) + return c.Status(504).JSON(fiber.Map{"error": "Request timed out. Session reset."}) + } + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + // Download images locally + if len(resp.Images) > 0 { + for i, imgURL := range resp.Images { + filename := fmt.Sprintf("img_%s_%d.png", resp.ResponseID, i) + if resp.ResponseID == "" { + filename = fmt.Sprintf("img_%d_%d.png", time.Now().Unix(), i) + } + if err := downloadAndClean(client, imgURL, filename, "image"); err == nil { + resp.Images[i] = fmt.Sprintf("http://%s/output/%s", c.Host(), filename) + } else { + log.Printf("❌ Failed to download image: %v", err) + } + } + } + + // Download videos locally + if len(resp.Videos) > 0 { + vidURL := resp.Videos[0] + if vidURL != "" && len(vidURL) > 50 { + filename := fmt.Sprintf("vid_%s_0.mp4", resp.ResponseID) + if resp.ResponseID == "" { + filename = fmt.Sprintf("vid_%d_0.mp4", time.Now().Unix()) + } + if err := downloadAndClean(client, vidURL, filename, "video"); err == nil { + resp.Videos[0] = fmt.Sprintf("http://%s/output/%s", c.Host(), filename) + } + } + } + + return c.JSON(resp) + }) + + // Music generation endpoint (separate because it uses tool="music_gen") + app.Post("/music", func(c fiber.Ctx) error { + var req gemini.ChatRequest + if err := c.Bind().JSON(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) + } + + sessionID := req.UserID + if sessionID == "" { + sessionID = c.IP() + } + + if req.NewChat { + userSessions.Delete(sessionID) + } + + client, err := getOrCreateClient(sessionID) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + log.Printf("🎵 Music request from %s: %s", sessionID, req.Prompt) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + resp, err := client.AskWithTool(req.Prompt, "music_gen") + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + userSessions.Delete(sessionID) + return c.Status(504).JSON(fiber.Map{"error": "Music generation timed out."}) + } + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + if len(resp.Music) > 0 { + var filteredMusic []gemini.MusicTrack + for i, track := range resp.Music { + if track.DownloadURL == "" || len(track.DownloadURL) < 50 { + continue + } + ext := ".mp3" + if strings.Contains(track.DownloadURL, ".mp4") { + if i > 0 { + continue + } + ext = ".mp4" + } + filename := fmt.Sprintf("music_%s_%d%s", resp.ResponseID, i, ext) + if resp.ResponseID == "" { + filename = fmt.Sprintf("music_%d_%d%s", time.Now().Unix(), i, ext) + } + if err := downloadAndClean(client, track.DownloadURL, filename, "music"); err == nil { + track.LocalPath = fmt.Sprintf("http://%s/output/%s", c.Host(), filename) + log.Printf("🎵 Music downloaded: %s", track.Title) + filteredMusic = append(filteredMusic, track) + break + } else { + log.Printf("❌ Failed to download music track %d: %v", i, err) + } + } + resp.Music = filteredMusic + } + + return c.JSON(resp) + }) + + app.Post("/reset", func(c fiber.Ctx) error { + type ResetRequest struct { + UserID string `json:"user_id"` + } + var req ResetRequest + c.Bind().JSON(&req) + + sessionID := req.UserID + if sessionID == "" { + sessionID = c.IP() + } + + if _, ok := userSessions.Load(sessionID); ok { + userSessions.Delete(sessionID) + return c.JSON(fiber.Map{"status": "success", "message": "Session reset"}) + } + + return c.JSON(fiber.Map{"status": "error", "message": "No active session"}) + }) + + app.Get("/status", func(c fiber.Ctx) error { + sessionID := c.Query("user_id") + if sessionID == "" { + sessionID = c.IP() + } + + if clientRaw, ok := userSessions.Load(sessionID); ok { + client := clientRaw.(*gemini.GeminiClient) + return c.JSON(fiber.Map{ + "session_id": sessionID, + "initialized": client.IsInitialized, + "conversation_id": client.ConversationID, + "expired": false, + }) + } + return c.JSON(fiber.Map{"session_id": sessionID, "active": false}) + }) + + app.Get("/output/*", func(c fiber.Ctx) error { + return c.SendFile("./output/" + c.Params("*")) + }) + + port := os.Getenv("PORT") + if port == "" { + port = "8001" + } + log.Fatal(app.Listen(":" + port)) +} diff --git a/free-gemini-api/test/test.go b/free-gemini-api/test/test.go new file mode 100644 index 0000000000000000000000000000000000000000..967cebfab2bc9f2b5f1b7e131774d08859da0d0c --- /dev/null +++ b/free-gemini-api/test/test.go @@ -0,0 +1,200 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const baseURL = "http://localhost:8000" +const userID = "test_go_client" + +type ChatRequest struct { + Prompt string `json:"prompt"` + UserID string `json:"user_id"` + NewChat bool `json:"new_chat"` + Stream bool `json:"stream"` +} + +type GeminiResponse struct { + Text string `json:"text"` + ConversationID string `json:"conversation_id"` + ResponseID string `json:"response_id"` + ChoiceID string `json:"choice_id"` + Images []string `json:"images"` + Videos []string `json:"videos"` + Music interface{} `json:"music"` + Elapsed float64 `json:"elapsed"` +} + +func main() { + fmt.Println("🚀 Starting API Server Endpoints Verification Tests...") + fmt.Printf("🔗 Target API Base URL: %s\n\n", baseURL) + + // Test 1: Simple Chat Request + runTextTest() + + // Test 2: Image Generation (Imagen 3) + runImageTest() + + // Test 3: Music/Song Generation + runMusicTest() + + // Test 4: Video Generation (Gemini Video) + runVideoTest() + + fmt.Println("\n🏁 All tests finished!") +} + +func runTextTest() { + fmt.Println("==================================================") + fmt.Println("📝 Test 1: Text Chat Generation") + fmt.Println("==================================================") + + payload := ChatRequest{ + Prompt: "Who are you? Answer in exactly one short sentence.", + UserID: userID, + NewChat: true, + } + + resp, err := sendPost("/chat", payload) + if err != nil { + fmt.Printf("❌ Text Chat test failed: %v\n\n", err) + return + } + + fmt.Printf("✅ Status: SUCCESS\n") + fmt.Printf("💬 Reply: %s\n", resp.Text) + fmt.Printf("⏱️ Time Elapsed: %.2f seconds\n\n", resp.Elapsed) +} + +func runImageTest() { + fmt.Println("==================================================") + fmt.Println("🎨 Test 2: Image Generation (Imagen 3)") + fmt.Println("==================================================") + fmt.Println("⏳ Please wait, image generation and watermark cleaning takes a moment...") + + payload := ChatRequest{ + Prompt: "Generate a beautiful minimalist logo of a glowing purple neon butterfly.", + UserID: userID, + NewChat: false, + } + + resp, err := sendPost("/chat", payload) + if err != nil { + fmt.Printf("❌ Image Gen test failed: %v\n\n", err) + return + } + + fmt.Printf("✅ Status: SUCCESS\n") + if len(resp.Images) > 0 { + fmt.Printf("📸 Generated Image URL: %s\n", resp.Images[0]) + } else { + fmt.Println("⚠️ Response text returned but no image URL found in response.") + fmt.Printf("💬 Response: %s\n", resp.Text) + } + fmt.Printf("⏱️ Time Elapsed: %.2f seconds\n\n", resp.Elapsed) +} + +func runMusicTest() { + fmt.Println("==================================================") + fmt.Println("🎵 Test 3: Music/Song Generation") + fmt.Println("==================================================") + fmt.Println("⏳ Generating track...") + + payload := ChatRequest{ + Prompt: "Generate a short lofi piano beat for coding.", + UserID: userID, + NewChat: false, + } + + resp, err := sendPost("/music", payload) + if err != nil { + fmt.Printf("❌ Music test failed: %v\n\n", err) + return + } + + fmt.Printf("✅ Status: SUCCESS\n") + fmt.Printf("💬 Message: %s\n", resp.Text) + + // Print raw music details + musicData, _ := json.MarshalIndent(resp.Music, "", " ") + fmt.Printf("🎶 Music Data: %s\n", string(musicData)) + fmt.Printf("⏱️ Time Elapsed: %.2f seconds\n\n", resp.Elapsed) +} + +func runVideoTest() { + fmt.Println("==================================================") + fmt.Println("🎬 Test 4: Video Generation (Gemini Video)") + fmt.Println("==================================================") + fmt.Println("💡 Note: Video generation consumes significant quota. Running test now...") + + payload := ChatRequest{ + Prompt: "Generate a 2-second cinematic video of waves gently washing onto a sandy beach.", + UserID: userID, + NewChat: false, + } + + resp, err := sendPost("/chat", payload) + if err != nil { + fmt.Printf("❌ Video Gen test failed: %v\n\n", err) + return + } + + fmt.Printf("✅ Status: SUCCESS\n") + if len(resp.Videos) > 0 { + fmt.Printf("📹 Generated Video URL: %s\n", resp.Videos[0]) + } else { + fmt.Println("⚠️ No video URL returned (may have reached account daily limit/quota).") + fmt.Printf("💬 Response: %s\n", resp.Text) + } + fmt.Printf("⏱️ Time Elapsed: %.2f seconds\n\n", resp.Elapsed) +} + +func sendPost(endpoint string, payload interface{}) (*GeminiResponse, error) { + jsonPayload, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", baseURL+endpoint, bytes.NewBuffer(jsonPayload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Timeout: 6 * time.Minute, // High timeout for media generation + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var result GeminiResponse + if err := json.Unmarshal(bodyBytes, &result); err != nil { + return nil, fmt.Errorf("failed to parse response JSON: %w (body: %s)", err, string(bodyBytes)) + } + + // Clean raw formatting of contribution URL in response if needed + if len(result.Images) > 0 { + result.Images[0] = strings.ReplaceAll(result.Images[0], `\`, "") + } + + return &result, nil +} diff --git a/start_hf.sh b/start_hf.sh index c687ef0331765783d1e98d78a876a48c04b9fdee..554e3211e1f6d0e1afad76909d79423c60d6f508 100644 --- a/start_hf.sh +++ b/start_hf.sh @@ -108,7 +108,7 @@ google-chrome-stable \ --disable-background-timer-throttling \ --disable-renderer-backgrounding \ --disable-backgrounding-occluded-windows \ - --disable-extensions \ + --load-extension=/opt/gpt-extension,/opt/gemini-extension,/opt/flow-extension \ --disable-translate \ --memory-pressure-off \ --process-per-site \