iobroker.telegram
Version:
The adapter allows to send and receive telegram messages from ioBroker and to be a broker.
1,041 lines (1,040 loc) • 132 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
const node_https_1 = require("node:https");
const axios_1 = __importDefault(require("axios"));
const node_telegram_bot_api_1 = __importDefault(require("node-telegram-bot-api"));
const adapter_core_1 = require("@iobroker/adapter-core");
const webserver_1 = require("@iobroker/webserver");
const iobUri_1 = require("./iobUri");
const systemLang2CallMe = {
en: 'en-GB-Standard-A',
de: 'de-DE-Standard-A',
ru: 'ru-RU-Standard-A',
pt: 'pt-BR-Standard-A',
nl: 'nl-NL-Standard-A',
fr: 'fr-FR-Standard-A',
it: 'it-IT-Standard-A',
es: 'es-ES-Standard-A',
pl: 'pl-PL-Standard-A',
uk: 'uk-UA-Standard-A',
'zh-cn': 'en-GB-Standard-A',
};
const actions = [
'typing',
'upload_photo',
'upload_video',
'record_video',
'record_audio',
'upload_document',
'find_location',
];
class Telegram extends adapter_core_1.Adapter {
bot;
storedUsers = {};
storedChats = {};
systemLang = 'en';
reconnectTimer;
pollConnectionStatus;
isConnected = null;
lastMessageTime = 0;
lastMessageText = '';
enumsCache = { rooms: {} };
protection = {};
gcInterval;
commands = {};
callbackQueryId = {};
mediaGroupExport = {};
tmpDirName = '';
questions = [];
garbageCollectorInterval;
isServer = false;
/**
* In-memory queue of outgoing sends that failed because telegram was unreachable. They are resent (FIFO)
* as soon as the connection is re-established. See issue #839. The queue is intentionally NOT persisted,
* so an adapter restart clears it.
*/
sendQueue = [];
sendQueueFlushing = false;
sendQueueRetryTimer;
static MAX_SEND_QUEUE_LENGTH = 100;
static SEND_QUEUE_RETRY_MS = 30000;
static MAX_SEND_QUEUE_AGE_MS = 24 * 60 * 60 * 1000; // 24 h
static MAX_SEND_ATTEMPTS = 10;
server = {
server: null,
settings: null,
};
constructor(options = {}) {
super({
...options,
name: 'telegram',
error: (err) => {
// Identify unhandled errors originating from callbacks in scripts
// These are not caught by wrapping the execution code in try-catch
if (err) {
const errStr = err.toString();
if (errStr.includes('getaddrinfo') ||
errStr.includes('api.telegram.org') ||
errStr.includes('EAI_AGAIN')) {
return true;
}
}
return false;
},
ready: () => this.onReady(),
message: obj => this.onMessage(obj),
unload: callback => this.onUnload(callback),
stateChange: (id, state) => this.onStateChange(id, state),
objectChange: (id, obj) => this.onObjectChange(id, obj),
});
this.server.settings = this.config;
}
onMessage(obj) {
if (obj) {
if (obj.command === 'adminuser') {
let adminUserData;
this.getState('communicate.users', (err, state) => {
if (err) {
this.log.error(err.toString());
}
if (state?.val) {
try {
adminUserData = JSON.parse(state.val);
this.sendTo(obj.from, obj.command, adminUserData, obj.callback);
}
catch (err) {
this.log.error(err);
this.log.error('Cannot parse stored user IDs!');
}
}
});
}
else if (obj.command === 'delUser') {
const userID = obj.message;
let userObj = {};
this.getState('communicate.users', (err, state) => {
if (err) {
this.log.error(err.toString());
}
if (state?.val) {
try {
userObj = JSON.parse(state.val);
delete userObj[userID];
this.setState('communicate.users', JSON.stringify(userObj), true)
.then(() => {
this.sendTo(obj.from, obj.command, userID, obj.callback);
void this.updateUsers();
this.log.warn(`User ${userID} has been deleted!`);
})
.catch(e => this.log.error(`Cannot set state communicate.users: ${e}`));
}
catch (err) {
this.log.error(err);
this.log.error(`Cannot delete user ${userID}!`);
}
}
});
}
else if (obj.command === 'systemMessages') {
const userID = obj.message.itemId;
const checked = obj.message.checked;
let userObj = {};
this.getState('communicate.users', (err, state) => {
if (err) {
this.log.error(err.toString());
}
if (state?.val) {
try {
userObj = JSON.parse(state.val);
userObj[userID].sysMessages = checked;
this.setState('communicate.users', JSON.stringify(userObj), true)
.then(() => {
this.sendTo(obj.from, obj.command, userID, obj.callback);
void this.updateUsers();
this.log.info(`Receiving of system messages for user "${userID}" has been changed to ${checked}!`);
})
.catch(e => this.log.error(`Cannot set state communicate.users: ${e}`));
}
catch (err) {
this.log.error(err);
this.log.error(`Cannot change user ${userID}!`);
}
}
});
}
else if (obj.command === 'delAllUser') {
try {
this.setState('communicate.users', '{}', true)
.then(() => {
this.sendTo(obj.from, obj.command, true, obj.callback);
void this.updateUsers();
this.log.warn('List of saved users has been wiped. Every User has to reauthenticate with the new password!');
})
.catch(e => this.log.error(`Cannot set state communicate.users: ${e}`));
}
catch (err) {
this.log.error(err);
this.log.error('Cannot wipe list of saved users!');
}
}
else if (obj.command === 'sendNotification') {
this.processNotification(obj).catch(err => this.log.error(err));
}
else {
this.processMessage(obj).catch(err => this.log.error(err));
}
}
}
async onReady() {
this.isServer = this.config.server === 'true';
// i18n JSON files live in `<packageRoot>/i18n`; main.js runs from `<packageRoot>/build`
await adapter_core_1.I18n.init((0, node_path_1.join)(__dirname, '..'), this);
this.questions = [];
this.garbageCollectorInterval = this.setInterval(() => {
const now = Date.now();
Object.keys(this.callbackQueryId).forEach(id => {
if (now - this.callbackQueryId[id].ts > 120000) {
delete this.callbackQueryId[id];
}
});
}, 10000);
this.tmpDirName = (0, node_path_1.join)((0, adapter_core_1.getAbsoluteDefaultDataDir)(), this.namespace.replace('.', '_'));
// Create file system directories for media files
if (this.config.saveFilesTo == 'filesystem') {
try {
if (!(0, node_fs_1.existsSync)(this.tmpDirName)) {
(0, node_fs_1.mkdirSync)(this.tmpDirName);
}
const subDirectories = ['voice'];
if (this.config.saveFiles) {
// Create subdirs for other attachment types
subDirectories.push('photo', 'video', 'audio', 'document');
}
for (const subDir of subDirectories) {
const subDirPath = (0, node_path_1.join)(this.tmpDirName, subDir);
if (!(0, node_fs_1.existsSync)(subDirPath)) {
(0, node_fs_1.mkdirSync)(subDirPath);
}
}
}
catch (err) {
this.log.error(`Cannot create tmp directory: ${this.tmpDirName}: ${err}`);
}
}
if (this.isServer) {
this.config.port = parseInt(String(this.config.port), 10);
// Load certificates
this.getCertificates(undefined, undefined, undefined, async (err, certificates, leConfig) => {
this.config.certificates = certificates;
this.config.leConfig = leConfig;
this.config.secure = true;
try {
const webserver = new webserver_1.WebServer({
app: (req, res) => this.handleWebHook(req, res),
adapter: this,
secure: this.config.secure,
});
this.server.server = (await webserver.init());
}
catch (err) {
this.log.error(`Cannot create webserver: ${err}`);
this.terminate(adapter_core_1.EXIT_CODES.ADAPTER_REQUESTED_TERMINATION);
return;
}
if (this.server.server) {
this.server.server.__server = this.server;
let serverListening = false;
let serverPort = this.config.port;
this.server.server.on('error', (e) => {
if (e.toString().includes('EACCES') && serverPort <= 1024) {
this.log.error(`node.js process has no rights to start server on the port ${serverPort}.\n` +
`Do you know that on linux you need special permissions for ports under 1024?\n` +
`You can call in shell following scrip to allow it for node.js: "iobroker fix"`);
}
else {
this.log.error(`Cannot start server on ${this.config.bind || '0.0.0.0'}:${serverPort}: ${e}`);
}
if (!serverListening) {
this.terminate
? this.terminate(adapter_core_1.EXIT_CODES.ADAPTER_REQUESTED_TERMINATION)
: process.exit(adapter_core_1.EXIT_CODES.ADAPTER_REQUESTED_TERMINATION);
}
});
this.getPort(this.config.port, !this.config.bind || this.config.bind === '0.0.0.0' ? undefined : this.config.bind || undefined, port => {
if (parseInt(String(port), 10) !== this.config.port && !this.config.findNextPort) {
this.log.error(`port ${this.config.port} already in use`);
this.terminate ? this.terminate() : process.exit(1);
}
serverPort = port;
this.server.server?.listen(port, !this.config.bind || this.config.bind === '0.0.0.0'
? undefined
: this.config.bind || undefined, () => (serverListening = true));
this.log.info(`https server listening on port ${port}`);
this.main().catch(e => this.log.error(`Cannot start adapter: ${e}`));
});
}
});
}
else {
this.main().catch(e => this.log.error(`Cannot start adapter: ${e}`));
}
}
onUnload(callback) {
if (this.reconnectTimer) {
this.clearInterval(this.reconnectTimer);
this.reconnectTimer = undefined;
}
if (this.gcInterval) {
this.clearInterval(this.gcInterval);
this.gcInterval = undefined;
}
if (this.pollConnectionStatus) {
this.clearTimeout(this.pollConnectionStatus);
this.pollConnectionStatus = undefined;
}
if (this.sendQueueRetryTimer) {
this.clearTimeout(this.sendQueueRetryTimer);
this.sendQueueRetryTimer = undefined;
}
if (this.garbageCollectorInterval) {
this.clearInterval(this.garbageCollectorInterval);
this.garbageCollectorInterval = undefined;
}
// cancel any pending question answer-timeouts so they cannot fire after unload
this.questions?.forEach(q => q.timeout && this.clearTimeout(q.timeout));
this.questions = [];
if (this.config) {
if (this.config.restarting !== '') {
// default text
if (this.config.restarting === '_' ||
this.config.restarting === null ||
this.config.restarting === undefined) {
this.sendSystemMessage(this.config.rememberUsers
? adapter_core_1.I18n.translate('Restarting...')
: adapter_core_1.I18n.translate('Restarting... Reauthenticate!')).catch(err => this.log.error(err));
}
else {
this.sendSystemMessage(this.config.restarting).catch(err => this.log.error(err));
}
}
try {
if (this.server.server) {
this.server.server.close();
}
}
catch (e) {
console.error(`Cannot close server: ${e}`);
}
}
if (this.isConnected && this.setState) {
this.setState('info.connection', false, true).catch(e => this.log.error(`Cannot set state: ${e}`));
}
this.isConnected = false;
callback();
}
// This handler is called if a subscribed state changes
async onStateChange(id, state) {
if (state) {
if (!state.ack) {
if (id.endsWith('communicate.response')) {
if (typeof state.val === 'object') {
this.log.error(`communicate.response only supports passing a message to send as string. You provided ${JSON.stringify(state.val)}. Please use "communicate.responseJson" instead with a stringified JSON object!`);
return;
}
// Send to someone this message
await this.sendMessage(state.val);
await this.setState('communicate.response', { val: state.val, ack: true });
}
else if (id.endsWith('communicate.responseSilent')) {
if (typeof state.val === 'object') {
this.log.error(`communicate.responseSilent only supports passing a message to send as string. You provided ${JSON.stringify(state.val)}. Please use "communicate.responseSilentJson" instead with a stringified JSON object!`);
return;
}
// Send to someone this message
await this.sendMessage(state.val, null, null, { disable_notification: true });
await this.setState('communicate.responseSilent', { val: state.val, ack: true });
}
else if (id.endsWith('communicate.responseJson')) {
try {
const val = JSON.parse(state.val);
let options;
let text;
let chatId;
let user;
if (val && typeof val === 'object' && val.text !== undefined && typeof val.text === 'string') {
options = val;
text = options.text;
if (options.chatId) {
chatId = options.chatId;
}
if (options.user) {
user = options.user;
}
}
else {
text = val;
}
if (text) {
// Send to someone this message
await this.sendMessage(text, user, chatId, options);
}
else {
this.log.warn(`Invalid message: no text found: ${JSON.stringify(state.val)}`);
}
await this.setState('communicate.responseJson', { val: state.val, ack: true });
}
catch (err) {
this.log.error(`could not parse Json in communicate.responseJon state: ${err instanceof Error ? err.message : err}`);
}
}
else if (id.endsWith('communicate.responseSilentJson')) {
try {
const val = JSON.parse(state.val);
let options;
let text;
let chatId;
let user;
if (val && typeof val === 'object' && val.text !== undefined && typeof val.text === 'string') {
options = val;
text = options.text;
if (options.chatId) {
chatId = options.chatId;
}
if (options.user) {
user = options.user;
}
}
else {
text = val;
}
if (text) {
// Send to someone this message
await this.sendMessage(text, user, chatId, { disable_notification: true, ...options });
}
else {
this.log.warn(`Invalid message: no text found: ${JSON.stringify(state.val)}`);
}
await this.setState('communicate.responseSilentJson', { val: state.val, ack: true });
}
catch (err) {
this.log.error(`could not parse Json in communicate.responseSilentJon state: ${err instanceof Error ? err.message : err}`);
}
}
else if (id.endsWith('communicate.requestResponse')) {
try {
const text = state.val;
const chatIdState = await this.getStateAsync('communicate.requestChatId');
const threadIdState = await this.getStateAsync('communicate.requestMessageThreadId');
const options = {};
if (threadIdState && threadIdState.val > 0) {
options.message_thread_id = threadIdState.val;
}
// Send to someone this message
await this.sendMessage(text, null, chatIdState ? chatIdState.val : null, options);
await this.setState('communicate.requestResponse', { val: state.val, ack: true });
}
catch (err) {
this.log.error(`could not parse Json in communicate.requestResponse state: ${err instanceof Error ? err.message : err}`);
}
}
}
else if (this.commands[id]?.report) {
this.log.debug(`reporting state change of ${id}: ${JSON.stringify(this.commands[id])}`);
const options = this.commands[id].reportSilent == true ? { disable_notification: true } : {};
let users = null;
if (this.commands[id]?.recipients) {
users = this.commands[id].recipients;
}
if (this.commands[id].reportChanges) {
if (state.val !== this.commands[id].lastState) {
this.commands[id].lastState = state.val;
await this.sendMessage(this.getStatus(id, state), users, null, options);
}
}
else {
await this.sendMessage(this.getStatus(id, state), users, null, options);
}
}
}
}
onObjectChange(id, obj) {
if (obj?.common?.custom?.[this.namespace]?.enabled) {
const stateObj = obj;
const alias = this.getName(stateObj);
if (!this.commands[id]) {
this.log.info(`enabled logging of ${id}, Alias=${alias}`);
setImmediate(() => this.subscribeForeignStates(id));
}
this.commands[id] = stateObj.common.custom?.[this.namespace];
this.commands[id].type = stateObj.common.type;
this.commands[id].states = this.parseStates(stateObj.common?.states);
this.commands[id].unit = stateObj.common?.unit;
this.commands[id].min = stateObj.common?.min;
this.commands[id].max = stateObj.common?.max;
this.commands[id].alias = alias;
// read actual state to detect changes
if (this.commands[id].reportChanges) {
this.getForeignStateAsync(id)
.then(state => (this.commands[id].lastState = state ? state.val : undefined))
.catch(err => this.log.error(err));
}
}
else if (this.commands[id]) {
this.log.debug(`Removed command: ${id}`);
delete this.commands[id];
setImmediate(() => this.unsubscribeForeignStates(id));
}
else if (id.startsWith('enum.rooms') && this.config.rooms) {
if (obj?.common?.members?.length) {
this.enumsCache.rooms[id] = obj.common;
}
else if (this.enumsCache.rooms[id]) {
delete this.enumsCache.rooms[id];
}
}
}
/**
* Send a message to all system users
*
* @param text text to send
* @param options additional options, e.g. parse_mode
*/
async sendSystemMessage(text, options = {}) {
const _users = Object.keys(this.storedUsers)
.filter(id => this.storedUsers[id].sysMessages !== false)
.map(id => (this.config.useUsername ? this.storedUsers[id].userName : this.storedUsers[id].firstName));
await this.sendMessage(text, _users, null, { ...options, disable_notification: true });
}
getStatus(id, state) {
const cmd = this.commands[id];
// If the state has no value yet, we cannot tell ON from OFF - report it as uncertain instead of
// rendering a not-set boolean as "ON" (falsy 'State not set' string used to leak through here).
if (state?.val == null) {
return `${cmd.alias} => ${adapter_core_1.I18n.translate('uncertain')}`;
}
let val = state.val;
if (cmd.type === 'boolean') {
return `${cmd.alias} => ${val ? cmd.onStatus || adapter_core_1.I18n.translate('ON-Status') : cmd.offStatus || adapter_core_1.I18n.translate('OFF-Status')}`;
}
if (cmd.states?.[String(val)] !== undefined) {
val = cmd.states[String(val)];
}
return `${cmd.alias} => ${val}${cmd.unit ? ` ${cmd.unit}` : ''}`;
}
/**
* Fire-and-forget `setState` that never rejects: a failure is logged instead of surfacing as an
* unhandled promise rejection (which would otherwise terminate the adapter).
*
* @param id the (namespaced) state id
* @param value the value or a settable-state object (e.g. `{ val, ack: true }`)
*/
setStateSafe(id, value) {
this.setState(id, value).catch(e => this.log.error(`Cannot set state "${id}": ${e}`));
}
connectionState(connected, logSuccess) {
let errorCounter = 0;
const checkConnection = () => {
this.pollConnectionStatus = undefined;
this.bot
?.getMe?.()
.then(data => {
this.log.debug(`getMe (reconnect): ${JSON.stringify(data)}`);
this.connectionState(true, errorCounter > 0);
})
.catch(error => {
if (errorCounter % 10 === 0) {
this.log.error(`getMe (reconnect #${errorCounter}) Error:${error}`);
}
errorCounter++;
if (this.pollConnectionStatus) {
this.clearTimeout(this.pollConnectionStatus);
}
this.pollConnectionStatus = this.setTimeout(checkConnection, 1000);
});
};
if (connected && logSuccess) {
this.log.info('getMe (reconnect): Success');
}
if (this.isConnected !== connected) {
this.isConnected = connected;
this.setStateSafe('info.connection', { val: this.isConnected, ack: true });
if (this.isConnected) {
if (this.pollConnectionStatus) {
this.clearTimeout(this.pollConnectionStatus);
this.pollConnectionStatus = undefined;
}
// Connection (re)established: resend everything that piled up while telegram was unreachable.
void this.flushSendQueue();
}
else {
checkConnection();
}
}
}
/**
* Whether a failed send is worth retrying. Only transient/network problems are retried; permanent
* telegram errors (e.g. 400 "chat not found", 403 "bot was blocked") must not be requeued, otherwise
* they would be retried forever. See issue #839.
*
* @param error the error thrown by the telegram API call
* @returns true if the send should be queued and retried later
*/
isRetryableSendError(error) {
const code = error?.code;
// EFATAL: network/connection problem (e.g. no internet); EPARSE: telegram answered with a
// non-JSON body, typically a transient 5xx gateway error.
if (code === 'EFATAL' || code === 'EPARSE') {
return true;
}
if (code === 'ETELEGRAM') {
// the Bot API replied with ok:false - retry only on rate limit (429) or server errors (5xx)
const status = error?.response?.body?.error_code ?? error?.response?.statusCode;
if (status === 429 || (typeof status === 'number' && status >= 500 && status < 600)) {
return true;
}
const m = /ETELEGRAM:\s*(\d{3})/.exec(String(error?.message ?? error));
if (m) {
const s = parseInt(m[1], 10);
return s === 429 || (s >= 500 && s < 600);
}
return false;
}
// unknown error shape - be conservative and do not retry
return false;
}
/**
* Queue a send that failed because telegram was unreachable, so it can be resent on reconnect. The
* queue is bounded: when it is full the oldest entry is dropped. See issue #839.
*
* @param action the telegram API call to repeat later
* @param label a human-readable recipient label for logging
* @param error the error that caused the enqueue (for the log message)
*/
enqueueFailedSend(action, label, error) {
if (this.sendQueue.length >= Telegram.MAX_SEND_QUEUE_LENGTH) {
this.sendQueue.shift();
this.log.warn(`Outgoing message queue is full (${Telegram.MAX_SEND_QUEUE_LENGTH}); dropped the oldest queued message`);
}
this.sendQueue.push({ action, label, ts: Date.now(), attempts: 1 });
this.log.info(`Telegram not reachable - queued message for "${label}" to resend on reconnect (queue size: ${this.sendQueue.length}). Reason: ${String(error)}`);
// Safety net for transient failures that do not trigger a full disconnect/reconnect cycle: keep
// retrying the queue periodically until it is empty (see flushSendQueue).
this.scheduleSendQueueRetry();
}
/** Schedule a delayed flush of the send queue (once), unless one is already pending. */
scheduleSendQueueRetry() {
if (this.sendQueueRetryTimer || !this.sendQueue.length) {
return;
}
this.sendQueueRetryTimer = this.setTimeout(() => {
this.sendQueueRetryTimer = undefined;
void this.flushSendQueue();
}, Telegram.SEND_QUEUE_RETRY_MS);
}
/**
* Resend all queued messages (FIFO) once the connection is back. Entries older than the max age are
* dropped; if a resend fails again with a retryable error, the queue is kept and the flush stops (the
* server is probably down again) to be retried on the next reconnect. See issue #839.
*/
async flushSendQueue() {
if (this.sendQueueFlushing || !this.sendQueue.length) {
return;
}
this.sendQueueFlushing = true;
try {
// drop messages that are too old to be worth sending
const now = Date.now();
for (let i = this.sendQueue.length - 1; i >= 0; i--) {
if (now - this.sendQueue[i].ts >= Telegram.MAX_SEND_QUEUE_AGE_MS) {
const [dropped] = this.sendQueue.splice(i, 1);
this.log.warn(`Dropped queued message for "${dropped.label}" (older than 24 h)`);
}
}
if (this.sendQueue.length) {
this.log.info(`Connection restored - resending ${this.sendQueue.length} queued message(s)`);
}
while (this.sendQueue.length) {
const item = this.sendQueue[0];
try {
const response = await item.action();
this.saveSendRequest(response);
this.sendQueue.shift();
this.log.debug(`Resent queued message for "${item.label}"`);
}
catch (error) {
item.attempts++;
if (this.isRetryableSendError(error) &&
item.attempts <= Telegram.MAX_SEND_ATTEMPTS &&
Date.now() - item.ts < Telegram.MAX_SEND_QUEUE_AGE_MS) {
// still unreachable: keep the queue and wait for the next reconnect
this.log.warn(`Resending queued message for "${item.label}" failed again (attempt ${item.attempts}); will retry on next reconnect: ${error}`);
break;
}
// permanent error or too many attempts: give up on this message and continue with the rest
this.sendQueue.shift();
this.log.error(`Giving up on queued message for "${item.label}" after ${item.attempts} attempt(s): ${error}`);
}
}
}
finally {
this.sendQueueFlushing = false;
// if anything is still queued (server still down), make sure we try again later
this.scheduleSendQueueRetry();
}
}
parseStates(states) {
if (!states) {
return undefined;
}
if (Array.isArray(states)) {
const obj = {};
states.forEach(value => (obj[value] = value));
return obj;
}
if (typeof states === 'string') {
const parts = states.split(';');
const obj = {};
parts.forEach(value => (obj[value] = value));
return obj;
}
return states;
}
getName(obj) {
const custom = obj.common.custom;
if (custom[this.namespace].alias) {
return custom[this.namespace].alias;
}
let name = obj.common.name;
if (typeof name === 'object') {
name = name[this.systemLang] || name.en;
}
return name || obj._id;
}
handleWebHook(req, res) {
if (req.method === 'POST' && req.url === `/${this.config.token}`) {
let body = '';
req.on('data', data => {
body += data;
if (body.length > 100_000) {
res.writeHead(413, 'Request Entity Too Large', {
'Content-Type': 'text/html',
});
res.end('<!doctype html><html><head><title>413</title></head><body>413: Request Entity Too Large</body></html>');
}
});
req.on('end', () => {
let msg;
try {
msg = JSON.parse(body);
}
catch (e) {
this.log.error(`Cannot parse webhook response!: ${e}`);
return;
}
res.end('OK');
this.bot.processUpdate(msg);
});
}
else {
res.writeHead(404, 'Resource Not Found', {
'Content-Type': 'text/html',
});
res.end('<!doctype html><html><head><title>404</title></head><body>404: Resource Not Found</body></html>');
}
}
saveSendRequest(msg) {
this.log.debug(`Request [saveSendRequest]: ${JSON.stringify(msg)}`);
if (typeof msg === 'object') {
if (this.config.storeRawRequest) {
this.setState('communicate.botSendRaw', JSON.stringify(msg, null, 2), true).catch(err => this.log.error(err.message));
}
if (msg?.message_id) {
this.setState('communicate.botSendMessageId', msg.message_id, true).catch(err => this.log.error(err.message));
}
if (msg?.message_thread_id) {
this.setState('communicate.botSendMessageThreadId', msg.message_thread_id, true).catch(err => this.log.error(err.message));
}
if (msg?.chat?.id) {
this.setState('communicate.botSendChatId', msg.chat.id.toString(), true).catch(err => this.log.error(err.message));
}
}
}
/**
* Resolve an ioBroker URI to a payload that can be handed to the telegram send functions.
*
* File and (base64) object/state content is returned as a Buffer, so that it can be uploaded even when
* the file is not present on the local filesystem. `http(s)` URLs and filesystem paths are returned as a
* string and forwarded as-is (telegram / the bot library fetch them).
*
* @param uri the ioBroker URI (string or already parsed)
* @param depth internal recursion guard (a state may reference another URI)
* @returns the resolved payload or null if it could not be resolved
*/
async resolveIobUri(uri, depth = 0) {
if (depth > 5) {
this.log.warn('Too many nested ioBroker URI references');
return null;
}
const parsed = typeof uri === 'string' ? (0, iobUri_1.iobUriFromString)(uri) : uri;
if (parsed.type === 'http') {
return { content: parsed.address, type: (0, iobUri_1.detectMediaTypeFromName)(parsed.address) };
}
if (parsed.type === 'file') {
const result = await this.readFileAsync(parsed.address, parsed.path || '');
const file = result?.file;
if (file === undefined || file === null) {
return null;
}
const content = Buffer.isBuffer(file) ? file : Buffer.from(file, 'binary');
const fileName = (parsed.path || '').split('/').pop() || undefined;
let type = (0, iobUri_1.detectMediaTypeFromName)(parsed.path || '');
if (!type && result?.mimeType) {
type = (0, iobUri_1.mimeToMediaType)(result.mimeType);
}
return { content, type: type || 'document', fileName };
}
if (parsed.type === 'state') {
const state = await this.getForeignStateAsync(parsed.address);
return this.valueToPayload(state ? state.val : null, depth);
}
// parsed.type === 'object'
const obj = await this.getForeignObjectAsync(parsed.address);
let value = obj;
if (parsed.path) {
for (const key of parsed.path.split('/')) {
if (value && typeof value === 'object') {
value = value[key];
}
else {
value = undefined;
break;
}
}
}
return this.valueToPayload(value, depth);
}
/**
* Convert an ioBroker state/object value into a send payload. Data URLs are decoded to a Buffer, nested
* ioBroker/http URIs are resolved recursively, everything else is returned as a string.
*
* @param value the raw value
* @param depth current recursion depth (see {@link resolveIobUri})
* @returns the resolved payload or null
*/
async valueToPayload(value, depth) {
if (value === undefined || value === null) {
return null;
}
if (typeof value === 'string') {
const dataUrl = value.match(/^data:([^;]+);base64,(.*)$/s);
if (dataUrl) {
const mime = dataUrl[1];
return {
content: Buffer.from(dataUrl[2], 'base64'),
type: (0, iobUri_1.mimeToMediaType)(mime),
fileName: `data.${mime.split('/')[1] || 'bin'}`,
};
}
if ((0, iobUri_1.isIobUri)(value) || /^https?:\/\//i.test(value)) {
return this.resolveIobUri(value, depth + 1);
}
return { content: value, type: (0, iobUri_1.detectMediaTypeFromName)(value) };
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return { content: String(value) };
}
if (typeof value === 'object') {
return { content: JSON.stringify(value) };
}
return null;
}
async sendMessageHelper(dest, name, text, options) {
const bot = this.bot;
// Resolve ioBroker URIs (iobfile://, iobobject://, iobstate://) to their content before dispatching.
// This allows sending files that live in the ioBroker file storage (e.g. behind Redis/jsonl) and are
// therefore not accessible through the local filesystem. See issue #907.
if (typeof text === 'string' && (0, iobUri_1.isIobUri)(text)) {
try {
const resolved = await this.resolveIobUri(text);
if (!resolved) {
this.log.error(`Cannot resolve ioBroker URI: ${text}`);
return JSON.stringify({
error: { [String(options.chatId ?? dest)]: 'Cannot resolve ioBroker URI' },
});
}
text = resolved.content;
if (resolved.type && !options.type) {
options.type = resolved.type;
}
if (resolved.fileName && !options.fileName) {
options.fileName = resolved.fileName;
}
}
catch (err) {
this.log.error(`Cannot resolve ioBroker URI "${text}": ${err instanceof Error ? err.message : err}`);
return JSON.stringify({ error: { [String(options.chatId ?? dest)]: String(err) } });
}
}
return new Promise(resolve => {
const messageIds = {};
if (options?.chatId !== undefined && options.user === undefined) {
options.user = this.config.useUsername
? this.storedUsers[options.chatId].userName
: this.storedUsers[options.chatId].firstName;
}
// to push chatId value for the group chats - useful to process the errors, and list of processed messages.
if (options.chatId === undefined && options.user === undefined && name === 'chat' && dest) {
options.chatId = dest;
}
if (options?.editMessageReplyMarkup !== undefined) {
this.log.debug(`Send editMessageReplyMarkup to "${name}"`);
if (bot) {
this.executeSending(() => bot.editMessageReplyMarkup(options.editMessageReplyMarkup.reply_markup, options.editMessageReplyMarkup.options), options, resolve);
}
}
else if (options?.editMessageText !== undefined) {
this.log.debug(`Send editMessageText to "${name}"`);
if (bot) {
this.executeSending(() => bot.editMessageText({ ...options.editMessageText.options, text }), options, resolve);
}
}
else if (options?.editMessageMedia !== undefined) {
this.log.debug(`Send editMessageMedia to "${name}"`);
if (text) {
let mediaInput;
if ((typeof text === 'string' &&
text.match(/\.(jpg|png|jpeg|bmp|gif)$/i) &&
((0, node_fs_1.existsSync)(text) || text.match(/^(https|http)/i))) ||
options?.type === 'photo') {
mediaInput = {
type: 'photo',
media: text,
};
}
else if ((typeof text === 'string' && text.match(/\.(gif)/i) && (0, node_fs_1.existsSync)(text)) ||
options?.type === 'animation') {
mediaInput = {
type: 'animation',
media: text,
};
}
else if ((typeof text === 'string' && text.match(/\.(mp4)$/i) && (0, node_fs_1.existsSync)(text)) ||
options?.type === 'video') {
mediaInput = {
type: 'video',
media: text,
};
}
else if ((typeof text === 'string' && text.match(/\.(wav|mp3|ogg)$/i) && (0, node_fs_1.existsSync)(text)) ||
options?.type === 'audio') {
mediaInput = {
type: 'audio',
media: text,
};
}
else if ((typeof text === 'string' &&
text.match(/\.(txt|doc|docx|csv|pdf|xls|xlsx)$/i) &&
(0, node_fs_1.existsSync)(text)) ||
options?.type === 'document') {
mediaInput = {
type: 'document',
media: text,
};
}
if (mediaInput) {
// The library resolves the file itself (local path / Buffer / stream / URL /
// file_id) and builds the multipart form + `attach://` reference internally.
// We only pass the InputMedia object and the extra form fields.
const inputMedia = {
type: mediaInput.type,
media: mediaInput.media,
};
if (options.editMessageMedia.options?.caption) {
inputMedia.caption = options.editMessageMedia.options.caption;
}
if (options.editMessageMedia.options?.parse_mode) {
inputMedia.parse_mode = options.editMessageMedia.options.parse_mode;
}
const form = {};
if (options.editMessageMedia.options?.chat_id !== undefined) {
form.chat_id = options.editMessageMedia.options.chat_id;
}
if (options.editMessageMedia.options?.message_id !== undefined) {
form.message_id = options.editMessageMedia.options.message_id;
}
if (options.editMessageMedia.options?.reply_markup) {
form.reply_markup = options.editMessageMedia.options.reply_markup;
}
if (bot) {
this.executeSending(() => bot.editMessageMedia(inputMedia, form), options, resolve);
}
}
else {
this.log.error(`Cannot send editMessageMedia [chatId - ${options.chatId}]: unsupported media type`);
resolve(JSON.stringify(messageIds));
}
}
else {
this.log.error(`Cannot send editMessageMedia [chatId - ${options.chatId}]: no media found. "text" may not be empty`);
resolve(JSON.stringify(messageIds));
}
}
else if (options?.editMessageCaption !== undefined) {
this.log.debug(`Send editMessageCaption to "${name}"`);
if (bot) {
this.executeSending(() => bot.editMessageCaption(text, options.editMessageCaption.options), options, resolve);
}
}
else if (options?.deleteMessage !== undefined) {
this.log.debug(`Send deleteMessage to "${name}"`);
if (bot) {
this.executeSending(() => bot.deleteMessage(options.deleteMessage.options.chat_id, options.deleteMessage.options.message_id), options, resolve);
}
}
else if (options &&
options.latitude !== undefined &&
options.longitude !== undefined &&
options.title !== undefined &&
options.address !== undefined) {
this.log.debug(`Send venue to "${name}": ${options.latitude},${options.longitude}`);
if (bot) {
this.executeSending(() => bot.sendVenue(dest, parseFloat(String(options.latitude)), parseFloat(String(options.longitude)), options.title, options.address, options), options, resolve);
}
}
else if (options?.latitude !== undefined && options.longitude !== undefined) {
this.log.debug(`Send location to "${name}": ${options.latitude},${options.longitude}`);
if (bot) {
this.executeSending(() => bot.sendLocation(dest, parseFloat(String(options.latitude)), parseFloat(String(options.longitude)), options), options, resolve);
}
}
else if (options?.type === 'mediagroup') {
this.log.debug(`Send media group to "${name}": `);
if (bot) {
const { media: fileNames } = options;
if (fileNames instanceof Array) {
bot.sendChatAction(dest, 'upload_photo')
.then(() => {
if (fileNames.every((name) => (0, node_fs_1.existsSync)(name))) {
const filesAsArray = fileNames
.map((element) => {
try {
return { type: 'photo', media: (0, node_fs_1.readFileSync)(element) };
}
catch (err) {
this.log.error(`Cannot read file ${element}: ${err}`);
return undefined;
}
})
.filter(element => element !== undefined);
const size = filesAsArray
.map(element => element.media.length)
.reduce((acc, val) => acc + val);
this.log.info(`Send media group to "${name}": ${size} bytes`);
if (filesAsArray.length > 0) {
this.executeSending(() => bot.sendMediaGroup(dest, filesAsArray), options, resolve);
}