فهرست منبع

Initial commit: Telegram Bot plugin for Telegram API integration

ShadowMan Service User 1 هفته پیش
کامیت
a336ae57fa
4فایلهای تغییر یافته به همراه630 افزوده شده و 0 حذف شده
  1. 21 0
      .gitignore
  2. 22 0
      .spkgignore
  3. 435 0
      index.js
  4. 152 0
      manifest.json

+ 21 - 0
.gitignore

@@ -0,0 +1,21 @@
+# Logs
+logs/
+*.log
+
+# Environment files
+.env
+.env.*
+
+# Package files
+*.spkg
+*.spkg.sig
+
+# Dependencies
+node_modules/
+
+# SDK (belongs to plugin-template, not plugin repos)
+shadowman-sdk.d.ts
+
+# OS files
+.DS_Store
+Thumbs.db

+ 22 - 0
.spkgignore

@@ -0,0 +1,22 @@
+# Dependencies
+node_modules/
+
+# Environment
+.env
+.env.*
+
+# Git
+.git/
+.gitignore
+
+# Package metadata
+.spkgignore
+
+# Test files
+test/
+tests/
+*.test.js
+*.spec.js
+
+# Logs
+*.log

+ 435 - 0
index.js

@@ -0,0 +1,435 @@
+/**
+ * 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('telegram_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('telegram_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('telegram_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('telegram_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('telegram_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('telegram_get_me', function() {
+    return sanitizeResponse(apiCall('getMe', {}));
+});
+
+shadowman.tools.register('telegram_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('telegram_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('telegram_delete_webhook', function() {
+    return sanitizeResponse(apiCall('deleteWebhook', {}));
+});
+
+shadowman.tools.register('telegram_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)');

+ 152 - 0
manifest.json

@@ -0,0 +1,152 @@
+{
+  "id": "telegram",
+  "name": "Telegram Bot",
+  "description": "Telegram Bot API integration with long polling via background worker. Messages are forwarded to the LLM and replies are sent back automatically.",
+  "version": "3.1.0",
+  "runtime": "quickjs",
+  "sdkVersion": 1,
+  "auto_start": true,
+  "config": [
+    {
+      "key": "bot_token",
+      "label": "Telegram Bot API token (from @BotFather)",
+      "secret": true
+    },
+    {
+      "key": "authorized_user_id",
+      "label": "Authorized Telegram user ID (only this user can interact with the bot)",
+      "secret": false
+    },
+    {
+      "key": "polling_enabled",
+      "label": "Enable long polling (recommended for private use, no public URL needed)",
+      "secret": false,
+      "default": "true"
+    },
+    {
+      "key": "polling_interval",
+      "label": "Polling interval in milliseconds (recommended: 2000-5000)",
+      "secret": false,
+      "default": "2000"
+    },
+    {
+      "key": "polling_timeout",
+      "label": "Long polling timeout in seconds (recommended: 25-30)",
+      "secret": false,
+      "default": "30"
+    }
+  ],
+  "tools": [
+    {
+      "name": "telegram_send_message",
+      "description": "Send a text message to a Telegram chat",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "chat_id": { "type": "string", "description": "Target chat ID or @username" },
+          "text": { "type": "string", "description": "Message text (1-4096 characters)" },
+          "parse_mode": { "type": "string", "description": "Text formatting mode", "enum": ["HTML", "Markdown", "MarkdownV2"] },
+          "disable_notification": { "type": "boolean", "description": "Send silently" },
+          "reply_to_message_id": { "type": "integer", "description": "Reply to message ID" }
+        },
+        "required": ["chat_id", "text"]
+      }
+    },
+    {
+      "name": "telegram_send_to_owner",
+      "description": "Send a message to the authorized user (bot owner)",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "text": { "type": "string", "description": "Message text (1-4096 characters)" },
+          "parse_mode": { "type": "string", "description": "Text formatting mode", "enum": ["HTML", "Markdown", "MarkdownV2"] }
+        },
+        "required": ["text"]
+      }
+    },
+    {
+      "name": "telegram_send_photo",
+      "description": "Send a photo to a Telegram chat",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "chat_id": { "type": "string", "description": "Target chat ID" },
+          "photo": { "type": "string", "description": "Photo URL or file_id" },
+          "caption": { "type": "string", "description": "Photo caption" },
+          "parse_mode": { "type": "string", "description": "Caption formatting mode", "enum": ["HTML", "Markdown", "MarkdownV2"] }
+        },
+        "required": ["chat_id", "photo"]
+      }
+    },
+    {
+      "name": "telegram_send_document",
+      "description": "Send a document/file to a Telegram chat",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "chat_id": { "type": "string", "description": "Target chat ID" },
+          "document": { "type": "string", "description": "Document URL or file_id" },
+          "caption": { "type": "string", "description": "Document caption" },
+          "parse_mode": { "type": "string", "description": "Caption formatting mode", "enum": ["HTML", "Markdown", "MarkdownV2"] }
+        },
+        "required": ["chat_id", "document"]
+      }
+    },
+    {
+      "name": "telegram_forward_message",
+      "description": "Forward a message from one chat to another",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "chat_id": { "type": "string", "description": "Target chat ID" },
+          "from_chat_id": { "type": "string", "description": "Source chat ID" },
+          "message_id": { "type": "integer", "description": "Message ID to forward" }
+        },
+        "required": ["chat_id", "from_chat_id", "message_id"]
+      }
+    },
+    {
+      "name": "telegram_get_me",
+      "description": "Get bot information",
+      "parameters": { "type": "object", "properties": {} }
+    },
+    {
+      "name": "telegram_get_chat",
+      "description": "Get chat information",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "chat_id": { "type": "string", "description": "Chat ID" }
+        },
+        "required": ["chat_id"]
+      }
+    },
+    {
+      "name": "telegram_set_webhook",
+      "description": "Set webhook URL for the bot (alternative to polling)",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "webhook_url": { "type": "string", "description": "Public HTTPS URL for webhook" },
+          "secret_token": { "type": "string", "description": "Optional secret token for verification" }
+        },
+        "required": ["webhook_url"]
+      }
+    },
+    {
+      "name": "telegram_delete_webhook",
+      "description": "Delete webhook and switch to polling mode",
+      "parameters": { "type": "object", "properties": {} }
+    },
+    {
+      "name": "telegram_get_webhook_info",
+      "description": "Get current webhook configuration",
+      "parameters": { "type": "object", "properties": {} }
+    }
+  ],
+  "permissions": {
+    "allowNetwork": true,
+    "allowStorage": true,
+    "allowFiles": true
+  }
+}