Skip to content

Commit 4ea6771

Browse files
committed
Fix profile save verification and Steam Guard UX
1 parent e6d12ef commit 4ea6771

7 files changed

Lines changed: 303 additions & 27 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ print(client.get_trade_offer_url())
6464
print(client.get_group_membership_state("Valve"))
6565
```
6666

67+
If Steam Guard is required, the credential login flow prompts automatically in an interactive terminal. You can still pass `steam_guard_code="12345"` explicitly for non-interactive runs.
68+
6769
## Verified Workflow Examples
6870

6971
### Save and restore a logged-in session

docs/authentication.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ client.login_to_community("YOUR_USERNAME", "YOUR_PASSWORD")
6060
print(client.get_account_info())
6161
```
6262

63-
If Steam Guard is required, pass a code:
63+
If Steam Guard is required and your script is running in an interactive terminal, `login_to_community(...)` prompts automatically.
64+
65+
If you already have the code, you can pass it directly:
6466

6567
```python
6668
client.login_to_community(
@@ -70,13 +72,13 @@ client.login_to_community(
7072
)
7173
```
7274

73-
Or provide a callback:
75+
Advanced callers can still provide their own code provider:
7476

7577
```python
7678
client.login_to_community(
7779
"YOUR_USERNAME",
7880
"YOUR_PASSWORD",
79-
steam_guard_code_provider=lambda prompt: input("Steam Guard code: "),
81+
steam_guard_code_provider=lambda confirmation: get_code_somehow(confirmation),
8082
)
8183
```
8284

@@ -145,4 +147,3 @@ This is useful when you need to hit a supported Steam Community page that does n
145147

146148
- `SteamResponseError`
147149
- Steam returned malformed or unexpected content
148-

docs/community.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ client.update_summary("Profile summary text")
3838
client.update_location(country="US", state="IN", city="123")
3939
```
4040

41+
The single-field helpers only submit the field you asked to change. Steam sometimes replies with warning-style `success: 2` payloads for profile saves, so SteamCommunityKit verifies the requested field after the write before treating the call as successful.
42+
4143
## Privacy
4244

4345
Read privacy:
@@ -105,4 +107,3 @@ Useful fields include:
105107
- `registration_form_visible`
106108
- `revoke_available`
107109
- `reason`
108-

src/steamcommunitykit/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1957,7 +1957,7 @@ def login_to_community(
19571957
persistence: bool = True,
19581958
steam_guard_code: Optional[str] = None,
19591959
steam_guard_code_provider: Optional[Callable[[dict], str]] = None,
1960-
prompt_for_steam_guard: bool = False,
1960+
prompt_for_steam_guard: Optional[bool] = None,
19611961
poll_interval: float = 1.5,
19621962
poll_timeout: float = 60.0,
19631963
) -> CredentialLoginResult:

src/steamcommunitykit/services/auth.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import base64
44
import json
5+
import sys
56
import time
67
import urllib.parse
78
from collections.abc import Mapping
@@ -176,7 +177,7 @@ def login_with_credentials(
176177
persistence: bool = True,
177178
steam_guard_code: Optional[str] = None,
178179
steam_guard_code_provider: Optional[Callable[[dict], str]] = None,
179-
prompt_for_steam_guard: bool = False,
180+
prompt_for_steam_guard: Optional[bool] = None,
180181
poll_interval: float = 1.5,
181182
poll_timeout: float = 60.0,
182183
) -> CredentialLoginResult:
@@ -200,8 +201,8 @@ def login_with_credentials(
200201
if confirmation is not None:
201202
code = steam_guard_code
202203
if code is None and steam_guard_code_provider is not None:
203-
code = steam_guard_code_provider(started)
204-
if code is None and prompt_for_steam_guard:
204+
code = steam_guard_code_provider(confirmation)
205+
if code is None and self._should_prompt_for_steam_guard(prompt_for_steam_guard):
205206
code = self.prompt_for_steam_guard_code(confirmation)
206207
if code is None:
207208
raise SteamAuthenticationError(
@@ -296,6 +297,25 @@ def _format_steam_guard_required_message(confirmation: dict) -> str:
296297
return "A Steam Guard email code is required for this login."
297298
return "A Steam Guard confirmation is required for this login."
298299

300+
@staticmethod
301+
def _terminal_can_prompt() -> bool:
302+
stdin = getattr(sys, "stdin", None)
303+
stdout = getattr(sys, "stdout", None)
304+
return bool(
305+
stdin is not None
306+
and stdout is not None
307+
and hasattr(stdin, "isatty")
308+
and hasattr(stdout, "isatty")
309+
and stdin.isatty()
310+
and stdout.isatty()
311+
)
312+
313+
@classmethod
314+
def _should_prompt_for_steam_guard(cls, prompt_for_steam_guard: Optional[bool]) -> bool:
315+
if prompt_for_steam_guard is not None:
316+
return bool(prompt_for_steam_guard)
317+
return cls._terminal_can_prompt()
318+
299319
@staticmethod
300320
def prompt_for_steam_guard_code(confirmation: dict) -> str:
301321
confirmation_type = int(confirmation.get("confirmation_type", 0))

src/steamcommunitykit/services/community.py

Lines changed: 143 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,134 @@ def _extract_json_data_attribute(html_text: str, attribute_name: str) -> dict:
3939
) from exc
4040

4141
@staticmethod
42-
def _ensure_ajax_success(response: dict, action: str) -> dict:
42+
def _normalize_ajax_error_message(message: object) -> str:
43+
return html.unescape(
44+
str(message or "")
45+
.replace("<br />", "\n")
46+
.replace("<br \\/>", "\n")
47+
.replace("<br/>", "\n")
48+
).strip()
49+
50+
@classmethod
51+
def _ensure_ajax_success(cls, response: dict, action: str) -> dict:
4352
success = response.get("success")
44-
error_message = html.unescape(str(response.get("errmsg", "")).replace("<br />", " ").strip())
53+
error_message = cls._normalize_ajax_error_message(response.get("errmsg"))
4554
if success == 1:
4655
return response
4756
if error_message:
48-
raise SteamResponseError("{0} failed: {1}".format(action, error_message))
57+
raise SteamResponseError(
58+
"{0} failed: {1}".format(action, " ".join(error_message.splitlines()))
59+
)
4960
raise SteamResponseError("{0} failed.".format(action))
5061

62+
@staticmethod
63+
def _profile_edit_state_value(profile_edit_state: dict, field_name: str):
64+
if field_name == "personaName":
65+
return profile_edit_state.get("strPersonaName", "")
66+
if field_name == "real_name":
67+
return profile_edit_state.get("strRealName", "")
68+
if field_name == "summary":
69+
return profile_edit_state.get("strSummary", "")
70+
if field_name == "customURL":
71+
return profile_edit_state.get("strCustomURL", "")
72+
if field_name == "country":
73+
return (profile_edit_state.get("LocationData") or {}).get("locCountryCode", "")
74+
if field_name == "state":
75+
return (profile_edit_state.get("LocationData") or {}).get("locStateCode", "")
76+
if field_name == "city":
77+
return (profile_edit_state.get("LocationData") or {}).get("locCityCode", "")
78+
if field_name == "hide_profile_awards":
79+
return int(bool((profile_edit_state.get("ProfilePreferences") or {}).get("hide_profile_awards", 0)))
80+
return None
81+
82+
@staticmethod
83+
def _normalize_profile_edit_value(field_name: str, value):
84+
if field_name == "hide_profile_awards":
85+
return int(bool(value))
86+
if value is None:
87+
return ""
88+
return str(value)
89+
90+
def _verify_profile_edit_updates(
91+
self,
92+
steam_id: str,
93+
requested_updates: Dict[str, object],
94+
) -> dict:
95+
profile_edit_state = self.get_profile_edit_state(steam_id)
96+
mismatches = {}
97+
for field_name, expected_value in requested_updates.items():
98+
actual_value = self._profile_edit_state_value(profile_edit_state, field_name)
99+
if self._normalize_profile_edit_value(
100+
field_name,
101+
actual_value,
102+
) != self._normalize_profile_edit_value(field_name, expected_value):
103+
mismatches[field_name] = {
104+
"expected": expected_value,
105+
"actual": actual_value,
106+
}
107+
return {
108+
"profile_edit_state": profile_edit_state,
109+
"mismatches": mismatches,
110+
}
111+
112+
def _finalize_profile_edit_response(
113+
self,
114+
steam_id: str,
115+
response: dict,
116+
requested_updates: Dict[str, object],
117+
) -> dict:
118+
success = response.get("success")
119+
if success == 1:
120+
return response
121+
error_message = self._normalize_ajax_error_message(response.get("errmsg"))
122+
if success != 2:
123+
if error_message:
124+
raise SteamResponseError(
125+
"Profile update failed: {0}".format(" ".join(error_message.splitlines()))
126+
)
127+
raise SteamResponseError("Profile update failed.")
128+
129+
try:
130+
verification = self._verify_profile_edit_updates(steam_id, requested_updates)
131+
except Exception as exc:
132+
if error_message:
133+
raise SteamResponseError(
134+
"Profile update returned an ambiguous Steam response: {0}".format(
135+
" ".join(error_message.splitlines())
136+
)
137+
) from exc
138+
raise SteamResponseError("Profile update returned an ambiguous Steam response.") from exc
139+
140+
if verification["mismatches"]:
141+
mismatch_details = ", ".join(
142+
"{0} expected {1!r} but Steam now reports {2!r}".format(
143+
field_name,
144+
details["expected"],
145+
details["actual"],
146+
)
147+
for field_name, details in verification["mismatches"].items()
148+
)
149+
if error_message:
150+
raise SteamResponseError(
151+
"Profile update failed: {0} ({1})".format(
152+
" ".join(error_message.splitlines()),
153+
mismatch_details,
154+
)
155+
)
156+
raise SteamResponseError(
157+
"Profile update failed: {0}".format(mismatch_details)
158+
)
159+
160+
finalized = dict(response)
161+
finalized["verified"] = True
162+
finalized["verified_fields"] = sorted(requested_updates.keys())
163+
finalized["warnings"] = [
164+
line.strip()
165+
for line in error_message.splitlines()
166+
if line.strip()
167+
]
168+
return finalized
169+
51170
def _resolved_steam_id(self, steam_id=None) -> str:
52171
if steam_id is None:
53172
return self.transport.require_community_credentials().steam_id
@@ -322,41 +441,44 @@ def edit_profile(
322441
country: Optional[str] = None,
323442
state: Optional[str] = None,
324443
city: Optional[Union[int, str]] = None,
325-
hide_profile_awards: bool = False,
444+
hide_profile_awards: Optional[bool] = None,
326445
) -> dict:
327446
credentials = self.transport.require_community_credentials()
328447
normalized_steam_id = self._resolved_steam_id(steam_id)
329448
data = {
330449
"sessionID": credentials.session_id,
331450
"type": "profileSave",
332-
"hide_profile_awards": int(hide_profile_awards),
333451
"json": 1,
334452
}
453+
requested_updates = {}
335454
if persona_name is not None:
336-
data["personaName"] = ensure_not_blank(persona_name, "persona_name")
455+
normalized_persona_name = ensure_not_blank(persona_name, "persona_name")
456+
data["personaName"] = normalized_persona_name
457+
requested_updates["personaName"] = normalized_persona_name
337458
if real_name is not None:
338459
data["real_name"] = real_name
460+
requested_updates["real_name"] = real_name
339461
if summary is not None:
340462
data["summary"] = summary
463+
requested_updates["summary"] = summary
341464
if custom_url is not None:
342465
data["customURL"] = custom_url
466+
requested_updates["customURL"] = custom_url
343467
if country is not None:
344468
data["country"] = country
469+
requested_updates["country"] = country
345470
if state is not None:
346471
data["state"] = state
472+
requested_updates["state"] = state
347473
if city is not None:
348474
data["city"] = str(city)
475+
requested_updates["city"] = str(city)
476+
if hide_profile_awards is not None:
477+
normalized_hide_profile_awards = int(bool(hide_profile_awards))
478+
data["hide_profile_awards"] = normalized_hide_profile_awards
479+
requested_updates["hide_profile_awards"] = normalized_hide_profile_awards
349480

350-
editable_fields = {
351-
"personaName",
352-
"real_name",
353-
"summary",
354-
"customURL",
355-
"country",
356-
"state",
357-
"city",
358-
}
359-
if not any(field in data for field in editable_fields):
481+
if not requested_updates:
360482
raise SteamValidationError("edit_profile requires at least one editable field.")
361483

362484
response = self.transport.request(
@@ -366,7 +488,11 @@ def edit_profile(
366488
headers=self._headers(f"{COMMUNITY_BASE_URL}/profiles/{normalized_steam_id}/edit/"),
367489
cookies=self._community_cookies(),
368490
)
369-
return self._ensure_ajax_success(response, "Profile update")
491+
return self._finalize_profile_edit_response(
492+
normalized_steam_id,
493+
response,
494+
requested_updates,
495+
)
370496

371497
def upload_avatar(self, image_path: Union[str, Path], steam_id=None) -> dict:
372498
credentials = self.transport.require_community_credentials()

0 commit comments

Comments
 (0)