use std::{future::Future, sync::OnceLock, time::Duration};
use regex::Regex;
use scraper::{Html, Selector};
static SEL_ACCOUNT_BLOCK: OnceLock<Selector> = OnceLock::new();
fn sel_account_block() -> &'static Selector {
SEL_ACCOUNT_BLOCK.get_or_init(|| Selector::parse(".account_setting_block").expect("valid CSS selector"))
}
static SEL_ACCOUNT_LABEL: OnceLock<Selector> = OnceLock::new();
fn sel_account_label() -> &'static Selector {
SEL_ACCOUNT_LABEL.get_or_init(|| Selector::parse(".account_manage_label").expect("valid CSS selector"))
}
static SEL_ACCOUNT_FIELD: OnceLock<Selector> = OnceLock::new();
fn sel_account_field() -> &'static Selector {
SEL_ACCOUNT_FIELD.get_or_init(|| Selector::parse(".account_data_field").expect("valid CSS selector"))
}
static SEL_CLIENT_CONN_MACHINE: OnceLock<Selector> = OnceLock::new();
fn sel_client_conn_machine() -> &'static Selector {
SEL_CLIENT_CONN_MACHINE.get_or_init(|| Selector::parse(".clientConnMachineText").expect("valid CSS selector"))
}
static SEL_HELP_WIZARD_BUTTON: OnceLock<Selector> = OnceLock::new();
fn sel_help_wizard_button() -> &'static Selector {
SEL_HELP_WIZARD_BUTTON.get_or_init(|| Selector::parse("a.help_wizard_button").expect("valid CSS selector"))
}
static SEL_FORGOT_LOGIN_FORM: OnceLock<Selector> = OnceLock::new();
fn sel_forgot_login_form() -> &'static Selector {
SEL_FORGOT_LOGIN_FORM.get_or_init(|| Selector::parse("#forgot_login_code_form").expect("valid CSS selector"))
}
static RE_SESSION_ID: OnceLock<Regex> = OnceLock::new();
fn re_session_id() -> &'static Regex {
RE_SESSION_ID.get_or_init(|| Regex::new(r#"var g_sessionID = "([^"]+)";"#).expect("valid regex"))
}
static RE_WIZARD_PARAMS: OnceLock<Regex> = OnceLock::new();
fn re_wizard_params() -> &'static Regex {
RE_WIZARD_PARAMS.get_or_init(|| Regex::new(r"g_rgDefaultWizardPageParams = (\{.*?\});").expect("valid regex"))
}
use crate::{
client::SteamUser,
endpoint::{steam_endpoint, Host},
error::SteamUserError,
types::{AccountRecoveryStatus, ChangeEmailResult, ConfirmEmailResponse, SendRecoveryCodeResponse, SubmitEmailResponse, WizardDefaultParams, WizardIssue, WizardPageParams},
};
impl SteamUser {
#[steam_endpoint(GET, host = Store, path = "/account/", kind = Read)]
pub async fn get_account_email(&self) -> Result<String, SteamUserError> {
let response = self.get_path("/account/").send().await?.text().await?;
let document = Html::parse_document(&response);
for block in document.select(sel_account_block()) {
if let Some(label) = block.select(sel_account_label()).next() {
let label_text = label.text().collect::<String>();
if label_text.trim() == "Email address:" {
if let Some(field) = block.select(sel_account_field()).next() {
return Ok(field.text().collect::<String>().trim().to_string());
}
}
}
}
Ok(String::new())
}
#[steam_endpoint(GET, host = Community, path = "/my/games/", kind = Read)]
pub async fn get_current_steam_login(&self) -> Result<String, SteamUserError> {
let response = self.get_path("/my/games/?tab=all").send().await?.text().await?;
let document = Html::parse_document(&response);
if let Some(el) = document.select(sel_client_conn_machine()).next() {
let text = el.text().collect::<String>();
if let Some(pos) = text.rfind('|') {
return Ok(text[..pos].trim().to_string());
}
return Ok(text.trim().to_string());
}
Ok(String::new())
}
#[tracing::instrument(skip(self, identity_secret, get_email_otp, new_email))]
pub async fn change_email<F, Fut>(&self, new_email: &str, identity_secret: &str, get_email_otp: F) -> Result<ChangeEmailResult, SteamUserError>
where
F: Fn() -> Fut,
Fut: Future<Output = Option<Vec<String>>>,
{
let account = self.get_miniprofile_id();
let help_link = match self.get_email_help_link().await? {
Some(link) => link,
None => return Ok(ChangeEmailResult::Error("Can't get help link".into())),
};
let wizard_params = match self.send_email_app_confirmation(&help_link).await? {
Some(params) => params,
None => return Ok(ChangeEmailResult::Error("Can't send app confirmation".into())),
};
let issue = &wizard_params.issue;
let default_params = &wizard_params.default_params;
let enter_code_path = format!(
"/en/wizard/HelpWithLoginInfoEnterCode?s={}&account={}&reset={}&lost={}&issueid={}&wizard_ajax=1&gamepad=0",
urlencoding::encode(&issue.s),
account,
urlencoding::encode(&issue.reset),
urlencoding::encode(&issue.lost),
urlencoding::encode(&issue.issueid),
);
let _ = self.get_path_on(Host::Help, &enter_code_path).send().await;
if !self.send_email_recovery_code(issue, default_params, &help_link).await? {
return Ok(ChangeEmailResult::Error("Can't send app recovery code".into()));
}
tokio::time::sleep(Duration::from_millis(1000)).await;
let confirmations = self.get_confirmations(identity_secret, None).await;
let confirmations = match confirmations {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "change_email: first get_confirmations failed; retrying after 2s");
tokio::time::sleep(Duration::from_millis(2000)).await;
self.get_confirmations(identity_secret, None).await?
}
};
if confirmations.is_empty() {
return Ok(ChangeEmailResult::Error("Can't get app recovery code".into()));
}
for confirmation in &confirmations {
let creator_id = confirmation.creator.parse::<u64>().map_err(|_| SteamUserError::InvalidInput(format!("Invalid confirmation creator ID: {:?}", confirmation.creator)))?;
self.accept_confirmation_for_object(identity_secret, creator_id).await?;
}
let mut checking_ok = AccountRecoveryStatus { r#continue: true, success: false, error: None };
for _ in 0..10 {
checking_ok = self.poll_account_recovery_confirmation(issue, default_params, &help_link).await?;
if checking_ok.r#continue {
tokio::time::sleep(Duration::from_millis(5000)).await;
} else {
break;
}
}
if !checking_ok.success {
return Ok(ChangeEmailResult::Error("Can't confirm app recovery code".into()));
}
let reset_path = format!(
"/en/wizard/HelpWithLoginInfoReset/?s={}&account={}&reset={}&issueid={}",
urlencoding::encode(&issue.s),
account,
urlencoding::encode(&issue.reset),
urlencoding::encode(&issue.issueid),
);
let _ = self.get_path_on(Host::Help, &reset_path).send().await;
let submit_result = self.submit_new_email(issue, default_params, account, new_email).await?;
if !submit_result.error_msg.is_empty() {
return Ok(ChangeEmailResult::Error(format!("submitNewEmail Failed: {}", submit_result.error_msg)));
}
for _ in 0..5 {
if let Some(codes) = get_email_otp().await {
for code in codes {
let confirm_result = self.confirm_new_email(issue, default_params, account, new_email, &code).await?;
if confirm_result.hash.contains("HelpWithLoginInfoComplete") {
return Ok(ChangeEmailResult::Success);
}
tokio::time::sleep(Duration::from_millis(1000)).await;
}
} else {
tokio::time::sleep(Duration::from_millis(5000)).await;
}
}
Ok(ChangeEmailResult::Error("Can't confirm new email code".into()))
}
#[steam_endpoint(GET, host = Help, path = "/en/wizard/HelpChangeEmail", kind = Recovery)]
async fn get_email_help_link(&self) -> Result<Option<String>, SteamUserError> {
let response = self.get_path("/en/wizard/HelpChangeEmail?redir=store/account/").send().await?.text().await?;
let document = Html::parse_document(&response);
for button in document.select(sel_help_wizard_button()) {
let text = button.text().collect::<String>();
if text.trim() == "Send a confirmation to my Steam Mobile app" {
return Ok(button.value().attr("href").map(|s| s.to_string()));
}
}
Ok(None)
}
#[tracing::instrument(skip(self, help_link))]
async fn send_email_app_confirmation(&self, help_link: &str) -> Result<Option<WizardPageParams>, SteamUserError> {
let help_path = help_link.strip_prefix("https://help.steampowered.com").or_else(|| help_link.strip_prefix("http://help.steampowered.com")).unwrap_or(help_link);
let response = self.get_path_on(Host::Help, help_path).send().await?.text().await?;
if !response.contains("For security, verify that the code in the box below matches the code we display on the confirmations page.") {
return Ok(None);
}
Ok(Self::parse_wizard_page_params(&response))
}
fn parse_wizard_page_params(html: &str) -> Option<WizardPageParams> {
let document = Html::parse_document(html);
let form = document.select(sel_forgot_login_form()).next()?;
let get_input_value = |name: &str| -> String {
let selector = Selector::parse(&format!("input[name=\"{}\"]", name)).expect("valid CSS selector");
form.select(&selector).next().and_then(|el| el.value().attr("value")).unwrap_or("").to_string()
};
let issue = WizardIssue {
s: get_input_value("s"),
reset: get_input_value("reset"),
lost: get_input_value("lost"),
method: get_input_value("method"),
issueid: get_input_value("issueid"),
};
let session_id = re_session_id().captures(html).map(|c| c[1].to_string()).unwrap_or_default();
let default_params = re_wizard_params().captures(html).and_then(|c| serde_json::from_str::<WizardDefaultParams>(&c[1]).ok()).unwrap_or_default();
Some(WizardPageParams { session_id, issue, default_params })
}
#[steam_endpoint(POST, host = Help, path = "/en/wizard/AjaxSendAccountRecoveryCode", kind = Recovery)]
async fn send_email_recovery_code(&self, issue: &WizardIssue, default_params: &WizardDefaultParams, help_link: &str) -> Result<bool, SteamUserError> {
let params = Self::merge_params(default_params, &[("s", &issue.s), ("method", &issue.method), ("link", "")]);
let response: SendRecoveryCodeResponse = self.post_path("/en/wizard/AjaxSendAccountRecoveryCode").header("content-type", "application/x-www-form-urlencoded").header("x-requested-with", "XMLHttpRequest").header("referer", help_link).form(¶ms).send().await?.json().await?;
Ok(response.success)
}
#[steam_endpoint(POST, host = Help, path = "/en/wizard/AjaxPollAccountRecoveryConfirmation", kind = Recovery)]
async fn poll_account_recovery_confirmation(&self, issue: &WizardIssue, default_params: &WizardDefaultParams, help_link: &str) -> Result<AccountRecoveryStatus, SteamUserError> {
let params = Self::merge_params(default_params, &[("s", &issue.s), ("reset", &issue.reset), ("lost", &issue.lost), ("method", &issue.method), ("issueid", &issue.issueid)]);
let response: AccountRecoveryStatus = self.post_path("/en/wizard/AjaxPollAccountRecoveryConfirmation").header("content-type", "application/x-www-form-urlencoded").header("x-requested-with", "XMLHttpRequest").header("referer", help_link).form(¶ms).send().await?.json().await?;
Ok(response)
}
#[steam_endpoint(POST, host = Help, path = "/en/wizard/AjaxAccountRecoveryChangeEmail/", kind = Recovery)]
async fn submit_new_email(&self, issue: &WizardIssue, default_params: &WizardDefaultParams, account: u32, new_email: &str) -> Result<SubmitEmailResponse, SteamUserError> {
let referer = format!(
"https://help.steampowered.com/en/wizard/HelpWithLoginInfoReset/?s={}&account={}&reset={}&issueid={}",
urlencoding::encode(&issue.s),
account,
urlencoding::encode(&issue.reset),
urlencoding::encode(&issue.issueid),
);
let account_str = account.to_string();
let params = Self::merge_params(default_params, &[("s", issue.s.as_str()), ("account", &account_str), ("email", new_email)]);
let response: SubmitEmailResponse = self.post_path("/en/wizard/AjaxAccountRecoveryChangeEmail/").header("content-type", "application/x-www-form-urlencoded").header("x-requested-with", "XMLHttpRequest").header("referer", &referer).form(¶ms).send().await?.json().await?;
Ok(response)
}
#[steam_endpoint(POST, host = Help, path = "/en/wizard/AjaxAccountRecoveryConfirmChangeEmail/", kind = Recovery)]
async fn confirm_new_email(&self, issue: &WizardIssue, default_params: &WizardDefaultParams, account: u32, new_email: &str, code: &str) -> Result<ConfirmEmailResponse, SteamUserError> {
let referer = format!(
"https://help.steampowered.com/en/wizard/HelpWithLoginInfoReset/?s={}&account={}&reset={}&issueid={}",
urlencoding::encode(&issue.s),
account,
urlencoding::encode(&issue.reset),
urlencoding::encode(&issue.issueid),
);
let account_str = account.to_string();
let params = Self::merge_params(default_params, &[("s", issue.s.as_str()), ("account", &account_str), ("email", new_email), ("email_change_code", code)]);
let response: ConfirmEmailResponse = self.post_path("/en/wizard/AjaxAccountRecoveryConfirmChangeEmail/").header("content-type", "application/x-www-form-urlencoded").header("x-requested-with", "XMLHttpRequest").header("referer", &referer).form(¶ms).send().await?.json().await?;
Ok(response)
}
fn merge_params(default_params: &WizardDefaultParams, specific_params: &[(&str, &str)]) -> std::collections::HashMap<String, String> {
let mut map = default_params.extra.clone();
if let Some(acc) = default_params.account {
map.insert("account".to_string(), acc.to_string());
}
if let Some(wiz) = &default_params.wizard {
map.insert("wizard".to_string(), wiz.clone());
}
for (k, v) in specific_params {
map.insert(k.to_string(), v.to_string());
}
map
}
fn get_miniprofile_id(&self) -> u32 {
self.steam_id().map(|id| id.account_id).unwrap_or(0)
}
}