mirror of
https://github.com/koush/scrypted.git
synced 2026-04-11 19:10:21 +01:00
709 lines
24 KiB
TypeScript
709 lines
24 KiB
TypeScript
import axios from 'axios';
|
|
import sdk, { HttpRequest, HttpRequestHandler, MixinProvider, ScryptedDevice, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, EventDetails, Setting, SettingValue, Settings, HttpResponseOptions, HttpResponse } from '@scrypted/sdk';
|
|
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
import { addBattery, addOnline, deviceErrorResponse, mirroredResponse, authErrorResponse, AlexaHttpResponse } from './common';
|
|
import { supportedTypes } from './types';
|
|
import { v4 as createMessageId } from 'uuid';
|
|
import { ChangeReport, Discovery, DiscoveryEndpoint } from './alexa';
|
|
import { alexaHandlers, alexaDeviceHandlers } from './handlers';
|
|
|
|
const { systemManager, deviceManager } = sdk;
|
|
|
|
const client_id = "amzn1.application-oa2-client.3283807e04d8408eb44a698c10f9dd13";
|
|
const client_secret = "bed445e2b26730acd818b90e175b275f6b67b18ff8645e571c5b3e311fa75ee9";
|
|
const includeToken = 4;
|
|
|
|
export let DEBUG = false;
|
|
|
|
function debug(...args: any[]) {
|
|
if (DEBUG)
|
|
console.debug(...args);
|
|
}
|
|
|
|
class AlexaPlugin extends ScryptedDeviceBase implements HttpRequestHandler, MixinProvider, Settings {
|
|
storageSettings = new StorageSettings(this, {
|
|
tokenInfo: {
|
|
hide: true,
|
|
json: true
|
|
},
|
|
syncedDevices: {
|
|
multiple: true,
|
|
hide: true
|
|
},
|
|
defaultIncluded: {
|
|
hide: true,
|
|
json: true
|
|
},
|
|
apiEndpoint: {
|
|
title: 'Alexa Endpoint',
|
|
description: 'This is the endpoint Alexa will use to send events to. This is set after you login.',
|
|
type: 'string',
|
|
readonly: true
|
|
},
|
|
debug: {
|
|
title: 'Debug Events',
|
|
description: 'Log all events to the console. This will be very noisy and should not be left enabled.',
|
|
type: 'boolean',
|
|
onPut(oldValue: boolean, newValue: boolean) {
|
|
DEBUG = newValue;
|
|
}
|
|
},
|
|
pairedUserId: {
|
|
title: "Pairing Key",
|
|
description: "The pairing key used to validate requests from Alexa. Clear this key or delete the plugin to allow pairing with a different Alexa login.",
|
|
},
|
|
});
|
|
|
|
accessToken: Promise<string>;
|
|
validAuths = new Set<string>();
|
|
devices = new Map<string, ScryptedDevice>();
|
|
|
|
constructor(nativeId?: string) {
|
|
super(nativeId);
|
|
|
|
DEBUG = this.storageSettings.values.debug ?? false;
|
|
|
|
alexaHandlers.set('Alexa.Authorization/AcceptGrant', this.onAlexaAuthorization);
|
|
alexaHandlers.set('Alexa.Discovery/Discover', this.onDiscoverEndpoints);
|
|
|
|
this.start();
|
|
}
|
|
|
|
async start() {
|
|
|
|
for (const id of Object.keys(systemManager.getSystemState())) {
|
|
const device = systemManager.getDeviceById(id);
|
|
await this.tryEnableMixin(device);
|
|
}
|
|
|
|
systemManager.listen((async (eventSource: ScryptedDevice | undefined, eventDetails: EventDetails, eventData: any) => {
|
|
const status = await this.tryEnableMixin(eventSource);
|
|
|
|
// sync new devices when added or removed
|
|
if (status === DeviceMixinStatus.Setup)
|
|
await this.syncEndpoints();
|
|
|
|
if (status === DeviceMixinStatus.Setup || status === DeviceMixinStatus.AlreadySetup) {
|
|
|
|
if (!this.devices.has(eventSource.id)) {
|
|
this.devices.set(eventSource.id, eventSource);
|
|
eventSource.listen(ScryptedInterface.ObjectDetector, this.deviceListen.bind(this));
|
|
}
|
|
|
|
this.deviceListen(eventSource, eventDetails, eventData);
|
|
}
|
|
}).bind(this));
|
|
|
|
await this.syncEndpoints();
|
|
}
|
|
|
|
private async tryEnableMixin(device: ScryptedDevice): Promise<DeviceMixinStatus> {
|
|
if (!device)
|
|
return DeviceMixinStatus.NotSupported;
|
|
|
|
const mixins = (device.mixins || []).slice();
|
|
if (mixins.includes(this.id))
|
|
return DeviceMixinStatus.AlreadySetup;
|
|
|
|
const defaultIncluded = this.storageSettings.values.defaultIncluded || {};
|
|
if (defaultIncluded[device.id] === includeToken)
|
|
return DeviceMixinStatus.AlreadySetup;
|
|
|
|
if (!supportedTypes.has(device.type))
|
|
return DeviceMixinStatus.NotSupported;
|
|
|
|
mixins.push(this.id);
|
|
|
|
const plugins = await systemManager.getComponent('plugins');
|
|
await plugins.setMixins(device.id, mixins);
|
|
|
|
defaultIncluded[device.id] = includeToken;
|
|
this.storageSettings.values.defaultIncluded = defaultIncluded;
|
|
|
|
return DeviceMixinStatus.Setup;
|
|
}
|
|
|
|
async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]> {
|
|
const available = supportedTypes.has(type);
|
|
|
|
if (available)
|
|
return [];
|
|
|
|
return;
|
|
}
|
|
|
|
async getMixin(device: ScryptedDevice, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: { [key: string]: any }): Promise<any> {
|
|
return device;
|
|
}
|
|
|
|
async releaseMixin(id: string, mixinDevice: any): Promise<void> {
|
|
const device = systemManager.getDeviceById(id);
|
|
const mixins = (device.mixins || []).slice();
|
|
if (mixins.includes(this.id))
|
|
return;
|
|
|
|
this.log.i(`Device removed from Alexa: ${device.name}. Requesting sync.`);
|
|
await this.syncEndpoints();
|
|
}
|
|
|
|
async deviceListen(eventSource: ScryptedDevice | undefined, eventDetails: EventDetails, eventData: any): Promise<void> {
|
|
if (!eventSource)
|
|
return;
|
|
|
|
if (!this.storageSettings.values.syncedDevices.includes(eventSource.id))
|
|
return;
|
|
|
|
if (eventDetails.eventInterface === ScryptedInterface.ScryptedDevice)
|
|
return;
|
|
|
|
const supportedType = supportedTypes.get(eventSource.type);
|
|
if (!supportedType)
|
|
return;
|
|
|
|
let report = await supportedType.sendEvent(eventSource, eventDetails, eventData);
|
|
|
|
if (!report && eventDetails.eventInterface === ScryptedInterface.Online) {
|
|
report = {};
|
|
}
|
|
|
|
if (!report && eventDetails.eventInterface === ScryptedInterface.Battery) {
|
|
report = {};
|
|
}
|
|
|
|
if (!report) {
|
|
debug(`${eventDetails.eventInterface}.${eventDetails.property} not supported for device ${eventSource.type}`);
|
|
return;
|
|
}
|
|
|
|
debug("event", eventDetails.eventInterface, eventDetails.property, eventSource.type);
|
|
|
|
let data = {
|
|
"event": {
|
|
"header": {
|
|
"messageId": createMessageId(),
|
|
"namespace": report?.event?.header?.namespace ?? "Alexa",
|
|
"name": report?.event?.header?.name ?? "ChangeReport",
|
|
"payloadVersion": "3"
|
|
},
|
|
"endpoint": {
|
|
"endpointId": eventSource.id,
|
|
},
|
|
payload: report?.event?.payload
|
|
},
|
|
context: report?.context
|
|
} as ChangeReport;
|
|
|
|
data = addOnline(data, eventSource);
|
|
data = addBattery(data, eventSource);
|
|
|
|
// nothing to report
|
|
if (data.context === undefined && data.event.payload === undefined)
|
|
return;
|
|
|
|
data = await this.addAccessToken(data);
|
|
|
|
await this.postEvent(data);
|
|
}
|
|
|
|
private async addAccessToken(data: any): Promise<any> {
|
|
const accessToken = await this.getAccessToken();
|
|
|
|
if (data.event === undefined)
|
|
data.event = {};
|
|
|
|
if (data.event.endpoint === undefined)
|
|
data.event.endpoint = [];
|
|
|
|
data.event.endpoint.scope = {
|
|
"type": "BearerToken",
|
|
"token": accessToken,
|
|
};
|
|
|
|
return data;
|
|
}
|
|
|
|
getSettings(): Promise<Setting[]> {
|
|
return this.storageSettings.getSettings();
|
|
}
|
|
|
|
putSetting(key: string, value: SettingValue): Promise<void> {
|
|
return this.storageSettings.putSetting(key, value);
|
|
}
|
|
|
|
readonly endpoints: string[] = [
|
|
'api.amazonalexa.com',
|
|
'api.eu.amazonalexa.com',
|
|
'api.fe.amazonalexa.com'
|
|
];
|
|
|
|
async getAlexaEndpoint(): Promise<string> {
|
|
if (this.storageSettings.values.apiEndpoint)
|
|
return this.storageSettings.values.apiEndpoint;
|
|
|
|
try {
|
|
const accessToken = await this.getAccessToken();
|
|
const response = await axios.get(`https://${this.endpoints[0]}/v1/alexaApiEndpoint`, {
|
|
headers: {
|
|
'Authorization': 'Bearer ' + accessToken,
|
|
}
|
|
});
|
|
|
|
const endpoint: string = response.data.endpoints[0];
|
|
this.storageSettings.values.apiEndpoint = endpoint;
|
|
return endpoint;
|
|
} catch (err) {
|
|
this.console.error(err);
|
|
|
|
// default to NA/RoW endpoint if we can't get the endpoint.
|
|
return this.endpoints[0];
|
|
}
|
|
}
|
|
|
|
async postEvent(data: any) {
|
|
const accessToken = await this.getAccessToken();
|
|
const endpoint = await this.getAlexaEndpoint();
|
|
const self = this;
|
|
|
|
debug("send event to alexa", data);
|
|
|
|
return axios.post(`https://${endpoint}/v3/events`, data, {
|
|
headers: {
|
|
'Authorization': 'Bearer ' + accessToken,
|
|
}
|
|
}).catch(error => {
|
|
self.console.error(error?.response?.data);
|
|
|
|
if (error?.response?.status === 401 || error?.response?.status === 403) {
|
|
self.storageSettings.values.tokenInfo = undefined;
|
|
self.accessToken = undefined;
|
|
}
|
|
});
|
|
}
|
|
|
|
async getEndpoints(): Promise<DiscoveryEndpoint[]> {
|
|
const endpoints: DiscoveryEndpoint[] = [];
|
|
|
|
for (const id of Object.keys(systemManager.getSystemState())) {
|
|
const device = systemManager.getDeviceById(id);
|
|
|
|
if (!device.mixins?.includes(this.id))
|
|
continue;
|
|
|
|
const endpoint = await this.getEndpointForDevice(device);
|
|
if (endpoint)
|
|
endpoints.push(endpoint);
|
|
}
|
|
|
|
return endpoints;
|
|
}
|
|
|
|
async onDiscoverEndpoints(request: HttpRequest, response: AlexaHttpResponse, directive: any) {
|
|
const endpoints = await this.getEndpoints();
|
|
|
|
const data = {
|
|
"event": {
|
|
"header": {
|
|
"namespace": 'Alexa.Discovery',
|
|
"name": 'Discover.Response',
|
|
"payloadVersion": '3',
|
|
"messageId": createMessageId()
|
|
},
|
|
"payload": {
|
|
endpoints
|
|
}
|
|
}
|
|
} as Discovery;
|
|
|
|
response.send(data);
|
|
|
|
await this.saveEndpoints(endpoints);
|
|
}
|
|
|
|
async syncEndpoints() {
|
|
const endpoints = await this.getEndpoints();
|
|
|
|
if (!endpoints.length)
|
|
return [];
|
|
|
|
const accessToken = await this.getAccessToken();
|
|
const data = {
|
|
"event": {
|
|
"header": {
|
|
"namespace": "Alexa.Discovery",
|
|
"name": "AddOrUpdateReport",
|
|
"payloadVersion": "3",
|
|
"messageId": createMessageId()
|
|
},
|
|
"payload": {
|
|
endpoints,
|
|
"scope": {
|
|
"type": "BearerToken",
|
|
"token": accessToken,
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
await this.postEvent(data);
|
|
|
|
await this.saveEndpoints(endpoints);
|
|
}
|
|
|
|
async saveEndpoints(endpoints: DiscoveryEndpoint[]) {
|
|
const existingEndpoints: string[] = this.storageSettings.values.syncedDevices;
|
|
const newEndpoints = endpoints.map(endpoint => endpoint.endpointId);
|
|
const deleted = new Set(existingEndpoints);
|
|
|
|
for (const id of newEndpoints) {
|
|
deleted.delete(id);
|
|
}
|
|
|
|
const all = new Set([...existingEndpoints, ...newEndpoints]);
|
|
|
|
// save all the endpoints
|
|
this.storageSettings.values.syncedDevices = [...all];
|
|
|
|
// delete leftover endpoints
|
|
await this.deleteEndpoints(...deleted);
|
|
|
|
// prune if the delete report completed successfully
|
|
this.storageSettings.values.syncedDevices = newEndpoints;
|
|
}
|
|
|
|
async deleteEndpoints(...ids: string[]) {
|
|
if (!ids.length)
|
|
return;
|
|
|
|
const accessToken = await this.getAccessToken();
|
|
return this.postEvent({
|
|
"event": {
|
|
"header": {
|
|
"namespace": "Alexa.Discovery",
|
|
"name": "DeleteReport",
|
|
"messageId": createMessageId(),
|
|
"payloadVersion": "3"
|
|
},
|
|
"payload": {
|
|
"endpoints": ids.map(id => ({
|
|
"endpointId": id,
|
|
})),
|
|
"scope": {
|
|
"type": "BearerToken",
|
|
"token": accessToken,
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
private setReauthenticateAlert() {
|
|
const msg: string = "Please reauthenticate by following the directions below.";
|
|
this.log.a(msg);
|
|
}
|
|
|
|
getAccessToken(): Promise<string> {
|
|
if (this.accessToken)
|
|
return this.accessToken;
|
|
|
|
this.log.clearAlerts();
|
|
|
|
const { tokenInfo } = this.storageSettings.values;
|
|
|
|
if (tokenInfo === undefined) {
|
|
this.setReauthenticateAlert();
|
|
throw new Error("'tokenInfo' is undefined");
|
|
}
|
|
|
|
const { code } = tokenInfo;
|
|
|
|
const body: Record<string, string> = {
|
|
client_id,
|
|
client_secret
|
|
};
|
|
if (code) {
|
|
body.code = code;
|
|
body.grant_type = 'authorization_code';
|
|
}
|
|
else {
|
|
const { refresh_token } = tokenInfo;
|
|
body.refresh_token = refresh_token;
|
|
body.grant_type = 'refresh_token';
|
|
}
|
|
|
|
const self = this;
|
|
|
|
const accessTokenPromise = (async () => {
|
|
const response = await axios.post('https://api.amazon.com/auth/o2/token', new URLSearchParams(body).toString(), {
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
}
|
|
}).catch(error => {
|
|
switch (error?.response?.data?.error) {
|
|
case 'invalid_client':
|
|
case 'invalid_grant':
|
|
case 'unauthorized_client':
|
|
self.console.error(error?.response?.data);
|
|
self.log.a(error?.response?.data?.error_description);
|
|
self.storageSettings.values.tokenInfo = undefined;
|
|
self.accessToken = undefined;
|
|
break;
|
|
|
|
case 'authorization_pending':
|
|
self.console.warn(error?.response?.data);
|
|
self.log.a(error?.response?.data?.error_description);
|
|
break;
|
|
|
|
case 'expired_token':
|
|
self.console.warn(error?.response?.data);
|
|
self.log.a(error?.response?.data?.error_description);
|
|
self.accessToken = undefined;
|
|
break;
|
|
|
|
default:
|
|
self.console.error(error?.response?.data);
|
|
}
|
|
throw error;
|
|
});
|
|
// expires_in is 1 hr
|
|
const { access_token, expires_in } = response.data;
|
|
this.storageSettings.values.tokenInfo = response.data;
|
|
setTimeout(() => {
|
|
if (this.accessToken === accessTokenPromise)
|
|
this.accessToken = undefined;
|
|
}, (expires_in - 300) * 1000);
|
|
return access_token;
|
|
})();
|
|
|
|
this.accessToken = accessTokenPromise;
|
|
this.accessToken.catch(() => this.accessToken = undefined);
|
|
return this.accessToken;
|
|
}
|
|
|
|
async onAlexaAuthorization(request: HttpRequest, response: AlexaHttpResponse, directive: any) {
|
|
const { grant } = directive.payload;
|
|
this.storageSettings.values.tokenInfo = grant;
|
|
this.storageSettings.values.apiEndpoint = undefined;
|
|
this.accessToken = undefined;
|
|
|
|
const self = this;
|
|
let accessToken: any;
|
|
|
|
try {
|
|
accessToken = await this.getAccessToken();
|
|
}
|
|
catch (reason) {
|
|
self.console.error(`Failed to handle the AcceptGrant directive because ${reason}`);
|
|
|
|
this.storageSettings.values.tokenInfo = undefined;
|
|
this.storageSettings.values.apiEndpoint = undefined;
|
|
this.accessToken = undefined;
|
|
|
|
response.send(authErrorResponse("ACCEPT_GRANT_FAILED", `Failed to handle the AcceptGrant directive because ${reason}`, directive));
|
|
|
|
return;
|
|
};
|
|
this.log.clearAlerts();
|
|
response.send({
|
|
"event": {
|
|
"header": {
|
|
"namespace": "Alexa.Authorization",
|
|
"name": "AcceptGrant.Response",
|
|
"messageId": createMessageId(),
|
|
"payloadVersion": "3"
|
|
},
|
|
"payload": {}
|
|
}
|
|
});
|
|
}
|
|
|
|
async getEndpointForDevice(device: ScryptedDevice): Promise<DiscoveryEndpoint> {
|
|
if (!device)
|
|
return;
|
|
|
|
const discovery = await supportedTypes.get(device.type)?.discover(device);
|
|
if (!discovery)
|
|
return;
|
|
|
|
const data: DiscoveryEndpoint = {
|
|
endpointId: device.id,
|
|
manufacturerName: "Scrypted",
|
|
description: `${device.info?.manufacturer ?? 'Unknown'} ${device.info?.model ?? `device of type ${device.type}`}, connected via Scrypted`,
|
|
friendlyName: device.name,
|
|
additionalAttributes: {
|
|
manufacturer: device.info?.manufacturer || undefined,
|
|
model: device.info?.model || undefined,
|
|
serialNumber: device.info?.serialNumber || undefined,
|
|
firmwareVersion: device.info?.firmware || undefined,
|
|
softwareVersion: device.info?.version || undefined
|
|
},
|
|
displayCategories: discovery.displayCategories,
|
|
capabilities: discovery.capabilities
|
|
};
|
|
|
|
let supportedEndpointHealths: any[] = [];
|
|
|
|
if (device.interfaces.includes(ScryptedInterface.Online)) {
|
|
supportedEndpointHealths.push({
|
|
"name": "connectivity"
|
|
});
|
|
}
|
|
|
|
// {
|
|
// "name": "radioDiagnostics"
|
|
// },
|
|
// {
|
|
// "name": "networkThroughput"
|
|
// }
|
|
|
|
if (device.interfaces.includes(ScryptedInterface.Battery)) {
|
|
supportedEndpointHealths.push({
|
|
"name": "battery"
|
|
})
|
|
}
|
|
|
|
if (supportedEndpointHealths.length > 0) {
|
|
data.capabilities.push(
|
|
{
|
|
"type": "AlexaInterface",
|
|
"interface": "Alexa.EndpointHealth",
|
|
"version": "3.2",
|
|
"properties": {
|
|
"supported": supportedEndpointHealths,
|
|
"proactivelyReported": true,
|
|
"retrievable": true
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
data.capabilities.push(
|
|
{
|
|
"type": "AlexaInterface",
|
|
"interface": "Alexa",
|
|
"version": "3"
|
|
}
|
|
);
|
|
|
|
if (device.info?.mac !== undefined)
|
|
data.connections = [
|
|
{
|
|
"type": "TCP_IP",
|
|
"macAddress": device.info.mac
|
|
}
|
|
];
|
|
|
|
return data as any;
|
|
}
|
|
|
|
async onRequest(request: HttpRequest, rawResponse: HttpResponse) {
|
|
const response = new HttpResponseLoggingImpl(rawResponse, this.console);
|
|
|
|
const { authorization } = request.headers;
|
|
if (!this.validAuths.has(authorization)) {
|
|
try {
|
|
debug("making authorization request to Scrypted");
|
|
|
|
const getcookieResponse = await axios.get('https://home.scrypted.app/_punch/getcookie', {
|
|
headers: {
|
|
'Authorization': authorization,
|
|
}
|
|
});
|
|
// new tokens will contain a lot of information, including the expiry and client id.
|
|
// validate this. old tokens will be grandfathered in.
|
|
if (getcookieResponse.data.expiry && getcookieResponse.data.clientId !== 'amazon')
|
|
throw new Error('client id mismatch');
|
|
if (!this.storageSettings.values.pairedUserId) {
|
|
this.storageSettings.values.pairedUserId = getcookieResponse.data.id;
|
|
}
|
|
else if (this.storageSettings.values.pairedUserId !== getcookieResponse.data.id) {
|
|
this.log.a('This plugin is already paired with a different account. Clear the existing key in the plugin settings to pair this plugin with a different account.');
|
|
throw new Error('user id mismatch');
|
|
}
|
|
this.validAuths.add(authorization);
|
|
}
|
|
catch (e) {
|
|
this.console.error(`request failed due to invalid authorization`, e);
|
|
response.send(e.message, {
|
|
code: 500,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const body = JSON.parse(request.body);
|
|
const { directive } = body;
|
|
const { namespace, name } = directive.header;
|
|
|
|
const mapName = `${namespace}/${name}`;
|
|
|
|
debug("received directive from alexa", mapName, body);
|
|
|
|
const handler = alexaHandlers.get(mapName);
|
|
if (handler) {
|
|
await handler.apply(this, [request, response, directive]);
|
|
return;
|
|
}
|
|
|
|
const deviceHandler = alexaDeviceHandlers.get(mapName);
|
|
|
|
if (deviceHandler) {
|
|
const device = systemManager.getDeviceById(directive.endpoint.endpointId);
|
|
if (!device) {
|
|
response.send(deviceErrorResponse("NO_SUCH_ENDPOINT", "The device doesn't exist in Scrypted", directive));
|
|
return;
|
|
}
|
|
|
|
await deviceHandler.apply(this, [request, response, directive, device]);
|
|
return;
|
|
} else {
|
|
this.console.error(`no handler for: ${mapName}`);
|
|
}
|
|
|
|
// it is better to send a non-specific response than an error, as the API might get rate throttled
|
|
response.send(mirroredResponse(directive));
|
|
}
|
|
}
|
|
|
|
enum DeviceMixinStatus {
|
|
NotSupported = 0,
|
|
Setup = 1,
|
|
AlreadySetup = 2
|
|
}
|
|
|
|
class HttpResponseLoggingImpl implements AlexaHttpResponse {
|
|
constructor(private response: HttpResponse, private console: Console) {
|
|
}
|
|
|
|
send(body: string): void;
|
|
send(body: string, options: HttpResponseOptions): void;
|
|
send(body: Buffer): void;
|
|
send(body: Buffer, options: HttpResponseOptions): void;
|
|
send(body: any, options?: any): void {
|
|
if (!options)
|
|
options = {};
|
|
|
|
if (!options.code)
|
|
options.code = 200;
|
|
|
|
if (options.code !== 200)
|
|
this.console.error(`response error ${options.code}:`, body);
|
|
else
|
|
debug("response to alexa directive", options.code, body);
|
|
|
|
if (typeof body === 'object')
|
|
body = JSON.stringify(body);
|
|
|
|
this.response.send(body, options);
|
|
}
|
|
sendFile(path: string): void;
|
|
sendFile(path: string, options: HttpResponseOptions): void;
|
|
sendFile(path: any, options?: any): void {
|
|
this.response.sendFile(path, options);
|
|
}
|
|
sendSocket(socket: any, options: HttpResponseOptions): void {
|
|
this.response.sendSocket(socket, options);
|
|
}
|
|
}
|
|
|
|
export default AlexaPlugin;
|