AlexaPy 1.29.22__tar.gz → 1.29.24__tar.gz
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.
- {alexapy-1.29.22 → alexapy-1.29.24}/PKG-INFO +1 -1
- {alexapy-1.29.22 → alexapy-1.29.24}/alexapy/alexalogin.py +491 -150
- {alexapy-1.29.22 → alexapy-1.29.24}/alexapy/const.py +8 -0
- {alexapy-1.29.22 → alexapy-1.29.24}/pyproject.toml +1 -1
- {alexapy-1.29.22 → alexapy-1.29.24}/AUTHORS.md +0 -0
- {alexapy-1.29.22 → alexapy-1.29.24}/LICENSE +0 -0
- {alexapy-1.29.22 → alexapy-1.29.24}/README.md +0 -0
- {alexapy-1.29.22 → alexapy-1.29.24}/alexapy/__init__.py +0 -0
- {alexapy-1.29.22 → alexapy-1.29.24}/alexapy/alexaapi.py +0 -0
- {alexapy-1.29.22 → alexapy-1.29.24}/alexapy/alexahttp2.py +0 -0
- {alexapy-1.29.22 → alexapy-1.29.24}/alexapy/alexaproxy.py +0 -0
- {alexapy-1.29.22 → alexapy-1.29.24}/alexapy/alexawebsocket.py +0 -0
- {alexapy-1.29.22 → alexapy-1.29.24}/alexapy/errors.py +0 -0
- {alexapy-1.29.22 → alexapy-1.29.24}/alexapy/helpers.py +0 -0
|
@@ -26,7 +26,7 @@ from binascii import Error
|
|
|
26
26
|
from collections import defaultdict
|
|
27
27
|
from collections.abc import Callable
|
|
28
28
|
from http.cookies import BaseCookie, Morsel, SimpleCookie
|
|
29
|
-
from json import JSONDecodeError, dumps
|
|
29
|
+
from json import JSONDecodeError, dumps, loads
|
|
30
30
|
from typing import Any
|
|
31
31
|
from urllib.parse import urlencode, urlparse
|
|
32
32
|
from uuid import uuid4
|
|
@@ -42,7 +42,15 @@ from requests.cookies import RequestsCookieJar
|
|
|
42
42
|
from simplejson import JSONDecodeError as SimpleJSONDecodeError
|
|
43
43
|
from yarl import URL
|
|
44
44
|
|
|
45
|
-
from .const import
|
|
45
|
+
from .const import (
|
|
46
|
+
APP_NAME,
|
|
47
|
+
CALL_VERSION,
|
|
48
|
+
COOKIE_SERIALIZATION_FORMAT,
|
|
49
|
+
COOKIE_SERIALIZATION_VERSION,
|
|
50
|
+
EXCEPTION_TEMPLATE,
|
|
51
|
+
LOCALE_KEY,
|
|
52
|
+
USER_AGENT,
|
|
53
|
+
)
|
|
46
54
|
from .errors import AlexapyPyotpInvalidKey
|
|
47
55
|
from .helpers import (
|
|
48
56
|
_catch_all_exceptions,
|
|
@@ -54,18 +62,6 @@ from .helpers import (
|
|
|
54
62
|
|
|
55
63
|
_LOGGER = logging.getLogger(__name__)
|
|
56
64
|
|
|
57
|
-
"""Ensure cookies.Morsel contains "partitioned"
|
|
58
|
-
See: https://github.com/python/cpython/issues/112713
|
|
59
|
-
"""
|
|
60
|
-
partitioned = {"partitioned": "Partitioned"}
|
|
61
|
-
Morsel._reserved.update(partitioned)
|
|
62
|
-
Morsel._flags.add("partitioned")
|
|
63
|
-
_LOGGER.debug(
|
|
64
|
-
"http.cookies patch: Morsel._reserved: %s; Morsel._flags: %s",
|
|
65
|
-
partitioned,
|
|
66
|
-
Morsel._flags,
|
|
67
|
-
)
|
|
68
|
-
|
|
69
65
|
|
|
70
66
|
def create_alexa_context() -> ssl.SSLContext:
|
|
71
67
|
"""Create an SSL context for Alexa."""
|
|
@@ -78,6 +74,318 @@ def create_alexa_context() -> ssl.SSLContext:
|
|
|
78
74
|
_SSL_CONTEXT = create_alexa_context()
|
|
79
75
|
|
|
80
76
|
|
|
77
|
+
def _morsel_attr(morsel: Morsel, attr: str) -> str | bool:
|
|
78
|
+
"""Return a morsel attribute without assuming runtime support for newer attrs."""
|
|
79
|
+
try:
|
|
80
|
+
return morsel[attr]
|
|
81
|
+
except KeyError:
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cookie_url(domain: str, path: str = "/", secure: bool = True) -> URL:
|
|
86
|
+
"""Build a response URL suitable for aiohttp.CookieJar.update_cookies."""
|
|
87
|
+
clean_domain = (domain or "amazon.com").lstrip(".")
|
|
88
|
+
clean_path = path or "/"
|
|
89
|
+
if not clean_path.startswith("/"):
|
|
90
|
+
clean_path = f"/{clean_path}"
|
|
91
|
+
scheme = "https" if secure else "http"
|
|
92
|
+
return URL.build(scheme=scheme, host=clean_domain, path=clean_path)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _safe_bool(value: Any) -> bool:
|
|
96
|
+
"""Convert cookie flag values to bool."""
|
|
97
|
+
if isinstance(value, bool):
|
|
98
|
+
return value
|
|
99
|
+
return value not in ("", None, "False", "false", "0", 0)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _strip_wrapping_quotes(value: str) -> str:
|
|
103
|
+
"""Strip one pair of extra cookie quotes without altering embedded quotes."""
|
|
104
|
+
if len(value) >= 2 and value[0] == value[-1] == '"':
|
|
105
|
+
return value[1:-1]
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _serialize_morsel(
|
|
110
|
+
name: str,
|
|
111
|
+
morsel: Morsel,
|
|
112
|
+
*,
|
|
113
|
+
fallback_domain: str = "",
|
|
114
|
+
fallback_path: str = "/",
|
|
115
|
+
host_only_cookies: set[tuple[str, str]] | None = None,
|
|
116
|
+
expirations: dict[tuple[str, str, str], Any] | None = None,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
"""Serialize one Morsel to a stable dict."""
|
|
119
|
+
domain = str(_morsel_attr(morsel, "domain") or fallback_domain or "")
|
|
120
|
+
path = str(_morsel_attr(morsel, "path") or fallback_path or "/")
|
|
121
|
+
host_only_cookies = host_only_cookies or set()
|
|
122
|
+
expirations = expirations or {}
|
|
123
|
+
return {
|
|
124
|
+
"name": str(name),
|
|
125
|
+
"value": str(morsel.value),
|
|
126
|
+
"domain": domain,
|
|
127
|
+
"path": path,
|
|
128
|
+
"expires": _morsel_attr(morsel, "expires") or "",
|
|
129
|
+
"max_age": _morsel_attr(morsel, "max-age") or "",
|
|
130
|
+
"secure": _safe_bool(_morsel_attr(morsel, "secure")),
|
|
131
|
+
"httponly": _safe_bool(_morsel_attr(morsel, "httponly")),
|
|
132
|
+
"samesite": _morsel_attr(morsel, "samesite") or "",
|
|
133
|
+
"partitioned": _safe_bool(_morsel_attr(morsel, "partitioned")),
|
|
134
|
+
"host_only": (domain, str(name)) in host_only_cookies,
|
|
135
|
+
"expiration": expirations.get((domain, path, str(name))),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _serialize_cookie_jar(cookie_jar: aiohttp.CookieJar) -> dict[str, Any]:
|
|
140
|
+
"""Persist cookies using an alexapy-owned serialization format.
|
|
141
|
+
|
|
142
|
+
aiohttp.CookieJar.save/load serializes private implementation details,
|
|
143
|
+
which have changed across aiohttp/Python releases (for example the
|
|
144
|
+
addition of the "Partitioned" cookie attribute). Using an explicit
|
|
145
|
+
serialization format provides forward compatibility and allows schema
|
|
146
|
+
versioning.
|
|
147
|
+
"""
|
|
148
|
+
serialized: dict[str, Any] = {
|
|
149
|
+
"format": COOKIE_SERIALIZATION_FORMAT,
|
|
150
|
+
"version": COOKIE_SERIALIZATION_VERSION,
|
|
151
|
+
"cookies": [],
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
cookies: list[dict[str, Any]] = serialized["cookies"]
|
|
155
|
+
host_only_cookies = getattr(cookie_jar, "_host_only_cookies", set())
|
|
156
|
+
expirations = getattr(cookie_jar, "_expirations", {})
|
|
157
|
+
seen: set[tuple[str, str, str]] = set()
|
|
158
|
+
|
|
159
|
+
# Preserve aiohttp's domain/path buckets when available. Current aiohttp uses
|
|
160
|
+
# a mapping like {(domain, path): SimpleCookie(...)}; older/newer versions may
|
|
161
|
+
# vary, so this is deliberately defensive.
|
|
162
|
+
jar_cookies = getattr(cookie_jar, "_cookies", {})
|
|
163
|
+
if isinstance(jar_cookies, dict):
|
|
164
|
+
for key, simple_cookie in jar_cookies.items():
|
|
165
|
+
domain = ""
|
|
166
|
+
path = "/"
|
|
167
|
+
if isinstance(key, tuple):
|
|
168
|
+
domain = str(key[0] or "") if len(key) >= 1 else ""
|
|
169
|
+
path = str(key[1] or "/") if len(key) >= 2 else "/"
|
|
170
|
+
elif isinstance(key, str):
|
|
171
|
+
# Some private aiohttp pickles have been observed with a string
|
|
172
|
+
# bucket key. Treat it as a domain, not as a cookie name.
|
|
173
|
+
domain = key
|
|
174
|
+
|
|
175
|
+
if not isinstance(simple_cookie, BaseCookie):
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
for name, morsel in simple_cookie.items():
|
|
179
|
+
entry = _serialize_morsel(
|
|
180
|
+
str(name),
|
|
181
|
+
morsel,
|
|
182
|
+
fallback_domain=domain,
|
|
183
|
+
fallback_path=path,
|
|
184
|
+
host_only_cookies=host_only_cookies,
|
|
185
|
+
expirations=expirations,
|
|
186
|
+
)
|
|
187
|
+
cookie_key = (entry["domain"], entry["path"], entry["name"])
|
|
188
|
+
if cookie_key in seen:
|
|
189
|
+
continue
|
|
190
|
+
seen.add(cookie_key)
|
|
191
|
+
cookies.append(entry)
|
|
192
|
+
|
|
193
|
+
# Fallback for aiohttp versions whose internals differ from the above.
|
|
194
|
+
for morsel in cookie_jar:
|
|
195
|
+
entry = _serialize_morsel(morsel.key, morsel)
|
|
196
|
+
cookie_key = (entry["domain"], entry["path"], entry["name"])
|
|
197
|
+
if cookie_key in seen:
|
|
198
|
+
continue
|
|
199
|
+
seen.add(cookie_key)
|
|
200
|
+
cookies.append(entry)
|
|
201
|
+
|
|
202
|
+
return serialized
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _deserialize_cookie(
|
|
206
|
+
cookie_jar: aiohttp.CookieJar, cookie: dict[str, Any]
|
|
207
|
+
) -> tuple[str, str] | None:
|
|
208
|
+
"""Restore one cookie entry to an aiohttp CookieJar."""
|
|
209
|
+
name = str(cookie.get("name") or "")
|
|
210
|
+
value = cookie.get("value")
|
|
211
|
+
domain = str(cookie.get("domain") or "")
|
|
212
|
+
path = str(cookie.get("path") or "/")
|
|
213
|
+
expires = str(cookie.get("expires") or "")
|
|
214
|
+
max_age = str(cookie.get("max_age") or "")
|
|
215
|
+
secure = _safe_bool(cookie.get("secure", True))
|
|
216
|
+
httponly = _safe_bool(cookie.get("httponly", False))
|
|
217
|
+
samesite = str(cookie.get("samesite") or "")
|
|
218
|
+
partitioned = _safe_bool(cookie.get("partitioned", False))
|
|
219
|
+
host_only = _safe_bool(cookie.get("host_only", False))
|
|
220
|
+
|
|
221
|
+
if not name or value is None:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
cookie_value = _strip_wrapping_quotes(str(value))
|
|
225
|
+
raw_cookie = SimpleCookie()
|
|
226
|
+
raw_cookie[name] = cookie_value
|
|
227
|
+
morsel = raw_cookie[name]
|
|
228
|
+
|
|
229
|
+
clean_domain = domain.lstrip(".")
|
|
230
|
+
clean_path = path
|
|
231
|
+
if not clean_path.startswith("/"):
|
|
232
|
+
clean_path = f"/{clean_path}"
|
|
233
|
+
|
|
234
|
+
# For host-only cookies, do not set the Domain attribute. The response URL
|
|
235
|
+
# supplies the host scope. Domain cookies keep their explicit Domain attr.
|
|
236
|
+
if clean_domain and not host_only:
|
|
237
|
+
with contextlib.suppress(KeyError):
|
|
238
|
+
morsel["domain"] = clean_domain
|
|
239
|
+
if clean_path:
|
|
240
|
+
with contextlib.suppress(KeyError):
|
|
241
|
+
morsel["path"] = clean_path
|
|
242
|
+
if expires:
|
|
243
|
+
with contextlib.suppress(KeyError):
|
|
244
|
+
morsel["expires"] = expires
|
|
245
|
+
if max_age:
|
|
246
|
+
with contextlib.suppress(KeyError):
|
|
247
|
+
morsel["max-age"] = max_age
|
|
248
|
+
if secure:
|
|
249
|
+
with contextlib.suppress(KeyError):
|
|
250
|
+
morsel["secure"] = True
|
|
251
|
+
if httponly:
|
|
252
|
+
with contextlib.suppress(KeyError):
|
|
253
|
+
morsel["httponly"] = True
|
|
254
|
+
if samesite:
|
|
255
|
+
with contextlib.suppress(KeyError):
|
|
256
|
+
morsel["samesite"] = samesite
|
|
257
|
+
if partitioned:
|
|
258
|
+
# Only Python/aiohttp combinations that know the attr should keep it.
|
|
259
|
+
with contextlib.suppress(KeyError):
|
|
260
|
+
morsel["partitioned"] = True
|
|
261
|
+
|
|
262
|
+
cookie_jar.update_cookies(raw_cookie, _cookie_url(clean_domain, clean_path, secure))
|
|
263
|
+
return name, cookie_value
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _deserialize_cookie_jar(
|
|
267
|
+
cookie_jar: aiohttp.CookieJar, serialized: dict[str, Any]
|
|
268
|
+
) -> dict[str, str]:
|
|
269
|
+
"""Restore a serialized alexapy cookie jar into an aiohttp CookieJar."""
|
|
270
|
+
return_cookies: dict[str, str] = {}
|
|
271
|
+
if serialized.get("format") != COOKIE_SERIALIZATION_FORMAT:
|
|
272
|
+
return return_cookies
|
|
273
|
+
|
|
274
|
+
with contextlib.suppress(Exception):
|
|
275
|
+
cookie_jar.clear()
|
|
276
|
+
|
|
277
|
+
for cookie in serialized.get("cookies", []):
|
|
278
|
+
if not isinstance(cookie, dict):
|
|
279
|
+
continue
|
|
280
|
+
restored = _deserialize_cookie(cookie_jar, cookie)
|
|
281
|
+
if restored:
|
|
282
|
+
return_cookies[restored[0]] = restored[1]
|
|
283
|
+
|
|
284
|
+
return return_cookies
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _legacy_cookie_value(value: Any) -> Any:
|
|
288
|
+
"""Extract a value from old aiohttp private-cookie records."""
|
|
289
|
+
if isinstance(value, Morsel):
|
|
290
|
+
return value.value
|
|
291
|
+
if isinstance(value, dict):
|
|
292
|
+
return value.get("value") or value.get("coded_value")
|
|
293
|
+
return value
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _restore_legacy_aiohttp_cookie_mapping(
|
|
297
|
+
cookie_jar: aiohttp.CookieJar, cookies: dict[Any, Any]
|
|
298
|
+
) -> dict[str, str]:
|
|
299
|
+
"""Restore old aiohttp.CookieJar.save() mappings.
|
|
300
|
+
|
|
301
|
+
This avoids treating bucket keys as cookie names.
|
|
302
|
+
"""
|
|
303
|
+
return_cookies: dict[str, str] = {}
|
|
304
|
+
restored_count = 0
|
|
305
|
+
|
|
306
|
+
for bucket_key, bucket in cookies.items():
|
|
307
|
+
domain = ""
|
|
308
|
+
path = "/"
|
|
309
|
+
if isinstance(bucket_key, tuple):
|
|
310
|
+
domain = str(bucket_key[0] or "") if len(bucket_key) >= 1 else ""
|
|
311
|
+
path = str(bucket_key[1] or "/") if len(bucket_key) >= 2 else "/"
|
|
312
|
+
elif isinstance(bucket_key, str):
|
|
313
|
+
# aiohttp private bucket key; do not turn this into a cookie name.
|
|
314
|
+
domain = bucket_key
|
|
315
|
+
|
|
316
|
+
if isinstance(bucket, BaseCookie | dict):
|
|
317
|
+
iterator = bucket.items()
|
|
318
|
+
else:
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
for name, stored in iterator:
|
|
322
|
+
cookie_domain = domain
|
|
323
|
+
cookie_path = path
|
|
324
|
+
expires = ""
|
|
325
|
+
max_age = ""
|
|
326
|
+
secure = True
|
|
327
|
+
httponly = False
|
|
328
|
+
samesite = ""
|
|
329
|
+
partitioned = False
|
|
330
|
+
value = _legacy_cookie_value(stored)
|
|
331
|
+
|
|
332
|
+
if isinstance(stored, Morsel):
|
|
333
|
+
cookie_domain = str(_morsel_attr(stored, "domain") or domain)
|
|
334
|
+
cookie_path = str(_morsel_attr(stored, "path") or path or "/")
|
|
335
|
+
expires = str(_morsel_attr(stored, "expires") or "")
|
|
336
|
+
max_age = str(_morsel_attr(stored, "max-age") or "")
|
|
337
|
+
secure = _safe_bool(_morsel_attr(stored, "secure"))
|
|
338
|
+
httponly = _safe_bool(_morsel_attr(stored, "httponly"))
|
|
339
|
+
samesite = str(_morsel_attr(stored, "samesite") or "")
|
|
340
|
+
partitioned = _safe_bool(_morsel_attr(stored, "partitioned"))
|
|
341
|
+
elif isinstance(stored, dict):
|
|
342
|
+
cookie_domain = str(stored.get("domain") or domain)
|
|
343
|
+
cookie_path = str(stored.get("path") or path or "/")
|
|
344
|
+
expires = str(stored.get("expires") or "")
|
|
345
|
+
max_age = str(stored.get("max-age") or stored.get("max_age") or "")
|
|
346
|
+
secure = _safe_bool(stored.get("secure", True))
|
|
347
|
+
httponly = _safe_bool(stored.get("httponly", False))
|
|
348
|
+
samesite = str(stored.get("samesite") or "")
|
|
349
|
+
partitioned = _safe_bool(stored.get("partitioned", False))
|
|
350
|
+
|
|
351
|
+
restored = _deserialize_cookie(
|
|
352
|
+
cookie_jar,
|
|
353
|
+
{
|
|
354
|
+
"name": str(name),
|
|
355
|
+
"value": value,
|
|
356
|
+
"domain": cookie_domain,
|
|
357
|
+
"path": cookie_path,
|
|
358
|
+
"expires": expires,
|
|
359
|
+
"max_age": max_age,
|
|
360
|
+
"secure": secure,
|
|
361
|
+
"httponly": httponly,
|
|
362
|
+
"samesite": samesite,
|
|
363
|
+
"partitioned": partitioned,
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
if restored:
|
|
367
|
+
restored_count += 1
|
|
368
|
+
return_cookies[restored[0]] = restored[1]
|
|
369
|
+
|
|
370
|
+
if restored_count:
|
|
371
|
+
_LOGGER.debug(
|
|
372
|
+
"Restored %s cookies from legacy aiohttp cookie mapping",
|
|
373
|
+
restored_count,
|
|
374
|
+
)
|
|
375
|
+
return return_cookies
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _is_flat_cookie_dict(cookies: dict[Any, Any]) -> bool:
|
|
379
|
+
"""Return True only for a legacy plain name/value cookie dict."""
|
|
380
|
+
return bool(cookies) and all(
|
|
381
|
+
isinstance(key, str)
|
|
382
|
+
and isinstance(value, str)
|
|
383
|
+
and not value.lstrip().startswith("{")
|
|
384
|
+
for key, value in cookies.items()
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
|
|
81
389
|
class AlexaLogin:
|
|
82
390
|
"""Class to handle login connection to Alexa. This class will not reconnect.
|
|
83
391
|
|
|
@@ -123,6 +431,7 @@ class AlexaLogin:
|
|
|
123
431
|
}
|
|
124
432
|
self._outputpath = outputpath
|
|
125
433
|
self._cookiefile: list[str] = [
|
|
434
|
+
self._outputpath(f".storage/{self._hass_domain}.{self.email}.cookies"),
|
|
126
435
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.pickle"),
|
|
127
436
|
self._outputpath(f"{self._hass_domain}.{self.email}.pickle"),
|
|
128
437
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.txt"),
|
|
@@ -317,153 +626,182 @@ class AlexaLogin:
|
|
|
317
626
|
return ""
|
|
318
627
|
|
|
319
628
|
async def load_cookie(self, cookies_txt: str = "") -> dict[str, str] | None: # noqa: PLR0915
|
|
320
|
-
"""Load
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
loaded
|
|
325
|
-
|
|
326
|
-
|
|
629
|
+
"""Load persisted cookies.
|
|
630
|
+
|
|
631
|
+
The preferred format is alexapy's versioned JSON .cookies file.
|
|
632
|
+
Legacy .pickle and .txt cookie files are still accepted for migration.
|
|
633
|
+
When a legacy file is successfully loaded, it is rewritten as .cookies
|
|
634
|
+
and the legacy files are removed.
|
|
635
|
+
"""
|
|
636
|
+
return_cookies: dict[str, str] = {}
|
|
637
|
+
if not self._cookiefile:
|
|
638
|
+
return return_cookies
|
|
639
|
+
|
|
640
|
+
if cookies_txt:
|
|
641
|
+
legacy_txt_cookiefile = self._cookiefile[-1]
|
|
642
|
+
_LOGGER.debug(
|
|
643
|
+
"Saving passed in cookie to %s\n%s",
|
|
644
|
+
legacy_txt_cookiefile.replace(self.email, hide_email(self.email)),
|
|
645
|
+
repr(cookies_txt),
|
|
646
|
+
)
|
|
647
|
+
try:
|
|
648
|
+
async with aiofiles.open(legacy_txt_cookiefile, mode="w") as localfile:
|
|
649
|
+
await localfile.write(cookies_txt)
|
|
650
|
+
except (OSError, EOFError, TypeError, AttributeError) as ex:
|
|
327
651
|
_LOGGER.debug(
|
|
328
|
-
"
|
|
329
|
-
|
|
330
|
-
|
|
652
|
+
"Error saving passed in cookie to %s: %s",
|
|
653
|
+
legacy_txt_cookiefile.replace(self.email, hide_email(self.email)),
|
|
654
|
+
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
331
655
|
)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
656
|
+
|
|
657
|
+
for cookiefile in self._cookiefile:
|
|
658
|
+
_LOGGER.debug(
|
|
659
|
+
"Searching for cookies from %s",
|
|
660
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
661
|
+
)
|
|
662
|
+
if not await aioos.path.exists(cookiefile):
|
|
663
|
+
continue
|
|
664
|
+
|
|
665
|
+
_LOGGER.debug(
|
|
666
|
+
"Trying to load cookie from file %s",
|
|
667
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
cookies: (
|
|
671
|
+
dict[Any, Any]
|
|
672
|
+
| RequestsCookieJar
|
|
673
|
+
| http.cookiejar.MozillaCookieJar
|
|
674
|
+
| None
|
|
675
|
+
) = None
|
|
676
|
+
try:
|
|
677
|
+
async with aiofiles.open(cookiefile, "rb") as myfile:
|
|
678
|
+
raw_cookie_file = await myfile.read()
|
|
679
|
+
try:
|
|
680
|
+
cookies = loads(raw_cookie_file.decode())
|
|
681
|
+
if self._debug:
|
|
336
682
|
_LOGGER.debug(
|
|
337
|
-
"
|
|
338
|
-
self._cookiefile[0].replace(
|
|
339
|
-
self.email,
|
|
340
|
-
hide_email(self.email),
|
|
341
|
-
),
|
|
342
|
-
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
683
|
+
"JSON cookie loaded: %s %s", type(cookies), cookies
|
|
343
684
|
)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
numcookies = 0
|
|
352
|
-
if not os.path.exists(cookiefile):
|
|
353
|
-
continue
|
|
354
|
-
if loaded and cookiefile != self._cookiefile[0]:
|
|
355
|
-
await delete_cookie(cookiefile)
|
|
356
|
-
_LOGGER.debug(
|
|
357
|
-
"Trying to load cookie from file %s",
|
|
358
|
-
cookiefile.replace(self.email, hide_email(self.email)),
|
|
359
|
-
)
|
|
685
|
+
except (UnicodeDecodeError, JSONDecodeError):
|
|
686
|
+
cookies = pickle.loads(raw_cookie_file)
|
|
687
|
+
if self._debug:
|
|
688
|
+
_LOGGER.debug(
|
|
689
|
+
"Pickled cookie loaded: %s %s", type(cookies), cookies
|
|
690
|
+
)
|
|
691
|
+
except pickle.UnpicklingError:
|
|
360
692
|
try:
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
_LOGGER.debug(
|
|
365
|
-
"Pickled cookie loaded: %s %s", type(cookies), cookies
|
|
366
|
-
)
|
|
367
|
-
except pickle.UnpicklingError:
|
|
368
|
-
try:
|
|
369
|
-
cookies = http.cookiejar.MozillaCookieJar(cookiefile)
|
|
370
|
-
cookies.load(ignore_discard=True, ignore_expires=True)
|
|
371
|
-
if self._debug:
|
|
372
|
-
_LOGGER.debug(
|
|
373
|
-
"Mozilla cookie loaded: %s %s", type(cookies), cookies
|
|
374
|
-
)
|
|
375
|
-
except (ValueError, http.cookiejar.LoadError) as ex:
|
|
693
|
+
cookies = http.cookiejar.MozillaCookieJar(cookiefile)
|
|
694
|
+
cookies.load(ignore_discard=True, ignore_expires=True)
|
|
695
|
+
if self._debug:
|
|
376
696
|
_LOGGER.debug(
|
|
377
|
-
"
|
|
378
|
-
cookiefile.replace(self.email, hide_email(self.email)),
|
|
379
|
-
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
697
|
+
"Mozilla cookie loaded: %s %s", type(cookies), cookies
|
|
380
698
|
)
|
|
381
|
-
|
|
382
|
-
except (OSError, EOFError) as ex:
|
|
699
|
+
except (ValueError, http.cookiejar.LoadError) as ex:
|
|
383
700
|
_LOGGER.debug(
|
|
384
|
-
"
|
|
701
|
+
"Cookie %s is truncated: %s",
|
|
385
702
|
cookiefile.replace(self.email, hide_email(self.email)),
|
|
386
703
|
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
387
704
|
)
|
|
388
705
|
continue
|
|
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
|
-
_LOGGER.debug("Found Mozillacookiejar")
|
|
432
|
-
for cookie in cookies:
|
|
433
|
-
if self._debug:
|
|
434
|
-
_LOGGER.debug(
|
|
435
|
-
"Processing cookie %s expires: %s",
|
|
436
|
-
cookie,
|
|
437
|
-
cookie.expires,
|
|
438
|
-
)
|
|
439
|
-
# escape extra quote marks from MozillaCookieJar cookie
|
|
440
|
-
return_cookies[cookie.name] = cookie.value.strip('"')
|
|
706
|
+
except (OSError, EOFError) as ex:
|
|
707
|
+
_LOGGER.debug(
|
|
708
|
+
"Error loading cookie from %s: %s",
|
|
709
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
710
|
+
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
711
|
+
)
|
|
712
|
+
continue
|
|
713
|
+
|
|
714
|
+
numcookies = 0
|
|
715
|
+
if (
|
|
716
|
+
isinstance(cookies, dict)
|
|
717
|
+
and cookies.get("format") == COOKIE_SERIALIZATION_FORMAT
|
|
718
|
+
):
|
|
719
|
+
_LOGGER.debug("Loading serialized alexapy cookie jar")
|
|
720
|
+
cookie_jar = self._session.cookie_jar
|
|
721
|
+
return_cookies = _deserialize_cookie_jar(cookie_jar, cookies)
|
|
722
|
+
numcookies = len(return_cookies)
|
|
723
|
+
elif isinstance(cookies, RequestsCookieJar):
|
|
724
|
+
_LOGGER.debug("Loading RequestsCookieJar")
|
|
725
|
+
requests_cookies = cookies.get_dict()
|
|
726
|
+
for key, value in requests_cookies.items():
|
|
727
|
+
if self._debug:
|
|
728
|
+
_LOGGER.debug('Key: "%s", Value: "%s"', key, value)
|
|
729
|
+
# Skip "partitioned" so older Python http.cookies does not fail.
|
|
730
|
+
if key != "partitioned":
|
|
731
|
+
return_cookies[str(key)] = value.strip('"')
|
|
732
|
+
numcookies = len(return_cookies)
|
|
733
|
+
elif isinstance(cookies, defaultdict):
|
|
734
|
+
_LOGGER.debug("Loading legacy aiohttp cookie mapping")
|
|
735
|
+
cookie_jar = self._session.cookie_jar
|
|
736
|
+
return_cookies = _restore_legacy_aiohttp_cookie_mapping(
|
|
737
|
+
cookie_jar, cookies
|
|
738
|
+
)
|
|
739
|
+
numcookies = len(return_cookies)
|
|
740
|
+
elif isinstance(cookies, dict):
|
|
741
|
+
if _is_flat_cookie_dict(cookies):
|
|
742
|
+
_LOGGER.debug("Found legacy flat dict cookie")
|
|
743
|
+
return_cookies = {
|
|
744
|
+
str(key): str(value).strip('"')
|
|
745
|
+
for key, value in cookies.items()
|
|
746
|
+
if key != "partitioned"
|
|
747
|
+
}
|
|
441
748
|
numcookies = len(return_cookies)
|
|
442
749
|
else:
|
|
443
|
-
_LOGGER.debug("
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
750
|
+
_LOGGER.debug("Loading legacy aiohttp cookie dict mapping")
|
|
751
|
+
cookie_jar = self._session.cookie_jar
|
|
752
|
+
return_cookies = _restore_legacy_aiohttp_cookie_mapping(
|
|
753
|
+
cookie_jar, cookies
|
|
754
|
+
)
|
|
755
|
+
numcookies = len(return_cookies)
|
|
756
|
+
elif isinstance(cookies, http.cookiejar.MozillaCookieJar):
|
|
757
|
+
_LOGGER.debug("Found Mozillacookiejar")
|
|
758
|
+
for cookie in cookies:
|
|
759
|
+
if self._debug:
|
|
448
760
|
_LOGGER.debug(
|
|
449
|
-
"
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
hide_email(self.email),
|
|
453
|
-
),
|
|
761
|
+
"Processing cookie %s expires: %s",
|
|
762
|
+
cookie,
|
|
763
|
+
cookie.expires,
|
|
454
764
|
)
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
765
|
+
return_cookies[cookie.name] = cookie.value.strip('"')
|
|
766
|
+
numcookies = len(return_cookies)
|
|
767
|
+
else:
|
|
768
|
+
_LOGGER.debug("Ignoring unknown file %s", type(cookies))
|
|
769
|
+
|
|
770
|
+
if not numcookies:
|
|
771
|
+
continue
|
|
772
|
+
|
|
773
|
+
_LOGGER.debug("Loaded %s cookies", numcookies)
|
|
774
|
+
if cookiefile != self._cookiefile[0]:
|
|
775
|
+
_LOGGER.debug(
|
|
776
|
+
"Migrating legacy cookiefile to %s ",
|
|
777
|
+
self._cookiefile[0].replace(self.email, hide_email(self.email)),
|
|
778
|
+
)
|
|
779
|
+
try:
|
|
780
|
+
cookie_jar = self._session.cookie_jar
|
|
781
|
+
if not list(cookie_jar):
|
|
782
|
+
cookie_jar.update_cookies(
|
|
783
|
+
return_cookies, URL(f"https://{self.url}")
|
|
784
|
+
)
|
|
785
|
+
serialized_cookie_jar = _serialize_cookie_jar(cookie_jar)
|
|
786
|
+
async with aiofiles.open(
|
|
787
|
+
self._cookiefile[0], mode="w"
|
|
788
|
+
) as localfile:
|
|
789
|
+
await localfile.write(dumps(serialized_cookie_jar))
|
|
790
|
+
for legacy_cookiefile in self._cookiefile[1:]:
|
|
791
|
+
if await aioos.path.exists(legacy_cookiefile):
|
|
792
|
+
await delete_cookie(legacy_cookiefile)
|
|
793
|
+
except (OSError, EOFError, TypeError, AttributeError) as ex:
|
|
794
|
+
_LOGGER.debug(
|
|
795
|
+
"Error migrating cookie from %s to %s: %s",
|
|
796
|
+
cookiefile.replace(self.email, hide_email(self.email)),
|
|
797
|
+
self._cookiefile[0].replace(
|
|
798
|
+
self.email,
|
|
799
|
+
hide_email(self.email),
|
|
800
|
+
),
|
|
801
|
+
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
802
|
+
)
|
|
803
|
+
break
|
|
804
|
+
|
|
467
805
|
return return_cookies
|
|
468
806
|
|
|
469
807
|
async def close(self) -> None:
|
|
@@ -831,6 +1169,7 @@ class AlexaLogin:
|
|
|
831
1169
|
async def save_cookiefile(self) -> None:
|
|
832
1170
|
"""Save login session cookies to file."""
|
|
833
1171
|
self._cookiefile = [
|
|
1172
|
+
self._outputpath(f".storage/{self._hass_domain}.{self.email}.cookies"),
|
|
834
1173
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.pickle"),
|
|
835
1174
|
self._outputpath(f"{self._hass_domain}.{self.email}.pickle"),
|
|
836
1175
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.txt"),
|
|
@@ -841,14 +1180,15 @@ class AlexaLogin:
|
|
|
841
1180
|
assert isinstance(cookie_jar, aiohttp.CookieJar)
|
|
842
1181
|
if self._debug:
|
|
843
1182
|
_LOGGER.debug("Saving cookie to %s", cookiefile)
|
|
844
|
-
loop = asyncio.get_event_loop()
|
|
845
1183
|
try:
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1184
|
+
serialized_cookie_jar = _serialize_cookie_jar(cookie_jar)
|
|
1185
|
+
async with aiofiles.open(
|
|
1186
|
+
self._cookiefile[0], mode="w"
|
|
1187
|
+
) as localfile:
|
|
1188
|
+
await localfile.write(dumps(serialized_cookie_jar))
|
|
849
1189
|
except (OSError, EOFError, TypeError, AttributeError) as ex:
|
|
850
1190
|
_LOGGER.debug(
|
|
851
|
-
"Error saving
|
|
1191
|
+
"Error saving serialized cookie to %s: %s",
|
|
852
1192
|
self._cookiefile[0].replace(self.email, hide_email(self.email)),
|
|
853
1193
|
EXCEPTION_TEMPLATE.format(type(ex).__name__, ex.args),
|
|
854
1194
|
)
|
|
@@ -864,6 +1204,7 @@ class AlexaLogin:
|
|
|
864
1204
|
async def delete_cookiefile(self) -> None:
|
|
865
1205
|
"""Delete cookiefile."""
|
|
866
1206
|
self._cookiefile = [
|
|
1207
|
+
self._outputpath(f".storage/{self._hass_domain}.{self.email}.cookies"),
|
|
867
1208
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.pickle"),
|
|
868
1209
|
self._outputpath(f"{self._hass_domain}.{self.email}.pickle"),
|
|
869
1210
|
self._outputpath(f".storage/{self._hass_domain}.{self.email}.txt"),
|
|
@@ -13,6 +13,14 @@ EXCEPTION_TEMPLATE = "An exception of type {0} occurred. Arguments:\n{1!r}"
|
|
|
13
13
|
CALL_VERSION = "2.2.556530.0"
|
|
14
14
|
APP_NAME = "Alexa Media Player"
|
|
15
15
|
|
|
16
|
+
# Cookie persistence format used by alexapy.
|
|
17
|
+
#
|
|
18
|
+
# This is intentionally owned by alexapy rather than relying on aiohttp
|
|
19
|
+
# CookieJar.save/load internals, which can change across aiohttp/Python
|
|
20
|
+
# versions and may not support newly introduced cookie attributes.
|
|
21
|
+
COOKIE_SERIALIZATION_FORMAT = "alexapy.cookies"
|
|
22
|
+
COOKIE_SERIALIZATION_VERSION = 1
|
|
23
|
+
|
|
16
24
|
# REST-style Alexa API hosts (for things like /api/notifications)
|
|
17
25
|
ALEXA_API_NA = "https://na-api-alexa.amazon.com"
|
|
18
26
|
ALEXA_API_EU = "https://eu-api-alexa.amazon.com"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|