| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435 |
- /**
- * 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';
- } 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';
- } 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)');
|