steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
//! Account related types.

use std::collections::HashMap;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// Transaction ID wrapper
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TransactionId(pub String);

/// Response from adding a phone number.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddPhoneNumberResponse {
    pub success: bool,
    #[serde(rename = "showResend")]
    pub show_resend: bool,
    pub state: String,
    #[serde(rename = "errorText")]
    pub error_text: String,
    pub token: String,
    #[serde(rename = "phoneNumber")]
    pub phone_number: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfirmPhoneCodeResponse {
    pub success: bool,
    #[serde(rename = "showResend")]
    pub show_resend: bool,
    pub state: serde_json::Value, // Can be string or bool in JS example
    #[serde(rename = "errorText")]
    pub error_text: String,
    pub token: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemovePhoneResult {
    pub success: bool,
    pub method: Option<i32>,
    #[serde(rename = "type")]
    pub confirm_type: Option<String>,
    pub link: Option<String>,
    pub wizard_param: Option<serde_json::Value>,
}

/// Wallet balance information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalletBalance {
    #[serde(rename = "mainBalance")]
    pub main_balance: Option<String>,
    pub pending: Option<String>,
    pub currency: Option<String>,
}

impl WalletBalance {
    /// Parse [`main_balance`](Self::main_balance) into a float.
    ///
    /// Expects VNĐ format (`"35.016,48₫"` or `"132.500₫"`).
    /// Returns `None` if the field is absent or unparseable.
    pub fn parse_main_balance(&self) -> Option<f64> {
        crate::utils::parse_steam_balance(self.main_balance.as_deref()?)
    }

    /// Parse [`pending`](Self::pending) into a float.
    pub fn parse_pending(&self) -> Option<f64> {
        crate::utils::parse_steam_balance(self.pending.as_deref()?)
    }
}

/// A single purchase history item from the Steam account history.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PurchaseHistoryItem {
    /// The date of the purchase (e.g., "Jan 10, 2024"). Stored as a
    /// `DateTime<Utc>` at midnight UTC (Steam reports day granularity only).
    pub date: DateTime<Utc>,
    /// The type of transaction (e.g., "Purchase", "Refund", "Gift Purchase")
    #[serde(rename = "type")]
    pub transaction_type: String,
    /// List of items involved in the transaction
    pub items: Vec<String>,
    /// The total amount of the transaction
    pub total: String,
    /// The base price before taxes/discounts
    pub base_price: Option<String>,
    /// Tax applied
    pub tax: Option<String>,
    /// Shipping cost
    pub shipping: Option<String>,
    /// Amount wallet balance changed
    pub wallet_change: Option<String>,
    /// The payment method used (e.g., "Visa", "Wallet")
    pub payment_method: Option<String>,
    /// The current wallet balance after this transaction
    pub wallet_balance: Option<String>,
    /// A unique transaction ID if available
    pub transaction_id: Option<TransactionId>,
}

/// Response from redeeming a Steam wallet code.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedeemWalletCodeResponse {
    /// Whether the redemption was successful (1 = success)
    pub success: i32,
    /// Detail code for errors (0 = no error)
    pub detail: i32,
    /// The wallet balance after redemption
    #[serde(rename = "formattednewwalletbalance")]
    pub formatted_new_wallet_balance: Option<String>,
}

// ── Authorized Devices Page ────────────────────────────────────────────────

/// IPv4/v6 address for a device location entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceIp {
    pub v4: Option<u64>,
    pub v6: Option<serde_json::Value>,
}

/// Geographic + time info for a device first/last-seen event.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceLocation {
    pub time: Option<u64>,
    pub ip: Option<DeviceIp>,
    pub locale: Option<String>,
    pub country: Option<String>,
    pub state: Option<String>,
    pub city: Option<String>,
}

/// A single authorized Steam device / active session.
///
/// Returned in both `active_devices` and `revoked_devices` lists from
/// `https://store.steampowered.com/account/authorizeddevices`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthorizedDevice {
    /// Unique token identifier (u64 serialized as string by Steam).
    pub token_id: String,
    /// Human-readable description (e.g. user-agent string or device name).
    pub token_description: String,
    /// Unix timestamp of the last token update.
    pub time_updated: u64,
    /// Steam platform type (2 = browser/web, 3 = mobile, 7 = Steam client).
    pub platform_type: i32,
    /// Whether the session is currently logged in (1) or not (0).
    pub logged_in: i32,
    /// OS platform code (2 = Windows, 8 = Android, etc.).
    pub os_platform: i32,
    /// Auth type code (2 = credentials, 3 = QR, 4 = token refresh, …).
    pub auth_type: i32,
    /// Gaming device subtype (e.g. 528 = Samsung Galaxy; null for non-gaming).
    pub gaming_device_type: Option<i32>,
    /// Where and when the session was first established.
    pub first_seen: Option<DeviceLocation>,
    /// Where and when the session was last active.
    pub last_seen: Option<DeviceLocation>,
    /// Detailed OS type integer returned by Steam.
    pub os_type: i32,
    /// How authentication was performed (1 = new login, 2 = token refresh, …).
    pub authentication_type: i32,
    /// Token state: 3 = active, 99 = revoked/expired.
    pub effective_token_state: i32,
}

/// A single two-factor authenticator usage record.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TwoFactorUsage {
    pub time: u64,
    pub usage_type: i32,
    pub confirmation_type: i32,
    pub confirmation_action: i32,
}

/// Two-factor / Steam Guard status for the account.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TwoFactorStatus {
    /// Guard state: 0 = email guard active, 1 = authenticator active.
    pub state: i32,
    pub inactivation_reason: Option<serde_json::Value>,
    pub authenticator_type: Option<i32>,
    pub authenticator_allowed: Option<i32>,
    pub steamguard_scheme: Option<i32>,
    pub token_gid: Option<String>,
    pub email_validated: Option<i32>,
    pub device_identifier: Option<String>,
    pub time_created: Option<u64>,
    pub revocation_attempts_remaining: Option<i32>,
    pub classified_agent: Option<String>,
    pub allow_external_authenticator: Option<bool>,
    pub time_transferred: Option<u64>,
    pub version: Option<i32>,
    pub last_seen_auth_token_id: Option<String>,
    pub usages: Option<Vec<TwoFactorUsage>>,
    /// EResult from the underlying Steam API call.
    pub success: Option<i32>,
    /// Internal result code (`rwgrsn`).
    pub rwgrsn: Option<i32>,
}

/// Basic account / session info embedded in every Steam Store page.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageUserInfo {
    pub logged_in: bool,
    pub country_code: Option<String>,
    #[serde(default)]
    pub excluded_content_descriptors: Vec<i32>,
    pub steamid: Option<String>,
    pub accountid: Option<u64>,
    pub account_name: Option<String>,
    pub is_support: Option<bool>,
    pub is_limited: Option<bool>,
    pub is_partner_member: Option<bool>,
    pub is_valve_email: Option<bool>,
}

/// Hardware / client-type flags embedded in the page.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageHwInfo {
    #[serde(rename = "bSteamOS")]
    pub steam_os: bool,
    #[serde(rename = "bSteamDeck")]
    pub steam_deck: bool,
}

/// Store page configuration block (`data-config`).
///
/// Contains CDN base URLs, build metadata, and runtime flags.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PageConfig {
    #[serde(rename = "EUNIVERSE")]
    pub euniverse: Option<i32>,
    #[serde(rename = "WEB_UNIVERSE")]
    pub web_universe: Option<String>,
    #[serde(rename = "LANGUAGE")]
    pub language: Option<String>,
    #[serde(rename = "COUNTRY")]
    pub country: Option<String>,
    #[serde(rename = "PLATFORM")]
    pub platform: Option<String>,
    #[serde(rename = "WEBSITE_ID")]
    pub website_id: Option<String>,
    #[serde(rename = "BUILD_TIMESTAMP")]
    pub build_timestamp: Option<u64>,
    #[serde(rename = "PAGE_TIMESTAMP")]
    pub page_timestamp: Option<u64>,
    #[serde(rename = "NOW")]
    pub now: Option<u64>,
    #[serde(rename = "EREALM")]
    pub erealm: Option<i32>,
    #[serde(rename = "SNR")]
    pub snr: Option<String>,
    #[serde(rename = "IN_CLIENT")]
    pub in_client: Option<bool>,
    #[serde(rename = "IN_TENFOOT")]
    pub in_tenfoot: Option<bool>,
    #[serde(rename = "IN_GAMEPADUI")]
    pub in_gamepadui: Option<bool>,
    #[serde(rename = "IN_CHROMEOS")]
    pub in_chromeos: Option<bool>,
    #[serde(rename = "IN_MOBILE_WEBVIEW")]
    pub in_mobile_webview: Option<bool>,
    #[serde(rename = "FROM_WEB")]
    pub from_web: Option<bool>,
    #[serde(rename = "USE_POPUPS")]
    pub use_popups: Option<bool>,
    #[serde(rename = "COMMUNITY_BASE_URL")]
    pub community_base_url: Option<String>,
    #[serde(rename = "STORE_BASE_URL")]
    pub store_base_url: Option<String>,
    #[serde(rename = "STORE_CHECKOUT_BASE_URL")]
    pub store_checkout_base_url: Option<String>,
    #[serde(rename = "HELP_BASE_URL")]
    pub help_base_url: Option<String>,
    #[serde(rename = "WEBAPI_BASE_URL")]
    pub webapi_base_url: Option<String>,
    #[serde(rename = "AVATAR_BASE_URL")]
    pub avatar_base_url: Option<String>,
    #[serde(rename = "LOGIN_BASE_URL")]
    pub login_base_url: Option<String>,
    #[serde(rename = "MEDIA_CDN_URL")]
    pub media_cdn_url: Option<String>,
    #[serde(rename = "MEDIA_CDN_COMMUNITY_URL")]
    pub media_cdn_community_url: Option<String>,
    #[serde(rename = "COMMUNITY_CDN_URL")]
    pub community_cdn_url: Option<String>,
    #[serde(rename = "COMMUNITY_CDN_ASSET_URL")]
    pub community_cdn_asset_url: Option<String>,
    #[serde(rename = "STORE_CDN_URL")]
    pub store_cdn_url: Option<String>,
    #[serde(rename = "STORE_ICON_BASE_URL")]
    pub store_icon_base_url: Option<String>,
    #[serde(rename = "STORE_ITEM_BASE_URL")]
    pub store_item_base_url: Option<String>,
    #[serde(rename = "VIDEO_CDN_URL")]
    pub video_cdn_url: Option<String>,
    #[serde(rename = "TOKEN_URL")]
    pub token_url: Option<String>,
    #[serde(rename = "STEAMTV_BASE_URL")]
    pub steamtv_base_url: Option<String>,
    #[serde(rename = "PARTNER_BASE_URL")]
    pub partner_base_url: Option<String>,
    #[serde(rename = "SUPPORT_BASE_URL")]
    pub support_base_url: Option<String>,
    #[serde(rename = "CHAT_BASE_URL")]
    pub chat_base_url: Option<String>,
    #[serde(rename = "PUBLIC_SHARED_URL")]
    pub public_shared_url: Option<String>,
    #[serde(rename = "BASE_URL_SHARED_CDN")]
    pub base_url_shared_cdn: Option<String>,
    #[serde(rename = "BASE_URL_STORE_CDN_ASSETS")]
    pub base_url_store_cdn_assets: Option<String>,
    #[serde(rename = "COMMUNITY_ASSETS_BASE_URL")]
    pub community_assets_base_url: Option<String>,
    #[serde(rename = "CLAN_CDN_ASSET_URL")]
    pub clan_cdn_asset_url: Option<String>,
}

/// Store-side user config block (`data-store_user_config`).
///
/// Contains the short-lived WebAPI JWT token for the current browser session.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StoreUserConfig {
    /// Short-lived WebAPI JWT (`webapi_token`). Valid for ~2 hours.
    pub webapi_token: Option<String>,
    /// Any remaining fields returned by Steam.
    #[serde(flatten)]
    pub extra: HashMap<String, serde_json::Value>,
}

/// A single Steam notification embedded in the page.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageNotification {
    pub notification_id: String,
    /// Bitmask of targets (store / community / …).
    pub notification_targets: i32,
    /// Notification type code (3 = forum reply, etc.).
    pub notification_type: i32,
    /// Inner JSON payload; content depends on `notification_type`.
    pub body_data: String,
    /// 0 = unread, 1 = read.
    pub read: i32,
    pub timestamp: u64,
    pub hidden: i32,
    pub expiry: u64,
    pub viewed: Option<u64>,
}

/// Notification list and pending-count data from `data-steam_notifications`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PageNotifications {
    #[serde(default)]
    pub notifications: Vec<PageNotification>,
    pub pending_gift_count: Option<i32>,
    pub pending_friend_count: Option<i32>,
}

/// Broadcast status for the logged-in user (`data-broadcastuser`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BroadcastUser {
    pub success: i32,
    #[serde(rename = "bHideStoreBroadcast")]
    pub hide_store_broadcast: i32,
}

/// Full account details parsed from `https://store.steampowered.com/account/authorizeddevices`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AccountDetails {
    /// Steam account name (`data-accountName`).
    pub account_name: Option<String>,
    /// Account email address (`data-email`).
    pub email: Option<String>,
    /// Masked phone number hint, empty string if none (`data-phone_hint`).
    pub phone_hint: Option<String>,
    /// Latest Steam Android app version string
    /// (`data-latest_android_app_version`).
    pub latest_android_app_version: Option<String>,
    /// Token ID of the session currently browsing the page
    /// (`data-requesting_token_id`).
    pub requesting_token_id: Option<String>,
    /// Sessions that are currently active / not revoked.
    pub active_devices: Vec<AuthorizedDevice>,
    /// Sessions that were previously revoked.
    pub revoked_devices: Vec<AuthorizedDevice>,
    /// Steam Guard / two-factor authenticator status.
    pub two_factor_status: Option<TwoFactorStatus>,
    /// Basic account info embedded in every Store page.
    pub user_info: Option<PageUserInfo>,
    /// Hardware / platform flags.
    pub hw_info: Option<PageHwInfo>,
    /// Page-level configuration (CDN URLs, build info, etc.).
    pub page_config: Option<PageConfig>,
    /// Store user config including the short-lived WebAPI token.
    pub store_user_config: Option<StoreUserConfig>,
    /// Steam notifications for this account.
    pub notifications: Option<PageNotifications>,
    /// Broadcast status for this account.
    pub broadcast_user: Option<BroadcastUser>,
    /// Wallet balance parsed from the page header.
    pub wallet_balance: Option<WalletBalance>,
    /// SHA-1 avatar hash (e.g. `"834966fea6a0a8a3b7011db7f96d38b51ee0ba64"`).
    pub avatar_hash: Option<String>,
    /// Country code from `data-userinfo` (e.g. `"VN"`, `"US"`).
    pub country: Option<String>,
}

impl AccountDetails {
    /// Human-readable Steam Guard status derived from
    /// [`two_factor_status`](Self::two_factor_status).
    ///
    /// - `state == 1` → `"Steam Guard Mobile Authenticator"`
    /// - `state == 0, email_validated == 1` → `"Steam Guard (Email)"`
    /// - otherwise → `None`
    pub fn account_security(&self) -> Option<String> {
        let tf = self.two_factor_status.as_ref()?;
        match tf.state {
            1 => Some("Steam Guard Mobile Authenticator".to_string()),
            0 if tf.email_validated == Some(1) => Some("Steam Guard (Email)".to_string()),
            _ => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_deserialize_redeem_wallet_response() {
        let json = r#"{"success":1,"detail":0,"formattednewwalletbalance":"132.500\u20ab"}"#;
        let response: RedeemWalletCodeResponse = serde_json::from_str(json).unwrap();
        assert_eq!(response.success, 1);
        assert_eq!(response.detail, 0);
        assert_eq!(response.formatted_new_wallet_balance.as_deref(), Some("132.500₫"));
    }
}