Skip to main content

steam_friend_code/
short_steam_friend_code.rs

1//! Short Steam friend code (hex-mapping for `s.team/p/` links).
2//!
3//! These short codes are used in Steam quick invite links to encode
4//! a user's account ID using a consonant substitution cipher.
5
6/// Character set for short friend code encoding.
7/// Maps hex digits 0–15 to consonant-like characters.
8pub const SHORT_STEAM_FRIEND_CODE_CHARS: [char; 16] = ['b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 't', 'v', 'w'];
9
10/// Create a short friend code from an account ID.
11///
12/// Short friend codes are used in quick invite links (`https://s.team/p/{code}/{token}`).
13/// They encode the account ID's hex digits using a consonant substitution
14/// cipher.
15///
16/// # Example
17/// ```
18/// let code = steam_friend_code::create_short_steam_friend_code(123456789);
19/// assert!(code.contains('-'));
20/// ```
21pub fn create_short_steam_friend_code(account_id: u32) -> String {
22    let hex = format!("{:x}", account_id);
23
24    let mut friend_code = String::with_capacity(hex.len() + 1);
25
26    for c in hex.chars() {
27        let digit = c.to_digit(16).unwrap_or(0) as usize;
28        friend_code.push(SHORT_STEAM_FRIEND_CODE_CHARS[digit]);
29    }
30
31    // Insert dash in the middle
32    let dash_pos = friend_code.len() / 2;
33    if dash_pos > 0 && friend_code.len() > 1 {
34        friend_code.insert(dash_pos, '-');
35    }
36
37    friend_code
38}
39
40/// Parse a short friend code back into an account ID.
41///
42/// Returns `None` if the code contains invalid characters.
43pub fn parse_short_steam_friend_code(friend_code: &str) -> Option<u32> {
44    let code = friend_code.replace('-', "");
45    let mut hex = String::with_capacity(code.len());
46
47    for c in code.chars() {
48        let pos = SHORT_STEAM_FRIEND_CODE_CHARS.iter().position(|&x| x == c)?;
49        hex.push(char::from_digit(pos as u32, 16).unwrap());
50    }
51
52    u32::from_str_radix(&hex, 16).ok()
53}
54
55/// Parse a quick invite link URL into (friend_code, token).
56///
57/// Supported formats:
58/// - `https://s.team/p/{friend_code}/{token}`
59/// - `https://steamcommunity.com/user/{friend_code}/{token}`
60/// - `{friend_code}/{token}` (just the path)
61///
62/// Returns `None` if the link format is invalid.
63pub fn parse_quick_invite_link(link: &str) -> Option<(String, String)> {
64    let path = link.trim().trim_start_matches("https://").trim_start_matches("http://").trim_start_matches("s.team/p/").trim_start_matches("steamcommunity.com/user/");
65
66    let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
67
68    if parts.len() < 2 {
69        return None;
70    }
71
72    Some((parts[0].to_string(), parts[1].to_string()))
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn test_create_short_steam_friend_code() {
81        let code = create_short_steam_friend_code(123456789);
82        for c in code.chars() {
83            assert!(SHORT_STEAM_FRIEND_CODE_CHARS.contains(&c) || c == '-');
84        }
85        assert_eq!(code.matches('-').count(), 1);
86    }
87
88    #[test]
89    fn test_parse_short_steam_friend_code_roundtrip() {
90        let ids = [123456789u32, 1, 999999, 2147483647];
91        for id in ids {
92            let code = create_short_steam_friend_code(id);
93            let parsed = parse_short_steam_friend_code(&code);
94            assert_eq!(parsed, Some(id), "Roundtrip failed for id {} code {}", id, code);
95        }
96    }
97
98    #[test]
99    fn test_parse_quick_invite_link() {
100        let (code, token) = parse_quick_invite_link("https://s.team/p/bcdf-ghjk/ABCD1234").unwrap();
101        assert_eq!(code, "bcdf-ghjk");
102        assert_eq!(token, "ABCD1234");
103
104        let (code, token) = parse_quick_invite_link("bcdf-ghjk/TOKEN").unwrap();
105        assert_eq!(code, "bcdf-ghjk");
106        assert_eq!(token, "TOKEN");
107    }
108
109    #[test]
110    fn test_parse_quick_invite_link_invalid() {
111        assert!(parse_quick_invite_link("https://s.team/p/bcdf-ghjk").is_none());
112        assert!(parse_quick_invite_link("").is_none());
113    }
114}