iobroker.anthbot 0.0.2
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/LICENSE +21 -0
- package/README.md +46 -0
- package/admin/anthbot.png +0 -0
- package/admin/i18n/de.json +5 -0
- package/admin/i18n/en.json +5 -0
- package/admin/i18n/es.json +5 -0
- package/admin/i18n/fr.json +5 -0
- package/admin/i18n/it.json +5 -0
- package/admin/i18n/nl.json +5 -0
- package/admin/i18n/pl.json +5 -0
- package/admin/i18n/pt.json +5 -0
- package/admin/i18n/ru.json +5 -0
- package/admin/i18n/uk.json +5 -0
- package/admin/i18n/zh-cn.json +5 -0
- package/admin/jsonConfig.json +74 -0
- package/io-package.json +107 -0
- package/lib/adapter-config.d.ts +19 -0
- package/lib/anthbotApi.js +885 -0
- package/main.js +765 -0
- package/package.json +70 -0
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API client for Anthbot Genie cloud polling
|
|
3
|
+
* NodeJS port of the Python api.py module by @vincentjanv...
|
|
4
|
+
* https://github.com/vincentjanv/anthbot_genie_ha
|
|
5
|
+
* ... with a few addions/changes of course ;)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { Buffer } = require('node:buffer');
|
|
9
|
+
const crypto = require('node:crypto');
|
|
10
|
+
const zlib = require('node:zlib');
|
|
11
|
+
|
|
12
|
+
const tarStream = require('tar-stream');
|
|
13
|
+
const { URLSearchParams } = require('url');
|
|
14
|
+
|
|
15
|
+
// Constants
|
|
16
|
+
const DEFAULT_API_HOST = 'api.anthbot.com';
|
|
17
|
+
const DEFAULT_IOT_REGION = 'us-east-1';
|
|
18
|
+
const DEFAULT_IOT_ENDPOINT = 'a2bhy9nr7jkgaj-ats.iot.us-east-1.amazonaws.com';
|
|
19
|
+
const CN_NORTHWEST_IOT_ENDPOINT = 'a2iw0czxjowiip-ats.iot.cn-northwest-1.amazonaws.com.cn';
|
|
20
|
+
|
|
21
|
+
const AWS_ACCESS_KEY_DEFAULT = 'AKIAV2C4RVIAOLEXB545';
|
|
22
|
+
const AWS_SECRET_KEY_DEFAULT = 'ZYE0HGBogztfOrU2R4m1bKckcwjCKZ+4tpHh8cIi';
|
|
23
|
+
|
|
24
|
+
const AWS_ACCESS_KEY_CN = 'AKIAWJ3KIT7IV6AHMJ5V';
|
|
25
|
+
const AWS_SECRET_KEY_CN = '9uqNfRASNsjjjxAR6HG9Nby18gehRnoV9/87amA3';
|
|
26
|
+
|
|
27
|
+
const AWS_ACCESS_KEY_CN_NORTHWEST = 'AKIAYVWVSSRF7W5YWI74';
|
|
28
|
+
const AWS_SECRET_KEY_CN_NORTHWEST = 'MPQhRjYNUoYP8grS9zkxtfNmH8SAY/5wk9BJLtEw';
|
|
29
|
+
|
|
30
|
+
const CLIENT_USER_AGENT = 'LdMower/1581 CFNetwork/3860.400.51 Darwin/25.3.0';
|
|
31
|
+
|
|
32
|
+
const REQUEST_TIMEOUT = 15000; // 15 seconds
|
|
33
|
+
|
|
34
|
+
// Shared methods
|
|
35
|
+
class AnthotCloudApi {
|
|
36
|
+
/**
|
|
37
|
+
* @param {{ verboseLogger?: ((message: string) => void) | null}} options
|
|
38
|
+
*/
|
|
39
|
+
constructor({ verboseLogger = null }) {
|
|
40
|
+
this.verboseLogger = verboseLogger;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async fetch(url, options = {}) {
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const timeout = REQUEST_TIMEOUT;
|
|
46
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
47
|
+
|
|
48
|
+
if (this.verboseLogger) {
|
|
49
|
+
this.verboseLogger(`[VERBOSE] >>> ${options.method || 'GET'} ${url}`);
|
|
50
|
+
if (options.headers) {
|
|
51
|
+
this.verboseLogger(`[VERBOSE] Headers: ${JSON.stringify(options.headers, null, 2)}`);
|
|
52
|
+
}
|
|
53
|
+
if (options.body) {
|
|
54
|
+
this.verboseLogger(`[VERBOSE] Body: ${options.body}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const response = await fetch(url, {
|
|
60
|
+
...options,
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (this.verboseLogger) {
|
|
65
|
+
this.verboseLogger(`[VERBOSE] <<< ${response.status} ${response.statusText}`);
|
|
66
|
+
this.verboseLogger(
|
|
67
|
+
`[VERBOSE] Response Headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2)}`,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Clone response to read body without consuming original
|
|
71
|
+
const responseClone = response.clone();
|
|
72
|
+
try {
|
|
73
|
+
const contentType = responseClone.headers.get('content-type') || '';
|
|
74
|
+
if (contentType.includes('application/json') || contentType.startsWith('text/')) {
|
|
75
|
+
const responseBody = await responseClone.text();
|
|
76
|
+
if (responseBody) {
|
|
77
|
+
try {
|
|
78
|
+
const jsonBody = JSON.parse(responseBody);
|
|
79
|
+
this.verboseLogger(`[VERBOSE] Response Body: ${JSON.stringify(jsonBody, null, 2)}`);
|
|
80
|
+
} catch {
|
|
81
|
+
const maxLoggingLength = 512;
|
|
82
|
+
if (responseBody.length > maxLoggingLength) {
|
|
83
|
+
this.verboseLogger(
|
|
84
|
+
`[VERBOSE] Truncated body: ${responseBody.substring(0, maxLoggingLength)}...`,
|
|
85
|
+
);
|
|
86
|
+
} else {
|
|
87
|
+
this.verboseLogger(`[VERBOSE] Response body: ${responseBody}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
const contentLength = responseClone.headers.get('content-length');
|
|
93
|
+
if (contentLength) {
|
|
94
|
+
this.verboseLogger(`[VERBOSE] Binary response body of ${contentLength} bytes`);
|
|
95
|
+
} else {
|
|
96
|
+
this.verboseLogger(`[VERBOSE] Binary response body`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
this.verboseLogger(`[VERBOSE] Could not read response body: ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return response;
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err.name === 'AbortError') {
|
|
107
|
+
throw new Error('Request timed out');
|
|
108
|
+
}
|
|
109
|
+
throw err;
|
|
110
|
+
} finally {
|
|
111
|
+
clearTimeout(timeoutId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Client for Anthbot cloud account endpoints
|
|
118
|
+
*/
|
|
119
|
+
class AnthbotCloudApiClient {
|
|
120
|
+
/** @param {{ verboseLogger?: ((message: string) => void) | null }} options */
|
|
121
|
+
constructor({ verboseLogger = null }) {
|
|
122
|
+
this.endpointHost = DEFAULT_API_HOST;
|
|
123
|
+
this.authHeaders = {
|
|
124
|
+
Accept: 'application/json, text/plain, */*',
|
|
125
|
+
version: 'v2',
|
|
126
|
+
language: 'en',
|
|
127
|
+
'User-Agent': CLIENT_USER_AGENT,
|
|
128
|
+
};
|
|
129
|
+
this.bearerToken = null;
|
|
130
|
+
this.verboseLogger = verboseLogger;
|
|
131
|
+
this.fetch = new AnthotCloudApi({ verboseLogger }).fetch;
|
|
132
|
+
this.shadowClient = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Login and return bearer token
|
|
137
|
+
*/
|
|
138
|
+
async asyncLogin({ username, password, areaCode }) {
|
|
139
|
+
const url = `https://${this.endpointHost}/api/v1/login`;
|
|
140
|
+
const headers = {
|
|
141
|
+
Accept: 'application/json, text/plain, */*',
|
|
142
|
+
'content-type': 'application/json',
|
|
143
|
+
version: 'v2',
|
|
144
|
+
language: 'en',
|
|
145
|
+
'User-Agent': CLIENT_USER_AGENT,
|
|
146
|
+
};
|
|
147
|
+
const body = { username, password, areaCode };
|
|
148
|
+
|
|
149
|
+
const response = await this.fetch(url, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers,
|
|
152
|
+
body: JSON.stringify(body),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (response.status !== 200) {
|
|
156
|
+
throw new Error(`Login failed (${response.status})`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let data;
|
|
160
|
+
try {
|
|
161
|
+
data = /** @type {{ code: number; data: { access_token: string } }} */ (await response.json());
|
|
162
|
+
} catch {
|
|
163
|
+
throw new Error('Invalid JSON response from login');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (typeof data !== 'object' || data === null) {
|
|
167
|
+
throw new Error('Invalid login payload type');
|
|
168
|
+
}
|
|
169
|
+
if (data.code !== 0) {
|
|
170
|
+
throw new Error(`Login rejected: code=${JSON.stringify(data.code)}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const tokenData = data.data;
|
|
174
|
+
if (typeof tokenData !== 'object' || tokenData === null) {
|
|
175
|
+
throw new Error('Login payload missing data object');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const accessToken = tokenData.access_token;
|
|
179
|
+
if (typeof accessToken !== 'string' || !accessToken) {
|
|
180
|
+
throw new Error('Login payload missing access_token');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const bearerToken = `Bearer ${accessToken}`;
|
|
184
|
+
this.bearerToken = bearerToken;
|
|
185
|
+
this.authHeaders['Authorization'] = bearerToken;
|
|
186
|
+
return bearerToken;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
checkToken() {
|
|
190
|
+
if (!this.bearerToken) {
|
|
191
|
+
throw new Error('Bearer token not configured');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Fetch account-bound Anthbot devices
|
|
197
|
+
*/
|
|
198
|
+
async asyncGetBoundDevices() {
|
|
199
|
+
this.checkToken();
|
|
200
|
+
|
|
201
|
+
const url = `https://${this.endpointHost}/api/v1/device/bind/list`;
|
|
202
|
+
const response = await this.fetch(url, {
|
|
203
|
+
method: 'GET',
|
|
204
|
+
headers: this.authHeaders,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (response.status !== 200) {
|
|
208
|
+
throw new Error(`Request to ${url} failed, response ${response.status}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return /** @type {{ data: {alias: string, sn: string}[] }} */ (await response.json()).data;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Fetch latest messages
|
|
216
|
+
* Returns only last message by default
|
|
217
|
+
*/
|
|
218
|
+
async asyncGetCodeList(serialNumber, pageNum = 1, pageSize = 1) {
|
|
219
|
+
this.checkToken();
|
|
220
|
+
|
|
221
|
+
// TODO: allow language other than English?
|
|
222
|
+
const url = `https://${this.endpointHost}/api/v1/device/v2/code/list?sn=${serialNumber}&pagenum=${pageNum}&pagesize=${pageSize}&language=English`;
|
|
223
|
+
|
|
224
|
+
const response = await this.fetch(url, {
|
|
225
|
+
method: 'GET',
|
|
226
|
+
headers: this.authHeaders,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (response.status !== 200) {
|
|
230
|
+
throw new Error(`Request to ${url} failed, response ${response.status}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return /** @type {{ data: { data: unknown } }} */ (await response.json())?.data?.data;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
buildVerificationToken(serialNumber) {
|
|
237
|
+
const unixTimestamp = Math.floor(Date.now() / 1000);
|
|
238
|
+
const tokenSuffix = unixTimestamp.toString();
|
|
239
|
+
const tokenPrefix = crypto.createHash('md5').update(`${serialNumber}${tokenSuffix}`, 'utf8').digest('hex');
|
|
240
|
+
return `${tokenPrefix}${tokenSuffix}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Fetch account-bound Anthbot devices
|
|
245
|
+
*
|
|
246
|
+
* @param {string} sn
|
|
247
|
+
* @param {string} filename
|
|
248
|
+
* @param {string} category
|
|
249
|
+
* @param {string} sub_category
|
|
250
|
+
* @returns {Promise<string>} URL for the requested file
|
|
251
|
+
*/
|
|
252
|
+
async asyncGetPresignedUrl(sn, filename, category, sub_category) {
|
|
253
|
+
this.checkToken();
|
|
254
|
+
|
|
255
|
+
const params = new URLSearchParams({
|
|
256
|
+
filename,
|
|
257
|
+
sn,
|
|
258
|
+
category,
|
|
259
|
+
sub_category,
|
|
260
|
+
verification_token: this.buildVerificationToken(sn),
|
|
261
|
+
});
|
|
262
|
+
const url = `https://${this.endpointHost}/api/v1/device/v2/presigned_url?${params}`;
|
|
263
|
+
const response = await this.fetch(url, {
|
|
264
|
+
method: 'GET',
|
|
265
|
+
headers: this.authHeaders,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (response.status !== 200) {
|
|
269
|
+
throw new Error(`Request to ${url} failed, response ${response.status}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return /** @type {{ data: {presigned_url: string } }} */ (await response.json()).data?.presigned_url;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Decodes a TAR.GZ archive from a Buffer and returns an array of file entries with filename, type and content.
|
|
277
|
+
*
|
|
278
|
+
* @param {Buffer} buffer
|
|
279
|
+
* @returns {Promise<Array<{ filename: string; type: 'json' | 'blob'; content: unknown }>>}
|
|
280
|
+
*/
|
|
281
|
+
|
|
282
|
+
decodeTgzBuffer(buffer) {
|
|
283
|
+
return new Promise((resolve, reject) => {
|
|
284
|
+
const results = [];
|
|
285
|
+
const extract = tarStream.extract();
|
|
286
|
+
|
|
287
|
+
extract.on('entry', (header, stream, next) => {
|
|
288
|
+
if (this.verboseLogger) {
|
|
289
|
+
this.verboseLogger(`Processing entry: ${JSON.stringify(header)}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (header.type !== 'file') {
|
|
293
|
+
stream.resume();
|
|
294
|
+
return next();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const chunks = [];
|
|
298
|
+
stream.on('data', chunk => chunks.push(chunk));
|
|
299
|
+
stream.on('end', () => {
|
|
300
|
+
const fileBuffer = Buffer.concat(chunks);
|
|
301
|
+
const filename = header.name || '';
|
|
302
|
+
let entryType = 'blob';
|
|
303
|
+
let content = fileBuffer;
|
|
304
|
+
|
|
305
|
+
if (filename.toLowerCase().endsWith('.bin')) {
|
|
306
|
+
// Do nothing as entryType and content already set
|
|
307
|
+
} else if (filename.toLowerCase().endsWith('.json')) {
|
|
308
|
+
try {
|
|
309
|
+
content = JSON.parse(fileBuffer.toString());
|
|
310
|
+
entryType = 'json';
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (this.verboseLogger) {
|
|
313
|
+
this.verboseLogger(
|
|
314
|
+
`Failed to parse JSON for ${filename}: ${err.message}; leaving as binary`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
if (this.verboseLogger) {
|
|
320
|
+
this.verboseLogger(`Unknown archive file type for ${filename}; leaving as binary`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
results[filename] = { type: entryType, content };
|
|
325
|
+
next();
|
|
326
|
+
});
|
|
327
|
+
stream.on('error', reject);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
extract.on('finish', () => resolve(results));
|
|
331
|
+
extract.on('error', reject);
|
|
332
|
+
|
|
333
|
+
const gunzip = zlib.createGunzip();
|
|
334
|
+
gunzip.on('error', reject);
|
|
335
|
+
gunzip.pipe(extract);
|
|
336
|
+
gunzip.end(buffer);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async asyncGetDeviceMap(serialNumber) {
|
|
341
|
+
const url = await this.asyncGetPresignedUrl(
|
|
342
|
+
serialNumber,
|
|
343
|
+
`map_manager_${serialNumber}.tar.gz`,
|
|
344
|
+
'device',
|
|
345
|
+
'map',
|
|
346
|
+
);
|
|
347
|
+
const response = await this.fetch(url, {
|
|
348
|
+
method: 'GET',
|
|
349
|
+
headers: {
|
|
350
|
+
Accept: 'application/gzip, application/octet-stream, */*',
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
if (response.status !== 200) {
|
|
355
|
+
throw new Error(`Device map download failed (${response.status})`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return await this.decodeTgzBuffer(Buffer.from(await response.arrayBuffer()));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Fetch device cloud region metadata
|
|
363
|
+
*/
|
|
364
|
+
async asyncGetDeviceRegion(serialNumber) {
|
|
365
|
+
if (this.verboseLogger) {
|
|
366
|
+
this.verboseLogger(`Cache miss - fetching device region for ${serialNumber}`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
this.checkToken();
|
|
370
|
+
|
|
371
|
+
const url = `https://${this.endpointHost}/api/v1/device/v2/region`;
|
|
372
|
+
const params = new URLSearchParams({ sn: serialNumber });
|
|
373
|
+
const response = await this.fetch(`${url}?${params}`, {
|
|
374
|
+
method: 'GET',
|
|
375
|
+
headers: this.authHeaders,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
if (response.status !== 200) {
|
|
379
|
+
const body = await response.text();
|
|
380
|
+
throw new Error(`Device region failed (${response.status}): ${body.slice(0, 300)}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let payload;
|
|
384
|
+
try {
|
|
385
|
+
payload = /** @type {{ code: number; data: {region_name: string; iot_endpoint: string } }} */ (
|
|
386
|
+
await response.json()
|
|
387
|
+
);
|
|
388
|
+
} catch {
|
|
389
|
+
throw new Error('Invalid JSON response from device region');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
393
|
+
throw new Error('Invalid device region payload type');
|
|
394
|
+
}
|
|
395
|
+
if (payload.code !== 0) {
|
|
396
|
+
throw new Error(`Device region returned code=${payload.code}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const data = payload.data;
|
|
400
|
+
if (typeof data !== 'object' || data === null) {
|
|
401
|
+
throw new Error('Device region payload missing data object');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const regionName = data.region_name;
|
|
405
|
+
const iotEndpoint = data.iot_endpoint;
|
|
406
|
+
if (typeof regionName !== 'string' || !regionName) {
|
|
407
|
+
throw new Error('Device region missing region_name');
|
|
408
|
+
}
|
|
409
|
+
if (typeof iotEndpoint !== 'string' || !iotEndpoint) {
|
|
410
|
+
throw new Error('Device region missing iot_endpoint');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const deviceRegion = { regionName, iotEndpoint };
|
|
414
|
+
if (this.verboseLogger) {
|
|
415
|
+
this.verboseLogger(`deviceRegion for ${serialNumber}: ${JSON.stringify(deviceRegion)}`);
|
|
416
|
+
}
|
|
417
|
+
return deviceRegion;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async checkShadowClient(serialNumber) {
|
|
421
|
+
// TODO: Handle multiple devices with different shadow clients instead of caching just one
|
|
422
|
+
if (!this.shadowClient) {
|
|
423
|
+
if (this.verboseLogger) {
|
|
424
|
+
this.verboseLogger(`No shadow client for ${serialNumber}, creating one`);
|
|
425
|
+
}
|
|
426
|
+
const deviceRegion = await this.asyncGetDeviceRegion(serialNumber);
|
|
427
|
+
this.shadowClient = new AnthbotShadowApiClient({
|
|
428
|
+
serialNumber,
|
|
429
|
+
regionName: deviceRegion.regionName,
|
|
430
|
+
iotEndpoint: deviceRegion.iotEndpoint,
|
|
431
|
+
verboseLogger: this.verboseLogger,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async asyncGetShadowReportedState(serialNumber) {
|
|
437
|
+
await this.checkShadowClient(serialNumber);
|
|
438
|
+
return await this.shadowClient?.asyncGetShadowReportedState();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async asyncSendServiceCommand(serialNumber, cmd, data) {
|
|
442
|
+
await this.checkShadowClient(serialNumber);
|
|
443
|
+
return await this.shadowClient?.asyncPublishServiceCommand({ cmd, data });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Client for Anthbot AWS IoT shadow endpoint
|
|
449
|
+
*/
|
|
450
|
+
class AnthbotShadowApiClient {
|
|
451
|
+
/**
|
|
452
|
+
* @param {{serialNumber: string, regionName?: string | null, iotEndpoint?: string | null, verboseLogger?: ((message: string) => void) | null}} options
|
|
453
|
+
*/
|
|
454
|
+
constructor({ serialNumber, regionName = null, iotEndpoint = null, verboseLogger = null }) {
|
|
455
|
+
this._serialNumber = serialNumber;
|
|
456
|
+
this._regionName = typeof regionName === 'string' && regionName ? regionName : null;
|
|
457
|
+
this._iotEndpoint = AnthbotShadowApiClient._normalizeEndpoint(iotEndpoint);
|
|
458
|
+
this.verboseLogger = verboseLogger;
|
|
459
|
+
this.fetch = new AnthotCloudApi({ verboseLogger }).fetch;
|
|
460
|
+
|
|
461
|
+
const endpointRegion = AnthbotShadowApiClient._guessRegionFromEndpoint(this._iotEndpoint);
|
|
462
|
+
if (this._regionName && endpointRegion && this._regionName !== endpointRegion) {
|
|
463
|
+
if (this.verboseLogger) {
|
|
464
|
+
this.verboseLogger(
|
|
465
|
+
`Anthbot region mismatch for ${serialNumber}: api region=${this._regionName} endpoint region=${endpointRegion} endpoint=${this._iotEndpoint}; endpoint region will be used for signing`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
static _normalizeEndpoint(iotEndpoint) {
|
|
472
|
+
if (typeof iotEndpoint !== 'string' || !iotEndpoint) {
|
|
473
|
+
return DEFAULT_IOT_ENDPOINT;
|
|
474
|
+
}
|
|
475
|
+
let endpoint = iotEndpoint.trim();
|
|
476
|
+
endpoint = endpoint.replace(/^https?:\/\//i, '');
|
|
477
|
+
endpoint = endpoint.replace(/\/$/, '');
|
|
478
|
+
return endpoint || DEFAULT_IOT_ENDPOINT;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
static _guessRegionFromEndpoint(iotEndpoint) {
|
|
482
|
+
if (!iotEndpoint.includes('.iot.')) {
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
const rightSide = iotEndpoint.split('.iot.', 2)[1];
|
|
486
|
+
const region = rightSide.split('.', 1)[0];
|
|
487
|
+
return region || null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
static guessRegionFromEndpoint(iotEndpoint) {
|
|
491
|
+
return AnthbotShadowApiClient._guessRegionFromEndpoint(iotEndpoint);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
get serialNumber() {
|
|
495
|
+
return this._serialNumber;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
get iotEndpoint() {
|
|
499
|
+
return this._iotEndpoint;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
get signingRegion() {
|
|
503
|
+
const endpointRegion = AnthbotShadowApiClient._guessRegionFromEndpoint(this._iotEndpoint);
|
|
504
|
+
if (endpointRegion) {
|
|
505
|
+
return endpointRegion;
|
|
506
|
+
}
|
|
507
|
+
return this._regionName || DEFAULT_IOT_REGION;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
_accessKeyId() {
|
|
511
|
+
if (this._iotEndpoint === CN_NORTHWEST_IOT_ENDPOINT) {
|
|
512
|
+
return AWS_ACCESS_KEY_CN_NORTHWEST;
|
|
513
|
+
}
|
|
514
|
+
if (this.signingRegion.startsWith('cn')) {
|
|
515
|
+
return AWS_ACCESS_KEY_CN;
|
|
516
|
+
}
|
|
517
|
+
return AWS_ACCESS_KEY_DEFAULT;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
_secretAccessKey() {
|
|
521
|
+
if (this._iotEndpoint === CN_NORTHWEST_IOT_ENDPOINT) {
|
|
522
|
+
return AWS_SECRET_KEY_CN_NORTHWEST;
|
|
523
|
+
}
|
|
524
|
+
if (this.signingRegion.startsWith('cn')) {
|
|
525
|
+
return AWS_SECRET_KEY_CN;
|
|
526
|
+
}
|
|
527
|
+
return AWS_SECRET_KEY_DEFAULT;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
static _sign(key, msg) {
|
|
531
|
+
if (typeof key === 'string') {
|
|
532
|
+
key = Buffer.from(key, 'utf-8');
|
|
533
|
+
}
|
|
534
|
+
return crypto.createHmac('sha256', key).update(msg).digest();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
_signingKey(dateStamp) {
|
|
538
|
+
const service = 'iotdata';
|
|
539
|
+
const kDate = AnthbotShadowApiClient._sign(`AWS4${this._secretAccessKey()}`, dateStamp);
|
|
540
|
+
const kRegion = AnthbotShadowApiClient._sign(kDate, this.signingRegion);
|
|
541
|
+
const kService = AnthbotShadowApiClient._sign(kRegion, service);
|
|
542
|
+
return AnthbotShadowApiClient._sign(kService, 'aws4_request');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
_buildAuthorization(amzDate, dateStamp, canonicalRequest) {
|
|
546
|
+
const algorithm = 'AWS4-HMAC-SHA256';
|
|
547
|
+
const signedHeaders = AnthbotShadowApiClient._signedHeadersFromRequest(canonicalRequest);
|
|
548
|
+
const credentialScope = `${dateStamp}/${this.signingRegion}/iotdata/aws4_request`;
|
|
549
|
+
const stringToSign =
|
|
550
|
+
`${algorithm}\n` +
|
|
551
|
+
`${amzDate}\n` +
|
|
552
|
+
`${credentialScope}\n` +
|
|
553
|
+
crypto.createHash('sha256').update(canonicalRequest).digest('hex');
|
|
554
|
+
|
|
555
|
+
const signature = crypto.createHmac('sha256', this._signingKey(dateStamp)).update(stringToSign).digest('hex');
|
|
556
|
+
|
|
557
|
+
return (
|
|
558
|
+
`${algorithm} Credential=${this._accessKeyId()}/${credentialScope}, ` +
|
|
559
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
static _normalizeHeaderValue(value) {
|
|
564
|
+
return value.trim().split(/\s+/).join(' ');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
static _canonicalHeaders(headers) {
|
|
568
|
+
const lowered = {};
|
|
569
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
570
|
+
lowered[key.toLowerCase()] = AnthbotShadowApiClient._normalizeHeaderValue(value);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const orderedKeys = Object.keys(lowered).sort();
|
|
574
|
+
let canonical = '';
|
|
575
|
+
for (const key of orderedKeys) {
|
|
576
|
+
canonical += `${key}:${lowered[key]}\n`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const signedHeaders = orderedKeys.join(';');
|
|
580
|
+
return [canonical, signedHeaders];
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
static _signedHeadersFromRequest(canonicalRequest) {
|
|
584
|
+
const parts = canonicalRequest.split('\n');
|
|
585
|
+
if (parts.length < 6) {
|
|
586
|
+
return 'host;x-amz-content-sha256;x-amz-date';
|
|
587
|
+
}
|
|
588
|
+
return parts[parts.length - 2];
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
static _canonicalUriForSigv4(requestUri) {
|
|
592
|
+
/**
|
|
593
|
+
* Build SigV4 canonical URI.
|
|
594
|
+
* AWS canonicalization requires encoding '%' as '%25', so an already
|
|
595
|
+
* encoded request path (for example '/topics/%24aws%2F...') must be
|
|
596
|
+
* double-encoded only for signing.
|
|
597
|
+
*/
|
|
598
|
+
const encoded = [];
|
|
599
|
+
const buffer = Buffer.from(requestUri, 'utf-8');
|
|
600
|
+
|
|
601
|
+
for (const byte of buffer) {
|
|
602
|
+
// 0-9: 0x30-0x39, A-Z: 0x41-0x5A, a-z: 0x61-0x7A, - . _ ~ /
|
|
603
|
+
if (
|
|
604
|
+
(byte >= 0x30 && byte <= 0x39) ||
|
|
605
|
+
(byte >= 0x41 && byte <= 0x5a) ||
|
|
606
|
+
(byte >= 0x61 && byte <= 0x7a) ||
|
|
607
|
+
[45, 46, 95, 126, 47].includes(byte) // - . _ ~ /
|
|
608
|
+
) {
|
|
609
|
+
encoded.push(String.fromCharCode(byte));
|
|
610
|
+
} else {
|
|
611
|
+
encoded.push(`%${byte.toString(16).toUpperCase().padStart(2, '0')}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return encoded.join('');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async asyncGetShadowReportedState() {
|
|
619
|
+
const requestUri = `/things/${this._encodePathComponent(this._serialNumber)}/shadow`;
|
|
620
|
+
const canonicalUri = AnthbotShadowApiClient._canonicalUriForSigv4(requestUri);
|
|
621
|
+
const canonicalQuery = `name=${this._encodePathComponent('property')}`;
|
|
622
|
+
const payloadHash = crypto.createHash('sha256').update('').digest('hex');
|
|
623
|
+
|
|
624
|
+
const now = new Date();
|
|
625
|
+
const amzDate = now
|
|
626
|
+
.toISOString()
|
|
627
|
+
.replace(/[:-]/g, '')
|
|
628
|
+
.replace(/\.\d{3}/, '');
|
|
629
|
+
const dateStamp = amzDate.substring(0, 8);
|
|
630
|
+
|
|
631
|
+
const signedHeaderValues = {
|
|
632
|
+
host: this._iotEndpoint,
|
|
633
|
+
'x-amz-content-sha256': payloadHash,
|
|
634
|
+
'x-amz-date': amzDate,
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const [canonicalHeaders, signedHeaders] = AnthbotShadowApiClient._canonicalHeaders(signedHeaderValues);
|
|
638
|
+
|
|
639
|
+
const canonicalRequest =
|
|
640
|
+
`GET\n` +
|
|
641
|
+
`${canonicalUri}\n` +
|
|
642
|
+
`${canonicalQuery}\n` +
|
|
643
|
+
`${canonicalHeaders}\n` +
|
|
644
|
+
`${signedHeaders}\n` +
|
|
645
|
+
`${payloadHash}`;
|
|
646
|
+
|
|
647
|
+
const authorization = this._buildAuthorization(amzDate, dateStamp, canonicalRequest);
|
|
648
|
+
|
|
649
|
+
const url = `https://${this._iotEndpoint}${requestUri}?${canonicalQuery}`;
|
|
650
|
+
const headers = {
|
|
651
|
+
Accept: '*/*',
|
|
652
|
+
Host: this._iotEndpoint,
|
|
653
|
+
'x-amz-date': amzDate,
|
|
654
|
+
'x-amz-content-sha256': payloadHash,
|
|
655
|
+
Authorization: authorization,
|
|
656
|
+
'User-Agent': CLIENT_USER_AGENT,
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const response = await this.fetch(url, {
|
|
660
|
+
method: 'GET',
|
|
661
|
+
headers,
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
if (response.status !== 200) {
|
|
665
|
+
const body = await response.text();
|
|
666
|
+
throw new Error(`Shadow request failed (${response.status}): ${body.slice(0, 300)}`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
let payload;
|
|
670
|
+
try {
|
|
671
|
+
payload = /** @type {{ state: { reported: unknown } }} */ (await response.json());
|
|
672
|
+
} catch {
|
|
673
|
+
throw new Error('Invalid JSON response from shadow request');
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
677
|
+
throw new Error('Invalid response payload type');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const state = payload.state;
|
|
681
|
+
const reported = typeof state === 'object' && state !== null ? state.reported : null;
|
|
682
|
+
if (typeof reported !== 'object' || reported === null) {
|
|
683
|
+
throw new Error('Missing state.reported in response');
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return reported;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Encode path component for AWS SigV4
|
|
691
|
+
*/
|
|
692
|
+
_encodePathComponent(component) {
|
|
693
|
+
return encodeURIComponent(component).replace(/[!'()*]/g, ch => {
|
|
694
|
+
return `%${ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`;
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async asyncPublishServiceCommand({ cmd, data }) {
|
|
699
|
+
const body = { state: { desired: { cmd, data } } };
|
|
700
|
+
const payloadBytes = Buffer.from(JSON.stringify(body, null, 0).replace(/\s/g, ''), 'utf-8');
|
|
701
|
+
|
|
702
|
+
const topic = `$aws/things/${this._serialNumber}/shadow/name/service/update`;
|
|
703
|
+
const requestUriEncoded = `/topics/${this._encodePathComponent(topic)}`;
|
|
704
|
+
const requestUriRaw = `/topics/${topic}`;
|
|
705
|
+
|
|
706
|
+
// Different AWS clients canonicalize the URI slightly differently.
|
|
707
|
+
// Try the app-observed mode first, then fall back to alternatives.
|
|
708
|
+
/** @type {[string, boolean, string | null, boolean][]} */
|
|
709
|
+
const attempts = [
|
|
710
|
+
// 1) SDK headers + encoded URI + app-style canonical URI (trace match)
|
|
711
|
+
[requestUriEncoded, true, null, true],
|
|
712
|
+
// 2) SDK headers + encoded URI + raw canonical URI
|
|
713
|
+
[requestUriEncoded, true, requestUriEncoded, true],
|
|
714
|
+
// 3) SDK headers + encoded URI + app-style canonical URI, no signed content-length
|
|
715
|
+
[requestUriEncoded, true, null, false],
|
|
716
|
+
// 4) LdMower headers + encoded URI + app-style canonical URI
|
|
717
|
+
[requestUriEncoded, false, null, true],
|
|
718
|
+
// 5) Raw topic path, SDK headers, app-style canonical URI
|
|
719
|
+
[requestUriRaw, true, null, true],
|
|
720
|
+
// 6) Raw topic path, SDK headers, raw canonical URI
|
|
721
|
+
[requestUriRaw, true, requestUriRaw, true],
|
|
722
|
+
// 7) Raw topic path, LdMower headers, app-style canonical URI
|
|
723
|
+
[requestUriRaw, false, null, true],
|
|
724
|
+
];
|
|
725
|
+
|
|
726
|
+
let lastStatus = 0;
|
|
727
|
+
let lastBody = '';
|
|
728
|
+
let lastHeaders = {};
|
|
729
|
+
|
|
730
|
+
for (let attemptIndex = 0; attemptIndex < attempts.length; attemptIndex++) {
|
|
731
|
+
const [requestUri, includeSdkHeaders, canonicalUriOverride, signContentLength] = attempts[attemptIndex];
|
|
732
|
+
|
|
733
|
+
const [status, bodyText, payload, responseHeaders] = await this._asyncSignedPost({
|
|
734
|
+
requestUri,
|
|
735
|
+
canonicalQuery: '',
|
|
736
|
+
payloadBytes,
|
|
737
|
+
includeSdkHeaders,
|
|
738
|
+
canonicalUriOverride,
|
|
739
|
+
signContentLength,
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
if (status === 200 && typeof payload === 'object' && payload !== null) {
|
|
743
|
+
if (attemptIndex > 0 && this.verboseLogger) {
|
|
744
|
+
this.verboseLogger(`Anthbot command publish recovered after fallback`);
|
|
745
|
+
}
|
|
746
|
+
// Response should contain { message: "OK" }
|
|
747
|
+
if (payload.message !== 'OK') {
|
|
748
|
+
if (this.verboseLogger) {
|
|
749
|
+
this.verboseLogger(`payload.message !== 'OK' even with status === 200`);
|
|
750
|
+
}
|
|
751
|
+
} else {
|
|
752
|
+
// Everything is good
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
lastStatus = status;
|
|
758
|
+
lastBody = bodyText;
|
|
759
|
+
lastHeaders = responseHeaders;
|
|
760
|
+
|
|
761
|
+
if (status !== 403) {
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (this.verboseLogger) {
|
|
766
|
+
this.verboseLogger(`Anthbot command publish attempt failed (${status}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
throw new Error(
|
|
771
|
+
`Command '${cmd}' failed (${lastStatus}) at endpoint '${this._iotEndpoint}' ` +
|
|
772
|
+
`(region '${this.signingRegion}', errortype '${lastHeaders['x-amzn-errortype']}', ` +
|
|
773
|
+
`requestid '${lastHeaders['x-amzn-requestid'] || lastHeaders['x-amzn-request-id']}'): ` +
|
|
774
|
+
`${lastBody.slice(0, 300)}`,
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* @param {{requestUri: string, canonicalQuery: string, payloadBytes: Buffer, includeSdkHeaders: boolean, canonicalUriOverride?: string | null, signContentLength?: boolean}} options
|
|
780
|
+
*/
|
|
781
|
+
async _asyncSignedPost({
|
|
782
|
+
requestUri,
|
|
783
|
+
canonicalQuery,
|
|
784
|
+
payloadBytes,
|
|
785
|
+
includeSdkHeaders,
|
|
786
|
+
canonicalUriOverride = null,
|
|
787
|
+
signContentLength = true,
|
|
788
|
+
}) {
|
|
789
|
+
const payloadHash = crypto.createHash('sha256').update(payloadBytes).digest('hex');
|
|
790
|
+
|
|
791
|
+
const now = new Date();
|
|
792
|
+
const amzDate = now
|
|
793
|
+
.toISOString()
|
|
794
|
+
.replace(/[:-]/g, '')
|
|
795
|
+
.replace(/\.\d{3}/, '');
|
|
796
|
+
const dateStamp = amzDate.substring(0, 8);
|
|
797
|
+
|
|
798
|
+
const signedHeaderValues = {
|
|
799
|
+
host: this._iotEndpoint,
|
|
800
|
+
'content-type': 'application/octet-stream',
|
|
801
|
+
'x-amz-content-sha256': payloadHash,
|
|
802
|
+
'x-amz-date': amzDate,
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const headers = {
|
|
806
|
+
Accept: '*/*',
|
|
807
|
+
Host: this._iotEndpoint,
|
|
808
|
+
'Content-Type': 'application/octet-stream',
|
|
809
|
+
'x-amz-content-sha256': payloadHash,
|
|
810
|
+
'x-amz-date': amzDate,
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
if (signContentLength) {
|
|
814
|
+
signedHeaderValues['content-length'] = String(payloadBytes.length);
|
|
815
|
+
headers['Content-Length'] = String(payloadBytes.length);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (includeSdkHeaders) {
|
|
819
|
+
const invocationId = crypto.randomUUID();
|
|
820
|
+
signedHeaderValues['amz-sdk-invocation-id'] = invocationId;
|
|
821
|
+
signedHeaderValues['amz-sdk-request'] = 'attempt=1; max=3';
|
|
822
|
+
signedHeaderValues['x-amz-user-agent'] = 'aws-sdk-js/3.846.0';
|
|
823
|
+
headers['amz-sdk-invocation-id'] = invocationId;
|
|
824
|
+
headers['amz-sdk-request'] = 'attempt=1; max=3';
|
|
825
|
+
headers['x-amz-user-agent'] = 'aws-sdk-js/3.846.0';
|
|
826
|
+
headers['User-Agent'] =
|
|
827
|
+
'aws-sdk-js/3.846.0 ua/2.1 os/other lang/js md/rn api/iot-data-plane#3.846.0 m/N,E,e';
|
|
828
|
+
} else {
|
|
829
|
+
headers['User-Agent'] = CLIENT_USER_AGENT;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const [canonicalHeaders, signedHeaders] = AnthbotShadowApiClient._canonicalHeaders(signedHeaderValues);
|
|
833
|
+
|
|
834
|
+
const canonicalUri =
|
|
835
|
+
canonicalUriOverride !== null
|
|
836
|
+
? canonicalUriOverride
|
|
837
|
+
: AnthbotShadowApiClient._canonicalUriForSigv4(requestUri);
|
|
838
|
+
|
|
839
|
+
const canonicalRequest =
|
|
840
|
+
`POST\n` +
|
|
841
|
+
`${canonicalUri}\n` +
|
|
842
|
+
`${canonicalQuery}\n` +
|
|
843
|
+
`${canonicalHeaders}\n` +
|
|
844
|
+
`${signedHeaders}\n` +
|
|
845
|
+
`${payloadHash}`;
|
|
846
|
+
|
|
847
|
+
headers['Authorization'] = this._buildAuthorization(amzDate, dateStamp, canonicalRequest);
|
|
848
|
+
|
|
849
|
+
let url = `https://${this._iotEndpoint}${requestUri}`;
|
|
850
|
+
if (canonicalQuery) {
|
|
851
|
+
url = `${url}?${canonicalQuery}`;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const response = await this.fetch(url, {
|
|
855
|
+
method: 'POST',
|
|
856
|
+
headers,
|
|
857
|
+
body: payloadBytes,
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
const bodyText = await response.text();
|
|
861
|
+
let payload = null;
|
|
862
|
+
try {
|
|
863
|
+
const parsed = JSON.parse(bodyText);
|
|
864
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
865
|
+
payload = parsed;
|
|
866
|
+
}
|
|
867
|
+
} catch {
|
|
868
|
+
// Invalid JSON, leave payload as null
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const responseHeaders = {
|
|
872
|
+
'x-amzn-errortype': response.headers.get('x-amzn-errortype') || '',
|
|
873
|
+
'x-amzn-requestid': response.headers.get('x-amzn-requestid') || '',
|
|
874
|
+
'x-amzn-request-id': response.headers.get('x-amzn-request-id') || '',
|
|
875
|
+
date: response.headers.get('date') || '',
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
return [response.status, bodyText, payload, responseHeaders];
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Exports
|
|
883
|
+
module.exports = {
|
|
884
|
+
AnthbotCloudApiClient,
|
|
885
|
+
};
|