/** * ShadowMan Telegram Bot Plugin v3.1.0 * Telegram Bot API integration with long polling via background worker. * * Features: * - Background worker polling (non-blocking) * - Automatic LLM response forwarding to Telegram * - Typing indicator while LLM processes * - Photo/document receiving and sending * - File download from Telegram servers * * Requires: bot_token (secret), authorized_user_id (config) */ var config = shadowman.config.get(); var BOT_TOKEN = config.bot_token || shadowman.config.value('bot_token'); if (!BOT_TOKEN) { shadowman.log.error('bot_token not configured — plugin cannot start'); } var AUTHORIZED_USER_ID = config.authorized_user_id || shadowman.config.value('authorized_user_id') || ''; if (!AUTHORIZED_USER_ID) { shadowman.log.warn('authorized_user_id not set — bot will reject all messages'); } var POLLING_ENABLED = config.polling_enabled !== false; var POLLING_INTERVAL = parseInt(config.polling_interval) || 2000; var POLLING_TIMEOUT = parseInt(config.polling_timeout) || 30; var API_BASE = 'https://api.telegram.org/bot' + BOT_TOKEN + '/'; var FILE_BASE = 'https://api.telegram.org/file/bot' + BOT_TOKEN + '/'; var lastUpdateId = shadowman.storage.get('last_update_id') || 0; // ── Helpers ──────────────────────────────────────────────────────────── function apiCall(method, params) { try { var response = shadowman.http.post(API_BASE + method, JSON.stringify(params || {}), { headers: { 'Content-Type': 'application/json' }, timeout: 60 }); var data = response.json(); if (!data.ok) { return { success: false, error: data.description || 'Unknown error', error_code: data.error_code }; } return { success: true, result: data.result }; } catch (e) { return { success: false, error: e.toString() }; } } function sanitizeResponse(response) { if (!BOT_TOKEN) return response; if (typeof response === 'string') { return response.replace(new RegExp(BOT_TOKEN, 'g'), '[REDACTED]'); } if (typeof response === 'object' && response !== null) { var s = {}; for (var key in response) { if (response.hasOwnProperty(key)) s[key] = sanitizeResponse(response[key]); } return s; } return response; } function isAuthorizedUser(userId) { return String(userId) === String(AUTHORIZED_USER_ID); } function getOrCreateConversationId(chatId) { var key = 'conv_' + String(chatId); var existing = shadowman.storage.get(key); if (existing) return existing; var id = shadowman.utils.uuid(); shadowman.storage.set(key, id); return id; } /** * Download a file from Telegram servers by file_id. * Returns { success, file_path, size, data } where data is base64 content. */ function downloadTelegramFile(fileId) { var fileInfo = apiCall('getFile', { file_id: fileId }); if (!fileInfo.success || !fileInfo.result || !fileInfo.result.file_path) { return { success: false, error: 'Failed to get file info' }; } var filePath = fileInfo.result.file_path; var url = FILE_BASE + filePath; try { var response = shadowman.http.get(url, { timeout: 60 }); if (response.status === 200) { return { success: true, file_path: filePath, size: fileInfo.result.file_size || 0, data: shadowman.utils.base64Encode(response.body) }; } return { success: false, error: 'HTTP ' + response.status }; } catch (e) { return { success: false, error: e.toString() }; } } /** * Get the largest photo from a Telegram photo array. */ function getBestPhoto(photoArray) { if (!photoArray || photoArray.length === 0) return null; var best = photoArray[0]; for (var i = 1; i < photoArray.length; i++) { if ((photoArray[i].file_size || 0) > (best.file_size || 0)) { best = photoArray[i]; } } return best; } // ── Message processing ───────────────────────────────────────────────── function processTelegramUpdate(update) { var message = update.message; if (!message || !message.from) return; var userId = message.from.id; var chatId = message.chat.id; var username = message.from.username || message.from.first_name || 'Unknown'; if (!isAuthorizedUser(userId)) { shadowman.log.debug('Unauthorized message from ' + userId); return; } var conversationId = getOrCreateConversationId(chatId); var text = message.text || message.caption || ''; var hasPhoto = message.photo && message.photo.length > 0; var hasDocument = message.document; // Handle photo messages if (hasPhoto) { var photo = getBestPhoto(message.photo); if (photo) { shadowman.log.info('Photo from @' + username + (text ? ': ' + text.substring(0, 40) : '')); var downloaded = downloadTelegramFile(photo.file_id); if (downloaded.success) { var filename = 'telegram_photo_' + message.message_id + '.jpg'; var saved = shadowman.files.save(filename, downloaded.data, 'image/jpeg'); if (saved && saved.url) { // Use markdown image syntax so WebUI renders it text = (text || '') + '\n\n![Photo from Telegram](' + saved.url + ')'; } else { text = text || 'Sent a photo (failed to save)'; } } } } // Handle document messages if (hasDocument) { var doc = message.document; var docFilename = doc.file_name || ('telegram_file_' + message.message_id); var mime = doc.mime_type || 'application/octet-stream'; shadowman.log.info('Document from @' + username + ': ' + docFilename); var downloaded = downloadTelegramFile(doc.file_id); if (downloaded.success) { var saved = shadowman.files.save(docFilename, downloaded.data, mime); if (saved && saved.url) { // Images as markdown, other files as links var isImage = mime.indexOf('image/') === 0; if (isImage) { text = (text || '') + '\n\n![' + docFilename + '](' + saved.url + ')'; } else { text = (text || '') + '\n\n[' + docFilename + '](' + saved.url + ')'; } } else { text = text || 'Sent a file: ' + docFilename + ' (failed to save)'; } } } if (!text && !hasPhoto && !hasDocument) return; shadowman.log.info('Message from @' + username + ': ' + text.substring(0, 80)); // Start typing indicator shadowman.state.set('typing_chat_id', chatId); shadowman.events.emit('message', { text: text, conversationId: conversationId, platform: 'Telegram', sender: message.from.first_name || username, metadata: { source: 'telegram', user_id: userId, username: username, chat_id: chatId, message_id: message.message_id } }); } // ── Event handlers (main thread) ─────────────────────────────────────── shadowman.events.on('llm_response', function(data) { if (!data.metadata || data.metadata.source !== 'telegram') return; var chatId = data.metadata.chat_id; var text = data.text || ''; // Stop typing indicator shadowman.state.set('typing_chat_id', 0); if (!chatId || !text) return; // Detect image references and send as multipart photo uploads var imageRegex = /!\[([^\]]*)\]\((\/files\/[^\s\)]+\.(png|jpg|jpeg|gif|webp))\)/gi; var imgMatch; var sentImages = {}; while ((imgMatch = imageRegex.exec(text)) !== null) { var imgCaption = imgMatch[1] || ''; var imgUrl = imgMatch[2]; if (!sentImages[imgUrl]) { sentImages[imgUrl] = true; // Extract filename from URL path var parts = imgUrl.split('/'); var imgFilename = parts[parts.length - 1]; try { var fileData = shadowman.files.read(imgFilename); if (fileData && fileData.content) { shadowman.log.info('Uploading photo to Telegram: ' + imgFilename); shadowman.http.post(API_BASE + 'sendPhoto', null, { multipart: [ { name: 'chat_id', value: String(chatId) }, { name: 'photo', filename: imgFilename, data: fileData.content, contentType: 'image/' + (imgFilename.endsWith('.png') ? 'png' : 'jpeg') }, { name: 'caption', value: imgCaption || '' } ], timeout: 60 }); } } catch (e) { shadowman.log.debug('Could not send image: ' + e.toString()); } } } // Send text — strip image markdown for cleaner message var cleanText = text.replace(/!\[[^\]]*\]\(\/files\/[^\)]+\)/g, '').trim(); if (cleanText) { // Telegram 4096 char limit — split long messages while (cleanText.length > 0) { var chunk = cleanText.substring(0, 4096); cleanText = cleanText.substring(4096); apiCall('sendMessage', { chat_id: chatId, text: chunk, parse_mode: 'Markdown' }); } } }); // Handle updates from background worker shadowman.events.on('telegram_update', function(update) { processTelegramUpdate(update); var lastId = shadowman.state.get('last_update_id'); if (lastId > 0) { shadowman.storage.set('last_update_id', lastId); } }); // ── Tools ────────────────────────────────────────────────────────────── shadowman.tools.register('send_message', function(args) { if (!args.chat_id) return { success: false, error: 'chat_id is required' }; if (!args.text) return { success: false, error: 'text is required' }; var params = { chat_id: args.chat_id, text: args.text }; if (args.parse_mode) params.parse_mode = args.parse_mode; if (args.disable_notification) params.disable_notification = true; if (args.reply_to_message_id) params.reply_to_message_id = args.reply_to_message_id; return sanitizeResponse(apiCall('sendMessage', params)); }); shadowman.tools.register('send_to_owner', function(args) { if (!args.text) return { success: false, error: 'text is required' }; if (!AUTHORIZED_USER_ID) return { success: false, error: 'authorized_user_id not configured' }; var params = { chat_id: AUTHORIZED_USER_ID, text: args.text }; if (args.parse_mode) params.parse_mode = args.parse_mode; return sanitizeResponse(apiCall('sendMessage', params)); }); shadowman.tools.register('send_photo', function(args) { if (!args.chat_id) return { success: false, error: 'chat_id is required' }; if (!args.photo) return { success: false, error: 'photo is required' }; var params = { chat_id: args.chat_id, photo: args.photo }; if (args.caption) params.caption = args.caption; if (args.parse_mode) params.parse_mode = args.parse_mode; return sanitizeResponse(apiCall('sendPhoto', params)); }); shadowman.tools.register('send_document', function(args) { if (!args.chat_id) return { success: false, error: 'chat_id is required' }; if (!args.document) return { success: false, error: 'document is required' }; var params = { chat_id: args.chat_id, document: args.document }; if (args.caption) params.caption = args.caption; if (args.parse_mode) params.parse_mode = args.parse_mode; return sanitizeResponse(apiCall('sendDocument', params)); }); shadowman.tools.register('forward_message', function(args) { if (!args.chat_id) return { success: false, error: 'chat_id is required' }; if (!args.from_chat_id) return { success: false, error: 'from_chat_id is required' }; if (!args.message_id) return { success: false, error: 'message_id is required' }; return sanitizeResponse(apiCall('forwardMessage', { chat_id: args.chat_id, from_chat_id: args.from_chat_id, message_id: args.message_id })); }); shadowman.tools.register('get_me', function() { return sanitizeResponse(apiCall('getMe', {})); }); shadowman.tools.register('get_chat', function(args) { if (!args.chat_id) return { success: false, error: 'chat_id is required' }; return sanitizeResponse(apiCall('getChat', { chat_id: args.chat_id })); }); shadowman.tools.register('set_webhook', function(args) { if (!args.webhook_url) return { success: false, error: 'webhook_url is required' }; var params = { url: args.webhook_url, allowed_updates: ['message', 'edited_message', 'callback_query'] }; if (args.secret_token) params.secret_token = args.secret_token; return sanitizeResponse(apiCall('setWebhook', params)); }); shadowman.tools.register('delete_webhook', function() { return sanitizeResponse(apiCall('deleteWebhook', {})); }); shadowman.tools.register('get_webhook_info', function() { return sanitizeResponse(apiCall('getWebhookInfo', {})); }); // ── Shared state and background workers ──────────────────────────────── shadowman.state.set('api_base', API_BASE); shadowman.state.set('last_update_id', lastUpdateId); shadowman.state.set('polling', POLLING_ENABLED); shadowman.state.set('polling_timeout', POLLING_TIMEOUT); shadowman.state.set('polling_interval', POLLING_INTERVAL); shadowman.state.set('typing_chat_id', 0); // Typing indicator worker — sends "typing..." every 4s while LLM processes shadowman.background.run(function() { while (shadowman.state.get('polling') !== false) { var chatId = shadowman.state.get('typing_chat_id'); if (chatId && chatId !== 0) { var base = shadowman.state.get('api_base'); shadowman.http.post(base + 'sendChatAction', JSON.stringify({ chat_id: chatId, action: 'typing' }), { headers: { 'Content-Type': 'application/json' }, timeout: 10 }); } shadowman.utils.sleep(4000); } }); // Polling worker if (POLLING_ENABLED) { shadowman.background.run(function() { var base = shadowman.state.get('api_base'); var timeout = shadowman.state.get('polling_timeout'); var interval = shadowman.state.get('polling_interval'); shadowman.http.post(base + 'deleteWebhook', '{}', { headers: { 'Content-Type': 'application/json' } }); shadowman.utils.sleep(2000); shadowman.log.info('Polling worker started'); while (shadowman.state.get('polling')) { try { var offset = shadowman.state.get('last_update_id'); var result = shadowman.http.post(base + 'getUpdates', JSON.stringify({ offset: offset + 1, timeout: timeout, allowed_updates: ['message', 'edited_message', 'callback_query'] }), { headers: { 'Content-Type': 'application/json' }, timeout: timeout + 10 }); if (result.status === 200) { var data = result.json(); if (data.ok && data.result && data.result.length > 0) { for (var i = 0; i < data.result.length; i++) { var update = data.result[i]; if (update.update_id > offset) { shadowman.state.set('last_update_id', update.update_id); } shadowman.events.emit('telegram_update', update); } } } else { shadowman.log.warn('Polling error: HTTP ' + result.status); shadowman.utils.sleep(5000); } } catch (e) { shadowman.log.error('Polling exception: ' + e.toString()); shadowman.utils.sleep(5000); } shadowman.utils.sleep(interval); } shadowman.log.info('Polling worker stopped'); }); } shadowman.log.info('Telegram plugin v3.1.0 ready (' + (POLLING_ENABLED ? 'polling' : 'webhook') + ' mode)');