Spaces:
Sleeping
Sleeping
| import * as crypto from 'crypto'; | |
| import { randomUUID } from 'crypto'; | |
| import { URLSearchParams, fileURLToPath } from 'url'; | |
| import axios from 'axios'; | |
| import https from 'https'; | |
| import * as fs from 'fs'; | |
| import * as os from 'os'; | |
| import * as path from 'path'; | |
| import { z } from 'zod'; | |
| import { logger } from '@exocore/multi'; | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // SECTION 1 — Constants | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| export const Endpoint = { | |
| GOOGLE: 'https://www.google.com', | |
| INIT: 'https://gemini.google.com/app', | |
| GENERATE: 'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate', | |
| BATCH_EXEC: 'https://gemini.google.com/_/BardChatUi/data/batchexecute', | |
| UPLOAD: 'https://content-push.googleapis.com/upload', | |
| ROTATE_COOKIES: 'https://accounts.google.com/RotateCookies', | |
| PROCESS_GEM_FILE: 'https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/ProcessFile', | |
| }; | |
| export const RPC = { | |
| LIST_CHATS: 'MaZiqc', | |
| READ_CHAT: 'hNvQHb', | |
| DELETE_CHAT: 'GzXR5e', | |
| DELETE_CHAT_2: 'qWymEb', | |
| LIST_GEMS: 'CNgdBe', | |
| CREATE_GEM: 'oMH3Zd', | |
| UPDATE_GEM: 'kHv0Vd', | |
| DELETE_GEM: 'UXcSJb', | |
| LIST_NLM_NOTEBOOKS: 'NXpLKc', | |
| BARD_ACTIVITY: 'ESY5D', | |
| STATUS_POLL: 'aPya6c', | |
| ASYNC_POLL: 'kwDCne', | |
| POST_PROCESS: 'PCck7e', | |
| INIT_CONFIG: 'otAQ7b', | |
| INIT_FEATURES: 'GPRiHf', | |
| INIT_SETTINGS: 'maGuAc', | |
| INIT_STATE: 'cYRIkd', | |
| INIT_CAPABILITIES: 'ozz5Z', | |
| SYNC_STATE: 'L5adhe', | |
| USER_PREFS: 'K4WWud', | |
| USAGE_INFO: 'qpEbW', | |
| ACCOUNT_INFO: 'o30O0e', | |
| HISTORY_STATE: 'DYBcR', | |
| NOTIFICATION_STATE: 'ku4Jyf', | |
| }; | |
| export const BATCH_EXEC_HEADERS = { | |
| 'x-goog-ext-525001261-jspb': '[1,null,null,null,null,null,null,null,[4]]', | |
| 'x-goog-ext-73010989-jspb': '[0]', | |
| }; | |
| export const MODEL_HEADER_KEY = 'x-goog-ext-525001261-jspb'; | |
| export const TRACKING_HEADER_KEY = 'x-goog-ext-73010989-jspb'; | |
| export const SESSION_HEADER_KEY = 'x-goog-ext-525005358-jspb'; | |
| export const SAFETY_HEADER_KEY = 'x-goog-ext-73010990-jspb'; | |
| export const TEMP_MODEL_HASH = 'fbb127bbb056c959'; | |
| export const SYNC_STATE_NULL_COUNT = 87; | |
| export class Model { | |
| constructor(name, hash = null) { | |
| this.name = name; | |
| this.hash = hash; | |
| } | |
| get header() { | |
| if (!this.hash) return {}; | |
| return { [MODEL_HEADER_KEY]: `[1,null,null,null,"${this.hash}",null,null,0,[4],null,null,2]` }; | |
| } | |
| toString() { return `Model(name=${JSON.stringify(this.name)}, hash=${JSON.stringify(this.hash)})`; } | |
| equals(other) { if (other instanceof Model) return this.name === other.name; return false; } | |
| } | |
| export const MODELS = { | |
| unspecified: new Model('unspecified', null), | |
| fast: new Model('fast', '56fdd199312815e2'), | |
| thinking: new Model('thinking', 'e051ce1aa80aa576'), | |
| pro: new Model('pro', 'e6fa609c3fa255c0'), | |
| }; | |
| export const MODEL_ALIASES = { flash: 'fast', preview: 'fast', default: 'fast' }; | |
| export const DEFAULT_MODEL = MODELS.fast; | |
| export const TOKEN_FACTORY_MODELS = new Set(['pro', 'thinking']); | |
| export const CHROME_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36'; | |
| export const XBV_API_KEY = 'AIzaSyDr2UxVnv_U85AbhhY8XSHSIavUW0DC-sY'; | |
| export const DRIVE_PICKER_API_KEY = 'AIzaSyAw-cTyp9Xotzvu3vNDWhDU3E9NConkKxQ'; | |
| export const CHROME_HEADERS = { | |
| Accept: '*/*', 'Accept-Language': 'en-US,en;q=0.9', | |
| 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', | |
| Origin: 'https://gemini.google.com', Referer: 'https://gemini.google.com/', | |
| 'User-Agent': CHROME_USER_AGENT, 'X-Same-Domain': '1', | |
| 'x-browser-channel': 'stable', 'x-browser-year': '2026', | |
| 'x-client-data': 'CJWJywE=', | |
| 'Sec-Ch-Ua': '"Chromium";v="145", "Google Chrome";v="145", "Not-A.Brand";v="24"', | |
| 'Sec-Ch-Ua-Mobile': '?0', 'Sec-Ch-Ua-Platform': '"macOS"', | |
| 'Sec-Fetch-Dest': 'empty', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Site': 'same-origin', | |
| }; | |
| export function computeXbv(userAgent) { | |
| return crypto.createHash('sha1').update(XBV_API_KEY + userAgent).digest('base64'); | |
| } | |
| export function getModel(name) { | |
| if (name in MODELS) return MODELS[name]; | |
| if (name in MODEL_ALIASES) return MODELS[MODEL_ALIASES[name]]; | |
| const all = Array.from(new Set([...Object.keys(MODELS), ...Object.keys(MODEL_ALIASES)])).sort(); | |
| throw new Error(`Unknown model: ${JSON.stringify(name)}. Available: ${all.join(', ')}`); | |
| } | |
| export const ErrorCode = { | |
| TEMPORARY_ERROR: 1013, USAGE_LIMIT_EXCEEDED: 1037, | |
| MODEL_INCONSISTENT: 1050, MODEL_HEADER_INVALID: 1052, IP_TEMPORARILY_BLOCKED: 1060, | |
| }; | |
| export const GEMINI_HEADERS = { | |
| 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', | |
| Host: 'gemini.google.com', Origin: 'https://gemini.google.com', | |
| Referer: 'https://gemini.google.com/', | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36', | |
| 'X-Same-Domain': '1', | |
| }; | |
| export const ROTATE_COOKIES_HEADERS = { 'Content-Type': 'application/json' }; | |
| export const UPLOAD_HEADERS = { 'Push-ID': 'feeds/mcudyrk2a4khkz' }; | |
| export const TOKEN_PATTERNS = { | |
| snlm0e: /"SNlM0e":\s*"(.*?)"/, | |
| cfb2h: /"cfb2h":\s*"(.*?)"/, | |
| fdrfje: /"FdrFJe":\s*"(.*?)"/, | |
| }; | |
| export const EMAIL_PATTERN = /"oPEP7c":\s*"([^"]+@[^"]+)"/; | |
| export const COOKIE_1PSID = '__Secure-1PSID'; | |
| export const COOKIE_1PSIDTS = '__Secure-1PSIDTS'; | |
| export const COOKIE_DOMAIN = '.google.com'; | |
| export const COOKIE_REFRESH_INTERVAL = 540; | |
| export const COOKIE_REFRESH_COOLDOWN = 60; | |
| export const ROTATE_COOKIES_BODY = '[000,"-0000000000000000000"]'; | |
| export const CONFIG_DIR_NAME = '.gemini-web-mcp-cli'; | |
| export const NLM_CONFIG_DIR_NAME = '.notebooklm-mcp-cli'; | |
| export const PROFILES_DIR = 'profiles'; | |
| export const CONFIG_FILE = 'config.json'; | |
| export const AUTH_FILE = 'auth.json'; | |
| export const ENV_PROFILE = 'GEMCLI_PROFILE'; | |
| export const DEFAULT_PROFILE_NAME = 'default'; | |
| export const REQID_INCREMENT = 100000; | |
| export const INNER_REQ_LIST_SIZE = 70; | |
| export const VIDEO_TOOL_ID = 11; | |
| export const IMAGE_TOOL_ID = 14; | |
| export const MUSIC_TOOL_ID = 21; | |
| export const RESPONSE_PATHS = { | |
| inner_json: [2], metadata: [1], candidates: [4], completion: [25], | |
| error_code: [5, 2, 0, 1, 0], candidate_rcid: [0], candidate_text: [1, 0], | |
| candidate_thoughts: [37, 0, 0], candidate_web_images: [12, 1], | |
| candidate_generated_images: [12, 7, 0], candidate_generated_music: [12, 86], | |
| candidate_generated_video: [12, 59], | |
| }; | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // SECTION 2 — Exceptions | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| export class GeminiError extends Error { | |
| constructor(message) { super(message); this.name = new.target.name; Object.setPrototypeOf(this, new.target.prototype); } | |
| } | |
| export class AuthError extends GeminiError {} | |
| export class APIError extends GeminiError { constructor(message, errorCode = null) { super(message); this.errorCode = errorCode; } } | |
| export class UsageLimitExceeded extends GeminiError {} | |
| export class ModelInvalid extends GeminiError {} | |
| export class TemporarilyBlocked extends GeminiError {} | |
| export class GeminiTimeoutError extends GeminiError {} | |
| export class ImageGenerationError extends APIError {} | |
| export class VideoGenerationError extends APIError {} | |
| export class MusicGenerationError extends APIError {} | |
| export class ResearchError extends APIError {} | |
| export class ProfileError extends GeminiError {} | |
| export class TokenFactoryError extends GeminiError {} | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // SECTION 3 — Profiles | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| function configDir() { return path.join(os.homedir(), CONFIG_DIR_NAME); } | |
| function nlmConfigDir() { return path.join(os.homedir(), NLM_CONFIG_DIR_NAME); } | |
| function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } } | |
| export class ProfileManager { | |
| constructor({ configDir: cd, nlmConfigDir: ncd } = {}) { | |
| this.configDir = cd ?? configDir(); | |
| this.profilesDir = path.join(this.configDir, PROFILES_DIR); | |
| this.configFile = path.join(this.configDir, CONFIG_FILE); | |
| this.nlmConfigDir = ncd ?? nlmConfigDir(); | |
| this.nlmProfilesDir = path.join(this.nlmConfigDir, PROFILES_DIR); | |
| } | |
| #ensureDirs() { fs.mkdirSync(this.configDir, { recursive: true }); fs.mkdirSync(this.profilesDir, { recursive: true }); } | |
| #profileDir(name) { return path.join(this.profilesDir, name); } | |
| #authFilePath(name) { return path.join(this.#profileDir(name), AUTH_FILE); } | |
| #nlmProfileDir(name) { return path.join(this.nlmProfilesDir, name); } | |
| #readConfig() { | |
| if (fs.existsSync(this.configFile)) { try { return JSON.parse(fs.readFileSync(this.configFile, 'utf-8')); } catch { return {}; } } | |
| return {}; | |
| } | |
| #writeConfig(config) { this.#ensureDirs(); fs.writeFileSync(this.configFile, JSON.stringify(config, null, 2)); } | |
| getActiveProfile() { | |
| const env = process.env[ENV_PROFILE]; | |
| if (env) return env; | |
| const cfg = this.#readConfig(); | |
| return cfg.active_profile ?? DEFAULT_PROFILE_NAME; | |
| } | |
| setActiveProfile(name) { | |
| if (!this.profileExists(name)) throw new ProfileError(`Profile '${name}' does not exist.`); | |
| const cfg = this.#readConfig(); | |
| cfg.active_profile = name; cfg.version = 1; | |
| this.#writeConfig(cfg); | |
| logger.info(`Switched to profile: ${name}`); | |
| } | |
| profileExists(name) { return isDir(this.#profileDir(name)) || isDir(this.#nlmProfileDir(name)); } | |
| #listGemcliProfiles() { | |
| if (!fs.existsSync(this.profilesDir)) return []; | |
| return fs.readdirSync(this.profilesDir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name).sort(); | |
| } | |
| #listNlmProfiles() { | |
| if (!fs.existsSync(this.nlmProfilesDir)) return []; | |
| return fs.readdirSync(this.nlmProfilesDir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name).sort(); | |
| } | |
| listProfiles() { return Array.from(new Set([...this.#listGemcliProfiles(), ...this.#listNlmProfiles()])).sort(); } | |
| listProfilesDetailed() { | |
| const profiles = new Map(); | |
| for (const name of this.#listNlmProfiles()) profiles.set(name, { name, source: 'nlm', path: this.#nlmProfileDir(name), displayName: `${name} (from NotebookLM)` }); | |
| for (const name of this.#listGemcliProfiles()) profiles.set(name, { name, source: 'gemcli', path: this.#profileDir(name), displayName: name }); | |
| return Array.from(profiles.values()).sort((a, b) => a.name.localeCompare(b.name)); | |
| } | |
| getProfileSource(name) { | |
| if (isDir(this.#profileDir(name))) return 'gemcli'; | |
| if (isDir(this.#nlmProfileDir(name))) return 'nlm'; | |
| throw new ProfileError(`Profile '${name}' does not exist.`); | |
| } | |
| createProfile(name) { | |
| this.#ensureDirs(); | |
| const dir = this.#profileDir(name); | |
| if (fs.existsSync(dir)) throw new ProfileError(`Profile '${name}' already exists.`); | |
| fs.mkdirSync(dir, { recursive: true }); logger.info(`Created profile: ${name}`); return dir; | |
| } | |
| deleteProfile(name) { | |
| const dir = this.#profileDir(name); | |
| if (!fs.existsSync(dir)) { | |
| if (isDir(this.#nlmProfileDir(name))) throw new ProfileError(`Profile '${name}' is from NotebookLM MCP. Delete it from NotebookLM instead.`); | |
| throw new ProfileError(`Profile '${name}' does not exist.`); | |
| } | |
| if (this.getActiveProfile() === name) throw new ProfileError(`Cannot delete the active profile '${name}'. Switch to another profile first.`); | |
| fs.rmSync(dir, { recursive: true, force: true }); logger.info(`Deleted profile: ${name}`); | |
| } | |
| renameProfile(oldName, newName) { | |
| const oldDir = this.#profileDir(oldName); | |
| if (!fs.existsSync(oldDir)) { | |
| if (isDir(this.#nlmProfileDir(oldName))) throw new ProfileError(`Profile '${oldName}' is from NotebookLM MCP. Rename it from NotebookLM instead.`); | |
| throw new ProfileError(`Profile '${oldName}' does not exist.`); | |
| } | |
| const trimmed = newName.trim(); | |
| if (!trimmed) throw new ProfileError('New profile name cannot be empty.'); | |
| if (this.profileExists(trimmed)) throw new ProfileError(`Profile '${trimmed}' already exists.`); | |
| const newDir = this.#profileDir(trimmed); | |
| fs.renameSync(oldDir, newDir); | |
| logger.debug(`Renamed profile dir: ${oldName} -> ${trimmed}`); | |
| const chromeProfilesDir = path.join(this.configDir, 'chrome-profiles'); | |
| const oldChrome = path.join(chromeProfilesDir, oldName); | |
| const newChrome = path.join(chromeProfilesDir, trimmed); | |
| if (fs.existsSync(oldChrome)) { fs.renameSync(oldChrome, newChrome); logger.debug(`Renamed Chrome profile: ${oldName} -> ${trimmed}`); } | |
| const oldTf = path.join(chromeProfilesDir, `${oldName}_tf`); | |
| const newTf = path.join(chromeProfilesDir, `${trimmed}_tf`); | |
| if (fs.existsSync(oldTf)) { fs.renameSync(oldTf, newTf); logger.debug(`Renamed TF profile: ${oldName}_tf -> ${trimmed}_tf`); } | |
| const authFile = this.#authFilePath(trimmed); | |
| if (fs.existsSync(authFile)) { | |
| try { | |
| const data = JSON.parse(fs.readFileSync(authFile, 'utf-8')); | |
| const oldPath = data.chrome_profile_path ?? ''; | |
| if (oldPath && oldPath.includes(oldName)) { data.chrome_profile_path = oldPath.replace(`/${oldName}`, `/${trimmed}`); fs.writeFileSync(authFile, JSON.stringify(data, null, 2)); } | |
| } catch (e) { logger.warn(`Could not update auth.json: ${e.message}`); } | |
| } | |
| if (this.getActiveProfile() === oldName) { const cfg = this.#readConfig(); cfg.active_profile = trimmed; this.#writeConfig(cfg); logger.debug(`Updated active profile: ${oldName} -> ${trimmed}`); } | |
| logger.info(`Renamed profile: ${oldName} -> ${trimmed}`); | |
| } | |
| loadAuth(name) { | |
| const profileName = name ?? this.getActiveProfile(); | |
| const gemcliAuth = this.#authFilePath(profileName); | |
| if (fs.existsSync(gemcliAuth)) { try { return JSON.parse(fs.readFileSync(gemcliAuth, 'utf-8')); } catch (e) { logger.warn(`Failed to load gemcli auth for '${profileName}': ${e.message}`); } } | |
| const nlmChromeDir = path.join(this.nlmConfigDir, 'chrome-profiles', profileName); | |
| if (isDir(nlmChromeDir)) { logger.debug(`Using NLM Chrome profile for '${profileName}': ${nlmChromeDir}`); return { chrome_profile_path: nlmChromeDir, _source: 'nlm', _nlm_profile: profileName }; } | |
| return {}; | |
| } | |
| saveAuth(authData, name) { | |
| const profileName = name ?? this.getActiveProfile(); | |
| this.#ensureDirs(); | |
| const dir = this.#profileDir(profileName); | |
| fs.mkdirSync(dir, { recursive: true }); | |
| fs.writeFileSync(this.#authFilePath(profileName), JSON.stringify(authData, null, 2)); | |
| logger.debug(`Saved auth for profile: ${profileName}`); | |
| } | |
| getChromeProfilePath(name) { | |
| const profileName = name ?? this.getActiveProfile(); | |
| const gemcliAuth = this.#authFilePath(profileName); | |
| if (fs.existsSync(gemcliAuth)) { try { const data = JSON.parse(fs.readFileSync(gemcliAuth, 'utf-8')); if (data.chrome_profile_path) return data.chrome_profile_path; } catch { } } | |
| const nlmChrome = path.join(this.nlmConfigDir, 'chrome-profiles', profileName); | |
| if (isDir(nlmChrome)) return nlmChrome; | |
| return null; | |
| } | |
| hasNlmProfiles() { return this.#listNlmProfiles().length > 0; } | |
| isFirstRun() { return this.#listGemcliProfiles().length === 0; } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // SECTION 4 — Models (data classes) | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| function stripPrefix(s, prefix) { return s.startsWith(prefix) ? s.slice(prefix.length) : s; } | |
| function ensurePrefix(s, prefix) { if (!s) return s; return s.startsWith(prefix) ? s : prefix + s; } | |
| export class RPCPayload { | |
| constructor({ rpcId, payload = '[]', identifier = 'generic' }) { | |
| this.rpcId = rpcId; this.payload = payload; this.identifier = identifier; | |
| } | |
| serialize() { return [this.rpcId, this.payload, null, this.identifier]; } | |
| } | |
| export class ConversationMetadata { | |
| constructor({ cid = '', rid = '', rcid = '', extra } = {}) { | |
| this.cid = stripPrefix(cid, 'c_'); this.rid = stripPrefix(rid, 'r_'); this.rcid = stripPrefix(rcid, 'rc_'); | |
| this.extra = extra ?? new Array(7).fill(null); | |
| } | |
| toList() { | |
| const cid = this.cid ? ensurePrefix(this.cid, 'c_') : ''; | |
| const rid = this.rid ? ensurePrefix(this.rid, 'r_') : ''; | |
| const rcid = this.rcid ? ensurePrefix(this.rcid, 'rc_') : ''; | |
| const tail = [...this.extra]; | |
| while (tail.length < 7) tail.push(null); | |
| if (tail.length > 7) tail.length = 7; | |
| return [cid, rid, rcid, ...tail, '']; | |
| } | |
| static fromList(data) { | |
| if (!Array.isArray(data)) return new ConversationMetadata(); | |
| return new ConversationMetadata({ | |
| cid: typeof data[0] === 'string' && data[0] ? data[0] : '', | |
| rid: typeof data[1] === 'string' && data[1] ? data[1] : '', | |
| rcid: typeof data[2] === 'string' && data[2] ? data[2] : '', | |
| extra: data.length > 3 ? data.slice(3, 10) : new Array(7).fill(null), | |
| }); | |
| } | |
| } | |
| export class Candidate { | |
| constructor({ rcid = null, text = null, thoughts = null, webImages = null, generatedImages = null, generatedMusic = null, generatedVideo = null } = {}) { | |
| this.rcid = rcid; this.text = text; this.thoughts = thoughts; | |
| this.webImages = webImages; this.generatedImages = generatedImages; | |
| this.generatedMusic = generatedMusic; this.generatedVideo = generatedVideo; | |
| } | |
| } | |
| export class StreamResponse { | |
| constructor({ metadata = null, candidates = [], text = null, thoughts = null, isComplete = false, serverModelHash = null, serverModelLabel = null } = {}) { | |
| this.metadata = metadata ?? new ConversationMetadata(); this.candidates = candidates; | |
| this.text = text; this.thoughts = thoughts; this.isComplete = isComplete; | |
| this.serverModelHash = serverModelHash; this.serverModelLabel = serverModelLabel; | |
| } | |
| get hasImages() { return this.candidates.some(c => c.generatedImages?.length > 0); } | |
| get hasMusic() { return this.candidates.some(c => c.generatedMusic?.length > 0); } | |
| get hasVideo() { return this.candidates.some(c => c.generatedVideo?.length > 0); } | |
| } | |
| export class BatchResult { | |
| constructor({ rpcId = null, data = null, raw = null } = {}) { this.rpcId = rpcId; this.data = data; this.raw = raw; } | |
| } | |
| export const ZRPCPayload = z.object({ rpcId: z.string(), payload: z.string().default('[]'), identifier: z.string().default('generic') }); | |
| export const ZConversationMetadata = z.object({ cid: z.string().default(''), rid: z.string().default(''), rcid: z.string().default(''), extra: z.array(z.unknown()).default(() => new Array(7).fill(null)) }); | |
| export const ZCandidate = z.object({ rcid: z.string().nullable().optional(), text: z.string().nullable().optional(), thoughts: z.string().nullable().optional(), webImages: z.array(z.unknown()).nullable().optional(), generatedImages: z.array(z.unknown()).nullable().optional(), generatedMusic: z.array(z.unknown()).nullable().optional(), generatedVideo: z.array(z.unknown()).nullable().optional() }); | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // SECTION 5 — Auth | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| export class AuthTokens { | |
| constructor(snlm0e = '', cfb2h = '', fdrfje = '') { this.snlm0e = snlm0e; this.cfb2h = cfb2h; this.fdrfje = fdrfje; } | |
| } | |
| export class AuthState { | |
| constructor({ cookies = {}, tokens = new AuthTokens(), chromeProfilePath = null, lastRefreshed = 0, email = '' } = {}) { | |
| this.cookies = cookies; this.tokens = tokens; this.chromeProfilePath = chromeProfilePath; | |
| this.lastRefreshed = lastRefreshed; this.email = email; | |
| } | |
| toDict() { | |
| const d = { cookies: this.cookies, tokens: { snlm0e: this.tokens.snlm0e, cfb2h: this.tokens.cfb2h, fdrfje: this.tokens.fdrfje }, chrome_profile_path: this.chromeProfilePath, last_refreshed: this.lastRefreshed }; | |
| if (this.email) d.email = this.email; return d; | |
| } | |
| static fromDict(data) { | |
| const tokensData = data.tokens ?? {}; | |
| return new AuthState({ | |
| cookies: data.cookies ?? {}, tokens: new AuthTokens(tokensData.snlm0e ?? '', tokensData.cfb2h ?? '', tokensData.fdrfje ?? ''), | |
| chromeProfilePath: data.chrome_profile_path ?? null, lastRefreshed: data.last_refreshed ?? 0, email: data.email ?? '', | |
| }); | |
| } | |
| get isValid() { return Boolean(this.cookies[COOKIE_1PSID] && this.tokens.snlm0e); } | |
| } | |
| export function extractTokens(html) { | |
| const tokens = new AuthTokens(); | |
| for (const [name, pattern] of Object.entries(TOKEN_PATTERNS)) { | |
| const m = html.match(pattern); | |
| if (m) tokens[name] = m[1]; | |
| } | |
| if (!tokens.snlm0e) throw new AuthError('Failed to extract SNlM0e access token from Gemini page. Cookies may be expired or invalid.'); | |
| return tokens; | |
| } | |
| export function extractEmail(html) { const m = html.match(EMAIL_PATTERN); return m ? m[1] : ''; } | |
| export class CookieJar { | |
| constructor(cookies = {}) { | |
| this._store = new Map(); | |
| if (cookies && typeof cookies === 'object') { | |
| for (const [k, v] of Object.entries(cookies)) { | |
| if (typeof v === 'string') this._store.set(k, v); | |
| } | |
| } | |
| } | |
| set(name, value, attrs = {}) { this._store.set(name, String(value)); return this; } | |
| get(name) { return this._store.get(name) ?? null; } | |
| toObject() { const o = {}; for (const [k, v] of this._store) o[k] = v; return o; } | |
| toHeader() { return Array.from(this._store.entries()).map(([k, v]) => `${k}=${v}`).join('; '); } | |
| toArray() { return Array.from(this._store.entries()).map(([k, v]) => ({ name: k, value: v, domain: COOKIE_DOMAIN, path: '/', secure: true, httpOnly: true, session: false })); } | |
| attachTo(client) { | |
| const self = this; | |
| client.interceptors.request.use(config => { | |
| const existing = config.headers.cookie || config.headers.Cookie || ''; | |
| const our = self.toHeader(); | |
| config.headers.cookie = existing ? `${existing}; ${our}` : our; | |
| return config; | |
| }); | |
| client.interceptors.response.use(response => { | |
| const setCookie = response.headers['set-cookie']; | |
| if (setCookie) { | |
| const arr = Array.isArray(setCookie) ? setCookie : [setCookie]; | |
| for (const raw of arr) { | |
| const eq = raw.indexOf('='); | |
| const semi = raw.indexOf(';'); | |
| if (eq > 0) { | |
| const name = raw.slice(0, eq).trim(); | |
| const value = semi > eq ? raw.slice(eq + 1, semi).trim() : raw.slice(eq + 1).trim(); | |
| if (name && value) self._store.set(name, value); | |
| } | |
| } | |
| } | |
| return response; | |
| }); | |
| } | |
| } | |
| export async function fetchTokens(cookies, proxy = null) { | |
| const jar = new CookieJar(cookies); | |
| const client = axios.create({ timeout: 30_000, headers: { ...GEMINI_HEADERS }, maxRedirects: 5, proxy: proxy ? parseAxiosProxy(proxy) : false, validateStatus: () => true }); | |
| jar.attachTo(client); | |
| const res = await client.get(Endpoint.INIT); | |
| if (res.status >= 400) throw new AuthError(`Gemini init page returned ${res.status} — cookies may be invalid.`); | |
| const html = String(res.data ?? ''); | |
| return { tokens: extractTokens(html), cookies: jar.toObject(), email: extractEmail(html) }; | |
| } | |
| export async function rotateCookies(cookies, proxy = null) { | |
| const jar = new CookieJar(cookies); | |
| const client = axios.create({ timeout: 30_000, headers: { ...ROTATE_COOKIES_HEADERS }, proxy: proxy ? parseAxiosProxy(proxy) : false, validateStatus: () => true }); | |
| jar.attachTo(client); | |
| const res = await client.post(Endpoint.ROTATE_COOKIES, ROTATE_COOKIES_BODY); | |
| if (res.status === 401) throw new AuthError('Cookie rotation returned 401. Full re-authentication required.'); | |
| if (res.status >= 400) throw new AuthError(`Cookie rotation failed: HTTP ${res.status}`); | |
| const updated = jar.toObject(); | |
| if (updated[COOKIE_1PSIDTS] && updated[COOKIE_1PSIDTS] !== cookies[COOKIE_1PSIDTS]) return updated; | |
| return null; | |
| } | |
| function parseAxiosProxy(proxy) { | |
| try { | |
| const u = new URL(proxy); | |
| const port = u.port ? parseInt(u.port, 10) : (u.protocol === 'https:' ? 443 : 80); | |
| const out = { protocol: u.protocol.replace(':', ''), host: u.hostname, port }; | |
| if (u.username) out.auth = { username: decodeURIComponent(u.username), password: decodeURIComponent(u.password ?? '') }; | |
| return out; | |
| } catch { return false; } | |
| } | |
| export class AuthManager { | |
| #state = null; | |
| constructor(profileManager, profileName = null, proxy = null) { this.profileManager = profileManager; this.profileName = profileName; this.proxy = proxy; } | |
| get state() { if (!this.#state) throw new AuthError('AuthManager not initialized. Call load() first.'); return this.#state; } | |
| load() { const data = this.profileManager.loadAuth(this.profileName ?? undefined); this.#state = data && Object.keys(data).length > 0 ? AuthState.fromDict(data) : new AuthState(); return this.#state; } | |
| save() { if (this.#state) this.profileManager.saveAuth(this.#state.toDict(), this.profileName ?? undefined); } | |
| async refreshTokens() { | |
| if (!this.state.cookies[COOKIE_1PSID] && !this.state.cookies['__Secure-3PSID']) throw new AuthError('No cookies available. Provide cookies first.'); | |
| logger.debug('auth: refreshing tokens from Gemini app page…'); | |
| const { tokens, cookies, email } = await fetchTokens(this.state.cookies, this.proxy); | |
| this.state.tokens = tokens; | |
| if (email) this.state.email = email; | |
| for (const [k, v] of Object.entries(cookies)) this.state.cookies[k] = v; | |
| this.state.lastRefreshed = Date.now() / 1000; | |
| this.save(); logger.debug('auth: tokens refreshed.'); return tokens; | |
| } | |
| async refreshCookies() { | |
| const elapsed = Date.now() / 1000 - this.state.lastRefreshed; | |
| if (elapsed < COOKIE_REFRESH_COOLDOWN) { logger.debug(`auth: cookie-refresh cooldown ${Math.round(COOKIE_REFRESH_COOLDOWN - elapsed)}s remaining`); return false; } | |
| try { | |
| const updated = await rotateCookies(this.state.cookies, this.proxy); | |
| if (updated) { this.state.cookies = updated; this.state.lastRefreshed = Date.now() / 1000; this.save(); logger.debug('auth: cookies refreshed via RotateCookies.'); return true; } | |
| } catch (err) { if (err instanceof AuthError) { logger.warn('auth: cookie rotation failed (401). Full re-auth needed.'); throw err; } logger.warn(`auth: cookie rotation failed: ${err.message}`); } | |
| return false; | |
| } | |
| async recover() { | |
| try { await this.refreshTokens(); if (this.state.isValid) return this.state; } catch { logger.debug('auth: layer 1 (token refresh) failed.'); } | |
| try { this.load(); if (this.state.isValid) { logger.debug('auth: layer 2 (disk reload) succeeded.'); return this.state; } } catch { logger.debug('auth: layer 2 (disk reload) failed.'); } | |
| throw new AuthError('All recovery layers exhausted. Re-paste your Gemini cookies via POST /api/gemini/cookies.'); | |
| } | |
| saveCookies(cookies) { if (!this.#state) this.#state = new AuthState(); this.#state.cookies = { ...cookies }; this.#state.lastRefreshed = Date.now() / 1000; this.save(); } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // SECTION 6 — Parser | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| const LENGTH_MARKER_RE = /^(\d+)\n/; | |
| function utf16CharCount(s, start, utf16Units) { | |
| const remaining = s.length - start; | |
| if (remaining < utf16Units) return [remaining, remaining]; | |
| return [utf16Units, utf16Units]; | |
| } | |
| export function stripJsonpPrefix(text) { | |
| if (text.startsWith(")]}'")) return text.slice(4).replace(/^\n+/, ''); | |
| return text; | |
| } | |
| export function parseFrames(content) { | |
| let pos = 0; | |
| const total = content.length; | |
| const frames = []; | |
| while (pos < total) { | |
| while (pos < total && /\s/.test(content[pos])) pos++; | |
| if (pos >= total) break; | |
| const slice = content.slice(pos); | |
| const m = slice.match(LENGTH_MARKER_RE); | |
| if (!m) break; | |
| const frameLength = parseInt(m[1], 10); | |
| const contentStart = pos + m[1].length; | |
| const [charCount] = utf16CharCount(content, contentStart, frameLength); | |
| const endPos = contentStart + charCount; | |
| const chunk = content.slice(contentStart, endPos).trim(); | |
| pos = endPos; | |
| if (!chunk) continue; | |
| try { | |
| const parsed = JSON.parse(chunk); | |
| if (Array.isArray(parsed)) frames.push(...parsed); else frames.push(parsed); | |
| } catch { logger.debug(`Failed to parse frame JSON: ${chunk.slice(0, 100)}...`); } | |
| } | |
| return [frames, content.slice(pos)]; | |
| } | |
| export function extractJsonFromResponse(text) { | |
| let content = stripJsonpPrefix(text).replace(/^\s+/, ''); | |
| const [frames] = parseFrames(content); | |
| if (frames.length > 0) return frames; | |
| try { const parsed = JSON.parse(content.trim()); return Array.isArray(parsed) ? parsed : [parsed]; } catch { } | |
| const collected = []; | |
| for (const rawLine of content.trim().split(/\r?\n/)) { | |
| const line = rawLine.trim(); | |
| if (!line) continue; | |
| try { const parsed = JSON.parse(line); if (Array.isArray(parsed)) collected.push(...parsed); else if (parsed !== null && typeof parsed === 'object') collected.push(parsed); } catch { continue; } | |
| } | |
| if (collected.length > 0) return collected; | |
| throw new Error('Could not parse any valid JSON from the response.'); | |
| } | |
| export function getNested(data, path, defaultValue = null) { | |
| let current = data; | |
| for (const key of path) { | |
| try { | |
| if (typeof key === 'number') { | |
| if (Array.isArray(current)) { | |
| if (key >= -current.length && key < current.length) { current = current[key < 0 ? current.length + key : key]; } | |
| else if (current.length === 1 && current[0] !== null && typeof current[0] === 'object' && !Array.isArray(current[0])) { const pk = String(key + 1); if (pk in current[0]) current = current[0][pk]; else return defaultValue; } | |
| else return defaultValue; | |
| } else if (current !== null && typeof current === 'object' && !Array.isArray(current)) { const pk = String(key + 1); if (pk in current) current = current[pk]; else return defaultValue; } | |
| else return defaultValue; | |
| } else { if (current !== null && typeof current === 'object' && key in current) current = current[key]; else return defaultValue; } | |
| } catch { return defaultValue; } | |
| } | |
| return (current ?? defaultValue); | |
| } | |
| export function parseBatchexecuteResponse(text) { | |
| const frames = extractJsonFromResponse(text); | |
| const results = []; | |
| for (const frame of frames) { | |
| if (!Array.isArray(frame)) continue; | |
| if (frame.length >= 3 && frame[0] === 'wrb.fr') { | |
| const rawJson = frame[2]; | |
| let data = null; | |
| if (rawJson && typeof rawJson === 'string') { try { data = JSON.parse(rawJson); } catch { data = rawJson; } } | |
| results.push({ rpc_id: frame[1], data, raw: rawJson }); | |
| } | |
| } | |
| return results; | |
| } | |
| const KNOWN_MODEL_HASHES = new Set(['56fdd199312815e2', 'e051ce1aa80aa576', 'e6fa609c3fa255c0', 'fbb127bbb056c959', '9d8ca3786ebdfbea', '5bf011840784117a']); | |
| const KNOWN_MODEL_LABELS = new Set(['Fast', 'Thinking', 'Pro']); | |
| function extractServerModel(data, depth = 0) { | |
| if (depth > 10 || !Array.isArray(data)) return [null, null]; | |
| for (let i = 0; i < data.length; i++) { | |
| const item = data[i]; | |
| if (typeof item === 'string' && item.length === 16 && KNOWN_MODEL_HASHES.has(item)) { | |
| let label = null; | |
| for (let off = 1; off <= 5; off++) { | |
| if (i + off < data.length) { const v = data[i + off]; if (typeof v === 'string' && KNOWN_MODEL_LABELS.has(v)) { label = v; break; } } | |
| } | |
| return [item, label]; | |
| } | |
| } | |
| for (const item of data) { if (Array.isArray(item)) { const [h, l] = extractServerModel(item, depth + 1); if (h) return [h, l]; } } | |
| return [null, null]; | |
| } | |
| export function parseStreamResponse(text) { | |
| const frames = extractJsonFromResponse(text); | |
| const results = []; | |
| for (const frame of frames) { | |
| if (!Array.isArray(frame)) continue; | |
| if (frame.length < 3 || frame[0] !== 'wrb.fr') continue; | |
| const rawInner = frame[2]; | |
| if (!rawInner || typeof rawInner !== 'string') continue; | |
| let inner; | |
| try { inner = JSON.parse(rawInner); } catch { continue; } | |
| if (!Array.isArray(inner)) continue; | |
| const result = { metadata: getNested(inner, [1]), candidates: [], text: null, thoughts: null, completion: getNested(inner, [25]), server_model_hash: null, server_model_label: null }; | |
| const candidatesData = getNested(inner, [4]); | |
| if (Array.isArray(candidatesData)) { | |
| for (const candidate of candidatesData) { | |
| if (!Array.isArray(candidate)) continue; | |
| result.candidates.push({ | |
| rcid: getNested(candidate, [0]), text: getNested(candidate, [1, 0]), thoughts: getNested(candidate, [37, 0, 0]), | |
| web_images: getNested(candidate, [12, 1]), generated_images: getNested(candidate, [12, 7, 0]), | |
| generated_music: getNested(candidate, [12, 86]), generated_video: getNested(candidate, [12, 59]), | |
| }); | |
| } | |
| if (result.candidates.length > 0) { result.text = result.candidates[0].text; result.thoughts = result.candidates[0].thoughts; } | |
| } | |
| const [hash, label] = extractServerModel(inner); | |
| if (hash) { result.server_model_hash = hash; result.server_model_label = label; } | |
| results.push(result); | |
| } | |
| return results; | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // SECTION 7 — RPC Transport | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| export function buildBatchexecuteBody(payloads, accessToken) { | |
| const serialized = [payloads.map(p => p.serialize())]; | |
| const params = new URLSearchParams(); | |
| params.set('f.req', JSON.stringify(serialized)); | |
| params.set('at', accessToken); | |
| return params.toString() + '&'; | |
| } | |
| export function buildBatchexecuteUrl({ rpcIds, reqid, buildLabel, sessionId, sourcePath = '/app' }) { | |
| const params = new URLSearchParams(); | |
| params.set('rpcids', rpcIds.join(',')); | |
| params.set('source-path', sourcePath); | |
| if (buildLabel) params.set('bl', buildLabel); | |
| if (sessionId) params.set('f.sid', sessionId); | |
| params.set('hl', 'en'); | |
| params.set('_reqid', String(reqid)); | |
| params.set('rt', 'c'); | |
| return `${Endpoint.BATCH_EXEC}?${params.toString()}`; | |
| } | |
| export function buildStreamgenerateBody({ prompt, accessToken, metadata, fileData, gemId, toolId, stylePreset, botguardToken, botguardHash }) { | |
| const inner = new Array(INNER_REQ_LIST_SIZE).fill(null); | |
| const innerZero = [prompt, 0, null, fileData ?? null, null, null, 0]; | |
| if (stylePreset) { try { logger.debug('musicPresets module not yet ported; ignoring stylePreset'); } catch { } } | |
| inner[0] = innerZero; inner[1] = ['en']; | |
| if (metadata) inner[2] = metadata.toList(); else inner[2] = ['', '', '', null, null, null, null, null, null, null]; | |
| if (botguardToken) inner[3] = botguardToken; | |
| if (botguardHash) inner[4] = botguardHash; | |
| inner[6] = [1]; inner[7] = 1; inner[10] = 1; inner[11] = 0; inner[17] = [[0]]; inner[18] = 0; | |
| if (gemId) inner[19] = gemId; | |
| inner[27] = 1; inner[30] = [4]; inner[41] = [1]; | |
| if (toolId !== null && toolId !== undefined) inner[49] = toolId; | |
| inner[53] = 0; inner[59] = randomUUID().toUpperCase(); inner[61] = []; | |
| const nowMs = Date.now(); inner[66] = [Math.floor(nowMs / 1000), (nowMs % 1000) * 1_000_000]; inner[68] = 2; | |
| const params = new URLSearchParams(); | |
| params.set('f.req', JSON.stringify([null, JSON.stringify(inner)])); | |
| params.set('at', accessToken); | |
| return params.toString() + '&'; | |
| } | |
| export function buildStreamgenerateUrl({ reqid, buildLabel, sessionId }) { | |
| const params = new URLSearchParams(); | |
| if (buildLabel) params.set('bl', buildLabel); | |
| if (sessionId) params.set('f.sid', sessionId); | |
| params.set('hl', 'en'); | |
| params.set('_reqid', String(reqid)); | |
| params.set('rt', 'c'); | |
| return `${Endpoint.GENERATE}?${params.toString()}`; | |
| } | |
| export function buildHeaders(model, cid, tempSession = null) { | |
| const headers = { ...GEMINI_HEADERS }; | |
| if (tempSession) { | |
| const { clientUUID, sessionUUID } = tempSession; | |
| headers[MODEL_HEADER_KEY] = `[1,null,null,null,"${TEMP_MODEL_HASH}",null,null,1,[4],null,null,1,null,null,1,null,"${clientUUID}"]`; | |
| headers[SESSION_HEADER_KEY] = `["${sessionUUID}",1]`; | |
| headers[TRACKING_HEADER_KEY] = '[0]'; headers[SAFETY_HEADER_KEY] = '[0,0,0]'; | |
| } else if (model && Object.keys(model.header).length > 0) { Object.assign(headers, model.header); } | |
| if (cid) { const bare = cid.startsWith('c_') ? cid.slice(2) : cid; headers.Referer = `https://gemini.google.com/app/${bare}`; } | |
| return headers; | |
| } | |
| export function buildSyncStateBody(accessToken) { | |
| const arr = new Array(SYNC_STATE_NULL_COUNT).fill(null); | |
| arr[SYNC_STATE_NULL_COUNT - 1] = 5; | |
| const payload = JSON.stringify([arr, [['popup_zs_visits_cooldown']]]); | |
| const rpc = new RPCPayload({ rpcId: RPC.SYNC_STATE, payload }); | |
| return buildBatchexecuteBody([rpc], accessToken); | |
| } | |
| export function buildStreamResponse(responseText) { | |
| const parsedFrames = parseStreamResponse(responseText); | |
| const result = new StreamResponse(); | |
| const prevMedia = new Map(); | |
| for (const frame of parsedFrames) { | |
| if (frame.metadata) result.metadata = ConversationMetadata.fromList(frame.metadata); | |
| if (frame.candidates?.length > 0) { | |
| const newCandidates = []; | |
| for (let i = 0; i < frame.candidates.length; i++) { | |
| const c = frame.candidates[i]; | |
| const prev = prevMedia.get(i) ?? { images: null, music: null, video: null }; | |
| const candidate = new Candidate({ | |
| rcid: c.rcid, text: c.text, thoughts: c.thoughts, webImages: c.web_images, | |
| generatedImages: c.generated_images || prev.images, | |
| generatedMusic: c.generated_music || prev.music, | |
| generatedVideo: c.generated_video || prev.video, | |
| }); | |
| newCandidates.push(candidate); | |
| prevMedia.set(i, { images: candidate.generatedImages, music: candidate.generatedMusic, video: candidate.generatedVideo }); | |
| } | |
| result.candidates = newCandidates; | |
| } | |
| if (frame.text) result.text = frame.text; | |
| if (frame.thoughts) result.thoughts = frame.thoughts; | |
| if (frame.completion !== null && frame.completion !== undefined) result.isComplete = true; | |
| if (frame.server_model_hash) { result.serverModelHash = frame.server_model_hash; result.serverModelLabel = frame.server_model_label; } | |
| } | |
| return result; | |
| } | |
| function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } | |
| export class RPCTransport { | |
| #reqid; | |
| constructor({ client, accessToken, buildLabel = null, sessionId = null }) { | |
| this.client = client; this.accessToken = accessToken; this.buildLabel = buildLabel; this.sessionId = sessionId; | |
| this.#reqid = randomInt(10000, 99999); | |
| } | |
| #nextReqid() { const c = this.#reqid; this.#reqid += REQID_INCREMENT; return c; } | |
| async batchexecute(payloads, sourcePath = '/app') { | |
| const rpcIds = payloads.map(p => p.rpcId); | |
| const url = buildBatchexecuteUrl({ rpcIds, reqid: this.#nextReqid(), buildLabel: this.buildLabel, sessionId: this.sessionId, sourcePath }); | |
| const body = buildBatchexecuteBody(payloads, this.accessToken); | |
| const cidFromPath = sourcePath.startsWith('/app/') ? sourcePath.slice('/app/'.length) : null; | |
| const headers = { ...buildHeaders(null, cidFromPath), ...BATCH_EXEC_HEADERS }; | |
| logger.debug(`batchexecute: ${rpcIds.join(',')}`); | |
| const response = await this.client.post(url, body, { headers, responseType: 'text', transformResponse: [d => d] }); | |
| if (response.status >= 400) throw new Error(`batchexecute HTTP ${response.status}: ${String(response.data).slice(0, 200)}`); | |
| return parseBatchexecuteResponse(String(response.data)).map(r => new BatchResult({ rpcId: r.rpc_id, data: r.data, raw: r.raw })); | |
| } | |
| async syncState() { | |
| const url = buildBatchexecuteUrl({ rpcIds: [RPC.SYNC_STATE], reqid: this.#nextReqid(), buildLabel: this.buildLabel, sessionId: this.sessionId }); | |
| const body = buildSyncStateBody(this.accessToken); | |
| const headers = { ...GEMINI_HEADERS, [TRACKING_HEADER_KEY]: '[0]' }; | |
| logger.debug('syncState: calling L5adhe for temp chat session'); | |
| const response = await this.client.post(url, body, { headers, responseType: 'text', transformResponse: [d => d] }); | |
| if (response.status >= 400) logger.warn(`syncState: HTTP ${response.status} — continuing anyway`); | |
| return response; | |
| } | |
| async streamGenerate(opts) { | |
| const tempSession = opts.temp ?? null; | |
| if (tempSession) await this.syncState().catch(e => logger.warn(`syncState failed: ${e.message}`)); | |
| const url = buildStreamgenerateUrl({ reqid: this.#nextReqid(), buildLabel: this.buildLabel, sessionId: this.sessionId }); | |
| const body = buildStreamgenerateBody({ prompt: opts.prompt, accessToken: this.accessToken, metadata: opts.metadata, fileData: opts.fileData, gemId: opts.gemId, toolId: opts.toolId, stylePreset: opts.stylePreset }); | |
| const cid = opts.metadata?.cid ?? null; | |
| const headers = buildHeaders(opts.model ?? DEFAULT_MODEL, cid, tempSession); | |
| logger.debug(`StreamGenerate${tempSession ? ' [TEMP]' : ''}: ${opts.prompt.slice(0, 50)}...`); | |
| const { onDelta } = opts; | |
| if (onDelta) { | |
| const response = await this.client.post(url, body, { headers, responseType: 'stream', timeout: 120000 }); | |
| if (response.status >= 400) throw new Error(`StreamGenerate HTTP ${response.status}`); | |
| return this._streamFrames(response.data, onDelta); | |
| } | |
| const response = await this.client.post(url, body, { headers, responseType: 'text', transformResponse: [d => d] }); | |
| if (response.status >= 400) throw new Error(`StreamGenerate HTTP ${response.status}: ${String(response.data).slice(0, 200)}`); | |
| return buildStreamResponse(String(response.data)); | |
| } | |
| async _streamFrames(stream, onDelta) { | |
| let buffer = ''; | |
| let chunkCount = 0; | |
| let frameCount = 0; | |
| let totalDeltaChars = 0; | |
| let loggedFrames = 0; | |
| let prevText = ''; | |
| const result = { metadata: null, cid: null, rid: null, rcid: null, serverModelLabel: null }; | |
| const emitInner = (inner) => { | |
| frameCount++; | |
| if (loggedFrames < 3) { | |
| const textPreview = JSON.stringify(getNested(inner, [4, 0, 1, 0])).slice(0, 80); | |
| const candLen = Array.isArray(getNested(inner, [4])) ? getNested(inner, [4]).length : 0; | |
| logger.debug(`_streamFrames: frame #${frameCount} inner_keys=${Object.keys(inner).length} inner[4] len=${candLen} text=${textPreview} inner[25]=${JSON.stringify(getNested(inner, [25]))}`); | |
| loggedFrames++; | |
| } | |
| if (!result.metadata) { | |
| const metaList = getNested(inner, [1]); | |
| if (Array.isArray(metaList)) { | |
| try { result.metadata = ConversationMetadata.fromList(metaList); } catch {} | |
| } | |
| } | |
| const text = getNested(inner, [4, 0, 1, 0]); | |
| if (text && text !== prevText) { | |
| const delta = text.slice(prevText.length); | |
| prevText = text; | |
| totalDeltaChars += delta.length; | |
| if (delta) onDelta(delta); | |
| } | |
| if (!result.serverModelLabel) { | |
| const [, label] = extractServerModel(inner); | |
| if (label) result.serverModelLabel = label; | |
| } | |
| }; | |
| const tryParseWrb = (frame) => { | |
| if (!Array.isArray(frame) || frame.length < 3 || frame[0] !== 'wrb.fr') return; | |
| const rawInner = frame[2]; | |
| if (!rawInner || typeof rawInner !== 'string') return; | |
| let inner; | |
| try { inner = JSON.parse(rawInner); } catch { return; } | |
| if (!Array.isArray(inner)) return; | |
| emitInner(inner); | |
| }; | |
| const consume = () => { | |
| buffer = stripJsonpPrefix(buffer); | |
| let consumed; | |
| // 1) Try length-prefixed frames | |
| do { | |
| consumed = false; | |
| buffer = buffer.replace(/^\s+/, ''); | |
| if (!buffer) break; | |
| const m = buffer.match(LENGTH_MARKER_RE); | |
| if (!m) break; | |
| const frameLength = parseInt(m[1], 10); | |
| const contentStart = m.index + m[0].length; | |
| const remaining = buffer.slice(contentStart); | |
| const [charCount] = utf16CharCount(remaining, 0, frameLength); | |
| if (remaining.length < charCount) break; | |
| let frameJson; | |
| try { frameJson = JSON.parse(remaining.slice(0, charCount)); } catch { break; } | |
| buffer = remaining.slice(charCount); | |
| consumed = true; | |
| if (Array.isArray(frameJson)) { | |
| for (const f of frameJson) tryParseWrb(f); | |
| } | |
| } while (consumed); | |
| // 2) Try newline-delimited JSON on remainder | |
| if (buffer.trim()) { | |
| const lines = buffer.split(/\r?\n/); | |
| const remaining = []; | |
| for (const line of lines) { | |
| const trimmed = line.trim(); | |
| if (!trimmed) continue; | |
| try { | |
| const parsed = JSON.parse(trimmed); | |
| if (Array.isArray(parsed)) { | |
| for (const f of parsed) tryParseWrb(f); | |
| } else { | |
| remaining.push(line); | |
| } | |
| } catch { | |
| remaining.push(line); | |
| } | |
| } | |
| buffer = remaining.join('\n'); | |
| } | |
| }; | |
| for await (const chunk of stream) { | |
| chunkCount++; | |
| const chunkStr = chunk.toString(); | |
| if (chunkCount <= 3) logger.debug(`_streamFrames: chunk #${chunkCount} size=${chunkStr.length} first100=${chunkStr.slice(0, 100)}`); | |
| buffer += chunkStr; | |
| consume(); | |
| } | |
| consume(); // final pass | |
| logger.debug(`_streamFrames: done — ${chunkCount} chunks, ${frameCount} frames, ${totalDeltaChars} chars streamed, prevText="${prevText.slice(0, 100)}"`); | |
| if (result.metadata) { | |
| result.cid = result.metadata.cid ?? null; | |
| result.rid = result.metadata.rid ?? null; | |
| result.rcid = result.metadata.rcid ?? null; | |
| } | |
| return result; | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // SECTION 8 — Upload | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| const UPLOAD_ENDPOINT = 'https://push.clients6.google.com/upload/'; | |
| const PUSH_ID_UPLOAD = 'feeds/mcudyrk2a4khkz'; | |
| const X_TENANT_ID = 'bard-storage'; | |
| const X_CLIENT_DATA = 'CJvoygE='; | |
| const X_CLIENT_PCTX = 'CgcSBWjK7pYx'; | |
| function formatCookieHeader(cookies) { | |
| return Object.entries(cookies).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => `${k}=${v}`).join('; '); | |
| } | |
| function makeUploadClient() { return axios.create({ timeout: 60_000, maxRedirects: 5, validateStatus: () => true }); } | |
| export async function uploadToContentPush(_client, cookies, content, mimeType, filename) { | |
| const cookieHeader = formatCookieHeader(cookies); | |
| if (!cookieHeader) { logger.warn('upload: no cookies available — cannot authenticate upload'); return null; } | |
| const http = makeUploadClient(); | |
| const step1Headers = { | |
| 'accept': '*/*', 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', 'push-id': PUSH_ID_UPLOAD, | |
| 'x-goog-upload-command': 'start', 'x-goog-upload-protocol': 'resumable', | |
| 'x-goog-upload-header-content-length': content.length.toString(), 'x-tenant-id': X_TENANT_ID, | |
| 'x-client-data': X_CLIENT_DATA, 'x-client-pctx': X_CLIENT_PCTX, 'cookie': cookieHeader, | |
| 'origin': 'https://gemini.google.com', 'referer': 'https://gemini.google.com/', | |
| }; | |
| let uploadUrl; | |
| try { | |
| logger.debug(`upload: step1 start — size=${content.length} mime=${mimeType} file=${filename}`); | |
| const r1 = await http.post(UPLOAD_ENDPOINT, `File name: ${filename}`, { headers: step1Headers, responseType: 'text', maxRedirects: 0 }); | |
| if (r1.status !== 200) { logger.warn(`upload: step1 failed status=${r1.status} body="${String(r1.data).slice(0, 200)}"`); return null; } | |
| uploadUrl = r1.headers['x-goog-upload-url'] ?? ''; | |
| if (!uploadUrl) { logger.warn('upload: step1 OK but no x-goog-upload-url header.'); return null; } | |
| logger.debug(`upload: step1 OK — upload_url=${uploadUrl.slice(0, 80)}…`); | |
| } catch (e) { logger.warn(`upload: step1 threw: ${e.message}`); return null; } | |
| const step2Headers = { | |
| 'accept': '*/*', 'content-type': mimeType, 'push-id': PUSH_ID_UPLOAD, | |
| 'x-goog-upload-command': 'upload, finalize', 'x-goog-upload-offset': '0', | |
| 'x-tenant-id': X_TENANT_ID, 'x-client-data': X_CLIENT_DATA, 'x-client-pctx': X_CLIENT_PCTX, | |
| 'cookie': cookieHeader, 'origin': 'https://gemini.google.com', 'referer': 'https://gemini.google.com/', | |
| }; | |
| try { | |
| logger.debug(`upload: step2 upload,finalize — url=${uploadUrl.slice(0, 80)}…`); | |
| const r2 = await http.post(uploadUrl, content, { headers: step2Headers, responseType: 'text', maxRedirects: 5 }); | |
| const body = (r2.data ?? ''); | |
| logger.debug(`upload: step2 status=${r2.status} body="${body.slice(0, 120)}"`); | |
| if (r2.status === 200 && body.trim().startsWith('/')) { logger.info(`upload: success → ${body.trim().slice(0, 80)}`); return body.trim(); } | |
| logger.warn(`upload: step2 failed status=${r2.status} body="${body.slice(0, 200)}"`); return null; | |
| } catch (e) { logger.warn(`upload: step2 threw: ${e.message}`); return null; } | |
| } | |
| export function buildFileDataItem(storagePath, mimeType, filename) { | |
| return [[storagePath, 1, null, mimeType], filename, null, null, null, null, null, null, [0]]; | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // SECTION 9 — Temporary (no-auth) Chat | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| const GEMINI_HOST = 'gemini.google.com'; | |
| const APP_PATH = '/app'; | |
| const STREAM_PATH = '/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate'; | |
| const LANG = 'en-US'; | |
| function httpsGet(opts) { | |
| return new Promise((resolve, reject) => { | |
| const req = https.request({ method: 'GET', ...opts }, res => { | |
| let body = ''; | |
| res.on('data', c => body += c); | |
| res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body })); | |
| }); | |
| req.on('error', reject); | |
| req.end(); | |
| }); | |
| } | |
| function httpsPost(opts, bodyStr) { | |
| return new Promise((resolve, reject) => { | |
| const req = https.request({ method: 'POST', ...opts }, res => { | |
| let body = ''; | |
| res.on('data', c => body += c); | |
| res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body })); | |
| }); | |
| req.on('error', reject); | |
| req.write(bodyStr); | |
| req.end(); | |
| }); | |
| } | |
| function extractWiz(html) { | |
| const marker = 'WIZ_global_data = '; | |
| const start = html.indexOf(marker); | |
| if (start < 0) return {}; | |
| const jsonStart = start + marker.length; | |
| let depth = 0, i = jsonStart, end = -1; | |
| while (i < html.length) { | |
| if (html[i] === '{') depth++; | |
| else if (html[i] === '}') { depth--; if (depth === 0) { end = i + 1; break; } } | |
| i++; | |
| } | |
| try { return end > 0 ? JSON.parse(html.slice(jsonStart, end)) : {}; } catch { return {}; } | |
| } | |
| function extractCookies(setCookieHeader) { | |
| if (!setCookieHeader) return ''; | |
| const arr = Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader]; | |
| return arr.map(c => c.split(';')[0]).join('; '); | |
| } | |
| async function fetchSession() { | |
| logger.debug('tempChat: fetching anonymous session…'); | |
| const res = await httpsGet({ | |
| hostname: GEMINI_HOST, path: APP_PATH, | |
| headers: { 'accept': 'text/html', 'accept-language': LANG, 'user-agent': CHROME_USER_AGENT }, | |
| }); | |
| const wiz = extractWiz(res.body); | |
| const fsid = wiz['FdrFJe']; | |
| const bl = wiz['cfb2h']; | |
| const cookies = extractCookies(res.headers['set-cookie']); | |
| if (!fsid || !bl) logger.warn('tempChat: could not extract f.sid / bl from page — proceeding without'); | |
| logger.debug(`tempChat: session acquired (f.sid=${fsid?.slice(0, 8)}…, bl=${bl})`); | |
| return { fsid, bl, cookies }; | |
| } | |
| let _reqid = Math.floor(Math.random() * 90000) + 10000; | |
| function nextReqid() { const r = _reqid; _reqid += REQID_INCREMENT; return r; } | |
| function buildNoAuthBody(prompt) { | |
| const inner = new Array(INNER_REQ_LIST_SIZE).fill(null); | |
| inner[0] = [prompt, 0, null, null, null, null, 0]; | |
| inner[1] = [LANG]; | |
| inner[2] = ['', '', '', null, null, null, null, null, null, '']; | |
| inner[6] = [1]; inner[7] = 1; inner[10] = 1; inner[11] = 0; inner[17] = [[0]]; inner[18] = 0; | |
| inner[27] = 1; inner[30] = [4]; inner[41] = [1]; inner[53] = 0; | |
| inner[59] = randomUUID().toUpperCase(); inner[61] = []; inner[68] = 2; | |
| return 'f.req=' + encodeURIComponent(JSON.stringify([null, JSON.stringify(inner)])); | |
| } | |
| function buildNoAuthHeaders(cookies) { | |
| const uuid = randomUUID().toUpperCase(); | |
| return { | |
| 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', 'user-agent': CHROME_USER_AGENT, | |
| 'accept-language': LANG, 'origin': 'https://gemini.google.com', 'referer': 'https://gemini.google.com/', | |
| 'x-same-domain': '1', | |
| [MODEL_HEADER_KEY]: `[1,null,null,null,"${TEMP_MODEL_HASH}",null,null,0,[4],null,null,1,null,null,1,null,"${uuid}"]`, | |
| [SESSION_HEADER_KEY]: `["${uuid}",1]`, [TRACKING_HEADER_KEY]: '[0]', [SAFETY_HEADER_KEY]: '[0,0,0]', | |
| ...(cookies ? { cookie: cookies } : {}), | |
| }; | |
| } | |
| export class TempChat { | |
| #session = null; | |
| async #getSession() { if (!this.#session) this.#session = await fetchSession(); return this.#session; } | |
| async send(prompt) { | |
| const { fsid, bl, cookies } = await this.#getSession(); | |
| const reqid = nextReqid(); | |
| const body = buildNoAuthBody(prompt); | |
| const headers = buildNoAuthHeaders(cookies); | |
| const params = new URLSearchParams(); | |
| if (bl) params.set('bl', bl); | |
| if (fsid) params.set('f.sid', fsid); | |
| params.set('hl', 'en-US'); params.set('_reqid', String(reqid)); params.set('rt', 'c'); | |
| const path = `${STREAM_PATH}?${params.toString()}`; | |
| headers['content-length'] = String(Buffer.byteLength(body)); | |
| logger.debug(`tempChat: StreamGenerate [no-auth] ${prompt.slice(0, 50)}…`); | |
| const res = await httpsPost({ hostname: GEMINI_HOST, path, headers }, body); | |
| if (res.status >= 400) { this.#session = null; throw new Error(`tempChat: StreamGenerate HTTP ${res.status}: ${res.body.slice(0, 150)}`); } | |
| logger.debug(`tempChat: received ${res.body.length} bytes`); | |
| const parsed = buildStreamResponse(res.body); | |
| return parsed.text ?? parsed.candidates?.[0]?.text ?? ''; | |
| } | |
| async refresh() { this.#session = await fetchSession(); } | |
| async close() {} | |
| } | |
| let _tempInstance = null; | |
| export function getTempChat() { if (!_tempInstance) _tempInstance = new TempChat(); return _tempInstance; } | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // SECTION 10 — Cookie Manager | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| const __filename_cm = fileURLToPath(import.meta.url); | |
| const __dirname_cm = path.dirname(__filename_cm); | |
| export const COOKIES_JSON_PATH = path.resolve(__dirname_cm, '../..', 'data/gemini.json'); | |
| export const LOCAL_COOKIES_PATH = COOKIES_JSON_PATH; | |
| if (!fs.existsSync(COOKIES_JSON_PATH)) { | |
| fs.writeFileSync(COOKIES_JSON_PATH, '[]', 'utf-8'); | |
| } | |
| export const AUTH_COOKIE_KEYS = [ | |
| '__Secure-1PSID', '__Secure-1PSIDTS', '__Secure-1PSIDCC', '__Secure-1PAPISID', | |
| '__Secure-3PSID', '__Secure-3PSIDTS', '__Secure-3PSIDCC', '__Secure-3PAPISID', | |
| 'SID', 'HSID', 'SSID', 'APISID', 'SAPISID', 'SIDCC', 'AEC', 'NID', | |
| '1P_JAR', '__Secure-ENID', 'SEARCH_SAMESITE', | |
| ]; | |
| export const CRITICAL_COOKIES = ['__Secure-1PSID', '__Secure-1PSIDTS']; | |
| export const ALT_CRITICAL = ['__Secure-3PSID', '__Secure-3PSIDTS']; | |
| function activePath() { return COOKIES_JSON_PATH; } | |
| export function isCookiesFileEmpty() { | |
| if (!fs.existsSync(COOKIES_JSON_PATH)) return true; | |
| try { | |
| const data = JSON.parse(fs.readFileSync(COOKIES_JSON_PATH, 'utf-8')); | |
| if (Array.isArray(data)) return data.length === 0; | |
| if (data !== null && typeof data === 'object') return Object.keys(data).length === 0; | |
| } catch { /* ignore */ } | |
| return true; | |
| } | |
| export function parseCookies(source) { | |
| if (source !== null && typeof source === 'object' && !Array.isArray(source)) { | |
| const out = {}; | |
| for (const [k, v] of Object.entries(source)) { | |
| if (typeof v === 'string') out[k] = v; | |
| } | |
| return out; | |
| } | |
| if (Array.isArray(source)) { | |
| const result = {}; | |
| for (const item of source) { | |
| if (item !== null && typeof item === 'object' && !Array.isArray(item)) { | |
| if (typeof item.name === 'string' && typeof item.value === 'string') { | |
| result[item.name] = item.value; | |
| } | |
| } | |
| } | |
| return result; | |
| } | |
| if (typeof source === 'string') { | |
| let s = source.trim() | |
| .replace(/^(var|let|const)\s+\w+\s*=\s*/, '') | |
| .replace(/;$/, ''); | |
| try { return parseCookies(JSON.parse(s)); } catch { /* fall through */ } | |
| if (s.startsWith('{') && !s.startsWith('[')) { | |
| try { return parseCookies(JSON.parse(`[${s}]`)); } catch { /* fall through */ } | |
| } | |
| const result = {}; | |
| for (const rawLine of s.split(/\r?\n/)) { | |
| let line = rawLine.trim().replace(/^,/, '').replace(/,$/, '') | |
| .replace(/^["']/, '').replace(/["']$/, ''); | |
| if (line.includes(':')) { | |
| const idx = line.indexOf(':'); | |
| let k = line.slice(0, idx).trim().replace(/^["']/, '').replace(/["']$/, ''); | |
| let v = line.slice(idx + 1).trim().replace(/^["']/, '').replace(/["']$/, ''); | |
| if (k && v) result[k] = v; | |
| } else if (line.includes('=')) { | |
| const idx = line.indexOf('='); | |
| const k = line.slice(0, idx).trim(); | |
| const v = line.slice(idx + 1).trim(); | |
| if (k && v) result[k] = v; | |
| } | |
| } | |
| return result; | |
| } | |
| return {}; | |
| } | |
| export function loadCookiesJson() { | |
| const p = activePath(); | |
| if (!fs.existsSync(p)) return []; | |
| try { | |
| const data = JSON.parse(fs.readFileSync(p, 'utf-8')); | |
| if (Array.isArray(data)) return data; | |
| if (data !== null && typeof data === 'object') { | |
| return Object.entries(data).map(([k, v]) => ({ | |
| name: k, value: String(v), domain: '.google.com', path: '/', | |
| secure: true, httpOnly: true, session: false, | |
| })); | |
| } | |
| } catch { /* ignore */ } | |
| return []; | |
| } | |
| export function saveCookiesJson(cookies, targetPath) { | |
| const target = targetPath ?? COOKIES_JSON_PATH; | |
| fs.mkdirSync(path.dirname(target), { recursive: true }); | |
| let toWrite; | |
| if (!Array.isArray(cookies)) { | |
| toWrite = Object.entries(cookies).map(([k, v]) => ({ | |
| name: k, value: v, domain: '.google.com', path: '/', | |
| secure: true, httpOnly: true, session: false, | |
| })); | |
| } else { | |
| toWrite = cookies; | |
| } | |
| fs.writeFileSync(target, JSON.stringify(toWrite, null, 2)); | |
| } | |
| export function mergeCookies(existing, newCookies) { | |
| const index = {}; | |
| for (const c of existing) { if (c.name) index[c.name] = c; } | |
| if (Array.isArray(newCookies)) { | |
| for (const item of newCookies) { | |
| if (item.name && item.value !== undefined) { | |
| if (item.name in index) Object.assign(index[item.name], item); | |
| else index[item.name] = item; | |
| } | |
| } | |
| } else { | |
| for (const [name, value] of Object.entries(newCookies)) { | |
| if (name in index) index[name].value = value; | |
| else index[name] = { name, value, domain: '.google.com', path: '/', secure: true, httpOnly: true, session: false }; | |
| } | |
| } | |
| return Object.values(index); | |
| } | |
| export function getFlatCookies(filterAuth = true) { | |
| const raw = loadCookiesJson(); | |
| const flat = parseCookies(raw); | |
| if (filterAuth) { | |
| const filtered = {}; | |
| for (const [k, v] of Object.entries(flat)) { | |
| if (AUTH_COOKIE_KEYS.includes(k)) filtered[k] = v; | |
| } | |
| return filtered; | |
| } | |
| return flat; | |
| } | |
| export function checkCookies() { | |
| const flat = getFlatCookies(false); | |
| const p = activePath(); | |
| const results = {}; | |
| for (const key of CRITICAL_COOKIES) { | |
| const val = flat[key] ?? ''; | |
| results[key] = { present: !!val, value_preview: val.length > 28 ? val.slice(0, 28) + '...' : val }; | |
| } | |
| const altResults = {}; | |
| for (const key of ALT_CRITICAL) { | |
| const val = flat[key] ?? ''; | |
| altResults[key] = { present: !!val, value_preview: val.length > 28 ? val.slice(0, 28) + '...' : val }; | |
| } | |
| const otherFound = AUTH_COOKIE_KEYS.filter( | |
| k => !CRITICAL_COOKIES.includes(k) && !ALT_CRITICAL.includes(k) && k in flat | |
| ); | |
| const primaryValid = Object.values(results).every(r => r.present); | |
| const altValid = Object.values(altResults).every(r => r.present); | |
| return { | |
| critical: results, alt: altResults, other_auth_found: otherFound, | |
| total_cookies: Object.keys(flat).length, | |
| valid: primaryValid || altValid, | |
| psid: flat['__Secure-1PSID'] || flat['__Secure-3PSID'] || '', | |
| path: p, | |
| jar_size: Object.keys(flat).length, | |
| }; | |
| } | |
| export function importCookiesFromRaw(rawText) { | |
| const parsed = parseCookies(rawText); | |
| if (Object.keys(parsed).length === 0) | |
| return { ok: false, error: 'Could not parse any cookies from input' }; | |
| const psid = parsed['__Secure-1PSID'] || parsed['__Secure-3PSID'] || parsed.SID || ''; | |
| const psidts = parsed['__Secure-1PSIDTS'] || parsed['__Secure-3PSIDTS'] || ''; | |
| const existing = loadCookiesJson(); | |
| const merged = mergeCookies(existing, parsed); | |
| saveCookiesJson(merged); | |
| return { | |
| ok: true, | |
| psid_found: !!psid, | |
| psidts_found: !!psidts, | |
| total: Object.keys(parsed).length, | |
| path: COOKIES_JSON_PATH, | |
| preview: psid ? psid.slice(0, 20) + '...' : '(not found)', | |
| }; | |
| } | |
| export function getCookiesPath() { return activePath(); } | |
| export function clearCookiesFile() { | |
| const p = activePath(); | |
| if (!fs.existsSync(p)) return { ok: true, path: p, message: 'No cookies file found — nothing to delete.' }; | |
| fs.writeFileSync(p, '[]', 'utf-8'); | |
| return { ok: true, path: p, message: 'Cookies cleared.' }; | |
| } | |
| export function discoverCookiesPath() { | |
| return fs.existsSync(COOKIES_JSON_PATH) ? COOKIES_JSON_PATH : null; | |
| } | |
| export function importCookiesToProfile(auth) { | |
| const flat = getFlatCookies(true); | |
| if (Object.keys(flat).length === 0) return false; | |
| auth.saveCookies(flat); | |
| return true; | |
| } | |
| function cookiesFromJar(jar) { | |
| const c = jar.toObject ? jar.toObject() : jar; | |
| return Object.entries(c).map(([name, value]) => ({ name, value, domain: '.google.com', path: '/', secure: true })); | |
| } | |
| function cookiesFromState(state) { | |
| return Object.entries(state.cookies || {}).map(([name, value]) => ({ name, value, domain: '.google.com', path: '/', secure: true })); | |
| } | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| // SECTION 11 — GeminiClient (main class) | |
| // ═══════════════════════════════════════════════════════════════════════════ | |
| export class GeminiClient { | |
| #httpClient = null; | |
| #jar = null; | |
| #transport = null; | |
| #initialized = false; | |
| constructor({ profileName = null, proxy = null, timeoutMs = 300_000 } = {}) { | |
| this.profileManager = new ProfileManager(); | |
| this.authManager = new AuthManager(this.profileManager, profileName, proxy); | |
| this.proxy = proxy; | |
| this.timeoutMs = timeoutMs; | |
| } | |
| get transport() { if (!this.#transport) throw new AuthError('Client not initialized. Call init() first.'); return this.#transport; } | |
| get initialized() { return this.#initialized; } | |
| async init() { | |
| let authState = this.authManager.load(); | |
| const localFound = discoverCookiesPath(); | |
| const haveAnyCookies = localFound !== null; | |
| if (haveAnyCookies && importCookiesToProfile(this.authManager)) { | |
| authState = this.authManager.load(); | |
| logger.info(`client: auto-synced exocoreCookies.json → profile (${Object.keys(authState.cookies).length} cookies).`); | |
| } | |
| const hasPsid = !!(authState.cookies['__Secure-1PSID'] || authState.cookies['__Secure-3PSID']); | |
| if (!hasPsid) { | |
| throw new AuthError('Not signed in yet — tap the login button to paste your cookies.'); | |
| } | |
| // Don't call refreshTokens() here — hitting gemini.google.com/app from a | |
| // datacenter IP with user cookies triggers Google's anti-abuse system and | |
| // invalidates the user's browser session (auto-logout). Use whatever cached | |
| // SNlM0e token we have; if missing, the API call will fail naturally. | |
| if (!authState.tokens.snlm0e) { | |
| logger.warn('client: no cached SNlM0e token. Gemini API calls may fail — paste fresh cookies after signing in again.'); | |
| } | |
| this.#jar = new CookieJar(authState.cookies); | |
| this.#httpClient = axios.create({ | |
| timeout: this.timeoutMs, | |
| headers: { ...GEMINI_HEADERS }, | |
| maxRedirects: 5, | |
| proxy: this.proxy ? parseAxiosProxy(this.proxy) : false, | |
| validateStatus: () => true, | |
| }); | |
| this.#jar.attachTo(this.#httpClient); | |
| this.#transport = new RPCTransport({ | |
| client: this.#httpClient, | |
| accessToken: authState.tokens.snlm0e, | |
| buildLabel: authState.tokens.cfb2h ?? null, | |
| sessionId: authState.tokens.fdrfje ?? null, | |
| }); | |
| this.#initialized = true; | |
| logger.info('client: GeminiClient initialized.'); | |
| } | |
| async send(prompt, { model = null, metadata = null, fileData = null, gemId = null, toolId = null, stylePreset = null, temp = null, onDelta = null } = {}) { | |
| if (!this.#initialized) throw new AuthError('GeminiClient not initialized. Call init() first.'); | |
| if (toolId !== null && toolId !== undefined) throw new GeminiError('Tool-flagged requests require BotGuard and are not supported in the HTTP-only port.'); | |
| if (fileData?.length > 0) logger.warn('client: file_data provided over HTTP path — Google may return 0 candidates.'); | |
| const resolvedModel = this.#resolveModel(model); | |
| return this.transport.streamGenerate({ prompt, model: resolvedModel, metadata, fileData, gemId, toolId: null, stylePreset, temp, onDelta }); | |
| } | |
| async executeRpc(rpcId, payload = '[]', sourcePath = '/app') { | |
| return this.transport.batchexecute([new RPCPayload({ rpcId, payload })], sourcePath); | |
| } | |
| async executeRpcs(payloads, sourcePath = '/app') { | |
| return this.transport.batchexecute(payloads, sourcePath); | |
| } | |
| async uploadFile(content, mimeType, filename) { | |
| if (!this.#initialized || !this.#httpClient) return null; | |
| const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8'); | |
| const storagePath = await uploadToContentPush(this.#httpClient, this.getCookies(), buf, mimeType, filename); | |
| if (!storagePath) return null; | |
| return buildFileDataItem(storagePath, mimeType, filename); | |
| } | |
| getCookies() { return this.#jar ? this.#jar.toObject() : { ...this.authManager.state.cookies }; } | |
| async listChats(limit = 50) { | |
| if (!this.#initialized) throw new AuthError('GeminiClient not initialized.'); | |
| throw new AuthError('Puppeteer module removed; listChats is unavailable.'); | |
| try { | |
| const { ensurePage, setCookies } = await import('../../puppeteer/chrome/index.js'); | |
| const page = await ensurePage(undefined, { fresh: true }); | |
| await page.setViewport({ width: 1400, height: 900 }); | |
| const cookies = this.#jar ? cookiesFromJar(this.#jar) : cookiesFromState(this.authManager.state); | |
| await setCookies(page, cookies); | |
| await page.goto('https://gemini.google.com/', { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {}); | |
| await page.waitForSelector('[data-test-id="conversation"]', { timeout: 15000 }).catch(() => {}); | |
| await new Promise(r => setTimeout(r, 3000)); | |
| const chats = await page.evaluate(async () => { | |
| const sidebar = document.querySelector('conversations-list')?.parentElement || document.documentElement; | |
| const target = sidebar === document.documentElement ? window : sidebar; | |
| const sleep = ms => new Promise(r => setTimeout(r, ms)); | |
| let prev = -1; | |
| for (let i = 0; i < 30; i++) { | |
| target.scrollBy(0, 500); | |
| await sleep(300); | |
| const cur = target.scrollTop || 0; | |
| if (cur === prev) break; | |
| prev = cur; | |
| } | |
| return Array.from(document.querySelectorAll('[data-test-id="conversation"]')).map(a => { | |
| const href = a.getAttribute('href') || ''; | |
| const title = a.querySelector('.conversation-title')?.textContent?.trim() || ''; | |
| const match = href.match(/\/app\/([a-z0-9_]+)/); | |
| return match ? { id: match[1], title: title || '', pinned: false, timestamp: null } : null; | |
| }).filter(Boolean); | |
| }); | |
| return chats; | |
| } catch (e) { | |
| logger.debug(`listChats failed: ${e.message}`); | |
| return []; | |
| } | |
| } | |
| async deleteChat(cid) { | |
| if (!this.#initialized) throw new AuthError('GeminiClient not initialized.'); | |
| const cleanId = cid.startsWith('c_') ? cid : `c_${cid}`; | |
| const payload1 = JSON.stringify([cleanId]); | |
| await this.transport.batchexecute([new RPCPayload({ rpcId: RPC.DELETE_CHAT, payload: payload1 })], `/app/${cleanId.slice(2)}`); | |
| const payload2 = JSON.stringify([cleanId, [1, null, 0, 1]]); | |
| await this.transport.batchexecute([new RPCPayload({ rpcId: RPC.DELETE_CHAT_2, payload: payload2 })], `/app/${cleanId.slice(2)}`); | |
| return { deleted: true, id: cleanId }; | |
| } | |
| async deleteAllChats() { | |
| const chats = await this.listChats(200); | |
| const results = { deleted: 0, failed: 0, errors: [] }; | |
| for (const chat of chats) { | |
| try { await this.deleteChat(chat.id); results.deleted++; } | |
| catch (e) { results.failed++; results.errors.push({ id: chat.id, error: e.message }); } | |
| } | |
| return results; | |
| } | |
| #resolveModel(model) { | |
| if (model instanceof Model) return model; | |
| if (typeof model === 'string' && model.length > 0) return getModel(model); | |
| return DEFAULT_MODEL; | |
| } | |
| async close() { | |
| this.#httpClient = null; | |
| this.#jar = null; | |
| this.#transport = null; | |
| this.#initialized = false; | |
| } | |
| } | |