UNPKG

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
"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); }