1use reqwest::blocking::Client;
2use serde::Deserialize;
3use shakmaty::Color;
4use std::error::Error;
5use std::io::{BufRead, BufReader};
6use std::sync::mpsc::Sender;
7use std::thread;
8
9const LICHESS_API_URL: &str = "https://lichess.org/api";
10
11#[derive(Debug, Deserialize)]
12#[serde(tag = "type")]
13enum GameEvent {
14 #[serde(rename = "gameFull")]
15 GameFull {
16 id: String,
17 white: Player,
18 black: Player,
19 state: GameState,
20 },
21 #[serde(rename = "gameState")]
22 GameState(GameState),
23 #[serde(rename = "chatLine")]
24 ChatLine,
25}
26
27#[derive(Debug, Deserialize)]
29#[serde(tag = "type")]
30enum EventStreamEvent {
31 #[serde(rename = "gameStart")]
32 GameStart { game: EventStreamGame },
33 #[serde(rename = "gameFinish")]
34 GameFinish { game: EventStreamGame },
35 #[serde(rename = "challenge")]
36 Challenge,
37 #[serde(rename = "challengeCanceled")]
38 ChallengeCanceled,
39 #[serde(rename = "challengeDeclined")]
40 ChallengeDeclined,
41}
42
43#[derive(Debug, Deserialize)]
44struct EventStreamGame {
45 #[serde(rename = "gameId")]
46 game_id: String,
47 color: String,
48 #[serde(flatten)]
50 _rest: serde_json::Value,
51}
52
53#[derive(Debug, Deserialize)]
54struct Player {
55 id: Option<String>,
56}
57
58#[derive(Debug, Deserialize)]
59struct GameState {
60 moves: String,
61 status: String,
62}
63
64#[derive(Debug, Deserialize, Clone)]
65pub struct OngoingGame {
66 #[serde(rename = "gameId")]
67 pub game_id: String,
68 #[serde(rename = "fullId")]
69 pub full_id: String,
70 pub color: String,
71 pub fen: String,
72 pub opponent: OpponentInfo,
73 #[serde(rename = "isMyTurn")]
74 pub is_my_turn: bool,
75}
76
77#[derive(Debug, Deserialize, Clone)]
78pub struct OpponentInfo {
79 pub id: Option<String>,
80 pub username: String,
81 pub rating: Option<u32>,
82}
83
84#[derive(Debug, Deserialize)]
85struct OngoingGamesResponse {
86 #[serde(rename = "nowPlaying")]
87 now_playing: Vec<OngoingGame>,
88}
89
90#[derive(Debug, Deserialize, Clone)]
91pub struct Puzzle {
92 pub game: PuzzleGame,
93 pub puzzle: PuzzleInfo,
94}
95
96#[derive(Debug, Deserialize, Clone)]
97pub struct PuzzleGame {
98 pub id: String,
99 pub pgn: String,
100 pub clock: String,
101}
102
103#[derive(Debug, Deserialize, Clone)]
104pub struct PuzzleInfo {
105 pub id: String,
106 pub rating: u32,
107 pub plays: u32,
108 #[serde(rename = "initialPly")]
109 pub initial_ply: u32,
110 pub solution: Vec<String>,
111 pub themes: Vec<String>,
112}
113
114#[derive(Debug, Deserialize, Clone)]
115pub struct UserProfile {
116 pub id: String,
117 pub username: String,
118 #[serde(default)]
119 pub perfs: Option<Perfs>,
120 #[serde(default)]
121 pub title: Option<String>,
122 #[serde(default)]
123 pub online: Option<bool>,
124 #[serde(default)]
125 pub profile: Option<ProfileInfo>,
126 #[serde(default)]
127 pub seen_at: Option<u64>,
128 #[serde(default)]
129 pub created_at: Option<u64>,
130 #[serde(default)]
131 pub count: Option<UserCounts>,
132}
133
134#[derive(Debug, Deserialize, Clone)]
135pub struct ProfileInfo {
136 #[serde(default)]
137 pub bio: Option<String>,
138 #[serde(default)]
139 pub country: Option<String>,
140 #[serde(default)]
141 pub location: Option<String>,
142 #[serde(default, rename = "firstName")]
143 pub first_name: Option<String>,
144 #[serde(default, rename = "lastName")]
145 pub last_name: Option<String>,
146}
147
148#[derive(Debug, Deserialize, Clone)]
149pub struct UserCounts {
150 #[serde(default)]
151 pub all: Option<u32>,
152 #[serde(default)]
153 pub rated: Option<u32>,
154 #[serde(default)]
155 pub ai: Option<u32>,
156 #[serde(default)]
157 pub draw: Option<u32>,
158 #[serde(default, rename = "drawH")]
159 pub draw_h: Option<u32>,
160 #[serde(default)]
161 pub loss: Option<u32>,
162 #[serde(default, rename = "lossH")]
163 pub loss_h: Option<u32>,
164 #[serde(default)]
165 pub win: Option<u32>,
166 #[serde(default, rename = "winH")]
167 pub win_h: Option<u32>,
168 #[serde(default)]
169 pub bookmark: Option<u32>,
170 #[serde(default)]
171 pub playing: Option<u32>,
172 #[serde(default)]
173 pub import: Option<u32>,
174 #[serde(default)]
175 pub me: Option<u32>,
176}
177
178#[derive(Debug, Deserialize, Clone)]
179pub struct Perfs {
180 #[serde(default)]
181 pub bullet: Option<Perf>,
182 #[serde(default)]
183 pub blitz: Option<Perf>,
184 #[serde(default)]
185 pub rapid: Option<Perf>,
186 #[serde(default)]
187 pub classical: Option<Perf>,
188 #[serde(default)]
189 pub puzzle: Option<Perf>,
190}
191
192#[derive(Debug, Deserialize, Clone)]
193pub struct Perf {
194 pub rating: u32,
195 #[serde(default)]
196 pub rd: Option<u32>,
197 #[serde(default)]
198 pub prog: Option<i32>,
199}
200
201#[derive(Debug, Deserialize, Clone)]
202pub struct RatingHistoryEntry {
203 pub name: String,
204 pub points: Vec<[i32; 4]>, }
206
207#[derive(Clone)]
208pub struct LichessClient {
209 token: String,
210 client: Client,
211}
212
213impl LichessClient {
214 pub fn new(token: String) -> Self {
215 Self {
216 token,
217 client: Client::builder()
218 .timeout(None)
219 .http1_only()
220 .build()
221 .unwrap_or_else(|_| Client::new()),
222 }
223 }
224
225 pub fn get_my_profile(&self) -> Result<String, Box<dyn Error>> {
226 let url = format!("{}/account", LICHESS_API_URL);
227 let response = self
228 .client
229 .get(&url)
230 .header(
231 "User-Agent",
232 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
233 )
234 .bearer_auth(&self.token)
235 .send()?;
236
237 if !response.status().is_success() {
238 return Err(format!("Failed to fetch profile: {}", response.status()).into());
239 }
240
241 let player: Player = response.json()?;
242 player.id.ok_or("Profile missing ID".into())
243 }
244
245 pub fn get_user_profile(&self) -> Result<UserProfile, Box<dyn Error>> {
246 let url = format!("{}/account", LICHESS_API_URL);
247 log::info!("Fetching user profile from: {}", url);
248
249 let response = self
250 .client
251 .get(&url)
252 .header(
253 "User-Agent",
254 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
255 )
256 .bearer_auth(&self.token)
257 .send()?;
258
259 if !response.status().is_success() {
260 return Err(format!("Failed to fetch user profile: {}", response.status()).into());
261 }
262
263 let profile: UserProfile = response.json()?;
264 log::info!("Fetched user profile: {}", profile.username);
265 Ok(profile)
266 }
267
268 pub fn get_rating_history(
269 &self,
270 username: &str,
271 ) -> Result<Vec<RatingHistoryEntry>, Box<dyn Error>> {
272 let url = format!("{}/user/{}/rating-history", LICHESS_API_URL, username);
273 log::info!("Fetching rating history from: {}", url);
274
275 let response = self
276 .client
277 .get(&url)
278 .header(
279 "User-Agent",
280 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
281 )
282 .bearer_auth(&self.token)
283 .send()?;
284
285 if !response.status().is_success() {
286 return Err(format!("Failed to fetch rating history: {}", response.status()).into());
287 }
288
289 let history: Vec<RatingHistoryEntry> = response.json()?;
290 log::info!(
291 "Fetched rating history with {} time controls",
292 history.len()
293 );
294 Ok(history)
295 }
296
297 pub fn get_ongoing_games(&self) -> Result<Vec<OngoingGame>, Box<dyn Error>> {
298 let url = format!("{}/account/playing", LICHESS_API_URL);
299 log::info!("Fetching ongoing games from: {}", url);
300
301 let response = self
302 .client
303 .get(&url)
304 .header(
305 "User-Agent",
306 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
307 )
308 .bearer_auth(&self.token)
309 .send()?;
310
311 if !response.status().is_success() {
312 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
313 return Err("Invalid token. Please check your token or generate a new one.".into());
314 }
315 return Err(format!("Failed to fetch ongoing games: {}", response.status()).into());
316 }
317
318 let games_response: OngoingGamesResponse = response.json()?;
319 log::info!("Found {} ongoing games", games_response.now_playing.len());
320 Ok(games_response.now_playing)
321 }
322
323 pub fn get_next_puzzle(&self) -> Result<Puzzle, Box<dyn Error>> {
324 use std::time::{SystemTime, UNIX_EPOCH};
327 let _timestamp = SystemTime::now()
328 .duration_since(UNIX_EPOCH)
329 .unwrap_or_default()
330 .as_millis();
331 let url = format!("{}/puzzle/next?t={}", LICHESS_API_URL, _timestamp);
332
333 log::info!("Fetching puzzle from: {}", url);
334
335 let response = self
336 .client
337 .get(&url)
338 .header(
339 "User-Agent",
340 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
341 )
342 .bearer_auth(&self.token)
343 .send()?;
344
345 if !response.status().is_success() {
346 return Err(format!("Failed to fetch puzzle: {}", response.status()).into());
347 }
348
349 let puzzle: Puzzle = response.json()?;
350 log::info!(
351 "Fetched puzzle: {} (rating: {})",
352 puzzle.puzzle.id,
353 puzzle.puzzle.rating
354 );
355 Ok(puzzle)
356 }
357
358 pub fn submit_puzzle_result(
362 &self,
363 puzzle_id: &str,
364 win: bool,
365 time: Option<u32>,
366 ) -> Result<(), Box<dyn Error>> {
367 use serde_json::json;
368
369 let payload = json!({
372 "solutions": [{
373 "id": puzzle_id,
374 "win": win,
375 "time": time.unwrap_or(0)
376 }]
377 });
378
379 let url = format!("{}/puzzle/batch/angle", LICHESS_API_URL);
380 log::info!("=== SUBMITTING PUZZLE RESULT ===");
381 log::info!("URL: {}", url);
382 log::info!("Puzzle ID: {}, Win: {}, Time: {:?}ms", puzzle_id, win, time);
383 log::info!(
384 "Payload: {}",
385 serde_json::to_string_pretty(&payload).unwrap_or_default()
386 );
387
388 let response = self
389 .client
390 .post(&url)
391 .header(
392 "User-Agent",
393 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
394 )
395 .header("Content-Type", "application/json")
396 .bearer_auth(&self.token)
397 .json(&payload)
398 .send()?;
399
400 let status = response.status();
401 let response_text = response.text().unwrap_or_default();
402
403 log::info!("Response status: {}", status);
404 log::info!("Response body: {}", response_text);
405
406 if !status.is_success() {
407 log::error!(
408 "Failed to submit puzzle result: {} - {}",
409 status,
410 response_text
411 );
412 return Err(format!(
413 "Failed to submit puzzle result: {} - {}",
414 status, response_text
415 )
416 .into());
417 }
418
419 log::info!("✓ Puzzle result submitted successfully to Lichess!");
420 Ok(())
421 }
422
423 fn spawn_event_stream_thread(
424 &self,
425 initial_game_ids: std::collections::HashSet<String>,
426 cancellation_token: std::sync::Arc<std::sync::atomic::AtomicBool>,
427 game_found_tx: Sender<Result<(String, Color), String>>,
428 ) {
429 let token = self.token.clone();
430 let client = self.client.clone();
431
432 thread::spawn(move || {
433 log::info!("Starting event stream thread for game detection");
434 let url = format!("{}/stream/event", LICHESS_API_URL);
435
436 loop {
437 if cancellation_token.load(std::sync::atomic::Ordering::Relaxed) {
438 break;
439 }
440
441 log::info!("Connecting to event stream...");
442 let response = match client
443 .get(&url)
444 .header(
445 "User-Agent",
446 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
447 )
448 .bearer_auth(&token)
449 .send()
450 {
451 Ok(resp) => resp,
452 Err(e) => {
453 log::error!("Failed to connect to event stream: {}", e);
454 std::thread::sleep(std::time::Duration::from_secs(5));
455 continue;
456 }
457 };
458
459 if !response.status().is_success() {
460 log::error!("Event stream returned status: {}", response.status());
461 std::thread::sleep(std::time::Duration::from_secs(5));
462 continue;
463 }
464
465 log::info!("Connected to event stream");
466 let reader = BufReader::new(response);
467
468 for line in reader.lines() {
469 if cancellation_token.load(std::sync::atomic::Ordering::Relaxed) {
470 break;
471 }
472
473 let line = match line {
474 Ok(l) => l,
475 Err(e) => {
476 log::error!("Error reading event stream line: {}", e);
477 break; }
479 };
480
481 if line.trim().is_empty() {
483 continue;
484 }
485
486 log::debug!("Received event: {}", line);
487
488 match serde_json::from_str::<EventStreamEvent>(&line) {
490 Ok(EventStreamEvent::GameStart { game }) => {
491 if !initial_game_ids.contains(&game.game_id) {
493 let color = if game.color == "white" {
494 Color::White
495 } else {
496 Color::Black
497 };
498 log::info!(
499 "Event stream found new game: {} as {:?}",
500 game.game_id,
501 color
502 );
503 let _ = game_found_tx.send(Ok((game.game_id.clone(), color)));
504 return; }
506 }
507 Ok(EventStreamEvent::GameFinish { game }) => {
508 log::debug!("Game finished: {}", game.game_id);
509 }
510 Ok(EventStreamEvent::Challenge) => {
511 log::debug!("Challenge event received");
512 }
513 Ok(EventStreamEvent::ChallengeCanceled) => {
514 log::debug!("Challenge canceled event received");
515 }
516 Ok(EventStreamEvent::ChallengeDeclined) => {
517 log::debug!("Challenge declined event received");
518 }
519 Err(e) => {
520 log::warn!("Failed to parse event: {} - {}", line, e);
521 }
522 }
523 }
524
525 log::warn!("Event stream ended, reconnecting in 5 seconds...");
527 std::thread::sleep(std::time::Duration::from_secs(5));
528 }
529 });
530 }
531
532 pub fn seek_game(
533 &self,
534 time: u32,
535 increment: u32,
536 cancellation_token: std::sync::Arc<std::sync::atomic::AtomicBool>,
537 ) -> Result<(String, Color), Box<dyn Error>> {
538 let url = format!("{}/board/seek", LICHESS_API_URL);
539
540 let initial_games = self.get_ongoing_games().unwrap_or_default();
542 let initial_game_ids: std::collections::HashSet<String> =
543 initial_games.iter().map(|g| g.game_id.clone()).collect();
544 log::info!(
545 "Tracking {} existing games before seek",
546 initial_game_ids.len()
547 );
548
549 let (game_found_tx, game_found_rx) =
551 std::sync::mpsc::channel::<Result<(String, Color), String>>();
552
553 self.spawn_event_stream_thread(
554 initial_game_ids.clone(),
555 cancellation_token.clone(),
556 game_found_tx,
557 );
558
559 std::thread::sleep(std::time::Duration::from_millis(500));
561
562 log::info!("Creating seek request...");
563 let request_builder = self
564 .client
565 .post(&url)
566 .header(
567 "User-Agent",
568 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
569 )
570 .header("Content-Type", "application/x-www-form-urlencoded")
571 .bearer_auth(&self.token);
572
573 let response = if time == 0 && increment == 0 {
574 request_builder
576 .form(&[
577 ("rated", "true"),
578 ("variant", "standard"),
579 ("ratingRange", ""),
580 ("days", "3"),
581 ("color", "random"),
582 ])
583 .send()?
584 } else {
585 let time_str = time.to_string();
587 let inc_str = increment.to_string();
588 request_builder
589 .form(&[
590 ("rated", "true"),
591 ("variant", "standard"),
592 ("ratingRange", ""),
593 ("time", &time_str),
594 ("increment", &inc_str),
595 ("color", "random"),
596 ])
597 .send()?
598 };
599
600 let status = response.status();
601 if !status.is_success() {
602 let error_text = response.text().unwrap_or_default();
603 log::error!("Seek request failed: {} - {}", status, error_text);
604
605 if status == reqwest::StatusCode::FORBIDDEN {
606 return Err("Token missing permissions. Please generate a new token with 'board:play' scope enabled.".into());
607 }
608 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
609 return Err(
610 "Rate limit exceeded. Please wait a minute before trying again.".into(),
611 );
612 }
613 if status == reqwest::StatusCode::UNAUTHORIZED {
614 return Err("Invalid token. Please check your token or generate a new one.".into());
615 }
616 if status == reqwest::StatusCode::BAD_REQUEST {
617 return Err(format!("Invalid seek parameters: {}", error_text).into());
618 }
619 return Err(format!("Failed to seek game: {} - {}", status, error_text).into());
620 }
621
622 log::info!("Seek created. Waiting for event stream to detect game start...");
623
624 if time == 0 && increment == 0 {
626 let seek_id: String = response.text()?.trim().to_string();
627 log::info!("Correspondence seek ID: {}", seek_id);
628 } else {
631 let reader = BufReader::new(response);
635
636 for line in reader.lines() {
637 if cancellation_token.load(std::sync::atomic::Ordering::Relaxed) {
639 log::info!("Seek cancelled by user - closing connection to cancel seek");
640 return Err("Seek cancelled".into());
642 }
643
644 match game_found_rx.try_recv() {
646 Ok(result) => {
647 log::info!("Game found via event stream");
648 return result.map_err(|e| e.into());
650 }
651 Err(std::sync::mpsc::TryRecvError::Empty) => {
652 }
654 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
655 log::warn!("Event stream disconnected");
656 }
658 }
659
660 if let Err(e) = line {
662 log::debug!("Seek stream read error (may be normal): {}", e);
663 break; }
665 }
666 log::info!("Seek connection closed by server");
668 }
669
670 let max_wait_seconds = if time == 0 && increment == 0 {
674 300 } else {
676 10 };
678
679 log::info!(
680 "Waiting for event stream to detect game (up to {} seconds)...",
681 max_wait_seconds
682 );
683 for _ in 0..max_wait_seconds {
684 if cancellation_token.load(std::sync::atomic::Ordering::Relaxed) {
685 return Err("Seek cancelled".into());
686 }
687
688 match game_found_rx.recv_timeout(std::time::Duration::from_secs(1)) {
690 Ok(result) => {
691 log::info!("Game found via event stream");
692 return result.map_err(|e| e.into());
693 }
694 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
695 }
697 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
698 return Err("Event stream disconnected".into());
699 }
700 }
701 }
702
703 Err("Seek timed out. No game started.".into())
704 }
705
706 pub fn join_game(
707 &self,
708 game_id: &str,
709 my_id: String,
710 ) -> Result<(String, Color), Box<dyn Error>> {
711 log::info!("Attempting to join game: {}", game_id);
712
713 if let Ok(ongoing_games) = self.get_ongoing_games() {
715 if let Some(game) = ongoing_games.iter().find(|g| g.game_id == game_id) {
716 let color = if game.color == "white" {
717 Color::White
718 } else {
719 Color::Black
720 };
721 log::info!("Found game in ongoing games: {} as {:?}", game_id, color);
722 return Ok((game_id.to_string(), color));
723 }
724 }
725
726 log::info!("Attempting to accept challenge: {}", game_id);
728 let accept_url = format!("{}/challenge/{}/accept", LICHESS_API_URL, game_id);
729 let accept_response = self
730 .client
731 .post(&accept_url)
732 .header(
733 "User-Agent",
734 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
735 )
736 .bearer_auth(&self.token)
737 .send();
738
739 let challenge_accepted = match accept_response {
740 Ok(resp) => {
741 if resp.status().is_success() {
742 log::info!("Successfully accepted challenge");
743 true
744 } else if resp.status() == reqwest::StatusCode::NOT_FOUND {
745 log::info!("Challenge not found, treating as game ID");
747 false
748 } else {
749 log::info!(
750 "Challenge accept returned {}, game may already be started or you created the challenge",
751 resp.status()
752 );
753 false
754 }
755 }
756 Err(e) => {
757 log::warn!(
758 "Failed to accept challenge: {}, will try to stream game anyway",
759 e
760 );
761 false
762 }
763 };
764
765 if challenge_accepted {
767 std::thread::sleep(std::time::Duration::from_millis(1000));
768 }
769
770 const MAX_POLL_ATTEMPTS: usize = 30;
773 const POLL_INTERVAL_MS: u64 = 1000;
774
775 for attempt in 0..MAX_POLL_ATTEMPTS {
776 if let Ok(ongoing_games) = self.get_ongoing_games() {
778 if let Some(game) = ongoing_games.iter().find(|g| g.game_id == game_id) {
779 let color = if game.color == "white" {
780 Color::White
781 } else {
782 Color::Black
783 };
784 log::info!(
785 "Found game in ongoing games after polling: {} as {:?}",
786 game_id,
787 color
788 );
789 return Ok((game_id.to_string(), color));
790 }
791 }
792
793 let url = format!("{}/board/game/{}/stream", LICHESS_API_URL, game_id);
795 let response = match self
796 .client
797 .get(&url)
798 .header(
799 "User-Agent",
800 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
801 )
802 .bearer_auth(&self.token)
803 .send()
804 {
805 Ok(resp) => {
806 if !resp.status().is_success() {
807 if resp.status() == reqwest::StatusCode::NOT_FOUND {
808 if attempt < MAX_POLL_ATTEMPTS - 1 {
810 log::info!(
811 "Game not started yet, waiting... (attempt {}/{})",
812 attempt + 1,
813 MAX_POLL_ATTEMPTS
814 );
815 std::thread::sleep(std::time::Duration::from_millis(
816 POLL_INTERVAL_MS,
817 ));
818 continue;
819 } else {
820 return Err("Game not found or hasn't started yet. Make sure the challenge has been accepted by your opponent.".into());
821 }
822 }
823 if resp.status() == reqwest::StatusCode::FORBIDDEN {
824 return Err(
825 "Cannot join this game. You may not be a participant.".into()
826 );
827 }
828 if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
829 return Err(
830 "Invalid token. Please check your token or generate a new one."
831 .into(),
832 );
833 }
834 return Err(format!("Failed to join game: {}", resp.status()).into());
835 }
836 resp
837 }
838 Err(e) => {
839 log::warn!("Failed to connect to stream: {}, will retry", e);
840 if attempt < MAX_POLL_ATTEMPTS - 1 {
841 std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
842 continue;
843 } else {
844 return Err(format!("Failed to connect to game stream: {}", e).into());
845 }
846 }
847 };
848
849 log::info!("Connected to game stream. Status: {}", response.status());
850
851 let reader = BufReader::new(response);
852 let mut line_count = 0;
853 const MAX_LINES: usize = 100; for line in reader.lines() {
856 let line = line?;
857 line_count += 1;
858
859 if line_count > MAX_LINES {
860 break; }
862
863 if line.trim().is_empty() {
864 continue;
865 }
866
867 log::info!("Received game event: {}", line);
868 match serde_json::from_str::<GameEvent>(&line) {
869 Ok(event) => {
870 if let GameEvent::GameFull {
871 id, white, black, ..
872 } = event
873 {
874 let color = if white.id.as_ref() == Some(&my_id) {
876 Color::White
877 } else if black.id.as_ref() == Some(&my_id) {
878 Color::Black
879 } else {
880 return Err("You are not a participant in this game.".into());
881 };
882
883 log::info!("Successfully joined game {} as {:?}", id, color);
884 return Ok((id, color));
885 }
886 if let GameEvent::GameState(_) = event {
889 log::info!("Received GameState event, checking ongoing games");
890 if let Ok(ongoing_games) = self.get_ongoing_games() {
892 if let Some(game) =
893 ongoing_games.iter().find(|g| g.game_id == game_id)
894 {
895 let color = if game.color == "white" {
896 Color::White
897 } else {
898 Color::Black
899 };
900 log::info!(
901 "Found game in ongoing games after GameState: {} as {:?}",
902 game_id,
903 color
904 );
905 return Ok((game_id.to_string(), color));
906 }
907 }
908 break;
910 }
911 }
912 Err(e) => {
913 log::error!("Failed to parse event: {} - Error: {}", line, e);
914 }
915 }
916 }
917
918 if attempt < MAX_POLL_ATTEMPTS - 1 {
920 log::info!(
921 "Game not fully started yet, waiting... (attempt {}/{})",
922 attempt + 1,
923 MAX_POLL_ATTEMPTS
924 );
925 std::thread::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS));
926 }
927 }
928
929 Err("Game not started yet or failed to join. If you created a challenge, make sure your opponent has accepted it. Otherwise, try using 'My Ongoing Games' to join.".into())
930 }
931
932 pub fn get_game_turn_count_and_last_move(
935 &self,
936 game_id: &str,
937 ) -> Result<(usize, Option<String>), Box<dyn Error>> {
938 let url = format!("{}/stream/game/{}", LICHESS_API_URL, game_id);
941 let response = self
942 .client
943 .get(&url)
944 .header(
945 "User-Agent",
946 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
947 )
948 .send()?;
949
950 if !response.status().is_success() {
951 return Err(format!("Failed to get game info: {}", response.status()).into());
952 }
953
954 let reader = BufReader::new(response);
956 if let Some(Ok(line)) = reader.lines().next() {
957 if line.trim().is_empty() {
958 return Err("Empty response from stream".into());
959 }
960
961 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
962 log::info!("Game info JSON: {:?}", json);
963 let turns = json
965 .get("turns")
966 .and_then(|v| v.as_u64())
967 .map(|v| v as usize)
968 .unwrap_or(0);
969
970 let last_move = json
972 .get("lastMove")
973 .and_then(|v| v.as_str())
974 .map(|s| s.to_string());
975
976 return Ok((turns, last_move));
977 } else {
978 return Err("Failed to parse JSON from stream".into());
979 }
980 }
981
982 Err("No data received from stream".into())
983 }
984
985 fn spawn_game_stream_thread(
986 &self,
987 game_id: String,
988 move_tx: Sender<String>,
989 _player_color: Option<Color>,
990 ) {
991 let client: Client = self.client.clone();
992 let token = self.token.clone();
993
994 thread::spawn(move || {
995 log::info!("Starting game stream thread for Lichess game {}", game_id);
996 let mut last_turns: Option<usize> = None;
997 let mut last_move_seen: Option<String> = None;
998 let mut last_status: Option<String> = None;
999
1000 loop {
1001 let stream_url = format!("{}/board/game/stream/{}", LICHESS_API_URL, game_id);
1002 log::info!("Connecting to board game stream: {}", stream_url);
1003
1004 let response = match client
1005 .get(&stream_url)
1006 .header(
1007 "User-Agent",
1008 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
1009 )
1010 .bearer_auth(&token)
1011 .send()
1012 {
1013 Ok(resp) => resp,
1014 Err(e) => {
1015 log::error!("Failed to connect to board game stream: {}", e);
1016 std::thread::sleep(std::time::Duration::from_secs(5));
1017 continue;
1018 }
1019 };
1020
1021 if !response.status().is_success() {
1022 if response.status() == reqwest::StatusCode::NOT_FOUND {
1023 log::info!("Game {} not found, stopping stream", game_id);
1024 break;
1025 }
1026 log::error!("Game stream returned status: {}", response.status());
1027 std::thread::sleep(std::time::Duration::from_secs(5));
1028 continue;
1029 }
1030
1031 log::info!("Connected to game stream");
1032 let reader = BufReader::new(response);
1033
1034 for line in reader.lines() {
1035 let line = match line {
1036 Ok(l) => l,
1037 Err(e) => {
1038 log::error!("Error reading game stream line: {}", e);
1039 break; }
1041 };
1042
1043 if line.trim().is_empty() {
1045 continue;
1046 }
1047
1048 log::debug!("Game stream received: {}", line);
1049
1050 match serde_json::from_str::<GameEvent>(&line) {
1052 Ok(GameEvent::GameFull {
1053 id: _,
1054 white: _,
1055 black: _,
1056 state,
1057 }) => {
1058 if last_turns.is_none() {
1060 let turns = state.moves.split_whitespace().count();
1061 log::info!("Initial game state: {} moves", turns);
1062 last_turns = Some(turns);
1063
1064 if turns > 0 {
1066 if let Some(last_move) = state.moves.split_whitespace().last() {
1067 log::info!("Sending initial move: {}", last_move);
1068 let _ = move_tx.send(last_move.to_string());
1069 last_move_seen = Some(last_move.to_string());
1070 }
1071 }
1072
1073 let _ = move_tx.send(format!("INIT_MOVES:{}", turns));
1075 }
1076 }
1077 Ok(GameEvent::GameState(state)) => {
1078 let current_turns = state.moves.split_whitespace().count();
1080
1081 if last_status.as_ref() != Some(&state.status) {
1083 log::info!("Game status changed: {}", state.status);
1084 match state.status.as_str() {
1085 "mate" | "checkmate" => {
1086 let _ = move_tx.send("GAME_STATUS:checkmate".to_string());
1087 }
1088 "draw" | "stalemate" | "repetition" | "insufficient"
1089 | "fifty" => {
1090 let _ = move_tx.send("GAME_STATUS:draw".to_string());
1091 }
1092 "resign" => {
1093 let _ = move_tx.send("GAME_STATUS:resign".to_string());
1094 }
1095 "aborted" => {
1096 let _ = move_tx.send("GAME_STATUS:aborted".to_string());
1097 }
1098 _ => {}
1099 }
1100 last_status = Some(state.status.clone());
1101 }
1102
1103 if let Some(last_turns_val) = last_turns {
1105 if current_turns > last_turns_val {
1106 if let Some(new_move) = state.moves.split_whitespace().last() {
1108 if last_move_seen.as_ref() != Some(&new_move.to_string()) {
1109 log::info!("New move from stream: {}", new_move);
1110 let _ = move_tx.send(new_move.to_string());
1111 last_move_seen = Some(new_move.to_string());
1112 last_turns = Some(current_turns);
1113 }
1114 }
1115 }
1116 } else {
1117 last_turns = Some(current_turns);
1119 if current_turns > 0 {
1120 if let Some(last_move) = state.moves.split_whitespace().last() {
1121 let _ = move_tx.send(last_move.to_string());
1122 last_move_seen = Some(last_move.to_string());
1123 }
1124 }
1125 let _ = move_tx.send(format!("INIT_MOVES:{}", current_turns));
1126 }
1127 }
1128 Ok(GameEvent::ChatLine) => {
1129 continue;
1131 }
1132 Err(e) => {
1133 if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
1135 if json.get("fen").is_some() && json.get("turns").is_some() {
1137 if last_turns.is_none() {
1138 if let Some(turns) =
1139 json.get("turns").and_then(|v| v.as_u64())
1140 {
1141 let turns_usize = turns as usize;
1142 last_turns = Some(turns_usize);
1143 if turns_usize > 0 {
1144 if let Some(last_move) =
1145 json.get("lastMove").and_then(|v| v.as_str())
1146 {
1147 let _ = move_tx.send(last_move.to_string());
1148 last_move_seen = Some(last_move.to_string());
1149 }
1150 }
1151 let _ =
1152 move_tx.send(format!("INIT_MOVES:{}", turns_usize));
1153 }
1154 } else {
1155 if let Some(last_move) =
1157 json.get("lastMove").and_then(|v| v.as_str())
1158 {
1159 if last_move_seen.as_ref()
1160 != Some(&last_move.to_string())
1161 {
1162 log::info!("New move from stream: {}", last_move);
1163 let _ = move_tx.send(last_move.to_string());
1164 last_move_seen = Some(last_move.to_string());
1165 if let Some(turns) =
1166 json.get("turns").and_then(|v| v.as_u64())
1167 {
1168 last_turns = Some(turns as usize);
1169 }
1170 }
1171 }
1172 }
1173
1174 if let Some(status) = json
1176 .get("status")
1177 .and_then(|s| s.get("name"))
1178 .and_then(|n| n.as_str())
1179 {
1180 if last_status.as_ref() != Some(&status.to_string()) {
1181 match status {
1182 "mate" | "checkmate" => {
1183 let _ = move_tx
1184 .send("GAME_STATUS:checkmate".to_string());
1185 }
1186 "draw" | "stalemate" | "repetition"
1187 | "insufficient" | "fifty" => {
1188 let _ = move_tx
1189 .send("GAME_STATUS:draw".to_string());
1190 }
1191 "resign" => {
1192 let _ = move_tx
1193 .send("GAME_STATUS:resign".to_string());
1194 }
1195 "aborted" => {
1196 let _ = move_tx
1197 .send("GAME_STATUS:aborted".to_string());
1198 }
1199 _ => {}
1200 }
1201 last_status = Some(status.to_string());
1202 }
1203 }
1204 }
1205 } else {
1206 log::warn!("Failed to parse game stream event: {} - {}", line, e);
1207 }
1208 }
1209 }
1210 }
1211
1212 log::warn!("Game stream ended, reconnecting in 5 seconds...");
1214 std::thread::sleep(std::time::Duration::from_secs(5));
1215 }
1216
1217 log::info!("Game stream thread ended for game {}", game_id);
1218 });
1219 }
1220
1221 pub fn stream_game(
1222 &self,
1223 game_id: String,
1224 move_tx: Sender<String>,
1225 player_color: Option<Color>,
1226 ) -> Result<(), Box<dyn Error>> {
1227 if game_id.is_empty() {
1229 log::warn!(
1230 "Cannot start stream: empty game_id (this should not happen for Lichess games)"
1231 );
1232 return Ok(());
1233 }
1234
1235 self.spawn_game_stream_thread(game_id, move_tx, player_color);
1237
1238 Ok(())
1239 }
1240
1241 pub fn make_move(&self, game_id: &str, move_str: &str) -> Result<(), Box<dyn Error>> {
1242 let url = format!(
1243 "{}/board/game/{}/move/{}",
1244 LICHESS_API_URL, game_id, move_str
1245 );
1246 let response = self.client.post(&url).bearer_auth(&self.token).send()?;
1247
1248 if !response.status().is_success() {
1249 return Err(format!("Failed to make move: {}", response.status()).into());
1250 }
1251 Ok(())
1252 }
1253
1254 pub fn resign_game(&self, game_id: &str) -> Result<(), Box<dyn Error>> {
1257 let url = format!("{}/board/game/{}/resign", LICHESS_API_URL, game_id);
1258 log::info!("Resigning game: {}", game_id);
1259
1260 let response = self
1261 .client
1262 .post(&url)
1263 .header(
1264 "User-Agent",
1265 "chess-tui (https://github.com/thomas-mauran/chess-tui)",
1266 )
1267 .bearer_auth(&self.token)
1268 .send()?;
1269
1270 if !response.status().is_success() {
1271 let status = response.status();
1272 let error_text = response.text().unwrap_or_default();
1273 log::error!("Failed to resign game: {} - {}", status, error_text);
1274 return Err(format!("Failed to resign game: {} - {}", status, error_text).into());
1275 }
1276
1277 log::info!("Successfully resigned game: {}", game_id);
1278 Ok(())
1279 }
1280}