UNPKG

node-red-contrib-telegrambot

Version:
1,132 lines (1,016 loc) 56.4 kB
// Walks an Error's .cause chain (Node 16+ standard) and any nested .errors arrays // (AggregateError) to collect the leaf error messages — i.e. the ones that actually // carry the useful diagnostic (typically the syscall-level message like // "connect ETIMEDOUT 149.154.166.110:443" rather than the intermediate library // wrappers' generic "AggregateError" / "RequestError" labels). // // The whole point is to get a one-line warn that tells an operator what to actually // fix (IPv4 vs IPv6, DNS, blocked port, ...) without needing to enable verbose // logging and read a 5-deep util.inspect dump. // // Returns a string formatted as: "<message1>; <message2>; ..." with consecutive // duplicates removed. Empty input -> empty string. function formatErrorChain(error) { const seen = new Set(); const leaves = []; function walk(e, depth) { if (!e || typeof e !== 'object' || seen.has(e) || depth > 10) return; seen.add(e); const isAgg = Array.isArray(e.errors) && e.errors.length > 0; const hasCause = e.cause && typeof e.cause === 'object'; if (isAgg) { e.errors.forEach(function (inner) { walk(inner, depth + 1); }); } if (hasCause) { walk(e.cause, depth + 1); } if (!isAgg && !hasCause) { // Prefer e.message; for plain string inputs, the string itself; else nothing. // Avoid String(e) so a shape-less object doesn't show up as "[object Object]". const msg = e.message || (typeof e === 'string' ? e : ''); if (msg) leaves.push(msg); } } walk(error, 0); // De-duplicate while preserving order. const dedup = []; leaves.forEach(function (m) { if (dedup.indexOf(m) === -1) dedup.push(m); }); if (dedup.length === 0) { // Avoid the JS default "[object Object]" for shape-less inputs — fall back to // the message if present, the raw string itself if the caller passed a string, // else empty. if (!error) return ''; if (typeof error === 'string') return error; return error.message || ''; } return dedup.join('; '); } // Parses a comma-separated list of single- or double-quoted string literals. // Returns the array of decoded strings, or null if the input is not a valid list of string literals. // Lifted to module scope so it can be unit-tested directly without a RED runtime. function parseStringArgList(input) { const args = []; let ok = true; let i = 0; const skipWs = function () { while (i < input.length && /\s/.test(input[i])) i++; }; skipWs(); while (ok && i < input.length) { const quote = input[i]; if (quote !== '"' && quote !== "'") { ok = false; } else { i++; let val = ''; while (i < input.length && input[i] !== quote) { if (input[i] === '\\' && i + 1 < input.length) { const next = input[i + 1]; val += next === 'n' ? '\n' : next === 't' ? '\t' : next === 'r' ? '\r' : next; i += 2; } else { val += input[i++]; } } if (input[i] !== quote) { ok = false; } else { i++; args.push(val); skipWs(); if (i < input.length) { if (input[i] !== ',') { ok = false; } else { i++; skipWs(); } } } } } return ok ? args : null; } // Safely evaluates the small subset of expressions allowed in token / usernames / chatids fields. // Supported forms (see README): // flow.get("key"[, "store"]) flow.keys() // global.get("key"[, "store"]) global.keys() // context.get("key"[, "store"]) context.keys() // context.flow.get(...) context.global.get(...) // env.get("VAR") // Anything else evaluates to undefined. // Lifted to module scope so it can be unit-tested directly without a RED runtime. function evalContextExpression(node, expression) { let result; const trimmed = String(expression).trim(); const match = trimmed.match(/^(flow|global|context|env)(?:\.(flow|global))?\.(get|keys)\s*\(([\s\S]*)\)\s*$/); if (match) { const [, scope, subScope, method, argsRaw] = match; const args = parseStringArgList(argsRaw); if (args !== null) { if (scope === 'env') { if (!subScope && method === 'get' && args.length === 1) { try { result = node._flow.getSetting(args[0]); } catch (e) { // ignore — result stays undefined } } } else { let target; const ctx = node.context(); if (scope === 'context') { target = subScope ? ctx[subScope] : ctx; } else if (!subScope) { target = ctx[scope]; } if (target && typeof target[method] === 'function') { try { result = target[method](...args); } catch (e) { // ignore — result stays undefined } } } } } return result; } module.exports = function (RED) { let telegramBot = require('node-telegram-bot-api'); let telegramBotWebHook = require('node-telegram-bot-api/src/telegramWebHook'); let { SocksProxyAgent } = require('socks-proxy-agent'); // Override upstream's FatalError so the underlying cause is preserved on the thrown // error (upstream copies error.stack but not error itself). PR #1257, which originally // added FatalError to node-telegram-bot-api, has long since been merged, so the class // exists upstream - we only keep the override for the `this.cause = error` line. The // 'SLIGHTLYBETTEREFATAL' code marks the patched form so it is distinguishable in logs // from the stock 'EFATAL' string. Original context: issue #345. let tgbe = require('node-telegram-bot-api/src/errors'); class FatalError extends tgbe.BaseError { constructor(data) { const error = typeof data === 'string' ? null : data; const message = error ? error.message : data; super('SLIGHTLYBETTEREFATAL', message); if (error) this.stack = error.stack; if (error) this.cause = error; } } tgbe.FatalError = FatalError; // Orginal class is extended to be able to emit an event when getUpdates is called. class telegramBotWebHookEx extends telegramBotWebHook { constructor(bot) { super(bot); } open() { if (this.isOpen()) { return Promise.resolve(); } return new Promise((resolve, reject) => { this._webServer.listen(this.options.port, this.options.host, () => { RED.log.info('node-red-contrib-telegrambot: WebHook listening on ' + this.options.host + ':' + this.options.port); this._open = true; return resolve(); }); this._webServer.once('error', (err) => { reject(err); }); }); } } // Orginal class is extended to be able to emit an event when getUpdates is called. class telegramBotEx extends telegramBot { constructor(token, options = {}) { super(token, options); this.cycle = 0; } getUpdates(form = {}) { this.cycle++; this.emit('getUpdates_start', this.cycle); let startTime = new Date().getTime(); let result = super.getUpdates(form); result .then((updates) => { let endTime = new Date().getTime(); this.emit('getUpdates_end', this.cycle, endTime - startTime, updates); }) .catch(() => { // Errors from getUpdates are handled by the caller; suppress unhandled rejection here. }); return result; } processUpdate(update) { this.emit('update', update); super.processUpdate(update); } openWebHook() { if (this.isPolling()) { return Promise.reject('WebHook and Polling are mutually exclusive'); } if (!this._webHook) { this._webHook = new telegramBotWebHookEx(this); } return this._webHook.open(); } _request(_path, options = {}) { let result; if (_path !== 'getUpdates') { // TODO: add catch and retry later here. result = super._request(_path, options); // result.catch(function (err) { // ; // }); } else { result = super._request(_path, options); // no special handling for polling updates. } return result; } } // -------------------------------------------------------------------------------------------- let botsByToken = {}; // -------------------------------------------------------------------------------------------- // The configuration node // holds the token // and establishes the connection to the telegram bot // you can either select between polling mode and webhook mode. function TelegramBotNode(n) { RED.nodes.createNode(this, n); let self = this; // this is a dummy in case we abort to avoid problems in the nodes that make use of this function. // It will be overwritten during initialization! this.getTelegramBot = function () { return null; }; this.tokenRegistered = false; // first of all check if the token is used twice: in this case we abort if (this.credentials !== undefined && this.credentials.token !== undefined) { this.token = this.credentials.token; let configNodeId = botsByToken[this.token]; if (configNodeId === undefined) { botsByToken[self.token] = n.id; this.tokenRegistered = true; } else { if (configNodeId == n.id) { this.tokenRegistered = true; } else { this.tokenRegistered = false; let conflictingConfigNode = RED.nodes.getNode(configNodeId); self.error('Aborting: Token of ' + n.botname + ' is already in use by ' + conflictingConfigNode.botname); return; } } } else { self.warn('Aborting: Token of ' + n.botname + ' is not set'); return; } // Issue #198: many runtime nodes attach to the config node's 'status' event, // and the default cap of 10 fires a "possible EventEmitter memory leak" warning // for legitimate flows. Bumping to a generous-but-finite cap keeps the warning // available if a real listener leak ever re-emerges (e.g. a future regression of // the listener-tracking work in ADR 0005), instead of suppressing it entirely. self.setMaxListeners(50); this.pendingCommands = {}; // dictionary that contains all pending comands. this.commandsByNode = {}; // contains all configured command infos (command, description) by node. this.commandsByLanguage = {}; // contains all command sorted by language. this.config = n; this.status = 'disconnected'; // Reading configuration properties... this.botname = n.botname; // Coerce to strict boolean. n.verboselogging is bound to an HTML checkbox so // it should be true/false, but older configs (or hand-edited / imported // flows.json) can carry the value as the *string* 'false', which is truthy // and silently flips every verbose-gated `self.warn(...)` to fire even when // the UI checkbox is unchecked. Issue #411 retest, May 2026. this.verbose = !!n.verboselogging && n.verboselogging !== 'false'; this.baseApiUrl = n.baseapiurl; this.testEnvironment = n.testenvironment; this.updateMode = n.updatemode; if (!this.updateMode) { this.updateMode = 'polling'; } // Only 4 (IPv4) and 6 (IPv6) are valid for http.Agent.family. parseInt yields NaN // when the field is empty, which we use further down to mean "leave unset and let // node pick both stacks". The previous `|| 0` here mapped that case to 0, which is // not a documented family value and confused the agent. this.addressFamily = parseInt(n.addressfamily); // 1. optional when polling mode is used this.pollInterval = parseInt(n.pollinterval); if (isNaN(this.pollInterval)) { this.pollInterval = 300; } this.pollTimeout = 10; // seconds. This timeout is set to avoid close timeout on redeploy. // 2. optional when webhook is used. this.botHost = n.bothost; this.botPath = n.botpath; this.publicBotPort = parseInt(n.publicbotport); if (isNaN(this.publicBotPort)) { this.publicBotPort = 8443; } this.localBotPort = parseInt(n.localbotport); if (isNaN(this.localBotPort)) { this.localBotPort = this.publicBotPort; } this.localBotHost = n.localbothost || '0.0.0.0'; if (this.localBotHost == '') { this.localBotHost = '0.0.0.0'; } // 3. optional when webhook and self signed certificate is used this.privateKey = n.privatekey; this.certificate = n.certificate; this.useSelfSignedCertificate = n.useselfsignedcertificate; this.sslTerminated = n.sslterminated; // 4. optional when request via SOCKS is used. this.useSocks = n.usesocks; // Builds the @cypress/request options object that node-telegram-bot-api passes // into every HTTP call. Returned with a fresh `pool: {}` reference each call — // see destroyRequestPool below for why. Earlier versions of this code passed // `agentOptions` with no `pool` field for the non-SOCKS path, which silently // routed all bot traffic through @cypress/request's process-global pool. That // meant the keep-alive socket pool persisted across bot rebuilds — exactly the // wedge petermeter69 reported on #442 ("connection to TG is dead until manual // redeploy, network itself is fine"). With a fresh per-bot `pool: {}` and the // explicit destroy on rebuild, scheduleRestart genuinely replaces the agent. this.buildRequestOptions = function () { const pool = {}; self.requestPool = pool; let result; if (self.useSocks) { let socksprotocol = n.socksprotocol || 'socks5'; let agentOptions = { hostname: n.sockshost, port: n.socksport, protocol: socksprotocol, // type: 5, timeout: 5000, // ms <-- does not really work }; if (n.socksusername !== '') { agentOptions.username = n.socksusername; } if (n.sockspassword !== '') { agentOptions.password = n.sockspassword; } if (self.addressFamily === 4 || self.addressFamily === 6) { agentOptions.family = self.addressFamily; } result = { agentClass: SocksProxyAgent, agentOptions: agentOptions, pool: pool, }; } else { let agentOptions = { keepAlive: true, }; if (self.addressFamily === 4 || self.addressFamily === 6) { agentOptions.family = self.addressFamily; } result = { agentOptions: agentOptions, pool: pool, }; } return result; }; // Destroys every agent currently cached in self.requestPool. @cypress/request // populates the pool keyed by protocol + cert/cipher options (see request.js // getNewAgent) and reuses the same agent instance across requests with the same // key. Without explicit destroy(), the agent's keep-alive sockets stay open // until they idle out or the agent is garbage-collected — neither happens // promptly when a dropped network link silently kills half-open sockets, which // is the root cause of the "bot says polling but nothing flows" wedge in #442. this.destroyRequestPool = function () { const pool = self.requestPool; if (pool && typeof pool === 'object') { for (const key of Object.keys(pool)) { const agent = pool[key]; if (agent && typeof agent.destroy === 'function') { agent.destroy(); } } } self.requestPool = null; }; this.request = this.buildRequestOptions(); this.useWebhook = false; if (this.updateMode == 'webhook') { if (this.botHost && (this.sslTerminated || (this.privateKey && this.certificate))) { this.useWebhook = true; } else { let missing = []; if (!this.botHost) { missing.push('botHost'); } if (!this.sslTerminated && !(this.privateKey && this.certificate)) { missing.push('sslTerminated OR (privateKey AND certificate)'); } self.error( 'Bot ' + n.botname + ': webhook mode requested but configuration is incomplete (missing: ' + missing.join(', ') + '). Falling back to send-only mode - this bot will NOT receive messages until the configuration is fixed.' ); } } this.usePolling = false; if (this.updateMode == 'polling') { this.usePolling = true; } this.createTelegramBotForWebhookMode = function () { let newTelegramBot; let webHook = { autoOpen: false, port: this.localBotPort, host: this.localBotHost, }; if (!this.sslTerminated) { webHook.key = this.privateKey; webHook.cert = this.certificate; } const options = { webHook: webHook, baseApiUrl: this.baseApiUrl, testEnvironment: this.testEnvironment, request: this.request, }; newTelegramBot = new telegramBotEx(this.token, options); newTelegramBot .openWebHook() .then(function () { // web hook listening on port, everything ok. }) .catch(function (err) { self.warn('Opening webhook failed: ' + err); self.abortBot('Failed to listen on configured port', function () { self.error('Bot stopped: failed to open web hook.'); }); }); newTelegramBot.on('webhook_error', function (error) { self.setStatus('error', 'webhook error'); if (self.verbose) { self.warn('Webhook error: ' + error.message); } // TODO: check if we should abort in future when this happens // self.abortBot(error.message, function () { // self.warn("Bot stopped: Webhook error."); // }); }); const protocol = 'https://'; // 1, check if the botHost contains a full path begining with https:// let tempUrl = this.botHost; if (!tempUrl.startsWith(protocol)) { tempUrl = protocol + tempUrl; } // 2. check if the botHost contains a port; if not add publicBotPort const parsed = new URL(tempUrl); if (parsed.port == '') { parsed.port = this.publicBotPort; } // 3. check if the botHost contains a subpath: if not then add the botPath if (parsed.pathname == '' || parsed.pathname == '/') { parsed.pathname = this.botPath; } else { if (this.botPath != '') { parsed.pathname = parsed.pathname + '/' + this.botPath; } } // 4. create the url from the patsed parts. let botUrl = parsed.href; if (!botUrl.endsWith('/')) { botUrl += '/'; } botUrl += this.token; let setWebHookOptions; if (!this.sslTerminated && this.useSelfSignedCertificate) { setWebHookOptions = { certificate: options.webHook.cert, }; } newTelegramBot .setWebHook(botUrl, setWebHookOptions) .then(function (success) { if (self.verbose) { newTelegramBot .getWebHookInfo() .then(function (result) { self.log('Webhook enabled: ' + JSON.stringify(result)); }) .catch(function (err) { self.warn('Failed to get webhook info: ' + err); }); } if (success) { self.status = 'connected'; // Broadcast the started status so receiver / event / command nodes can // attach their listeners. Without this, the webhook-success branch only // updated the local string and downstream nodes stayed in "not connected". self.setStatus('started', 'webhook enabled'); } else { self.abortBot('Failed to set webhook ' + botUrl, function () { self.error('Bot stopped: Webhook not set.'); }); } }) .catch(function (err) { self.abortBot('Failed to set webhook ' + botUrl + ': ' + err, function () { self.error('Bot stopped: Webhook not set.'); }); }); return newTelegramBot; }; this.createTelegramBotForPollingMode = function () { function restartPolling() { // Single-flight guard: if a restart is already pending, drop this one. // Without it, a burst of polling_error events queues N parallel 3 s timers // and the bot ends up scheduling several startPolling calls in parallel // (the root cause behind issue #442's "12 cycles in 3 minutes" pattern). if (self.pollingRestartTimer) { return; } self.pollingRestartTimer = setTimeout(function () { self.pollingRestartTimer = null; // Check if abort was called in the meantime. if (self.telegramBot) { // Force a fully fresh polling instance. We previously trusted // startPolling({ restart: true }) (the documented soft-restart) but // since V17.3.0's df46aa0 removed the explicit teardown, the library // kept enough internal polling state across restarts that a new // getUpdates would race a still-pending one server-side, causing // the 409 Conflict loops in issue #442. Resetting _polling to null // first guarantees the library treats the next start as a clean // boot. Reaches into the library's private API on purpose — keep // an eye on this on node-telegram-bot-api major bumps. delete self.telegramBot._polling; self.telegramBot._polling = null; self.telegramBot.startPolling({ restart: true }); } }, 3000); // 3 seconds to not flood the output with too many messages. } let newTelegramBot; let polling = { autoStart: true, interval: this.pollInterval, params: { timeout: this.pollTimeout, }, // These events can be used https://core.telegram.org/bots/api#update // see event node: e.g. // params: { // allowed_updates: [ // 'update_id', // 'message', // 'edited_message', // 'channel_post', // 'edited_channel_post', // 'inline_query', // 'chosen_inline_result', // 'callback_query', // 'shipping_query', // 'pre_checkout_query', // 'poll', // 'poll_answer', // 'my_chat_member', // 'chat_member', // 'chat_join_request'], // } }; const options = { polling: polling, baseApiUrl: this.baseApiUrl, testEnvironment: this.testEnvironment, request: this.request, }; newTelegramBot = new telegramBotEx(this.token, options); self.status = 'connected'; newTelegramBot.on('polling_error', function (error) { self.setStatus('error', 'polling error'); // We reset the polling status after the 80% of the timeout setTimeout(function () { // check if abort was called in the meantime. if (self.telegramBot) { self.setStatus('info', 'polling'); } }, self.pollInterval * 0.8); if (self.verbose) { // formatErrorChain extracts the leaf-level messages (e.g. // "connect ETIMEDOUT 149.154.166.110:443") so the headline log line is // immediately actionable rather than just showing "AggregateError". self.warn(formatErrorChain(error)); // patch see #345 // node-telegram-bot-api error objects can carry the request URL deep in their // structure (e.g. https://api.telegram.org/bot<TOKEN>/getUpdates). Redact the // token before writing the inspected dump to Node-RED's log/UI. let inspected = require('node:util').inspect(error, { depth: 5 }); if (self.token) { inspected = inspected.split(self.token).join('<token>'); } self.warn(inspected); } let stopPolling = false; let skipRestart = false; let hint; if (error.message === 'ETELEGRAM: 401 Unauthorized') { hint = 'Please check if the bot token is valid.'; stopPolling = true; } else if (error.message && error.message.indexOf('ETELEGRAM: 409 Conflict') === 0) { // 409 means Telegram saw another getUpdates request for the same token — // typically the previous one is still being processed server-side after a // restart or redeploy. The library's polling loop will naturally retry on // the next interval; calling stopPolling+restartPolling on top of that // races yet ANOTHER getUpdates and perpetuates the conflict (issue #442 // retest, "ETELEGRAM: 409 Conflict ... on pressing the deploy button"). // Skip the restart, let it clear on its own. hint = '409 Conflict — another getUpdates still in flight server-side; letting it clear naturally.'; skipRestart = true; } else { // unknown error occured... we simply ignore it. hint = 'Polling error --> Trying again.'; } if (stopPolling) { self.abortBot(error.message, function () { self.error('Bot ' + self.botname + ' stopped: ' + hint); }); } else if (skipRestart) { if (self.verbose) { self.warn(hint); } } else { // here we simply ignore the bug and try to reestablish polling. self.telegramBot.stopPolling({ cancel: false }).then(restartPolling, restartPolling); // The following line is removed as this would create endless log files if (self.verbose) { self.warn(hint); } } }); return newTelegramBot; }; this.createTelegramBotForSendOnlyMode = function () { let newTelegramBot; const options = { baseApiUrl: this.baseApiUrl, testEnvironment: this.testEnvironment, request: this.request, }; newTelegramBot = new telegramBotEx(this.token, options); self.status = 'send only mode'; return newTelegramBot; }; this.createTelegramBot = function () { let newTelegramBot; if (this.useWebhook) { newTelegramBot = this.createTelegramBotForWebhookMode(); } else if (this.usePolling) { newTelegramBot = this.createTelegramBotForPollingMode(); } else { // here we configure send only mode. We do not poll nor use a web hook which means // that we can not receive messages. newTelegramBot = this.createTelegramBotForSendOnlyMode(); } newTelegramBot.on('error', function (error) { // During a network outage the bot library can emit 'error' many times in // rapid succession (each pending request fails its own way). The single- // flight in scheduleRestart already collapses the *restart attempts* to one, // but the per-event warn line below was still flooding the log with // "Bot error: ..." duplicates (issue #411 retest, 14 May 2026). Suppress // the warn while a restart is already queued — the original warn for the // first error of the burst plus the scheduleRestart "will restart in Xms" // message together describe the situation, and additional copies add no // information. // // formatErrorChain walks the .cause + .errors hierarchy down to the leaf // messages so the warn line carries the actionable detail // (e.g. "connect ETIMEDOUT 149.154.166.110:443") rather than the // intermediate wrapper labels ("AggregateError", "RequestError") that // node-telegram-bot-api / request-promise-core stack on top. const detail = formatErrorChain(error); if (!self.restartTimer) { self.warn('Bot error: ' + detail); } // Schedule a backoff-restart so the bot recovers from transient fatal // failures (stale keep-alive sockets, prolonged proxy outages, etc.) // without operator intervention. Issues #442 / #440. self.scheduleRestart('fatal: ' + detail); }); return newTelegramBot; }; // Activates the bot or returns the already activated bot. this.getTelegramBot = function (createIfMissing = true) { if (createIfMissing && !this.telegramBot) { if (this.credentials) { this.token = this.getBotToken(this.credentials.token); if (this.token) { if (!this.telegramBot) { this.telegramBot = this.createTelegramBot(); } } } } return this.telegramBot; }; // deletes the commands if we will register one. this.deleteMyCommands = function () { let botCommandsByLanguage = self.getBotCommands(); if (Object.keys(botCommandsByLanguage).length > 0) { let telegramBot = self.getTelegramBot(); if (telegramBot) { // TODO:iterate over languages and delete the ones we do not have commands for. // let languages = Object.keys(botCommandsByLanguage); let scopes = ['default', 'all_private_chats', 'all_group_chats', 'all_chat_administrators']; for (const scope of scopes) { let options = { scope: { type: scope }, language_code: '', }; telegramBot .deleteMyCommands(options) .then(function (result) { if (!result) { self.warn('Failed to call /deleteMyCommands'); } }) .catch(function (err) { self.warn('Failed to call /deleteMyCommands: ' + err); }); } } } }; // registers the bot commands at the telegram server. this.setMyCommands = function () { let botCommandsByLanguage = self.getBotCommands(); if (Object.keys(botCommandsByLanguage).length > 0) { let scopes = ['default', 'all_private_chats', 'all_group_chats', 'all_chat_administrators']; // let languages = Object.keys(botCommandsByLanguage); let telegramBot = self.getTelegramBot(); if (telegramBot) { for (const scope of scopes) { for (let language in botCommandsByLanguage) { let botCommandsForLanguage = botCommandsByLanguage[language]; let botCommands = botCommandsForLanguage.filter(function (botCommand) { return botCommand.scope == scope; }); if (botCommands && botCommands.length > 0) { let options = { scope: { type: scope }, language_code: language, }; telegramBot .setMyCommands(botCommands, options) .then(function (result) { if (!result) { self.warn('Failed to call /setMyCommands for language' + language); } }) .catch(function (err) { self.warn('Failed to call /setMyCommands for language ' + language + ': ' + err); }); } } } } } }; this.onStarted = function () { self.deleteMyCommands(); self.setMyCommands(); }; // Used by the control node's "setwebhook" command (issue #410) — exposes // bot.setWebHook / deleteWebHook so flows can swap the public URL at runtime // (e.g. when an ngrok tunnel restarts with a new URL). Best-effort: only // meaningful in webhook mode; Telegram rejects setWebHook while polling is // active. Empty url => deleteWebHook. this.setWebHookDynamically = function (url, options, callback) { let telegramBot = self.getTelegramBot(); if (!telegramBot) { callback(new Error('bot not initialized')); return; } let p; if (!url || url === '') { p = telegramBot.deleteWebHook(); } else { p = telegramBot.setWebHook(url, options || {}); } p.then( function (result) { callback(null, result); }, function (err) { callback(err); } ); }; RED.events.on('flows:started', this.onStarted); this.on('close', function (removed, done) { RED.events.removeListener('flows:started', this.onStarted); // Cancel any pending restart / polling-restart / stable-window timer so we // don't fire on a deleted node. if (self.restartTimer) { clearTimeout(self.restartTimer); self.restartTimer = null; } if (self.pollingRestartTimer) { clearTimeout(self.pollingRestartTimer); self.pollingRestartTimer = null; } if (self.restartStableTimer) { clearTimeout(self.restartStableTimer); self.restartStableTimer = null; } if (removed) { if (self.tokenRegistered) { delete botsByToken[self.token]; } } self.abortBot('closing', function () { // Tear down the keep-alive socket pool too, so a redeploy doesn't leave // dangling sockets behind. See destroyRequestPool for context. self.destroyRequestPool(); done(); }); }); this.abortBot = function (hint, done) { self.status = 'disconnecting'; function setStatusDisconnected() { self.status = 'disconnected'; self.setStatus('stopped', 'stopped ' + hint); self.telegramBot = null; done(); } if (self.telegramBot !== undefined && self.telegramBot !== null) { if (self.telegramBot._polling) { // cancel:true asks node-telegram-bot-api to abort the in-flight // getUpdates so stopPolling resolves immediately instead of waiting // for the long-poll timeout. Previously we passed cancel:false and // then reached into _polling._lastRequest.cancel() to achieve the // same thing - same outcome, two racing cancellations, internal API. self.telegramBot.stopPolling({ cancel: true }).then(setStatusDisconnected, setStatusDisconnected); } else if (self.telegramBot._webHook) { // Telegram keeps the previously registered webhook URL on file until we tell it // to drop it. Wait for deleteWebHook to complete (or fail) before tearing the // local listener down so a redeploy with a new URL takes effect immediately. // Either branch falls through to closing the local hook. self.telegramBot .deleteWebHook() .catch(function () { // ignore - we still want to close the local hook }) .then(function () { self.telegramBot.closeWebHook().then(setStatusDisconnected, setStatusDisconnected); }); } else { setStatusDisconnected(); } } else { setStatusDisconnected(); } }; // Tear the bot down, then rebuild it after a back-off. This is the recovery // path for fatal failures emitted on the bot's 'error' event — keep-alive socket // pools going stale, proxy interruptions that outlive the polling-restart logic, // etc. Without this the bot stays silent until manual redeploy (issues #442, #440). // // Single-flight: while a restart is queued or in progress, further calls are dropped. // Backoff: 3 s, 6 s, 12 s, 24 s, 48 s, capped at 60 s. After 8 failed restarts in a // row the helper logs a node.error and gives up — operator intervention required. // // Stable-window: a "successful" restart only counts as such once the bot has been // operational for STABLE_WINDOW_MS without another error. Until that timer fires, // a fresh error keeps the count climbing through the backoff curve. Without this, // persistent network problems (issue #442 retest, where errors arrive every ~5 s) // would have the helper oscillate at the minimum 3 s delay forever and never let // the exponential curve do its job. const STABLE_WINDOW_MS = 60000; this.restartCount = 0; this.restartTimer = null; this.restartStableTimer = null; this.scheduleRestart = function (reason) { if (self.restartTimer) { return; } // A fresh error invalidates any in-progress "looks stable" countdown — the // previous restart's success was illusory. if (self.restartStableTimer) { clearTimeout(self.restartStableTimer); self.restartStableTimer = null; } if (self.restartCount >= 8) { self.error('Bot ' + self.botname + ' gave up restarting after fatal: ' + reason); return; } const delay = Math.min(60000, 3000 * Math.pow(2, self.restartCount)); self.restartCount++; self.warn('Bot ' + self.botname + ' will restart in ' + delay + 'ms (' + reason + ')'); self.restartTimer = setTimeout(function () { self.restartTimer = null; self.abortBot('pre-restart', function () { // abortBot already nulled self.telegramBot via setStatusDisconnected. // Destroy every agent in the per-bot request pool and replace it with // a fresh empty pool before re-creating the bot. @cypress/request keys // its agent cache on the pool object and reuses agent instances across // requests with the same protocol/cert combination — without an // explicit destroy, half-dead keep-alive sockets from the previous // outage stay parked in the pool and the new bot inherits the same // wedge (issue #442: "bot says polling, nothing flows, only manual // redeploy recovers"). The buildRequestOptions call hands back a // request option object with a fresh pool reference, which // createTelegramBot then passes into the new bot. self.status = 'disconnected'; self.destroyRequestPool(); self.request = self.buildRequestOptions(); const bot = self.getTelegramBot(); if (bot) { self.status = 'connected'; self.setStatus('started', 'restarted after ' + reason); // Don't reset restartCount yet. If another error fires inside the // stable window, scheduleRestart will clear this timer and treat // the next failure as a continuation of the same outage so the // backoff keeps escalating. self.restartStableTimer = setTimeout(function () { self.restartStableTimer = null; self.restartCount = 0; }, STABLE_WINDOW_MS); } else { // creation failed (e.g. webhook config incomplete); back off again self.scheduleRestart('retry-create'); } }); }, delay); }; // stops the bot if not already stopped this.stop = function (hint, done) { if (self.telegramBot !== null && self.status === 'connected') { self.abortBot(hint, done); } else { done(); } }; // starts the bot if not already started this.start = function (hint, done) { // On first construction self.telegramBot is undefined, not null. abortBot() // explicitly sets it back to null, so allow both forms here - otherwise a // control-node "start" on a never-started bot would be a silent no-op. if (!self.telegramBot && self.status === 'disconnected') { self.status = 'connecting'; self.getTelegramBot(); // trigger creation if (self.telegramBot) { self.status = 'connected'; self.setStatus('started', 'started ' + hint); } } done(); }; this.getBotToken = function (botToken) { botToken = this.credentials.token; if (botToken !== undefined) { botToken = botToken.trim(); if (botToken.startsWith('{') && botToken.endsWith('}')) { let expression = botToken.substr(1, botToken.length - 2); botToken = evalContextExpression(self, expression); } } return botToken; }; this.getUserNames = function () { let usernames = []; // Truthiness check rather than !== '' so undefined / null (e.g. flow JSON that // omits the field entirely) is handled the same as an empty string. if (self.config.usernames) { let trimmedUsernames = self.config.usernames.trim(); if (trimmedUsernames.startsWith('{') && trimmedUsernames.endsWith('}')) { let expression = trimmedUsernames.substr(1, trimmedUsernames.length - 2); let result = evalContextExpression(self, expression); if (Array.isArray(result)) { usernames = result; } else if (typeof result === 'string') { // env.get / flow.get / global.get may yield a raw string when the // stored value is e.g. a process env var ("alice,bob"). Split on // commas to match the literal-comma branch below. usernames = result.split(','); } } else { usernames = self.config.usernames.split(','); } } return usernames; }; this.getChatIds = function () { let chatids = []; // Same truthiness reasoning as getUserNames. if (self.config.chatids) { let trimmedChatIds = self.config.chatids.trim(); if (trimmedChatIds.startsWith('{') && trimmedChatIds.endsWith('}')) { let expression = trimmedChatIds.substr(1, trimmedChatIds.length - 2); let result = evalContextExpression(self, expression); if (Array.isArray(result)) { chatids = result; } else if (typeof result === 'string') { // env.get / flow.get / global.get may yield a raw string when the // stored value is e.g. a process env var ("123,456"). Split and // coerce to numbers to match the literal-comma branch below. chatids = result.split(',').map(function (it