record-daemon: initial commit
This commit is contained in:
423
record-daemon/src/lqp/client.rs
Normal file
423
record-daemon/src/lqp/client.rs
Normal file
@@ -0,0 +1,423 @@
|
||||
//! LQP Client for communicating with the League Client API.
|
||||
//!
|
||||
//! Provides both WebSocket (for events) and REST (for queries) interfaces.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tokio_tungstenite::{connect_async_with_config, tungstenite::protocol::Message};
|
||||
use tracing::{debug, error, info, trace, warn};
|
||||
|
||||
use super::auth::LockfileCredentials;
|
||||
use super::events::GameEvent;
|
||||
use crate::error::{LqpError, Result};
|
||||
|
||||
/// LQP WebSocket endpoints to subscribe to.
|
||||
const SUBSCRIBE_ENDPOINTS: &[&str] = &[
|
||||
"/lol-gameflow/v1/gameflow-phase",
|
||||
"/lol-matchmaking/v1/ready-check",
|
||||
"/lol-game-events/v1/game-events",
|
||||
];
|
||||
|
||||
/// LQP REST API endpoints.
|
||||
pub mod endpoints {
|
||||
pub const GAMEFLOW_PHASE: &str = "/lol-gameflow/v1/gameflow-phase";
|
||||
pub const SESSION: &str = "/lol-gameflow/v1/session";
|
||||
pub const CHAMPION_SELECT: &str = "/lol-champ-select/v1/session";
|
||||
pub const SUMMONER: &str = "/lol-summoner/v1/current-summoner";
|
||||
pub const GAME_STATS: &str = "/lol-end-of-game/v1/eog-stats-block";
|
||||
}
|
||||
|
||||
/// Game flow phase states.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GameflowPhase {
|
||||
/// Client is in main menu or lobby.
|
||||
None,
|
||||
/// In lobby.
|
||||
Lobby,
|
||||
/// In queue.
|
||||
Queue,
|
||||
/// Match found, ready check.
|
||||
ReadyCheck,
|
||||
/// In champion select.
|
||||
ChampSelect,
|
||||
/// Game is starting.
|
||||
GameStart,
|
||||
/// In game.
|
||||
InProgress,
|
||||
/// Game ended, waiting for stats.
|
||||
WaitingForStats,
|
||||
/// End of game stats screen.
|
||||
EndOfGame,
|
||||
/// Unknown phase.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl From<&str> for GameflowPhase {
|
||||
fn from(s: &str) -> Self {
|
||||
match s {
|
||||
"None" => GameflowPhase::None,
|
||||
"Lobby" => GameflowPhase::Lobby,
|
||||
"Queue" => GameflowPhase::Queue,
|
||||
"ReadyCheck" => GameflowPhase::ReadyCheck,
|
||||
"ChampSelect" => GameflowPhase::ChampSelect,
|
||||
"GameStart" => GameflowPhase::GameStart,
|
||||
"InProgress" => GameflowPhase::InProgress,
|
||||
"WaitingForStats" => GameflowPhase::WaitingForStats,
|
||||
"EndOfGame" => GameflowPhase::EndOfGame,
|
||||
_ => GameflowPhase::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LQP Client state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClientState {
|
||||
/// Current gameflow phase.
|
||||
pub phase: GameflowPhase,
|
||||
/// Current game ID if in game.
|
||||
pub game_id: Option<u64>,
|
||||
/// Current champion name.
|
||||
pub champion: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ClientState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phase: GameflowPhase::None,
|
||||
game_id: None,
|
||||
champion: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LQP Client for League Client communication.
|
||||
pub struct LqpClient {
|
||||
/// Connection credentials.
|
||||
credentials: Arc<RwLock<Option<LockfileCredentials>>>,
|
||||
/// Current client state.
|
||||
state: Arc<RwLock<ClientState>>,
|
||||
/// Event broadcaster.
|
||||
event_sender: broadcast::Sender<GameEvent>,
|
||||
/// HTTP client for REST API.
|
||||
http_client: reqwest::Client,
|
||||
/// Shutdown signal.
|
||||
shutdown: Arc<RwLock<bool>>,
|
||||
}
|
||||
|
||||
impl LqpClient {
|
||||
/// Create a new LQP client.
|
||||
pub fn new() -> Self {
|
||||
let (event_sender, _) = broadcast::channel(256);
|
||||
|
||||
let http_client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true) // LQP uses self-signed certs
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
Self {
|
||||
credentials: Arc::new(RwLock::new(None)),
|
||||
state: Arc::new(RwLock::new(ClientState::default())),
|
||||
event_sender,
|
||||
http_client,
|
||||
shutdown: Arc::new(RwLock::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a subscriber for game events.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<GameEvent> {
|
||||
self.event_sender.subscribe()
|
||||
}
|
||||
|
||||
/// Get current client state.
|
||||
pub async fn state(&self) -> ClientState {
|
||||
self.state.read().await.clone()
|
||||
}
|
||||
|
||||
/// Check if connected to League Client.
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
self.credentials.read().await.is_some()
|
||||
}
|
||||
|
||||
/// Connect to the League Client with the given credentials.
|
||||
pub async fn connect(&self, creds: LockfileCredentials) -> Result<()> {
|
||||
info!("Connecting to League Client at port {}", creds.port);
|
||||
|
||||
// Store credentials
|
||||
*self.credentials.write().await = Some(creds.clone());
|
||||
|
||||
// Verify connection by fetching current phase
|
||||
match self.get_gameflow_phase().await {
|
||||
Ok(phase) => {
|
||||
self.state.write().await.phase = phase;
|
||||
info!("Connected to League Client, current phase: {:?}", phase);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to verify connection: {}", e);
|
||||
// Still consider connected, WebSocket might work
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disconnect from the League Client.
|
||||
pub async fn disconnect(&self) {
|
||||
*self.shutdown.write().await = true;
|
||||
*self.credentials.write().await = None;
|
||||
*self.state.write().await = ClientState::default();
|
||||
info!("Disconnected from League Client");
|
||||
}
|
||||
|
||||
/// Start the WebSocket event listener.
|
||||
///
|
||||
/// This runs in a background task and broadcasts events to subscribers.
|
||||
pub async fn start_event_listener(&self) -> Result<()> {
|
||||
let creds = self
|
||||
.credentials
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or(LqpError::ClientNotRunning)?;
|
||||
|
||||
let ws_url = format!("{}/", creds.ws_url());
|
||||
let auth_header = creds.auth_header();
|
||||
|
||||
info!("Connecting to LQP WebSocket at {}", ws_url);
|
||||
|
||||
// Build WebSocket request with auth header
|
||||
let request = tokio_tungstenite::tungstenite::http::Request::builder()
|
||||
.uri(&ws_url)
|
||||
.header("Authorization", auth_header)
|
||||
.body(())
|
||||
.map_err(|e| LqpError::ConnectionFailed(e.to_string()))?;
|
||||
|
||||
let (ws_stream, _) = connect_async_with_config(
|
||||
request, None, false,
|
||||
// None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| LqpError::WebSocketError(e.to_string()))?;
|
||||
|
||||
info!("WebSocket connected, subscribing to events");
|
||||
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
// Subscribe to endpoints
|
||||
for endpoint in SUBSCRIBE_ENDPOINTS {
|
||||
let subscribe_msg = serde_json::json!([5, endpoint]);
|
||||
let msg = Message::Text(subscribe_msg.to_string());
|
||||
write
|
||||
.send(msg)
|
||||
.await
|
||||
.map_err(|e| LqpError::WebSocketError(e.to_string()))?;
|
||||
trace!("Subscribed to {}", endpoint);
|
||||
}
|
||||
|
||||
// Clone references for the async block
|
||||
let event_sender = self.event_sender.clone();
|
||||
let state = self.state.clone();
|
||||
let shutdown = self.shutdown.clone();
|
||||
let credentials = self.credentials.clone();
|
||||
|
||||
// Spawn the message handler
|
||||
tokio::spawn(async move {
|
||||
while let Some(msg) = read.next().await {
|
||||
if *shutdown.read().await {
|
||||
debug!("WebSocket listener shutting down");
|
||||
break;
|
||||
}
|
||||
|
||||
match msg {
|
||||
Ok(Message::Text(text)) => {
|
||||
if let Some(event) = Self::parse_websocket_message(&text) {
|
||||
// Update state based on event
|
||||
Self::update_state_from_event(&state, &event).await;
|
||||
|
||||
// Broadcast event
|
||||
if event_sender.send(event.clone()).is_err() {
|
||||
trace!("No event subscribers");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
info!("WebSocket closed by server");
|
||||
break;
|
||||
}
|
||||
Ok(Message::Ping(data)) => {
|
||||
// Respond with pong
|
||||
let _ = write.send(Message::Pong(data)).await;
|
||||
}
|
||||
Err(e) => {
|
||||
error!("WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear credentials on disconnect
|
||||
*credentials.write().await = None;
|
||||
debug!("WebSocket listener ended");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a WebSocket message into a game event.
|
||||
fn parse_websocket_message(text: &str) -> Option<GameEvent> {
|
||||
trace!("WebSocket message: {}", text);
|
||||
|
||||
// Parse the message array format: [type, endpoint, data]
|
||||
let value: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
|
||||
// Check if it's an event message (type 8)
|
||||
if let Some(arr) = value.as_array() {
|
||||
if arr.len() >= 3 {
|
||||
let msg_type = arr.first()?.as_u64()?;
|
||||
|
||||
if msg_type == 8 {
|
||||
// Event message
|
||||
let endpoint = arr.get(1)?.as_str()?;
|
||||
let data = arr.get(2)?;
|
||||
|
||||
return Self::parse_event_from_endpoint(endpoint, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse an event based on the endpoint.
|
||||
fn parse_event_from_endpoint(endpoint: &str, data: &serde_json::Value) -> Option<GameEvent> {
|
||||
match endpoint {
|
||||
"/lol-gameflow/v1/gameflow-phase" => {
|
||||
let phase = data.as_str()?;
|
||||
Some(
|
||||
GameEvent::from_json(&serde_json::json!({
|
||||
"eventType": "lcu-phase-change",
|
||||
"phase": phase
|
||||
}))
|
||||
.unwrap_or(GameEvent::Unknown),
|
||||
)
|
||||
}
|
||||
"/lol-game-events/v1/game-events" => GameEvent::from_json(data),
|
||||
_ => {
|
||||
trace!("Unhandled endpoint: {}", endpoint);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update internal state from a game event.
|
||||
async fn update_state_from_event(state: &Arc<RwLock<ClientState>>, event: &GameEvent) {
|
||||
let mut state = state.write().await;
|
||||
|
||||
match event {
|
||||
GameEvent::GameStart(info) => {
|
||||
state.phase = GameflowPhase::InProgress;
|
||||
state.game_id = Some(info.game_id);
|
||||
state.champion = info.champion.clone();
|
||||
}
|
||||
GameEvent::GameEnd(_) => {
|
||||
state.phase = GameflowPhase::EndOfGame;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a REST API request to the League Client.
|
||||
pub async fn request(&self, method: &str, endpoint: &str) -> Result<serde_json::Value> {
|
||||
let creds = self
|
||||
.credentials
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or(LqpError::ClientNotRunning)?;
|
||||
|
||||
let url = format!("{}{}", creds.base_url(), endpoint);
|
||||
|
||||
let request = match method {
|
||||
"GET" => self.http_client.get(&url),
|
||||
"POST" => self.http_client.post(&url),
|
||||
"PUT" => self.http_client.put(&url),
|
||||
"DELETE" => self.http_client.delete(&url),
|
||||
_ => {
|
||||
return Err(
|
||||
LqpError::ConnectionFailed(format!("Invalid method: {}", method)).into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
.header("Authorization", creds.auth_header());
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| LqpError::ConnectionFailed(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(LqpError::ConnectionFailed(format!(
|
||||
"API request failed: {}",
|
||||
response.status()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let json = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| LqpError::EventParseError(e.to_string()))?;
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
/// Get the current gameflow phase.
|
||||
pub async fn get_gameflow_phase(&self) -> Result<GameflowPhase> {
|
||||
let json = self.request("GET", endpoints::GAMEFLOW_PHASE).await?;
|
||||
|
||||
let phase_str = json
|
||||
.as_str()
|
||||
.ok_or_else(|| LqpError::EventParseError("Invalid phase response".to_string()))?;
|
||||
|
||||
Ok(GameflowPhase::from(phase_str))
|
||||
}
|
||||
|
||||
/// Get the current game session info.
|
||||
pub async fn get_session(&self) -> Result<serde_json::Value> {
|
||||
self.request("GET", endpoints::SESSION).await
|
||||
}
|
||||
|
||||
/// Get current summoner info.
|
||||
pub async fn get_summoner(&self) -> Result<serde_json::Value> {
|
||||
self.request("GET", endpoints::SUMMONER).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LqpClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_gameflow_phase_from_str() {
|
||||
assert_eq!(GameflowPhase::from("InProgress"), GameflowPhase::InProgress);
|
||||
assert_eq!(
|
||||
GameflowPhase::from("ChampSelect"),
|
||||
GameflowPhase::ChampSelect
|
||||
);
|
||||
assert_eq!(GameflowPhase::from("UnknownPhase"), GameflowPhase::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_creation() {
|
||||
let client = LqpClient::new();
|
||||
assert!(!tokio_test::block_on(client.is_connected()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user