@camstack/addon-import-alexa 0.2.174 → 0.2.176

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-import-alexa",
3
- "version": "0.2.174",
3
+ "version": "0.2.176",
4
4
  "description": "Alexa device-import provider for CamStack — imports the smart-home devices in a user's Alexa account via the unofficial alexa-remote2 cookie/token client (the inverse of the Alexa exporter)",
5
5
  "keywords": [
6
6
  "camstack",
@@ -68,7 +68,7 @@
68
68
  }
69
69
  ],
70
70
  "requires": {
71
- "server": ">=1.2.719"
71
+ "server": ">=1.2.729"
72
72
  }
73
73
  },
74
74
  "files": [
@@ -69,6 +69,9 @@ def normalize_device(raw: dict) -> dict | None:
69
69
  return result
70
70
 
71
71
 
72
+ # The skill alexapy itself names for an AlexaAnnouncement node.
73
+ ANNOUNCEMENT_SKILL_ID = "amzn1.ask.1p.routines.messaging"
74
+
72
75
  class AlexaPyGateway:
73
76
  """Holds one AlexaLogin for the life of the process (one process per broker)."""
74
77
 
@@ -119,14 +122,32 @@ class AlexaPyGateway:
119
122
  bare_domain(domain), label, "", session_output(session_path), False, oauth=oauth
120
123
  )
121
124
  self._login = login
125
+ cookies: dict = {}
122
126
  if refresh_token != "":
123
127
  exchanged = await _exchange_refresh_token(login, device_app_name)
124
128
  if not exchanged:
125
- return {"status": "failed", "error": "Amazon did not accept the broker's token"}
129
+ return {
130
+ "status": "failed",
131
+ "error": "step 1 of 3, token exchange: Amazon refused the broker's token",
132
+ }
126
133
  await login.save_cookiefile()
127
- cookies = await login.load_cookie()
128
- if not cookies:
129
- return {"status": "failed", "error": "no Amazon session to start from"}
134
+ # The exchange put its cookies in the live session; use them as they are. Reading
135
+ # them back through the cookie file filters them by host and can come back empty.
136
+ cookies = _session_cookies(login)
137
+ if not cookies:
138
+ cookies = await login.load_cookie() or {}
139
+ if not cookies:
140
+ return {
141
+ "status": "failed",
142
+ "error": "step 2 of 3, cookies: the token exchange was accepted but returned no cookies",
143
+ }
144
+ else:
145
+ cookies = await login.load_cookie() or {}
146
+ if not cookies:
147
+ return {
148
+ "status": "failed",
149
+ "error": "step 2 of 3, cookies: no stored Amazon session and no broker token to start from",
150
+ }
130
151
  await login.login(cookies=cookies)
131
152
  await self._persist_session()
132
153
  return self._status_result()
@@ -159,13 +180,28 @@ class AlexaPyGateway:
159
180
  async def speak(self, sequence: dict, customer_id: str) -> None:
160
181
  login = self._require_login()
161
182
  _AlexaLogin, AlexaAPI = _import_alexapy()
162
- await AlexaAPI.send_sequence(login, sequence, customer_id, None)
183
+ # alexapy 1.29.20: send_sequence/send_announcement are INSTANCE methods that read the
184
+ # device behind the API object (type, serial, locale). Our payloads already carry the
185
+ # target, so run the prebuilt node through run_behavior, which needs only the login.
186
+ # queue_delay=0 posts at once instead of batching with other queued nodes.
187
+ await AlexaAPI(None, login).run_behavior(sequence["startNode"], queue_delay=0)
163
188
  self._touch_success()
164
189
 
165
190
  async def announce(self, announcement: dict, customer_id: str) -> None:
166
191
  login = self._require_login()
167
192
  _AlexaLogin, AlexaAPI = _import_alexapy()
168
- await AlexaAPI.send_announcement(login, announcement, customer_id, None)
193
+ node = {
194
+ "@type": "com.amazon.alexa.behaviors.model.OpaquePayloadOperationNode",
195
+ "type": "AlexaAnnouncement",
196
+ "operationPayload": {
197
+ "expireAfter": announcement["expireAfter"],
198
+ "content": announcement["content"],
199
+ "target": announcement["target"],
200
+ "customerId": customer_id,
201
+ "skillId": ANNOUNCEMENT_SKILL_ID,
202
+ },
203
+ }
204
+ await AlexaAPI(None, login).run_behavior(node, queue_delay=0)
169
205
  self._touch_success()
170
206
 
171
207
  async def session_status(self) -> dict:
@@ -213,6 +249,22 @@ def _import_alexapy():
213
249
  return AlexaLogin, AlexaAPI
214
250
 
215
251
 
252
+ def _session_cookies(login) -> dict:
253
+ """Every cookie the login's live session holds, by name. Values only leave this process
254
+ inside the library; nothing here logs them."""
255
+ session = getattr(login, "_session", None)
256
+ jar = getattr(session, "cookie_jar", None)
257
+ found: dict = {}
258
+ if jar is None:
259
+ return found
260
+ for morsel in jar:
261
+ name = getattr(morsel, "key", None)
262
+ value = getattr(morsel, "value", None)
263
+ if isinstance(name, str) and isinstance(value, str) and name != "":
264
+ found[name] = value.strip('"')
265
+ return found
266
+
267
+
216
268
  async def _exchange_refresh_token(login, device_app_name: str) -> bool:
217
269
  """Trade the broker's refresh token for cookies, under the name that token was registered with."""
218
270
  import alexapy.alexalogin as module
@@ -19,7 +19,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
19
  from alexa_gateway import AlexaPyGateway, bare_domain, normalize_device, session_output
20
20
 
21
21
 
22
- def install_fake_alexapy(exchange_ok: bool = True, stored_cookies: dict | None = None):
22
+ def install_fake_alexapy(
23
+ exchange_ok: bool = True, stored_cookies: dict | None = None, file_readback: bool = True
24
+ ):
23
25
  """Put a fake `alexapy` (and `alexapy.alexalogin`) in sys.modules; return the call log."""
24
26
  calls: dict = {"exchange_app_names": [], "logins": [], "saved": 0}
25
27
  module = types.ModuleType("alexapy.alexalogin")
@@ -34,10 +36,13 @@ def install_fake_alexapy(exchange_ok: bool = True, stored_cookies: dict | None =
34
36
  calls["jar_path"] = outputpath(".storage/alexa_media.x.pickle")
35
37
  self.status = {"login_successful": True}
36
38
  self.refresh_token = (oauth or {}).get("refresh_token")
39
+ self._session = types.SimpleNamespace(cookie_jar=[])
37
40
 
38
41
  async def exchange_token_for_cookies(self):
39
42
  calls["exchange_app_names"].append(module.APP_NAME)
40
43
  calls["exchanged_token"] = self.refresh_token
44
+ if exchange_ok:
45
+ self._session.cookie_jar.append(types.SimpleNamespace(key="session-id", value="1"))
41
46
  return exchange_ok
42
47
 
43
48
  async def save_cookiefile(self):
@@ -45,7 +50,9 @@ def install_fake_alexapy(exchange_ok: bool = True, stored_cookies: dict | None =
45
50
  Path(calls["jar_path"]).write_bytes(b"jar")
46
51
 
47
52
  async def load_cookie(self):
48
- return {"session-id": "1"} if (exchange_ok or stored_cookies) else {}
53
+ if stored_cookies:
54
+ return stored_cookies
55
+ return {"session-id": "1"} if (exchange_ok and file_readback) else {}
49
56
 
50
57
  async def login(self, cookies=None, data=None):
51
58
  calls["logins"].append(cookies)
@@ -68,6 +75,10 @@ def run(coro):
68
75
  return asyncio.run(coro)
69
76
 
70
77
 
78
+ async def _accept_without_cookies(self):
79
+ return True
80
+
81
+
71
82
  class GatewayTests(unittest.TestCase):
72
83
  def tearDown(self) -> None:
73
84
  remove_fake_alexapy()
@@ -113,6 +124,27 @@ class GatewayTests(unittest.TestCase):
113
124
  self.assertEqual(calls["logins"], [])
114
125
  self.assertEqual(module.APP_NAME, "Alexa Media Player")
115
126
 
127
+ def test_cookies_from_the_exchange_are_used_when_the_file_reads_back_empty(self) -> None:
128
+ calls, _ = install_fake_alexapy(file_readback=False)
129
+ with tempfile.TemporaryDirectory() as directory:
130
+ result = run(
131
+ AlexaPyGateway().login_broker("alexa_002", "t", "", "amazon.com", "NA", str(Path(directory) / "s"))
132
+ )
133
+ self.assertEqual(result, {"status": "ok"})
134
+ self.assertEqual(calls["logins"], [{"session-id": "1"}])
135
+
136
+ def test_an_exchange_that_yields_no_cookies_names_its_step(self) -> None:
137
+ calls, _ = install_fake_alexapy(file_readback=False)
138
+ sys.modules["alexapy"].AlexaLogin.exchange_token_for_cookies = _accept_without_cookies
139
+ with tempfile.TemporaryDirectory() as directory:
140
+ result = run(
141
+ AlexaPyGateway().login_broker("alexa_002", "t", "", "amazon.com", "NA", str(Path(directory) / "s"))
142
+ )
143
+ self.assertEqual(result["status"], "failed")
144
+ self.assertIn("cookies", result["error"])
145
+ self.assertIn("exchange was accepted", result["error"])
146
+ self.assertEqual(calls["logins"], [])
147
+
116
148
  def test_with_no_token_only_the_stored_jar_is_tried(self) -> None:
117
149
  calls, _ = install_fake_alexapy(stored_cookies={"session-id": "1"})
118
150
  with tempfile.TemporaryDirectory() as directory:
@@ -0,0 +1,83 @@
1
+ """speak/announce must call alexapy the way alexapy 1.29.20 defines it.
2
+
3
+ In that release send_sequence / send_announcement are INSTANCE methods that read
4
+ `self._device`; calling them on the class with a login first (the old code) fails
5
+ with "'AlexaLogin' object has no attribute '_device'". The fake below mirrors the
6
+ real signatures, so the wrong call shape raises the same way.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import sys
13
+ import unittest
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
17
+
18
+ from alexa_gateway import AlexaPyGateway
19
+ from alexa_sequences import build_announcement, build_speak_sequence
20
+
21
+ TARGET = {"id": "G090XX", "deviceType": "A3S5BH2HU6VAYF", "customerId": "A1CUST"}
22
+
23
+
24
+ def make_fake_api(posted: list):
25
+ class FakeAlexaAPI:
26
+ def __init__(self, device, login):
27
+ self._device = device
28
+ self._login = login
29
+
30
+ async def run_behavior(self, node_data, queue_delay: float = 1.5) -> None:
31
+ posted.append((node_data, queue_delay))
32
+
33
+ # Real signatures: the first positional parameter is `self`, then message/sequence.
34
+ async def send_sequence(self, sequence, customer_id=None, queue_delay=1.5, extra=None, **kwargs):
35
+ self._device._device_type # raises AttributeError without a device, as the real one
36
+
37
+ async def send_announcement(self, message, method="all", title="Announcement",
38
+ customer_id=None, targets=None, queue_delay=1.5, extra=None):
39
+ self._device._locale
40
+
41
+ return FakeAlexaAPI
42
+
43
+
44
+ class SendTests(unittest.TestCase):
45
+ def _gateway(self, posted):
46
+ import types
47
+
48
+ package = types.ModuleType("alexapy")
49
+ package.AlexaLogin = object
50
+ package.AlexaAPI = make_fake_api(posted)
51
+ sys.modules["alexapy"] = package
52
+ sys.modules["alexapy.alexalogin"] = types.ModuleType("alexapy.alexalogin")
53
+ gateway = AlexaPyGateway()
54
+ gateway._login = object()
55
+ return gateway
56
+
57
+ def tearDown(self):
58
+ sys.modules.pop("alexapy", None)
59
+ sys.modules.pop("alexapy.alexalogin", None)
60
+
61
+ def test_speak_posts_the_sequence_start_node_immediately(self):
62
+ posted: list = []
63
+ sequence = build_speak_sequence(TARGET, "hello", 30, "it-IT")
64
+ asyncio.run(self._gateway(posted).speak(sequence, "A1CUST"))
65
+ self.assertEqual(posted, [(sequence["startNode"], 0)])
66
+
67
+ def test_announce_posts_an_announcement_node_with_target_and_content(self):
68
+ posted: list = []
69
+ announcement = build_announcement(TARGET, "hello", "it-IT")
70
+ asyncio.run(self._gateway(posted).announce(announcement, "A1CUST"))
71
+ self.assertEqual(len(posted), 1)
72
+ node, delay = posted[0]
73
+ self.assertEqual(delay, 0)
74
+ self.assertEqual(node["type"], "AlexaAnnouncement")
75
+ payload = node["operationPayload"]
76
+ self.assertEqual(payload["target"], announcement["target"])
77
+ self.assertEqual(payload["content"], announcement["content"])
78
+ self.assertEqual(payload["customerId"], "A1CUST")
79
+ self.assertEqual(payload["skillId"], "amzn1.ask.1p.routines.messaging")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ unittest.main()