@emby-chat/node-sdk
Version:
Official Node SDK for Emby Chat
799 lines (681 loc) • 31.4 kB
JavaScript
const crypto = require('crypto');
const https = require('https');
const http = require('http');
const url = require('url');
const querystring = require('querystring');
const processUserRights = require('./libs/processUserRights');
const _ = require('./libs/helpers');
const strRandom = function(len = 10) {
let text = '';
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
for(let i = 0; i < len; i++)
{
text+= charset.charAt(Math.floor(Math.random() * charset.length));
}
return text;
}
const flatten = function(obj, prefix = '', posfix = '')
{
const data = {};
Object.keys(obj).forEach(item => {
if(typeof(obj[item]) === 'object') {
const values = flatten(obj[item], '[', ']');
if(Object.keys(values).length) {
Object.keys(values).forEach(_item => {
data[`${prefix}${item}${posfix}${_item}`] = values[_item];
})
}
}
else {
data[`${prefix}${item}${posfix}`] = obj[item];
}
})
return data;
}
const _trim = function(value)
{
return _.isString(value) ? value.trim() : value;
}
const normalizeData = function(data, filter = [])
{
const result = {};
if(!_.isPlainObject(data)) {
throw new Error(`first parameter have to be a plain object type`);
}
let filterFields = [];
if(_.isFilledPlainObject(filter)) {
filterFields = Object.keys(filter);
}
else if(_.isFilledArray(filter)) {
filterFields = filter;
}
data = Object.assign({}, data);
if (filterFields.length)
{
filterFields.forEach(key => {
if (_.isFunction( _.getValue(filter, [key, 'process']) ))
{
const _data = filter[key].process(data[key]);
if(_data !== undefined) {
result[key] = _trim(_data);
}
}
else if(_.isNoValue(data[key]))
{
if( _.getValue(filter, [key, 'default'], undefined) !== undefined ) {
result[key] = _trim(filter[key]['default']);
}
}
else {
result[key] = _trim(data[key]);
}
})
}
else { return data; }
return result;
}
const normalizeChat = function (chat) {
return normalizeData(chat, ['id', 'title', 'socket_port', 'create', 'type', 'metadata']);
}
const normalizeParticipant = function(participant) {
return normalizeData(participant, { id: null, name: null, email: null, link: null, picture: null, is_bot: { default: false } });
}
const packObjectForSignature = function(obj, key = '', hasKey = true) {
const packed = [];
_.sort(Object.keys(obj)).forEach(k => {
packed.push(`${key ? `${key}.` : ''}${k}=${obj[k]}`);
});
return packed;
}
const addToSignature = function(signature, data, filterKeys)
{
signature = Array.prototype.slice.call(signature);
let keys = Object.keys(data);
if (keys.length)
{
if(_.isFilledArray(filterKeys)) {
// only specified keys and in that order
keys = filterKeys.filter(key => keys.indexOf(key) > -1);
}
else {
// canonical order
keys = _.sort(keys);
}
keys.forEach(key => {
switch(_.getType(data[key])) {
case _.TYPES.SCALAR:
signature.push(data[key]);
break;
case _.TYPES.OBJECT:
packObjectForSignature(data[key], key, false).forEach(element => {
signature.push(element);
});
break;
}
});
}
return signature;
}
/**
* @typedef {'private' | 'group' | 'supergroup' | 'channel'} ChatType
*/
class Emby {
constructor(config = {}) {
this.clientId = config.id;
this.clientSecret = config.secret;
this.apiToken = config.api_token;
this.baseUrl = config.base_url;
this.apiUrl = config.api_url || this.baseUrl;
if(_.isString(this.baseUrl)) {
// remove trailing slash
this.baseUrl = this.baseUrl.replace(/\/+$/g, '');
}
}
/**
* Make a request to the API.
*
* @param {string} method - The API method to be called. This parameter is required.
* @param {Object} [params={}] - The parameters to be passed to the API method.
* @param {string} [type='get'] - The HTTP request type (e.g., 'get', 'post'). Defaults to 'get'.
* @param {string} [version='v1'] - The API version to use. Defaults to 'v1'.
* @returns {Promise<Object>} A promise that resolves to the API response.
*/
requestApi(method, params = {}, type = 'get', version = 'v1')
{
let sParams = '';
let _url = `${this.apiUrl}/api/${version}/${method}`;
if(!(type === 'post' || type === 'put')) {
_url+= '?'+querystring.stringify(flatten(params))
}
_url = encodeURI(_url);
const urlParts = url.parse(_url);
const options = {
method: type.toUpperCase(),
...(_.onlyProps(urlParts, ['hostname', 'port', 'path'])),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiToken}`
}
}
if(type === 'post' || type === 'put') {
sParams = JSON.stringify(params);
}
return new Promise((resolve, reject) => {
const request = (urlParts.protocol === 'https:' ? https : http).request(options, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
const contentType = res.headers['content-type'];
if (contentType === 'application/json') {
try {
body = JSON.parse(body);
} catch (e) {
reject(e);
}
}
if (res.statusCode >= 200 && res.statusCode < 400) {
resolve(body);
}
else {
const e = new Error(body);
e.status = res.statusCode;
reject(e);
}
});
})
.on('error', (e) => {
reject(e);
});
if(sParams.length) {
request.write(sParams);
}
request.end();
});
}
/**
* Generate a chat URL.
*
* @param {Object} options - The options object containing all parameters.
* @param {Object} [options.chat] - The chat object containing chat details. This parameter is required.
* @param {string} [options.chat.id] - The unique identifier for the chat. This parameter is required.
* @param {string} [options.chat.title] - The title of the chat.
* @param {Object} options.user - The user object containing user details. This parameter is required.
* @param {string} options.user.id - The unique identifier for the user.
* @param {string} [options.user.name] - The name of the user.
* @param {string} [options.user.email] - The email of the user.
* @param {string} [options.user.picture] - The picture URL of the user.
* @param {string} [options.user.link] - The link associated with the user.
* @param {Object} [options.user.rights] - The rights object containing user permissions.
* @param {boolean} [options.user.rights.send_messages=true] - Enable or disable sending messages.
* @param {("none"|"my"|"any")} [options.user.rights.edit_messages="my"] - Permission to edit messages.
* @param {("none"|"my"|"any")} [options.user.rights.delete_messages="my"] - Permission to delete messages.
* @param {("none"|"for_me"|"for_everyone")} [options.user.rights.pin_messages="for_me"] - Permission to pin messages.
* @param {boolean} [options.user.rights.send_photos=false] - Enable or disable sending photos.
* @param {boolean} [options.user.rights.send_audio=false] - Enable or disable sending audio.
* @param {boolean} [options.user.rights.send_documents=false] - Enable or disable sending documents.
* @param {boolean} [options.user.rights.send_location=false] - Enable or disable sending location.
* @param {boolean} [options.user.rights.create_pools=false] - Enable or disable creating pools.
* @param {boolean} [options.user.rights.vote_pool=false] - Enable or disable voting in pools.
* @param {boolean} [options.user.rights.kick_users=false] - Enable or disable kicking users.
* @param {Object[]} [options.participants=[]] - An array of recipient objects.
* @param {string} options.participants[].id - The unique identifier for the recipient.
* @param {string} options.participants[].name - The name of the recipient.
* @param {boolean} [options.participants[].is_bot=false] - Indicates if the recipient is a bot.
* @param {Object} [options.extra={}] - Additional options.
* @param {Object} [options.extra.skin_options] - Skin options for the chat interface.
* @param {boolean} [options.extra.skin_options.display_header=true] - Show or hide header.
* @param {boolean} [options.extra.skin_options.display_network_pane=true] - Show or hide network pane (only works for default skin).
* @param {boolean} [options.extra.skin_options.hide_day_delimiter=false] - Hide date delimiter.
* @param {boolean} [options.extra.skin_options.hide_deleted_message=false] - If true, deleted messages won't be displayed.
* @param {number} [options.extra.skin_options.message_max_length=0] - Set limit for input message length (0 means no limit).
* @param {("en"|"pt"|"ru")} [options.extra.skin_options.lang="en"] - Set language for skin.
*
* @returns {string} The generated chat URL.
*/
url({chat = null, user = {}, participants = [], extra = {} })
{
if(! this.clientId) {
throw new Error('To generate chat URL client id is required, please set it in the constructor config');
}
if(! this.clientSecret) {
throw new Error('To generate chat URL client secret is required, please set it in the constructor config');
}
// check chat parameter
if(_.isPlainObject(chat)) {
chat = normalizeChat(chat);
}
else if(_.isString(chat)) {
chat = {id: chat};
}
else {
chat = null;
}
// check user parameter
if(_.isPlainObject(user)) {
user = normalizeData(user, {
id: null,
name: null,
email: null,
picture: null,
rights: {
process: (data) => {
if (_.isFilledPlainObject(data))
{
const userRights = processUserRights(data);
if(userRights && Object.keys(userRights).length) {
return userRights;
}
}
return undefined;
}
},
session: {
process: (data) => {
if (!user.id) {
return _.isString(data) ? data : strRandom(40)
}
return undefined;
}
}
});
}
else {
throw new Error('user parameter have to be a plain object');
}
const nonce = strRandom(32);
let signatureParams = [
this.clientId,
nonce
];
const queryParams = {
'nonce': nonce,
'user': user,
'recipients': []
};
signatureParams = addToSignature(signatureParams, user, ['id', 'name', 'email', 'link', 'picture', 'rights']);
participants.forEach(participant => {
participant = normalizeData(participant, {id: null, name: null, is_bot: {default: false}});
queryParams['recipients'].push(participant)
signatureParams = addToSignature(signatureParams, participant, ['id', 'name']);
});
if (chat) {
signatureParams = addToSignature(signatureParams, chat, ['id', 'title', 'socket_port', 'create'])
queryParams['chat'] = chat;
}
queryParams['signature'] = crypto.createHmac('sha256', this.clientSecret).update(signatureParams.join(',')).digest('hex');
Object.keys(extra).forEach(key => {
queryParams[key] = extra[key];
});
const query = querystring.stringify(flatten(queryParams));
return `${this.baseUrl}?${query}`;
}
/**
* Generate a chat URL by chat ID.
*
* @param {Object} chat - The chat object containing chat details. This parameter is required.
* @param {string} chat.id - The unique identifier for the chat. This parameter is required.
* @param {string} chat.title - The title of the chat.
* @param {Object} user - The user object containing user details. This parameter is required.
* @param {string} user.id - The unique identifier for the user.
* @param {string} user.name - The name of the user.
* @param {string} [user.email] - The email of the user.
* @param {string} [user.picture] - The picture URL of the user.
* @param {string} [user.link] - The link associated with the user.
* @param {Object} [user.rights] - The rights object containing user permissions.
* @param {boolean} [user.rights.send_messages=true] - Enable or disable sending messages.
* @param {("none"|"my"|"any")} [user.rights.edit_messages="my"] - Permission to edit messages.
* @param {("none"|"my"|"any")} [user.rights.delete_messages="my"] - Permission to delete messages.
* @param {("none"|"for_me"|"for_everyone")} [user.rights.pin_messages="for_me"] - Permission to pin messages.
* @param {boolean} [user.rights.send_photos=false] - Enable or disable sending photos.
* @param {boolean} [user.rights.send_audio=false] - Enable or disable sending audio.
* @param {boolean} [user.rights.send_documents=false] - Enable or disable sending documents.
* @param {boolean} [user.rights.send_location=false] - Enable or disable sending location.
* @param {boolean} [user.rights.create_pools=false] - Enable or disable creating pools.
* @param {boolean} [user.rights.vote_pool=false] - Enable or disable voting in pools.
* @param {boolean} [user.rights.kick_users=false] - Enable or disable kicking users.
* @param {Object[]} [participants=[]] - An array of recipient objects.
* @param {string} participants[].id - The unique identifier for the recipient.
* @param {string} participants[].name - The name of the recipient.
* @param {boolean} [participants[].is_bot=false] - Indicates if the recipient is a bot.
* @param {Object} [extra={}] - Additional options.
* @param {Object} [extra.skin_options] - Skin options for the chat interface.
* @param {boolean} [extra.skin_options.display_header=true] - Show or hide header.
* @param {boolean} [extra.skin_options.display_network_pane=true] - Show or hide network pane (only works for default skin).
* @param {boolean} [extra.skin_options.hide_day_delimiter=false] - Hide date delimiter.
* @param {boolean} [extra.skin_options.hide_deleted_message=false] - If true, deleted messages won't be displayed.
* @param {number} [extra.skin_options.message_max_length=0] - Set limit for input message length (0 means no limit).
* @param {("en"|"pt"|"ru")} [extra.skin_options.lang="en"] - Set language for skin.
*
* @returns {string} The generated chat URL.
*/
urlByChatId(chat = {}, user = {}, participants = [], extra = {})
{
if(! this.clientId) {
throw new Error('To generate chat URL client id is required, please set it in the constructor config');
}
if(! this.clientSecret) {
throw new Error('To generate chat URL client secret is required, please set it in the constructor config');
}
// check chat parameter
if(_.isPlainObject(chat)) {
chat = normalizeChat(chat);
}
else if(_.isString(chat)) {
chat = {id: chat};
}
else {
throw new Error('first parameter(chat) have to be a plain object or string');
}
if(!_.isString(chat.id)) {
throw new Error('chat id isn\'t passed');
}
// check user parameter
if(_.isPlainObject(user)) {
user = normalizeData(user, {
id: null,
name: null,
email: null,
picture: null,
rights: {
process: (data) => {
if (_.isFilledPlainObject(data))
{
const userRights = processUserRights(data);
if(userRights && Object.keys(userRights).length) {
return userRights;
}
}
return undefined;
}
},
session: {
process: (data) => {
if (!user.id) {
return _.isString(data) ? data : strRandom(40)
}
return undefined;
}
}
});
}
else {
throw new Error('second parameter(user) have to be a plain object');
}
const nonce = strRandom(32);
let signatureParams = [
this.clientSecret,
nonce
];
const queryParams = {
'nonce': nonce,
'chat': chat,
'user': user,
'recipients': []
};
signatureParams = addToSignature(signatureParams, user, ['id', 'name', 'email', 'link', 'picture']);
participants.forEach(participant => {
participant = normalizeData(participant, {id: null, name: null, is_bot: {default: false}});
queryParams['recipients'].push(participant)
signatureParams = addToSignature(signatureParams, participant, ['id', 'name']);
});
signatureParams = addToSignature(signatureParams, chat, ['id', 'title', 'socket_port', 'create'])
queryParams['signature'] = crypto.createHash('md5').update(signatureParams.join(',')).digest('hex');
Object.keys(extra).forEach(key => {
queryParams[key] = extra[key];
});
const query = querystring.stringify(flatten(queryParams));
return `${this.baseUrl}?${query}`;
}
/**
* Retrieve chat details by chat ID.
*
* @param {string} chatId - The unique identifier for the chat. This parameter is required.
*
* @returns {Object[]} An Chat object.
*/
getChatInfo(id)
{
if(!_.isString(id)) {
throw new Error('chat id isn\'t passed');
}
return this.requestApi(`chats/${id}`);
}
/**
* Retrieve messages from a chat by chat ID.
*
* @param {string} chatId - The unique identifier for the chat. This parameter is required.
* @param {Object} queryParams - The query parameters to filter messages. This parameter is required.
* @param {number} [page=1] - The page number for pagination. Defaults to 1.
* @param {number} [limit=1] - The number of messages to retrieve per page. Defaults to 1.
*
* @returns {Object[]} An array of messages from the chat.
*/
getMessagesFromChat(chatId, queryParams, page = 1, limit = 1)
{
limit = Math.min(parseInt(limit, 10), 1000);
page = Math.max(parseInt(page, 10), 1);
let params = {
'page': page,
'limit': limit
};
if(_.isFilledPlainObject(queryParams)) {
const _params = {};
if(_.isFilledPlainObject(queryParams.extra)) {
const extraParams = {};
Object.keys(queryParams.extra).forEach(key => {
const value = queryParams.extra[key];
if(_.isScalar(value)) {
extraParams[key] = _.isBoolean(value, true) ? _.isTRUE(value) : value;
}
});
if(Object.keys(extraParams).length) {
_params.extra = extraParams;
}
}
if(_.isBoolean(queryParams.isDeleted, true)) {
_params.isDeleted = Number(_.isTRUE(queryParams.isDeleted));
}
if(_.isBoolean(queryParams.isEdited, true)) {
_params.isEdited = Number(_.isTRUE(queryParams.isEdited));
}
if(_.isBoolean(queryParams.withUsers, true)) {
_params.withUsers = Number(_.isTRUE(queryParams.withUsers));
}
if(Object.keys(_params).length) {
params = Object.assign({}, params, _params);
}
}
return this.requestApi(`chats/${chatId}/messages`, params);
}
/**
* Send a message to a chat.
*
* @param {Object | string} chat - The unique identifier for the chat. This parameter is required.
* @param {string} chat.id - The unique identifier for the chat. This parameter is required.
* @param {string} [chat.title] - The title of the chat. This parameter is required when creating a new chat.
* @param {ChatType} [chat.type] - The type of the chat. This parameter is required when creating a new chat.
* @param {Object} [chat.metadata] - Additional metadata for the chat. This parameter is optional.
* @param {Object} user - The user object sending the message. This parameter is required.
* @param {string} user.id - The unique identifier for the user.
* @param {string} [user.name] - The name of the user.
* @param {string} [user.email] - The email of the user.
* @param {string} [user.picture] - The picture URL of the user.
* @param {string} [user.link] - The link associated with the user.
* @param {Object[]} [participants] - An array of recipient objects. Backend takes into participants only for the new chat, otherwise it will be ignored.
* @param {string} participants[].id - The unique identifier for the recipient.
* @param {string} participants[].name - The name of the recipient.
* @param {string} [participants[].email] - The name of the recipient.
* @param {string} [participants[].picture] - The name of the recipient.
* @param {string} [participants[].link] - The name of the recipient.
* @param {boolean} [participants[].is_bot=false] - Indicates if the recipient is a bot.
* @param {string | Object} message - The message content to be sent. If object, it must contain `text` and may contain optional `recipient_id`.
* @param {string} message.text - The message text (required when `message` is an object).
* @param {string} [message.recipient_id] - Optional recipient id for this message.
* @param {Object} [extra] - Additional options for the message.
* @param {Object[]} [buttons=[]] - An array of button objects to be included with the message.
* @param {string} buttons[].label - The label of the button. This parameter is required.
* @param {string} buttons[].action - The action associated with the button. This parameter is required.
* @param {string} buttons[].type - The type of the button (local or remote). This parameter is required.
* @param {string} [buttons[].style] - The style of the button.
*
* @returns {Promise<Object>} A promise that resolves to the response of the send message action.
*/
sendMessage(chat, user, participants, message, extra = {}, buttons = [])
{
const queryParams = {
'user': user,
'participants': participants
};
const messageData = {};
if (_.isPlainObject(message)) {
message = normalizeData(message, ['text', 'recipient_id']);
if (_.isString(message.text)) {
messageData.text = message.text;
}
if (!_.isNoValue(message.recipient_id)) {
messageData.recipient_id = message.recipient_id;
}
}
else if (_.isString(message)) {
messageData.text = message;
}
if(! (_.isString(messageData.text) && messageData.text.length)) {
throw new Error('message text is required');
}
// check chat parameter
if(_.isPlainObject(chat)) {
chat = normalizeChat(chat);
}
else if(_.isString(chat)) {
chat = {id: chat};
}
else {
throw new Error('first parameter(chat) have to be a plain object or string');
}
if(!_.isString(chat.id)) {
if(! _.isNumeric(chat.id)) {
throw new Error('chat id isn\'t passed');
}
chat.id = chat.id.toString();
}
const chatId = chat.id;
delete chat.id;
if (Object.keys(chat).length) {
queryParams.chat = chat;
}
if (_.isFilledPlainObject(extra))
{
messageData.extra = extra;
}
if (_.isFilledArray(buttons))
{
messageData.buttons = buttons;
}
if (_.isFilledArray(participants)) {
const _participants = participants.map(normalizeParticipant);
if (_.isFilledArray(_participants)) {
queryParams.participants = _participants;
}
}
queryParams.messages = [messageData];
return this.requestApi(`chats/${chatId}/messages`, queryParams, 'post');
}
/**
* Update a message in a chat.
*
* @param {string} chatId - The unique identifier for the chat. This parameter is required.
* @param {string} messageId - The unique identifier for the message. This parameter is required.
* @param {Object} updateData - The data to update the message with. This parameter is required.
* @param {string} updateData.text - The new text content of the message.
* @param {boolean} [updateData.isDeleted=false] - Flag indicating if the message is deleted.
* @param {Object} [updateData.extra={}] - Additional options for the message.
* @param {Object[]} [updateData.buttons=[]] - An array of button objects to be included with the message.
* @param {string} updateData.buttons[].label - The label of the button. This parameter is required.
* @param {string} updateData.buttons[].action - The action associated with the button. This parameter is required.
* @param {string} [updateData.buttons[].type] - The type of the button (local or remote).
* @param {string} [updateData.buttons[].style] - The style of the button.
* @param {Object} [options={}] - Additional options for the update operation.
* @param {boolean} [options.replaceExtra=false] - Flag to replace the existing extra options with the new ones.
* @param {boolean} [options.returnMessage=false] - Flag to return the updated message.
*
* @returns {Promise<Object>} A promise that resolves to the response of the update message action.
*/
updateMessage(chatId, messageId, {text, isDeleted = false, extra = {}, buttons = []}, {replaceExtra = false, returnMessage = false} = {})
{
const params = {message: {}};
if(_.isString(text) && text.length) {
params.message.text = text;
}
if(_.isTRUE(isDeleted)) {
params.message.is_deleted = "1";
delete params.message.text;
}
if(_.isFilledPlainObject(extra)) {
params.message.extra = extra;
}
if(_.isFilledArray(buttons)) {
params.message.buttons = buttons;
}
params.update_extra_mode = (replaceExtra === true ? 'replace' : 'merge');
if(returnMessage === true) {
params.return_message = '1'
}
return this.requestApi(`chats/${chatId}/messages/${messageId}`, params, 'put');
}
/**
* Update a message in a chat.
*
* @param {string} chatId - The unique identifier for the chat. This parameter is required.
* @param {string} messageId - The unique identifier for the message. This parameter is required.
*
* @returns {Promise<Object>} A promise that resolves to the response of the update message action.
*/
deleteMessage(chatId, messageId)
{
const params = { message: {} };
params.message.is_deleted = "1";
return this.requestApi(`chats/${chatId}/messages/${messageId}`, params, 'put');
}
/**
* Send a typing indicator to a chat.
*
* @param {string} chatId - The unique identifier for the chat. This parameter is required.
* @param {string} userId - The unique identifier for the user. This parameter is required.
*
* @returns {Promise<void>} A promise that resolves when the typing indicator has been sent.
*/
sendTyping(chatId, userId)
{
const queryParams = {
'user': userId,
};
return this.requestApi(`chats/${chatId}/typing`, queryParams, 'put');
}
/**
* Add a participants to a chat.
*
* @param {string} chatId - The unique identifier for the chat. This parameter is required.
* @param {Object[]} participants=[] - An array of participant objects. This parameter is required.
* @param {string} participants[].id - The unique identifier for the recipient. This parameter is required.
* @param {string} participants[].name - The name of the recipient.
* @param {string} [participants[].email] - The name of the recipient.
* @param {string} [participants[].picture] - The name of the recipient.
* @param {string} [participants[].link] - The name of the recipient.
* @param {boolean} [participants[].is_bot=false] - Indicates if the recipient is a bot.
*
* @returns {Promise<Object>} A promise that resolves to the response of the send message action.
*/
addParticipantsToChat(chatId, participants = [])
{
if(! _.isFilledArray(participants)) {
throw new Error('participants have to be an array of participant objects');
}
const queryParams = {
participants: participants.map(normalizeParticipant)
};
return this.requestApi(`chats/${chatId}/participants`, queryParams, 'post');
}
}
module.exports = Emby;