iobroker.synochat
Version:
This adapter provides an interface of Synology Chat and ioBroker.
716 lines (643 loc) • 23.7 kB
JavaScript
"use strict";
/*
* Created with @iobroker/create-adapter v2.1.1
*/
// The adapter-core module gives you access to the core ioBroker functions
// you need to create an adapter
const utils = require("@iobroker/adapter-core");
const SynoChatRequests = require("./lib/synoChatRequests.js");
const iFaces = require("os").networkInterfaces();
const uuid = require("uuid");
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
class Synochat extends utils.Adapter {
constructor(options) {
super({
...options,
name: "synochat",
});
this.connected = false;
this.on("ready", this.onReady.bind(this));
this.on("message", this.onMessage.bind(this));
this.on("stateChange", this.onStateChange.bind(this));
this.on("unload", this.onUnload.bind(this));
this.synoChatRequestHandler = null;
this.messageQueue = [];
}
/**
* Is called when databases are connected and adapter received configuration.
*/
async onReady() {
this.messageQueue = [];
let configChanged = false;
this.setState("info.connection", false, true);
//this.log.info("Got instance configuration. SynoChat adapter instance not yet ready!");
this.log.info("Initializing SynoChat...");
if (
this.config &&
Object.keys(this.config).length === 0 &&
Object.getPrototypeOf(this.config) === Object.prototype
) {
this.log.error(
"Instance configuration missing! Please update the instance configuration!",
);
this.log.error(`Adapter instance not in a usable state!`);
return;
}
this.log.info("Instance configuration found! > Checking configuration...");
// Migration from older versions
if (
this.config.channelName ||
this.config.channelToken ||
this.config.channelType
) {
this.log.warn(
"Configuration data from older version found! > Migrating data to new channel object...",
);
// Adding first web instance
if (!this.config.webInstance) {
this.log.warn(
"Web adapter instance not configured! > Checking current Web adapter instances...",
);
let webInstanceObjects = await this.getObjectViewAsync(
"system",
"instance",
{
startkey: "system.adapter.web.",
endkey: "system.adapter.web.\u9999",
},
);
let webInstanceIds = [];
if (webInstanceObjects && webInstanceObjects.rows) {
webInstanceObjects.rows.forEach((row) => {
webInstanceIds.push({
id: row.id.replace("system.adapter.", ""),
config: row.value.native.type,
});
});
if (webInstanceIds.length >= 1) {
this.config.webInstance = webInstanceIds[0].id.toString();
this.log.debug(
`Found '${webInstanceIds.length.toString()}' Web adapter instances! > Set Web adapter instance '${this.config.webInstance}' as initial configuration value!`,
);
configChanged = true;
} else {
this.log.error(
"No Web adapter instances found! > A Web adapter instance is required to start up this adapter instance!",
);
}
} else {
this.log.error(
"No Web adapter instances found! > A Web adapter instance is required to start up this adapter instance!",
);
}
}
// Set ioBroker Host address to the first address in the listed network interfaces
if (this.config.iobrokerHost == "") {
let ipAddress = "localhost";
Object.keys(iFaces).forEach((dev) => {
iFaces[dev].filter((details) => {
if (
(details.family === "IPv4" || details.family === 4) &&
details.internal === false
) {
ipAddress = details.address;
}
});
});
this.log.debug(
`Hostname for 'iobrokerHost' is unset! > Set default value of current local IP '${ipAddress}'.\nNOTE: This might be incorrect when using an Docker instance!`,
);
this.config.iobrokerHost = ipAddress;
configChanged = true;
}
// Main migration of previous data
let migrationChannel = {
channelEnabled: true,
channelName: this.config.channelName,
channelAccessToken: this.config.channelToken,
channelType: this.config.channelType,
channelObjectValueTemplate: this.config.channelObjectValueTemplate,
channelReactOnNotificationmanager: false,
channelReactOnAllIobrokerMessages: false,
channelValidateCert: this.config.channelContentCertCheck,
};
if (
this.config.channels.length == 1 &&
this.config.channels[0].channelName == "" &&
this.config.channels[0].channelAccessToken == ""
) {
this.log.debug(
"Found empty initial channel item! > Deleting this item for migration...",
);
this.config.channels.pop();
}
this.config.channels.push(migrationChannel);
this.config.channelName = null;
this.config.channelToken = null;
this.config.channelType = null;
this.config.channelObjectValueTemplate = null;
this.log.debug(
"Migration data of of older version done! > Old config data was deleted!",
);
configChanged = true;
}
if (configChanged) {
this.log.debug(
"A adapter configuration change was detected! > Adapter will be restarted by the configuration change!",
);
this.updateConfig(this.config);
return "migration";
}
if (
!this.config.synoUrl ||
!this.config.iobrokerHost ||
!this.config.webInstance
) {
this.log.error(
"Instance main configuration invalid! One or more values of the configuration are missing.",
);
this.log.error(`Adapter instance not in a usable state!`);
return;
}
for (let i = 0; i < this.config.channels.length; i++) {
if (
!this.config.channels[i].channelName ||
!this.config.channels[i].channelAccessToken ||
!this.config.channels[i].channelType
) {
this.log.error(
"At least one channel configuration is invalid! One or more values of the configuration is missing.",
);
this.log.error(`Adapter instance not in a usable state!`);
return;
}
}
this.log.info("Instance configuration check passed!");
this.log.info("Checking and creating object resources...");
// Create configured channel resources
let coveredChannels = [];
for (let i = 0; i < this.config.channels.length; i++) {
if (coveredChannels.includes(i)) {
continue;
}
let infoText = `${this.config.channels[i].channelType} messages`;
try {
switch (this.config.channels[i].channelType.toLowerCase()) {
case "incoming":
infoText = "sending messages to the Synology chat server";
break;
case "outgoing":
infoText = "receiving messages from the Synology chat server";
break;
}
if (i < this.config.channels.length - 1) {
for (let j = i + 1; j < this.config.channels.length; j++) {
if (
this.config.channels[i].channelName ==
this.config.channels[j].channelName
) {
coveredChannels.push(j);
infoText = "bidirectional communication";
break;
}
}
}
} catch (e) {
this.log.warn(
`Unable to parse provided object type for parrent message object descriotion! Set to default! ${e}`,
);
infoText = `${this.config.channels[i].channelType} messages`;
}
await this.setObjectAsync(this.config.channels[i].channelName, {
type: "folder",
common: {
name: `Synology chat channel for ${infoText}`,
},
native: {},
});
await this.setObjectNotExistsAsync(
`${this.config.channels[i].channelName}.message`,
{
type: "state",
common: {
name: "Message object to be handled",
type: "string",
role: "text",
read: true,
write: true,
},
native: {},
},
);
}
// Clean up
for (const adapterInstanceObject in await this.getAdapterObjectsAsync()) {
if (adapterInstanceObject.split(".").length === 3) {
if (
(await this.getObjectAsync(adapterInstanceObject)).type == "folder" &&
adapterInstanceObject.split(".")[2] != "info"
) {
let deleteObj = true;
for (let i = 0; i < this.config.channels.length; i++) {
if (
this.config.channels[i].channelName ==
adapterInstanceObject.split(".")[2]
) {
deleteObj = false;
break;
}
}
if (deleteObj) {
this.log.warn(
`Clean up not configured object. Deleting channel objects in '${adapterInstanceObject}'`,
);
await this.delObjectAsync(adapterInstanceObject, {
recursive: true,
});
}
}
}
}
await this.setObjectNotExistsAsync("info.webHookUrl", {
type: "state",
common: {
name: "Adapter web hook URL for receiving data from the Synology chat server",
type: "string",
role: "text",
read: true,
write: false,
},
native: {},
});
this.synoChatRequestHandler = new SynoChatRequests.SynoChatRequests(
this,
this.config.synoUrl,
this.config.certCheck,
);
if (await this.synoChatRequestHandler.initialConnectivityCheck()) {
this.setState("info.connection", true, true);
// In order to get state updates, you need to subscribe to them. The following line adds a subscription for our variable we have created above.
this.log.info("Subscribing adapter instance to all instance states.");
this.subscribeStates("*");
this.log.info(
"SynoChat adapter instance initialized! > Instance up and running!",
);
} else {
this.log.error(
"Initial connectivity check failed! > Adapter instance not in a usable state!",
);
}
}
/**
* Is called when adapter shuts down - callback has to be called under any circumstances!
*
* @param callback Callback for unloading the adapter instance
*/
onUnload(callback) {
try {
this.log.warn(
"Got termination signal for SynoChat adapter instance! > Terminating instance...",
);
// Here you must clear all timeouts or intervals that may still be active
// clearTimeout(timeout1);
// clearTimeout(timeout2);
// ...
// clearInterval(interval1);
this.setState("info.connection", false, true);
callback();
} catch {
callback();
}
this.log.info("SynoChat adapter instance unloaded!");
}
/**
* Is called if a subscribed state changes
*
* @param id ID of the changed object
* @param state State of the changes vale
*/
async onStateChange(id, state) {
if (state) {
if (id.endsWith("info.connection")) {
return "managementStateChange";
}
if (!id.endsWith(".message")) {
return "notAMessageObject";
}
if (state.ack) {
//only continue when application triggered a change without ack flag, filter out reception state changes
//enable this for system testing
//this.interfaceTest(id, state);
this.log.debug(
`State for object '${id}' changed to value '${state.val}' but ack flag is set. > Request will not be processed!`,
);
return "stateChangeAcknowledged";
}
if (!(await this.getStateAsync("info.connection"))) {
this.log.warn(
`State for object '${id}' changed to value '${state.val}' but instance is not ready (info.connection)! > Request will not be processed!`,
);
return "instanceNotReady";
}
let msgUuid = uuid.v1();
this.log.debug(
`State for object '${id}' changed to value '${state.val}' with ack=${state.ack}. ID of message: '${msgUuid}'`,
);
let sendingResult = false;
for (let i = 0; i < this.config.channels.length; i++) {
if (
id.split(".")[id.split(".").length - 2].toLowerCase() ==
this.config.channels[i].channelName.toLowerCase()
) {
this.log.debug(
`Found channel '${this.config.channels[i].channelName}' for requested message to be sent to the Synology chat server with object id '${id}'.`,
);
sendingResult = await this.enqueueAndSendMessage(
i,
state.val,
msgUuid,
);
if (sendingResult) {
this.setState(id, { ack: true });
return;
}
}
}
this.log.debug(
`Unable to find an incoming remote channel for the requested object '${id}' on the Synology chat server! > Request will not be processed!`,
);
} else {
// The state was deleted
this.log.info(
`The state for '${id}' was deleted! > Request will not be processed!`,
);
}
}
/**
* Process a `sendNotification` request
*
* @param obj Object representing the message
*/
async onMessage(obj) {
this.log.debug("Received message object. Processing...");
let msgUuid = uuid.v1();
if (obj && obj.command === "sendNotification" && obj.message) {
this.log.debug(
`Process message from Notification-Manager with internal message ID: '${msgUuid}'`,
);
let sendingResult = 1;
for (let i = 0; i < this.config.channels.length; i++) {
if (this.config.channels[i].channelReactOnNotificationmanager == true) {
this.log.debug(
`Found channel '${this.config.channels[i].channelName}' for requested message to react on messages from Notification-Manager.`,
);
// Telegram default text from Notificaiton-Manager messages:
//const subject = obj.message.category.name;
//const { instances } = obj.message.category;
//
//const readableInstances = Object.entries(instances).map(([instance, entry]) => `${instance.substring('system.adapter.'.length)}`);
//const text = `${obj.message.category.description}
// ${obj.message.host}:
// ${readableInstances.join('\n')}
//`;
// sample: *Issues with RAM availability* Your system is running out of memory. Please check the number of running adapters and processes or if single processes need too many memory. system.host.iobroker: notification-manager.0
//const msg = `*${subject}*\n\n${text}`
sendingResult = await this.enqueueAndSendMessage(
i,
obj,
msgUuid,
this.config.receivedNotificationManagerTemplate,
);
if (!sendingResult) {
this.sendTo(
obj.from,
"sendNotification",
{ sent: false },
obj.callback,
);
this.log.error(
`Unable to send the received message '${obj._id}' from Notification-Manager! > Request will not be processed!`,
);
return;
}
}
}
if (sendingResult) {
this.sendTo(obj.from, "sendNotification", { sent: true }, obj.callback);
} else {
this.sendTo(
obj.from,
"sendNotification",
{ sent: false },
obj.callback,
);
this.log.error(
`Unable to send the received message '${obj._id}' from Notification-Manager! > Request will not be processed!`,
);
}
} else if (obj && obj.message) {
this.log.debug(
`Process message from unknown provider '${obj.from}' with internal message ID: '${msgUuid}'`,
);
for (let i = 0; i < this.config.channels.length; i++) {
if (this.config.channels[i].channelReactOnAllIobrokerMessages == true) {
this.log.debug(
`Found channel '${this.config.channels[i].channelName}' for requested message to react on messages default messages.`,
);
await this.enqueueAndSendMessage(
i,
obj,
msgUuid,
this.config.receivedMessageTemplate,
);
}
}
} else {
this.log.debug(
`Unable to process received message from unknown provider '${obj.from}' with object message ID: '${obj._id}'. None or empty inner Message object was provided!`,
);
}
}
async enqueueAndSendMessage(
channelIndex,
messageObject,
msgUuid,
messageTemplate = null,
) {
let lookupChannelEnabled =
this.config.channels[channelIndex].channelEnabled;
let lookupChannelName = this.config.channels[channelIndex].channelName;
let lookupChannelToken =
this.config.channels[channelIndex].channelAccessToken;
let lookupChannelContentCertCheck =
this.config.channels[channelIndex].channelValidateCert;
let lookupChannelType = this.config.channels[channelIndex].channelType;
let lookupChannelObjectValueTemplate =
this.config.channels[channelIndex].channelObjectValueTemplate;
if (!lookupChannelEnabled) {
this.log.debug(
`Channel '${lookupChannelName}' was disabled in the adapter instance configuration! > Checking next channel...`,
);
} else if (lookupChannelType.toLowerCase() == "incoming") {
this.log.debug(`Adding message '${msgUuid}' to the send queue...`);
this.messageQueue.push(msgUuid);
// Adding message queue to ensure messages will send in the incoming order
let j = 0;
for (j = 0; j < 30; j++) {
if (this.messageQueue[0] == msgUuid) {
let formattedMessage = "";
if (messageTemplate) {
formattedMessage = this.formatReceivedOnMessageData(
messageObject,
messageTemplate,
);
} else {
if (lookupChannelObjectValueTemplate) {
this.log.debug(
`Parsing template '${lookupChannelObjectValueTemplate}' for provided message...`,
);
formattedMessage = this.formatObjectMessageData(
messageObject,
this.config[lookupChannelObjectValueTemplate],
);
} else {
formattedMessage = messageObject;
}
}
let messageWasSend = await this.synoChatRequestHandler.sendMessage(
lookupChannelToken,
lookupChannelType,
lookupChannelContentCertCheck,
String(formattedMessage),
msgUuid,
);
this.messageQueue.splice(this.messageQueue.indexOf(msgUuid), 1);
return messageWasSend;
}
this.log.debug(
`Message '${msgUuid}' still in the queue. Waiting for processing...`,
);
// Math.floor(Math.random() * (max - min + 1) + min)
await sleep(Math.floor(Math.random() * (1450 - 890 + 1) + 890));
}
if (j >= 30) {
this.log.error(
`Timeout for sending message '${msgUuid}'. Message will be discarded!`,
);
return;
}
this.log.debug(
`Message '${msgUuid}' not successfully sent. > Lookup next configured channel...`,
);
} else {
this.log.debug(
`WARN: The found channel '${lookupChannelName}' for message '${msgUuid}' is not an incoming channel! > Checking next channel...`,
);
}
}
formatObjectMessageData(obj, formatTemplate) {
try {
obj = JSON.parse(obj);
} catch (e) {
this.log.error(`Unable to parse provided object message to JSON! ${e}`);
return "Unable to parse provided object message to JSON!";
}
let maxItter = 100000;
while (formatTemplate.match(/\$\{(.+?)\}/) && maxItter > 0) {
let wholeMatch = formatTemplate.match(/\$\{(.+?)\}/)[1];
let currentMatch = wholeMatch.split(".")[0];
if (wholeMatch === currentMatch) {
let replaceValue = currentMatch.split(".").reduce(function (o, k) {
return o && o[k.replaceAll("/-", ".")];
}, obj);
formatTemplate = formatTemplate.replaceAll(
String(`\${${currentMatch}}`),
String(replaceValue),
);
} else {
let maxItterB = 100000;
while (
formatTemplate.match(`\\$\\{${currentMatch}\\.(.+?)\\}`) &&
maxItter > 0
) {
let replacePattern = `${currentMatch}.${
formatTemplate.match(`\\$\\{${currentMatch}\\.(.+?)\\}`)[1]
}`;
// https://stackoverflow.com/questions/37611143/access-json-data-with-string-path
let replaceValue = replacePattern.split(".").reduce(function (o, k) {
return o && o[k.replaceAll("/-", ".")];
}, obj);
formatTemplate = formatTemplate.replaceAll(
String(`\${${replacePattern}}`),
JSON.stringify(replaceValue),
);
maxItterB--;
}
if (maxItterB <= 0) {
maxItter = 0;
}
}
maxItter--;
}
if (maxItter <= 0) {
this.log.error(
`Infinite loop while parsing JSON detected! Returning raw text!`,
);
formatTemplate = JSON.stringify(obj, undefined, 4);
}
return formatTemplate;
}
formatReceivedOnMessageData(obj, formatTemplate) {
let formattedMessage = formatTemplate;
if (formatTemplate === this.config.receivedNotificationManagerTemplate) {
const { instances } = obj.message.category;
const readableInstances = Object.entries(instances).map(
([instance, _entry]) =>
`${instance.substring("system.adapter.".length)}`,
);
formattedMessage = formattedMessage.replaceAll(
"${instances}",
String(readableInstances.join(", ")),
);
}
formattedMessage = formattedMessage.replaceAll(
"${command}",
String(obj.command),
);
formattedMessage = formattedMessage.replaceAll("${from}", String(obj.from));
formattedMessage = formattedMessage.replaceAll("${_id}", String(obj._id));
formattedMessage = formattedMessage.replaceAll(
"${message}",
String(JSON.stringify(obj.message, undefined, 4)),
);
let maxItter = 100000;
while (formattedMessage.match(/\$\{message\.(.+?)\}/) && maxItter > 0) {
let replacePattern = formattedMessage.match(/\$\{message\.(.+?)\}/)[1];
let replaceValue = replacePattern.split(".").reduce(function (o, k) {
return o && o[k.replaceAll("/-", ".")];
}, obj.message);
formattedMessage = formattedMessage.replaceAll(
String(`\${message.${replacePattern}}`),
JSON.stringify(replaceValue),
);
maxItter--;
}
if (maxItter <= 0) {
this.log.error(
`Infinite loop while parsing JSON detected! Returning raw text!`,
);
formattedMessage = obj.message;
}
return formattedMessage;
}
}
if (require.main !== module) {
// Export the constructor in compact mode
/**
* @param [options] Options for instantiating the adapter istance
*/
module.exports = (options) => new Synochat(options);
} else {
// otherwise start the instance directly
new Synochat();
}