this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #8 from HenrickTheBull/announce

Added on announcements managment module.

authored by

Henrick and committed by
GitHub
ca64921b cf6d4963

+963 -2
+1
.gitignore
··· 1 1 # Environment variables 2 2 .env 3 + announcements.json 3 4 4 5 # Queue files 5 6 queue/queue.json
+611 -2
bot/telegramBot.js
··· 8 8 const queueManager = require('../queue/queueManager'); 9 9 const scraperManager = require('../utils/scraperManager'); 10 10 const mediaCache = require('../utils/mediaCache'); 11 + const AnnouncementManager = require('../utils/announcementManager'); 11 12 const discordWebhook = require('./discordWebhook'); 12 13 13 14 class StagehandBot { 14 15 constructor() { 15 16 this.bot = new TelegramBot(config.botToken, { polling: true }); 16 17 this.serviceName = 'telegram'; 18 + this.channelId = config.channelId; 19 + this.announcements = new AnnouncementManager(this); 17 20 this.init(); 18 21 } 19 22 20 - init() { 23 + async init() { 24 + await this.announcements.init(); 21 25 this.registerCommands(); 22 26 this.registerCallbacks(); 23 27 console.log('Telegram bot started...'); ··· 37 41 Stagehand Bot Commands: 38 42 /queue - Show current queue status with interactive management 39 43 /send - Post the next image in the queue 40 - /schedule [cron] - Set posting schedule (cron syntax) 44 + /schedule [cron] - Set posting schedule (cron syntax, use https://crontab.guru/ for help) 41 45 /setcount [number] - Set number of images per scheduled post (default: 1) 42 46 /clear - Clear the queue 43 47 /cleancache - Clean expired items from media cache ··· 288 292 } 289 293 }); 290 294 295 + // Command to add a text announcement 296 + this.bot.onText(/\/announce(?:\s+(.+))?/, async (msg, match) => { 297 + const chatId = msg.chat.id; 298 + 299 + if (!this.isAuthorized(msg.from.id)) { 300 + this.bot.sendMessage(chatId, 'You are not authorized to use this command.'); 301 + return; 302 + } 303 + 304 + // Check if there's text after the command 305 + const text = match && match[1] ? match[1].trim() : ''; 306 + 307 + if (!text) { 308 + // No parameters - show usage help 309 + this.bot.sendMessage( 310 + chatId, 311 + 'Usage: /announce [message text]\n\nThis will initiate the announcement creation process. After sending the message text, you\'ll be prompted to set a name, schedule, and an optional button link for the announcement.' 312 + ); 313 + return; 314 + } 315 + 316 + // Store the message text in session and ask for a name 317 + this.pendingAnnouncements = this.pendingAnnouncements || {}; 318 + this.pendingAnnouncements[msg.from.id] = { message: text }; 319 + 320 + this.bot.sendMessage( 321 + chatId, 322 + 'Please enter a name for this announcement (or type "skip" for auto-generated name):', 323 + { reply_markup: { force_reply: true } } 324 + ).then(namePrompt => { 325 + // Set up a one-time listener for the name response 326 + this.bot.onReplyToMessage(chatId, namePrompt.message_id, async (nameMsg) => { 327 + const announcementName = nameMsg.text === 'skip' ? '' : nameMsg.text; 328 + this.pendingAnnouncements[msg.from.id].name = announcementName; 329 + 330 + // Now ask for a schedule 331 + this.bot.sendMessage( 332 + chatId, 333 + 'Please enter a cron schedule for when this announcement should run (use https://crontab.guru/ for help):', 334 + { reply_markup: { force_reply: true } } 335 + ).then(schedulePrompt => { 336 + // Set up a one-time listener for the schedule response 337 + this.bot.onReplyToMessage(chatId, schedulePrompt.message_id, async (scheduleMsg) => { 338 + const cronSchedule = scheduleMsg.text; 339 + 340 + // Ask if they want to add a button 341 + this.bot.sendMessage( 342 + chatId, 343 + 'Would you like to add a button with a link to this announcement?', 344 + { 345 + reply_markup: { 346 + inline_keyboard: [ 347 + [ 348 + { text: 'Yes', callback_data: 'add_button' }, 349 + { text: 'No', callback_data: 'skip_button' } 350 + ] 351 + ] 352 + } 353 + } 354 + ).then(buttonPrompt => { 355 + // Callback handler for yes/no button selection 356 + this.bot.once('callback_query', async (query) => { 357 + await this.bot.answerCallbackQuery(query.id); 358 + 359 + // Delete the yes/no prompt 360 + await this.bot.deleteMessage(chatId, buttonPrompt.message_id); 361 + 362 + if (query.data === 'add_button') { 363 + // User wants to add a button 364 + this.bot.sendMessage( 365 + chatId, 366 + 'Please enter the button text:', 367 + { reply_markup: { force_reply: true } } 368 + ).then(buttonTextPrompt => { 369 + this.bot.onReplyToMessage(chatId, buttonTextPrompt.message_id, async (buttonTextMsg) => { 370 + const buttonText = buttonTextMsg.text; 371 + 372 + // Now ask for the button URL 373 + this.bot.sendMessage( 374 + chatId, 375 + 'Please enter the button URL:', 376 + { reply_markup: { force_reply: true } } 377 + ).then(buttonUrlPrompt => { 378 + this.bot.onReplyToMessage(chatId, buttonUrlPrompt.message_id, async (buttonUrlMsg) => { 379 + const buttonUrl = buttonUrlMsg.text; 380 + 381 + // Create the button object 382 + const button = { 383 + text: buttonText, 384 + url: buttonUrl 385 + }; 386 + 387 + try { 388 + const announcement = await this.announcements.addAnnouncement( 389 + this.pendingAnnouncements[msg.from.id].message, 390 + cronSchedule, 391 + this.pendingAnnouncements[msg.from.id].name, 392 + button 393 + ); 394 + 395 + delete this.pendingAnnouncements[msg.from.id]; 396 + 397 + this.bot.sendMessage( 398 + chatId, 399 + `✅ Announcement "${announcement.name}" created!\n\n`+ 400 + `Scheduled for: ${announcement.cronSchedule}\n\n`+ 401 + `Button: "${button.text}" → ${button.url}\n\n`+ 402 + `You can manage all announcements with /announcements` 403 + ); 404 + } catch (error) { 405 + this.bot.sendMessage( 406 + chatId, 407 + `Error creating announcement: ${error.message}\n\nPlease try again.` 408 + ); 409 + } 410 + }); 411 + }); 412 + }); 413 + }); 414 + } else { 415 + // User doesn't want to add a button 416 + try { 417 + const announcement = await this.announcements.addAnnouncement( 418 + this.pendingAnnouncements[msg.from.id].message, 419 + cronSchedule, 420 + this.pendingAnnouncements[msg.from.id].name 421 + ); 422 + 423 + delete this.pendingAnnouncements[msg.from.id]; 424 + 425 + this.bot.sendMessage( 426 + chatId, 427 + `✅ Announcement "${announcement.name}" created!\n\nScheduled for: ${announcement.cronSchedule}\n\nYou can manage all announcements with /announcements` 428 + ); 429 + } catch (error) { 430 + this.bot.sendMessage( 431 + chatId, 432 + `Error creating announcement: ${error.message}\n\nPlease try again with a valid cron expression.` 433 + ); 434 + } 435 + } 436 + }); 437 + }); 438 + }); 439 + }); 440 + }); 441 + }); 442 + }); 443 + 444 + // Command to list and manage all announcements 445 + this.bot.onText(/\/announcements/, async (msg) => { 446 + const chatId = msg.chat.id; 447 + 448 + if (!this.isAuthorized(msg.from.id)) { 449 + this.bot.sendMessage(chatId, 'You are not authorized to use this command.'); 450 + return; 451 + } 452 + 453 + const announcements = this.announcements.getAnnouncements(); 454 + 455 + if (announcements.length === 0) { 456 + this.bot.sendMessage( 457 + chatId, 458 + 'No announcements configured. Use /announce to create a new announcement.' 459 + ); 460 + return; 461 + } 462 + 463 + // Format the list of announcements with inline buttons 464 + let message = '📣 *Text Announcements*\n\n'; 465 + 466 + const inlineKeyboard = []; 467 + 468 + for (let i = 0; i < announcements.length; i++) { 469 + const announcement = announcements[i]; 470 + 471 + // Add announcement details to message 472 + message += `*${i+1}. ${announcement.name}*\n`; 473 + message += `Schedule: \`${announcement.cronSchedule}\`\n`; 474 + message += `Last run: ${announcement.lastRun ? new Date(announcement.lastRun).toLocaleString() : 'Never'}\n`; 475 + 476 + // Show button info if present 477 + if (announcement.button && announcement.button.text && announcement.button.url) { 478 + message += `Button: "${announcement.button.text}" → ${announcement.button.url}\n`; 479 + } 480 + 481 + message += `Message: "${announcement.message.substring(0, 50)}${announcement.message.length > 50 ? '...' : ''}"\n\n`; 482 + 483 + // Add buttons for this announcement 484 + inlineKeyboard.push([ 485 + { 486 + text: `▶️ Run #${i+1}`, 487 + callback_data: `run_announcement_${announcement.id}` 488 + }, 489 + { 490 + text: `✏️ Edit #${i+1}`, 491 + callback_data: `edit_announcement_${announcement.id}` 492 + }, 493 + { 494 + text: `❌ Delete #${i+1}`, 495 + callback_data: `delete_announcement_${announcement.id}` 496 + } 497 + ]); 498 + } 499 + 500 + // Add a button to create a new announcement 501 + inlineKeyboard.push([ 502 + { 503 + text: '➕ Add New Announcement', 504 + callback_data: 'new_announcement' 505 + } 506 + ]); 507 + 508 + await this.bot.sendMessage(chatId, message, { 509 + parse_mode: 'Markdown', 510 + reply_markup: { 511 + inline_keyboard: inlineKeyboard 512 + } 513 + }); 514 + }); 515 + 291 516 // Handle URL links 292 517 this.bot.on('message', async (msg) => { 293 518 if (msg.text && msg.text.startsWith('http')) { 294 519 const chatId = msg.chat.id; 295 520 521 + // Skip processing if this is part of an announcement setup 522 + const isInAnnouncementFlow = this.pendingAnnouncements && this.pendingAnnouncements[msg.from.id]; 523 + const isInButtonEditFlow = this.editingAnnouncementButton && this.editingAnnouncementButton[msg.from.id]; 524 + 525 + if (isInAnnouncementFlow || isInButtonEditFlow) { 526 + // This URL is part of an announcement setup, so we should not process it as a link 527 + return; 528 + } 529 + 296 530 if (!this.isAuthorized(msg.from.id)) { 297 531 this.bot.sendMessage(chatId, 'You are not authorized to use this bot.'); 298 532 return; ··· 441 675 } 442 676 } else { 443 677 await this.bot.answerCallbackQuery(query.id, { text: 'Item not found' }); 678 + } 679 + break; 680 + } 681 + 682 + // New announcement management callback handlers 683 + case 'run': { 684 + if (data[1] === 'announcement') { 685 + const announcementId = data[2]; 686 + await this.bot.answerCallbackQuery(query.id, { text: 'Sending announcement...' }); 687 + 688 + try { 689 + const result = await this.announcements.sendAnnouncementNow(announcementId); 690 + if (result) { 691 + await this.bot.sendMessage(chatId, `✅ Announcement sent successfully!`); 692 + } else { 693 + await this.bot.sendMessage(chatId, `❌ Failed to send announcement.`); 694 + } 695 + } catch (error) { 696 + await this.bot.sendMessage(chatId, `❌ Error: ${error.message}`); 697 + } 698 + 699 + // Refresh announcements list 700 + await this.bot.deleteMessage(chatId, query.message.message_id); 701 + await this.bot.onText.handlers.find(h => h.regexp.toString().includes('/announcements'))?._callback({ chat: { id: chatId } }); 702 + } 703 + break; 704 + } 705 + 706 + case 'delete': { 707 + if (data[1] === 'announcement') { 708 + const announcementId = data[2]; 709 + 710 + // Get the announcement to show its name 711 + const announcement = this.announcements.getAnnouncementById(announcementId); 712 + if (!announcement) { 713 + await this.bot.answerCallbackQuery(query.id, { text: 'Announcement not found.' }); 714 + return; 715 + } 716 + 717 + // Show confirmation dialog 718 + await this.bot.answerCallbackQuery(query.id); 719 + 720 + const confirmMessage = await this.bot.sendMessage( 721 + chatId, 722 + `Are you sure you want to delete the announcement "${announcement.name}"?`, 723 + { 724 + reply_markup: { 725 + inline_keyboard: [ 726 + [ 727 + { text: '✅ Yes, delete it', callback_data: `confirm_delete_announcement_${announcementId}` }, 728 + { text: '❌ No, cancel', callback_data: 'cancel_delete_announcement' } 729 + ] 730 + ] 731 + } 732 + } 733 + ); 734 + } 735 + break; 736 + } 737 + 738 + case 'confirm': { 739 + if (data[1] === 'delete' && data[2] === 'announcement') { 740 + const announcementId = data[3]; 741 + 742 + try { 743 + const result = await this.announcements.removeAnnouncement(announcementId); 744 + if (result) { 745 + await this.bot.answerCallbackQuery(query.id, { text: 'Announcement deleted successfully.' }); 746 + } else { 747 + await this.bot.answerCallbackQuery(query.id, { text: 'Failed to delete announcement.' }); 748 + } 749 + 750 + // Delete confirmation message 751 + await this.bot.deleteMessage(chatId, query.message.message_id); 752 + 753 + // Refresh announcements list 754 + await this.bot.onText.handlers.find(h => h.regexp.toString().includes('/announcements'))?._callback({ chat: { id: chatId } }); 755 + } catch (error) { 756 + await this.bot.answerCallbackQuery(query.id, { text: `Error: ${error.message}` }); 757 + } 758 + } 759 + break; 760 + } 761 + 762 + case 'cancel': { 763 + if (data[1] === 'delete' && data[2] === 'announcement') { 764 + await this.bot.answerCallbackQuery(query.id, { text: 'Delete cancelled.' }); 765 + await this.bot.deleteMessage(chatId, query.message.message_id); 766 + } 767 + break; 768 + } 769 + 770 + case 'edit': { 771 + if (data[1] === 'announcement') { 772 + const announcementId = data[2]; 773 + const announcement = this.announcements.getAnnouncementById(announcementId); 774 + 775 + if (!announcement) { 776 + await this.bot.answerCallbackQuery(query.id, { text: 'Announcement not found.' }); 777 + return; 778 + } 779 + 780 + await this.bot.answerCallbackQuery(query.id); 781 + 782 + // Show edit options 783 + const editMessage = await this.bot.sendMessage( 784 + chatId, 785 + `Editing announcement: *${announcement.name}*\n\nWhat would you like to edit?`, 786 + { 787 + parse_mode: 'Markdown', 788 + reply_markup: { 789 + inline_keyboard: [ 790 + [ 791 + { 792 + text: '📝 Edit Message', 793 + callback_data: `edit_announcement_message_${announcementId}` 794 + } 795 + ], 796 + [ 797 + { 798 + text: '⏰ Edit Schedule', 799 + callback_data: `edit_announcement_schedule_${announcementId}` 800 + } 801 + ], 802 + [ 803 + { 804 + text: '🏷️ Edit Name', 805 + callback_data: `edit_announcement_name_${announcementId}` 806 + } 807 + ], 808 + [ 809 + { 810 + text: '🔗 Edit Button', 811 + callback_data: `edit_announcement_button_${announcementId}` 812 + } 813 + ], 814 + [ 815 + { 816 + text: '❌ Cancel', 817 + callback_data: 'cancel_edit_announcement' 818 + } 819 + ] 820 + ] 821 + } 822 + } 823 + ); 824 + } else if (data[1] === 'announcement' && (data[2] === 'message' || data[2] === 'name' || data[2] === 'schedule' || data[2] === 'button')) { 825 + const field = data[2]; 826 + const announcementId = data[3]; 827 + const announcement = this.announcements.getAnnouncementById(announcementId); 828 + 829 + if (!announcement) { 830 + await this.bot.answerCallbackQuery(query.id, { text: 'Announcement not found.' }); 831 + return; 832 + } 833 + 834 + await this.bot.answerCallbackQuery(query.id); 835 + 836 + // Delete the edit options message 837 + await this.bot.deleteMessage(chatId, query.message.message_id); 838 + 839 + let promptText = ''; 840 + switch (field) { 841 + case 'message': 842 + promptText = `Please enter the new message text for the announcement "${announcement.name}":\n\nCurrent message:\n${announcement.message}`; 843 + break; 844 + case 'name': 845 + promptText = `Please enter the new name for the announcement "${announcement.name}":`; 846 + break; 847 + case 'schedule': 848 + promptText = `Please enter the new cron schedule for the announcement "${announcement.name}" (use https://crontab.guru/ for help):\n\nCurrent schedule: ${announcement.cronSchedule}`; 849 + break; 850 + case 'button': 851 + // For button editing, we'll first ask if they want to add, edit, or remove a button 852 + const hasButton = announcement.button && announcement.button.text && announcement.button.url; 853 + 854 + if (hasButton) { 855 + // Show options to edit or remove existing button 856 + await this.bot.sendMessage( 857 + chatId, 858 + `Current button: "${announcement.button.text}" → ${announcement.button.url}\n\nWhat would you like to do?`, 859 + { 860 + reply_markup: { 861 + inline_keyboard: [ 862 + [ 863 + { 864 + text: '✏️ Edit Button', 865 + callback_data: `edit_announcement_button_edit_${announcementId}` 866 + } 867 + ], 868 + [ 869 + { 870 + text: '❌ Remove Button', 871 + callback_data: `edit_announcement_button_remove_${announcementId}` 872 + } 873 + ], 874 + [ 875 + { 876 + text: '↩️ Cancel', 877 + callback_data: 'cancel_edit_announcement_button' 878 + } 879 + ] 880 + ] 881 + } 882 + } 883 + ); 884 + return; 885 + } else { 886 + // No existing button, ask if they want to add one 887 + await this.bot.sendMessage( 888 + chatId, 889 + `This announcement doesn't have a button. Would you like to add one?`, 890 + { 891 + reply_markup: { 892 + inline_keyboard: [ 893 + [ 894 + { 895 + text: '➕ Add Button', 896 + callback_data: `edit_announcement_button_add_${announcementId}` 897 + } 898 + ], 899 + [ 900 + { 901 + text: '↩️ Cancel', 902 + callback_data: 'cancel_edit_announcement_button' 903 + } 904 + ] 905 + ] 906 + } 907 + } 908 + ); 909 + return; 910 + } 911 + } 912 + } 913 + break; 914 + } 915 + 916 + case 'new': { 917 + if (data[1] === 'announcement') { 918 + await this.bot.answerCallbackQuery(query.id); 919 + 920 + // Delete the announcements list message 921 + await this.bot.deleteMessage(chatId, query.message.message_id); 922 + 923 + // Trigger the /announce command 924 + await this.bot.sendMessage( 925 + chatId, 926 + 'Please use the /announce command followed by your announcement text to create a new announcement.' 927 + ); 928 + } 929 + break; 930 + } 931 + 932 + case 'cancel': { 933 + if (data[1] === 'edit' && data[2] === 'announcement') { 934 + await this.bot.answerCallbackQuery(query.id, { text: 'Edit cancelled.' }); 935 + await this.bot.deleteMessage(chatId, query.message.message_id); 936 + 937 + // Refresh announcements list 938 + await this.bot.onText.handlers.find(h => h.regexp.toString().includes('/announcements'))?._callback({ chat: { id: chatId } }); 939 + } 940 + break; 941 + } 942 + 943 + case 'edit': { 944 + if (data[1] === 'announcement' && data[2] === 'button') { 945 + if (data[3] === 'add' || data[3] === 'edit') { 946 + // Add or edit a button 947 + const announcementId = data[4]; 948 + const announcement = this.announcements.getAnnouncementById(announcementId); 949 + 950 + if (!announcement) { 951 + await this.bot.answerCallbackQuery(query.id, { text: 'Announcement not found.' }); 952 + return; 953 + } 954 + 955 + await this.bot.answerCallbackQuery(query.id); 956 + 957 + // Delete the options message 958 + await this.bot.deleteMessage(chatId, query.message.message_id); 959 + 960 + // Store the context for updating 961 + this.editingAnnouncementButton = this.editingAnnouncementButton || {}; 962 + this.editingAnnouncementButton[query.from.id] = { id: announcementId }; 963 + 964 + // First ask for button text 965 + const buttonTextPrompt = await this.bot.sendMessage( 966 + chatId, 967 + 'Please enter the button text:', 968 + { reply_markup: { force_reply: true } } 969 + ); 970 + 971 + this.bot.onReplyToMessage(chatId, buttonTextPrompt.message_id, async (buttonTextMsg) => { 972 + const buttonText = buttonTextMsg.text; 973 + 974 + // Now ask for the button URL 975 + const buttonUrlPrompt = await this.bot.sendMessage( 976 + chatId, 977 + 'Please enter the button URL:', 978 + { reply_markup: { force_reply: true } } 979 + ); 980 + 981 + this.bot.onReplyToMessage(chatId, buttonUrlPrompt.message_id, async (buttonUrlMsg) => { 982 + const buttonUrl = buttonUrlMsg.text; 983 + 984 + // Create the button object 985 + const button = { 986 + text: buttonText, 987 + url: buttonUrl 988 + }; 989 + 990 + try { 991 + // Update the announcement with the new button 992 + const updated = await this.announcements.updateAnnouncement(announcementId, { button }); 993 + 994 + if (updated) { 995 + await this.bot.sendMessage( 996 + chatId, 997 + `✅ Button ${data[3] === 'add' ? 'added' : 'updated'} successfully!` 998 + ); 999 + } else { 1000 + await this.bot.sendMessage( 1001 + chatId, 1002 + `❌ Failed to ${data[3] === 'add' ? 'add' : 'update'} button.` 1003 + ); 1004 + } 1005 + 1006 + // Clean up 1007 + delete this.editingAnnouncementButton[query.from.id]; 1008 + 1009 + // Refresh announcements list 1010 + await this.bot.onText.handlers.find(h => h.regexp.toString().includes('/announcements'))?._callback({ chat: { id: chatId } }); 1011 + } catch (error) { 1012 + await this.bot.sendMessage( 1013 + chatId, 1014 + `❌ Error updating button: ${error.message}` 1015 + ); 1016 + } 1017 + }); 1018 + }); 1019 + } else if (data[3] === 'remove') { 1020 + // Remove a button 1021 + const announcementId = data[4]; 1022 + 1023 + try { 1024 + // Remove the button by setting it to null 1025 + const updated = await this.announcements.updateAnnouncement(announcementId, { button: null }); 1026 + 1027 + if (updated) { 1028 + await this.bot.answerCallbackQuery(query.id, { text: 'Button removed successfully.' }); 1029 + } else { 1030 + await this.bot.answerCallbackQuery(query.id, { text: 'Failed to remove button.' }); 1031 + } 1032 + 1033 + // Delete the options message 1034 + await this.bot.deleteMessage(chatId, query.message.message_id); 1035 + 1036 + // Refresh announcements list 1037 + await this.bot.onText.handlers.find(h => h.regexp.toString().includes('/announcements'))?._callback({ chat: { id: chatId } }); 1038 + } catch (error) { 1039 + await this.bot.answerCallbackQuery(query.id, { text: `Error: ${error.message}` }); 1040 + } 1041 + } 1042 + } 1043 + break; 1044 + } 1045 + 1046 + case 'cancel': { 1047 + if (data[1] === 'edit' && data[2] === 'announcement' && data[3] === 'button') { 1048 + await this.bot.answerCallbackQuery(query.id, { text: 'Button edit cancelled.' }); 1049 + await this.bot.deleteMessage(chatId, query.message.message_id); 1050 + 1051 + // Refresh announcements list 1052 + await this.bot.onText.handlers.find(h => h.regexp.toString().includes('/announcements'))?._callback({ chat: { id: chatId } }); 444 1053 } 445 1054 break; 446 1055 }
+351
utils/announcementManager.js
··· 1 + /** 2 + * Announcement Manager for scheduled text announcements 3 + * Handles multiple announcements with individual cron schedules 4 + */ 5 + 6 + const fs = require('fs'); 7 + const path = require('path'); 8 + const cron = require('node-cron'); 9 + const { promisify } = require('util'); 10 + const writeFileAsync = promisify(fs.writeFile); 11 + const readFileAsync = promisify(fs.readFile); 12 + 13 + class AnnouncementManager { 14 + constructor(telegramBot) { 15 + this.telegramBot = telegramBot; 16 + this.announcements = []; 17 + this.cronJobs = {}; 18 + this.filePath = path.join(__dirname, '..', 'announcements.json'); 19 + this.initialized = false; 20 + } 21 + 22 + /** 23 + * Initialize the announcement manager 24 + */ 25 + async init() { 26 + if (this.initialized) return; 27 + 28 + try { 29 + await this.loadAnnouncementsFromDisk(); 30 + this.scheduleAllAnnouncements(); 31 + this.initialized = true; 32 + console.log('Announcement manager initialized'); 33 + } catch (error) { 34 + console.error('Error initializing announcement manager:', error); 35 + // If loading fails, start with empty announcements 36 + this.announcements = []; 37 + await this.saveAnnouncementsToDisk(); 38 + } 39 + } 40 + 41 + /** 42 + * Load announcements from disk 43 + */ 44 + async loadAnnouncementsFromDisk() { 45 + try { 46 + // Check if the file exists 47 + if (!fs.existsSync(this.filePath)) { 48 + // Create an empty announcements file 49 + this.announcements = []; 50 + await this.saveAnnouncementsToDisk(); 51 + return; 52 + } 53 + 54 + // Read and parse the file 55 + const data = await readFileAsync(this.filePath, 'utf8'); 56 + this.announcements = JSON.parse(data); 57 + console.log(`Loaded ${this.announcements.length} announcements from disk`); 58 + } catch (error) { 59 + console.error('Error loading announcements from disk:', error); 60 + throw error; 61 + } 62 + } 63 + 64 + /** 65 + * Save announcements to disk 66 + */ 67 + async saveAnnouncementsToDisk() { 68 + try { 69 + const data = JSON.stringify(this.announcements, null, 2); 70 + await writeFileAsync(this.filePath, data, 'utf8'); 71 + console.log(`Saved ${this.announcements.length} announcements to disk`); 72 + return true; 73 + } catch (error) { 74 + console.error('Error saving announcements to disk:', error); 75 + return false; 76 + } 77 + } 78 + 79 + /** 80 + * Add a new announcement 81 + * @param {string} message - The text message to post 82 + * @param {string} cronSchedule - Cron expression for schedule 83 + * @param {string} name - Optional name/label for the announcement 84 + * @param {Object} button - Optional button configuration {text: string, url: string} 85 + * @returns {Promise<Object>} - The added announcement object 86 + */ 87 + async addAnnouncement(message, cronSchedule, name = '', button = null) { 88 + // Validate cron expression 89 + if (!this.isValidCronExpression(cronSchedule)) { 90 + throw new Error('Invalid cron expression'); 91 + } 92 + 93 + // Validate button if provided 94 + if (button && (!button.text || !button.url)) { 95 + throw new Error('Button must have both text and url properties'); 96 + } 97 + 98 + // Create new announcement 99 + const id = Date.now().toString(); 100 + const announcement = { 101 + id, 102 + message, 103 + cronSchedule, 104 + name: name || `Announcement ${id.substring(id.length - 4)}`, 105 + createdAt: new Date().toISOString(), 106 + lastRun: null, 107 + button: button 108 + }; 109 + 110 + // Add to our list 111 + this.announcements.push(announcement); 112 + 113 + // Save changes 114 + await this.saveAnnouncementsToDisk(); 115 + 116 + // Schedule it 117 + this.scheduleAnnouncement(announcement); 118 + 119 + return announcement; 120 + } 121 + 122 + /** 123 + * Remove an announcement by its ID 124 + * @param {string} id - Announcement ID to remove 125 + * @returns {Promise<boolean>} - Whether removal was successful 126 + */ 127 + async removeAnnouncement(id) { 128 + const index = this.announcements.findIndex(a => a.id === id); 129 + 130 + if (index === -1) { 131 + return false; 132 + } 133 + 134 + // Stop the cron job 135 + if (this.cronJobs[id]) { 136 + this.cronJobs[id].stop(); 137 + delete this.cronJobs[id]; 138 + } 139 + 140 + // Remove from array 141 + this.announcements.splice(index, 1); 142 + 143 + // Save changes 144 + await this.saveAnnouncementsToDisk(); 145 + 146 + return true; 147 + } 148 + 149 + /** 150 + * Update an existing announcement 151 + * @param {string} id - ID of the announcement to update 152 + * @param {Object} updates - Object with fields to update (message, cronSchedule, or name) 153 + * @returns {Promise<Object>} - The updated announcement 154 + */ 155 + async updateAnnouncement(id, updates) { 156 + const index = this.announcements.findIndex(a => a.id === id); 157 + 158 + if (index === -1) { 159 + throw new Error('Announcement not found'); 160 + } 161 + 162 + const announcement = this.announcements[index]; 163 + 164 + // Update fields 165 + if (updates.message !== undefined) { 166 + announcement.message = updates.message; 167 + } 168 + 169 + if (updates.name !== undefined) { 170 + announcement.name = updates.name; 171 + } 172 + 173 + if (updates.cronSchedule !== undefined) { 174 + if (!this.isValidCronExpression(updates.cronSchedule)) { 175 + throw new Error('Invalid cron expression'); 176 + } 177 + 178 + announcement.cronSchedule = updates.cronSchedule; 179 + 180 + // Update the schedule 181 + if (this.cronJobs[id]) { 182 + this.cronJobs[id].stop(); 183 + } 184 + this.scheduleAnnouncement(announcement); 185 + } 186 + 187 + // Save changes 188 + await this.saveAnnouncementsToDisk(); 189 + 190 + return announcement; 191 + } 192 + 193 + /** 194 + * Get all announcements 195 + * @returns {Array} - Array of announcement objects 196 + */ 197 + getAnnouncements() { 198 + return [...this.announcements]; 199 + } 200 + 201 + /** 202 + * Get a specific announcement by ID 203 + * @param {string} id - The announcement ID 204 + * @returns {Object|null} - The announcement object or null if not found 205 + */ 206 + getAnnouncementById(id) { 207 + return this.announcements.find(a => a.id === id) || null; 208 + } 209 + 210 + /** 211 + * Schedule a single announcement 212 + * @param {Object} announcement - The announcement to schedule 213 + */ 214 + scheduleAnnouncement(announcement) { 215 + // Cancel existing job if it exists 216 + if (this.cronJobs[announcement.id]) { 217 + this.cronJobs[announcement.id].stop(); 218 + } 219 + 220 + // Create new cron job 221 + this.cronJobs[announcement.id] = cron.schedule(announcement.cronSchedule, async () => { 222 + try { 223 + console.log(`Running scheduled announcement: ${announcement.name}`); 224 + 225 + // Create message options with markdown support 226 + const messageOptions = { parse_mode: 'Markdown' }; 227 + 228 + // Add inline keyboard if a button is defined 229 + if (announcement.button && announcement.button.text && announcement.button.url) { 230 + messageOptions.reply_markup = { 231 + inline_keyboard: [ 232 + [ 233 + { 234 + text: announcement.button.text, 235 + url: announcement.button.url 236 + } 237 + ] 238 + ] 239 + }; 240 + } 241 + 242 + // Send the announcement message to the channel 243 + const result = await this.telegramBot.bot.sendMessage( 244 + this.telegramBot.channelId, 245 + announcement.message, 246 + messageOptions 247 + ); 248 + 249 + // Update last run time 250 + const index = this.announcements.findIndex(a => a.id === announcement.id); 251 + if (index !== -1) { 252 + this.announcements[index].lastRun = new Date().toISOString(); 253 + await this.saveAnnouncementsToDisk(); 254 + } 255 + 256 + console.log(`Successfully sent announcement: ${announcement.name}`); 257 + } catch (error) { 258 + console.error(`Error sending announcement ${announcement.name}:`, error); 259 + } 260 + }); 261 + 262 + console.log(`Scheduled announcement: ${announcement.name} with cron: ${announcement.cronSchedule}`); 263 + } 264 + 265 + /** 266 + * Schedule all announcements 267 + */ 268 + scheduleAllAnnouncements() { 269 + // Clear existing jobs 270 + Object.values(this.cronJobs).forEach(job => job.stop()); 271 + this.cronJobs = {}; 272 + 273 + // Schedule each announcement 274 + this.announcements.forEach(announcement => { 275 + this.scheduleAnnouncement(announcement); 276 + }); 277 + 278 + console.log(`Scheduled ${this.announcements.length} announcements`); 279 + } 280 + 281 + /** 282 + * Validate a cron expression 283 + * @param {string} cronExpression - The cron expression to validate 284 + * @returns {boolean} - Whether the expression is valid 285 + */ 286 + isValidCronExpression(cronExpression) { 287 + return cron.validate(cronExpression); 288 + } 289 + 290 + /** 291 + * Send an announcement immediately (one-time) 292 + * @param {string} id - ID of the announcement to send 293 + * @returns {Promise<boolean>} - Whether sending was successful 294 + */ 295 + async sendAnnouncementNow(id) { 296 + const announcement = this.getAnnouncementById(id); 297 + 298 + if (!announcement) { 299 + return false; 300 + } 301 + 302 + try { 303 + // Create message options with markdown support 304 + const messageOptions = { parse_mode: 'Markdown' }; 305 + 306 + // Add inline keyboard if a button is defined 307 + if (announcement.button && announcement.button.text && announcement.button.url) { 308 + messageOptions.reply_markup = { 309 + inline_keyboard: [ 310 + [ 311 + { 312 + text: announcement.button.text, 313 + url: announcement.button.url 314 + } 315 + ] 316 + ] 317 + }; 318 + } 319 + 320 + await this.telegramBot.bot.sendMessage( 321 + this.telegramBot.channelId, 322 + announcement.message, 323 + messageOptions 324 + ); 325 + 326 + // Update last run time 327 + const index = this.announcements.findIndex(a => a.id === id); 328 + if (index !== -1) { 329 + this.announcements[index].lastRun = new Date().toISOString(); 330 + await this.saveAnnouncementsToDisk(); 331 + } 332 + 333 + return true; 334 + } catch (error) { 335 + console.error(`Error sending announcement ${announcement.name}:`, error); 336 + return false; 337 + } 338 + } 339 + 340 + /** 341 + * Shutdown the announcement manager 342 + */ 343 + shutdown() { 344 + // Stop all cron jobs 345 + Object.values(this.cronJobs).forEach(job => job.stop()); 346 + this.cronJobs = {}; 347 + console.log('Announcement manager shutdown complete'); 348 + } 349 + } 350 + 351 + module.exports = AnnouncementManager;