record-daemon: initial commit

This commit is contained in:
2026-03-19 17:48:07 +01:00
commit d6c0334369
30 changed files with 9486 additions and 0 deletions

View 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()));
}
}