this repo has no description
0
fork

Configure Feed

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

feat: Implement Discord bot with slash commands Increased Perceptual Hashing Bits Implemented Queue File Path Rewriting for migration to new Phash pipeline.

+3551 -329
-22
.devcontainer/devcontainer.json
··· 1 - // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 - // README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node 3 - { 4 - "name": "Node.js", 5 - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 - "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm" 7 - 8 - // Features to add to the dev container. More info: https://containers.dev/features. 9 - // "features": {}, 10 - 11 - // Use 'forwardPorts' to make a list of ports inside the container available locally. 12 - // "forwardPorts": [], 13 - 14 - // Use 'postCreateCommand' to run commands after the container is created. 15 - // "postCreateCommand": "yarn install", 16 - 17 - // Configure tool-specific properties. 18 - // "customizations": {}, 19 - 20 - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 21 - // "remoteUser": "root" 22 - }
+24 -2
.env.example
··· 3 3 CHANNEL_ID=@your_channel_name_or_id 4 4 5 5 # Discord Configuration 6 + # ===================== 7 + 8 + # Discord Bot Mode (Recommended - Full bot with commands) 9 + # Get your bot token from: https://discord.com/developers/applications 10 + # Leave as placeholder to use webhook mode instead 11 + DISCORD_BOT_TOKEN=your_discord_bot_token_here 12 + 13 + # Discord channel ID where the bot should post (right-click channel > Copy Channel ID) 14 + # Requires Developer Mode enabled in Discord settings 15 + DISCORD_CHANNEL_ID=your_discord_channel_id_here 16 + 17 + # Discord Authorization (comma-separated Discord user IDs/snowflakes) 18 + # Only these users can use bot commands and add links to queue 19 + # To find your Discord ID: enable Developer Mode, right-click your name > Copy User ID 20 + # Example: DISCORD_AUTHORIZED_SNOWFLAKES=123456789012345678,234567890123456789 21 + # Leave empty to allow anyone (not recommended) 22 + DISCORD_AUTHORIZED_SNOWFLAKES= 23 + 24 + # Discord bot owner ID (for admin commands) 25 + DISCORD_OWNER_ID= 26 + 27 + # Discord Webhook Mode (Fallback - Simple posting only, no commands) 28 + # Used if DISCORD_BOT_TOKEN is not provided 6 29 # For multiple webhooks, use a comma-separated list: 7 30 # DISCORD_WEBHOOK_URLS=https://discord.com/api/webhooks/xxx/yyy,https://discord.com/api/webhooks/aaa/bbb 8 - # For a single webhook (legacy): 9 - DISCORD_WEBHOOK_URL=your_discord_webhook_url_here 31 + DISCORD_WEBHOOK_URLS= 10 32 DISCORD_ENABLED=true 11 33 12 34 # User Access Control (comma-separated list of Telegram user IDs)
+183
bot/discordBot.js
··· 1 + const { Client, GatewayIntentBits, Partials, REST, Routes } = require('discord.js'); 2 + const config = require('../config'); 3 + const queueManager = require('../queue/queueManager'); 4 + const AnnouncementManager = require('../utils/announcementManager'); 5 + const QueueMonitor = require('../utils/queueMonitor'); 6 + const CommandRegistry = require('./discordbot/commandRegistry'); 7 + 8 + class DiscordStagehandBot { 9 + constructor() { 10 + this.client = new Client({ 11 + intents: [ 12 + GatewayIntentBits.Guilds, 13 + GatewayIntentBits.GuildMessages, 14 + GatewayIntentBits.MessageContent, 15 + GatewayIntentBits.DirectMessages, 16 + ], 17 + partials: [Partials.Channel, Partials.Message], 18 + }); 19 + 20 + this.serviceName = 'discord'; 21 + this.channelId = config.discord.channelId; 22 + this.announcements = new AnnouncementManager(this); 23 + this.queueMonitor = new QueueMonitor(this); 24 + this.commandRegistry = new CommandRegistry(this.client, this.announcements, this.queueMonitor); 25 + this.isReady = false; 26 + 27 + this.init(); 28 + } 29 + 30 + async init() { 31 + // Set up ready event 32 + this.client.once('clientReady', async () => { 33 + console.log(`[DiscordBot] Discord bot logged in as ${this.client.user.tag}`); 34 + console.log(`[Discord Diagnostics] Intents bitfield: ${this.client.options.intents.bitfield}`); 35 + this.isReady = true; 36 + 37 + try { 38 + await this.client.application.fetch(); 39 + const appFlags = this.client.application.flags?.toArray?.() || []; 40 + console.log(`[Discord Diagnostics] Application flags: ${appFlags.join(', ') || 'none'}`); 41 + } catch (error) { 42 + console.warn('[Discord Diagnostics] Could not fetch application flags:', error.message); 43 + } 44 + 45 + await this.announcements.init(); 46 + await this.queueMonitor.init(queueManager); 47 + 48 + // Register slash commands with Discord 49 + await this.registerSlashCommands(); 50 + 51 + // Register command and message handlers 52 + this.registerCommands(); 53 + 54 + console.log('[DiscordBot] Discord bot started...'); 55 + }); 56 + 57 + // Set up error handlers 58 + this.client.on('error', (error) => { 59 + console.error('Discord client error:', error); 60 + }); 61 + 62 + // Login to Discord 63 + try { 64 + await this.client.login(config.discord.botToken); 65 + console.log(`[DiscordBot] Discord bot enabled - will post to channel: ${config.discord.channelId}`); 66 + } catch (error) { 67 + console.error('[DiscordBot] Failed to login to Discord:', error.message); 68 + throw error; 69 + } 70 + } 71 + 72 + /** 73 + * Register slash commands with Discord API 74 + */ 75 + async registerSlashCommands() { 76 + try { 77 + console.log('[DiscordBot] Registering Discord slash commands...'); 78 + 79 + const rest = new REST({ version: '10' }).setToken(config.discord.botToken); 80 + const commandData = this.commandRegistry.getCommandData(); 81 + 82 + // Register commands globally 83 + await rest.put( 84 + Routes.applicationCommands(this.client.user.id), 85 + { body: commandData } 86 + ); 87 + 88 + console.log(`[DiscordBot] Successfully registered ${commandData.length} slash commands`); 89 + } catch (error) { 90 + console.error('[DiscordBot] Error registering slash commands:', error); 91 + } 92 + } 93 + 94 + registerCommands() { 95 + // Use the command registry to register all commands and handlers 96 + this.commandRegistry.registerAll(); 97 + } 98 + 99 + /** 100 + * Post media (image or video) to the Discord channel 101 + * This method is used by the scheduler and external services 102 + * @param {Object} mediaData - The media data to post 103 + * @returns {Promise<boolean>} - Whether posting was successful 104 + */ 105 + async postMedia(mediaData) { 106 + if (!this.isReady) { 107 + console.log('Discord bot not ready yet, skipping post'); 108 + return false; 109 + } 110 + return await this.commandRegistry.getMediaHelper().postMedia(mediaData); 111 + } 112 + 113 + /** 114 + * Check if a user is authorized 115 + * @param {string} userId - The Discord user ID (snowflake) to check 116 + * @returns {boolean} - Whether the user is authorized 117 + */ 118 + isAuthorized(userId) { 119 + return this.commandRegistry.getAuthHelper().isAuthorized(userId); 120 + } 121 + 122 + /** 123 + * Check if the user is the owner of the bot 124 + * @param {string} userId - The Discord user ID (snowflake) to check 125 + * @returns {boolean} - Whether the user is the owner 126 + */ 127 + isOwner(userId) { 128 + return this.commandRegistry.getAuthHelper().isOwner(userId); 129 + } 130 + 131 + /** 132 + * Display a page of the queue with interactive buttons 133 + * @param {Object} message - Discord message object 134 + * @param {number} page - Page number to display (1-based) 135 + * @param {number} pageSize - Number of items per page 136 + */ 137 + async displayQueuePage(message, page, pageSize) { 138 + return await this.commandRegistry.getQueueHelper().displayQueuePage(message, page, pageSize); 139 + } 140 + 141 + /** 142 + * Send a message to a specific channel or user 143 + * @param {string} channelId - Discord channel ID 144 + * @param {string} message - Message to send 145 + * @param {Object} options - Additional message options 146 + */ 147 + async sendMessage(channelId, message, options = {}) { 148 + try { 149 + const channel = await this.client.channels.fetch(channelId); 150 + if (channel && channel.isTextBased()) { 151 + return await channel.send({ content: message, ...options }); 152 + } 153 + } catch (error) { 154 + console.error('Error sending Discord message:', error); 155 + return null; 156 + } 157 + } 158 + 159 + /** 160 + * Shutdown the bot gracefully 161 + * @returns {Promise<void>} 162 + */ 163 + async shutdown() { 164 + try { 165 + console.log('Stopping Discord bot...'); 166 + 167 + // Shutdown queue monitor 168 + if (this.queueMonitor) { 169 + await this.queueMonitor.shutdown(); 170 + } 171 + 172 + await this.client.destroy(); 173 + console.log('Discord bot stopped'); 174 + 175 + return true; 176 + } catch (error) { 177 + console.error('Error shutting down Discord bot:', error); 178 + return false; 179 + } 180 + } 181 + } 182 + 183 + module.exports = DiscordStagehandBot;
+3 -3
bot/discordWebhook.js
··· 18 18 this.enabled = config.discord?.enabled; 19 19 20 20 if (!this.webhookUrls.length) { 21 - console.warn('Discord webhook URL(s) not configured. Discord integration disabled.'); 21 + console.warn('[DiscordWebhook] Discord webhook URL(s) not configured. Discord integration disabled.'); 22 22 } else if (this.enabled === false) { 23 - console.log('Discord webhook URL(s) configured but integration is disabled in settings.'); 23 + console.log('[DiscordWebhook] Discord webhook URL(s) configured but integration is disabled in settings.'); 24 24 } else { 25 - console.log(`Discord webhook integration initialized for ${this.webhookUrls.length} webhook(s).`); 25 + console.log(`[DiscordWebhook] Discord webhook integration initialized for ${this.webhookUrls.length} webhook(s).`); 26 26 } 27 27 } 28 28
+81
bot/discordbot/commandRegistry.js
··· 1 + // Command imports 2 + const HelpCommand = require('./commands/help'); 3 + const AddCommand = require('./commands/add'); 4 + const QueueCommand = require('./commands/queue'); 5 + const StatusCommand = require('./commands/status'); 6 + 7 + // Helper imports 8 + const AuthHelper = require('./helpers/authHelper'); 9 + const QueueHelper = require('./helpers/queueHelper'); 10 + const MediaHelper = require('./helpers/mediaHelper'); 11 + const CallbackHandler = require('./helpers/callbackHandler'); 12 + 13 + /** 14 + * Command registry to manage all Discord bot commands and helpers 15 + */ 16 + class CommandRegistry { 17 + constructor(client, announcements, queueMonitor) { 18 + this.client = client; 19 + this.announcements = announcements; 20 + this.queueMonitor = queueMonitor; 21 + 22 + // Initialize helpers 23 + this.authHelper = new AuthHelper(); 24 + this.queueHelper = new QueueHelper(client); 25 + this.callbackHandler = new CallbackHandler(client, this.authHelper, this.queueHelper); 26 + this.mediaHelper = new MediaHelper(client); 27 + 28 + // Initialize commands 29 + this.commands = [ 30 + new HelpCommand(client), 31 + new AddCommand(client, this.authHelper), 32 + new QueueCommand(client, this.authHelper, this.queueHelper), 33 + new StatusCommand(client, this.authHelper, queueMonitor) 34 + ]; 35 + 36 + } 37 + 38 + /** 39 + * Register all commands and handlers 40 + */ 41 + registerAll() { 42 + // Register all commands 43 + this.commands.forEach(command => { 44 + command.register(); 45 + }); 46 + 47 + // Register callback handler for button interactions 48 + this.callbackHandler.register(); 49 + } 50 + 51 + /** 52 + * Get command data for Discord API registration 53 + * @returns {Array} - Array of slash command data 54 + */ 55 + getCommandData() { 56 + return this.commands.map(command => command.data.toJSON()); 57 + } 58 + 59 + /** 60 + * Get the media helper for external use (like in the main bot class) 61 + */ 62 + getMediaHelper() { 63 + return this.mediaHelper; 64 + } 65 + 66 + /** 67 + * Get the auth helper for external use 68 + */ 69 + getAuthHelper() { 70 + return this.authHelper; 71 + } 72 + 73 + /** 74 + * Get the queue helper for external use 75 + */ 76 + getQueueHelper() { 77 + return this.queueHelper; 78 + } 79 + } 80 + 81 + module.exports = CommandRegistry;
+127
bot/discordbot/commands/add.js
··· 1 + const { SlashCommandBuilder, MessageFlags } = require('discord.js'); 2 + const scraperManager = require('../../../utils/scraperManager'); 3 + const queueManager = require('../../../queue/queueManager'); 4 + const config = require('../../../config'); 5 + 6 + // Components V2 flag 7 + const IS_COMPONENTS_V2 = 1 << 15; // 32768 8 + 9 + /** 10 + * Add command - Add a supported link to the queue 11 + * Works in both DMs and guild channels for authorized users. 12 + */ 13 + class AddCommand { 14 + constructor(client, authHelper) { 15 + this.client = client; 16 + this.authHelper = authHelper; 17 + this.data = new SlashCommandBuilder() 18 + .setName('add') 19 + .setDescription('Add a supported media link to the queue') 20 + .addStringOption(option => 21 + option 22 + .setName('url') 23 + .setDescription('Supported media URL') 24 + .setRequired(true) 25 + ) 26 + .setDMPermission(true); 27 + } 28 + 29 + normalizeUrl(rawUrl) { 30 + if (!rawUrl || typeof rawUrl !== 'string') { 31 + return ''; 32 + } 33 + 34 + let url = rawUrl.trim(); 35 + 36 + if (url.startsWith('<') && url.endsWith('>')) { 37 + url = url.slice(1, -1); 38 + } 39 + 40 + url = url.replace(/[.,!?;:)\]\}]+$/g, ''); 41 + return url; 42 + } 43 + 44 + isSupportedUrl(url) { 45 + return config.supportedSites.some(site => site.pattern.test(url)); 46 + } 47 + 48 + createTextReply(content) { 49 + return { 50 + flags: IS_COMPONENTS_V2 | MessageFlags.Ephemeral, 51 + components: [{ type: 10, content }] 52 + }; 53 + } 54 + 55 + async execute(interaction) { 56 + if (!this.authHelper.isAuthorized(interaction.user.id)) { 57 + await interaction.reply( 58 + this.createTextReply(`❌ You are not authorized to add links. Your Discord ID: ${interaction.user.id}`) 59 + ); 60 + return; 61 + } 62 + 63 + const rawUrl = interaction.options.getString('url', true); 64 + const url = this.normalizeUrl(rawUrl); 65 + 66 + if (!/^https?:\/\//i.test(url)) { 67 + await interaction.reply(this.createTextReply('❌ Please provide a valid URL.')); 68 + return; 69 + } 70 + 71 + if (!this.isSupportedUrl(url)) { 72 + await interaction.reply(this.createTextReply('❌ Unsupported URL. Use /help for supported sites.')); 73 + return; 74 + } 75 + 76 + await interaction.deferReply({ flags: IS_COMPONENTS_V2 | MessageFlags.Ephemeral }); 77 + 78 + try { 79 + const mediaData = await scraperManager.extractFromUrl(url); 80 + 81 + if (mediaData.error) { 82 + await interaction.editReply(this.createTextReply(`❌ Error processing link: ${mediaData.error}`)); 83 + return; 84 + } 85 + 86 + const username = interaction.user.tag || interaction.user.username || interaction.user.id; 87 + const result = await queueManager.addToQueue(mediaData, username); 88 + 89 + if (result && result.duplicate) { 90 + const existingInfo = result.existingItem || {}; 91 + const addedByText = existingInfo.addedBy || 'unknown'; 92 + const timeText = existingInfo.timestamp 93 + ? new Date(existingInfo.timestamp).toLocaleString() 94 + : 'unknown time'; 95 + 96 + await interaction.editReply( 97 + this.createTextReply(`⚠️ Duplicate image detected. Already added by ${addedByText} at ${timeText}.`) 98 + ); 99 + return; 100 + } 101 + 102 + if (!result || !result.success) { 103 + await interaction.editReply(this.createTextReply('❌ Failed to add link to queue.')); 104 + return; 105 + } 106 + 107 + const queueLength = await queueManager.getQueueLength(); 108 + await interaction.editReply( 109 + this.createTextReply(`✅ Added to queue at position ${queueLength}: **${mediaData.title}** from ${mediaData.siteName}`) 110 + ); 111 + } catch (error) { 112 + console.error('Error in /add command:', error); 113 + await interaction.editReply(this.createTextReply(`❌ Error processing link: ${error.message}`)); 114 + } 115 + } 116 + 117 + register() { 118 + this.client.on('interactionCreate', async (interaction) => { 119 + if (!interaction.isChatInputCommand()) return; 120 + if (interaction.commandName !== 'add') return; 121 + 122 + await this.execute(interaction); 123 + }); 124 + } 125 + } 126 + 127 + module.exports = AddCommand;
+60
bot/discordbot/commands/help.js
··· 1 + const { SlashCommandBuilder, MessageFlags } = require('discord.js'); 2 + 3 + // Components V2 flag 4 + const IS_COMPONENTS_V2 = 1 << 15; // 32768 5 + 6 + /** 7 + * Help command - Show available commands 8 + */ 9 + class HelpCommand { 10 + constructor(client) { 11 + this.client = client; 12 + this.data = new SlashCommandBuilder() 13 + .setName('help') 14 + .setDescription('Show available bot commands and information'); 15 + } 16 + 17 + async execute(interaction) { 18 + const helpText = ` 19 + **📋 Stagehand Discord Bot Commands** 20 + 21 + **Queue Management:** 22 + \`/queue [page]\` - View the queue (default: page 1) 23 + \`/status\` - View bot status and queue info 24 + \`/add <url>\` - Add a supported link directly to queue 25 + 26 + **Media Processing:** 27 + Use \`/add <url>\` in DM to add supported links to the queue. 28 + 29 + **Supported sites:** Bluesky, e621, FurAffinity, SoFurry, Weasyl 30 + 31 + **Admin commands require authorization via Discord snowflake ID** 32 + Your Discord ID: \`${interaction.user.id}\` 33 + 34 + **Note:** Link intake uses the \`/add\` slash command in Direct Message. 35 + `; 36 + 37 + const components = [ 38 + { 39 + type: 10, // Text Display 40 + content: helpText 41 + } 42 + ]; 43 + 44 + await interaction.reply({ 45 + flags: IS_COMPONENTS_V2 | MessageFlags.Ephemeral, 46 + components: components 47 + }); 48 + } 49 + 50 + register() { 51 + this.client.on('interactionCreate', async (interaction) => { 52 + if (!interaction.isCommand()) return; 53 + if (interaction.commandName !== 'help') return; 54 + 55 + await this.execute(interaction); 56 + }); 57 + } 58 + } 59 + 60 + module.exports = HelpCommand;
+6
bot/discordbot/commands/index.js
··· 1 + module.exports = { 2 + HelpCommand: require('./help'), 3 + QueueCommand: require('./queue'), 4 + StatusCommand: require('./status'), 5 + LinkHandler: require('./linkHandler') 6 + };
+245
bot/discordbot/commands/linkHandler.js
··· 1 + const scraperManager = require('../../../utils/scraperManager'); 2 + const queueManager = require('../../../queue/queueManager'); 3 + 4 + /** 5 + * URL link handler for Discord 6 + * Processes messages containing URLs from authorized users in DMs only 7 + */ 8 + class LinkHandler { 9 + constructor(client, authHelper) { 10 + this.client = client; 11 + this.authHelper = authHelper; 12 + } 13 + 14 + register() { 15 + console.log('[Discord LinkHandler] Registering DM message listener'); 16 + 17 + this.client.on('messageCreate', async (message) => { 18 + try { 19 + // Ensure partial messages have full content before reading author/content 20 + if (message.partial) { 21 + try { 22 + await message.fetch(); 23 + } catch (error) { 24 + console.error('[Discord LinkHandler] Failed to fetch partial message:', error); 25 + return; 26 + } 27 + } 28 + 29 + if (!message.author) { 30 + console.warn('[Discord LinkHandler] Message has no author, skipping'); 31 + return; 32 + } 33 + 34 + // Ignore bot messages 35 + if (message.author.bot) return; 36 + 37 + console.log( 38 + `[Discord LinkHandler] messageCreate observed: channelType=${message.channel?.type} user=${message.author.tag} (${message.author.id})` 39 + ); 40 + 41 + // Only process links in DM-based channels 42 + if (!message.channel?.isDMBased?.()) { 43 + return; 44 + } 45 + 46 + console.log(`[Discord LinkHandler] DM received from ${message.author.tag} (${message.author.id})`); 47 + 48 + // Check authorization first for all URL processing 49 + if (!this.authHelper.isAuthorized(message.author.id)) { 50 + // Only show unauthorized message if the message contains URLs 51 + const urls = this.extractUrlsFromMessage(message); 52 + if (urls.length > 0) { 53 + await message.reply('❌ You are not authorized to add links to the queue. Your Discord ID: ' + message.author.id); 54 + } 55 + return; 56 + } 57 + 58 + // Check for messages with URLs 59 + const urls = this.extractUrlsFromMessage(message); 60 + if (urls.length > 0) { 61 + console.log(`[Discord LinkHandler] Processing ${urls.length} URL(s) from DM`); 62 + await this.processUrls(message, urls); 63 + } else { 64 + console.log('[Discord LinkHandler] DM contains no URLs, ignoring'); 65 + } 66 + } catch (error) { 67 + console.error('[Discord LinkHandler] Unexpected error in messageCreate handler:', error); 68 + } 69 + }); 70 + } 71 + 72 + /** 73 + * Extract URLs from a Discord message 74 + * @param {Object} message - Discord message object 75 + * @returns {Array<string>} - Array of URLs found in the message 76 + */ 77 + extractUrlsFromMessage(message) { 78 + const urls = []; 79 + const urlRegex = /https?:\/\/[^\s]+/g; 80 + 81 + // Extract from message content 82 + if (message.content) { 83 + const matches = message.content.match(urlRegex); 84 + if (matches) { 85 + urls.push(...matches); 86 + } 87 + } 88 + 89 + // Extract from embeds 90 + if (message.embeds && message.embeds.length > 0) { 91 + for (const embed of message.embeds) { 92 + if (embed.url) urls.push(embed.url); 93 + if (embed.description) { 94 + const matches = embed.description.match(urlRegex); 95 + if (matches) urls.push(...matches); 96 + } 97 + } 98 + } 99 + 100 + return [...new Set(urls)]; // Remove duplicates 101 + } 102 + 103 + /** 104 + * Normalize URL text copied from Discord messages 105 + * Handles links wrapped in <> and trailing punctuation. 106 + * @param {string} rawUrl - Raw URL text from message 107 + * @returns {string} 108 + */ 109 + normalizeUrl(rawUrl) { 110 + if (!rawUrl || typeof rawUrl !== 'string') { 111 + return ''; 112 + } 113 + 114 + let url = rawUrl.trim(); 115 + 116 + // Remove Discord-style markdown wrapper: <https://example.com> 117 + if (url.startsWith('<') && url.endsWith('>')) { 118 + url = url.slice(1, -1); 119 + } 120 + 121 + // Remove common trailing punctuation from conversational text 122 + url = url.replace(/[.,!?;:)\]\}]+$/g, ''); 123 + 124 + return url; 125 + } 126 + 127 + /** 128 + * Check if a URL is from a supported site 129 + * @param {string} url - URL to check 130 + * @returns {boolean} 131 + */ 132 + isSupportedUrl(url) { 133 + const config = require('../../../config'); 134 + return config.supportedSites.some(site => site.pattern.test(url)); 135 + } 136 + 137 + /** 138 + * Process URLs from a message 139 + * @param {Object} message - Discord message object 140 + * @param {Array<string>} urls - Array of URLs to process 141 + */ 142 + async processUrls(message, urls) { 143 + const normalizedUrls = urls 144 + .map(url => this.normalizeUrl(url)) 145 + .filter(Boolean); 146 + 147 + // Filter to only supported URLs 148 + const supportedUrls = normalizedUrls.filter(url => this.isSupportedUrl(url)); 149 + 150 + if (supportedUrls.length === 0) { 151 + return; // Silently ignore unsupported URLs 152 + } 153 + 154 + // Add reaction to show we're processing 155 + await message.react('⏳').catch(() => {}); 156 + 157 + let successCount = 0; 158 + let duplicateCount = 0; 159 + let errorCount = 0; 160 + 161 + for (const url of supportedUrls) { 162 + try { 163 + // Add delay for FurAffinity to avoid rate limits 164 + if (/^https?:\/\/(www\.)?furaffinity\.net\//i.test(url)) { 165 + await new Promise(res => setTimeout(res, 3000)); 166 + } 167 + 168 + const mediaData = await scraperManager.extractFromUrl(url); 169 + 170 + if (mediaData.error) { 171 + if (supportedUrls.length === 1) { 172 + await message.reply(`❌ Error processing link: ${mediaData.error}`); 173 + } 174 + errorCount++; 175 + continue; 176 + } 177 + 178 + // Get username for tracking 179 + const username = message.author.tag || `${message.author.username}#${message.author.discriminator}`; 180 + 181 + const result = await queueManager.addToQueue(mediaData, username); 182 + 183 + // Handle duplicate detection 184 + if (result && result.duplicate) { 185 + const existingInfo = result.existingItem; 186 + const addedByText = existingInfo.addedBy || 'unknown'; 187 + const timeAgo = existingInfo.timestamp ? 188 + new Date(existingInfo.timestamp).toLocaleString() : 'unknown time'; 189 + 190 + if (supportedUrls.length === 1) { 191 + await message.reply(`⚠️ Duplicate image detected!\nThis was already added by ${addedByText} at ${timeAgo}`); 192 + } 193 + duplicateCount++; 194 + continue; 195 + } 196 + 197 + // Check if it was successful 198 + if (!result || !result.success) { 199 + if (supportedUrls.length === 1) { 200 + await message.reply('❌ Failed to add to queue'); 201 + } 202 + errorCount++; 203 + continue; 204 + } 205 + 206 + successCount++; 207 + 208 + // If single URL, provide detailed feedback 209 + if (supportedUrls.length === 1) { 210 + const queuePosition = await queueManager.getQueueLength(); 211 + await message.reply(`✅ Added to queue at position ${queuePosition}: **${mediaData.title}** from ${mediaData.siteName}`); 212 + } 213 + } catch (error) { 214 + console.error('Error processing URL:', url, error); 215 + errorCount++; 216 + 217 + if (supportedUrls.length === 1) { 218 + await message.reply(`❌ Error processing link: ${error.message}`); 219 + } 220 + } 221 + } 222 + 223 + // Remove processing reaction 224 + await message.reactions.cache.get('⏳')?.users.remove(this.client.user).catch(() => {}); 225 + 226 + // For multiple URLs, provide summary 227 + if (supportedUrls.length > 1) { 228 + let summary = `Processed ${supportedUrls.length} links:\n`; 229 + if (successCount > 0) summary += `✅ Added: ${successCount}\n`; 230 + if (duplicateCount > 0) summary += `⚠️ Duplicates: ${duplicateCount}\n`; 231 + if (errorCount > 0) summary += `❌ Errors: ${errorCount}`; 232 + 233 + await message.reply(summary); 234 + } 235 + 236 + // Add success or failure reaction 237 + if (successCount > 0) { 238 + await message.react('✅').catch(() => {}); 239 + } else if (errorCount > 0 || duplicateCount > 0) { 240 + await message.react('❌').catch(() => {}); 241 + } 242 + } 243 + } 244 + 245 + module.exports = LinkHandler;
+49
bot/discordbot/commands/queue.js
··· 1 + const { SlashCommandBuilder, MessageFlags } = require('discord.js'); 2 + const queueManager = require('../../../queue/queueManager'); 3 + 4 + /** 5 + * Queue command - Show current queue 6 + */ 7 + class QueueCommand { 8 + constructor(client, authHelper, queueHelper) { 9 + this.client = client; 10 + this.authHelper = authHelper; 11 + this.queueHelper = queueHelper; 12 + this.data = new SlashCommandBuilder() 13 + .setName('queue') 14 + .setDescription('View the current queue') 15 + .addIntegerOption(option => 16 + option.setName('page') 17 + .setDescription('Page number to view') 18 + .setMinValue(1) 19 + .setRequired(false) 20 + ); 21 + } 22 + 23 + async execute(interaction) { 24 + if (!this.authHelper.isAuthorized(interaction.user.id)) { 25 + await interaction.reply({ 26 + content: '❌ You are not authorized to use this command.', 27 + flags: MessageFlags.Ephemeral 28 + }); 29 + return; 30 + } 31 + 32 + const page = interaction.options.getInteger('page') || 1; 33 + const pageSize = 5; 34 + 35 + await interaction.deferReply(); 36 + await this.queueHelper.displayQueuePage(interaction, page, pageSize); 37 + } 38 + 39 + register() { 40 + this.client.on('interactionCreate', async (interaction) => { 41 + if (!interaction.isCommand()) return; 42 + if (interaction.commandName !== 'queue') return; 43 + 44 + await this.execute(interaction); 45 + }); 46 + } 47 + } 48 + 49 + module.exports = QueueCommand;
+103
bot/discordbot/commands/status.js
··· 1 + const { SlashCommandBuilder, MessageFlags } = require('discord.js'); 2 + const queueManager = require('../../../queue/queueManager'); 3 + 4 + // Components V2 flag 5 + const IS_COMPONENTS_V2 = 1 << 15; // 32768 6 + 7 + /** 8 + * Status command - Show bot and queue status 9 + */ 10 + class StatusCommand { 11 + constructor(client, authHelper, queueMonitor) { 12 + this.client = client; 13 + this.authHelper = authHelper; 14 + this.queueMonitor = queueMonitor; 15 + this.data = new SlashCommandBuilder() 16 + .setName('status') 17 + .setDescription('View bot status and queue information'); 18 + } 19 + 20 + async execute(interaction) { 21 + if (!this.authHelper.isAuthorized(interaction.user.id)) { 22 + const components = [ 23 + { 24 + type: 10, // Text Display 25 + content: '❌ You are not authorized to use this command.' 26 + } 27 + ]; 28 + 29 + await interaction.reply({ 30 + flags: IS_COMPONENTS_V2 | MessageFlags.Ephemeral, 31 + components: components 32 + }); 33 + return; 34 + } 35 + 36 + try { 37 + const queue = await queueManager.getQueue(); 38 + const queueLength = queue.length; 39 + const schedule = queueManager.cronSchedule; 40 + const imagesPerInterval = queueManager.imagesPerInterval; 41 + const shuffleMode = queueManager.isShuffleModeEnabled() ? '🔀 ON' : '📋 OFF'; 42 + 43 + const uptime = process.uptime(); 44 + const hours = Math.floor(uptime / 3600); 45 + const minutes = Math.floor((uptime % 3600) / 60); 46 + 47 + let statusMsg = `**🤖 Bot Status**\n\n`; 48 + statusMsg += `**Queue:** ${queueLength} items\n`; 49 + statusMsg += `**Schedule:** ${schedule}\n`; 50 + statusMsg += `**Items per interval:** ${imagesPerInterval}\n`; 51 + statusMsg += `**Shuffle mode:** ${shuffleMode}\n`; 52 + statusMsg += `**Uptime:** ${hours}h ${minutes}m\n`; 53 + 54 + // Add queue monitor status if available 55 + if (this.queueMonitor) { 56 + const monitorStatus = await this.queueMonitor.getQueueStatus(); 57 + if (monitorStatus) { 58 + statusMsg += `\n**Queue Monitoring:**\n`; 59 + statusMsg += `Alerts: ${monitorStatus.alertsEnabled ? '✅ Enabled' : '❌ Disabled'}\n`; 60 + statusMsg += `Low threshold: ${monitorStatus.lowThreshold}\n`; 61 + statusMsg += `Empty threshold: ${monitorStatus.emptyThreshold}\n`; 62 + } 63 + } 64 + 65 + const components = [ 66 + { 67 + type: 10, // Text Display 68 + content: statusMsg 69 + } 70 + ]; 71 + 72 + await interaction.reply({ 73 + flags: IS_COMPONENTS_V2 | MessageFlags.Ephemeral, 74 + components: components 75 + }); 76 + } catch (error) { 77 + console.error('Error getting status:', error); 78 + 79 + const components = [ 80 + { 81 + type: 10, // Text Display 82 + content: '❌ Error getting status information' 83 + } 84 + ]; 85 + 86 + await interaction.reply({ 87 + flags: IS_COMPONENTS_V2 | MessageFlags.Ephemeral, 88 + components: components 89 + }); 90 + } 91 + } 92 + 93 + register() { 94 + this.client.on('interactionCreate', async (interaction) => { 95 + if (!interaction.isCommand()) return; 96 + if (interaction.commandName !== 'status') return; 97 + 98 + await this.execute(interaction); 99 + }); 100 + } 101 + } 102 + 103 + module.exports = StatusCommand;
+48
bot/discordbot/helpers/authHelper.js
··· 1 + const config = require('../../../config'); 2 + 3 + /** 4 + * Authorization helper for checking Discord user permissions 5 + * Discord user IDs are called "snowflakes" - unique 64-bit identifiers 6 + */ 7 + class AuthHelper { 8 + /** 9 + * Check if a Discord user is authorized to use bot commands 10 + * @param {string} userId - The Discord user ID (snowflake) to check 11 + * @returns {boolean} - Whether the user is authorized 12 + */ 13 + isAuthorized(userId) { 14 + // If no authorized Discord users are specified, anyone can use the bot 15 + if (!config.discord.authorizedSnowflakes || config.discord.authorizedSnowflakes.length === 0) { 16 + return true; 17 + } 18 + 19 + return config.discord.authorizedSnowflakes.includes(userId.toString()); 20 + } 21 + 22 + /** 23 + * Check if the user is the owner of the Discord bot 24 + * @param {string} userId - The Discord user ID (snowflake) to check 25 + * @returns {boolean} - Whether the user is the owner 26 + */ 27 + isOwner(userId) { 28 + return config.discord.ownerId && userId.toString() === config.discord.ownerId.toString(); 29 + } 30 + 31 + /** 32 + * Validate that a string is a valid Discord snowflake 33 + * Discord snowflakes are 64-bit integers represented as strings 34 + * @param {string} snowflake - The snowflake to validate 35 + * @returns {boolean} - Whether the snowflake is valid 36 + */ 37 + isValidSnowflake(snowflake) { 38 + if (typeof snowflake !== 'string') { 39 + return false; 40 + } 41 + 42 + // Snowflakes are numeric strings between 17-20 characters 43 + // (Discord epoch started in 2015, so minimum length is ~17) 44 + return /^\d{17,20}$/.test(snowflake); 45 + } 46 + } 47 + 48 + module.exports = AuthHelper;
+94
bot/discordbot/helpers/callbackHandler.js
··· 1 + const { MessageFlags } = require('discord.js'); 2 + 3 + /** 4 + * Callback handler for Discord button interactions 5 + */ 6 + class CallbackHandler { 7 + constructor(client, authHelper, queueHelper) { 8 + this.client = client; 9 + this.authHelper = authHelper; 10 + this.queueHelper = queueHelper; 11 + } 12 + 13 + /** 14 + * Register interaction handlers 15 + */ 16 + register() { 17 + this.client.on('interactionCreate', async (interaction) => { 18 + // Only handle button interactions 19 + if (!interaction.isButton()) return; 20 + 21 + try { 22 + // Check authorization 23 + if (!this.authHelper.isAuthorized(interaction.user.id)) { 24 + await interaction.reply({ 25 + content: '❌ You are not authorized to use this button.', 26 + flags: MessageFlags.Ephemeral 27 + }); 28 + return; 29 + } 30 + 31 + // Parse custom ID 32 + const customId = interaction.customId; 33 + 34 + // Handle queue pagination buttons 35 + if (customId.startsWith('queue_page_')) { 36 + await this.handleQueuePagination(interaction); 37 + return; 38 + } 39 + 40 + // Unknown button 41 + await interaction.reply({ 42 + content: '❌ Unknown button action.', 43 + flags: MessageFlags.Ephemeral 44 + }); 45 + } catch (error) { 46 + console.error('Error handling button interaction:', error); 47 + 48 + // Try to respond if we haven't already 49 + if (!interaction.replied && !interaction.deferred) { 50 + await interaction.reply({ 51 + content: '❌ An error occurred processing your request.', 52 + flags: MessageFlags.Ephemeral 53 + }).catch(() => {}); 54 + } 55 + } 56 + }); 57 + } 58 + 59 + /** 60 + * Handle queue pagination button clicks 61 + * @param {Interaction} interaction - Discord button interaction 62 + */ 63 + async handleQueuePagination(interaction) { 64 + // Parse the custom ID: queue_page_2_5 -> page=2, pageSize=5 65 + const parts = interaction.customId.split('_'); 66 + 67 + if (parts[0] === 'queue' && parts[1] === 'page') { 68 + // Handle "current page" button (disabled, just acknowledge) 69 + if (parts[2] === 'current') { 70 + await interaction.deferUpdate(); 71 + return; 72 + } 73 + 74 + const page = parseInt(parts[2], 10); 75 + const pageSize = parseInt(parts[3], 10); 76 + 77 + if (isNaN(page) || isNaN(pageSize)) { 78 + await interaction.reply({ 79 + content: '❌ Invalid page navigation data.', 80 + flags: MessageFlags.Ephemeral 81 + }); 82 + return; 83 + } 84 + 85 + // For button interactions, we need to defer so we can update 86 + await interaction.deferUpdate(); 87 + 88 + // Use the queue helper to display the requested page 89 + await this.queueHelper.displayQueuePage(interaction, page, pageSize); 90 + } 91 + } 92 + } 93 + 94 + module.exports = CallbackHandler;
+5
bot/discordbot/helpers/index.js
··· 1 + module.exports = { 2 + AuthHelper: require('./authHelper'), 3 + MediaHelper: require('./mediaHelper'), 4 + QueueHelper: require('./queueHelper') 5 + };
+214
bot/discordbot/helpers/mediaHelper.js
··· 1 + const { AttachmentBuilder } = require('discord.js'); 2 + const axios = require('axios'); 3 + const fs = require('fs'); 4 + const path = require('path'); 5 + const config = require('../../../config'); 6 + 7 + // Components V2 flag 8 + const IS_COMPONENTS_V2 = 1 << 15; // 32768 9 + 10 + /** 11 + * Media helper for posting images and videos to Discord using Components V2 12 + */ 13 + class MediaHelper { 14 + constructor(client) { 15 + this.client = client; 16 + } 17 + 18 + /** 19 + * Create a Components V2 text display component 20 + * @param {string} content - Markdown formatted text 21 + * @returns {Object} - Text Display component 22 + */ 23 + createTextDisplay(content) { 24 + return { 25 + type: 10, // Text Display 26 + content: content 27 + }; 28 + } 29 + 30 + /** 31 + * Create a Components V2 button 32 + * @param {string} label - Button text 33 + * @param {string} url - Link URL 34 + * @param {number} style - Button style (5 for Link) 35 + * @returns {Object} - Button component 36 + */ 37 + createButton(label, url, style = 5) { 38 + return { 39 + type: 2, // Button 40 + style: style, 41 + label: label, 42 + url: url 43 + }; 44 + } 45 + 46 + /** 47 + * Create a Components V2 action row 48 + * @param {Array} components - Array of button components 49 + * @returns {Object} - Action Row component 50 + */ 51 + createActionRow(components) { 52 + return { 53 + type: 1, // Action Row 54 + components: components 55 + }; 56 + } 57 + 58 + /** 59 + * Create a Components V2 media gallery 60 + * @param {Array} items - Array of media gallery items 61 + * @returns {Object} - Media Gallery component 62 + */ 63 + createMediaGallery(items) { 64 + return { 65 + type: 12, // Media Gallery 66 + items: items 67 + }; 68 + } 69 + 70 + /** 71 + * Create a media gallery item 72 + * @param {string} url - Media URL or attachment reference 73 + * @param {string} description - Alt text 74 + * @returns {Object} - Media Gallery item 75 + */ 76 + createMediaItem(url, description = null) { 77 + const item = { 78 + media: { url: url } 79 + }; 80 + if (description) { 81 + item.description = description; 82 + } 83 + return item; 84 + } 85 + 86 + /** 87 + * Post media (image or video) to the Discord channel using Components V2 88 + * @param {Object} mediaData - The media data to post 89 + * @returns {Promise<boolean>} - Whether posting was successful 90 + */ 91 + async postMedia(mediaData) { 92 + try { 93 + if (!config.discord.channelId) { 94 + console.error('Discord channel ID not configured'); 95 + return false; 96 + } 97 + 98 + const channel = await this.client.channels.fetch(config.discord.channelId); 99 + if (!channel || !channel.isTextBased()) { 100 + console.error('Discord channel not found or not text-based'); 101 + return false; 102 + } 103 + 104 + const components = []; 105 + const files = []; 106 + 107 + // Build title/caption text 108 + let titleText = ''; 109 + if (mediaData.siteName === 'FurAffinity' && mediaData.title && mediaData.name) { 110 + titleText = `🖼️ **${mediaData.title}**\n🎨 ${mediaData.name}`; 111 + } else if (mediaData.title) { 112 + titleText = `**${mediaData.title}**`; 113 + } 114 + 115 + // Add title as Text Display component if present 116 + if (titleText) { 117 + components.push(this.createTextDisplay(titleText)); 118 + } 119 + 120 + // Handle multiple images 121 + if (mediaData.imageUrls && Array.isArray(mediaData.imageUrls) && mediaData.imageUrls.length > 1) { 122 + console.log(`Posting multiple images: ${mediaData.imageUrls.length} images`); 123 + 124 + const mediaItems = []; 125 + for (const imagePath of mediaData.imageUrls) { 126 + if (fs.existsSync(imagePath)) { 127 + const filename = path.basename(imagePath); 128 + files.push(new AttachmentBuilder(imagePath)); 129 + mediaItems.push(this.createMediaItem(`attachment://${filename}`, mediaData.title)); 130 + } else { 131 + console.warn(`Image file not found: ${imagePath}`); 132 + } 133 + } 134 + 135 + if (mediaItems.length > 0) { 136 + components.push(this.createMediaGallery(mediaItems)); 137 + } 138 + } 139 + // Handle videos 140 + else if (mediaData.isVideo && mediaData.videoUrl) { 141 + console.log(`Posting video: ${mediaData.videoUrl}`); 142 + 143 + if (fs.existsSync(mediaData.videoUrl)) { 144 + const filename = path.basename(mediaData.videoUrl); 145 + files.push(new AttachmentBuilder(mediaData.videoUrl)); 146 + components.push(this.createMediaGallery([ 147 + this.createMediaItem(`attachment://${filename}`, mediaData.title) 148 + ])); 149 + } else if (/^https?:\/\//i.test(mediaData.videoUrl)) { 150 + // For external video URLs, show thumbnail if available 151 + if (mediaData.imageUrl && fs.existsSync(mediaData.imageUrl)) { 152 + const filename = path.basename(mediaData.imageUrl); 153 + files.push(new AttachmentBuilder(mediaData.imageUrl)); 154 + components.push(this.createMediaGallery([ 155 + this.createMediaItem(`attachment://${filename}`, 'Video thumbnail') 156 + ])); 157 + // Add video link in text 158 + components.push(this.createTextDisplay(`[🎬 View Video](${mediaData.videoUrl})`)); 159 + } else { 160 + components.push(this.createTextDisplay(`[🎬 View Video](${mediaData.videoUrl})`)); 161 + } 162 + } 163 + } 164 + // Handle single image 165 + else if (mediaData.imageUrl) { 166 + console.log(`Posting image: ${mediaData.imageUrl}`); 167 + 168 + if (fs.existsSync(mediaData.imageUrl)) { 169 + const filename = path.basename(mediaData.imageUrl); 170 + files.push(new AttachmentBuilder(mediaData.imageUrl)); 171 + components.push(this.createMediaGallery([ 172 + this.createMediaItem(`attachment://${filename}`, mediaData.title) 173 + ])); 174 + } else if (/^https?:\/\//i.test(mediaData.imageUrl)) { 175 + components.push(this.createMediaGallery([ 176 + this.createMediaItem(mediaData.imageUrl, mediaData.title) 177 + ])); 178 + } else if (mediaData.originalImageUrl && /^https?:\/\//i.test(mediaData.originalImageUrl)) { 179 + components.push(this.createMediaGallery([ 180 + this.createMediaItem(mediaData.originalImageUrl, mediaData.title) 181 + ])); 182 + } 183 + } 184 + 185 + // Add source button 186 + let buttonText = `View on ${mediaData.siteName}`; 187 + if (mediaData.siteName === 'Bluesky') { 188 + buttonText = `🦋 ${buttonText} 🦋`; 189 + } 190 + 191 + components.push(this.createActionRow([ 192 + this.createButton(buttonText, mediaData.sourceUrl) 193 + ])); 194 + 195 + // Send the message with Components V2 196 + try { 197 + await channel.send({ 198 + flags: IS_COMPONENTS_V2, 199 + components: components, 200 + files: files.length > 0 ? files : undefined 201 + }); 202 + return true; 203 + } catch (error) { 204 + console.error('Error sending Components V2 message:', error); 205 + return false; 206 + } 207 + } catch (error) { 208 + console.error('Error posting media to Discord:', error); 209 + return false; 210 + } 211 + } 212 + } 213 + 214 + module.exports = MediaHelper;
+171
bot/discordbot/helpers/queueHelper.js
··· 1 + const { MessageFlags } = require('discord.js'); 2 + const queueManager = require('../../../queue/queueManager'); 3 + 4 + // Components V2 flag 5 + const IS_COMPONENTS_V2 = 1 << 15; // 32768 6 + 7 + /** 8 + * Queue helper for displaying and managing queue pages in Discord using Components V2 9 + */ 10 + class QueueHelper { 11 + constructor(client) { 12 + this.client = client; 13 + } 14 + 15 + /** 16 + * Display a page of the queue with interactive buttons using Components V2 17 + * @param {Object} interaction - Discord interaction object 18 + * @param {number} page - Page number to display (1-based) 19 + * @param {number} pageSize - Number of items per page 20 + */ 21 + async displayQueuePage(interaction, page, pageSize) { 22 + const queue = await queueManager.getQueue(); 23 + const queueLength = queue.length; 24 + 25 + if (queueLength === 0) { 26 + // Check if this is a deferred reply or regular reply 27 + if (interaction.deferred) { 28 + await interaction.editReply({ content: 'Queue is empty.' }); 29 + } else { 30 + await interaction.reply({ content: 'Queue is empty.', flags: MessageFlags.Ephemeral }); 31 + } 32 + return; 33 + } 34 + 35 + // Calculate total pages 36 + const totalPages = Math.ceil(queueLength / pageSize); 37 + 38 + // Ensure page is within bounds 39 + const currentPage = Math.max(1, Math.min(page, totalPages)); 40 + 41 + // Calculate start and end indices for this page 42 + const startIdx = (currentPage - 1) * pageSize; 43 + const endIdx = Math.min(startIdx + pageSize, queueLength); 44 + 45 + // Build Components V2 structure 46 + const components = []; 47 + 48 + // Title with queue info 49 + const shuffleStatus = queueManager.isShuffleModeEnabled() ? '🔀 Shuffle: ON' : '📋 Shuffle: OFF'; 50 + const headerText = `📋 **Queue Management**\n\n**Total items:** ${queueLength}\n**${shuffleStatus}**\nShowing items ${startIdx + 1}-${endIdx} of ${queueLength}\n`; 51 + components.push({ 52 + type: 10, // Text Display 53 + content: headerText 54 + }); 55 + 56 + // Build queue items text 57 + let queueItemsText = ''; 58 + for (let i = startIdx; i < endIdx; i++) { 59 + const item = queue[i]; 60 + const itemType = item.isVideo ? '🎬' : '🖼️'; 61 + const itemIndex = i + 1; 62 + 63 + // Show posting status for each service 64 + let statusIcons = ''; 65 + if (item.postedTo) { 66 + if (item.postedTo.telegram) statusIcons += '✅TG '; 67 + else statusIcons += '❌TG '; 68 + 69 + if (queueManager.postServices.includes('discord')) { 70 + if (item.postedTo.discord) statusIcons += '✅DS'; 71 + else statusIcons += '❌DS'; 72 + } 73 + } 74 + 75 + const addedByText = item.addedBy ? ` (Added by: ${item.addedBy})` : ''; 76 + 77 + queueItemsText += `**${itemIndex}.** ${itemType} ${item.title}\n`; 78 + queueItemsText += `From: ${item.siteName} ${statusIcons}${addedByText}\n\n`; 79 + } 80 + 81 + if (queueItemsText) { 82 + components.push({ 83 + type: 10, // Text Display 84 + content: queueItemsText.trim() 85 + }); 86 + } 87 + 88 + // Create navigation buttons 89 + if (totalPages > 1) { 90 + const buttons = []; 91 + 92 + if (currentPage > 1) { 93 + buttons.push({ 94 + type: 2, // Button 95 + style: 1, // Primary 96 + label: '◀️ Previous', 97 + custom_id: `queue_page_${currentPage - 1}_${pageSize}` 98 + }); 99 + } 100 + 101 + buttons.push({ 102 + type: 2, // Button 103 + style: 2, // Secondary 104 + label: `Page ${currentPage}/${totalPages}`, 105 + custom_id: 'queue_page_current', 106 + disabled: true 107 + }); 108 + 109 + if (currentPage < totalPages) { 110 + buttons.push({ 111 + type: 2, // Button 112 + style: 1, // Primary 113 + label: 'Next ▶️', 114 + custom_id: `queue_page_${currentPage + 1}_${pageSize}` 115 + }); 116 + } 117 + 118 + components.push({ 119 + type: 1, // Action Row 120 + components: buttons 121 + }); 122 + } 123 + 124 + // Send or edit message based on interaction type 125 + const isButtonUpdate = interaction.deferred && interaction.message; 126 + 127 + const payload = { 128 + flags: IS_COMPONENTS_V2, 129 + components: components 130 + }; 131 + 132 + if (isButtonUpdate) { 133 + await interaction.editReply(payload); 134 + } else if (interaction.deferred) { 135 + await interaction.editReply(payload); 136 + } else { 137 + await interaction.reply({ ...payload, flags: IS_COMPONENTS_V2 | MessageFlags.Ephemeral }); 138 + } 139 + } 140 + 141 + /** 142 + * Format queue information as a simple string 143 + * @param {number} maxItems - Maximum number of items to show 144 + * @returns {Promise<string>} 145 + */ 146 + async formatQueueInfo(maxItems = 10) { 147 + const queue = await queueManager.getQueue(); 148 + 149 + if (queue.length === 0) { 150 + return 'Queue is empty.'; 151 + } 152 + 153 + const shuffleStatus = queueManager.isShuffleModeEnabled() ? '🔀 Shuffle: ON' : '📋 Shuffle: OFF'; 154 + let info = `📋 **Queue** (${queue.length} items total) - ${shuffleStatus}\n\n`; 155 + 156 + const itemsToShow = Math.min(maxItems, queue.length); 157 + for (let i = 0; i < itemsToShow; i++) { 158 + const item = queue[i]; 159 + const itemType = item.isVideo ? '🎬' : '🖼️'; 160 + info += `${i + 1}. ${itemType} **${item.title}** (${item.siteName})\n`; 161 + } 162 + 163 + if (queue.length > maxItems) { 164 + info += `\n...and ${queue.length - maxItems} more items`; 165 + } 166 + 167 + return info; 168 + } 169 + } 170 + 171 + module.exports = QueueHelper;
+11 -1
bot/telegramBot.js
··· 20 20 await this.announcements.init(); 21 21 await this.queueMonitor.init(queueManager); 22 22 this.registerCommands(); 23 - console.log('Telegram bot started...'); 23 + console.log('[TelegramBot] Telegram bot started...'); 24 24 } 25 25 26 26 registerCommands() { ··· 36 36 */ 37 37 async postMedia(mediaData) { 38 38 return await this.commandRegistry.getMediaHelper().postMedia(mediaData); 39 + } 40 + 41 + /** 42 + * Provide Discord post function to Telegram /send command 43 + * @param {Function|null} discordPostFunction - Function that posts media to Discord 44 + */ 45 + setDiscordPostFunction(discordPostFunction) { 46 + if (this.commandRegistry && typeof this.commandRegistry.setDiscordPostFunction === 'function') { 47 + this.commandRegistry.setDiscordPostFunction(discordPostFunction); 48 + } 39 49 } 40 50 41 51 /**
+12 -1
bot/telegrambot/commandRegistry.js
··· 35 35 this.queueHelper = new QueueHelper(bot); 36 36 this.mediaHelper = new MediaHelper(bot); 37 37 this.callbackHandler = new CallbackHandler(bot, this.authHelper, this.queueHelper, announcements, queueMonitor); 38 + this.sendCommand = new SendCommand(bot, this.authHelper, this.mediaHelper); 38 39 39 40 // Initialize commands 40 41 this.commands = [ 41 42 new StartCommand(bot), 42 43 new HelpCommand(bot), 43 44 new QueueCommand(bot, this.authHelper, this.queueHelper), 44 - new SendCommand(bot, this.authHelper, this.mediaHelper), 45 + this.sendCommand, 45 46 new ScheduleCommand(bot, this.authHelper), 46 47 new SetCountCommand(bot, this.authHelper), 47 48 new ClearCommand(bot, this.authHelper), ··· 101 102 */ 102 103 getQueueHelper() { 103 104 return this.queueHelper; 105 + } 106 + 107 + /** 108 + * Inject Discord post function for /send immediate cross-posting 109 + * @param {Function|null} discordPostFunction - Function that posts media to Discord 110 + */ 111 + setDiscordPostFunction(discordPostFunction) { 112 + if (this.sendCommand && typeof this.sendCommand.setDiscordPostFunction === 'function') { 113 + this.sendCommand.setDiscordPostFunction(discordPostFunction); 114 + } 104 115 } 105 116 } 106 117
+151 -34
bot/telegrambot/commands/recache.js
··· 1 1 const queueManager = require('../../../queue/queueManager'); 2 2 const mediaCache = require('../../../utils/mediaCache'); 3 - const fs = require('fs-extra'); 4 - const path = require('path'); 5 3 6 4 /** 7 5 * /recache command handler and scheduled recache logic ··· 12 10 this.authHelper = authHelper; 13 11 } 14 12 13 + /** 14 + * Build source URL list for a queue item 15 + * @param {Object} item 16 + * @returns {string[]} 17 + */ 18 + static getSourceUrlsForItem(item) { 19 + const urls = []; 20 + 21 + if (item.isVideo) { 22 + if (item.originalVideoUrl) urls.push(item.originalVideoUrl); 23 + if (item.sourceImgUrl) urls.push(item.sourceImgUrl); 24 + if (item.downloadUrl) urls.push(item.downloadUrl); 25 + } else { 26 + if (Array.isArray(item.originalImageUrls) && item.originalImageUrls.length > 0) { 27 + urls.push(...item.originalImageUrls); 28 + } 29 + if (item.sourceImgUrl) urls.push(item.sourceImgUrl); 30 + if (item.originalImageUrl) urls.push(item.originalImageUrl); 31 + if (item.downloadUrl) urls.push(item.downloadUrl); 32 + } 33 + 34 + return [...new Set(urls.filter(Boolean))]; 35 + } 36 + 37 + /** 38 + * Rewrite queue item paths from resolved cache files 39 + * @param {Object} item 40 + * @param {string[]} resolvedPaths 41 + * @returns {boolean} whether any queue field changed 42 + */ 43 + static rewriteItemPaths(item, resolvedPaths) { 44 + if (!Array.isArray(resolvedPaths) || resolvedPaths.length === 0) { 45 + return false; 46 + } 47 + 48 + let changed = false; 49 + 50 + if (item.isVideo) { 51 + const nextVideoPath = resolvedPaths[0]; 52 + if (item.videoUrl !== nextVideoPath) { 53 + item.videoUrl = nextVideoPath; 54 + changed = true; 55 + } 56 + return changed; 57 + } 58 + 59 + const nextImagePaths = [...new Set(resolvedPaths)]; 60 + const nextPrimary = nextImagePaths[0]; 61 + 62 + if (item.imageUrl !== nextPrimary) { 63 + item.imageUrl = nextPrimary; 64 + changed = true; 65 + } 66 + 67 + const existingImageUrls = Array.isArray(item.imageUrls) ? item.imageUrls : []; 68 + if (JSON.stringify(existingImageUrls) !== JSON.stringify(nextImagePaths)) { 69 + item.imageUrls = nextImagePaths; 70 + changed = true; 71 + } 72 + 73 + return changed; 74 + } 75 + 15 76 register() { 16 77 this.bot.onText(/\/recache/, async (msg) => { 17 78 const chatId = msg.chat.id; ··· 19 80 this.bot.sendMessage(chatId, 'You are not authorized to use this command.'); 20 81 return; 21 82 } 22 - this.bot.sendMessage(chatId, 'Recaching missing images and files. This may take a while...'); 23 - const { processed, removed } = await RecacheCommand.runScheduledRecache(); 24 - this.bot.sendMessage(chatId, `Recache complete. Processed ${processed} queue items. Removed ${removed} items after 3 failed attempts.`); 83 + this.bot.sendMessage(chatId, 'Recaching missing images and checking for oversized files. This may take a while...'); 84 + const { processed, removed, queueCompressed, cacheCompressed, rewritten } = await RecacheCommand.runScheduledRecache(); 85 + let message = `Recache complete. Processed ${processed} queue items.`; 86 + if (removed > 0) { 87 + message += ` Removed ${removed} items after 3 failed attempts.`; 88 + } 89 + if (rewritten > 0) { 90 + message += ` Rewrote ${rewritten} queue item filename mapping${rewritten === 1 ? '' : 's'}.`; 91 + } 92 + const totalCompressed = queueCompressed + cacheCompressed; 93 + if (totalCompressed > 0) { 94 + message += ` Compressed ${totalCompressed} oversized images`; 95 + if (queueCompressed > 0 && cacheCompressed > 0) { 96 + message += ` (${queueCompressed} in queue, ${cacheCompressed} in cache)`; 97 + } 98 + message += '.'; 99 + } 100 + this.bot.sendMessage(chatId, message); 25 101 }); 26 102 } 27 103 28 104 /** 29 105 * Run recache for missing files, remove items after 3 failures 30 106 * Can be called from a scheduler (does not require bot instance) 31 - * @returns {Promise<{processed: number, removed: number}>} 107 + * @returns {Promise<{processed: number, removed: number, queueCompressed: number, cacheCompressed: number, rewritten: number}>} 32 108 */ 33 109 static async runScheduledRecache() { 34 110 const queue = await queueManager.getQueue(); 35 111 let processed = 0; 36 112 let removed = 0; 113 + let queueCompressed = 0; 114 + let rewritten = 0; 115 + let queueUpdated = false; 37 116 // Track indices to remove after loop to avoid index shifting 38 117 const indicesToRemove = []; 39 118 for (let i = 0; i < queue.length; i++) { 40 119 const item = queue[i]; 41 - // Gather all relevant URLs 42 - const urls = []; 43 - if (item.sourceImgUrl) urls.push(item.sourceImgUrl); 44 - if (Array.isArray(item.originalImageUrls)) urls.push(...item.originalImageUrls); 45 - if (item.downloadUrl && !urls.includes(item.downloadUrl)) urls.push(item.downloadUrl); 46 - if (item.originalImageUrl && !urls.includes(item.originalImageUrl)) urls.push(item.originalImageUrl); 47 - const uniqueUrls = [...new Set(urls.filter(Boolean))]; 48 - let allFilesExist = true; 120 + const uniqueUrls = RecacheCommand.getSourceUrlsForItem(item); 121 + 122 + if (uniqueUrls.length === 0) { 123 + processed++; 124 + continue; 125 + } 126 + 127 + const resolvedPaths = []; 128 + let hasFailure = false; 129 + 49 130 for (const url of uniqueUrls) { 50 - // Determine expected cache path 51 - const hash = mediaCache.getHashedFilename(url); 52 - const ext = mediaCache.getFileExtension(url); 53 - const isVideo = item.isVideo; 54 - const dir = isVideo ? mediaCache.videoDir : mediaCache.imageDir; 55 - const filePath = path.join(dir, `${hash}${ext}`); 56 - const exists = await fs.pathExists(filePath); 57 - if (!exists) { 58 - allFilesExist = false; 59 - try { 60 - await mediaCache.processMediaUrl(url, isVideo); 61 - // Reset failure count on success 62 - if (item._recacheFailures) item._recacheFailures = 0; 63 - } catch (err) { 64 - item._recacheFailures = (item._recacheFailures || 0) + 1; 65 - if (item._recacheFailures >= 3) { 66 - indicesToRemove.push(i); 67 - break; // No need to try other URLs for this item 131 + try { 132 + // Pass source URL to allow hash DB lookup and canonical filename resolution. 133 + const processedMedia = await mediaCache.processMediaUrl(url, item.isVideo, url); 134 + if (processedMedia && processedMedia.localPath) { 135 + resolvedPaths.push(processedMedia.localPath); 136 + 137 + if (!item.isVideo) { 138 + try { 139 + const wasCompressed = await mediaCache.compressImageIfNeeded(processedMedia.localPath); 140 + if (wasCompressed) { 141 + queueCompressed++; 142 + } 143 + } catch (err) { 144 + console.error(`Error compressing existing file ${processedMedia.localPath}:`, err); 145 + } 68 146 } 147 + } 148 + } catch (err) { 149 + hasFailure = true; 150 + item._recacheFailures = (item._recacheFailures || 0) + 1; 151 + queueUpdated = true; 152 + 153 + if (item._recacheFailures >= 3) { 154 + indicesToRemove.push(i); 155 + break; 69 156 } 70 157 } 71 158 } 159 + 160 + if (!hasFailure && item._recacheFailures) { 161 + item._recacheFailures = 0; 162 + queueUpdated = true; 163 + } 164 + 165 + if (!indicesToRemove.includes(i)) { 166 + const changed = RecacheCommand.rewriteItemPaths(item, resolvedPaths); 167 + if (changed) { 168 + rewritten++; 169 + queueUpdated = true; 170 + } 171 + } 172 + 72 173 processed++; 73 174 } 175 + 74 176 // Remove items in reverse order to avoid index shifting 75 177 indicesToRemove.sort((a, b) => b - a); 76 178 for (const idx of indicesToRemove) { 77 179 queue.splice(idx, 1); 78 180 removed++; 79 181 } 80 - if (removed > 0) await queueManager.saveQueueToDisk(); 81 - return { processed, removed }; 182 + 183 + if (removed > 0) { 184 + queueUpdated = true; 185 + } 186 + 187 + if (queueUpdated) { 188 + await queueManager.saveQueueToDisk(); 189 + } 190 + 191 + if (rewritten > 0) { 192 + console.log(`[Recache] Rewrote ${rewritten} queue item path set(s) to canonical cache filenames`); 193 + } 194 + 195 + // Also scan all cached images for oversized files 196 + const { compressed: cacheCompressed } = await mediaCache.compressOversizedCachedImages(); 197 + 198 + return { processed, removed, queueCompressed, cacheCompressed, rewritten }; 82 199 } 83 200 } 84 201
+39 -5
bot/telegrambot/commands/send.js
··· 1 1 const queueManager = require('../../../queue/queueManager'); 2 + const config = require('../../../config'); 2 3 const discordWebhook = require('../../discordWebhook'); 3 4 4 5 /** 5 6 * /send command handler 6 7 */ 7 8 class SendCommand { 8 - constructor(bot, authHelper, mediaHelper) { 9 + constructor(bot, authHelper, mediaHelper, discordPostFunction = null) { 9 10 this.bot = bot; 10 11 this.authHelper = authHelper; 11 12 this.mediaHelper = mediaHelper; 13 + this.discordPostFunction = discordPostFunction; 14 + } 15 + 16 + setDiscordPostFunction(discordPostFunction) { 17 + this.discordPostFunction = discordPostFunction; 12 18 } 13 19 14 20 register() { ··· 50 56 telegramSuccess = true; 51 57 } 52 58 53 - // Post to Discord if it's configured and hasn't been posted yet 54 - if (discordWebhook.isEnabled() && !queueManager.hasBeenPostedByService(0, 'discord')) { 59 + const isDiscordBotMode = !!( 60 + config.discord.useBot && 61 + config.discord.botToken && 62 + config.discord.channelId 63 + ); 64 + 65 + const discordAlreadyPosted = queueManager.hasBeenPostedByService(0, 'discord'); 66 + 67 + // Post to Discord bot immediately when bot mode is active 68 + if (!discordAlreadyPosted && isDiscordBotMode && this.discordPostFunction) { 69 + discordStatus = 'attempting'; 70 + try { 71 + const discordResult = await this.discordPostFunction(nextItem); 72 + 73 + if (discordResult) { 74 + await queueManager.markPostedByService(0, 'discord'); 75 + discordSuccess = true; 76 + discordStatus = 'posted'; 77 + } else { 78 + discordStatus = 'failed'; 79 + } 80 + } catch (error) { 81 + console.error('Error posting to Discord bot:', error); 82 + discordStatus = 'error: ' + error.message; 83 + } 84 + } 85 + // Post to Discord webhook only when bot mode is not active 86 + else if (!discordAlreadyPosted && !isDiscordBotMode && discordWebhook.isEnabled()) { 55 87 discordStatus = 'attempting'; 56 88 try { 57 89 const discordResult = await discordWebhook.postMedia(nextItem); ··· 67 99 console.error('Error posting to Discord:', error); 68 100 discordStatus = 'error: ' + error.message; 69 101 } 70 - } else if (discordWebhook.isEnabled()) { 102 + } else if (!discordAlreadyPosted && isDiscordBotMode) { 103 + discordStatus = 'discord bot unavailable'; 104 + } else if (discordAlreadyPosted) { 71 105 discordStatus = 'already posted'; 72 106 discordSuccess = true; 73 107 } else { ··· 79 113 let responseMessage = `${itemType}: "${nextItem.title}"\n\n`; 80 114 responseMessage += `Telegram: ${telegramStatus}\n`; 81 115 82 - if (discordWebhook.isEnabled()) { 116 + if (queueManager.postServices.includes('discord')) { 83 117 responseMessage += `Discord: ${discordStatus}\n`; 84 118 } 85 119
+1 -1
bot/telegrambot/helpers/callbackHandler.js
··· 385 385 { 386 386 parse_mode: 'Markdown', 387 387 reply_markup: { force_reply: true } 388 - } 388 + }/send 389 389 ).then(prompt => { 390 390 this.handleEditMessageInput(chatId, userId, prompt.message_id, announcement); 391 391 });
+8 -1
bot/telegrambot/helpers/mediaHelper.js
··· 1 1 const axios = require('axios'); 2 2 const fs = require('fs'); 3 + const path = require('path'); 3 4 const config = require('../../../config'); 4 5 5 6 /** ··· 121 122 `${caption}\n(Video post - see original)` : 122 123 "(Video post - see original)"; 123 124 125 + // Use image path directly (already compressed in cache if needed) 126 + const imageToSend = fs.existsSync(mediaData.imageUrl) 127 + ? mediaData.imageUrl 128 + : mediaData.imageUrl; 129 + 124 130 await this.bot.sendPhoto( 125 131 config.channelId, 126 - mediaData.imageUrl, 132 + imageToSend, 127 133 { 128 134 caption: fallbackCaption, 129 135 reply_markup: inlineKeyboard ··· 142 148 143 149 // For images from local cache, we need to use the file path 144 150 if (fs.existsSync(mediaData.imageUrl)) { 151 + // Image is already compressed in cache if needed 145 152 await this.bot.sendPhoto( 146 153 config.channelId, 147 154 fs.createReadStream(mediaData.imageUrl),
+20 -1
config.js
··· 8 8 9 9 // Discord configuration 10 10 discord: { 11 - // Support multiple webhook URLs (comma-separated) 11 + // Discord bot configuration 12 + botToken: process.env.DISCORD_BOT_TOKEN, 13 + channelId: process.env.DISCORD_CHANNEL_ID, 14 + 15 + // Authorization (Discord user IDs/snowflakes) 16 + authorizedSnowflakes: process.env.DISCORD_AUTHORIZED_SNOWFLAKES 17 + ? process.env.DISCORD_AUTHORIZED_SNOWFLAKES.split(',').map(id => id.trim()).filter(Boolean) 18 + : [], 19 + ownerId: process.env.DISCORD_OWNER_ID, 20 + 21 + // Use bot mode if token is provided, otherwise fall back to webhook 22 + useBot: !!process.env.DISCORD_BOT_TOKEN, 23 + 24 + // Webhook configuration (fallback) 12 25 webhookUrls: process.env.DISCORD_WEBHOOK_URLS 13 26 ? process.env.DISCORD_WEBHOOK_URLS.split(',').map(url => url.trim()).filter(Boolean) 14 27 : undefined, ··· 34 47 // Media cache configuration 35 48 cacheDir: process.env.CACHE_DIR || path.join(__dirname, 'cache'), 36 49 maxCacheAgeDays: parseInt(process.env.MAX_CACHE_AGE_DAYS || '15', 10), 50 + 51 + // Image hash configuration 52 + imageHash: { 53 + // imghash hash size (8-32, even). Higher values are stricter and reduce collisions. 54 + perceptualHashBits: parseInt(process.env.PERCEPTUAL_HASH_BITS || '16', 10) 55 + }, 37 56 38 57 // Bluesky configuration 39 58 bluesky: {
+156
docs/discord-bot-implementation.md
··· 1 + # Discord Bot Implementation Summary 2 + 3 + ## What Was Added 4 + 5 + This implementation adds a full Discord bot to Stagehand with the following features: 6 + 7 + ### Core Features 8 + 1. **Full Discord Bot** with command support (using discord.js) 9 + 2. **Snowflake-based Authorization** - Only approved Discord users can add links 10 + 3. **Automatic Link Processing** - Detects and processes supported URLs from messages 11 + 4. **Command System** - Bot commands for queue management and status 12 + 5. **Webhook Fallback** - Keeps existing webhook system as a backup 13 + 14 + ### File Structure 15 + 16 + ``` 17 + bot/ 18 + ├── discordBot.js # Main Discord bot class 19 + ├── discordWebhook.js # Existing webhook (fallback) 20 + └── discordbot/ 21 + ├── commandRegistry.js # Command registration and management 22 + ├── commands/ 23 + │ ├── index.js # Command exports 24 + │ ├── help.js # Help command 25 + │ ├── queue.js # Queue display command 26 + │ ├── status.js # Bot status command 27 + │ └── linkHandler.js # URL processing handler 28 + └── helpers/ 29 + ├── index.js # Helper exports 30 + ├── authHelper.js # Snowflake authorization 31 + ├── mediaHelper.js # Media posting to Discord 32 + └── queueHelper.js # Queue display helpers 33 + ``` 34 + 35 + ### New Configuration Options 36 + 37 + Added to `config.js`: 38 + - `discord.botToken` - Discord bot token 39 + - `discord.channelId` - Channel ID for posting 40 + - `discord.authorizedSnowflakes` - Array of authorized user IDs 41 + - `discord.ownerId` - Bot owner ID 42 + - `discord.useBot` - Auto-detected based on token presence 43 + 44 + ### Environment Variables 45 + 46 + New `.env` variables: 47 + ```env 48 + DISCORD_BOT_TOKEN=your_bot_token 49 + DISCORD_CHANNEL_ID=your_channel_id 50 + DISCORD_AUTHORIZED_SNOWFLAKES=id1,id2,id3 51 + DISCORD_OWNER_ID=your_id 52 + ``` 53 + 54 + ## How It Works 55 + 56 + ### Authorization Flow 57 + 1. User posts a link in Discord 58 + 2. Bot checks if user's Discord ID (snowflake) is in `DISCORD_AUTHORIZED_SNOWFLAKES` 59 + 3. If authorized: processes link and adds to queue 60 + 4. If not authorized: sends error message with user's Discord ID 61 + 62 + ### Bot vs Webhook 63 + - If `DISCORD_BOT_TOKEN` is set → Use full bot mode 64 + - If not set → Fall back to webhook mode 65 + - Webhook is kept as a safety fallback 66 + 67 + ### Commands 68 + All commands use Discord's slash command system (`/`): 69 + - `/help` - Show commands and your Discord ID (available to all) 70 + - `/queue [page]` - View queue (authorized only) 71 + - `/status` - Bot status (authorized only) 72 + 73 + **Link Processing:** 74 + - Send any supported link to the bot via **Direct Message** 75 + - Links only work in DMs (not in server channels) 76 + - Prevents spam and follows Discord's app guidelines 77 + 78 + ### Supported Sites 79 + Same as Telegram bot: 80 + - Bluesky (all instances) 81 + - e621.net 82 + - FurAffinity 83 + - SoFurry 84 + - Weasyl 85 + 86 + ## Installation Steps 87 + 88 + 1. **Install dependencies:** 89 + ```bash 90 + npm install 91 + ``` 92 + 93 + 2. **Create Discord bot:** 94 + - Go to https://discord.com/developers/applications 95 + - Create new application → Add Bot 96 + - Enable "Message Content Intent" 97 + - Copy bot token 98 + 99 + 3. **Get Discord IDs:** 100 + - Enable Developer Mode in Discord 101 + - Right-click channel → Copy Channel ID 102 + - Right-click your user → Copy User ID 103 + 104 + 4. **Configure .env:** 105 + ```env 106 + DISCORD_BOT_TOKEN=your_token_here 107 + DISCORD_CHANNEL_ID=123456789012345678 108 + DISCORD_AUTHORIZED_SNOWFLAKES=your_id,friend_id 109 + DISCORD_OWNER_ID=your_id 110 + ``` 111 + 112 + 5. **Start the bot:** 113 + ```bash 114 + npm start 115 + Use `/help` in any channel or DM - should show commands and your Discord ID 116 + 2. **Open a DM with the bot** and send a supported link (e.g., from Bluesky) 117 + - If authorized: should add to queue 118 + - If not authorized: should show error with your ID 119 + 3. Run `/queue` to see the queue 120 + 4. Run `/status` to see bot status 121 + 122 + **Important:** Links must be sent in DMs, not in server channels.om Bluesky) 123 + - If authorized: should add to queue 124 + - If not authorized: should show error with your ID 125 + 3. Run `!queue` to see the queue 126 + 4. Run `!status` to see bot status 127 + 128 + ## Key Differences from Telegram 129 + 130 + | Feature | Telegram | Discord | 131 + |---------|----------|---------| 132 + | User ID | Numeric (12345)/command` (slash commands) | 133 + | Link Processing | Any chat | DM onlyake (123456789012345678) | 134 + | Auth Check | `AUTHORIZED_USERS` | `DISCORD_AUTHORIZED_SNOWFLAKES` | 135 + | Commands | `/command` | `!command` | 136 + | Buttons | Inline keyboard | Action rows with buttons | 137 + | Media | SendPhoto/SendVideo | Embeds with attachments | 138 + 139 + ## Security Notes 140 + 141 + - Snowflakes are public (anyone can see them by copying your ID) 142 + - Authorization list prevents unauthorized queue additions 143 + - Bot token must be kept secret 144 + - Only authorized users can execute commands 145 + - `!help` is public to allow users to find their ID 146 + 147 + ## Future Enhancements 148 + 149 + Potential addit options for advanced features 150 + - Context menu commands (right-click message → Add to queue) 151 + - Role-based authorization in addition to snowflakes 152 + - Per-server configuration 153 + - Discord announcement system 154 + - Advanced queue management withem 155 + - Advanced queue management buttons 156 + - Multi-server support
+200
docs/discord-bot-setup.md
··· 1 + # Discord Bot Setup Guide 2 + 3 + This guide explains how to set up the full Discord bot with snowflake-based authorization. 4 + 5 + ## Overview 6 + 7 + The bot now supports two Discord integration modes: 8 + 1. **Discord Bot (recommended)** - Full bot with commands and authorization 9 + 2. **Discord Webhook (fallback)** - Simple webhook posting only 10 + 11 + The bot will automatically use the Discord Bot mode if `DISCORD_BOT_TOKEN` is provided, otherwise it falls back to webhook mode. 12 + 13 + ## Discord Bot Setup 14 + 15 + ### 1. Create a Discord Bot 16 + 17 + 1. Go to https://discord.com/developers/applications 18 + 2. Click "New Application" and give it a name 19 + 3. Go to the "Bot" section in the left sidebar 20 + 4. Click "Add Bot" 21 + 5. Under "Privileged Gateway Intents", enable: 22 + - **Message Content Intent** (required to read message content) 23 + - **Server Members Intent** (optional, for member-related features) 24 + 6. Click "Reset Token" to get your bot token (save this securely!) 25 + 26 + ### 2. Invite the Bot to Your Server 27 + 28 + 1. Go to the "OAuth2" > "URL Generator" section 29 + 2. Select the following scopes: 30 + - `bot` 31 + - `applications.commands` (if you want slash commands in the future) 32 + 3. Select the following bot permissions: 33 + - Read Messages/View Channels 34 + - Send Messages 35 + - Embed Links 36 + - Attach Files 37 + - Read Message History 38 + - Add Reactions 39 + - Use External Emojis 40 + 4. Copy the generated URL and open it in your browser to invite the bot 41 + 42 + ### 3. Get Your Discord IDs (Snowflakes) 43 + 44 + #### Enable Developer Mode: 45 + 1. In Discord, go to User Settings > Advanced 46 + 2. Enable "Developer Mode" 47 + 48 + #### Get Channel ID: 49 + 1. Right-click on the channel where you want the bot to post 50 + 2. Click "Copy Channel ID" 51 + 3. This is your `DISCORD_CHANNEL_ID` 52 + 53 + #### Get Your User ID: 54 + 1. Right-click on your username anywhere in Discord 55 + 2. Click "Copy User ID" 56 + 3. This is your Discord snowflake for authorization 57 + 58 + ### 4. Configure Environment Variables 59 + 60 + Add these to your `.env` file: 61 + 62 + ```env 63 + # Discord Bot Configuration 64 + DISCORD_BOT_TOKEN=your_bot_token_here 65 + DISCORD_CHANNEL_ID=your_channel_id_here 66 + 67 + # Discord Authorization (comma-separated user IDs/snowflakes) 68 + DISCORD_AUTHORIZED_SNOWFLAKES=123456789012345678,234567890123456789 69 + DISCORD_OWNER_ID=123456789012345678 70 + 71 + # Optional: Keep webhook as fallback (if bot fails) 72 + DISCORD_WEBHOOK_URL=your_webhook_url_here 73 + DISCORD_ENABLED=true 74 + ``` 75 + 76 + ### 5. Understanding Snowflakes 77 + 78 + Discord snowflakes are unique 64-bit identifiers for users, channels, servers, etc. They look like: `123456789012345678` 79 + 80 + **Authorization:** 81 + - `DISCORD_AUTHORIZED_SNOWFLAKES` - Comma-separated list of user IDs who can use the bot 82 + - `DISCORD_OWNER_ID` - The primary owner/admin (usually your user ID) 83 + - If `DISCORD_AUTHORIZED_SNOWFLAKES` is empty, **anyone** can use the bot 84 + 85 + **Finding Snowflakes:** 86 + - Users: Right-click user > Copy User ID 87 + - Channels: Right-click channel > Copy Channel ID 88 + - Servers: Right-click server icon > Copy Server ID 89 + 90 + ## Discord Bot Commands 91 + 92 + Once configured, authorized users can use these slash commands: 93 + 94 + - `/help` - Show all available commands and your Discord ID (available to everyone) 95 + - `/queue [page]` - View the queue (authorized users only) 96 + - `/status` - View bot status (authorized users only) 97 + 98 + ### Adding Links to Queue 99 + 100 + **Important:** Links can only be submitted via **Direct Message (DM)** to the bot. 101 + 102 + 1. Open a DM with the bot 103 + 2. Send any supported link 104 + 3. The bot will process it if you're authorized 105 + 106 + This follows Discord's app guidelines and prevents spam in public channels. 107 + 108 + ###**Commands** (`/queue`, `/status`): 109 + - The bot checks if the user's Discord ID is in `DISCORD_AUTHORIZED_SNOWFLAKES` 110 + - If authorized: command executes 111 + - If not authorized: error message shown 112 + 113 + 2. **Link Processing** (DM only): 114 + - User sends a link via Direct Message to the bot 115 + - Bot checks if their Discord ID is authorized 116 + - If authorized: processes link and adds to queue 117 + - If not authorized: shows error with their Discord ID 118 + 119 + 3. **Help Command** (`/help`): 120 + - Available to everyone 121 + - Shows user's Discord ID so they can request access 122 + 123 + **Note:** Link submission only works in DMs to comply with Discord's app guidelines and prevent channel spam.KES` 124 + - If authorized, the link is processed and added to queue 125 + - If not authorized, they receive an error message with their Discord ID 126 + 127 + 2. For commands like `!queue`, `!status`: 128 + - Same authorization check applies 129 + - Unauthorized users see an error message 130 + 131 + 3. The `!help` command: 132 + - Available to everyone (shows their Discord ID so they can request access) 133 + 134 + ## Example Configuration 135 + 136 + ### Full Bot Mode (Recommended) 137 + ```env 138 + # Telegram (existing) 139 + BOT_TOKEN=your_telegram_bot_token 140 + CHANNEL_ID=your_telegram_channel_id 141 + AUTHORIZED_USERS=12345,67890 142 + OWNER_ID=12345 143 + 144 + # Discord Bot 145 + DISCORD_BOT_TOKEN=your_discord_bot_token 146 + DISCORD_CHANNEL_ID=987654321098765432 147 + DISCORD_AUTHORIZED_SNOWFLAKES=123456789012345678,234567890123456789 148 + DISCORD_OWNER_ID=123456789012345678 149 + DISCORD_ENABLED=true 150 + ``` 151 + 152 + ### Webhook Fallback Mode 153 + ```env 154 + # If you don't provide DISCORD_BOT_TOKEN, webhook mode is used 155 + DISCORD_WEBHOOK_URL=your_webhook_url 156 + DISCORD_ENABLED=true 157 + ```slash commands are registered (check console for "Successfully registered X slash commands") 158 + 2. Verify your Discord user ID is in `DISCORD_AUTHORIZED_SNOWFLAKES` 159 + 3. Try using `/help` first (available to everyone) 160 + 4. If commands don't appear, try kicking and re-inviting the bot 161 + 162 + ### Links aren't being processed 163 + 1. **Make sure you're sending links in a DM to the bot**, not in a server channel 164 + 2. Open a Direct Message with the bot first 165 + 3. Check that you're posting a link from a supported site 166 + 4. Verify your user ID is authorized (use `/help` to see your ID) 167 + 5. Check the console logs for errors 168 + ### Links aren't being processed 169 + 1. Check that you're posting in a channel the bot can see 170 + 2. Verify your user ID is authorized 171 + 3. Check the console logs for errors 172 + 4. Ensure the link is from a supported site 173 + 174 + ### Can't find my Discord ID 175 + 1. Or use the `/help` command - it shows your Discord ID 176 + 4. The ID should be 17-20 digits long 177 + 178 + ### Bot not responding in DMs 179 + 1. Make sure you've opened a DM with the bot (click on the bot's profile and "Message") 180 + 2. Check that the bot is online 181 + 3. Verify the bot has the correct intents enabled (see setup steps) 182 + 4. Check console logs for any errorsettings (User Settings > Advanced) 183 + 2. Right-click your username and select "Copy User ID" 184 + 3. The ID should be 17-20 digits long 185 + 186 + ## Migration from Webhook 187 + 188 + If you're currently using the webhook system: 189 + 190 + 1. Keep your existing `DISCORD_WEBHOOK_URL` in .env as a fallback 191 + 2. Add the new Discord bot configuration variables 192 + 3. The bot will automatically use bot mode if `DISCORD_BOT_TOKEN` is set 193 + 4. The webhook will still be used if the bot fails to initialize 194 + 195 + ## Security Notes 196 + 197 + - **Never share your bot token!** It gives full access to your bot 198 + - Keep your `.env` file private and never commit it to version control 199 + - Only add trusted users to `DISCORD_AUTHORIZED_SNOWFLAKES` 200 + - The owner ID has special privileges (future admin commands)
+148
docs/discord-compliance-update.md
··· 1 + # Discord Bot Compliance Update 2 + 3 + ## Changes Made 4 + 5 + This update brings the Discord bot into compliance with Discord's app guidelines by implementing two key changes: 6 + 7 + ### 1. ✅ Slash Commands (Application Commands) 8 + **Before:** Prefix commands (`!help`, `!queue`, `!status`) 9 + **After:** Slash commands (`/help`, `/queue`, `/status`) 10 + 11 + **Why:** Discord strongly recommends using their native Application Commands system for better UX and discoverability. 12 + 13 + **Benefits:** 14 + - Commands appear in Discord's UI with autocomplete 15 + - Built-in parameter validation 16 + - Better user experience 17 + - Follows Discord's best practices 18 + - More discoverable for users 19 + 20 + ### 2. ✅ DM-Only Link Processing 21 + **Before:** Links processed in any channel the bot could see 22 + **After:** Links only processed when sent via Direct Message to the bot 23 + 24 + **Why:** Prevents spam in public channels and follows Discord's guidelines for bot behavior. 25 + 26 + **Benefits:** 27 + - Prevents channel spam 28 + - More intentional user interaction 29 + - Complies with Discord's app guidelines 30 + - Better control over bot interactions 31 + - Cleaner server channels 32 + 33 + ## Updated Files 34 + 35 + ### Core Bot Files 36 + - `bot/discordBot.js` - Added slash command registration via Discord API 37 + - `bot/discordbot/commandRegistry.js` - Added `getCommandData()` method for slash commands 38 + - `bot/discordbot/commands/linkHandler.js` - Restricted to DM only (ChannelType.DM) 39 + 40 + ### Command Files (All converted to slash commands) 41 + - `bot/discordbot/commands/help.js` - Now uses SlashCommandBuilder 42 + - `bot/discordbot/commands/queue.js` - Now uses SlashCommandBuilder with options 43 + - `bot/discordbot/commands/status.js` - Now uses SlashCommandBuilder 44 + 45 + ### Helper Files 46 + - `bot/discordbot/helpers/queueHelper.js` - Updated to work with interactions instead of messages 47 + 48 + ### Documentation 49 + - `docs/discord-bot-setup.md` - Updated with slash command info and DM requirements 50 + - `docs/discord-bot-implementation.md` - Updated technical details 51 + 52 + ## How to Use 53 + 54 + ### Commands (Work everywhere) 55 + ``` 56 + /help - Show available commands and your Discord ID 57 + /queue [page] - View the queue (authorized users only) 58 + /status - View bot status (authorized users only) 59 + ``` 60 + 61 + ### Adding Links (DM only) 62 + 1. Open a Direct Message with the bot 63 + 2. Send any supported link (Bluesky, e621, FurAffinity, etc.) 64 + 3. Bot processes if you're authorized 65 + 66 + ## Technical Details 67 + 68 + ### Slash Command Registration 69 + When the bot starts, it automatically registers all slash commands with Discord using the REST API: 70 + 71 + ```javascript 72 + const rest = new REST({ version: '10' }).setToken(config.discord.botToken); 73 + await rest.put( 74 + Routes.applicationCommands(this.client.user.id), 75 + { body: commandData } 76 + ); 77 + ``` 78 + 79 + ### DM Detection 80 + Link handler now checks channel type: 81 + 82 + ```javascript 83 + if (message.channel.type !== ChannelType.DM) { 84 + return; // Ignore non-DM messages 85 + } 86 + ``` 87 + 88 + ### Interaction Handling 89 + Commands now use `interaction.reply()` instead of `message.reply()`: 90 + 91 + ```javascript 92 + async execute(interaction) { 93 + await interaction.reply({ content: '...', ephemeral: true }); 94 + } 95 + ``` 96 + 97 + ## Authorization 98 + 99 + Authorization still works the same way via `DISCORD_AUTHORIZED_SNOWFLAKES`: 100 + - Only authorized users can use `/queue` and `/status` 101 + - Only authorized users can submit links (in DMs) 102 + - `/help` is available to everyone 103 + 104 + ## Migration Notes 105 + 106 + **No breaking changes to configuration!** 107 + 108 + Your existing `.env` file continues to work: 109 + ```env 110 + DISCORD_BOT_TOKEN=your_token 111 + DISCORD_CHANNEL_ID=your_channel_id 112 + DISCORD_AUTHORIZED_SNOWFLAKES=id1,id2,id3 113 + DISCORD_OWNER_ID=your_id 114 + ``` 115 + 116 + **User-facing changes:** 117 + - Users must use `/` commands instead of `!` commands 118 + - Users must DM the bot to submit links (not post in channels) 119 + 120 + ## Testing Checklist 121 + 122 + - [ ] Bot starts and logs in successfully 123 + - [ ] Slash commands appear in Discord UI 124 + - [ ] `/help` works and shows your Discord ID 125 + - [ ] `/queue` shows queue (if authorized) 126 + - [ ] `/status` shows bot status (if authorized) 127 + - [ ] Sending link in DM works (if authorized) 128 + - [ ] Sending link in server channel is ignored 129 + - [ ] Unauthorized users see error messages 130 + 131 + ## Compliance Benefits 132 + 133 + This update ensures the bot follows Discord's guidelines: 134 + 135 + ✅ **Uses Application Commands** - Best practice for all Discord bots 136 + ✅ **Respects channel context** - Links only in DMs prevent spam 137 + ✅ **Clear authorization** - Users know immediately if they're authorized 138 + ✅ **Better UX** - Slash commands are more discoverable 139 + ✅ **Future-proof** - Built on Discord's recommended systems 140 + 141 + ## Next Steps 142 + 143 + 1. Restart the bot to apply changes 144 + 2. Test slash commands in your server 145 + 3. Open a DM with the bot and test link submission 146 + 4. Update any user documentation about how to use the bot 147 + 148 + The bot is now fully compliant with Discord's app guidelines! 🎉
-91
examples/image-hash-example.js
··· 1 - /** 2 - * Example: Using the Image Hash Manager 3 - * 4 - * This example demonstrates how to use the perceptual hashing module 5 - * to detect duplicate and similar images. 6 - */ 7 - 8 - const ImageHashManager = require('./utils/imageHashManager'); 9 - const path = require('path'); 10 - 11 - async function example() { 12 - // Initialize the hash manager 13 - const hashManager = new ImageHashManager(); 14 - 15 - console.log('=== Image Perceptual Hashing Example ===\n'); 16 - 17 - try { 18 - // Example 1: Process an image 19 - console.log('1. Processing an image:'); 20 - const imagePath = path.join(__dirname, 'cache', 'images', 'd9fa3f2934bb020b7612b6b367184ff9.webp'); 21 - const imageUrl = 'https://example.com/test-image.jpg'; 22 - 23 - // Note: This will only work if the image exists in your cache 24 - // const result = await hashManager.processImage(imageUrl, imagePath); 25 - // console.log(' Hash:', result.hash); 26 - // console.log(' Stored:', result.stored); 27 - console.log(' (Run with actual cached images)\n'); 28 - 29 - // Example 2: Get database statistics 30 - console.log('2. Database statistics:'); 31 - const stats = hashManager.getStats(); 32 - console.log(' Total images:', stats.totalImages); 33 - console.log(' Images (last 7 days):', stats.imagesLastWeek); 34 - console.log(' Database path:', stats.databasePath); 35 - console.log(''); 36 - 37 - // Example 3: List recent hashes 38 - console.log('3. Recent image hashes:'); 39 - const recentHashes = hashManager.getAllHashes(5); 40 - recentHashes.forEach((record, index) => { 41 - console.log(` ${index + 1}. ${record.perceptual_hash}`); 42 - console.log(` URL: ${record.url.substring(0, 60)}...`); 43 - }); 44 - console.log(''); 45 - 46 - // Example 4: Find similar images 47 - if (recentHashes.length > 0) { 48 - console.log('4. Finding similar images:'); 49 - const testHash = recentHashes[0].perceptual_hash; 50 - const similar = hashManager.findSimilarImages(testHash, 5); 51 - console.log(` Found ${similar.length} similar image(s) to hash ${testHash}`); 52 - similar.forEach((img, index) => { 53 - console.log(` ${index + 1}. Distance: ${img.distance}, URL: ${img.url.substring(0, 50)}...`); 54 - }); 55 - console.log(''); 56 - } 57 - 58 - // Example 5: Search by URL 59 - if (recentHashes.length > 0) { 60 - console.log('5. Searching by URL:'); 61 - const testUrl = recentHashes[0].url; 62 - const found = hashManager.getHashByUrl(testUrl); 63 - if (found) { 64 - console.log(` Found: ${found.perceptual_hash}`); 65 - console.log(` Cached at: ${found.cached_at}`); 66 - } 67 - console.log(''); 68 - } 69 - 70 - // Example 6: Cleanup orphaned hashes 71 - console.log('6. Cleaning up orphaned records:'); 72 - const cleaned = await hashManager.cleanupOrphanedHashes(); 73 - console.log(` Removed ${cleaned} orphaned record(s)`); 74 - console.log(''); 75 - 76 - console.log('=== Example Complete ==='); 77 - 78 - } catch (error) { 79 - console.error('Error:', error); 80 - } finally { 81 - // Always close the database connection 82 - hashManager.close(); 83 - } 84 - } 85 - 86 - // Run the example 87 - if (require.main === module) { 88 - example().catch(console.error); 89 - } 90 - 91 - module.exports = example;
+71 -18
index.js
··· 1 1 require('dotenv').config(); 2 + const config = require('./config'); 2 3 const StagehandBot = require('./bot/telegramBot'); 3 - const DiscordWebhook = require('./bot/discordWebhook'); 4 + const DiscordStagehandBot = require('./bot/discordBot'); 5 + const discordWebhook = require('./bot/discordWebhook'); 4 6 const queueManager = require('./queue/queueManager'); 5 7 const mediaCache = require('./utils/mediaCache'); 6 8 const updater = require('./utils/updater'); 7 9 8 10 // Check if required environment variables are set 9 11 if (!process.env.BOT_TOKEN) { 10 - console.error('Error: BOT_TOKEN environment variable is not set'); 12 + console.error('[Startup] BOT_TOKEN environment variable is not set'); 11 13 process.exit(1); 12 14 } 13 15 14 16 if (!process.env.CHANNEL_ID) { 15 - console.error('Error: CHANNEL_ID environment variable is not set'); 17 + console.error('[Startup] CHANNEL_ID environment variable is not set'); 16 18 process.exit(1); 17 19 } 18 20 19 21 let telegramBot; 20 - let discordWebhook; 22 + let discordBot; 23 + let shouldPreferDiscordBot = false; 21 24 22 25 // Initialize the bots and services 23 26 try { 24 27 // Initialize media cache first 25 - console.log('Initializing media cache...'); 28 + console.log('[Startup] Initializing media cache...'); 26 29 27 30 // Initialize the Telegram bot 28 31 telegramBot = new StagehandBot(); 29 - console.log('Stagehand Telegram bot started successfully'); 30 - console.log(`Posting to Telegram channel: ${process.env.CHANNEL_ID}`); 32 + console.log('[Startup] Stagehand Telegram bot started successfully'); 33 + console.log(`[Startup] Posting to Telegram channel: ${process.env.CHANNEL_ID}`); 34 + 35 + // Initialize Discord - use bot if token is provided and has authorized users, otherwise use webhook 36 + const isPlaceholderToken = !config.discord.botToken || 37 + config.discord.botToken.includes('your_') || 38 + config.discord.botToken.includes('_here'); 39 + 40 + if (config.discord.useBot && config.discord.botToken && !isPlaceholderToken && 41 + config.discord.channelId && config.discord.authorizedSnowflakes.length > 0) { 42 + shouldPreferDiscordBot = true; 43 + try { 44 + console.log('[Startup] Initializing Discord bot...'); 45 + discordBot = new DiscordStagehandBot(); 46 + // Success message will be logged after successful login in discordBot.init() 47 + } catch (error) { 48 + console.error('[Startup] Failed to initialize Discord bot:', error.message); 49 + console.log('[Startup] Discord bot mode is enabled; webhook fallback is disabled'); 50 + discordBot = null; 51 + } 52 + } 53 + 54 + if (telegramBot && typeof telegramBot.setDiscordPostFunction === 'function') { 55 + const discordPostFunction = discordBot 56 + ? discordBot.postMedia.bind(discordBot) 57 + : null; 58 + telegramBot.setDiscordPostFunction(discordPostFunction); 59 + } 31 60 32 - // Initialize Discord webhook if enabled 33 - discordWebhook = DiscordWebhook; 34 - if (discordWebhook.isEnabled()) { 35 - console.log('Discord webhook integration enabled'); 61 + // Log webhook status if bot is not initialized 62 + if (!discordBot) { 63 + if (discordWebhook.isEnabled() && !shouldPreferDiscordBot) { 64 + console.log('[Startup] Discord webhook integration enabled (bot not configured)'); 65 + } else if (isPlaceholderToken) { 66 + console.log('[Startup] Discord bot token is a placeholder - update .env with real token to enable bot mode'); 67 + } else if (config.discord.botToken && !config.discord.authorizedSnowflakes.length) { 68 + console.log('[Startup] Discord bot token provided but no authorized snowflakes configured - webhook mode active'); 69 + } else if (shouldPreferDiscordBot) { 70 + console.log('[Startup] Discord bot mode active; webhook fallback disabled'); 71 + } 36 72 } 37 73 38 74 // Send startup notification to owner (unless this is an auto-update restart) ··· 49 85 if (flagExists) { 50 86 // Delete the flag and skip notification 51 87 await fs.remove(flagFile); 52 - console.log('Auto-update restart detected, skipping startup notification'); 88 + console.log('[Startup] Auto-update restart detected, skipping startup notification'); 53 89 return; 54 90 } 55 91 ··· 57 93 if (config.ownerId) { 58 94 const startupMessage = '✅ Bot has started successfully!'; 59 95 await telegramBot.bot.sendMessage(config.ownerId, startupMessage); 60 - console.log('Startup notification sent to owner'); 96 + console.log('[Startup] Startup notification sent to owner'); 61 97 } 62 98 } catch (error) { 63 - console.error('Error handling startup notification:', error); 99 + console.error('[Startup] Error handling startup notification:', error); 64 100 } 65 101 }; 66 102 ··· 69 105 70 106 // Start the scheduler with both posting services 71 107 const postFunctions = { 72 - telegram: telegramBot.postMedia.bind(telegramBot), 73 - discord: discordWebhook.postMedia.bind(discordWebhook) 108 + telegram: telegramBot.postMedia.bind(telegramBot) 74 109 }; 110 + 111 + if (discordBot) { 112 + postFunctions.discord = discordBot.postMedia.bind(discordBot); 113 + } else if (!shouldPreferDiscordBot && discordWebhook.isEnabled()) { 114 + postFunctions.discord = discordWebhook.postMedia.bind(discordWebhook); 115 + } 116 + 117 + queueManager.postServices = Object.keys(postFunctions); 118 + queueManager.initializeServiceTracking(); 75 119 queueManager.startScheduler(postFunctions); 76 - console.log('Post scheduler started'); 120 + console.log('[Startup] Post scheduler started'); 77 121 78 122 // Start the auto-updater 79 123 updater.start(); ··· 102 146 await telegramBot.shutdown(); 103 147 } 104 148 149 + // Discord bot cleanup 150 + if (discordBot && discordBot.shutdown) { 151 + await discordBot.shutdown(); 152 + } 153 + 105 154 // Discord webhook cleanup 106 155 if (discordWebhook && discordWebhook.shutdown) { 107 156 await discordWebhook.shutdown(); ··· 134 183 await telegramBot.shutdown(); 135 184 } 136 185 186 + if (discordBot && discordBot.shutdown) { 187 + await discordBot.shutdown(); 188 + } 189 + 137 190 if (discordWebhook && discordWebhook.shutdown) { 138 191 await discordWebhook.shutdown(); 139 192 } ··· 144 197 }); 145 198 146 199 } catch (error) { 147 - console.error('Error starting services:', error); 200 + console.error('[Startup] Error starting services:', error); 148 201 process.exit(1); 149 202 }
+803 -7
package-lock.json
··· 15 15 "better-sqlite3": "^12.6.2", 16 16 "cheerio": "^1.0.0-rc.12", 17 17 "crypto-js": "^4.2.0", 18 + "discord.js": "^14.16.3", 18 19 "dotenv": "^16.0.3", 19 20 "ffmpeg-static": "^5.2.0", 20 21 "fluent-ffmpeg": "^2.1.3", ··· 22 23 "fs-extra": "^11.1.1", 23 24 "imghash": "^1.1.2", 24 25 "node-cron": "^3.0.2", 25 - "node-telegram-bot-api": "^0.61.0" 26 + "node-telegram-bot-api": "^0.61.0", 27 + "sharp": "^0.34.5" 26 28 }, 27 29 "devDependencies": { 28 30 "nodemon": "^3.1.9" ··· 171 173 "node": ">=6.0.0" 172 174 } 173 175 }, 176 + "node_modules/@discordjs/builders": { 177 + "version": "1.14.1", 178 + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", 179 + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", 180 + "license": "Apache-2.0", 181 + "dependencies": { 182 + "@discordjs/formatters": "^0.6.2", 183 + "@discordjs/util": "^1.2.0", 184 + "@sapphire/shapeshift": "^4.0.0", 185 + "discord-api-types": "^0.38.40", 186 + "fast-deep-equal": "^3.1.3", 187 + "ts-mixer": "^6.0.4", 188 + "tslib": "^2.6.3" 189 + }, 190 + "engines": { 191 + "node": ">=16.11.0" 192 + }, 193 + "funding": { 194 + "url": "https://github.com/discordjs/discord.js?sponsor" 195 + } 196 + }, 197 + "node_modules/@discordjs/collection": { 198 + "version": "1.5.3", 199 + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", 200 + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", 201 + "license": "Apache-2.0", 202 + "engines": { 203 + "node": ">=16.11.0" 204 + } 205 + }, 206 + "node_modules/@discordjs/formatters": { 207 + "version": "0.6.2", 208 + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", 209 + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", 210 + "license": "Apache-2.0", 211 + "dependencies": { 212 + "discord-api-types": "^0.38.33" 213 + }, 214 + "engines": { 215 + "node": ">=16.11.0" 216 + }, 217 + "funding": { 218 + "url": "https://github.com/discordjs/discord.js?sponsor" 219 + } 220 + }, 221 + "node_modules/@discordjs/rest": { 222 + "version": "2.6.1", 223 + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", 224 + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", 225 + "license": "Apache-2.0", 226 + "dependencies": { 227 + "@discordjs/collection": "^2.1.1", 228 + "@discordjs/util": "^1.2.0", 229 + "@sapphire/async-queue": "^1.5.3", 230 + "@sapphire/snowflake": "^3.5.5", 231 + "@vladfrangu/async_event_emitter": "^2.4.6", 232 + "discord-api-types": "^0.38.40", 233 + "magic-bytes.js": "^1.13.0", 234 + "tslib": "^2.6.3", 235 + "undici": "6.24.1" 236 + }, 237 + "engines": { 238 + "node": ">=18" 239 + }, 240 + "funding": { 241 + "url": "https://github.com/discordjs/discord.js?sponsor" 242 + } 243 + }, 244 + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { 245 + "version": "2.1.1", 246 + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", 247 + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", 248 + "license": "Apache-2.0", 249 + "engines": { 250 + "node": ">=18" 251 + }, 252 + "funding": { 253 + "url": "https://github.com/discordjs/discord.js?sponsor" 254 + } 255 + }, 256 + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { 257 + "version": "3.5.5", 258 + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", 259 + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", 260 + "license": "MIT", 261 + "engines": { 262 + "node": ">=v14.0.0", 263 + "npm": ">=7.0.0" 264 + } 265 + }, 266 + "node_modules/@discordjs/util": { 267 + "version": "1.2.0", 268 + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", 269 + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", 270 + "license": "Apache-2.0", 271 + "dependencies": { 272 + "discord-api-types": "^0.38.33" 273 + }, 274 + "engines": { 275 + "node": ">=18" 276 + }, 277 + "funding": { 278 + "url": "https://github.com/discordjs/discord.js?sponsor" 279 + } 280 + }, 281 + "node_modules/@discordjs/ws": { 282 + "version": "1.2.3", 283 + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", 284 + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", 285 + "license": "Apache-2.0", 286 + "dependencies": { 287 + "@discordjs/collection": "^2.1.0", 288 + "@discordjs/rest": "^2.5.1", 289 + "@discordjs/util": "^1.1.0", 290 + "@sapphire/async-queue": "^1.5.2", 291 + "@types/ws": "^8.5.10", 292 + "@vladfrangu/async_event_emitter": "^2.2.4", 293 + "discord-api-types": "^0.38.1", 294 + "tslib": "^2.6.2", 295 + "ws": "^8.17.0" 296 + }, 297 + "engines": { 298 + "node": ">=16.11.0" 299 + }, 300 + "funding": { 301 + "url": "https://github.com/discordjs/discord.js?sponsor" 302 + } 303 + }, 304 + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { 305 + "version": "2.1.1", 306 + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", 307 + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", 308 + "license": "Apache-2.0", 309 + "engines": { 310 + "node": ">=18" 311 + }, 312 + "funding": { 313 + "url": "https://github.com/discordjs/discord.js?sponsor" 314 + } 315 + }, 316 + "node_modules/@emnapi/runtime": { 317 + "version": "1.9.2", 318 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", 319 + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", 320 + "license": "MIT", 321 + "optional": true, 322 + "dependencies": { 323 + "tslib": "^2.4.0" 324 + } 325 + }, 174 326 "node_modules/@ffmpeg-installer/darwin-arm64": { 175 327 "version": "4.1.5", 176 328 "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz", ··· 297 449 "win32" 298 450 ] 299 451 }, 452 + "node_modules/@img/colour": { 453 + "version": "1.1.0", 454 + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", 455 + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", 456 + "license": "MIT", 457 + "engines": { 458 + "node": ">=18" 459 + } 460 + }, 461 + "node_modules/@img/sharp-darwin-arm64": { 462 + "version": "0.34.5", 463 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", 464 + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", 465 + "cpu": [ 466 + "arm64" 467 + ], 468 + "license": "Apache-2.0", 469 + "optional": true, 470 + "os": [ 471 + "darwin" 472 + ], 473 + "engines": { 474 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 475 + }, 476 + "funding": { 477 + "url": "https://opencollective.com/libvips" 478 + }, 479 + "optionalDependencies": { 480 + "@img/sharp-libvips-darwin-arm64": "1.2.4" 481 + } 482 + }, 483 + "node_modules/@img/sharp-darwin-x64": { 484 + "version": "0.34.5", 485 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", 486 + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", 487 + "cpu": [ 488 + "x64" 489 + ], 490 + "license": "Apache-2.0", 491 + "optional": true, 492 + "os": [ 493 + "darwin" 494 + ], 495 + "engines": { 496 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 497 + }, 498 + "funding": { 499 + "url": "https://opencollective.com/libvips" 500 + }, 501 + "optionalDependencies": { 502 + "@img/sharp-libvips-darwin-x64": "1.2.4" 503 + } 504 + }, 505 + "node_modules/@img/sharp-libvips-darwin-arm64": { 506 + "version": "1.2.4", 507 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", 508 + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", 509 + "cpu": [ 510 + "arm64" 511 + ], 512 + "license": "LGPL-3.0-or-later", 513 + "optional": true, 514 + "os": [ 515 + "darwin" 516 + ], 517 + "funding": { 518 + "url": "https://opencollective.com/libvips" 519 + } 520 + }, 521 + "node_modules/@img/sharp-libvips-darwin-x64": { 522 + "version": "1.2.4", 523 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", 524 + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", 525 + "cpu": [ 526 + "x64" 527 + ], 528 + "license": "LGPL-3.0-or-later", 529 + "optional": true, 530 + "os": [ 531 + "darwin" 532 + ], 533 + "funding": { 534 + "url": "https://opencollective.com/libvips" 535 + } 536 + }, 537 + "node_modules/@img/sharp-libvips-linux-arm": { 538 + "version": "1.2.4", 539 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", 540 + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", 541 + "cpu": [ 542 + "arm" 543 + ], 544 + "license": "LGPL-3.0-or-later", 545 + "optional": true, 546 + "os": [ 547 + "linux" 548 + ], 549 + "funding": { 550 + "url": "https://opencollective.com/libvips" 551 + } 552 + }, 553 + "node_modules/@img/sharp-libvips-linux-arm64": { 554 + "version": "1.2.4", 555 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", 556 + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", 557 + "cpu": [ 558 + "arm64" 559 + ], 560 + "license": "LGPL-3.0-or-later", 561 + "optional": true, 562 + "os": [ 563 + "linux" 564 + ], 565 + "funding": { 566 + "url": "https://opencollective.com/libvips" 567 + } 568 + }, 569 + "node_modules/@img/sharp-libvips-linux-ppc64": { 570 + "version": "1.2.4", 571 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", 572 + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", 573 + "cpu": [ 574 + "ppc64" 575 + ], 576 + "license": "LGPL-3.0-or-later", 577 + "optional": true, 578 + "os": [ 579 + "linux" 580 + ], 581 + "funding": { 582 + "url": "https://opencollective.com/libvips" 583 + } 584 + }, 585 + "node_modules/@img/sharp-libvips-linux-riscv64": { 586 + "version": "1.2.4", 587 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", 588 + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", 589 + "cpu": [ 590 + "riscv64" 591 + ], 592 + "license": "LGPL-3.0-or-later", 593 + "optional": true, 594 + "os": [ 595 + "linux" 596 + ], 597 + "funding": { 598 + "url": "https://opencollective.com/libvips" 599 + } 600 + }, 601 + "node_modules/@img/sharp-libvips-linux-s390x": { 602 + "version": "1.2.4", 603 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", 604 + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", 605 + "cpu": [ 606 + "s390x" 607 + ], 608 + "license": "LGPL-3.0-or-later", 609 + "optional": true, 610 + "os": [ 611 + "linux" 612 + ], 613 + "funding": { 614 + "url": "https://opencollective.com/libvips" 615 + } 616 + }, 617 + "node_modules/@img/sharp-libvips-linux-x64": { 618 + "version": "1.2.4", 619 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", 620 + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", 621 + "cpu": [ 622 + "x64" 623 + ], 624 + "license": "LGPL-3.0-or-later", 625 + "optional": true, 626 + "os": [ 627 + "linux" 628 + ], 629 + "funding": { 630 + "url": "https://opencollective.com/libvips" 631 + } 632 + }, 633 + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { 634 + "version": "1.2.4", 635 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", 636 + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", 637 + "cpu": [ 638 + "arm64" 639 + ], 640 + "license": "LGPL-3.0-or-later", 641 + "optional": true, 642 + "os": [ 643 + "linux" 644 + ], 645 + "funding": { 646 + "url": "https://opencollective.com/libvips" 647 + } 648 + }, 649 + "node_modules/@img/sharp-libvips-linuxmusl-x64": { 650 + "version": "1.2.4", 651 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", 652 + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", 653 + "cpu": [ 654 + "x64" 655 + ], 656 + "license": "LGPL-3.0-or-later", 657 + "optional": true, 658 + "os": [ 659 + "linux" 660 + ], 661 + "funding": { 662 + "url": "https://opencollective.com/libvips" 663 + } 664 + }, 665 + "node_modules/@img/sharp-linux-arm": { 666 + "version": "0.34.5", 667 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", 668 + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", 669 + "cpu": [ 670 + "arm" 671 + ], 672 + "license": "Apache-2.0", 673 + "optional": true, 674 + "os": [ 675 + "linux" 676 + ], 677 + "engines": { 678 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 679 + }, 680 + "funding": { 681 + "url": "https://opencollective.com/libvips" 682 + }, 683 + "optionalDependencies": { 684 + "@img/sharp-libvips-linux-arm": "1.2.4" 685 + } 686 + }, 687 + "node_modules/@img/sharp-linux-arm64": { 688 + "version": "0.34.5", 689 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", 690 + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", 691 + "cpu": [ 692 + "arm64" 693 + ], 694 + "license": "Apache-2.0", 695 + "optional": true, 696 + "os": [ 697 + "linux" 698 + ], 699 + "engines": { 700 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 701 + }, 702 + "funding": { 703 + "url": "https://opencollective.com/libvips" 704 + }, 705 + "optionalDependencies": { 706 + "@img/sharp-libvips-linux-arm64": "1.2.4" 707 + } 708 + }, 709 + "node_modules/@img/sharp-linux-ppc64": { 710 + "version": "0.34.5", 711 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", 712 + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", 713 + "cpu": [ 714 + "ppc64" 715 + ], 716 + "license": "Apache-2.0", 717 + "optional": true, 718 + "os": [ 719 + "linux" 720 + ], 721 + "engines": { 722 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 723 + }, 724 + "funding": { 725 + "url": "https://opencollective.com/libvips" 726 + }, 727 + "optionalDependencies": { 728 + "@img/sharp-libvips-linux-ppc64": "1.2.4" 729 + } 730 + }, 731 + "node_modules/@img/sharp-linux-riscv64": { 732 + "version": "0.34.5", 733 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", 734 + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", 735 + "cpu": [ 736 + "riscv64" 737 + ], 738 + "license": "Apache-2.0", 739 + "optional": true, 740 + "os": [ 741 + "linux" 742 + ], 743 + "engines": { 744 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 745 + }, 746 + "funding": { 747 + "url": "https://opencollective.com/libvips" 748 + }, 749 + "optionalDependencies": { 750 + "@img/sharp-libvips-linux-riscv64": "1.2.4" 751 + } 752 + }, 753 + "node_modules/@img/sharp-linux-s390x": { 754 + "version": "0.34.5", 755 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", 756 + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", 757 + "cpu": [ 758 + "s390x" 759 + ], 760 + "license": "Apache-2.0", 761 + "optional": true, 762 + "os": [ 763 + "linux" 764 + ], 765 + "engines": { 766 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 767 + }, 768 + "funding": { 769 + "url": "https://opencollective.com/libvips" 770 + }, 771 + "optionalDependencies": { 772 + "@img/sharp-libvips-linux-s390x": "1.2.4" 773 + } 774 + }, 775 + "node_modules/@img/sharp-linux-x64": { 776 + "version": "0.34.5", 777 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", 778 + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", 779 + "cpu": [ 780 + "x64" 781 + ], 782 + "license": "Apache-2.0", 783 + "optional": true, 784 + "os": [ 785 + "linux" 786 + ], 787 + "engines": { 788 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 789 + }, 790 + "funding": { 791 + "url": "https://opencollective.com/libvips" 792 + }, 793 + "optionalDependencies": { 794 + "@img/sharp-libvips-linux-x64": "1.2.4" 795 + } 796 + }, 797 + "node_modules/@img/sharp-linuxmusl-arm64": { 798 + "version": "0.34.5", 799 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", 800 + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", 801 + "cpu": [ 802 + "arm64" 803 + ], 804 + "license": "Apache-2.0", 805 + "optional": true, 806 + "os": [ 807 + "linux" 808 + ], 809 + "engines": { 810 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 811 + }, 812 + "funding": { 813 + "url": "https://opencollective.com/libvips" 814 + }, 815 + "optionalDependencies": { 816 + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" 817 + } 818 + }, 819 + "node_modules/@img/sharp-linuxmusl-x64": { 820 + "version": "0.34.5", 821 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", 822 + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", 823 + "cpu": [ 824 + "x64" 825 + ], 826 + "license": "Apache-2.0", 827 + "optional": true, 828 + "os": [ 829 + "linux" 830 + ], 831 + "engines": { 832 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 833 + }, 834 + "funding": { 835 + "url": "https://opencollective.com/libvips" 836 + }, 837 + "optionalDependencies": { 838 + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" 839 + } 840 + }, 841 + "node_modules/@img/sharp-wasm32": { 842 + "version": "0.34.5", 843 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", 844 + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", 845 + "cpu": [ 846 + "wasm32" 847 + ], 848 + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", 849 + "optional": true, 850 + "dependencies": { 851 + "@emnapi/runtime": "^1.7.0" 852 + }, 853 + "engines": { 854 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 855 + }, 856 + "funding": { 857 + "url": "https://opencollective.com/libvips" 858 + } 859 + }, 860 + "node_modules/@img/sharp-win32-arm64": { 861 + "version": "0.34.5", 862 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", 863 + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", 864 + "cpu": [ 865 + "arm64" 866 + ], 867 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 868 + "optional": true, 869 + "os": [ 870 + "win32" 871 + ], 872 + "engines": { 873 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 874 + }, 875 + "funding": { 876 + "url": "https://opencollective.com/libvips" 877 + } 878 + }, 879 + "node_modules/@img/sharp-win32-ia32": { 880 + "version": "0.34.5", 881 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", 882 + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", 883 + "cpu": [ 884 + "ia32" 885 + ], 886 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 887 + "optional": true, 888 + "os": [ 889 + "win32" 890 + ], 891 + "engines": { 892 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 893 + }, 894 + "funding": { 895 + "url": "https://opencollective.com/libvips" 896 + } 897 + }, 898 + "node_modules/@img/sharp-win32-x64": { 899 + "version": "0.34.5", 900 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", 901 + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", 902 + "cpu": [ 903 + "x64" 904 + ], 905 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 906 + "optional": true, 907 + "os": [ 908 + "win32" 909 + ], 910 + "engines": { 911 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 912 + }, 913 + "funding": { 914 + "url": "https://opencollective.com/libvips" 915 + } 916 + }, 917 + "node_modules/@sapphire/async-queue": { 918 + "version": "1.5.5", 919 + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", 920 + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", 921 + "license": "MIT", 922 + "engines": { 923 + "node": ">=v14.0.0", 924 + "npm": ">=7.0.0" 925 + } 926 + }, 927 + "node_modules/@sapphire/shapeshift": { 928 + "version": "4.0.0", 929 + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", 930 + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", 931 + "license": "MIT", 932 + "dependencies": { 933 + "fast-deep-equal": "^3.1.3", 934 + "lodash": "^4.17.21" 935 + }, 936 + "engines": { 937 + "node": ">=v16" 938 + } 939 + }, 940 + "node_modules/@sapphire/snowflake": { 941 + "version": "3.5.3", 942 + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", 943 + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", 944 + "license": "MIT", 945 + "engines": { 946 + "node": ">=v14.0.0", 947 + "npm": ">=7.0.0" 948 + } 949 + }, 300 950 "node_modules/@types/node": { 301 951 "version": "10.17.60", 302 952 "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", 303 953 "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", 304 954 "license": "MIT" 955 + }, 956 + "node_modules/@types/ws": { 957 + "version": "8.18.1", 958 + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", 959 + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", 960 + "license": "MIT", 961 + "dependencies": { 962 + "@types/node": "*" 963 + } 964 + }, 965 + "node_modules/@vladfrangu/async_event_emitter": { 966 + "version": "2.4.7", 967 + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", 968 + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", 969 + "license": "MIT", 970 + "engines": { 971 + "node": ">=v14.0.0", 972 + "npm": ">=7.0.0" 973 + } 305 974 }, 306 975 "node_modules/agent-base": { 307 976 "version": "6.0.2", ··· 1000 1669 "license": "Apache-2.0", 1001 1670 "engines": { 1002 1671 "node": ">=8" 1672 + } 1673 + }, 1674 + "node_modules/discord-api-types": { 1675 + "version": "0.38.45", 1676 + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.45.tgz", 1677 + "integrity": "sha512-DiI01i00FPv6n+hXcFkFxK8Y/rFRpKs6U6aP32N4T73nTbj37Eua3H/95TBpLktLWB6xnLXhYDGvyLq6zzYY2w==", 1678 + "license": "MIT", 1679 + "workspaces": [ 1680 + "scripts/actions/documentation" 1681 + ] 1682 + }, 1683 + "node_modules/discord.js": { 1684 + "version": "14.26.2", 1685 + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.2.tgz", 1686 + "integrity": "sha512-feShi+gULJ6R2MAA4/KkCFnkJcuVrROJrKk4czplzq8gE1oqhqgOy9K0Scu44B8oGeWKe04egquzf+ia6VtXAw==", 1687 + "license": "Apache-2.0", 1688 + "dependencies": { 1689 + "@discordjs/builders": "^1.14.1", 1690 + "@discordjs/collection": "1.5.3", 1691 + "@discordjs/formatters": "^0.6.2", 1692 + "@discordjs/rest": "^2.6.1", 1693 + "@discordjs/util": "^1.2.0", 1694 + "@discordjs/ws": "^1.2.3", 1695 + "@sapphire/snowflake": "3.5.3", 1696 + "discord-api-types": "^0.38.40", 1697 + "fast-deep-equal": "3.1.3", 1698 + "lodash.snakecase": "4.1.1", 1699 + "magic-bytes.js": "^1.13.0", 1700 + "tslib": "^2.6.3", 1701 + "undici": "6.24.1" 1702 + }, 1703 + "engines": { 1704 + "node": ">=18" 1705 + }, 1706 + "funding": { 1707 + "url": "https://github.com/discordjs/discord.js?sponsor" 1003 1708 } 1004 1709 }, 1005 1710 "node_modules/dom-serializer": { ··· 2288 2993 "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", 2289 2994 "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 2290 2995 }, 2996 + "node_modules/lodash.snakecase": { 2997 + "version": "4.1.1", 2998 + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", 2999 + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", 3000 + "license": "MIT" 3001 + }, 3002 + "node_modules/magic-bytes.js": { 3003 + "version": "1.13.0", 3004 + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", 3005 + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", 3006 + "license": "MIT" 3007 + }, 2291 3008 "node_modules/math-intrinsics": { 2292 3009 "version": "1.1.0", 2293 3010 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", ··· 2974 3691 "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 2975 3692 }, 2976 3693 "node_modules/semver": { 2977 - "version": "7.7.1", 2978 - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", 2979 - "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", 3694 + "version": "7.7.4", 3695 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 3696 + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 3697 + "license": "ISC", 2980 3698 "bin": { 2981 3699 "semver": "bin/semver.js" 2982 3700 }, ··· 3027 3745 "node": ">= 0.4" 3028 3746 } 3029 3747 }, 3748 + "node_modules/sharp": { 3749 + "version": "0.34.5", 3750 + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", 3751 + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", 3752 + "hasInstallScript": true, 3753 + "license": "Apache-2.0", 3754 + "dependencies": { 3755 + "@img/colour": "^1.0.0", 3756 + "detect-libc": "^2.1.2", 3757 + "semver": "^7.7.3" 3758 + }, 3759 + "engines": { 3760 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 3761 + }, 3762 + "funding": { 3763 + "url": "https://opencollective.com/libvips" 3764 + }, 3765 + "optionalDependencies": { 3766 + "@img/sharp-darwin-arm64": "0.34.5", 3767 + "@img/sharp-darwin-x64": "0.34.5", 3768 + "@img/sharp-libvips-darwin-arm64": "1.2.4", 3769 + "@img/sharp-libvips-darwin-x64": "1.2.4", 3770 + "@img/sharp-libvips-linux-arm": "1.2.4", 3771 + "@img/sharp-libvips-linux-arm64": "1.2.4", 3772 + "@img/sharp-libvips-linux-ppc64": "1.2.4", 3773 + "@img/sharp-libvips-linux-riscv64": "1.2.4", 3774 + "@img/sharp-libvips-linux-s390x": "1.2.4", 3775 + "@img/sharp-libvips-linux-x64": "1.2.4", 3776 + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", 3777 + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", 3778 + "@img/sharp-linux-arm": "0.34.5", 3779 + "@img/sharp-linux-arm64": "0.34.5", 3780 + "@img/sharp-linux-ppc64": "0.34.5", 3781 + "@img/sharp-linux-riscv64": "0.34.5", 3782 + "@img/sharp-linux-s390x": "0.34.5", 3783 + "@img/sharp-linux-x64": "0.34.5", 3784 + "@img/sharp-linuxmusl-arm64": "0.34.5", 3785 + "@img/sharp-linuxmusl-x64": "0.34.5", 3786 + "@img/sharp-wasm32": "0.34.5", 3787 + "@img/sharp-win32-arm64": "0.34.5", 3788 + "@img/sharp-win32-ia32": "0.34.5", 3789 + "@img/sharp-win32-x64": "0.34.5" 3790 + } 3791 + }, 3030 3792 "node_modules/side-channel": { 3031 3793 "version": "1.1.0", 3032 3794 "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", ··· 3381 4143 "node": ">=0.8" 3382 4144 } 3383 4145 }, 4146 + "node_modules/ts-mixer": { 4147 + "version": "6.0.4", 4148 + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", 4149 + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", 4150 + "license": "MIT" 4151 + }, 4152 + "node_modules/tslib": { 4153 + "version": "2.8.1", 4154 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 4155 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 4156 + "license": "0BSD" 4157 + }, 3384 4158 "node_modules/tunnel-agent": { 3385 4159 "version": "0.6.0", 3386 4160 "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", ··· 3505 4279 "dev": true 3506 4280 }, 3507 4281 "node_modules/undici": { 3508 - "version": "6.21.2", 3509 - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.2.tgz", 3510 - "integrity": "sha512-uROZWze0R0itiAKVPsYhFov9LxrPMHLMEQFszeI2gCN6bnIIZ8twzBCJcN2LJrBBLfrP0t1FW0g+JmKVl8Vk1g==", 4282 + "version": "6.24.1", 4283 + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", 4284 + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", 4285 + "license": "MIT", 3511 4286 "engines": { 3512 4287 "node": ">=18.17" 3513 4288 } ··· 3680 4455 "version": "1.0.2", 3681 4456 "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 3682 4457 "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" 4458 + }, 4459 + "node_modules/ws": { 4460 + "version": "8.20.0", 4461 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", 4462 + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", 4463 + "license": "MIT", 4464 + "engines": { 4465 + "node": ">=10.0.0" 4466 + }, 4467 + "peerDependencies": { 4468 + "bufferutil": "^4.0.1", 4469 + "utf-8-validate": ">=5.0.2" 4470 + }, 4471 + "peerDependenciesMeta": { 4472 + "bufferutil": { 4473 + "optional": true 4474 + }, 4475 + "utf-8-validate": { 4476 + "optional": true 4477 + } 4478 + } 3683 4479 }, 3684 4480 "node_modules/zod": { 3685 4481 "version": "3.24.2",
+3 -1
package.json
··· 30 30 "better-sqlite3": "^12.6.2", 31 31 "cheerio": "^1.0.0-rc.12", 32 32 "crypto-js": "^4.2.0", 33 + "discord.js": "^14.16.3", 33 34 "dotenv": "^16.0.3", 34 35 "ffmpeg-static": "^5.2.0", 35 36 "fluent-ffmpeg": "^2.1.3", ··· 37 38 "fs-extra": "^11.1.1", 38 39 "imghash": "^1.1.2", 39 40 "node-cron": "^3.0.2", 40 - "node-telegram-bot-api": "^0.61.0" 41 + "node-telegram-bot-api": "^0.61.0", 42 + "sharp": "^0.34.5" 41 43 }, 42 44 "devDependencies": { 43 45 "nodemon": "^3.1.9"
+4 -4
queue/alert-state.json
··· 1 1 { 2 - "lowQueueAlertSent": true, 2 + "lowQueueAlertSent": false, 3 3 "emptyQueueAlertSent": false, 4 - "lastLowQueueAlertTime": 1751412078653, 5 - "lastEmptyQueueAlertTime": 1768772195476, 6 - "lastSaved": 1768775980826 4 + "lastLowQueueAlertTime": 0, 5 + "lastEmptyQueueAlertTime": 1776070938912, 6 + "lastSaved": 1776071208912 7 7 }
+1 -1
queue/queueManager.js
··· 42 42 // Log queue status on startup 43 43 const queueLength = this.queueData.queue.length; 44 44 const shuffleStatus = this.shuffleMode ? 'enabled' : 'disabled'; 45 - console.log(`Queue loaded with ${queueLength} item${queueLength !== 1 ? 's' : ''}, shuffle mode ${shuffleStatus}`); 45 + console.log(`[QueueManager] Queue loaded with ${queueLength} item${queueLength !== 1 ? 's' : ''}, shuffle mode ${shuffleStatus}`); 46 46 47 47 // Initialize service tracking for any existing items that don't have it 48 48 this.initializeServiceTracking();
+29 -13
scrapers/blueskyScraper.js
··· 269 269 270 270 // Calculate perceptual hash 271 271 const perceptualHash = await mediaCache.imageHashManager.calculateHash(tempFilePath); 272 + const contentHash = await mediaCache.imageHashManager.calculateContentHash(tempFilePath); 272 273 console.log(`[ImageHash] Calculated perceptual hash: ${perceptualHash}`); 273 274 274 275 // Create filename from perceptual hash 275 276 const hashFilename = `${perceptualHash}${fileExt}`; 276 - finalFilePath = path.join(mediaCache.imageDir, hashFilename); 277 - 277 + const canonicalPath = path.join(mediaCache.imageDir, hashFilename); 278 + finalFilePath = canonicalPath; 279 + 280 + const hashMatches = mediaCache.imageHashManager.getHashesByHash(perceptualHash); 281 + const exactMatch = hashMatches.find(record => 282 + record.content_hash && record.content_hash === contentHash 283 + ); 284 + 278 285 // Check if file with this hash already exists 279 286 const fs = require('fs-extra'); 280 - if (await fs.pathExists(finalFilePath)) { 281 - console.log(`[ImageHash] File with same perceptual hash already exists: ${hashFilename}`); 282 - // Delete temp file 287 + if (exactMatch && await fs.pathExists(exactMatch.file_path)) { 288 + console.log(`[ImageHash] Exact duplicate detected for perceptual hash: ${perceptualHash}`); 283 289 await fs.unlink(tempFilePath); 284 - 285 - // Add alternate URL if this is a different source 286 - const existing = mediaCache.imageHashManager.getHashByHash(perceptualHash); 287 - if (existing && existing.url !== postUrl) { 288 - console.log(`[ImageHash] Adding alternate URL for existing hash`); 289 - mediaCache.imageHashManager.addAlternateUrl(perceptualHash, postUrl); 290 + finalFilePath = exactMatch.file_path; 291 + 292 + if (exactMatch.url !== postUrl) { 293 + console.log('[ImageHash] Adding alternate URL for exact duplicate'); 294 + mediaCache.imageHashManager.addAlternateUrl(exactMatch.url, postUrl); 295 + } 296 + } else if (await fs.pathExists(canonicalPath)) { 297 + const variantFilename = `${perceptualHash}_${contentHash.substring(0, 12)}${fileExt}`; 298 + finalFilePath = path.join(mediaCache.imageDir, variantFilename); 299 + 300 + if (await fs.pathExists(finalFilePath)) { 301 + await fs.unlink(tempFilePath); 302 + } else { 303 + await fs.rename(tempFilePath, finalFilePath); 304 + console.log(`[ImageHash] Perceptual collision detected, saved variant: ${variantFilename}`); 290 305 } 291 306 } else { 292 307 // Rename temp file to final hash-based name 293 - await fs.rename(tempFilePath, finalFilePath); 308 + await fs.rename(tempFilePath, canonicalPath); 309 + finalFilePath = canonicalPath; 294 310 console.log(`[ImageHash] Renamed to: ${hashFilename}`); 295 311 } 296 312 ··· 300 316 cid: cid, 301 317 did: did, 302 318 downloadedAt: new Date().toISOString() 303 - }); 319 + }, contentHash); 304 320 console.log('[ImageHash] Successfully stored hash in database'); 305 321 } catch (hashError) { 306 322 console.error('[ImageHash] Failed to hash Bluesky image:', hashError);
+65 -47
scrapers/e621Scraper.js
··· 1 1 const axios = require('axios'); 2 - const cheerio = require('cheerio'); 3 2 const BaseScraper = require('./baseScraper'); 4 3 const mediaCache = require('../utils/mediaCache'); 5 4 const config = require('../config'); ··· 10 9 return e621Pattern.test(url); 11 10 } 12 11 12 + /** 13 + * Extract post ID from e621 URL 14 + * Supports both /posts/{id} and legacy /post/show/{id} formats 15 + */ 16 + extractPostId(url) { 17 + // Try modern format: https://e621.net/posts/12345 18 + let match = url.match(/\/posts\/(\d+)/); 19 + if (match) { 20 + return match[1]; 21 + } 22 + 23 + // Try legacy format: https://e621.net/post/show/12345 24 + match = url.match(/\/post\/show\/(\d+)/); 25 + if (match) { 26 + return match[1]; 27 + } 28 + 29 + throw new Error('Could not extract post ID from e621 URL'); 30 + } 31 + 13 32 async extract(url) { 14 33 try { 15 - // Use a common user agent to avoid being blocked 34 + // Extract post ID from URL 35 + const postId = this.extractPostId(url); 36 + 37 + // e621 API requires a descriptive User-Agent for identification 16 38 const headers = { 17 - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' 39 + 'User-Agent': 'Stagehand/1.1.0 (e621-bot)' 18 40 }; 19 41 20 - const response = await axios.get(url, { headers }); 21 - const $ = cheerio.load(response.data); 22 - 23 - // Check for download link - for both images and videos 24 - let mediaUrl = $('#image-download-link').attr('href') || 25 - $('a[download]').attr('href') || 26 - $('#image').attr('src'); 27 - 28 - // Extract OpenGraph data 29 - const ogImage = $('meta[property="og:image"]').attr('content'); 30 - const ogUrl = $('meta[property="og:url"]').attr('content') || url; 31 - 32 - // Find potential video sources - only from actual video elements 33 - let videoUrl = $('video source').attr('src') || 34 - $('video').attr('src'); 42 + // Call e621 API to get post data 43 + const apiUrl = `https://e621.net/posts/${postId}.json`; 44 + const response = await axios.get(apiUrl, { headers }); 35 45 36 - // Use OpenGraph image if direct media URL not found 37 - if (!mediaUrl && ogImage) { 38 - mediaUrl = ogImage; 46 + if (!response.data || !response.data.post) { 47 + throw new Error('Invalid API response from e621'); 39 48 } 40 49 41 - // Determine if we're dealing with a video post 42 - // Only consider it a video if we found an actual video element OR the URL has video extension 43 - const isVideo = (videoUrl && this.isVideoUrl(videoUrl)) || this.isVideoUrl(mediaUrl); 50 + const post = response.data.post; 44 51 45 - // If it's a video, use the direct video URL 46 - if (isVideo && videoUrl) { 47 - mediaUrl = videoUrl; 52 + // Check if post has been deleted or is unavailable 53 + if (!post.file || !post.file.url) { 54 + // Try fallback to sample if available 55 + if (post.sample && post.sample.url) { 56 + console.warn(`Post ${postId}: Full file URL not available, using sample`); 57 + } else { 58 + throw new Error('Post media is not available (possibly deleted or restricted)'); 59 + } 48 60 } 49 61 50 - if (!mediaUrl) { 51 - throw new Error('Could not find media on e621 page'); 52 - } 62 + // Get the full-size media URL (preferred) or sample as fallback 63 + const mediaUrl = post.file.url || post.sample.url; 64 + const fileExt = post.file.ext; 53 65 54 - // Make sure URLs are absolute 55 - if (!mediaUrl.startsWith('http')) { 56 - if (mediaUrl.startsWith('//')) { 57 - mediaUrl = 'https:' + mediaUrl; 58 - } else if (mediaUrl.startsWith('/')) { 59 - mediaUrl = 'https://e621.net' + mediaUrl; 60 - } 66 + if (!mediaUrl) { 67 + throw new Error('Could not find media URL in API response'); 61 68 } 62 69 63 - // Store the original source URL before processing 64 - const sourceImageUrl = mediaUrl; 70 + // Determine if this is a video based on file extension 71 + const videoExtensions = ['webm', 'mp4', 'mov']; 72 + const isVideo = videoExtensions.includes(fileExt); 65 73 66 74 // Process and cache the media, passing the original post URL 67 75 const processed = await mediaCache.processMediaUrl(mediaUrl, isVideo, url); ··· 72 80 imageUrl: processed.localPath, 73 81 videoUrl: processed.localPath, 74 82 isVideo: true, 75 - sourceUrl: ogUrl || url, 76 - title: "e621 Video", // Generic title without post-specific text 83 + sourceUrl: url, 84 + title: "e621 Video", 77 85 siteName: 'e621', 78 - originalVideoUrl: sourceImageUrl, 79 - sourceImgUrl: sourceImageUrl // Add the sourceImgUrl field 86 + originalVideoUrl: mediaUrl, 87 + sourceImgUrl: mediaUrl 80 88 }; 81 89 } else { 82 90 return { 83 91 imageUrl: processed.localPath, 84 - sourceUrl: ogUrl || url, 85 - title: "e621 Image", // Generic title without post-specific text 92 + sourceUrl: url, 93 + title: "e621 Image", 86 94 siteName: 'e621', 87 95 isVideo: false, 88 - originalImageUrl: sourceImageUrl, 89 - sourceImgUrl: sourceImageUrl // Add the sourceImgUrl field 96 + originalImageUrl: mediaUrl, 97 + sourceImgUrl: mediaUrl 90 98 }; 91 99 } 92 100 } catch (error) { 101 + // Provide more specific error messages 102 + if (error.response) { 103 + if (error.response.status === 404) { 104 + throw new Error(`e621 post not found (may be deleted or invalid ID)`); 105 + } else if (error.response.status === 429) { 106 + throw new Error(`e621 API rate limit exceeded, please try again later`); 107 + } else { 108 + throw new Error(`e621 API error (${error.response.status}): ${error.message}`); 109 + } 110 + } 93 111 console.error('Error extracting data from e621:', error); 94 112 throw new Error(`Failed to extract data from e621: ${error.message}`); 95 113 }
+6 -6
utils/announcementManager.js
··· 29 29 await this.loadAnnouncementsFromDisk(); 30 30 this.scheduleAllAnnouncements(); 31 31 this.initialized = true; 32 - console.log('Announcement manager initialized'); 32 + console.log('[AnnouncementManager] Announcement manager initialized'); 33 33 } catch (error) { 34 - console.error('Error initializing announcement manager:', error); 34 + console.error('[AnnouncementManager] Error initializing announcement manager:', error); 35 35 // If loading fails, start with empty announcements 36 36 this.announcements = []; 37 37 await this.saveAnnouncementsToDisk(); ··· 73 73 // Save migrated data if needed 74 74 if (migrationNeeded) { 75 75 await this.saveAnnouncementsToDisk(); 76 - console.log('Migrated announcement button format to support multiple buttons'); 76 + console.log('[AnnouncementManager] Migrated announcement button format to support multiple buttons'); 77 77 } 78 78 79 - console.log(`Loaded ${this.announcements.length} announcements from disk`); 79 + console.log(`[AnnouncementManager] Loaded ${this.announcements.length} announcements from disk`); 80 80 } catch (error) { 81 - console.error('Error loading announcements from disk:', error); 81 + console.error('[AnnouncementManager] Error loading announcements from disk:', error); 82 82 throw error; 83 83 } 84 84 } ··· 347 347 this.scheduleAnnouncement(announcement); 348 348 }); 349 349 350 - console.log(`Scheduled ${this.announcements.length} announcements`); 350 + console.log(`[AnnouncementManager] Scheduled ${this.announcements.length} announcements`); 351 351 } 352 352 353 353 /**
+126 -16
utils/imageHashManager.js
··· 1 1 const fs = require('fs-extra'); 2 2 const path = require('path'); 3 + const crypto = require('crypto'); 3 4 const Database = require('better-sqlite3'); 4 5 const imghash = require('imghash'); 5 6 const config = require('../config'); ··· 13 14 // Database path 14 15 const dbDir = path.join(__dirname, '..', 'data'); 15 16 this.dbPath = path.join(dbDir, 'image_hashes.db'); 17 + this.perceptualHashBits = this.getPerceptualHashBits(); 16 18 17 19 // Initialize database 18 20 this.initDatabase(); 19 21 } 20 22 21 23 /** 24 + * Resolve configured perceptual hash bit length 25 + * @returns {number} 26 + */ 27 + getPerceptualHashBits() { 28 + const configuredBits = config.imageHash && Number.isInteger(config.imageHash.perceptualHashBits) 29 + ? config.imageHash.perceptualHashBits 30 + : 16; 31 + 32 + // Keep this conservative: even number and within a practical range for imghash. 33 + if (configuredBits < 8 || configuredBits > 32 || configuredBits % 2 !== 0) { 34 + return 16; 35 + } 36 + 37 + return configuredBits; 38 + } 39 + 40 + /** 22 41 * Initialize the SQLite database and create tables if they don't exist 23 42 */ 24 43 initDatabase() { ··· 35 54 id INTEGER PRIMARY KEY AUTOINCREMENT, 36 55 url TEXT NOT NULL, 37 56 perceptual_hash TEXT NOT NULL, 57 + content_hash TEXT, 38 58 file_path TEXT, 39 59 cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 40 60 metadata TEXT, ··· 45 65 CREATE INDEX IF NOT EXISTS idx_perceptual_hash ON image_hashes(perceptual_hash); 46 66 CREATE INDEX IF NOT EXISTS idx_url ON image_hashes(url); 47 67 `); 68 + 69 + // Backward-compat: add content_hash when upgrading existing databases. 70 + const columns = this.db.prepare('PRAGMA table_info(image_hashes)').all(); 71 + const hasContentHash = columns.some(col => col.name === 'content_hash'); 72 + if (!hasContentHash) { 73 + this.db.exec('ALTER TABLE image_hashes ADD COLUMN content_hash TEXT'); 74 + } 75 + 76 + // Create this after migration check to avoid failures on older DBs. 77 + this.db.exec('CREATE INDEX IF NOT EXISTS idx_content_hash ON image_hashes(content_hash)'); 48 78 49 - console.log('Image hash database initialized'); 79 + console.log('[ImageHash] Image hash database initialized'); 50 80 } catch (error) { 51 - console.error('Error initializing image hash database:', error); 81 + console.error('[ImageHash] Error initializing image hash database:', error); 52 82 throw error; 53 83 } 54 84 } ··· 62 92 try { 63 93 // Use imghash to generate a perceptual hash 64 94 // This creates a hash that is similar for similar-looking images 65 - const hash = await imghash.hash(filePath); 95 + const hash = await imghash.hash(filePath, this.perceptualHashBits, 'hex'); 66 96 return hash; 67 97 } catch (error) { 68 98 console.error(`Error calculating hash for ${filePath}:`, error); ··· 71 101 } 72 102 73 103 /** 104 + * Calculate strong content hash for exact image identity checks 105 + * @param {string} filePath - Path to image file 106 + * @returns {Promise<string>} - SHA256 hash string 107 + */ 108 + async calculateContentHash(filePath) { 109 + try { 110 + const hash = crypto.createHash('sha256'); 111 + 112 + return await new Promise((resolve, reject) => { 113 + const stream = fs.createReadStream(filePath); 114 + stream.on('error', reject); 115 + stream.on('data', chunk => hash.update(chunk)); 116 + stream.on('end', () => resolve(hash.digest('hex'))); 117 + }); 118 + } catch (error) { 119 + console.error(`Error calculating content hash for ${filePath}:`, error); 120 + throw error; 121 + } 122 + } 123 + 124 + /** 74 125 * Store image hash in the database 75 126 * @param {string} url - Original URL of the image 76 127 * @param {string} hash - Perceptual hash of the image ··· 78 129 * @param {Object} metadata - Optional metadata to store 79 130 * @returns {boolean} - Whether the operation was successful 80 131 */ 81 - storeHash(url, hash, filePath, metadata = null) { 132 + storeHash(url, hash, filePath, metadata = null, contentHash = null) { 82 133 try { 83 134 const stmt = this.db.prepare(` 84 - INSERT INTO image_hashes (url, perceptual_hash, file_path, metadata) 85 - VALUES (?, ?, ?, ?) 135 + INSERT INTO image_hashes (url, perceptual_hash, content_hash, file_path, metadata) 136 + VALUES (?, ?, ?, ?, ?) 86 137 ON CONFLICT(url) DO UPDATE SET 87 138 perceptual_hash = excluded.perceptual_hash, 139 + content_hash = excluded.content_hash, 88 140 file_path = excluded.file_path, 89 141 cached_at = CURRENT_TIMESTAMP, 90 142 metadata = excluded.metadata 91 143 `); 92 144 93 145 const metadataJson = metadata ? JSON.stringify(metadata) : null; 94 - stmt.run(url, hash, filePath, metadataJson); 146 + stmt.run(url, hash, contentHash, filePath, metadataJson); 95 147 96 148 console.log(`Stored hash for URL: ${url.substring(0, 50)}...`); 97 149 return true; ··· 117 169 118 170 // Calculate perceptual hash 119 171 const hash = await this.calculateHash(filePath); 172 + const contentHash = await this.calculateContentHash(filePath); 120 173 121 174 // Check if this URL already exists 122 175 const existingByUrl = this.getHashByUrl(url); ··· 125 178 return { hash, stored: true, isDuplicate: false }; 126 179 } 127 180 128 - // Check if this hash already exists with a different URL 129 - const existingByHash = this.getHashByHash(hash); 130 - if (existingByHash) { 181 + // Exact duplicate check: only treat as duplicate when binary content matches. 182 + const existingByContentHash = this.getHashByContentHash(contentHash); 183 + if (existingByContentHash) { 131 184 // Same image, different URL - add as alternate URL 132 - console.log(`Found duplicate image with hash ${hash}, adding alternate URL`); 133 - const added = this.addAlternateUrl(existingByHash.url, url); 134 - return { hash, stored: added, isDuplicate: true, primaryUrl: existingByHash.url }; 185 + console.log(`Found exact duplicate image with content hash ${contentHash.substring(0, 12)}..., adding alternate URL`); 186 + const added = this.addAlternateUrl(existingByContentHash.url, url); 187 + return { hash, stored: added, isDuplicate: true, primaryUrl: existingByContentHash.url }; 135 188 } 136 189 137 190 // New image - store in database 138 - const stored = this.storeHash(url, hash, filePath, metadata); 191 + const stored = this.storeHash(url, hash, filePath, metadata, contentHash); 139 192 140 193 return { hash, stored, isDuplicate: false }; 141 194 } catch (error) { ··· 198 251 return result; 199 252 } catch (error) { 200 253 console.error('Error getting hash by hash value:', error); 254 + return null; 255 + } 256 + } 257 + 258 + /** 259 + * Get all records by perceptual hash value 260 + * @param {string} hash - Perceptual hash to look up 261 + * @returns {Array} - Hash records (possibly empty) 262 + */ 263 + getHashesByHash(hash) { 264 + try { 265 + const stmt = this.db.prepare(` 266 + SELECT * FROM image_hashes WHERE perceptual_hash = ? ORDER BY cached_at DESC 267 + `); 268 + 269 + const results = stmt.all(hash); 270 + 271 + return results.map(result => { 272 + if (result.metadata) { 273 + result.metadata = JSON.parse(result.metadata); 274 + } 275 + if (result.alternate_urls) { 276 + result.alternate_urls = JSON.parse(result.alternate_urls); 277 + } 278 + return result; 279 + }); 280 + } catch (error) { 281 + console.error('Error getting hashes by hash value:', error); 282 + return []; 283 + } 284 + } 285 + 286 + /** 287 + * Get hash record by content hash value 288 + * @param {string} contentHash - SHA256 content hash to look up 289 + * @returns {Object|null} - Hash data or null if not found 290 + */ 291 + getHashByContentHash(contentHash) { 292 + try { 293 + const stmt = this.db.prepare(` 294 + SELECT * FROM image_hashes WHERE content_hash = ? 295 + `); 296 + 297 + const result = stmt.get(contentHash); 298 + 299 + if (result) { 300 + if (result.metadata) { 301 + result.metadata = JSON.parse(result.metadata); 302 + } 303 + if (result.alternate_urls) { 304 + result.alternate_urls = JSON.parse(result.alternate_urls); 305 + } 306 + } 307 + 308 + return result; 309 + } catch (error) { 310 + console.error('Error getting hash by content hash value:', error); 201 311 return null; 202 312 } 203 313 } ··· 364 474 } 365 475 } 366 476 367 - console.log(`Cleaned up ${cleaned} orphaned hash records`); 477 + console.log(`[ImageHash] Cleaned up ${cleaned} orphaned hash records`); 368 478 return cleaned; 369 479 } catch (error) { 370 - console.error('Error cleaning up orphaned hashes:', error); 480 + console.error('[ImageHash] Error cleaning up orphaned hashes:', error); 371 481 return 0; 372 482 } 373 483 }
+265 -35
utils/mediaCache.js
··· 4 4 const crypto = require('crypto-js'); 5 5 const ffmpeg = require('fluent-ffmpeg'); 6 6 const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path; 7 + const sharp = require('sharp'); 7 8 const config = require('../config'); 8 9 const ImageHashManager = require('./imageHashManager'); 9 10 ··· 20 21 21 22 // Maximum cache age in days (15 days by default) 22 23 this.maxCacheAgeDays = config.maxCacheAgeDays || 15; 24 + 25 + // Retry ImageHashManager init periodically so recache can recover without restart. 26 + this.imageHashManager = null; 27 + this.imageHashLastRetryAt = 0; 28 + this.imageHashRetryIntervalMs = 60 * 1000; 23 29 24 30 // Initialize image hash manager 25 - try { 26 - this.imageHashManager = new ImageHashManager(); 27 - } catch (error) { 28 - console.error('Failed to initialize ImageHashManager:', error); 29 - this.imageHashManager = null; 30 - } 31 + this.initializeImageHashManager(); 31 32 32 33 // Initialize cache directories 33 34 this.initCacheDirs(); ··· 37 38 } 38 39 39 40 /** 41 + * Initialize ImageHashManager and report result 42 + * @returns {boolean} - Whether initialization succeeded 43 + */ 44 + initializeImageHashManager() { 45 + try { 46 + this.imageHashManager = new ImageHashManager(); 47 + console.log('[ImageHash] Hash manager initialized'); 48 + return true; 49 + } catch (error) { 50 + console.error('[ImageHash] Failed to initialize ImageHashManager:', error.message || error); 51 + this.imageHashManager = null; 52 + return false; 53 + } 54 + } 55 + 56 + /** 57 + * Ensure the hash manager is available. Retries init on a cooldown. 58 + * @returns {boolean} - Whether manager is available 59 + */ 60 + ensureImageHashManager() { 61 + if (this.imageHashManager) { 62 + return true; 63 + } 64 + 65 + const now = Date.now(); 66 + if (now - this.imageHashLastRetryAt < this.imageHashRetryIntervalMs) { 67 + return false; 68 + } 69 + 70 + this.imageHashLastRetryAt = now; 71 + console.log('[ImageHash] Attempting to reinitialize hash manager...'); 72 + return this.initializeImageHashManager(); 73 + } 74 + 75 + /** 40 76 * Initialize cache directories 41 77 */ 42 78 async initCacheDirs() { ··· 45 81 await fs.ensureDir(this.imageDir); 46 82 await fs.ensureDir(this.videoDir); 47 83 await fs.ensureDir(this.transcodedDir); 48 - console.log('Cache directories initialized'); 84 + console.log('[MediaCache] Cache directories initialized'); 49 85 } catch (error) { 50 - console.error('Error initializing cache directories:', error); 86 + console.error('[MediaCache] Error initializing cache directories:', error); 51 87 } 52 88 } 53 89 ··· 59 95 const oneDayMs = 24 * 60 * 60 * 1000; 60 96 setInterval(() => { 61 97 this.cleanupCache().catch(err => { 62 - console.error('Error during cache cleanup:', err); 98 + console.error('[MediaCache] Error during cache cleanup:', err); 63 99 }); 64 100 }, oneDayMs); 65 101 66 102 // Also run cleanup on startup 67 103 this.cleanupCache().catch(err => { 68 - console.error('Error during initial cache cleanup:', err); 104 + console.error('[MediaCache] Error during initial cache cleanup:', err); 105 + }); 106 + 107 + // Also compress oversized images on startup 108 + this.compressOversizedCachedImages().catch(err => { 109 + console.error('[Cache Compression] Error during oversized image compression:', err); 69 110 }); 70 111 } 71 112 ··· 93 134 } 94 135 } 95 136 96 - console.log(`Found ${filesInUse.size} files currently in queue`); 137 + console.log(`[MediaCache] Found ${filesInUse.size} files currently in queue`); 97 138 return filesInUse; 98 139 } 99 140 } catch (error) { 100 - console.error('Error reading queue file for cleanup:', error); 141 + console.error('[MediaCache] Error reading queue file for cleanup:', error); 101 142 } 102 143 103 144 return new Set(); ··· 107 148 * Clean up old cache files 108 149 */ 109 150 async cleanupCache() { 110 - console.log('Starting cache cleanup...'); 151 + console.log('[MediaCache] Starting cache cleanup...'); 111 152 const now = Date.now(); 112 153 const maxAgeMs = this.maxCacheAgeDays * 24 * 60 * 60 * 1000; 113 154 ··· 135 176 // Check if file is older than max cache age 136 177 if (now - stats.mtimeMs > maxAgeMs) { 137 178 await fs.remove(filePath); 138 - console.log(`Removed old cache file: ${file}`); 179 + console.log(`[MediaCache] Removed old cache file: ${file}`); 139 180 } 140 181 } 141 182 142 183 if (skippedCount > 0) { 143 - console.log(`Skipped ${skippedCount} files in queue from ${path.basename(dir)}`); 184 + console.log(`[MediaCache] Skipped ${skippedCount} files in queue from ${path.basename(dir)}`); 144 185 } 145 186 } catch (error) { 146 - console.error(`Error cleaning directory ${dir}:`, error); 187 + console.error(`[MediaCache] Error cleaning directory ${dir}:`, error); 147 188 } 148 189 }; 149 190 ··· 153 194 await cleanDir(this.transcodedDir); 154 195 155 196 // Clean up orphaned hash records 197 + this.ensureImageHashManager(); 156 198 if (this.imageHashManager) { 157 199 try { 158 200 await this.imageHashManager.cleanupOrphanedHashes(); 159 201 } catch (error) { 160 - console.error('Error cleaning orphaned hashes:', error); 202 + console.error('[ImageHash] Error cleaning orphaned hashes:', error); 161 203 } 162 204 } 163 205 164 - console.log('Cache cleanup completed'); 206 + console.log('[MediaCache] Cache cleanup completed'); 207 + } 208 + 209 + /** 210 + * Compress all oversized images already in the cache 211 + * @returns {Promise<{checked: number, compressed: number}>} 212 + */ 213 + async compressOversizedCachedImages() { 214 + console.log('[Cache Compression] Scanning cached images for oversized files...'); 215 + let checked = 0; 216 + let compressed = 0; 217 + 218 + try { 219 + const files = await fs.readdir(this.imageDir); 220 + 221 + for (const file of files) { 222 + const filePath = path.join(this.imageDir, file); 223 + const ext = path.extname(file).toLowerCase(); 224 + 225 + // Only check JPEG and PNG images 226 + if (['.jpg', '.jpeg', '.png'].includes(ext)) { 227 + checked++; 228 + try { 229 + const wasCompressed = await this.compressImageIfNeeded(filePath); 230 + if (wasCompressed) { 231 + compressed++; 232 + } 233 + } catch (error) { 234 + console.error(`[Cache Compression] Error compressing ${file}:`, error.message); 235 + } 236 + } 237 + } 238 + 239 + if (compressed > 0) { 240 + console.log(`[Cache Compression] Compressed ${compressed} oversized images out of ${checked} checked`); 241 + } else { 242 + console.log(`[Cache Compression] No oversized images found (checked ${checked} files)`); 243 + } 244 + } catch (error) { 245 + console.error('[Cache Compression] Error scanning image directory:', error); 246 + } 247 + 248 + return { checked, compressed }; 165 249 } 166 250 167 251 /** ··· 198 282 } 199 283 200 284 /** 285 + * Get file size in bytes 286 + * @param {string} filePath - Path to file 287 + * @returns {Promise<number>} - File size in bytes 288 + */ 289 + async getFileSize(filePath) { 290 + const stats = await fs.stat(filePath); 291 + return stats.size; 292 + } 293 + 294 + /** 295 + * Format bytes to human-readable string 296 + * @param {number} bytes - Number of bytes 297 + * @returns {string} - Formatted string 298 + */ 299 + formatFileSize(bytes) { 300 + if (bytes < 1024) return `${bytes} B`; 301 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; 302 + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; 303 + } 304 + 305 + /** 306 + * Compress an image if it exceeds Telegram's 10 MB photo limit 307 + * @param {string} filePath - Path to image file 308 + * @returns {Promise<boolean>} - True if compression was performed 309 + */ 310 + async compressImageIfNeeded(filePath) { 311 + const MAX_SIZE = 10 * 1024 * 1024; // 10 MB in bytes 312 + const ext = path.extname(filePath).toLowerCase(); 313 + 314 + // Only compress JPEG and PNG images 315 + if (!['.jpg', '.jpeg', '.png'].includes(ext)) { 316 + return false; 317 + } 318 + 319 + try { 320 + const originalSize = await this.getFileSize(filePath); 321 + 322 + if (originalSize <= MAX_SIZE) { 323 + return false; // No compression needed 324 + } 325 + 326 + console.log(`[Compression] Image exceeds 10 MB limit: ${this.formatFileSize(originalSize)}`); 327 + console.log(`[Compression] Starting compression for: ${path.basename(filePath)}`); 328 + 329 + const image = sharp(filePath); 330 + const metadata = await image.metadata(); 331 + 332 + let compressed = false; 333 + let currentQuality = 90; 334 + 335 + // Stage 1: Try reducing quality 336 + while (currentQuality >= 70) { 337 + const tempPath = filePath + '.tmp'; 338 + 339 + await image 340 + .jpeg({ quality: currentQuality, mozjpeg: true }) 341 + .toFile(tempPath); 342 + 343 + const newSize = await this.getFileSize(tempPath); 344 + 345 + if (newSize <= MAX_SIZE) { 346 + // Replace original with compressed version 347 + await fs.move(tempPath, filePath, { overwrite: true }); 348 + console.log(`[Compression] Success with quality ${currentQuality}%: ${this.formatFileSize(originalSize)} → ${this.formatFileSize(newSize)} (${((1 - newSize/originalSize) * 100).toFixed(2)}% reduction)`); 349 + compressed = true; 350 + break; 351 + } 352 + 353 + await fs.unlink(tempPath); 354 + currentQuality -= 10; 355 + } 356 + 357 + // Stage 2: If quality reduction isn't enough, try resizing 358 + if (!compressed) { 359 + console.log(`[Compression] Quality reduction insufficient, attempting resize`); 360 + 361 + const maxDimension = 4096; 362 + let width = metadata.width; 363 + let height = metadata.height; 364 + 365 + if (width > maxDimension || height > maxDimension) { 366 + if (width > height) { 367 + height = Math.round((height / width) * maxDimension); 368 + width = maxDimension; 369 + } else { 370 + width = Math.round((width / height) * maxDimension); 371 + height = maxDimension; 372 + } 373 + } 374 + 375 + const tempPath = filePath + '.tmp'; 376 + await sharp(filePath) 377 + .resize(width, height, { fit: 'inside', withoutEnlargement: true }) 378 + .jpeg({ quality: 85, mozjpeg: true }) 379 + .toFile(tempPath); 380 + 381 + const newSize = await this.getFileSize(tempPath); 382 + 383 + if (newSize <= MAX_SIZE) { 384 + await fs.move(tempPath, filePath, { overwrite: true }); 385 + console.log(`[Compression] Success with resize to ${width}x${height}: ${this.formatFileSize(originalSize)} → ${this.formatFileSize(newSize)} (${((1 - newSize/originalSize) * 100).toFixed(2)}% reduction)`); 386 + compressed = true; 387 + } else { 388 + await fs.unlink(tempPath); 389 + console.log(`[Compression] Warning: Could not compress below 10 MB limit. Final size: ${this.formatFileSize(newSize)}`); 390 + } 391 + } 392 + 393 + return compressed; 394 + } catch (error) { 395 + console.error(`[Compression] Error compressing image: ${error.message}`); 396 + return false; 397 + } 398 + } 399 + 400 + /** 201 401 * Get file extension from URL or content type 202 402 * @param {string} url - URL to extract extension from 203 403 * @param {string} contentType - Content-Type header ··· 320 520 if (!isVideo) { 321 521 isVideo = this.isVideoUrl(url, contentType); 322 522 } 523 + 524 + // For images, retry hash manager init periodically if startup init failed. 525 + if (!isVideo) { 526 + this.ensureImageHashManager(); 527 + } 323 528 324 529 // For videos, use URL hash as before 325 530 // For images, we'll calculate perceptual hash after download ··· 391 596 return new Promise((resolve, reject) => { 392 597 writer.on('finish', async () => { 393 598 try { 599 + if (!isVideo && !this.imageHashManager) { 600 + this.ensureImageHashManager(); 601 + } 602 + 394 603 // If it's an image, calculate perceptual hash and rename 395 604 if (!isVideo && this.imageHashManager) { 396 605 try { ··· 400 609 401 610 // Calculate perceptual hash 402 611 const perceptualHash = await this.imageHashManager.calculateHash(tempFilePath); 612 + const contentHash = await this.imageHashManager.calculateContentHash(tempFilePath); 403 613 console.log(`[ImageHash] Calculated perceptual hash: ${perceptualHash}`); 404 614 405 615 // Rename file to use perceptual hash 406 - const hashFilename = `${perceptualHash}${ext}`; 407 - finalFilePath = path.join(storageDir, hashFilename); 408 - 409 - // Check if file with this hash already exists 410 - if (await this.isValidCacheFile(finalFilePath)) { 411 - console.log(`[ImageHash] File with same perceptual hash already exists: ${hashFilename}`); 412 - // Delete temp file 616 + const canonicalFilename = `${perceptualHash}${ext}`; 617 + const canonicalFilePath = path.join(storageDir, canonicalFilename); 618 + finalFilePath = canonicalFilePath; 619 + 620 + // Determine whether an existing file with same perceptual hash is an exact duplicate. 621 + const hashMatches = this.imageHashManager.getHashesByHash(perceptualHash); 622 + const exactMatch = hashMatches.find(record => 623 + record.content_hash && record.content_hash === contentHash 624 + ); 625 + 626 + if (exactMatch && await this.isValidCacheFile(exactMatch.file_path)) { 627 + console.log(`[ImageHash] Exact duplicate detected for perceptual hash: ${perceptualHash}`); 413 628 await fs.unlink(tempFilePath); 414 - 415 - // Add alternate URL if this is a different source 416 - const existing = this.imageHashManager.getHashByHash(perceptualHash); 417 - if (existing && existing.url !== urlForHash) { 418 - console.log(`[ImageHash] Adding alternate URL for existing hash`); 419 - this.imageHashManager.addAlternateUrl(perceptualHash, urlForHash); 629 + finalFilePath = exactMatch.file_path; 630 + 631 + if (exactMatch.url !== urlForHash) { 632 + console.log('[ImageHash] Adding alternate URL for exact duplicate'); 633 + this.imageHashManager.addAlternateUrl(exactMatch.url, urlForHash); 634 + } 635 + } else if (await this.isValidCacheFile(canonicalFilePath)) { 636 + // Collision case: same perceptual hash but different content. 637 + const variantFilename = `${perceptualHash}_${contentHash.substring(0, 12)}${ext}`; 638 + finalFilePath = path.join(storageDir, variantFilename); 639 + 640 + if (await this.isValidCacheFile(finalFilePath)) { 641 + await fs.unlink(tempFilePath); 642 + } else { 643 + await fs.rename(tempFilePath, finalFilePath); 644 + console.log(`[ImageHash] Perceptual collision detected, saved variant: ${variantFilename}`); 645 + await this.compressImageIfNeeded(finalFilePath); 420 646 } 421 647 } else { 422 - // Rename temp file to final hash-based name 423 - await fs.rename(tempFilePath, finalFilePath); 424 - console.log(`[ImageHash] Renamed to: ${hashFilename}`); 648 + await fs.rename(tempFilePath, canonicalFilePath); 649 + console.log(`[ImageHash] Renamed to: ${canonicalFilename}`); 650 + await this.compressImageIfNeeded(canonicalFilePath); 425 651 } 426 652 427 653 // Store hash in database ··· 429 655 contentType, 430 656 downloadUrl: url, 431 657 downloadedAt: new Date().toISOString() 432 - }); 658 + }, contentHash); 433 659 console.log(`[ImageHash] Successfully stored hash in database`); 434 660 } catch (error) { 435 661 // If hashing fails, fall back to URL hash naming ··· 441 667 // Move temp file to fallback name if it doesn't exist 442 668 if (!await this.isValidCacheFile(finalFilePath)) { 443 669 await fs.rename(tempFilePath, finalFilePath); 670 + // Compress image if needed 671 + await this.compressImageIfNeeded(finalFilePath); 444 672 } else { 445 673 await fs.unlink(tempFilePath); 446 674 } ··· 452 680 const fallbackFilename = `${fallbackHash}${ext}`; 453 681 finalFilePath = path.join(storageDir, fallbackFilename); 454 682 await fs.rename(tempFilePath, finalFilePath); 683 + // Compress image if needed 684 + await this.compressImageIfNeeded(finalFilePath); 455 685 } 456 686 457 687 resolve({ filePath: finalFilePath, contentType, isVideo });
+8 -8
utils/queueMonitor.js
··· 36 36 this.periodicSaveInterval = null; 37 37 this.saveIntervalMs = 5 * 60 * 1000; // 5 minutes in milliseconds 38 38 39 - console.log(`Queue Monitor initialized - Low threshold: ${this.lowThreshold}, Empty threshold: ${this.emptyThreshold}, Alerts enabled: ${this.alertsEnabled}`); 39 + console.log(`[QueueMonitor] Initialized - Low threshold: ${this.lowThreshold}, Empty threshold: ${this.emptyThreshold}, Alerts enabled: ${this.alertsEnabled}`); 40 40 } 41 41 42 42 /** ··· 69 69 this.checkQueueStatus(); 70 70 }, this.intervalMs); 71 71 72 - console.log('Queue monitoring started'); 72 + console.log('[QueueMonitor] Queue monitoring started'); 73 73 } 74 74 75 75 /** ··· 85 85 console.log('Queue alert state saved (periodic backup)'); 86 86 }, this.saveIntervalMs); 87 87 88 - console.log('Periodic alert state saving started (every 5 minutes)'); 88 + console.log('[QueueMonitor] Periodic alert state saving started (every 5 minutes)'); 89 89 } 90 90 91 91 /** ··· 340 340 this.lastLowQueueAlertTime = state.lastLowQueueAlertTime || 0; 341 341 this.lastEmptyQueueAlertTime = state.lastEmptyQueueAlertTime || 0; 342 342 343 - console.log('Queue alert state loaded from persistent storage'); 343 + console.log('[QueueMonitor] Queue alert state loaded from persistent storage'); 344 344 345 345 // Log current cooldown status if alerts were previously sent 346 346 const currentTime = Date.now(); ··· 351 351 const lowCooldownRemaining = Math.max(0, cooldownMs - timeSinceLastLow); 352 352 if (lowCooldownRemaining > 0) { 353 353 const hoursRemaining = Math.ceil(lowCooldownRemaining / (60 * 60 * 1000)); 354 - console.log(`Low queue alert cooldown: ${hoursRemaining} hours remaining`); 354 + console.log(`[QueueMonitor] Low queue alert cooldown: ${hoursRemaining} hours remaining`); 355 355 } 356 356 } 357 357 ··· 360 360 const emptyCooldownRemaining = Math.max(0, cooldownMs - timeSinceLastEmpty); 361 361 if (emptyCooldownRemaining > 0) { 362 362 const hoursRemaining = Math.ceil(emptyCooldownRemaining / (60 * 60 * 1000)); 363 - console.log(`Empty queue alert cooldown: ${hoursRemaining} hours remaining`); 363 + console.log(`[QueueMonitor] Empty queue alert cooldown: ${hoursRemaining} hours remaining`); 364 364 } 365 365 } 366 366 367 367 } catch (error) { 368 368 if (error.code === 'ENOENT') { 369 - console.log('No existing alert state file found, starting with fresh state'); 369 + console.log('[QueueMonitor] No existing alert state file found, starting with fresh state'); 370 370 } else { 371 - console.error('Error loading alert state:', error); 371 + console.error('[QueueMonitor] Error loading alert state:', error); 372 372 } 373 373 // Continue with default values if file doesn't exist or is corrupted 374 374 }
+11 -11
utils/updater.js
··· 24 24 start() { 25 25 // Don't start the updater in dev mode 26 26 if (this.isDevMode) { 27 - console.log('Running in development mode, auto-updater disabled'); 27 + console.log('[Updater] Running in development mode, auto-updater disabled'); 28 28 return; 29 29 } 30 30 31 - console.log('Starting auto-updater, will check for updates every 12 hours'); 31 + console.log('[Updater] Starting auto-updater, will check for updates every 12 hours'); 32 32 33 33 // Run initial check 34 34 this.checkForUpdates(); ··· 55 55 */ 56 56 async checkForUpdates() { 57 57 try { 58 - console.log('Checking for updates...'); 58 + console.log('[Updater] Checking for updates...'); 59 59 60 60 // Fetch latest changes from origin without merging 61 61 const fetchResult = await execAsync('git fetch origin main'); ··· 68 68 const changeCount = parseInt(statusResult.stdout.trim(), 10); 69 69 70 70 if (changeCount > 0) { 71 - console.log(`Found ${changeCount} new commit(s), pulling updates...`); 71 + console.log(`[Updater] Found ${changeCount} new commit(s), pulling updates...`); 72 72 73 73 // Save current HEAD for comparison after pull 74 74 const oldHead = await execAsync('git rev-parse HEAD'); ··· 91 91 } 92 92 93 93 // Restart the bot using PM2 94 - console.log('Restarting bot with PM2...'); 94 + console.log('[Updater] Restarting bot with PM2...'); 95 95 try { 96 96 // Force save queue before restart to prevent data loss 97 97 const queueManager = require('../queue/queueManager'); 98 98 await queueManager.saveQueueToDisk(); 99 - console.log('Queue saved before restart'); 99 + console.log('[Updater] Queue saved before restart'); 100 100 101 101 // Create flag file to indicate this is an auto-update restart 102 102 const fs = require('fs-extra'); 103 103 const path = require('path'); 104 104 const flagFile = path.join(__dirname, '..', '.auto-update-restart'); 105 105 await fs.writeFile(flagFile, Date.now().toString()); 106 - console.log('Auto-update flag set'); 106 + console.log('[Updater] Auto-update flag set'); 107 107 108 108 await execAsync('pm2 restart --update-env stagehand'); 109 - console.log('Bot restarted successfully'); 109 + console.log('[Updater] Bot restarted successfully'); 110 110 } catch (restartError) { 111 111 // SIGINT is expected when PM2 restarts the process, not an actual error 112 112 if (restartError.signal === 'SIGINT' && restartError.stdout && restartError.stdout.includes('[PM2]')) { 113 - console.log('Bot restart initiated successfully (SIGINT received as expected)'); 113 + console.log('[Updater] Bot restart initiated successfully (SIGINT received as expected)'); 114 114 } else { 115 115 throw restartError; 116 116 } 117 117 } 118 118 } else { 119 - console.log('No updates found'); 119 + console.log('[Updater] No updates found'); 120 120 } 121 121 } catch (error) { 122 - console.error('Error checking for updates:', error); 122 + console.error('[Updater] Error checking for updates:', error); 123 123 } 124 124 } 125 125