use serde::{Deserialize, Serialize};
use crate::envelope::{Ack, ServerPush};
use crate::error::{ApiError, ValidationDetails};
use crate::ids::UserId;
use crate::kinds;
use crate::page::{Cursor, Page, PageRequest};
use crate::time::UnixMillis;
pub const FRIEND_CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
pub const FRIEND_CODE_LEN: usize = 8;
pub const MAX_NAME_CHARS: usize = 64;
pub fn normalize_friend_code(input: &str) -> Option<String> {
let code: String = input.chars().filter(|c| !matches!(c, ' ' | '-')).map(|c| c.to_ascii_uppercase()).collect();
(code.len() == FRIEND_CODE_LEN && code.bytes().all(|b| FRIEND_CODE_ALPHABET.contains(&b))).then_some(code)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum FriendState {
Friend,
Sent,
Received,
Blocked,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FriendEntry {
pub user: UserId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub state: FriendState,
pub since: UnixMillis,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub online: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_seen: Option<UnixMillis>,
}
impl FriendEntry {
pub fn new(user: UserId, state: FriendState, since: UnixMillis) -> Self {
Self { user, name: None, state, since, online: None, last_seen: None }
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_online(mut self, online: bool, last_seen: Option<UnixMillis>) -> Self {
self.online = Some(online);
self.last_seen = last_seen;
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AddFriend {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<UserId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
}
impl AddFriend {
pub fn by_id(user: UserId) -> Self {
Self { user: Some(user), ..Self::default() }
}
pub fn by_name(name: impl Into<String>) -> Self {
Self { name: Some(name.into()), ..Self::default() }
}
pub fn by_code(code: impl Into<String>) -> Self {
Self { code: Some(code.into()), ..Self::default() }
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
let given = usize::from(self.user.is_some()) + usize::from(self.name.is_some()) + usize::from(self.code.is_some());
if given != 1 {
details.add("user", "give exactly one of user, name and code");
}
if let Some(name) = &self.name {
let count = name.trim().chars().count();
if count == 0 || count > MAX_NAME_CHARS {
details.add("name", format!("must be 1 to {MAX_NAME_CHARS} characters"));
}
}
if let Some(code) = &self.code {
if normalize_friend_code(code).is_none() {
details.add("code", format!("must be {FRIEND_CODE_LEN} characters of {}", String::from_utf8_lossy(FRIEND_CODE_ALPHABET)));
}
}
details.into_result()
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RequestDirection {
#[default]
Received,
Sent,
#[serde(other)]
Unknown,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RequestQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direction: Option<RequestDirection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<Cursor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
impl RequestQuery {
pub fn received() -> Self {
Self::default()
}
pub fn sent() -> Self {
Self { direction: Some(RequestDirection::Sent), ..Self::default() }
}
pub fn after(mut self, cursor: Cursor) -> Self {
self.cursor = Some(cursor);
self
}
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
pub fn page(&self) -> PageRequest {
PageRequest { cursor: self.cursor.clone(), limit: self.limit }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FriendCode {
pub code: String,
}
impl FriendCode {
pub fn new(code: impl Into<String>) -> Self {
Self { code: code.into() }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FriendPresence {
pub user: UserId,
pub online: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_seen: Option<UnixMillis>,
}
impl FriendPresence {
pub fn new(user: UserId, online: bool) -> Self {
Self { user, online, last_seen: None }
}
pub fn with_last_seen(mut self, at: UnixMillis) -> Self {
self.last_seen = Some(at);
self
}
}
impl ServerPush for FriendPresence {
const KIND: &'static str = kinds::FRIENDS_PRESENCE;
}
pub const MAX_STEAM_IDS: usize = 2000;
const STEAM_INDIVIDUAL_HIGH: u64 = 0x0110_0001;
pub fn is_individual_steam_id(id: u64) -> bool {
id >> 32 == STEAM_INDIVIDUAL_HIGH && id & 0xFFFF_FFFF != 0
}
pub fn parse_steam_id(text: &str) -> Option<u64> {
if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let id: u64 = text.parse().ok()?;
(id.to_string() == text && is_individual_steam_id(id)).then_some(id)
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SteamMatch {
pub steam_ids: Vec<String>,
}
impl SteamMatch {
pub fn new(steam_ids: impl IntoIterator<Item = u64>) -> Self {
Self { steam_ids: steam_ids.into_iter().map(|id| id.to_string()).collect() }
}
pub fn ids(&self) -> Vec<u64> {
let mut seen = std::collections::HashSet::new();
self.steam_ids.iter().filter_map(|text| parse_steam_id(text)).filter(|id| seen.insert(*id)).collect()
}
pub fn validate(&self) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if self.steam_ids.len() > MAX_STEAM_IDS {
details.add("steam_ids", format!("at most {MAX_STEAM_IDS} Steam IDs"));
}
for (index, _) in self.steam_ids.iter().enumerate().filter(|(_, text)| parse_steam_id(text).is_none()).take(10) {
details.add(format!("steam_ids[{index}]"), "is not the SteamID64 of an individual Steam account (decimal digits)");
}
details.into_result()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SteamPlayer {
pub steam_id: String,
pub user: UserId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<FriendState>,
}
impl SteamPlayer {
pub fn new(steam_id: impl Into<String>, user: UserId) -> Self {
Self { steam_id: steam_id.into(), user, name: None, state: None }
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_state(mut self, state: FriendState) -> Self {
self.state = Some(state);
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SteamMatchResult {
pub players: Vec<SteamPlayer>,
}
impl SteamMatchResult {
pub fn new(players: Vec<SteamPlayer>) -> Self {
Self { players }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FriendSettings {
pub steam_findable: bool,
}
impl FriendSettings {
pub fn new(steam_findable: bool) -> Self {
Self { steam_findable }
}
}
impl Default for FriendSettings {
fn default() -> Self {
Self::new(true)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct UpdateFriendSettings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub steam_findable: Option<bool>,
}
impl UpdateFriendSettings {
pub fn new() -> Self {
Self::default()
}
pub fn steam_findable(mut self, findable: bool) -> Self {
self.steam_findable = Some(findable);
self
}
}
mod calls {
use super::*;
use crate::http_call::{payload_call, HttpCall, NoPayload, PathParams, PayloadKind, NO_PAYLOAD};
use crate::routes::{self, HttpMethod, Route};
payload_call!(AddFriend, Post, routes::friends::REQUESTS, true, Json, FriendEntry);
payload_call!(SteamMatch, Post, routes::friends::STEAM, true, Json, SteamMatchResult);
payload_call!(UpdateFriendSettings, Put, routes::friends::SETTINGS, true, Json, FriendSettings);
macro_rules! page_call {
($(#[$meta:meta])* $name:ident, $path:expr, $response:ty) => {
$(#[$meta])*
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct $name {
/// Which page.
pub page: PageRequest,
}
impl $name {
pub fn new() -> Self {
Self::default()
}
pub fn with_page(mut self, page: PageRequest) -> Self {
self.page = page;
self
}
}
impl HttpCall for $name {
type Payload = PageRequest;
type Response = $response;
const ROUTE: Route = Route::new(HttpMethod::Get, $path, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &PageRequest {
&self.page
}
fn from_parts(_params: &PathParams, page: PageRequest) -> Result<Self, ApiError> {
Ok(Self::new().with_page(page))
}
}
};
}
page_call!(
ListFriends,
routes::friends::LIST,
Page<FriendEntry>
);
page_call!(
ListBlocks,
routes::friends::BLOCKS,
Page<FriendEntry>
);
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListFriendRequests {
pub query: RequestQuery,
}
impl ListFriendRequests {
pub fn received() -> Self {
Self::default()
}
pub fn sent() -> Self {
Self { query: RequestQuery::sent() }
}
pub fn with_query(mut self, query: RequestQuery) -> Self {
self.query = query;
self
}
}
impl HttpCall for ListFriendRequests {
type Payload = RequestQuery;
type Response = Page<FriendEntry>;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::friends::REQUESTS, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &RequestQuery {
&self.query
}
fn from_parts(_params: &PathParams, query: RequestQuery) -> Result<Self, ApiError> {
Ok(Self::received().with_query(query))
}
}
macro_rules! user_call {
($(#[$meta:meta])* $name:ident, $method:ident, $path:expr, $response:ty) => {
$(#[$meta])*
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct $name {
/// The other player.
pub user: UserId,
}
impl $name {
pub fn new(user: UserId) -> Self {
Self { user }
}
}
impl HttpCall for $name {
type Payload = NoPayload;
type Response = $response;
const ROUTE: Route = Route::new(HttpMethod::$method, $path, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn path_params(&self) -> PathParams {
PathParams::new().with("user", self.user)
}
fn from_parts(params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new(params.id("user")?))
}
}
};
}
user_call!(
RemoveFriend,
Delete,
routes::friends::ONE,
Ack
);
user_call!(
CancelFriendRequest,
Delete,
routes::friends::REQUEST,
Ack
);
user_call!(
AcceptFriend,
Post,
routes::friends::ACCEPT,
FriendEntry
);
user_call!(
DeclineFriend,
Post,
routes::friends::DECLINE,
Ack
);
user_call!(
BlockUser,
Put,
routes::friends::BLOCK,
Ack
);
user_call!(
UnblockUser,
Delete,
routes::friends::BLOCK,
Ack
);
macro_rules! empty_call {
($(#[$meta:meta])* $name:ident, $method:ident, $path:expr, $response:ty) => {
$(#[$meta])*
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct $name {}
impl $name {
pub fn new() -> Self {
Self {}
}
}
impl HttpCall for $name {
type Payload = NoPayload;
type Response = $response;
const ROUTE: Route = Route::new(HttpMethod::$method, $path, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn from_parts(_params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new())
}
}
};
}
empty_call!(
GetFriendCode,
Get,
routes::friends::CODE,
FriendCode
);
empty_call!(
ResetFriendCode,
Post,
routes::friends::CODE,
FriendCode
);
empty_call!(
FriendsHeartbeat,
Post,
routes::friends::PRESENCE,
Ack
);
empty_call!(
GetFriendSettings,
Get,
routes::friends::SETTINGS,
FriendSettings
);
}
pub use calls::{
AcceptFriend, BlockUser, CancelFriendRequest, DeclineFriend, FriendsHeartbeat, GetFriendCode, GetFriendSettings, ListBlocks, ListFriendRequests,
ListFriends, RemoveFriend, ResetFriendCode, UnblockUser,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes() {
assert_eq!(normalize_friend_code("k7m2-q9xd").as_deref(), Some("K7M2Q9XD"));
assert_eq!(normalize_friend_code("K7M2 Q9XD").as_deref(), Some("K7M2Q9XD"));
for bad in ["", "K7M2Q9X", "K7M2Q9XDD", "K7M2Q9XO", "K7M2Q9X1", "K7M2Q9XÄ", "K7M2_Q9XD"] {
assert_eq!(normalize_friend_code(bad), None, "{bad}");
}
assert_eq!(FRIEND_CODE_ALPHABET.len(), 32);
}
#[test]
fn json_and_rules() {
assert_eq!(serde_json::to_string(&AddFriend::by_id(UserId(7))).ok().as_deref(), Some(r#"{"user":7}"#));
assert!(AddFriend::by_id(UserId(7)).validate().is_ok());
assert!(AddFriend::by_name(" Ada ").validate().is_ok());
assert!(AddFriend::by_code("k7m2-q9xd").validate().is_ok());
assert!(AddFriend::default().validate().is_err());
assert!(AddFriend { user: Some(UserId(1)), name: Some("Ada".into()), code: None }.validate().is_err());
assert!(AddFriend::by_name(" ").validate().is_err());
assert!(AddFriend::by_name("x".repeat(MAX_NAME_CHARS + 1)).validate().is_err());
assert!(AddFriend::by_code("nope").validate().is_err());
let entry = FriendEntry::new(UserId(7), FriendState::Friend, UnixMillis(5)).with_name("Ada").with_online(false, Some(UnixMillis(4)));
assert_eq!(serde_json::to_string(&entry).ok().as_deref(), Some(r#"{"user":7,"name":"Ada","state":"friend","since":5,"online":false,"last_seen":4}"#));
assert_eq!(serde_json::from_str::<FriendState>(r#""muted""#).ok(), Some(FriendState::Unknown));
assert_eq!(serde_json::to_string(&RequestQuery::sent().with_limit(5)).ok().as_deref(), Some(r#"{"direction":"sent","limit":5}"#));
assert_eq!(RequestQuery::sent().with_limit(5).page().limit, Some(5));
let push = FriendPresence::new(UserId(7), false).with_last_seen(UnixMillis(9));
assert_eq!(serde_json::to_string(&push).ok().as_deref(), Some(r#"{"user":7,"online":false,"last_seen":9}"#));
assert_eq!(<FriendPresence as ServerPush>::KIND, "friends.presence");
}
#[test]
fn steam_ids() {
let base: u64 = 76_561_197_960_265_728;
assert!(is_individual_steam_id(base + 1) && is_individual_steam_id(base + u64::from(u32::MAX)));
assert!(!is_individual_steam_id(base), "account number 0");
assert!(!is_individual_steam_id(base + (1 << 32)), "instance 2");
assert!(!is_individual_steam_id(0) && !is_individual_steam_id(u64::MAX));
for bad in
["", "-76561201960265729", "+76561201960265729", "7656120196026572x", "76561201960265729 ", "076561201960265729", "1", "18446744073709551616"]
{
assert_eq!(parse_steam_id(bad), None, "{bad}");
}
let call = SteamMatch::new([base + 1, base + 2, base + 1]);
assert!(call.validate().is_ok());
assert_eq!(call.ids(), [base + 1, base + 2], "in order, once each");
assert_eq!(serde_json::to_string(&call).ok().as_deref(), Some(r#"{"steam_ids":["76561197960265729","76561197960265730","76561197960265729"]}"#));
let bad = SteamMatch { steam_ids: vec!["76561197960265729".into(), "x".into(), "76561197960265728".into()] };
let error = bad.validate().err().map(|e| format!("{e:?}")).unwrap_or_default();
assert!(error.contains("steam_ids[1]") && error.contains("steam_ids[2]") && !error.contains("steam_ids[0]"), "{error}");
let many = SteamMatch::new((1..=MAX_STEAM_IDS as u64 + 1).map(|n| base + n));
assert!(many.validate().is_err());
assert!(SteamMatch::default().validate().is_ok(), "an empty list is fine");
let player = SteamPlayer::new("76561197960265729", UserId(7)).with_name("Ada").with_state(FriendState::Sent);
assert_eq!(serde_json::to_string(&player).ok().as_deref(), Some(r#"{"steam_id":"76561197960265729","user":7,"name":"Ada","state":"sent"}"#));
assert_eq!(
serde_json::to_string(&SteamPlayer::new("76561197960265729", UserId(7))).ok().as_deref(),
Some(r#"{"steam_id":"76561197960265729","user":7}"#)
);
assert_eq!(serde_json::to_string(&FriendSettings::default()).ok().as_deref(), Some(r#"{"steam_findable":true}"#));
assert_eq!(serde_json::to_string(&UpdateFriendSettings::new().steam_findable(false)).ok().as_deref(), Some(r#"{"steam_findable":false}"#));
assert_eq!(serde_json::to_string(&UpdateFriendSettings::new()).ok().as_deref(), Some("{}"));
}
}