/** * @node telegram-send * @name Telegram * @category integration * @version 1.0.0 * @description Send a message, photo or document to a Telegram chat through a bot * @icon send */ const configSchema = { type: 'object', properties: { credentialId: { type: 'string', title: 'Bot Token Credential', description: 'An API key or bearer credential holding the bot token from BotFather', dynamicOptions: { source: 'credentials', filter: { type: ['api_key', 'bearer'] } } }, chatId: { type: 'string', title: 'Chat ID', description: 'Numeric chat id, or @channelusername for a public channel' }, action: { type: 'string', title: 'Action', enum: ['sendMessage', 'sendPhoto', 'sendDocument'], default: 'sendMessage', description: 'What to send' }, text: { type: 'string', title: 'Text', description: 'Message text, or the caption for a photo or document. Supports {{variable}} interpolation.' }, parseMode: { type: 'string', title: 'Formatting', enum: ['none', 'Markdown', 'MarkdownV2', 'HTML'], default: 'none', description: 'How Telegram should interpret markup in the text' }, fileUrl: { type: 'string', title: 'File URL', description: 'Address of the photo or document to send. Telegram fetches it itself.' }, fileId: { type: 'string', title: 'Stored File ID', description: 'File store id to upload instead of giving Telegram an address' }, disableNotification: { type: 'boolean', title: 'Send Silently', description: 'Deliver without a notification sound', default: false }, replyToMessageId: { type: 'string', title: 'Reply To Message ID', description: 'Send as a reply to this message' }, apiBaseUrl: { type: 'string', title: 'API Base URL', description: 'Telegram Bot API address, for a self-hosted API server', default: 'https://api.telegram.org' }, timeoutMs: { type: 'number', title: 'Timeout (ms)', default: 30000 } }, required: ['credentialId', 'chatId'] }; const inputSchema = { type: 'object', properties: { chatId: { type: 'string', description: 'Overrides the configured chat' }, text: { type: 'string', description: 'Overrides the configured text' }, fileUrl: { type: 'string', description: 'Overrides the configured file address' }, fileId: { type: 'string', description: 'Overrides the configured stored file' } } }; const outputSchema = { type: 'object', properties: { success: { type: 'boolean' }, messageId: { type: 'integer', description: 'The id Telegram gave the sent message' }, chatId: { type: 'string' }, date: { type: 'integer', description: 'When Telegram accepted it, in seconds' } } }; const outputs = [ { name: 'main', displayName: 'Sent', type: 'object', color: '#3b82f6' } ]; // A connection hands the previous node's whole output over as input.data, and // some nodes wrap their own return in a result object on top of that. Rather // than guess which shape arrived, the layers are flattened with the innermost // winning, so a field set anywhere in the chain is found. function incoming(input) { const isPlain = (v) => v && typeof v === 'object' && !Array.isArray(v); const merged = {}; for (const layer of [input, input && input.data, input && input.data && input.data.result]) { if (!isPlain(layer)) continue; for (const key of Object.keys(layer)) { merged[key] = layer[key]; } } return merged; } // The bot token belongs in the URL rather than a header, so the stored header // value is unwrapped: a bearer credential arrives prefixed, an api key does not. function readBotToken(credentialId) { const credential = smartbotic.credentials.get(credentialId); if (!credential || !credential.success) { throw new Error('Could not read the bot token credential: ' + ((credential && credential.error) || 'unknown error')); } const value = String(credential.headerValue || '').trim(); const token = value.indexOf('Bearer ') === 0 ? value.substring(7).trim() : value; if (!token) { throw new Error('The credential holds no bot token'); } return token; } // Telegram accepts a file either as an address it fetches or as an upload. A // stored file has no address Telegram's servers can reach, so its bytes are // posted as multipart form data instead. function uploadStoredFile(url, field, fileId, fields, timeout) { const info = smartbotic.storage.getFileInfo(fileId); const file = smartbotic.storage.downloadFile(fileId); if (!file || !file.success || !file.data) { throw new Error('Stored file ' + fileId + ' could not be read'); } // Every value in a form part is sent as text, so the flags Telegram expects // as booleans or numbers are written out as strings here. const formData = {}; for (const key of Object.keys(fields)) { formData[key] = String(fields[key]); } return smartbotic.http.request({ method: 'POST', url: url, formData: formData, files: [{ name: field, filename: (info && info.name) || fileId, mimeType: (info && info.mimeType) || 'application/octet-stream', data: file.data }], timeout: timeout }); } module.exports = { configSchema, inputSchema, outputSchema, outputs, async execute(config, input, context) { const from = incoming(input); if (!config.credentialId) { throw new Error('A bot token credential is required'); } const chatId = String(from.chatId || config.chatId || '').trim(); if (!chatId) { throw new Error('A chat id is required'); } const action = config.action || 'sendMessage'; const text = from.text || config.text || ''; const token = readBotToken(config.credentialId); const base = String(config.apiBaseUrl || 'https://api.telegram.org').replace(/\/+$/, ''); const payload = { chat_id: chatId }; if (config.parseMode && config.parseMode !== 'none') { payload.parse_mode = config.parseMode; } if (config.disableNotification === true) { payload.disable_notification = true; } if (config.replyToMessageId) { payload.reply_to_message_id = Number(config.replyToMessageId); } const url = base + '/bot' + token + '/' + action; const timeout = Number(config.timeoutMs) || 30000; const fileUrl = from.fileUrl || config.fileUrl; const fileId = from.fileId || config.fileId; let response; if (action === 'sendMessage') { if (!text) { throw new Error('Message text is required'); } payload.text = text; response = smartbotic.http.request({ method: 'POST', url: url, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), timeout: timeout }); } else { const field = action === 'sendPhoto' ? 'photo' : 'document'; if (text) payload.caption = text; if (fileId) { response = uploadStoredFile(url, field, fileId, payload, timeout); } else if (fileUrl) { // Telegram fetches the file itself from the address it is given, // which avoids moving the bytes through this node. The address // has to be one Telegram's servers can reach. payload[field] = fileUrl; response = smartbotic.http.request({ method: 'POST', url: url, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), timeout: timeout }); } else { throw new Error(action + ' requires a file URL or a stored file id'); } } let body = response.data; if (typeof body === 'string') { try { body = JSON.parse(body); } catch (e) { // A proxy or a wrong API base answers with HTML, and parsing it // as JSON throws a syntax error that names neither the request // nor the response. throw new Error('Telegram ' + action + ' failed: HTTP ' + response.status + ' returned a non-JSON body: ' + body.substring(0, 200).replace(/\s+/g, ' ')); } } // Telegram reports its own failures in the body with a 4xx status and a // description that says far more than the status code does. if (!body || body.ok !== true) { const description = (body && body.description) || ('HTTP ' + response.status); throw new Error('Telegram ' + action + ' failed: ' + description); } const message = body.result || {}; smartbotic.log.info('Telegram: ' + action + ' to chat ' + chatId); return { success: true, messageId: message.message_id, chatId: chatId, date: message.date }; } };