index.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. /**
  2. * ShadowMan Telegram Bot Plugin v3.1.0
  3. * Telegram Bot API integration with long polling via background worker.
  4. *
  5. * Features:
  6. * - Background worker polling (non-blocking)
  7. * - Automatic LLM response forwarding to Telegram
  8. * - Typing indicator while LLM processes
  9. * - Photo/document receiving and sending
  10. * - File download from Telegram servers
  11. *
  12. * Requires: bot_token (secret), authorized_user_id (config)
  13. */
  14. var config = shadowman.config.get();
  15. var BOT_TOKEN = config.bot_token || shadowman.config.value('bot_token');
  16. if (!BOT_TOKEN) {
  17. shadowman.log.error('bot_token not configured — plugin cannot start');
  18. }
  19. var AUTHORIZED_USER_ID = config.authorized_user_id || shadowman.config.value('authorized_user_id') || '';
  20. if (!AUTHORIZED_USER_ID) {
  21. shadowman.log.warn('authorized_user_id not set — bot will reject all messages');
  22. }
  23. var POLLING_ENABLED = config.polling_enabled !== false;
  24. var POLLING_INTERVAL = parseInt(config.polling_interval) || 2000;
  25. var POLLING_TIMEOUT = parseInt(config.polling_timeout) || 30;
  26. var API_BASE = 'https://api.telegram.org/bot' + BOT_TOKEN + '/';
  27. var FILE_BASE = 'https://api.telegram.org/file/bot' + BOT_TOKEN + '/';
  28. var lastUpdateId = shadowman.storage.get('last_update_id') || 0;
  29. // ── Helpers ────────────────────────────────────────────────────────────
  30. function apiCall(method, params) {
  31. try {
  32. var response = shadowman.http.post(API_BASE + method,
  33. JSON.stringify(params || {}),
  34. { headers: { 'Content-Type': 'application/json' }, timeout: 60 });
  35. var data = response.json();
  36. if (!data.ok) {
  37. return { success: false, error: data.description || 'Unknown error', error_code: data.error_code };
  38. }
  39. return { success: true, result: data.result };
  40. } catch (e) {
  41. return { success: false, error: e.toString() };
  42. }
  43. }
  44. function sanitizeResponse(response) {
  45. if (!BOT_TOKEN) return response;
  46. if (typeof response === 'string') {
  47. return response.replace(new RegExp(BOT_TOKEN, 'g'), '[REDACTED]');
  48. }
  49. if (typeof response === 'object' && response !== null) {
  50. var s = {};
  51. for (var key in response) {
  52. if (response.hasOwnProperty(key)) s[key] = sanitizeResponse(response[key]);
  53. }
  54. return s;
  55. }
  56. return response;
  57. }
  58. function isAuthorizedUser(userId) {
  59. return String(userId) === String(AUTHORIZED_USER_ID);
  60. }
  61. function getOrCreateConversationId(chatId) {
  62. var key = 'conv_' + String(chatId);
  63. var existing = shadowman.storage.get(key);
  64. if (existing) return existing;
  65. var id = shadowman.utils.uuid();
  66. shadowman.storage.set(key, id);
  67. return id;
  68. }
  69. /**
  70. * Download a file from Telegram servers by file_id.
  71. * Returns { success, file_path, size, data } where data is base64 content.
  72. */
  73. function downloadTelegramFile(fileId) {
  74. var fileInfo = apiCall('getFile', { file_id: fileId });
  75. if (!fileInfo.success || !fileInfo.result || !fileInfo.result.file_path) {
  76. return { success: false, error: 'Failed to get file info' };
  77. }
  78. var filePath = fileInfo.result.file_path;
  79. var url = FILE_BASE + filePath;
  80. try {
  81. var response = shadowman.http.get(url, { timeout: 60 });
  82. if (response.status === 200) {
  83. return {
  84. success: true,
  85. file_path: filePath,
  86. size: fileInfo.result.file_size || 0,
  87. data: shadowman.utils.base64Encode(response.body)
  88. };
  89. }
  90. return { success: false, error: 'HTTP ' + response.status };
  91. } catch (e) {
  92. return { success: false, error: e.toString() };
  93. }
  94. }
  95. /**
  96. * Get the largest photo from a Telegram photo array.
  97. */
  98. function getBestPhoto(photoArray) {
  99. if (!photoArray || photoArray.length === 0) return null;
  100. var best = photoArray[0];
  101. for (var i = 1; i < photoArray.length; i++) {
  102. if ((photoArray[i].file_size || 0) > (best.file_size || 0)) {
  103. best = photoArray[i];
  104. }
  105. }
  106. return best;
  107. }
  108. // ── Message processing ─────────────────────────────────────────────────
  109. function processTelegramUpdate(update) {
  110. var message = update.message;
  111. if (!message || !message.from) return;
  112. var userId = message.from.id;
  113. var chatId = message.chat.id;
  114. var username = message.from.username || message.from.first_name || 'Unknown';
  115. if (!isAuthorizedUser(userId)) {
  116. shadowman.log.debug('Unauthorized message from ' + userId);
  117. return;
  118. }
  119. var conversationId = getOrCreateConversationId(chatId);
  120. var text = message.text || message.caption || '';
  121. var hasPhoto = message.photo && message.photo.length > 0;
  122. var hasDocument = message.document;
  123. // Handle photo messages
  124. if (hasPhoto) {
  125. var photo = getBestPhoto(message.photo);
  126. if (photo) {
  127. shadowman.log.info('Photo from @' + username + (text ? ': ' + text.substring(0, 40) : ''));
  128. var downloaded = downloadTelegramFile(photo.file_id);
  129. if (downloaded.success) {
  130. var filename = 'telegram_photo_' + message.message_id + '.jpg';
  131. var saved = shadowman.files.save(filename, downloaded.data, 'image/jpeg');
  132. if (saved && saved.url) {
  133. // Use markdown image syntax so WebUI renders it
  134. text = (text || '') + '\n\n![Photo from Telegram](' + saved.url + ')';
  135. } else {
  136. text = text || 'Sent a photo (failed to save)';
  137. }
  138. }
  139. }
  140. }
  141. // Handle document messages
  142. if (hasDocument) {
  143. var doc = message.document;
  144. var docFilename = doc.file_name || ('telegram_file_' + message.message_id);
  145. var mime = doc.mime_type || 'application/octet-stream';
  146. shadowman.log.info('Document from @' + username + ': ' + docFilename);
  147. var downloaded = downloadTelegramFile(doc.file_id);
  148. if (downloaded.success) {
  149. var saved = shadowman.files.save(docFilename, downloaded.data, mime);
  150. if (saved && saved.url) {
  151. // Images as markdown, other files as links
  152. var isImage = mime.indexOf('image/') === 0;
  153. if (isImage) {
  154. text = (text || '') + '\n\n![' + docFilename + '](' + saved.url + ')';
  155. } else {
  156. text = (text || '') + '\n\n[' + docFilename + '](' + saved.url + ')';
  157. }
  158. } else {
  159. text = text || 'Sent a file: ' + docFilename + ' (failed to save)';
  160. }
  161. }
  162. }
  163. if (!text && !hasPhoto && !hasDocument) return;
  164. shadowman.log.info('Message from @' + username + ': ' + text.substring(0, 80));
  165. // Start typing indicator
  166. shadowman.state.set('typing_chat_id', chatId);
  167. shadowman.events.emit('message', {
  168. text: text,
  169. conversationId: conversationId,
  170. platform: 'Telegram',
  171. sender: message.from.first_name || username,
  172. metadata: {
  173. source: 'telegram',
  174. user_id: userId,
  175. username: username,
  176. chat_id: chatId,
  177. message_id: message.message_id
  178. }
  179. });
  180. }
  181. // ── Event handlers (main thread) ───────────────────────────────────────
  182. shadowman.events.on('llm_response', function(data) {
  183. if (!data.metadata || data.metadata.source !== 'telegram') return;
  184. var chatId = data.metadata.chat_id;
  185. var text = data.text || '';
  186. // Stop typing indicator
  187. shadowman.state.set('typing_chat_id', 0);
  188. if (!chatId || !text) return;
  189. // Detect image references and send as multipart photo uploads
  190. var imageRegex = /!\[([^\]]*)\]\((\/files\/[^\s\)]+\.(png|jpg|jpeg|gif|webp))\)/gi;
  191. var imgMatch;
  192. var sentImages = {};
  193. while ((imgMatch = imageRegex.exec(text)) !== null) {
  194. var imgCaption = imgMatch[1] || '';
  195. var imgUrl = imgMatch[2];
  196. if (!sentImages[imgUrl]) {
  197. sentImages[imgUrl] = true;
  198. // Extract filename from URL path
  199. var parts = imgUrl.split('/');
  200. var imgFilename = parts[parts.length - 1];
  201. try {
  202. var fileData = shadowman.files.read(imgFilename);
  203. if (fileData && fileData.content) {
  204. shadowman.log.info('Uploading photo to Telegram: ' + imgFilename);
  205. shadowman.http.post(API_BASE + 'sendPhoto', null, {
  206. multipart: [
  207. { name: 'chat_id', value: String(chatId) },
  208. { name: 'photo', filename: imgFilename, data: fileData.content, contentType: 'image/' + (imgFilename.endsWith('.png') ? 'png' : 'jpeg') },
  209. { name: 'caption', value: imgCaption || '' }
  210. ],
  211. timeout: 60
  212. });
  213. }
  214. } catch (e) {
  215. shadowman.log.debug('Could not send image: ' + e.toString());
  216. }
  217. }
  218. }
  219. // Send text — strip image markdown for cleaner message
  220. var cleanText = text.replace(/!\[[^\]]*\]\(\/files\/[^\)]+\)/g, '').trim();
  221. if (cleanText) {
  222. // Telegram 4096 char limit — split long messages
  223. while (cleanText.length > 0) {
  224. var chunk = cleanText.substring(0, 4096);
  225. cleanText = cleanText.substring(4096);
  226. apiCall('sendMessage', {
  227. chat_id: chatId,
  228. text: chunk,
  229. parse_mode: 'Markdown'
  230. });
  231. }
  232. }
  233. });
  234. // Handle updates from background worker
  235. shadowman.events.on('telegram_update', function(update) {
  236. processTelegramUpdate(update);
  237. var lastId = shadowman.state.get('last_update_id');
  238. if (lastId > 0) {
  239. shadowman.storage.set('last_update_id', lastId);
  240. }
  241. });
  242. // ── Tools ──────────────────────────────────────────────────────────────
  243. shadowman.tools.register('telegram_send_message', function(args) {
  244. if (!args.chat_id) return { success: false, error: 'chat_id is required' };
  245. if (!args.text) return { success: false, error: 'text is required' };
  246. var params = { chat_id: args.chat_id, text: args.text };
  247. if (args.parse_mode) params.parse_mode = args.parse_mode;
  248. if (args.disable_notification) params.disable_notification = true;
  249. if (args.reply_to_message_id) params.reply_to_message_id = args.reply_to_message_id;
  250. return sanitizeResponse(apiCall('sendMessage', params));
  251. });
  252. shadowman.tools.register('telegram_send_to_owner', function(args) {
  253. if (!args.text) return { success: false, error: 'text is required' };
  254. if (!AUTHORIZED_USER_ID) return { success: false, error: 'authorized_user_id not configured' };
  255. var params = { chat_id: AUTHORIZED_USER_ID, text: args.text };
  256. if (args.parse_mode) params.parse_mode = args.parse_mode;
  257. return sanitizeResponse(apiCall('sendMessage', params));
  258. });
  259. shadowman.tools.register('telegram_send_photo', function(args) {
  260. if (!args.chat_id) return { success: false, error: 'chat_id is required' };
  261. if (!args.photo) return { success: false, error: 'photo is required' };
  262. var params = { chat_id: args.chat_id, photo: args.photo };
  263. if (args.caption) params.caption = args.caption;
  264. if (args.parse_mode) params.parse_mode = args.parse_mode;
  265. return sanitizeResponse(apiCall('sendPhoto', params));
  266. });
  267. shadowman.tools.register('telegram_send_document', function(args) {
  268. if (!args.chat_id) return { success: false, error: 'chat_id is required' };
  269. if (!args.document) return { success: false, error: 'document is required' };
  270. var params = { chat_id: args.chat_id, document: args.document };
  271. if (args.caption) params.caption = args.caption;
  272. if (args.parse_mode) params.parse_mode = args.parse_mode;
  273. return sanitizeResponse(apiCall('sendDocument', params));
  274. });
  275. shadowman.tools.register('telegram_forward_message', function(args) {
  276. if (!args.chat_id) return { success: false, error: 'chat_id is required' };
  277. if (!args.from_chat_id) return { success: false, error: 'from_chat_id is required' };
  278. if (!args.message_id) return { success: false, error: 'message_id is required' };
  279. return sanitizeResponse(apiCall('forwardMessage', {
  280. chat_id: args.chat_id,
  281. from_chat_id: args.from_chat_id,
  282. message_id: args.message_id
  283. }));
  284. });
  285. shadowman.tools.register('telegram_get_me', function() {
  286. return sanitizeResponse(apiCall('getMe', {}));
  287. });
  288. shadowman.tools.register('telegram_get_chat', function(args) {
  289. if (!args.chat_id) return { success: false, error: 'chat_id is required' };
  290. return sanitizeResponse(apiCall('getChat', { chat_id: args.chat_id }));
  291. });
  292. shadowman.tools.register('telegram_set_webhook', function(args) {
  293. if (!args.webhook_url) return { success: false, error: 'webhook_url is required' };
  294. var params = { url: args.webhook_url, allowed_updates: ['message', 'edited_message', 'callback_query'] };
  295. if (args.secret_token) params.secret_token = args.secret_token;
  296. return sanitizeResponse(apiCall('setWebhook', params));
  297. });
  298. shadowman.tools.register('telegram_delete_webhook', function() {
  299. return sanitizeResponse(apiCall('deleteWebhook', {}));
  300. });
  301. shadowman.tools.register('telegram_get_webhook_info', function() {
  302. return sanitizeResponse(apiCall('getWebhookInfo', {}));
  303. });
  304. // ── Shared state and background workers ────────────────────────────────
  305. shadowman.state.set('api_base', API_BASE);
  306. shadowman.state.set('last_update_id', lastUpdateId);
  307. shadowman.state.set('polling', POLLING_ENABLED);
  308. shadowman.state.set('polling_timeout', POLLING_TIMEOUT);
  309. shadowman.state.set('polling_interval', POLLING_INTERVAL);
  310. shadowman.state.set('typing_chat_id', 0);
  311. // Typing indicator worker — sends "typing..." every 4s while LLM processes
  312. shadowman.background.run(function() {
  313. while (shadowman.state.get('polling') !== false) {
  314. var chatId = shadowman.state.get('typing_chat_id');
  315. if (chatId && chatId !== 0) {
  316. var base = shadowman.state.get('api_base');
  317. shadowman.http.post(base + 'sendChatAction', JSON.stringify({
  318. chat_id: chatId,
  319. action: 'typing'
  320. }), { headers: { 'Content-Type': 'application/json' }, timeout: 10 });
  321. }
  322. shadowman.utils.sleep(4000);
  323. }
  324. });
  325. // Polling worker
  326. if (POLLING_ENABLED) {
  327. shadowman.background.run(function() {
  328. var base = shadowman.state.get('api_base');
  329. var timeout = shadowman.state.get('polling_timeout');
  330. var interval = shadowman.state.get('polling_interval');
  331. shadowman.http.post(base + 'deleteWebhook', '{}',
  332. { headers: { 'Content-Type': 'application/json' } });
  333. shadowman.utils.sleep(2000);
  334. shadowman.log.info('Polling worker started');
  335. while (shadowman.state.get('polling')) {
  336. try {
  337. var offset = shadowman.state.get('last_update_id');
  338. var result = shadowman.http.post(base + 'getUpdates', JSON.stringify({
  339. offset: offset + 1,
  340. timeout: timeout,
  341. allowed_updates: ['message', 'edited_message', 'callback_query']
  342. }), {
  343. headers: { 'Content-Type': 'application/json' },
  344. timeout: timeout + 10
  345. });
  346. if (result.status === 200) {
  347. var data = result.json();
  348. if (data.ok && data.result && data.result.length > 0) {
  349. for (var i = 0; i < data.result.length; i++) {
  350. var update = data.result[i];
  351. if (update.update_id > offset) {
  352. shadowman.state.set('last_update_id', update.update_id);
  353. }
  354. shadowman.events.emit('telegram_update', update);
  355. }
  356. }
  357. } else {
  358. shadowman.log.warn('Polling error: HTTP ' + result.status);
  359. shadowman.utils.sleep(5000);
  360. }
  361. } catch (e) {
  362. shadowman.log.error('Polling exception: ' + e.toString());
  363. shadowman.utils.sleep(5000);
  364. }
  365. shadowman.utils.sleep(interval);
  366. }
  367. shadowman.log.info('Polling worker stopped');
  368. });
  369. }
  370. shadowman.log.info('Telegram plugin v3.1.0 ready (' +
  371. (POLLING_ENABLED ? 'polling' : 'webhook') + ' mode)');