@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/CHANGELOG.md +13 -0
- package/dist/addon.js +451 -157
- package/dist/addon.mjs +451 -157
- package/package.json +2 -2
- package/python/alexa_gateway.py +58 -6
- package/python/test_alexa_gateway.py +34 -2
- package/python/test_alexa_gateway_send.py +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-import-alexa",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
71
|
+
"server": ">=1.2.729"
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
"files": [
|
package/python/alexa_gateway.py
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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()
|