texas-grocery-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- texas_grocery_mcp/__init__.py +3 -0
- texas_grocery_mcp/auth/__init__.py +5 -0
- texas_grocery_mcp/auth/browser_refresh.py +1629 -0
- texas_grocery_mcp/auth/credentials.py +337 -0
- texas_grocery_mcp/auth/session.py +767 -0
- texas_grocery_mcp/clients/__init__.py +5 -0
- texas_grocery_mcp/clients/graphql.py +2400 -0
- texas_grocery_mcp/models/__init__.py +54 -0
- texas_grocery_mcp/models/cart.py +60 -0
- texas_grocery_mcp/models/coupon.py +44 -0
- texas_grocery_mcp/models/errors.py +43 -0
- texas_grocery_mcp/models/health.py +41 -0
- texas_grocery_mcp/models/product.py +274 -0
- texas_grocery_mcp/models/store.py +77 -0
- texas_grocery_mcp/observability/__init__.py +6 -0
- texas_grocery_mcp/observability/health.py +141 -0
- texas_grocery_mcp/observability/logging.py +73 -0
- texas_grocery_mcp/reliability/__init__.py +23 -0
- texas_grocery_mcp/reliability/cache.py +116 -0
- texas_grocery_mcp/reliability/circuit_breaker.py +138 -0
- texas_grocery_mcp/reliability/retry.py +96 -0
- texas_grocery_mcp/reliability/throttle.py +113 -0
- texas_grocery_mcp/server.py +211 -0
- texas_grocery_mcp/services/__init__.py +5 -0
- texas_grocery_mcp/services/geocoding.py +227 -0
- texas_grocery_mcp/state.py +166 -0
- texas_grocery_mcp/tools/__init__.py +5 -0
- texas_grocery_mcp/tools/cart.py +821 -0
- texas_grocery_mcp/tools/coupon.py +381 -0
- texas_grocery_mcp/tools/product.py +437 -0
- texas_grocery_mcp/tools/session.py +486 -0
- texas_grocery_mcp/tools/store.py +353 -0
- texas_grocery_mcp/utils/__init__.py +5 -0
- texas_grocery_mcp/utils/config.py +146 -0
- texas_grocery_mcp/utils/secure_file.py +123 -0
- texas_grocery_mcp-0.1.0.dist-info/METADATA +296 -0
- texas_grocery_mcp-0.1.0.dist-info/RECORD +40 -0
- texas_grocery_mcp-0.1.0.dist-info/WHEEL +4 -0
- texas_grocery_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- texas_grocery_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
"""Session management for HEB authentication.
|
|
2
|
+
|
|
3
|
+
Uses Playwright MCP's storage state for authentication.
|
|
4
|
+
Provides cookie conversion for httpx-based API requests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from contextlib import suppress
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from functools import wraps
|
|
13
|
+
from typing import Any, ParamSpec, TypedDict
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
from texas_grocery_mcp.utils.config import get_settings
|
|
18
|
+
|
|
19
|
+
logger = structlog.get_logger()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Track last auto-refresh to prevent rapid retries
|
|
23
|
+
_last_auto_refresh_attempt: float = 0
|
|
24
|
+
_auto_refresh_min_interval: float = 60.0 # Don't retry more than once per minute
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SessionStatus(TypedDict):
|
|
28
|
+
"""Session status with lifecycle information."""
|
|
29
|
+
|
|
30
|
+
authenticated: bool
|
|
31
|
+
needs_refresh: bool
|
|
32
|
+
refresh_recommended: bool
|
|
33
|
+
time_remaining_hours: float | None
|
|
34
|
+
expires_at: str | None # ISO format
|
|
35
|
+
reese84_present: bool
|
|
36
|
+
message: str
|
|
37
|
+
|
|
38
|
+
# Module state for testing
|
|
39
|
+
_is_authenticated: bool = False
|
|
40
|
+
|
|
41
|
+
# Key cookies required for authenticated requests
|
|
42
|
+
REQUIRED_COOKIES = ["sat", "sst", "JSESSIONID"]
|
|
43
|
+
# Cookies that indicate an active session
|
|
44
|
+
SESSION_INDICATOR_COOKIES = ["sat", "DYN_USER_ID"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _reset_auth_state() -> None:
|
|
48
|
+
"""Reset authentication state. For testing only."""
|
|
49
|
+
global _is_authenticated
|
|
50
|
+
_is_authenticated = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_cookie_expired(cookie: dict[str, Any]) -> bool:
|
|
54
|
+
"""Check if a cookie has expired.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
cookie: Playwright-format cookie dict with 'expires' field
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if cookie is expired, False otherwise
|
|
61
|
+
"""
|
|
62
|
+
expires_raw = cookie.get("expires", -1)
|
|
63
|
+
# -1 means session cookie (no expiry)
|
|
64
|
+
if expires_raw == -1:
|
|
65
|
+
return False
|
|
66
|
+
try:
|
|
67
|
+
expires = float(expires_raw)
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
# If we can't parse expiration, treat cookie as expired.
|
|
70
|
+
return True
|
|
71
|
+
# Check if expired (expires is Unix timestamp)
|
|
72
|
+
return time.time() > expires
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_reese84_valid(state: dict[str, Any]) -> bool:
|
|
76
|
+
"""Check if reese84 bot detection token is present and not expired.
|
|
77
|
+
|
|
78
|
+
The reese84 token is stored in localStorage and contains a renewTime
|
|
79
|
+
that indicates when the token expires. HEB's WAF rejects requests
|
|
80
|
+
with expired tokens.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
state: Playwright storage state dict with 'origins' containing localStorage
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
True if reese84 token exists and is not expired, False otherwise
|
|
87
|
+
"""
|
|
88
|
+
# Extract localStorage from origins
|
|
89
|
+
origins = state.get("origins", [])
|
|
90
|
+
if not origins:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
local_storage = origins[0].get("localStorage", [])
|
|
94
|
+
|
|
95
|
+
# Find reese84 token
|
|
96
|
+
reese84_data: dict[str, Any] | None = None
|
|
97
|
+
for item in local_storage:
|
|
98
|
+
if item.get("name") == "reese84":
|
|
99
|
+
with suppress(json.JSONDecodeError):
|
|
100
|
+
reese84_data = json.loads(item.get("value", "{}"))
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
if not reese84_data:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
# Check expiration via renewTime (absolute timestamp in milliseconds)
|
|
107
|
+
renew_time_ms = reese84_data.get("renewTime")
|
|
108
|
+
if renew_time_ms:
|
|
109
|
+
try:
|
|
110
|
+
expires_at = datetime.fromtimestamp(renew_time_ms / 1000, tz=UTC)
|
|
111
|
+
now = datetime.now(UTC)
|
|
112
|
+
if now >= expires_at:
|
|
113
|
+
return False # Token expired
|
|
114
|
+
except (ValueError, OSError):
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
# Also check renewInSec + serverTimestamp as fallback
|
|
118
|
+
renew_in_sec = reese84_data.get("renewInSec")
|
|
119
|
+
server_timestamp = reese84_data.get("serverTimestamp")
|
|
120
|
+
if not renew_time_ms and renew_in_sec and server_timestamp:
|
|
121
|
+
try:
|
|
122
|
+
server_ts = server_timestamp / 1000 if server_timestamp > 1e12 else server_timestamp
|
|
123
|
+
expires_at = datetime.fromtimestamp(server_ts + renew_in_sec, tz=UTC)
|
|
124
|
+
now = datetime.now(UTC)
|
|
125
|
+
if now >= expires_at:
|
|
126
|
+
return False # Token expired
|
|
127
|
+
except (ValueError, OSError):
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def is_authenticated() -> bool:
|
|
134
|
+
"""Check if user is authenticated with valid, non-expired session.
|
|
135
|
+
|
|
136
|
+
Checks for:
|
|
137
|
+
1. Valid auth state file from Playwright MCP
|
|
138
|
+
2. Key session cookies present and not expired
|
|
139
|
+
3. reese84 bot detection token present and not expired
|
|
140
|
+
|
|
141
|
+
All three conditions must be met for authenticated operations to succeed.
|
|
142
|
+
"""
|
|
143
|
+
global _is_authenticated
|
|
144
|
+
|
|
145
|
+
# Check override for testing
|
|
146
|
+
if _is_authenticated:
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
settings = get_settings()
|
|
150
|
+
auth_path = settings.auth_state_path
|
|
151
|
+
|
|
152
|
+
if not auth_path.exists():
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
with open(auth_path) as f:
|
|
157
|
+
state = json.load(f)
|
|
158
|
+
|
|
159
|
+
# Check for HEB session cookies
|
|
160
|
+
cookies = state.get("cookies", [])
|
|
161
|
+
heb_cookies = [c for c in cookies if "heb.com" in c.get("domain", "")]
|
|
162
|
+
|
|
163
|
+
if not heb_cookies:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
# Check for session indicator cookies (not expired)
|
|
167
|
+
valid_session_cookies = []
|
|
168
|
+
for cookie in heb_cookies:
|
|
169
|
+
name = cookie.get("name", "")
|
|
170
|
+
if name in SESSION_INDICATOR_COOKIES and not _is_cookie_expired(cookie):
|
|
171
|
+
valid_session_cookies.append(name)
|
|
172
|
+
|
|
173
|
+
if not valid_session_cookies:
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
# Check reese84 bot detection token (required for API calls to succeed)
|
|
177
|
+
return _is_reese84_valid(state)
|
|
178
|
+
|
|
179
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
180
|
+
logger.warning("Failed to read auth state", error=str(e))
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def get_auth_instructions() -> list[str]:
|
|
185
|
+
"""Get instructions for authenticating with Playwright MCP."""
|
|
186
|
+
settings = get_settings()
|
|
187
|
+
return [
|
|
188
|
+
"1. Use Playwright MCP: browser_navigate('https://www.heb.com/my-account/login')",
|
|
189
|
+
"2. Complete the login process in the browser",
|
|
190
|
+
"3. Use Playwright MCP: browser_run_code to save storage state:",
|
|
191
|
+
f" await page.context().storageState({{ path: '{settings.auth_state_path}' }})",
|
|
192
|
+
"4. Retry this operation",
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def check_auth() -> dict[str, Any]:
|
|
197
|
+
"""Check authentication status and return appropriate response."""
|
|
198
|
+
if is_authenticated():
|
|
199
|
+
return {
|
|
200
|
+
"authenticated": True,
|
|
201
|
+
"message": "Authenticated with HEB",
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
"authenticated": False,
|
|
206
|
+
"auth_required": True,
|
|
207
|
+
"message": "Login required for cart operations",
|
|
208
|
+
"instructions": get_auth_instructions(),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_cookies() -> list[dict[str, Any]]:
|
|
213
|
+
"""Get cookies for authenticated requests (Playwright format)."""
|
|
214
|
+
settings = get_settings()
|
|
215
|
+
auth_path = settings.auth_state_path
|
|
216
|
+
|
|
217
|
+
if not auth_path.exists():
|
|
218
|
+
return []
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
with open(auth_path) as f:
|
|
222
|
+
state = json.load(f)
|
|
223
|
+
return [c for c in state.get("cookies", []) if "heb.com" in c.get("domain", "")]
|
|
224
|
+
except (json.JSONDecodeError, OSError):
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_httpx_cookies() -> dict[str, str]:
|
|
229
|
+
"""Get cookies in httpx-compatible format.
|
|
230
|
+
|
|
231
|
+
Converts Playwright storage state cookies to a simple dict
|
|
232
|
+
that can be passed to httpx.AsyncClient.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Dict mapping cookie names to values for HEB domains
|
|
236
|
+
"""
|
|
237
|
+
cookies = get_cookies()
|
|
238
|
+
httpx_cookies: dict[str, str] = {}
|
|
239
|
+
|
|
240
|
+
for cookie in cookies:
|
|
241
|
+
# Skip expired cookies
|
|
242
|
+
if _is_cookie_expired(cookie):
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
name = cookie.get("name", "")
|
|
246
|
+
value = cookie.get("value", "")
|
|
247
|
+
|
|
248
|
+
if name and value:
|
|
249
|
+
httpx_cookies[name] = value
|
|
250
|
+
|
|
251
|
+
logger.debug("Loaded cookies for httpx", count=len(httpx_cookies))
|
|
252
|
+
return httpx_cookies
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def save_browser_cookies(cookies: list[dict[str, Any]]) -> bool:
|
|
256
|
+
"""Save browser cookies to auth state file.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
cookies: List of Playwright-format cookies to save
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
True if saved successfully, False otherwise
|
|
263
|
+
"""
|
|
264
|
+
settings = get_settings()
|
|
265
|
+
auth_path = settings.auth_state_path
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
# Ensure parent directory exists
|
|
269
|
+
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
|
|
271
|
+
# Load existing state or create new
|
|
272
|
+
state: dict[str, Any] = {"cookies": [], "origins": []}
|
|
273
|
+
if auth_path.exists():
|
|
274
|
+
with open(auth_path) as f:
|
|
275
|
+
state = json.load(f)
|
|
276
|
+
|
|
277
|
+
# Filter to only HEB cookies from input
|
|
278
|
+
heb_cookies = [c for c in cookies if "heb.com" in c.get("domain", "")]
|
|
279
|
+
|
|
280
|
+
# Merge: replace existing HEB cookies with new ones
|
|
281
|
+
existing_non_heb = [
|
|
282
|
+
c for c in state.get("cookies", [])
|
|
283
|
+
if "heb.com" not in c.get("domain", "")
|
|
284
|
+
]
|
|
285
|
+
state["cookies"] = existing_non_heb + heb_cookies
|
|
286
|
+
|
|
287
|
+
# Write back with secure permissions
|
|
288
|
+
from texas_grocery_mcp.utils.secure_file import write_secure_json
|
|
289
|
+
|
|
290
|
+
write_secure_json(auth_path, state)
|
|
291
|
+
|
|
292
|
+
logger.info("Saved browser cookies", count=len(heb_cookies), path=str(auth_path))
|
|
293
|
+
return True
|
|
294
|
+
|
|
295
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
296
|
+
logger.error("Failed to save browser cookies", error=str(e))
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def get_reese84_info() -> dict[str, Any] | None:
|
|
301
|
+
"""Extract reese84 bot detection token metadata if available.
|
|
302
|
+
|
|
303
|
+
The reese84 cookie is used by Incapsula WAF for bot detection.
|
|
304
|
+
It contains or references a token with renewal time information.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
Dict with expires/renewTime metadata, or None if not found
|
|
308
|
+
"""
|
|
309
|
+
settings = get_settings()
|
|
310
|
+
auth_path = settings.auth_state_path
|
|
311
|
+
|
|
312
|
+
if not auth_path.exists():
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
with open(auth_path) as f:
|
|
317
|
+
state = json.load(f)
|
|
318
|
+
|
|
319
|
+
# Check cookies for reese84
|
|
320
|
+
cookies = state.get("cookies", [])
|
|
321
|
+
for cookie in cookies:
|
|
322
|
+
if cookie.get("name") == "reese84" and "heb.com" in cookie.get("domain", ""):
|
|
323
|
+
expires = cookie.get("expires", -1)
|
|
324
|
+
return {
|
|
325
|
+
"source": "cookie",
|
|
326
|
+
"expires": expires if expires > 0 else None,
|
|
327
|
+
"domain": cookie.get("domain"),
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
# Check localStorage (origins) for reese84 with renewTime
|
|
331
|
+
for origin in state.get("origins", []):
|
|
332
|
+
if "heb.com" in origin.get("origin", ""):
|
|
333
|
+
for item in origin.get("localStorage", []):
|
|
334
|
+
if item.get("name") == "reese84":
|
|
335
|
+
try:
|
|
336
|
+
value = json.loads(item.get("value", "{}"))
|
|
337
|
+
return {
|
|
338
|
+
"source": "localStorage",
|
|
339
|
+
"renew_time": value.get("renewTime"),
|
|
340
|
+
"renew_in_sec": value.get("renewInSec"),
|
|
341
|
+
"server_timestamp": value.get("serverTimestamp"),
|
|
342
|
+
}
|
|
343
|
+
except json.JSONDecodeError:
|
|
344
|
+
pass
|
|
345
|
+
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
349
|
+
logger.warning("Failed to read reese84 info", error=str(e))
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def check_session_freshness() -> dict[str, Any]:
|
|
354
|
+
"""Check if the session needs refresh due to stale bot detection tokens.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Dict with needs_refresh bool and diagnostic information
|
|
358
|
+
"""
|
|
359
|
+
info: dict[str, Any] = {
|
|
360
|
+
"needs_refresh": False,
|
|
361
|
+
"authenticated": is_authenticated(),
|
|
362
|
+
"reason": None,
|
|
363
|
+
"bot_detection_status": "unknown",
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if not info["authenticated"]:
|
|
367
|
+
info["needs_refresh"] = True
|
|
368
|
+
info["reason"] = "not_authenticated"
|
|
369
|
+
return info
|
|
370
|
+
|
|
371
|
+
# Check reese84 token
|
|
372
|
+
reese84_info = get_reese84_info()
|
|
373
|
+
if reese84_info is None:
|
|
374
|
+
info["bot_detection_status"] = "missing"
|
|
375
|
+
info["needs_refresh"] = True
|
|
376
|
+
info["reason"] = "reese84_missing"
|
|
377
|
+
return info
|
|
378
|
+
|
|
379
|
+
current_time = time.time()
|
|
380
|
+
|
|
381
|
+
# Check cookie expiration
|
|
382
|
+
expires = reese84_info.get("expires")
|
|
383
|
+
if expires and expires > 0 and current_time > expires - 300:
|
|
384
|
+
info["bot_detection_status"] = "cookie_expired"
|
|
385
|
+
info["needs_refresh"] = True
|
|
386
|
+
info["reason"] = "reese84_cookie_expired"
|
|
387
|
+
info["expired_at"] = expires
|
|
388
|
+
return info
|
|
389
|
+
|
|
390
|
+
# Check localStorage renewTime (milliseconds)
|
|
391
|
+
renew_time_ms = reese84_info.get("renew_time")
|
|
392
|
+
if renew_time_ms:
|
|
393
|
+
renew_time = renew_time_ms / 1000 # Convert to seconds
|
|
394
|
+
if current_time > renew_time:
|
|
395
|
+
info["bot_detection_status"] = "token_stale"
|
|
396
|
+
info["needs_refresh"] = True
|
|
397
|
+
info["reason"] = "reese84_renew_time_passed"
|
|
398
|
+
info["renew_time_passed_at"] = renew_time
|
|
399
|
+
return info
|
|
400
|
+
|
|
401
|
+
info["bot_detection_status"] = "valid"
|
|
402
|
+
return info
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def get_session_refresh_instructions() -> list[str]:
|
|
406
|
+
"""Get instructions for refreshing the session via Playwright MCP.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
List of step-by-step instructions
|
|
410
|
+
"""
|
|
411
|
+
settings = get_settings()
|
|
412
|
+
return [
|
|
413
|
+
"To refresh your HEB session (solves bot detection challenges):",
|
|
414
|
+
"",
|
|
415
|
+
"1. Navigate to HEB homepage to trigger bot detection refresh:",
|
|
416
|
+
" browser_navigate('https://www.heb.com')",
|
|
417
|
+
"",
|
|
418
|
+
"2. Wait for page to fully load (bot detection runs in background):",
|
|
419
|
+
" browser_wait_for({ state: 'networkidle' })",
|
|
420
|
+
"",
|
|
421
|
+
"3. Perform a search to verify session is working:",
|
|
422
|
+
" browser_type('input[data-qe-id=\"headerSearchInput\"]', 'milk')",
|
|
423
|
+
" browser_press_key('Enter')",
|
|
424
|
+
"",
|
|
425
|
+
"4. Wait for search results:",
|
|
426
|
+
" browser_wait_for({ selector: '[data-qe-id=\"productCard\"]', timeout: 10000 })",
|
|
427
|
+
"",
|
|
428
|
+
"5. Save the refreshed session:",
|
|
429
|
+
(
|
|
430
|
+
" browser_run_code with: await page.context().storageState({ path: '"
|
|
431
|
+
f"{settings.auth_state_path}"
|
|
432
|
+
"' })"
|
|
433
|
+
),
|
|
434
|
+
"",
|
|
435
|
+
"6. Verify with session_status and session_refresh",
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def get_session_info() -> dict[str, Any]:
|
|
440
|
+
"""Get detailed session information.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
Dict with session status, expiration info, and store ID
|
|
444
|
+
"""
|
|
445
|
+
settings = get_settings()
|
|
446
|
+
auth_path = settings.auth_state_path
|
|
447
|
+
|
|
448
|
+
info: dict[str, Any] = {
|
|
449
|
+
"authenticated": False,
|
|
450
|
+
"auth_path": str(auth_path),
|
|
451
|
+
"auth_file_exists": auth_path.exists(),
|
|
452
|
+
"cookies_count": 0,
|
|
453
|
+
"store_id": None,
|
|
454
|
+
"expires_at": None,
|
|
455
|
+
"user_id": None,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if not auth_path.exists():
|
|
459
|
+
return info
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
with open(auth_path) as f:
|
|
463
|
+
state = json.load(f)
|
|
464
|
+
|
|
465
|
+
cookies = state.get("cookies", [])
|
|
466
|
+
heb_cookies = [c for c in cookies if "heb.com" in c.get("domain", "")]
|
|
467
|
+
info["cookies_count"] = len(heb_cookies)
|
|
468
|
+
|
|
469
|
+
# Extract useful info from cookies
|
|
470
|
+
for cookie in heb_cookies:
|
|
471
|
+
name = cookie.get("name", "")
|
|
472
|
+
value = cookie.get("value", "")
|
|
473
|
+
expires = cookie.get("expires", -1)
|
|
474
|
+
|
|
475
|
+
if name == "CURR_SESSION_STORE":
|
|
476
|
+
info["store_id"] = value
|
|
477
|
+
elif name == "DYN_USER_ID":
|
|
478
|
+
info["user_id"] = value
|
|
479
|
+
elif name == "sat" and expires > 0:
|
|
480
|
+
# sat token expiration is a good indicator of session validity
|
|
481
|
+
info["expires_at"] = expires
|
|
482
|
+
|
|
483
|
+
info["authenticated"] = is_authenticated()
|
|
484
|
+
|
|
485
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
486
|
+
logger.warning("Failed to read session info", error=str(e))
|
|
487
|
+
|
|
488
|
+
return info
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# Refresh threshold: recommend refresh when less than this many hours remain
|
|
492
|
+
SESSION_REFRESH_THRESHOLD_HOURS = 4
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def get_session_status() -> SessionStatus:
|
|
496
|
+
"""Get comprehensive session status including token lifecycle.
|
|
497
|
+
|
|
498
|
+
Analyzes the reese84 bot detection token to determine:
|
|
499
|
+
- How much time remains before the token expires
|
|
500
|
+
- Whether refresh is recommended (< 4 hours remaining)
|
|
501
|
+
- Whether refresh is required (expired)
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
SessionStatus with authentication state, time remaining, and recommendations.
|
|
505
|
+
"""
|
|
506
|
+
settings = get_settings()
|
|
507
|
+
auth_path = settings.auth_state_path
|
|
508
|
+
|
|
509
|
+
# Check if auth file exists
|
|
510
|
+
if not auth_path.exists():
|
|
511
|
+
return SessionStatus(
|
|
512
|
+
authenticated=False,
|
|
513
|
+
needs_refresh=True,
|
|
514
|
+
refresh_recommended=True,
|
|
515
|
+
time_remaining_hours=None,
|
|
516
|
+
expires_at=None,
|
|
517
|
+
reese84_present=False,
|
|
518
|
+
message="No auth file found. Run session_refresh to authenticate.",
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
# Load and analyze auth state
|
|
522
|
+
try:
|
|
523
|
+
with open(auth_path) as f:
|
|
524
|
+
auth_data = json.load(f)
|
|
525
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
526
|
+
logger.warning("Failed to read auth file", error=str(e))
|
|
527
|
+
return SessionStatus(
|
|
528
|
+
authenticated=False,
|
|
529
|
+
needs_refresh=True,
|
|
530
|
+
refresh_recommended=True,
|
|
531
|
+
time_remaining_hours=None,
|
|
532
|
+
expires_at=None,
|
|
533
|
+
reese84_present=False,
|
|
534
|
+
message=f"Auth file corrupted: {e}. Run session_refresh.",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Extract reese84 info from localStorage
|
|
538
|
+
local_storage: list[dict[str, Any]] = []
|
|
539
|
+
origins = auth_data.get("origins", [])
|
|
540
|
+
if origins:
|
|
541
|
+
local_storage = origins[0].get("localStorage", [])
|
|
542
|
+
|
|
543
|
+
reese84_data: dict[str, Any] | None = None
|
|
544
|
+
for item in local_storage:
|
|
545
|
+
if item.get("name") == "reese84":
|
|
546
|
+
with suppress(json.JSONDecodeError):
|
|
547
|
+
reese84_data = json.loads(item.get("value", "{}"))
|
|
548
|
+
break
|
|
549
|
+
|
|
550
|
+
# Calculate time remaining
|
|
551
|
+
time_remaining_hours: float | None = None
|
|
552
|
+
expires_at: str | None = None
|
|
553
|
+
needs_refresh = True
|
|
554
|
+
refresh_recommended = True
|
|
555
|
+
reese84_present = reese84_data is not None
|
|
556
|
+
|
|
557
|
+
if reese84_data:
|
|
558
|
+
# reese84 has renewTime (Unix ms) or renewInSec
|
|
559
|
+
renew_time_ms = reese84_data.get("renewTime")
|
|
560
|
+
renew_in_sec = reese84_data.get("renewInSec")
|
|
561
|
+
server_timestamp = reese84_data.get("serverTimestamp")
|
|
562
|
+
|
|
563
|
+
now = time.time()
|
|
564
|
+
|
|
565
|
+
if renew_time_ms:
|
|
566
|
+
# renewTime is absolute Unix timestamp in milliseconds
|
|
567
|
+
renew_timestamp = renew_time_ms / 1000
|
|
568
|
+
remaining_seconds = renew_timestamp - now
|
|
569
|
+
time_remaining_hours = remaining_seconds / 3600
|
|
570
|
+
expires_at = datetime.fromtimestamp(
|
|
571
|
+
renew_timestamp, tz=UTC
|
|
572
|
+
).isoformat()
|
|
573
|
+
elif renew_in_sec and server_timestamp:
|
|
574
|
+
# Calculate from relative values
|
|
575
|
+
server_ts = (
|
|
576
|
+
server_timestamp / 1000 if server_timestamp > 1e12 else server_timestamp
|
|
577
|
+
)
|
|
578
|
+
renew_timestamp = server_ts + renew_in_sec
|
|
579
|
+
remaining_seconds = renew_timestamp - now
|
|
580
|
+
time_remaining_hours = remaining_seconds / 3600
|
|
581
|
+
expires_at = datetime.fromtimestamp(
|
|
582
|
+
renew_timestamp, tz=UTC
|
|
583
|
+
).isoformat()
|
|
584
|
+
|
|
585
|
+
if time_remaining_hours is not None:
|
|
586
|
+
needs_refresh = time_remaining_hours <= 0
|
|
587
|
+
# Recommend refresh when < threshold hours remaining
|
|
588
|
+
refresh_recommended = time_remaining_hours < SESSION_REFRESH_THRESHOLD_HOURS
|
|
589
|
+
|
|
590
|
+
# Check cookies for session validity
|
|
591
|
+
cookies = auth_data.get("cookies", [])
|
|
592
|
+
has_valid_cookies = any(
|
|
593
|
+
c.get("name") in ("sat", "DYN_USER_ID")
|
|
594
|
+
and "heb.com" in c.get("domain", "")
|
|
595
|
+
and (c.get("expires", 0) == -1 or c.get("expires", 0) > time.time())
|
|
596
|
+
for c in cookies
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
authenticated = reese84_present and has_valid_cookies and not needs_refresh
|
|
600
|
+
|
|
601
|
+
# Generate message
|
|
602
|
+
if not authenticated:
|
|
603
|
+
if not reese84_present:
|
|
604
|
+
message = "Session missing reese84 token. Run session_refresh."
|
|
605
|
+
elif needs_refresh:
|
|
606
|
+
message = "Session expired. Run session_refresh."
|
|
607
|
+
else:
|
|
608
|
+
message = "Session invalid. Run session_refresh."
|
|
609
|
+
elif refresh_recommended:
|
|
610
|
+
hours = round(time_remaining_hours, 1) if time_remaining_hours else 0
|
|
611
|
+
message = f"Session valid but expiring soon ({hours}h remaining). Consider session_refresh."
|
|
612
|
+
else:
|
|
613
|
+
hours_str = (
|
|
614
|
+
str(round(time_remaining_hours, 1))
|
|
615
|
+
if time_remaining_hours is not None
|
|
616
|
+
else "unknown"
|
|
617
|
+
)
|
|
618
|
+
message = f"Session healthy ({hours_str}h remaining)."
|
|
619
|
+
|
|
620
|
+
return SessionStatus(
|
|
621
|
+
authenticated=authenticated,
|
|
622
|
+
needs_refresh=needs_refresh,
|
|
623
|
+
refresh_recommended=refresh_recommended,
|
|
624
|
+
time_remaining_hours=(
|
|
625
|
+
round(time_remaining_hours, 2) if time_remaining_hours else None
|
|
626
|
+
),
|
|
627
|
+
expires_at=expires_at,
|
|
628
|
+
reese84_present=reese84_present,
|
|
629
|
+
message=message,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
async def auto_refresh_session_if_needed() -> dict[str, Any] | None:
|
|
634
|
+
"""Check session status and auto-refresh if needed.
|
|
635
|
+
|
|
636
|
+
Called by the @ensure_session decorator before authenticated operations.
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
None if session is healthy or refresh succeeded.
|
|
640
|
+
Error dict if manual login is required.
|
|
641
|
+
"""
|
|
642
|
+
global _last_auto_refresh_attempt
|
|
643
|
+
|
|
644
|
+
settings = get_settings()
|
|
645
|
+
|
|
646
|
+
# Check if auto-refresh is enabled
|
|
647
|
+
if not settings.auto_refresh_enabled:
|
|
648
|
+
return None
|
|
649
|
+
|
|
650
|
+
# If the user has never logged in / saved storage state, don't block read-only tools.
|
|
651
|
+
# Auth-required tools will provide explicit login instructions on their own.
|
|
652
|
+
auth_path = settings.auth_state_path
|
|
653
|
+
if not auth_path.exists():
|
|
654
|
+
return None
|
|
655
|
+
|
|
656
|
+
# Get current session status
|
|
657
|
+
status = get_session_status()
|
|
658
|
+
|
|
659
|
+
# Check if refresh is needed
|
|
660
|
+
threshold = settings.auto_refresh_threshold_hours
|
|
661
|
+
needs_auto_refresh = (
|
|
662
|
+
status["needs_refresh"]
|
|
663
|
+
or (
|
|
664
|
+
status["time_remaining_hours"] is not None
|
|
665
|
+
and status["time_remaining_hours"] < threshold
|
|
666
|
+
)
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
if not needs_auto_refresh:
|
|
670
|
+
return None
|
|
671
|
+
|
|
672
|
+
# Prevent rapid retries
|
|
673
|
+
current_time = time.time()
|
|
674
|
+
if current_time - _last_auto_refresh_attempt < _auto_refresh_min_interval:
|
|
675
|
+
logger.debug(
|
|
676
|
+
"Skipping auto-refresh (too soon since last attempt)",
|
|
677
|
+
seconds_since_last=round(current_time - _last_auto_refresh_attempt, 1),
|
|
678
|
+
)
|
|
679
|
+
return None
|
|
680
|
+
|
|
681
|
+
_last_auto_refresh_attempt = current_time
|
|
682
|
+
|
|
683
|
+
# Attempt headless refresh
|
|
684
|
+
logger.info(
|
|
685
|
+
"Auto-refreshing session",
|
|
686
|
+
needs_refresh=status["needs_refresh"],
|
|
687
|
+
time_remaining_hours=status["time_remaining_hours"],
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
try:
|
|
691
|
+
# Import here to avoid circular imports
|
|
692
|
+
from texas_grocery_mcp.auth.browser_refresh import (
|
|
693
|
+
BrowserRefreshError,
|
|
694
|
+
LoginRequiredError,
|
|
695
|
+
is_playwright_available,
|
|
696
|
+
refresh_session_with_browser,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
if not is_playwright_available():
|
|
700
|
+
logger.warning("Auto-refresh unavailable: Playwright not installed")
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
result = await refresh_session_with_browser(
|
|
704
|
+
auth_path=auth_path,
|
|
705
|
+
headless=True,
|
|
706
|
+
timeout=30000,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
logger.info(
|
|
710
|
+
"Session auto-refreshed successfully",
|
|
711
|
+
elapsed_seconds=result.get("elapsed_seconds"),
|
|
712
|
+
)
|
|
713
|
+
return None
|
|
714
|
+
|
|
715
|
+
except LoginRequiredError:
|
|
716
|
+
logger.warning("Auto-refresh failed: manual login required")
|
|
717
|
+
return {
|
|
718
|
+
"error": True,
|
|
719
|
+
"code": "LOGIN_REQUIRED",
|
|
720
|
+
"message": (
|
|
721
|
+
"Your HEB session has expired and requires manual login. "
|
|
722
|
+
"Run session_refresh(headless=False) to log in."
|
|
723
|
+
),
|
|
724
|
+
"auto_refresh_attempted": True,
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
except BrowserRefreshError as e:
|
|
728
|
+
logger.warning("Auto-refresh failed", error=str(e))
|
|
729
|
+
# Don't block the operation - let it try with potentially stale session
|
|
730
|
+
return None
|
|
731
|
+
|
|
732
|
+
except Exception as e:
|
|
733
|
+
logger.warning("Auto-refresh failed with unexpected error", error=str(e))
|
|
734
|
+
return None
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
P = ParamSpec("P")
|
|
738
|
+
ToolResult = dict[str, Any]
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def ensure_session(func: Callable[P, Awaitable[ToolResult]]) -> Callable[P, Awaitable[ToolResult]]:
|
|
742
|
+
"""Decorator to ensure valid session before executing authenticated tools.
|
|
743
|
+
|
|
744
|
+
Checks session status and auto-refreshes if:
|
|
745
|
+
- Session is expired (needs_refresh=True)
|
|
746
|
+
- Session is expiring soon (< auto_refresh_threshold_hours remaining)
|
|
747
|
+
|
|
748
|
+
If auto-refresh fails and manual login is required, returns an error
|
|
749
|
+
instead of executing the tool.
|
|
750
|
+
|
|
751
|
+
Usage:
|
|
752
|
+
@ensure_session
|
|
753
|
+
async def cart_add(...):
|
|
754
|
+
...
|
|
755
|
+
"""
|
|
756
|
+
|
|
757
|
+
@wraps(func)
|
|
758
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> ToolResult:
|
|
759
|
+
# Check and refresh session if needed
|
|
760
|
+
error = await auto_refresh_session_if_needed()
|
|
761
|
+
if error:
|
|
762
|
+
return error
|
|
763
|
+
|
|
764
|
+
# Execute the original function
|
|
765
|
+
return await func(*args, **kwargs)
|
|
766
|
+
|
|
767
|
+
return wrapper
|