Skip to main content

gemini_chat_api/
client.rs

1//! Async client for Gemini web chat endpoints.
2
3use crate::enums::{gemini_headers, rotate_cookies_headers, Endpoint, Model};
4use crate::error::{Error, Result};
5use crate::utils::upload_file;
6
7use rand::Rng;
8use regex::Regex;
9use reqwest::cookie::Jar;
10use reqwest::{Client, Url};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use std::path::Path;
15use std::sync::Arc;
16use std::time::Duration;
17
18const SNLM0E_PATTERN: &str = r#"["']SNlM0e["']\s*:\s*["']([^"']+)["']"#;
19
20/// Response returned by [`AsyncChatbot::ask`].
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct ChatResponse {
23    /// The primary response text.
24    pub content: String,
25    /// Conversation identifier returned by the server.
26    pub conversation_id: String,
27    /// Response identifier returned by the server.
28    pub response_id: String,
29    /// Optional structured data used by the backend for factuality checks.
30    pub factuality_queries: Option<Value>,
31    /// The text query echoed by the backend.
32    pub text_query: String,
33    /// Alternative response choices returned by the backend.
34    pub choices: Vec<Choice>,
35    /// Always `false` for successful responses.
36    ///
37    /// Errors are returned as [`Error`](crate::Error) instead of populating this field.
38    pub error: bool,
39}
40
41/// An alternative response choice returned by the backend.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Choice {
44    /// Backend choice identifier.
45    pub id: String,
46    /// Choice content text.
47    pub content: String,
48}
49
50/// Persisted conversation state written by [`AsyncChatbot::save_conversation`].
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SavedConversation {
53    /// User-provided name used as the lookup key.
54    pub conversation_name: String,
55    /// Internal request ID used by the backend.
56    #[serde(rename = "_reqid")]
57    pub reqid: u32,
58    /// Conversation identifier.
59    pub conversation_id: String,
60    /// Response identifier.
61    pub response_id: String,
62    /// Selected choice identifier.
63    pub choice_id: String,
64    /// Session token required for requests.
65    #[serde(rename = "SNlM0e")]
66    pub snlm0e: String,
67    /// Model name string at the time of saving.
68    pub model_name: String,
69    /// Seconds since UNIX epoch, as a string.
70    pub timestamp: String,
71}
72
73/// Async chatbot client for interacting with Gemini.
74///
75/// This client is stateful. Each successful call to [`ask`](Self::ask) updates
76/// internal conversation IDs so the next call continues the same thread. Call
77/// [`reset`](Self::reset) to start a new conversation while keeping cookies valid.
78///
79/// # Example
80/// ```no_run
81/// use gemini_chat_api::{load_cookies, AsyncChatbot, Model, Result};
82///
83/// #[tokio::main]
84/// async fn main() -> Result<()> {
85///     let (psid, psidts) = load_cookies("cookies.json")?;
86///     let mut chatbot = AsyncChatbot::new(&psid, &psidts, Model::default(), None, 30).await?;
87///
88///     // Continues the same conversation thread across calls.
89///     let first = chatbot.ask("Summarize Rust ownership in one paragraph.", None).await?;
90///     println!("{}", first.content);
91///
92///     let followup = chatbot.ask("Now give me a short code example.", None).await?;
93///     println!("{}", followup.content);
94///
95///     // Start a new conversation thread (cookies/session stay valid).
96///     chatbot.reset();
97///     let fresh = chatbot.ask("New topic: explain HTTP caching.", None).await?;
98///     println!("{}", fresh.content);
99///     Ok(())
100/// }
101/// ```
102pub struct AsyncChatbot {
103    client: Client,
104    snlm0e: String,
105    conversation_id: String,
106    response_id: String,
107    choice_id: String,
108    reqid: u32,
109    secure_1psidts: String,
110    model: Model,
111    proxy: Option<String>,
112}
113
114impl AsyncChatbot {
115    /// Creates a new `AsyncChatbot` instance and fetches the session token.
116    ///
117    /// # Arguments
118    /// * `secure_1psid` - The `__Secure-1PSID` cookie value
119    /// * `secure_1psidts` - The `__Secure-1PSIDTS` cookie value (may be empty)
120    /// * `model` - The model configuration to use
121    /// * `proxy` - Optional proxy URL (applied to all requests)
122    /// * `timeout` - Request timeout in seconds
123    ///
124    /// # Returns
125    /// A fully initialized client with a valid session token.
126    ///
127    /// # Errors
128    /// Returns an error if authentication fails (`Error::Authentication`),
129    /// if the network request fails (`Error::Network`), or if the session token
130    /// cannot be extracted (`Error::Parse`).
131    ///
132    /// There is no retry or polling behavior; a single request is attempted.
133    /// Timeouts are handled by `reqwest` and surface as `Error::Network`.
134    pub async fn new(
135        secure_1psid: &str,
136        secure_1psidts: &str,
137        model: Model,
138        proxy: Option<&str>,
139        timeout: u64,
140    ) -> Result<Self> {
141        if secure_1psid.is_empty() {
142            return Err(Error::Authentication(
143                "__Secure-1PSID cookie is required".to_string(),
144            ));
145        }
146
147        // Build cookie jar with proper Secure cookie attributes
148        let jar = Jar::default();
149        let url: Url = "https://gemini.google.com".parse().unwrap();
150        // Secure cookies need proper attributes in the cookie string
151        jar.add_cookie_str(
152            &format!(
153                "__Secure-1PSID={}; Domain=.google.com; Path=/; Secure; SameSite=None",
154                secure_1psid
155            ),
156            &url,
157        );
158        jar.add_cookie_str(
159            &format!(
160                "__Secure-1PSIDTS={}; Domain=.google.com; Path=/; Secure; SameSite=None",
161                secure_1psidts
162            ),
163            &url,
164        );
165
166        // Build headers
167        let mut headers = gemini_headers();
168        if let Some(model_headers) = model.headers() {
169            headers.extend(model_headers);
170        }
171
172        // Build client
173        let mut builder = Client::builder()
174            .cookie_provider(Arc::new(jar))
175            .default_headers(headers)
176            .timeout(Duration::from_secs(timeout));
177
178        if let Some(proxy_url) = proxy {
179            builder = builder.proxy(reqwest::Proxy::all(proxy_url)?);
180        }
181
182        let client = builder.build()?;
183
184        let mut chatbot = Self {
185            client,
186            snlm0e: String::new(),
187            conversation_id: String::new(),
188            response_id: String::new(),
189            choice_id: String::new(),
190            reqid: rand::thread_rng().gen_range(1000000..9999999),
191            secure_1psidts: secure_1psidts.to_string(),
192            model,
193            proxy: proxy.map(|s| s.to_string()),
194        };
195
196        // Fetch the SNlM0e token
197        chatbot.snlm0e = chatbot.get_snlm0e().await?;
198
199        Ok(chatbot)
200    }
201
202    /// Fetches the SNlM0e value required for API requests.
203    async fn get_snlm0e(&mut self) -> Result<String> {
204        // Proactively try to rotate cookies if PSIDTS is missing
205        if self.secure_1psidts.is_empty() {
206            let _ = self.rotate_cookies().await;
207        }
208
209        let response = self.client.get(Endpoint::Init.url()).send().await?;
210
211        let status = response.status();
212        let text = response.text().await?;
213
214        if !status.is_success() {
215            if status.as_u16() == 401 || status.as_u16() == 403 {
216                return Err(Error::Authentication(format!(
217                    "Authentication failed (status {}). Check cookies.",
218                    status
219                )));
220            }
221            return Err(Error::Parse(format!("HTTP error: {}", status)));
222        }
223
224        // Check for authentication redirect - be precise to avoid false positives
225        // Only trigger if it's an actual login page, not just any page with google.com links
226        if text.contains("\"identifier-shown\"")
227            || text.contains("SignIn?continue")
228            || text.contains("Sign in - Google Accounts")
229        {
230            return Err(Error::Authentication(
231                "Authentication failed. Cookies might be invalid or expired.".to_string(),
232            ));
233        }
234
235        // Extract SNlM0e using regex
236        let re = Regex::new(SNLM0E_PATTERN).unwrap();
237        match re.captures(&text) {
238            Some(caps) => Ok(caps.get(1).unwrap().as_str().to_string()),
239            None => {
240                if text.contains("429") {
241                    Err(Error::Parse(
242                        "SNlM0e not found. Rate limit likely exceeded.".to_string(),
243                    ))
244                } else {
245                    Err(Error::Parse(
246                        "SNlM0e value not found in response. Check cookie validity.".to_string(),
247                    ))
248                }
249            }
250        }
251    }
252
253    /// Rotates the __Secure-1PSIDTS cookie.
254    async fn rotate_cookies(&mut self) -> Result<Option<String>> {
255        let response = self
256            .client
257            .post(Endpoint::RotateCookies.url())
258            .headers(rotate_cookies_headers())
259            .body(r#"[000,"-0000000000000000000"]"#)
260            .send()
261            .await?;
262
263        if !response.status().is_success() {
264            return Ok(None);
265        }
266
267        // Check for new cookie in response
268        // Note: Reqwest's cookie store automatically handles Set-Cookie headers for the client
269        // But we want to update our struct field too
270        for cookie in response.cookies() {
271            if cookie.name() == "__Secure-1PSIDTS" {
272                let new_value = cookie.value().to_string();
273                self.secure_1psidts = new_value.clone();
274                return Ok(Some(new_value));
275            }
276        }
277
278        Ok(None)
279    }
280
281    /// Sends a message and returns the parsed response.
282    ///
283    /// # Arguments
284    /// * `message` - The message text to send
285    /// * `image` - Optional image bytes to upload and attach
286    ///
287    /// # Returns
288    /// A [`ChatResponse`] containing the reply and metadata.
289    ///
290    /// # Errors
291    /// Returns an error if the client is not initialized (`Error::NotInitialized`),
292    /// if the image upload fails (`Error::Upload`), if the network request fails
293    /// (`Error::Network`), or if the backend response cannot be parsed
294    /// (`Error::Parse`).
295    ///
296    /// There is no retry or polling behavior. Timeouts are handled by `reqwest`
297    /// and surface as `Error::Network`. External failures (Gemini backend,
298    /// upload endpoint, or connectivity issues) are returned as errors.
299    pub async fn ask(&mut self, message: &str, image: Option<&[u8]>) -> Result<ChatResponse> {
300        if self.snlm0e.is_empty() {
301            return Err(Error::NotInitialized(
302                "AsyncChatbot not properly initialized. SNlM0e is missing.".to_string(),
303            ));
304        }
305
306        // Handle image upload if provided
307        let image_upload_id = if let Some(img_data) = image {
308            Some(upload_file(img_data, self.proxy.as_deref()).await?)
309        } else {
310            None
311        };
312
313        // Prepare message structure
314        let message_struct: Value = if let Some(ref upload_id) = image_upload_id {
315            serde_json::json!([
316                [message],
317                [[[upload_id, 1]]],
318                [&self.conversation_id, &self.response_id, &self.choice_id]
319            ])
320        } else {
321            serde_json::json!([
322                [message],
323                null,
324                [&self.conversation_id, &self.response_id, &self.choice_id]
325            ])
326        };
327
328        // Prepare request
329        let freq_value = serde_json::json!([null, serde_json::to_string(&message_struct)?]);
330        let params = [
331            ("bl", "boq_assistant-bard-web-server_20240625.13_p0"),
332            ("_reqid", &self.reqid.to_string()),
333            ("rt", "c"),
334        ];
335
336        let form_data = [
337            ("f.req", serde_json::to_string(&freq_value)?),
338            ("at", self.snlm0e.clone()),
339        ];
340
341        let response = self
342            .client
343            .post(Endpoint::Generate.url())
344            .query(&params)
345            .form(&form_data)
346            .send()
347            .await?;
348
349        if !response.status().is_success() {
350            return Err(Error::Network(response.error_for_status().unwrap_err()));
351        }
352
353        let text = response.text().await?;
354        self.parse_response(&text)
355    }
356
357    /// Parses the Gemini API response text.
358    fn parse_response(&mut self, text: &str) -> Result<ChatResponse> {
359        let lines: Vec<&str> = text.lines().collect();
360        if lines.len() < 3 {
361            return Err(Error::Parse(format!(
362                "Unexpected response format. Content: {}...",
363                &text[..text.len().min(200)]
364            )));
365        }
366
367        // Find the main response body
368        let mut body: Option<Value> = None;
369
370        for line in &lines {
371            // Skip empty lines and security prefix
372            if line.is_empty() || *line == ")]}" {
373                continue;
374            }
375
376            let mut clean_line = *line;
377            if clean_line.starts_with(")]}") {
378                clean_line = clean_line.get(4..).unwrap_or("").trim();
379            }
380
381            if !clean_line.starts_with('[') {
382                continue;
383            }
384
385            if let Ok(response_json) = serde_json::from_str::<Value>(clean_line) {
386                if let Some(arr) = response_json.as_array() {
387                    for part in arr {
388                        if let Some(part_arr) = part.as_array() {
389                            if part_arr.len() > 2
390                                && part_arr.first().and_then(|v| v.as_str()) == Some("wrb.fr")
391                            {
392                                if let Some(inner_str) = part_arr.get(2).and_then(|v| v.as_str()) {
393                                    if let Ok(main_part) = serde_json::from_str::<Value>(inner_str)
394                                    {
395                                        if main_part
396                                            .as_array()
397                                            .map(|a| a.len() > 4 && !a[4].is_null())
398                                            .unwrap_or(false)
399                                        {
400                                            body = Some(main_part);
401                                            break;
402                                        }
403                                    }
404                                }
405                            }
406                        }
407                    }
408                }
409
410                if body.is_some() {
411                    break;
412                }
413            }
414        }
415
416        let body = body.ok_or_else(|| {
417            Error::Parse("Failed to parse response body. No valid data found.".to_string())
418        })?;
419
420        // Extract data
421        let body_arr = body.as_array().unwrap();
422
423        // Extract content
424        // Structure: body[4][0][1][0] -> content
425        let content = body_arr
426            .get(4)
427            .and_then(|v| v.as_array())
428            .and_then(|a| a.first())
429            .and_then(|v| v.as_array())
430            .and_then(|a| a.get(1))
431            .and_then(|v| v.as_array())
432            .and_then(|a| a.first())
433            .and_then(|v| v.as_str())
434            .unwrap_or("")
435            .to_string();
436
437        // Extract conversation metadata
438        let conversation_id = body_arr
439            .get(1)
440            .and_then(|v| v.as_array())
441            .and_then(|a| a.first())
442            .and_then(|v| v.as_str())
443            .unwrap_or(&self.conversation_id)
444            .to_string();
445
446        let response_id = body_arr
447            .get(1)
448            .and_then(|v| v.as_array())
449            .and_then(|a| a.get(1))
450            .and_then(|v| v.as_str())
451            .unwrap_or(&self.response_id)
452            .to_string();
453
454        // Extract other data
455        let factuality_queries = body_arr.get(3).cloned();
456        let text_query = body_arr
457            .get(2)
458            .and_then(|v| v.as_array())
459            .and_then(|a| a.first())
460            .and_then(|v| v.as_str())
461            .unwrap_or("")
462            .to_string();
463
464        // Extract choices
465        let mut choices = Vec::new();
466        if let Some(candidates) = body_arr.get(4).and_then(|v| v.as_array()) {
467            for candidate in candidates {
468                if let Some(cand_arr) = candidate.as_array() {
469                    if cand_arr.len() > 1 {
470                        let id = cand_arr
471                            .first()
472                            .and_then(|v| v.as_str())
473                            .unwrap_or("")
474                            .to_string();
475                        let choice_content = cand_arr
476                            .get(1)
477                            .and_then(|v| v.as_array())
478                            .and_then(|a| a.first())
479                            .and_then(|v| v.as_str())
480                            .unwrap_or("")
481                            .to_string();
482                        choices.push(Choice {
483                            id,
484                            content: choice_content,
485                        });
486                    }
487                }
488            }
489        }
490
491        let choice_id = choices
492            .first()
493            .map(|c| c.id.clone())
494            .unwrap_or_else(|| self.choice_id.clone());
495
496        // Update state
497        self.conversation_id = conversation_id.clone();
498        self.response_id = response_id.clone();
499        self.choice_id = choice_id;
500        self.reqid += rand::thread_rng().gen_range(1000..9000);
501
502        Ok(ChatResponse {
503            content,
504            conversation_id,
505            response_id,
506            factuality_queries,
507            text_query,
508            choices,
509            error: false,
510        })
511    }
512
513    /// Saves the current conversation state to a JSON file.
514    ///
515    /// The file format is a JSON array of [`SavedConversation`] values. If an
516    /// entry with the same `conversation_name` already exists, it is replaced.
517    pub async fn save_conversation(&self, file_path: &str, conversation_name: &str) -> Result<()> {
518        let mut conversations = self.load_conversations(file_path).await?;
519
520        let conversation_data = SavedConversation {
521            conversation_name: conversation_name.to_string(),
522            reqid: self.reqid,
523            conversation_id: self.conversation_id.clone(),
524            response_id: self.response_id.clone(),
525            choice_id: self.choice_id.clone(),
526            snlm0e: self.snlm0e.clone(),
527            model_name: self.model.name().to_string(),
528            timestamp: chrono_now(),
529        };
530
531        // Update or add conversation
532        let mut found = false;
533        for conv in &mut conversations {
534            if conv.conversation_name == conversation_name {
535                *conv = conversation_data.clone();
536                found = true;
537                break;
538            }
539        }
540        if !found {
541            conversations.push(conversation_data);
542        }
543
544        // Ensure parent directory exists
545        if let Some(parent) = Path::new(file_path).parent() {
546            std::fs::create_dir_all(parent)?;
547        }
548
549        let json = serde_json::to_string_pretty(&conversations)?;
550        std::fs::write(file_path, json)?;
551
552        Ok(())
553    }
554
555    /// Loads all saved conversations from a JSON file.
556    ///
557    /// If the file does not exist, this returns an empty vector.
558    pub async fn load_conversations(&self, file_path: &str) -> Result<Vec<SavedConversation>> {
559        if !Path::new(file_path).exists() {
560            return Ok(Vec::new());
561        }
562
563        let content = std::fs::read_to_string(file_path)?;
564        let conversations: Vec<SavedConversation> = serde_json::from_str(&content)?;
565        Ok(conversations)
566    }
567
568    /// Loads a specific conversation by name and applies it to this client.
569    ///
570    /// If the saved model name is unrecognized, the current model is left
571    /// unchanged.
572    ///
573    /// Returns `true` if the conversation was found and loaded.
574    pub async fn load_conversation(
575        &mut self,
576        file_path: &str,
577        conversation_name: &str,
578    ) -> Result<bool> {
579        let conversations = self.load_conversations(file_path).await?;
580
581        for conv in conversations {
582            if conv.conversation_name == conversation_name {
583                self.reqid = conv.reqid;
584                self.conversation_id = conv.conversation_id;
585                self.response_id = conv.response_id;
586                self.choice_id = conv.choice_id;
587                self.snlm0e = conv.snlm0e;
588
589                if let Some(model) = Model::from_name(&conv.model_name) {
590                    self.model = model;
591                }
592
593                return Ok(true);
594            }
595        }
596
597        Ok(false)
598    }
599
600    /// Returns the current conversation ID.
601    pub fn conversation_id(&self) -> &str {
602        &self.conversation_id
603    }
604
605    /// Returns the current model configuration.
606    pub fn model(&self) -> &Model {
607        &self.model
608    }
609
610    /// Resets conversation IDs to start a fresh session.
611    ///
612    /// Authentication (cookies and session token) is preserved.
613    pub fn reset(&mut self) {
614        self.conversation_id.clear();
615        self.response_id.clear();
616        self.choice_id.clear();
617        self.reqid = rand::thread_rng().gen_range(1000000..9999999);
618    }
619}
620
621/// Simple timestamp function (avoids adding chrono dependency).
622fn chrono_now() -> String {
623    use std::time::{SystemTime, UNIX_EPOCH};
624    let duration = SystemTime::now()
625        .duration_since(UNIX_EPOCH)
626        .unwrap_or_default();
627    format!("{}", duration.as_secs())
628}