Skip to content

Commit 9d207b5

Browse files
committed
Introduce recent rust servers mary
1 parent 35a7cf3 commit 9d207b5

File tree

20 files changed

+867
-11
lines changed

20 files changed

+867
-11
lines changed

app/Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ reqwest = { version = "0.12.5", features = [
2828
# Add back the default features excluding native-tls
2929
"charset",
3030
"http2",
31+
"json",
3132
"macos-system-configuration",
3233
], default-features = false }
3334
rustls = "0.23.19"
@@ -86,6 +87,7 @@ sqids = "0.4.2"
8687
rand = "0.9.0"
8788
jsonwebtoken = "9.3.1"
8889
fake = "4.0.0"
90+
uri = "0.4.0"
8991

9092
[build-dependencies]
9193
build-info-build = "0.0.39"

app/src/models/bm/core.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
#[derive(Debug, Serialize, Deserialize)]
4+
pub struct BattleMetricsObjectResponse {
5+
pub data: BattleMetricsType,
6+
#[serde(skip_serializing_if = "Option::is_none")]
7+
pub links: Option<serde_json::Value>,
8+
#[serde(skip_serializing_if = "Option::is_none")]
9+
pub included: Option<Vec<BattleMetricsType>>,
10+
}
11+
12+
#[derive(Debug, Serialize, Deserialize)]
13+
pub struct BattleMetricsResponse {
14+
pub data: Vec<BattleMetricsType>,
15+
#[serde(skip_serializing_if = "Option::is_none")]
16+
pub included: Option<Vec<BattleMetricsType>>,
17+
#[serde(skip_serializing_if = "Option::is_none")]
18+
pub links: Option<serde_json::Value>,
19+
}
20+
21+
22+
#[derive(Debug, Serialize, Deserialize)]
23+
pub struct BattleMetricsType {
24+
#[serde(rename = "type")]
25+
pub _type: String,
26+
#[serde(skip_serializing_if = "Option::is_none")]
27+
pub id: Option<String>,
28+
#[serde(skip_serializing_if = "Option::is_none")]
29+
pub attributes: Option<BattleMetricsAttributes>,
30+
#[serde(skip_serializing_if = "Option::is_none")]
31+
pub relationships: Option<BattleMetricsRelationships>,
32+
#[serde(skip_serializing_if = "Option::is_none")]
33+
pub meta: Option<BattleMetricsMeta>,
34+
}
35+
36+
#[derive(Debug, Serialize, Deserialize)]
37+
pub struct BattleMetricsAttributes {
38+
#[serde(rename = "type")]
39+
#[serde(skip_serializing_if = "Option::is_none")]
40+
pub _type: Option<String>,
41+
#[serde(skip_serializing_if = "Option::is_none")]
42+
pub name: Option<String>,
43+
#[serde(skip_serializing_if = "Option::is_none")]
44+
pub ip: Option<String>,
45+
#[serde(skip_serializing_if = "Option::is_none")]
46+
pub port: Option<u16>,
47+
#[serde(flatten)]
48+
pub extra: serde_json::Value,
49+
}
50+
51+
#[derive(Debug, Serialize, Deserialize)]
52+
pub struct BattleMetricsRelationshipData {
53+
pub data: BattleMetricsRelationshipIdentifier,
54+
}
55+
56+
#[derive(Debug, Serialize, Deserialize)]
57+
pub struct BattleMetricsRelationshipIdentifier {
58+
#[serde(rename = "type")]
59+
pub _type: String,
60+
pub id: String,
61+
}
62+
63+
#[derive(Debug, Serialize, Deserialize)]
64+
pub struct BattleMetricsRelationships {
65+
#[serde(skip_serializing_if = "Option::is_none")]
66+
pub server: Option<BattleMetricsRelationshipData>,
67+
#[serde(skip_serializing_if = "Option::is_none")]
68+
pub player: Option<BattleMetricsRelationshipData>,
69+
#[serde(skip_serializing_if = "Option::is_none")]
70+
pub organizations: Option<BattleMetricsRelationshipData>,
71+
}
72+
73+
#[derive(Debug, Serialize, Deserialize)]
74+
pub struct BattleMetricsMeta {
75+
#[serde(skip_serializing_if = "Option::is_none")]
76+
#[serde(rename = "timePlayed")]
77+
pub time_played: Option<u64>,
78+
#[serde(skip_serializing_if = "Option::is_none")]
79+
#[serde(rename = "firstSeen")]
80+
pub first_seen: Option<String>,
81+
#[serde(skip_serializing_if = "Option::is_none")]
82+
#[serde(rename = "lastSeen")]
83+
pub last_seen: Option<String>,
84+
#[serde(skip_serializing_if = "Option::is_none")]
85+
pub online: Option<bool>,
86+
}

app/src/models/bm/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pub mod core;
2+
pub mod recent;
3+
pub mod player;

app/src/models/bm/player.rs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
use std::ops::Deref;
2+
3+
use chrono::DateTime;
4+
use poem::Result;
5+
use poem_openapi::{payload::Json, types::ToJSON, Object, OpenApi};
6+
use reqwest::{Client, ClientBuilder};
7+
use serde::{Deserialize, Serialize};
8+
use tracing::{info, warn};
9+
10+
use super::core::*;
11+
12+
// Payload with the recent servers the user has connected to
13+
#[derive(Debug, Serialize, Deserialize)]
14+
pub struct BattleMetricsQuickMatchPayload {
15+
pub data: Vec<BattleMetricsType>,
16+
}
17+
18+
#[derive(Debug, Serialize, Deserialize, Object)]
19+
pub struct BattleMetricsPlayer {
20+
// `id` from BattleMetrics
21+
pub bm_id: String,
22+
pub name: Option<String>,
23+
pub private: Option<bool>,
24+
pub last_seen: Option<String>,
25+
}
26+
27+
impl From<&BattleMetricsType> for BattleMetricsPlayer {
28+
fn from(response: &BattleMetricsType) -> Self {
29+
BattleMetricsPlayer {
30+
bm_id: response
31+
.relationships
32+
.as_ref()
33+
.and_then(|r| r.player.as_ref().map(|p| p.data.id.clone()))
34+
.unwrap_or_default(),
35+
name: response
36+
.attributes
37+
.as_ref()
38+
.and_then(|a| a.extra.get("identifier").map(|v| v.to_string())),
39+
private: response
40+
.attributes
41+
.as_ref()
42+
.and_then(|a| a.extra.get("private").map(|v| v.as_bool().unwrap_or(false))),
43+
last_seen: response
44+
.attributes
45+
.as_ref()
46+
.and_then(|a| a.extra.get("lastSeen").map(|v| v.as_str().unwrap_or_default().to_string())),
47+
}
48+
}
49+
}
50+
51+
#[derive(Debug, Serialize, Deserialize)]
52+
pub struct BattleMetricsPlayerResponse {
53+
pub data: BattleMetricsPlayer,
54+
}
55+
56+
impl BattleMetricsPlayerResponse {
57+
pub fn from(response: BattleMetricsResponse) -> Result<Self> {
58+
// most last_seen response
59+
let mut data: Vec<BattleMetricsPlayer> = response
60+
.data
61+
.into_iter()
62+
.map(|x| BattleMetricsPlayer::from(&x))
63+
.collect();
64+
65+
data.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
66+
67+
// 5 days ago
68+
let day_cap = chrono::Duration::days(5);
69+
let now = chrono::Utc::now();
70+
71+
info!("length pre filtering: {}", data.len());
72+
73+
// filter out any players that haven't been seen in the last 5 days
74+
data.retain(|x| {
75+
// parse ISO 8601 dates like "2025-03-15T07:23:03.173Z"
76+
let last_seen_str = match x.last_seen.as_deref() {
77+
Some(last_seen) => last_seen,
78+
None => return false,
79+
};
80+
// Remove any surrounding quotes that might have been added
81+
let cleaned_str = last_seen_str.trim_matches('"');
82+
83+
info!("cleaned_str: {:?}", cleaned_str);
84+
85+
let last_seen = match chrono::DateTime::parse_from_rfc3339(cleaned_str) {
86+
Ok(last_seen) => last_seen,
87+
Err(e) => {
88+
warn!("Failed to parse last seen: {} - {:?}", cleaned_str, e);
89+
return false;
90+
},
91+
};
92+
93+
info!("last_seen: {:?}", last_seen);
94+
95+
last_seen > now - day_cap
96+
});
97+
98+
info!("length post filtering: {}", data.len());
99+
100+
// log all results
101+
102+
info!("data: {:?}", data);
103+
104+
let result_threshold = 4;
105+
106+
// if there are more then threshold results
107+
if data.len() > result_threshold {
108+
return Err(poem::Error::from_string(
109+
format!("Too many results: {}", data.len()),
110+
poem::http::StatusCode::BAD_REQUEST,
111+
));
112+
}
113+
114+
if data.is_empty() {
115+
return Err(poem::Error::from_string(
116+
"No results found".to_string(),
117+
poem::http::StatusCode::BAD_REQUEST,
118+
));
119+
}
120+
121+
// extract the first result and discard the rest
122+
let data = data.into_iter().next().unwrap();
123+
124+
Ok(Self { data })
125+
}
126+
}
127+
128+
pub async fn get_quick_match_players(
129+
player_name: String,
130+
auth_token: &String,
131+
) -> Result<BattleMetricsPlayerResponse> {
132+
let payload = BattleMetricsQuickMatchPayload {
133+
data: vec![BattleMetricsType {
134+
_type: "identifier".to_string(),
135+
id: None,
136+
attributes: Some(BattleMetricsAttributes {
137+
_type: Some("name".to_string()),
138+
name: None,
139+
ip: None,
140+
extra: serde_json::json!({ "identifier": player_name }),
141+
port: None,
142+
}),
143+
relationships: None,
144+
meta: None,
145+
}],
146+
};
147+
148+
let payload_json = serde_json::to_string(&payload).unwrap();
149+
info!("Payload: {}", payload_json);
150+
151+
let client = ClientBuilder::new()
152+
.timeout(std::time::Duration::from_secs(10))
153+
.use_rustls_tls()
154+
.build()
155+
.map_err(|e| {
156+
warn!("Failed to build HTTP client: {}", e);
157+
poem::Error::from_string(
158+
format!("Client build error: {}", e),
159+
poem::http::StatusCode::INTERNAL_SERVER_ERROR,
160+
)
161+
})?;
162+
163+
let url = "https://api.battlemetrics.com/players/quick-match?page[size]=5";
164+
165+
let response = client
166+
.post(url)
167+
.json(&payload)
168+
.header("Authorization", format!("Bearer {}", auth_token))
169+
.timeout(std::time::Duration::from_secs(2))
170+
.send()
171+
.await
172+
.map_err(|e| {
173+
warn!("Failed to send request: {}", e);
174+
poem::Error::from_string(
175+
format!("Request error: {}", e),
176+
poem::http::StatusCode::INTERNAL_SERVER_ERROR,
177+
)
178+
})?;
179+
180+
let body = response.text().await.map_err(|e| {
181+
warn!("Failed to get response text: {}", e);
182+
poem::Error::from_string(
183+
format!("Response text error: {}", e),
184+
poem::http::StatusCode::INTERNAL_SERVER_ERROR,
185+
)
186+
})?;
187+
188+
tracing::info!("Response body: {}", body);
189+
190+
let search_response: BattleMetricsResponse = serde_json::from_str(&body).map_err(|e| {
191+
warn!("Failed to parse JSON: {}", e);
192+
poem::Error::from_string(
193+
format!("JSON parse error: {}", e),
194+
poem::http::StatusCode::INTERNAL_SERVER_ERROR,
195+
)
196+
})?;
197+
198+
tracing::info!("Search response: {:?}", search_response);
199+
200+
// convert to
201+
let data = BattleMetricsPlayerResponse::from(search_response)?;
202+
203+
Ok(data)
204+
}

0 commit comments

Comments
 (0)