···11-// For format details, see https://aka.ms/devcontainer.json. For config options, see the
22-// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
33-{
44- "name": "Node.js",
55- // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
66- "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm"
77-88- // Features to add to the dev container. More info: https://containers.dev/features.
99- // "features": {},
1010-1111- // Use 'forwardPorts' to make a list of ports inside the container available locally.
1212- // "forwardPorts": [],
1313-1414- // Use 'postCreateCommand' to run commands after the container is created.
1515- // "postCreateCommand": "yarn install",
1616-1717- // Configure tool-specific properties.
1818- // "customizations": {},
1919-2020- // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
2121- // "remoteUser": "root"
2222-}
+24-2
.env.example
···33CHANNEL_ID=@your_channel_name_or_id
4455# Discord Configuration
66+# =====================
77+88+# Discord Bot Mode (Recommended - Full bot with commands)
99+# Get your bot token from: https://discord.com/developers/applications
1010+# Leave as placeholder to use webhook mode instead
1111+DISCORD_BOT_TOKEN=your_discord_bot_token_here
1212+1313+# Discord channel ID where the bot should post (right-click channel > Copy Channel ID)
1414+# Requires Developer Mode enabled in Discord settings
1515+DISCORD_CHANNEL_ID=your_discord_channel_id_here
1616+1717+# Discord Authorization (comma-separated Discord user IDs/snowflakes)
1818+# Only these users can use bot commands and add links to queue
1919+# To find your Discord ID: enable Developer Mode, right-click your name > Copy User ID
2020+# Example: DISCORD_AUTHORIZED_SNOWFLAKES=123456789012345678,234567890123456789
2121+# Leave empty to allow anyone (not recommended)
2222+DISCORD_AUTHORIZED_SNOWFLAKES=
2323+2424+# Discord bot owner ID (for admin commands)
2525+DISCORD_OWNER_ID=
2626+2727+# Discord Webhook Mode (Fallback - Simple posting only, no commands)
2828+# Used if DISCORD_BOT_TOKEN is not provided
629# For multiple webhooks, use a comma-separated list:
730# DISCORD_WEBHOOK_URLS=https://discord.com/api/webhooks/xxx/yyy,https://discord.com/api/webhooks/aaa/bbb
88-# For a single webhook (legacy):
99-DISCORD_WEBHOOK_URL=your_discord_webhook_url_here
3131+DISCORD_WEBHOOK_URLS=
1032DISCORD_ENABLED=true
11331234# User Access Control (comma-separated list of Telegram user IDs)
+183
bot/discordBot.js
···11+const { Client, GatewayIntentBits, Partials, REST, Routes } = require('discord.js');
22+const config = require('../config');
33+const queueManager = require('../queue/queueManager');
44+const AnnouncementManager = require('../utils/announcementManager');
55+const QueueMonitor = require('../utils/queueMonitor');
66+const CommandRegistry = require('./discordbot/commandRegistry');
77+88+class DiscordStagehandBot {
99+ constructor() {
1010+ this.client = new Client({
1111+ intents: [
1212+ GatewayIntentBits.Guilds,
1313+ GatewayIntentBits.GuildMessages,
1414+ GatewayIntentBits.MessageContent,
1515+ GatewayIntentBits.DirectMessages,
1616+ ],
1717+ partials: [Partials.Channel, Partials.Message],
1818+ });
1919+2020+ this.serviceName = 'discord';
2121+ this.channelId = config.discord.channelId;
2222+ this.announcements = new AnnouncementManager(this);
2323+ this.queueMonitor = new QueueMonitor(this);
2424+ this.commandRegistry = new CommandRegistry(this.client, this.announcements, this.queueMonitor);
2525+ this.isReady = false;
2626+2727+ this.init();
2828+ }
2929+3030+ async init() {
3131+ // Set up ready event
3232+ this.client.once('clientReady', async () => {
3333+ console.log(`[DiscordBot] Discord bot logged in as ${this.client.user.tag}`);
3434+ console.log(`[Discord Diagnostics] Intents bitfield: ${this.client.options.intents.bitfield}`);
3535+ this.isReady = true;
3636+3737+ try {
3838+ await this.client.application.fetch();
3939+ const appFlags = this.client.application.flags?.toArray?.() || [];
4040+ console.log(`[Discord Diagnostics] Application flags: ${appFlags.join(', ') || 'none'}`);
4141+ } catch (error) {
4242+ console.warn('[Discord Diagnostics] Could not fetch application flags:', error.message);
4343+ }
4444+4545+ await this.announcements.init();
4646+ await this.queueMonitor.init(queueManager);
4747+4848+ // Register slash commands with Discord
4949+ await this.registerSlashCommands();
5050+5151+ // Register command and message handlers
5252+ this.registerCommands();
5353+5454+ console.log('[DiscordBot] Discord bot started...');
5555+ });
5656+5757+ // Set up error handlers
5858+ this.client.on('error', (error) => {
5959+ console.error('Discord client error:', error);
6060+ });
6161+6262+ // Login to Discord
6363+ try {
6464+ await this.client.login(config.discord.botToken);
6565+ console.log(`[DiscordBot] Discord bot enabled - will post to channel: ${config.discord.channelId}`);
6666+ } catch (error) {
6767+ console.error('[DiscordBot] Failed to login to Discord:', error.message);
6868+ throw error;
6969+ }
7070+ }
7171+7272+ /**
7373+ * Register slash commands with Discord API
7474+ */
7575+ async registerSlashCommands() {
7676+ try {
7777+ console.log('[DiscordBot] Registering Discord slash commands...');
7878+7979+ const rest = new REST({ version: '10' }).setToken(config.discord.botToken);
8080+ const commandData = this.commandRegistry.getCommandData();
8181+8282+ // Register commands globally
8383+ await rest.put(
8484+ Routes.applicationCommands(this.client.user.id),
8585+ { body: commandData }
8686+ );
8787+8888+ console.log(`[DiscordBot] Successfully registered ${commandData.length} slash commands`);
8989+ } catch (error) {
9090+ console.error('[DiscordBot] Error registering slash commands:', error);
9191+ }
9292+ }
9393+9494+ registerCommands() {
9595+ // Use the command registry to register all commands and handlers
9696+ this.commandRegistry.registerAll();
9797+ }
9898+9999+ /**
100100+ * Post media (image or video) to the Discord channel
101101+ * This method is used by the scheduler and external services
102102+ * @param {Object} mediaData - The media data to post
103103+ * @returns {Promise<boolean>} - Whether posting was successful
104104+ */
105105+ async postMedia(mediaData) {
106106+ if (!this.isReady) {
107107+ console.log('Discord bot not ready yet, skipping post');
108108+ return false;
109109+ }
110110+ return await this.commandRegistry.getMediaHelper().postMedia(mediaData);
111111+ }
112112+113113+ /**
114114+ * Check if a user is authorized
115115+ * @param {string} userId - The Discord user ID (snowflake) to check
116116+ * @returns {boolean} - Whether the user is authorized
117117+ */
118118+ isAuthorized(userId) {
119119+ return this.commandRegistry.getAuthHelper().isAuthorized(userId);
120120+ }
121121+122122+ /**
123123+ * Check if the user is the owner of the bot
124124+ * @param {string} userId - The Discord user ID (snowflake) to check
125125+ * @returns {boolean} - Whether the user is the owner
126126+ */
127127+ isOwner(userId) {
128128+ return this.commandRegistry.getAuthHelper().isOwner(userId);
129129+ }
130130+131131+ /**
132132+ * Display a page of the queue with interactive buttons
133133+ * @param {Object} message - Discord message object
134134+ * @param {number} page - Page number to display (1-based)
135135+ * @param {number} pageSize - Number of items per page
136136+ */
137137+ async displayQueuePage(message, page, pageSize) {
138138+ return await this.commandRegistry.getQueueHelper().displayQueuePage(message, page, pageSize);
139139+ }
140140+141141+ /**
142142+ * Send a message to a specific channel or user
143143+ * @param {string} channelId - Discord channel ID
144144+ * @param {string} message - Message to send
145145+ * @param {Object} options - Additional message options
146146+ */
147147+ async sendMessage(channelId, message, options = {}) {
148148+ try {
149149+ const channel = await this.client.channels.fetch(channelId);
150150+ if (channel && channel.isTextBased()) {
151151+ return await channel.send({ content: message, ...options });
152152+ }
153153+ } catch (error) {
154154+ console.error('Error sending Discord message:', error);
155155+ return null;
156156+ }
157157+ }
158158+159159+ /**
160160+ * Shutdown the bot gracefully
161161+ * @returns {Promise<void>}
162162+ */
163163+ async shutdown() {
164164+ try {
165165+ console.log('Stopping Discord bot...');
166166+167167+ // Shutdown queue monitor
168168+ if (this.queueMonitor) {
169169+ await this.queueMonitor.shutdown();
170170+ }
171171+172172+ await this.client.destroy();
173173+ console.log('Discord bot stopped');
174174+175175+ return true;
176176+ } catch (error) {
177177+ console.error('Error shutting down Discord bot:', error);
178178+ return false;
179179+ }
180180+ }
181181+}
182182+183183+module.exports = DiscordStagehandBot;
+3-3
bot/discordWebhook.js
···1818 this.enabled = config.discord?.enabled;
19192020 if (!this.webhookUrls.length) {
2121- console.warn('Discord webhook URL(s) not configured. Discord integration disabled.');
2121+ console.warn('[DiscordWebhook] Discord webhook URL(s) not configured. Discord integration disabled.');
2222 } else if (this.enabled === false) {
2323- console.log('Discord webhook URL(s) configured but integration is disabled in settings.');
2323+ console.log('[DiscordWebhook] Discord webhook URL(s) configured but integration is disabled in settings.');
2424 } else {
2525- console.log(`Discord webhook integration initialized for ${this.webhookUrls.length} webhook(s).`);
2525+ console.log(`[DiscordWebhook] Discord webhook integration initialized for ${this.webhookUrls.length} webhook(s).`);
2626 }
2727 }
2828
+81
bot/discordbot/commandRegistry.js
···11+// Command imports
22+const HelpCommand = require('./commands/help');
33+const AddCommand = require('./commands/add');
44+const QueueCommand = require('./commands/queue');
55+const StatusCommand = require('./commands/status');
66+77+// Helper imports
88+const AuthHelper = require('./helpers/authHelper');
99+const QueueHelper = require('./helpers/queueHelper');
1010+const MediaHelper = require('./helpers/mediaHelper');
1111+const CallbackHandler = require('./helpers/callbackHandler');
1212+1313+/**
1414+ * Command registry to manage all Discord bot commands and helpers
1515+ */
1616+class CommandRegistry {
1717+ constructor(client, announcements, queueMonitor) {
1818+ this.client = client;
1919+ this.announcements = announcements;
2020+ this.queueMonitor = queueMonitor;
2121+2222+ // Initialize helpers
2323+ this.authHelper = new AuthHelper();
2424+ this.queueHelper = new QueueHelper(client);
2525+ this.callbackHandler = new CallbackHandler(client, this.authHelper, this.queueHelper);
2626+ this.mediaHelper = new MediaHelper(client);
2727+2828+ // Initialize commands
2929+ this.commands = [
3030+ new HelpCommand(client),
3131+ new AddCommand(client, this.authHelper),
3232+ new QueueCommand(client, this.authHelper, this.queueHelper),
3333+ new StatusCommand(client, this.authHelper, queueMonitor)
3434+ ];
3535+3636+ }
3737+3838+ /**
3939+ * Register all commands and handlers
4040+ */
4141+ registerAll() {
4242+ // Register all commands
4343+ this.commands.forEach(command => {
4444+ command.register();
4545+ });
4646+4747+ // Register callback handler for button interactions
4848+ this.callbackHandler.register();
4949+ }
5050+5151+ /**
5252+ * Get command data for Discord API registration
5353+ * @returns {Array} - Array of slash command data
5454+ */
5555+ getCommandData() {
5656+ return this.commands.map(command => command.data.toJSON());
5757+ }
5858+5959+ /**
6060+ * Get the media helper for external use (like in the main bot class)
6161+ */
6262+ getMediaHelper() {
6363+ return this.mediaHelper;
6464+ }
6565+6666+ /**
6767+ * Get the auth helper for external use
6868+ */
6969+ getAuthHelper() {
7070+ return this.authHelper;
7171+ }
7272+7373+ /**
7474+ * Get the queue helper for external use
7575+ */
7676+ getQueueHelper() {
7777+ return this.queueHelper;
7878+ }
7979+}
8080+8181+module.exports = CommandRegistry;
+127
bot/discordbot/commands/add.js
···11+const { SlashCommandBuilder, MessageFlags } = require('discord.js');
22+const scraperManager = require('../../../utils/scraperManager');
33+const queueManager = require('../../../queue/queueManager');
44+const config = require('../../../config');
55+66+// Components V2 flag
77+const IS_COMPONENTS_V2 = 1 << 15; // 32768
88+99+/**
1010+ * Add command - Add a supported link to the queue
1111+ * Works in both DMs and guild channels for authorized users.
1212+ */
1313+class AddCommand {
1414+ constructor(client, authHelper) {
1515+ this.client = client;
1616+ this.authHelper = authHelper;
1717+ this.data = new SlashCommandBuilder()
1818+ .setName('add')
1919+ .setDescription('Add a supported media link to the queue')
2020+ .addStringOption(option =>
2121+ option
2222+ .setName('url')
2323+ .setDescription('Supported media URL')
2424+ .setRequired(true)
2525+ )
2626+ .setDMPermission(true);
2727+ }
2828+2929+ normalizeUrl(rawUrl) {
3030+ if (!rawUrl || typeof rawUrl !== 'string') {
3131+ return '';
3232+ }
3333+3434+ let url = rawUrl.trim();
3535+3636+ if (url.startsWith('<') && url.endsWith('>')) {
3737+ url = url.slice(1, -1);
3838+ }
3939+4040+ url = url.replace(/[.,!?;:)\]\}]+$/g, '');
4141+ return url;
4242+ }
4343+4444+ isSupportedUrl(url) {
4545+ return config.supportedSites.some(site => site.pattern.test(url));
4646+ }
4747+4848+ createTextReply(content) {
4949+ return {
5050+ flags: IS_COMPONENTS_V2 | MessageFlags.Ephemeral,
5151+ components: [{ type: 10, content }]
5252+ };
5353+ }
5454+5555+ async execute(interaction) {
5656+ if (!this.authHelper.isAuthorized(interaction.user.id)) {
5757+ await interaction.reply(
5858+ this.createTextReply(`❌ You are not authorized to add links. Your Discord ID: ${interaction.user.id}`)
5959+ );
6060+ return;
6161+ }
6262+6363+ const rawUrl = interaction.options.getString('url', true);
6464+ const url = this.normalizeUrl(rawUrl);
6565+6666+ if (!/^https?:\/\//i.test(url)) {
6767+ await interaction.reply(this.createTextReply('❌ Please provide a valid URL.'));
6868+ return;
6969+ }
7070+7171+ if (!this.isSupportedUrl(url)) {
7272+ await interaction.reply(this.createTextReply('❌ Unsupported URL. Use /help for supported sites.'));
7373+ return;
7474+ }
7575+7676+ await interaction.deferReply({ flags: IS_COMPONENTS_V2 | MessageFlags.Ephemeral });
7777+7878+ try {
7979+ const mediaData = await scraperManager.extractFromUrl(url);
8080+8181+ if (mediaData.error) {
8282+ await interaction.editReply(this.createTextReply(`❌ Error processing link: ${mediaData.error}`));
8383+ return;
8484+ }
8585+8686+ const username = interaction.user.tag || interaction.user.username || interaction.user.id;
8787+ const result = await queueManager.addToQueue(mediaData, username);
8888+8989+ if (result && result.duplicate) {
9090+ const existingInfo = result.existingItem || {};
9191+ const addedByText = existingInfo.addedBy || 'unknown';
9292+ const timeText = existingInfo.timestamp
9393+ ? new Date(existingInfo.timestamp).toLocaleString()
9494+ : 'unknown time';
9595+9696+ await interaction.editReply(
9797+ this.createTextReply(`⚠️ Duplicate image detected. Already added by ${addedByText} at ${timeText}.`)
9898+ );
9999+ return;
100100+ }
101101+102102+ if (!result || !result.success) {
103103+ await interaction.editReply(this.createTextReply('❌ Failed to add link to queue.'));
104104+ return;
105105+ }
106106+107107+ const queueLength = await queueManager.getQueueLength();
108108+ await interaction.editReply(
109109+ this.createTextReply(`✅ Added to queue at position ${queueLength}: **${mediaData.title}** from ${mediaData.siteName}`)
110110+ );
111111+ } catch (error) {
112112+ console.error('Error in /add command:', error);
113113+ await interaction.editReply(this.createTextReply(`❌ Error processing link: ${error.message}`));
114114+ }
115115+ }
116116+117117+ register() {
118118+ this.client.on('interactionCreate', async (interaction) => {
119119+ if (!interaction.isChatInputCommand()) return;
120120+ if (interaction.commandName !== 'add') return;
121121+122122+ await this.execute(interaction);
123123+ });
124124+ }
125125+}
126126+127127+module.exports = AddCommand;
+60
bot/discordbot/commands/help.js
···11+const { SlashCommandBuilder, MessageFlags } = require('discord.js');
22+33+// Components V2 flag
44+const IS_COMPONENTS_V2 = 1 << 15; // 32768
55+66+/**
77+ * Help command - Show available commands
88+ */
99+class HelpCommand {
1010+ constructor(client) {
1111+ this.client = client;
1212+ this.data = new SlashCommandBuilder()
1313+ .setName('help')
1414+ .setDescription('Show available bot commands and information');
1515+ }
1616+1717+ async execute(interaction) {
1818+ const helpText = `
1919+**📋 Stagehand Discord Bot Commands**
2020+2121+**Queue Management:**
2222+\`/queue [page]\` - View the queue (default: page 1)
2323+\`/status\` - View bot status and queue info
2424+\`/add <url>\` - Add a supported link directly to queue
2525+2626+**Media Processing:**
2727+Use \`/add <url>\` in DM to add supported links to the queue.
2828+2929+**Supported sites:** Bluesky, e621, FurAffinity, SoFurry, Weasyl
3030+3131+**Admin commands require authorization via Discord snowflake ID**
3232+Your Discord ID: \`${interaction.user.id}\`
3333+3434+**Note:** Link intake uses the \`/add\` slash command in Direct Message.
3535+ `;
3636+3737+ const components = [
3838+ {
3939+ type: 10, // Text Display
4040+ content: helpText
4141+ }
4242+ ];
4343+4444+ await interaction.reply({
4545+ flags: IS_COMPONENTS_V2 | MessageFlags.Ephemeral,
4646+ components: components
4747+ });
4848+ }
4949+5050+ register() {
5151+ this.client.on('interactionCreate', async (interaction) => {
5252+ if (!interaction.isCommand()) return;
5353+ if (interaction.commandName !== 'help') return;
5454+5555+ await this.execute(interaction);
5656+ });
5757+ }
5858+}
5959+6060+module.exports = HelpCommand;
···11+const config = require('../../../config');
22+33+/**
44+ * Authorization helper for checking Discord user permissions
55+ * Discord user IDs are called "snowflakes" - unique 64-bit identifiers
66+ */
77+class AuthHelper {
88+ /**
99+ * Check if a Discord user is authorized to use bot commands
1010+ * @param {string} userId - The Discord user ID (snowflake) to check
1111+ * @returns {boolean} - Whether the user is authorized
1212+ */
1313+ isAuthorized(userId) {
1414+ // If no authorized Discord users are specified, anyone can use the bot
1515+ if (!config.discord.authorizedSnowflakes || config.discord.authorizedSnowflakes.length === 0) {
1616+ return true;
1717+ }
1818+1919+ return config.discord.authorizedSnowflakes.includes(userId.toString());
2020+ }
2121+2222+ /**
2323+ * Check if the user is the owner of the Discord bot
2424+ * @param {string} userId - The Discord user ID (snowflake) to check
2525+ * @returns {boolean} - Whether the user is the owner
2626+ */
2727+ isOwner(userId) {
2828+ return config.discord.ownerId && userId.toString() === config.discord.ownerId.toString();
2929+ }
3030+3131+ /**
3232+ * Validate that a string is a valid Discord snowflake
3333+ * Discord snowflakes are 64-bit integers represented as strings
3434+ * @param {string} snowflake - The snowflake to validate
3535+ * @returns {boolean} - Whether the snowflake is valid
3636+ */
3737+ isValidSnowflake(snowflake) {
3838+ if (typeof snowflake !== 'string') {
3939+ return false;
4040+ }
4141+4242+ // Snowflakes are numeric strings between 17-20 characters
4343+ // (Discord epoch started in 2015, so minimum length is ~17)
4444+ return /^\d{17,20}$/.test(snowflake);
4545+ }
4646+}
4747+4848+module.exports = AuthHelper;
+94
bot/discordbot/helpers/callbackHandler.js
···11+const { MessageFlags } = require('discord.js');
22+33+/**
44+ * Callback handler for Discord button interactions
55+ */
66+class CallbackHandler {
77+ constructor(client, authHelper, queueHelper) {
88+ this.client = client;
99+ this.authHelper = authHelper;
1010+ this.queueHelper = queueHelper;
1111+ }
1212+1313+ /**
1414+ * Register interaction handlers
1515+ */
1616+ register() {
1717+ this.client.on('interactionCreate', async (interaction) => {
1818+ // Only handle button interactions
1919+ if (!interaction.isButton()) return;
2020+2121+ try {
2222+ // Check authorization
2323+ if (!this.authHelper.isAuthorized(interaction.user.id)) {
2424+ await interaction.reply({
2525+ content: '❌ You are not authorized to use this button.',
2626+ flags: MessageFlags.Ephemeral
2727+ });
2828+ return;
2929+ }
3030+3131+ // Parse custom ID
3232+ const customId = interaction.customId;
3333+3434+ // Handle queue pagination buttons
3535+ if (customId.startsWith('queue_page_')) {
3636+ await this.handleQueuePagination(interaction);
3737+ return;
3838+ }
3939+4040+ // Unknown button
4141+ await interaction.reply({
4242+ content: '❌ Unknown button action.',
4343+ flags: MessageFlags.Ephemeral
4444+ });
4545+ } catch (error) {
4646+ console.error('Error handling button interaction:', error);
4747+4848+ // Try to respond if we haven't already
4949+ if (!interaction.replied && !interaction.deferred) {
5050+ await interaction.reply({
5151+ content: '❌ An error occurred processing your request.',
5252+ flags: MessageFlags.Ephemeral
5353+ }).catch(() => {});
5454+ }
5555+ }
5656+ });
5757+ }
5858+5959+ /**
6060+ * Handle queue pagination button clicks
6161+ * @param {Interaction} interaction - Discord button interaction
6262+ */
6363+ async handleQueuePagination(interaction) {
6464+ // Parse the custom ID: queue_page_2_5 -> page=2, pageSize=5
6565+ const parts = interaction.customId.split('_');
6666+6767+ if (parts[0] === 'queue' && parts[1] === 'page') {
6868+ // Handle "current page" button (disabled, just acknowledge)
6969+ if (parts[2] === 'current') {
7070+ await interaction.deferUpdate();
7171+ return;
7272+ }
7373+7474+ const page = parseInt(parts[2], 10);
7575+ const pageSize = parseInt(parts[3], 10);
7676+7777+ if (isNaN(page) || isNaN(pageSize)) {
7878+ await interaction.reply({
7979+ content: '❌ Invalid page navigation data.',
8080+ flags: MessageFlags.Ephemeral
8181+ });
8282+ return;
8383+ }
8484+8585+ // For button interactions, we need to defer so we can update
8686+ await interaction.deferUpdate();
8787+8888+ // Use the queue helper to display the requested page
8989+ await this.queueHelper.displayQueuePage(interaction, page, pageSize);
9090+ }
9191+ }
9292+}
9393+9494+module.exports = CallbackHandler;
···11+# Discord Bot Implementation Summary
22+33+## What Was Added
44+55+This implementation adds a full Discord bot to Stagehand with the following features:
66+77+### Core Features
88+1. **Full Discord Bot** with command support (using discord.js)
99+2. **Snowflake-based Authorization** - Only approved Discord users can add links
1010+3. **Automatic Link Processing** - Detects and processes supported URLs from messages
1111+4. **Command System** - Bot commands for queue management and status
1212+5. **Webhook Fallback** - Keeps existing webhook system as a backup
1313+1414+### File Structure
1515+1616+```
1717+bot/
1818+├── discordBot.js # Main Discord bot class
1919+├── discordWebhook.js # Existing webhook (fallback)
2020+└── discordbot/
2121+ ├── commandRegistry.js # Command registration and management
2222+ ├── commands/
2323+ │ ├── index.js # Command exports
2424+ │ ├── help.js # Help command
2525+ │ ├── queue.js # Queue display command
2626+ │ ├── status.js # Bot status command
2727+ │ └── linkHandler.js # URL processing handler
2828+ └── helpers/
2929+ ├── index.js # Helper exports
3030+ ├── authHelper.js # Snowflake authorization
3131+ ├── mediaHelper.js # Media posting to Discord
3232+ └── queueHelper.js # Queue display helpers
3333+```
3434+3535+### New Configuration Options
3636+3737+Added to `config.js`:
3838+- `discord.botToken` - Discord bot token
3939+- `discord.channelId` - Channel ID for posting
4040+- `discord.authorizedSnowflakes` - Array of authorized user IDs
4141+- `discord.ownerId` - Bot owner ID
4242+- `discord.useBot` - Auto-detected based on token presence
4343+4444+### Environment Variables
4545+4646+New `.env` variables:
4747+```env
4848+DISCORD_BOT_TOKEN=your_bot_token
4949+DISCORD_CHANNEL_ID=your_channel_id
5050+DISCORD_AUTHORIZED_SNOWFLAKES=id1,id2,id3
5151+DISCORD_OWNER_ID=your_id
5252+```
5353+5454+## How It Works
5555+5656+### Authorization Flow
5757+1. User posts a link in Discord
5858+2. Bot checks if user's Discord ID (snowflake) is in `DISCORD_AUTHORIZED_SNOWFLAKES`
5959+3. If authorized: processes link and adds to queue
6060+4. If not authorized: sends error message with user's Discord ID
6161+6262+### Bot vs Webhook
6363+- If `DISCORD_BOT_TOKEN` is set → Use full bot mode
6464+- If not set → Fall back to webhook mode
6565+- Webhook is kept as a safety fallback
6666+6767+### Commands
6868+All commands use Discord's slash command system (`/`):
6969+- `/help` - Show commands and your Discord ID (available to all)
7070+- `/queue [page]` - View queue (authorized only)
7171+- `/status` - Bot status (authorized only)
7272+7373+**Link Processing:**
7474+- Send any supported link to the bot via **Direct Message**
7575+- Links only work in DMs (not in server channels)
7676+- Prevents spam and follows Discord's app guidelines
7777+7878+### Supported Sites
7979+Same as Telegram bot:
8080+- Bluesky (all instances)
8181+- e621.net
8282+- FurAffinity
8383+- SoFurry
8484+- Weasyl
8585+8686+## Installation Steps
8787+8888+1. **Install dependencies:**
8989+ ```bash
9090+ npm install
9191+ ```
9292+9393+2. **Create Discord bot:**
9494+ - Go to https://discord.com/developers/applications
9595+ - Create new application → Add Bot
9696+ - Enable "Message Content Intent"
9797+ - Copy bot token
9898+9999+3. **Get Discord IDs:**
100100+ - Enable Developer Mode in Discord
101101+ - Right-click channel → Copy Channel ID
102102+ - Right-click your user → Copy User ID
103103+104104+4. **Configure .env:**
105105+ ```env
106106+ DISCORD_BOT_TOKEN=your_token_here
107107+ DISCORD_CHANNEL_ID=123456789012345678
108108+ DISCORD_AUTHORIZED_SNOWFLAKES=your_id,friend_id
109109+ DISCORD_OWNER_ID=your_id
110110+ ```
111111+112112+5. **Start the bot:**
113113+ ```bash
114114+ npm start
115115+ Use `/help` in any channel or DM - should show commands and your Discord ID
116116+2. **Open a DM with the bot** and send a supported link (e.g., from Bluesky)
117117+ - If authorized: should add to queue
118118+ - If not authorized: should show error with your ID
119119+3. Run `/queue` to see the queue
120120+4. Run `/status` to see bot status
121121+122122+**Important:** Links must be sent in DMs, not in server channels.om Bluesky)
123123+ - If authorized: should add to queue
124124+ - If not authorized: should show error with your ID
125125+3. Run `!queue` to see the queue
126126+4. Run `!status` to see bot status
127127+128128+## Key Differences from Telegram
129129+130130+| Feature | Telegram | Discord |
131131+|---------|----------|---------|
132132+| User ID | Numeric (12345)/command` (slash commands) |
133133+| Link Processing | Any chat | DM onlyake (123456789012345678) |
134134+| Auth Check | `AUTHORIZED_USERS` | `DISCORD_AUTHORIZED_SNOWFLAKES` |
135135+| Commands | `/command` | `!command` |
136136+| Buttons | Inline keyboard | Action rows with buttons |
137137+| Media | SendPhoto/SendVideo | Embeds with attachments |
138138+139139+## Security Notes
140140+141141+- Snowflakes are public (anyone can see them by copying your ID)
142142+- Authorization list prevents unauthorized queue additions
143143+- Bot token must be kept secret
144144+- Only authorized users can execute commands
145145+- `!help` is public to allow users to find their ID
146146+147147+## Future Enhancements
148148+149149+Potential addit options for advanced features
150150+- Context menu commands (right-click message → Add to queue)
151151+- Role-based authorization in addition to snowflakes
152152+- Per-server configuration
153153+- Discord announcement system
154154+- Advanced queue management withem
155155+- Advanced queue management buttons
156156+- Multi-server support
+200
docs/discord-bot-setup.md
···11+# Discord Bot Setup Guide
22+33+This guide explains how to set up the full Discord bot with snowflake-based authorization.
44+55+## Overview
66+77+The bot now supports two Discord integration modes:
88+1. **Discord Bot (recommended)** - Full bot with commands and authorization
99+2. **Discord Webhook (fallback)** - Simple webhook posting only
1010+1111+The bot will automatically use the Discord Bot mode if `DISCORD_BOT_TOKEN` is provided, otherwise it falls back to webhook mode.
1212+1313+## Discord Bot Setup
1414+1515+### 1. Create a Discord Bot
1616+1717+1. Go to https://discord.com/developers/applications
1818+2. Click "New Application" and give it a name
1919+3. Go to the "Bot" section in the left sidebar
2020+4. Click "Add Bot"
2121+5. Under "Privileged Gateway Intents", enable:
2222+ - **Message Content Intent** (required to read message content)
2323+ - **Server Members Intent** (optional, for member-related features)
2424+6. Click "Reset Token" to get your bot token (save this securely!)
2525+2626+### 2. Invite the Bot to Your Server
2727+2828+1. Go to the "OAuth2" > "URL Generator" section
2929+2. Select the following scopes:
3030+ - `bot`
3131+ - `applications.commands` (if you want slash commands in the future)
3232+3. Select the following bot permissions:
3333+ - Read Messages/View Channels
3434+ - Send Messages
3535+ - Embed Links
3636+ - Attach Files
3737+ - Read Message History
3838+ - Add Reactions
3939+ - Use External Emojis
4040+4. Copy the generated URL and open it in your browser to invite the bot
4141+4242+### 3. Get Your Discord IDs (Snowflakes)
4343+4444+#### Enable Developer Mode:
4545+1. In Discord, go to User Settings > Advanced
4646+2. Enable "Developer Mode"
4747+4848+#### Get Channel ID:
4949+1. Right-click on the channel where you want the bot to post
5050+2. Click "Copy Channel ID"
5151+3. This is your `DISCORD_CHANNEL_ID`
5252+5353+#### Get Your User ID:
5454+1. Right-click on your username anywhere in Discord
5555+2. Click "Copy User ID"
5656+3. This is your Discord snowflake for authorization
5757+5858+### 4. Configure Environment Variables
5959+6060+Add these to your `.env` file:
6161+6262+```env
6363+# Discord Bot Configuration
6464+DISCORD_BOT_TOKEN=your_bot_token_here
6565+DISCORD_CHANNEL_ID=your_channel_id_here
6666+6767+# Discord Authorization (comma-separated user IDs/snowflakes)
6868+DISCORD_AUTHORIZED_SNOWFLAKES=123456789012345678,234567890123456789
6969+DISCORD_OWNER_ID=123456789012345678
7070+7171+# Optional: Keep webhook as fallback (if bot fails)
7272+DISCORD_WEBHOOK_URL=your_webhook_url_here
7373+DISCORD_ENABLED=true
7474+```
7575+7676+### 5. Understanding Snowflakes
7777+7878+Discord snowflakes are unique 64-bit identifiers for users, channels, servers, etc. They look like: `123456789012345678`
7979+8080+**Authorization:**
8181+- `DISCORD_AUTHORIZED_SNOWFLAKES` - Comma-separated list of user IDs who can use the bot
8282+- `DISCORD_OWNER_ID` - The primary owner/admin (usually your user ID)
8383+- If `DISCORD_AUTHORIZED_SNOWFLAKES` is empty, **anyone** can use the bot
8484+8585+**Finding Snowflakes:**
8686+- Users: Right-click user > Copy User ID
8787+- Channels: Right-click channel > Copy Channel ID
8888+- Servers: Right-click server icon > Copy Server ID
8989+9090+## Discord Bot Commands
9191+9292+Once configured, authorized users can use these slash commands:
9393+9494+- `/help` - Show all available commands and your Discord ID (available to everyone)
9595+- `/queue [page]` - View the queue (authorized users only)
9696+- `/status` - View bot status (authorized users only)
9797+9898+### Adding Links to Queue
9999+100100+**Important:** Links can only be submitted via **Direct Message (DM)** to the bot.
101101+102102+1. Open a DM with the bot
103103+2. Send any supported link
104104+3. The bot will process it if you're authorized
105105+106106+This follows Discord's app guidelines and prevents spam in public channels.
107107+108108+###**Commands** (`/queue`, `/status`):
109109+ - The bot checks if the user's Discord ID is in `DISCORD_AUTHORIZED_SNOWFLAKES`
110110+ - If authorized: command executes
111111+ - If not authorized: error message shown
112112+113113+2. **Link Processing** (DM only):
114114+ - User sends a link via Direct Message to the bot
115115+ - Bot checks if their Discord ID is authorized
116116+ - If authorized: processes link and adds to queue
117117+ - If not authorized: shows error with their Discord ID
118118+119119+3. **Help Command** (`/help`):
120120+ - Available to everyone
121121+ - Shows user's Discord ID so they can request access
122122+123123+**Note:** Link submission only works in DMs to comply with Discord's app guidelines and prevent channel spam.KES`
124124+ - If authorized, the link is processed and added to queue
125125+ - If not authorized, they receive an error message with their Discord ID
126126+127127+2. For commands like `!queue`, `!status`:
128128+ - Same authorization check applies
129129+ - Unauthorized users see an error message
130130+131131+3. The `!help` command:
132132+ - Available to everyone (shows their Discord ID so they can request access)
133133+134134+## Example Configuration
135135+136136+### Full Bot Mode (Recommended)
137137+```env
138138+# Telegram (existing)
139139+BOT_TOKEN=your_telegram_bot_token
140140+CHANNEL_ID=your_telegram_channel_id
141141+AUTHORIZED_USERS=12345,67890
142142+OWNER_ID=12345
143143+144144+# Discord Bot
145145+DISCORD_BOT_TOKEN=your_discord_bot_token
146146+DISCORD_CHANNEL_ID=987654321098765432
147147+DISCORD_AUTHORIZED_SNOWFLAKES=123456789012345678,234567890123456789
148148+DISCORD_OWNER_ID=123456789012345678
149149+DISCORD_ENABLED=true
150150+```
151151+152152+### Webhook Fallback Mode
153153+```env
154154+# If you don't provide DISCORD_BOT_TOKEN, webhook mode is used
155155+DISCORD_WEBHOOK_URL=your_webhook_url
156156+DISCORD_ENABLED=true
157157+```slash commands are registered (check console for "Successfully registered X slash commands")
158158+2. Verify your Discord user ID is in `DISCORD_AUTHORIZED_SNOWFLAKES`
159159+3. Try using `/help` first (available to everyone)
160160+4. If commands don't appear, try kicking and re-inviting the bot
161161+162162+### Links aren't being processed
163163+1. **Make sure you're sending links in a DM to the bot**, not in a server channel
164164+2. Open a Direct Message with the bot first
165165+3. Check that you're posting a link from a supported site
166166+4. Verify your user ID is authorized (use `/help` to see your ID)
167167+5. Check the console logs for errors
168168+### Links aren't being processed
169169+1. Check that you're posting in a channel the bot can see
170170+2. Verify your user ID is authorized
171171+3. Check the console logs for errors
172172+4. Ensure the link is from a supported site
173173+174174+### Can't find my Discord ID
175175+1. Or use the `/help` command - it shows your Discord ID
176176+4. The ID should be 17-20 digits long
177177+178178+### Bot not responding in DMs
179179+1. Make sure you've opened a DM with the bot (click on the bot's profile and "Message")
180180+2. Check that the bot is online
181181+3. Verify the bot has the correct intents enabled (see setup steps)
182182+4. Check console logs for any errorsettings (User Settings > Advanced)
183183+2. Right-click your username and select "Copy User ID"
184184+3. The ID should be 17-20 digits long
185185+186186+## Migration from Webhook
187187+188188+If you're currently using the webhook system:
189189+190190+1. Keep your existing `DISCORD_WEBHOOK_URL` in .env as a fallback
191191+2. Add the new Discord bot configuration variables
192192+3. The bot will automatically use bot mode if `DISCORD_BOT_TOKEN` is set
193193+4. The webhook will still be used if the bot fails to initialize
194194+195195+## Security Notes
196196+197197+- **Never share your bot token!** It gives full access to your bot
198198+- Keep your `.env` file private and never commit it to version control
199199+- Only add trusted users to `DISCORD_AUTHORIZED_SNOWFLAKES`
200200+- The owner ID has special privileges (future admin commands)
+148
docs/discord-compliance-update.md
···11+# Discord Bot Compliance Update
22+33+## Changes Made
44+55+This update brings the Discord bot into compliance with Discord's app guidelines by implementing two key changes:
66+77+### 1. ✅ Slash Commands (Application Commands)
88+**Before:** Prefix commands (`!help`, `!queue`, `!status`)
99+**After:** Slash commands (`/help`, `/queue`, `/status`)
1010+1111+**Why:** Discord strongly recommends using their native Application Commands system for better UX and discoverability.
1212+1313+**Benefits:**
1414+- Commands appear in Discord's UI with autocomplete
1515+- Built-in parameter validation
1616+- Better user experience
1717+- Follows Discord's best practices
1818+- More discoverable for users
1919+2020+### 2. ✅ DM-Only Link Processing
2121+**Before:** Links processed in any channel the bot could see
2222+**After:** Links only processed when sent via Direct Message to the bot
2323+2424+**Why:** Prevents spam in public channels and follows Discord's guidelines for bot behavior.
2525+2626+**Benefits:**
2727+- Prevents channel spam
2828+- More intentional user interaction
2929+- Complies with Discord's app guidelines
3030+- Better control over bot interactions
3131+- Cleaner server channels
3232+3333+## Updated Files
3434+3535+### Core Bot Files
3636+- `bot/discordBot.js` - Added slash command registration via Discord API
3737+- `bot/discordbot/commandRegistry.js` - Added `getCommandData()` method for slash commands
3838+- `bot/discordbot/commands/linkHandler.js` - Restricted to DM only (ChannelType.DM)
3939+4040+### Command Files (All converted to slash commands)
4141+- `bot/discordbot/commands/help.js` - Now uses SlashCommandBuilder
4242+- `bot/discordbot/commands/queue.js` - Now uses SlashCommandBuilder with options
4343+- `bot/discordbot/commands/status.js` - Now uses SlashCommandBuilder
4444+4545+### Helper Files
4646+- `bot/discordbot/helpers/queueHelper.js` - Updated to work with interactions instead of messages
4747+4848+### Documentation
4949+- `docs/discord-bot-setup.md` - Updated with slash command info and DM requirements
5050+- `docs/discord-bot-implementation.md` - Updated technical details
5151+5252+## How to Use
5353+5454+### Commands (Work everywhere)
5555+```
5656+/help - Show available commands and your Discord ID
5757+/queue [page] - View the queue (authorized users only)
5858+/status - View bot status (authorized users only)
5959+```
6060+6161+### Adding Links (DM only)
6262+1. Open a Direct Message with the bot
6363+2. Send any supported link (Bluesky, e621, FurAffinity, etc.)
6464+3. Bot processes if you're authorized
6565+6666+## Technical Details
6767+6868+### Slash Command Registration
6969+When the bot starts, it automatically registers all slash commands with Discord using the REST API:
7070+7171+```javascript
7272+const rest = new REST({ version: '10' }).setToken(config.discord.botToken);
7373+await rest.put(
7474+ Routes.applicationCommands(this.client.user.id),
7575+ { body: commandData }
7676+);
7777+```
7878+7979+### DM Detection
8080+Link handler now checks channel type:
8181+8282+```javascript
8383+if (message.channel.type !== ChannelType.DM) {
8484+ return; // Ignore non-DM messages
8585+}
8686+```
8787+8888+### Interaction Handling
8989+Commands now use `interaction.reply()` instead of `message.reply()`:
9090+9191+```javascript
9292+async execute(interaction) {
9393+ await interaction.reply({ content: '...', ephemeral: true });
9494+}
9595+```
9696+9797+## Authorization
9898+9999+Authorization still works the same way via `DISCORD_AUTHORIZED_SNOWFLAKES`:
100100+- Only authorized users can use `/queue` and `/status`
101101+- Only authorized users can submit links (in DMs)
102102+- `/help` is available to everyone
103103+104104+## Migration Notes
105105+106106+**No breaking changes to configuration!**
107107+108108+Your existing `.env` file continues to work:
109109+```env
110110+DISCORD_BOT_TOKEN=your_token
111111+DISCORD_CHANNEL_ID=your_channel_id
112112+DISCORD_AUTHORIZED_SNOWFLAKES=id1,id2,id3
113113+DISCORD_OWNER_ID=your_id
114114+```
115115+116116+**User-facing changes:**
117117+- Users must use `/` commands instead of `!` commands
118118+- Users must DM the bot to submit links (not post in channels)
119119+120120+## Testing Checklist
121121+122122+- [ ] Bot starts and logs in successfully
123123+- [ ] Slash commands appear in Discord UI
124124+- [ ] `/help` works and shows your Discord ID
125125+- [ ] `/queue` shows queue (if authorized)
126126+- [ ] `/status` shows bot status (if authorized)
127127+- [ ] Sending link in DM works (if authorized)
128128+- [ ] Sending link in server channel is ignored
129129+- [ ] Unauthorized users see error messages
130130+131131+## Compliance Benefits
132132+133133+This update ensures the bot follows Discord's guidelines:
134134+135135+✅ **Uses Application Commands** - Best practice for all Discord bots
136136+✅ **Respects channel context** - Links only in DMs prevent spam
137137+✅ **Clear authorization** - Users know immediately if they're authorized
138138+✅ **Better UX** - Slash commands are more discoverable
139139+✅ **Future-proof** - Built on Discord's recommended systems
140140+141141+## Next Steps
142142+143143+1. Restart the bot to apply changes
144144+2. Test slash commands in your server
145145+3. Open a DM with the bot and test link submission
146146+4. Update any user documentation about how to use the bot
147147+148148+The bot is now fully compliant with Discord's app guidelines! 🎉
-91
examples/image-hash-example.js
···11-/**
22- * Example: Using the Image Hash Manager
33- *
44- * This example demonstrates how to use the perceptual hashing module
55- * to detect duplicate and similar images.
66- */
77-88-const ImageHashManager = require('./utils/imageHashManager');
99-const path = require('path');
1010-1111-async function example() {
1212- // Initialize the hash manager
1313- const hashManager = new ImageHashManager();
1414-1515- console.log('=== Image Perceptual Hashing Example ===\n');
1616-1717- try {
1818- // Example 1: Process an image
1919- console.log('1. Processing an image:');
2020- const imagePath = path.join(__dirname, 'cache', 'images', 'd9fa3f2934bb020b7612b6b367184ff9.webp');
2121- const imageUrl = 'https://example.com/test-image.jpg';
2222-2323- // Note: This will only work if the image exists in your cache
2424- // const result = await hashManager.processImage(imageUrl, imagePath);
2525- // console.log(' Hash:', result.hash);
2626- // console.log(' Stored:', result.stored);
2727- console.log(' (Run with actual cached images)\n');
2828-2929- // Example 2: Get database statistics
3030- console.log('2. Database statistics:');
3131- const stats = hashManager.getStats();
3232- console.log(' Total images:', stats.totalImages);
3333- console.log(' Images (last 7 days):', stats.imagesLastWeek);
3434- console.log(' Database path:', stats.databasePath);
3535- console.log('');
3636-3737- // Example 3: List recent hashes
3838- console.log('3. Recent image hashes:');
3939- const recentHashes = hashManager.getAllHashes(5);
4040- recentHashes.forEach((record, index) => {
4141- console.log(` ${index + 1}. ${record.perceptual_hash}`);
4242- console.log(` URL: ${record.url.substring(0, 60)}...`);
4343- });
4444- console.log('');
4545-4646- // Example 4: Find similar images
4747- if (recentHashes.length > 0) {
4848- console.log('4. Finding similar images:');
4949- const testHash = recentHashes[0].perceptual_hash;
5050- const similar = hashManager.findSimilarImages(testHash, 5);
5151- console.log(` Found ${similar.length} similar image(s) to hash ${testHash}`);
5252- similar.forEach((img, index) => {
5353- console.log(` ${index + 1}. Distance: ${img.distance}, URL: ${img.url.substring(0, 50)}...`);
5454- });
5555- console.log('');
5656- }
5757-5858- // Example 5: Search by URL
5959- if (recentHashes.length > 0) {
6060- console.log('5. Searching by URL:');
6161- const testUrl = recentHashes[0].url;
6262- const found = hashManager.getHashByUrl(testUrl);
6363- if (found) {
6464- console.log(` Found: ${found.perceptual_hash}`);
6565- console.log(` Cached at: ${found.cached_at}`);
6666- }
6767- console.log('');
6868- }
6969-7070- // Example 6: Cleanup orphaned hashes
7171- console.log('6. Cleaning up orphaned records:');
7272- const cleaned = await hashManager.cleanupOrphanedHashes();
7373- console.log(` Removed ${cleaned} orphaned record(s)`);
7474- console.log('');
7575-7676- console.log('=== Example Complete ===');
7777-7878- } catch (error) {
7979- console.error('Error:', error);
8080- } finally {
8181- // Always close the database connection
8282- hashManager.close();
8383- }
8484-}
8585-8686-// Run the example
8787-if (require.main === module) {
8888- example().catch(console.error);
8989-}
9090-9191-module.exports = example;
+71-18
index.js
···11require('dotenv').config();
22+const config = require('./config');
23const StagehandBot = require('./bot/telegramBot');
33-const DiscordWebhook = require('./bot/discordWebhook');
44+const DiscordStagehandBot = require('./bot/discordBot');
55+const discordWebhook = require('./bot/discordWebhook');
46const queueManager = require('./queue/queueManager');
57const mediaCache = require('./utils/mediaCache');
68const updater = require('./utils/updater');
79810// Check if required environment variables are set
911if (!process.env.BOT_TOKEN) {
1010- console.error('Error: BOT_TOKEN environment variable is not set');
1212+ console.error('[Startup] BOT_TOKEN environment variable is not set');
1113 process.exit(1);
1214}
13151416if (!process.env.CHANNEL_ID) {
1515- console.error('Error: CHANNEL_ID environment variable is not set');
1717+ console.error('[Startup] CHANNEL_ID environment variable is not set');
1618 process.exit(1);
1719}
18201921let telegramBot;
2020-let discordWebhook;
2222+let discordBot;
2323+let shouldPreferDiscordBot = false;
21242225// Initialize the bots and services
2326try {
2427 // Initialize media cache first
2525- console.log('Initializing media cache...');
2828+ console.log('[Startup] Initializing media cache...');
26292730 // Initialize the Telegram bot
2831 telegramBot = new StagehandBot();
2929- console.log('Stagehand Telegram bot started successfully');
3030- console.log(`Posting to Telegram channel: ${process.env.CHANNEL_ID}`);
3232+ console.log('[Startup] Stagehand Telegram bot started successfully');
3333+ console.log(`[Startup] Posting to Telegram channel: ${process.env.CHANNEL_ID}`);
3434+3535+ // Initialize Discord - use bot if token is provided and has authorized users, otherwise use webhook
3636+ const isPlaceholderToken = !config.discord.botToken ||
3737+ config.discord.botToken.includes('your_') ||
3838+ config.discord.botToken.includes('_here');
3939+4040+ if (config.discord.useBot && config.discord.botToken && !isPlaceholderToken &&
4141+ config.discord.channelId && config.discord.authorizedSnowflakes.length > 0) {
4242+ shouldPreferDiscordBot = true;
4343+ try {
4444+ console.log('[Startup] Initializing Discord bot...');
4545+ discordBot = new DiscordStagehandBot();
4646+ // Success message will be logged after successful login in discordBot.init()
4747+ } catch (error) {
4848+ console.error('[Startup] Failed to initialize Discord bot:', error.message);
4949+ console.log('[Startup] Discord bot mode is enabled; webhook fallback is disabled');
5050+ discordBot = null;
5151+ }
5252+ }
5353+5454+ if (telegramBot && typeof telegramBot.setDiscordPostFunction === 'function') {
5555+ const discordPostFunction = discordBot
5656+ ? discordBot.postMedia.bind(discordBot)
5757+ : null;
5858+ telegramBot.setDiscordPostFunction(discordPostFunction);
5959+ }
31603232- // Initialize Discord webhook if enabled
3333- discordWebhook = DiscordWebhook;
3434- if (discordWebhook.isEnabled()) {
3535- console.log('Discord webhook integration enabled');
6161+ // Log webhook status if bot is not initialized
6262+ if (!discordBot) {
6363+ if (discordWebhook.isEnabled() && !shouldPreferDiscordBot) {
6464+ console.log('[Startup] Discord webhook integration enabled (bot not configured)');
6565+ } else if (isPlaceholderToken) {
6666+ console.log('[Startup] Discord bot token is a placeholder - update .env with real token to enable bot mode');
6767+ } else if (config.discord.botToken && !config.discord.authorizedSnowflakes.length) {
6868+ console.log('[Startup] Discord bot token provided but no authorized snowflakes configured - webhook mode active');
6969+ } else if (shouldPreferDiscordBot) {
7070+ console.log('[Startup] Discord bot mode active; webhook fallback disabled');
7171+ }
3672 }
37733874 // Send startup notification to owner (unless this is an auto-update restart)
···4985 if (flagExists) {
5086 // Delete the flag and skip notification
5187 await fs.remove(flagFile);
5252- console.log('Auto-update restart detected, skipping startup notification');
8888+ console.log('[Startup] Auto-update restart detected, skipping startup notification');
5389 return;
5490 }
5591···5793 if (config.ownerId) {
5894 const startupMessage = '✅ Bot has started successfully!';
5995 await telegramBot.bot.sendMessage(config.ownerId, startupMessage);
6060- console.log('Startup notification sent to owner');
9696+ console.log('[Startup] Startup notification sent to owner');
6197 }
6298 } catch (error) {
6363- console.error('Error handling startup notification:', error);
9999+ console.error('[Startup] Error handling startup notification:', error);
64100 }
65101 };
66102···6910570106 // Start the scheduler with both posting services
71107 const postFunctions = {
7272- telegram: telegramBot.postMedia.bind(telegramBot),
7373- discord: discordWebhook.postMedia.bind(discordWebhook)
108108+ telegram: telegramBot.postMedia.bind(telegramBot)
74109 };
110110+111111+ if (discordBot) {
112112+ postFunctions.discord = discordBot.postMedia.bind(discordBot);
113113+ } else if (!shouldPreferDiscordBot && discordWebhook.isEnabled()) {
114114+ postFunctions.discord = discordWebhook.postMedia.bind(discordWebhook);
115115+ }
116116+117117+ queueManager.postServices = Object.keys(postFunctions);
118118+ queueManager.initializeServiceTracking();
75119 queueManager.startScheduler(postFunctions);
7676- console.log('Post scheduler started');
120120+ console.log('[Startup] Post scheduler started');
7712178122 // Start the auto-updater
79123 updater.start();
···102146 await telegramBot.shutdown();
103147 }
104148149149+ // Discord bot cleanup
150150+ if (discordBot && discordBot.shutdown) {
151151+ await discordBot.shutdown();
152152+ }
153153+105154 // Discord webhook cleanup
106155 if (discordWebhook && discordWebhook.shutdown) {
107156 await discordWebhook.shutdown();
···134183 await telegramBot.shutdown();
135184 }
136185186186+ if (discordBot && discordBot.shutdown) {
187187+ await discordBot.shutdown();
188188+ }
189189+137190 if (discordWebhook && discordWebhook.shutdown) {
138191 await discordWebhook.shutdown();
139192 }
···144197 });
145198146199} catch (error) {
147147- console.error('Error starting services:', error);
200200+ console.error('[Startup] Error starting services:', error);
148201 process.exit(1);
149202}
···11const axios = require('axios');
22-const cheerio = require('cheerio');
32const BaseScraper = require('./baseScraper');
43const mediaCache = require('../utils/mediaCache');
54const config = require('../config');
···109 return e621Pattern.test(url);
1110 }
12111212+ /**
1313+ * Extract post ID from e621 URL
1414+ * Supports both /posts/{id} and legacy /post/show/{id} formats
1515+ */
1616+ extractPostId(url) {
1717+ // Try modern format: https://e621.net/posts/12345
1818+ let match = url.match(/\/posts\/(\d+)/);
1919+ if (match) {
2020+ return match[1];
2121+ }
2222+2323+ // Try legacy format: https://e621.net/post/show/12345
2424+ match = url.match(/\/post\/show\/(\d+)/);
2525+ if (match) {
2626+ return match[1];
2727+ }
2828+2929+ throw new Error('Could not extract post ID from e621 URL');
3030+ }
3131+1332 async extract(url) {
1433 try {
1515- // Use a common user agent to avoid being blocked
3434+ // Extract post ID from URL
3535+ const postId = this.extractPostId(url);
3636+3737+ // e621 API requires a descriptive User-Agent for identification
1638 const headers = {
1717- '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'
3939+ 'User-Agent': 'Stagehand/1.1.0 (e621-bot)'
1840 };
19412020- const response = await axios.get(url, { headers });
2121- const $ = cheerio.load(response.data);
2222-2323- // Check for download link - for both images and videos
2424- let mediaUrl = $('#image-download-link').attr('href') ||
2525- $('a[download]').attr('href') ||
2626- $('#image').attr('src');
2727-2828- // Extract OpenGraph data
2929- const ogImage = $('meta[property="og:image"]').attr('content');
3030- const ogUrl = $('meta[property="og:url"]').attr('content') || url;
3131-3232- // Find potential video sources - only from actual video elements
3333- let videoUrl = $('video source').attr('src') ||
3434- $('video').attr('src');
4242+ // Call e621 API to get post data
4343+ const apiUrl = `https://e621.net/posts/${postId}.json`;
4444+ const response = await axios.get(apiUrl, { headers });
35453636- // Use OpenGraph image if direct media URL not found
3737- if (!mediaUrl && ogImage) {
3838- mediaUrl = ogImage;
4646+ if (!response.data || !response.data.post) {
4747+ throw new Error('Invalid API response from e621');
3948 }
40494141- // Determine if we're dealing with a video post
4242- // Only consider it a video if we found an actual video element OR the URL has video extension
4343- const isVideo = (videoUrl && this.isVideoUrl(videoUrl)) || this.isVideoUrl(mediaUrl);
5050+ const post = response.data.post;
44514545- // If it's a video, use the direct video URL
4646- if (isVideo && videoUrl) {
4747- mediaUrl = videoUrl;
5252+ // Check if post has been deleted or is unavailable
5353+ if (!post.file || !post.file.url) {
5454+ // Try fallback to sample if available
5555+ if (post.sample && post.sample.url) {
5656+ console.warn(`Post ${postId}: Full file URL not available, using sample`);
5757+ } else {
5858+ throw new Error('Post media is not available (possibly deleted or restricted)');
5959+ }
4860 }
49615050- if (!mediaUrl) {
5151- throw new Error('Could not find media on e621 page');
5252- }
6262+ // Get the full-size media URL (preferred) or sample as fallback
6363+ const mediaUrl = post.file.url || post.sample.url;
6464+ const fileExt = post.file.ext;
53655454- // Make sure URLs are absolute
5555- if (!mediaUrl.startsWith('http')) {
5656- if (mediaUrl.startsWith('//')) {
5757- mediaUrl = 'https:' + mediaUrl;
5858- } else if (mediaUrl.startsWith('/')) {
5959- mediaUrl = 'https://e621.net' + mediaUrl;
6060- }
6666+ if (!mediaUrl) {
6767+ throw new Error('Could not find media URL in API response');
6168 }
62696363- // Store the original source URL before processing
6464- const sourceImageUrl = mediaUrl;
7070+ // Determine if this is a video based on file extension
7171+ const videoExtensions = ['webm', 'mp4', 'mov'];
7272+ const isVideo = videoExtensions.includes(fileExt);
65736674 // Process and cache the media, passing the original post URL
6775 const processed = await mediaCache.processMediaUrl(mediaUrl, isVideo, url);
···7280 imageUrl: processed.localPath,
7381 videoUrl: processed.localPath,
7482 isVideo: true,
7575- sourceUrl: ogUrl || url,
7676- title: "e621 Video", // Generic title without post-specific text
8383+ sourceUrl: url,
8484+ title: "e621 Video",
7785 siteName: 'e621',
7878- originalVideoUrl: sourceImageUrl,
7979- sourceImgUrl: sourceImageUrl // Add the sourceImgUrl field
8686+ originalVideoUrl: mediaUrl,
8787+ sourceImgUrl: mediaUrl
8088 };
8189 } else {
8290 return {
8391 imageUrl: processed.localPath,
8484- sourceUrl: ogUrl || url,
8585- title: "e621 Image", // Generic title without post-specific text
9292+ sourceUrl: url,
9393+ title: "e621 Image",
8694 siteName: 'e621',
8795 isVideo: false,
8888- originalImageUrl: sourceImageUrl,
8989- sourceImgUrl: sourceImageUrl // Add the sourceImgUrl field
9696+ originalImageUrl: mediaUrl,
9797+ sourceImgUrl: mediaUrl
9098 };
9199 }
92100 } catch (error) {
101101+ // Provide more specific error messages
102102+ if (error.response) {
103103+ if (error.response.status === 404) {
104104+ throw new Error(`e621 post not found (may be deleted or invalid ID)`);
105105+ } else if (error.response.status === 429) {
106106+ throw new Error(`e621 API rate limit exceeded, please try again later`);
107107+ } else {
108108+ throw new Error(`e621 API error (${error.response.status}): ${error.message}`);
109109+ }
110110+ }
93111 console.error('Error extracting data from e621:', error);
94112 throw new Error(`Failed to extract data from e621: ${error.message}`);
95113 }
+6-6
utils/announcementManager.js
···2929 await this.loadAnnouncementsFromDisk();
3030 this.scheduleAllAnnouncements();
3131 this.initialized = true;
3232- console.log('Announcement manager initialized');
3232+ console.log('[AnnouncementManager] Announcement manager initialized');
3333 } catch (error) {
3434- console.error('Error initializing announcement manager:', error);
3434+ console.error('[AnnouncementManager] Error initializing announcement manager:', error);
3535 // If loading fails, start with empty announcements
3636 this.announcements = [];
3737 await this.saveAnnouncementsToDisk();
···7373 // Save migrated data if needed
7474 if (migrationNeeded) {
7575 await this.saveAnnouncementsToDisk();
7676- console.log('Migrated announcement button format to support multiple buttons');
7676+ console.log('[AnnouncementManager] Migrated announcement button format to support multiple buttons');
7777 }
78787979- console.log(`Loaded ${this.announcements.length} announcements from disk`);
7979+ console.log(`[AnnouncementManager] Loaded ${this.announcements.length} announcements from disk`);
8080 } catch (error) {
8181- console.error('Error loading announcements from disk:', error);
8181+ console.error('[AnnouncementManager] Error loading announcements from disk:', error);
8282 throw error;
8383 }
8484 }
···347347 this.scheduleAnnouncement(announcement);
348348 });
349349350350- console.log(`Scheduled ${this.announcements.length} announcements`);
350350+ console.log(`[AnnouncementManager] Scheduled ${this.announcements.length} announcements`);
351351 }
352352353353 /**
+126-16
utils/imageHashManager.js
···11const fs = require('fs-extra');
22const path = require('path');
33+const crypto = require('crypto');
34const Database = require('better-sqlite3');
45const imghash = require('imghash');
56const config = require('../config');
···1314 // Database path
1415 const dbDir = path.join(__dirname, '..', 'data');
1516 this.dbPath = path.join(dbDir, 'image_hashes.db');
1717+ this.perceptualHashBits = this.getPerceptualHashBits();
16181719 // Initialize database
1820 this.initDatabase();
1921 }
20222123 /**
2424+ * Resolve configured perceptual hash bit length
2525+ * @returns {number}
2626+ */
2727+ getPerceptualHashBits() {
2828+ const configuredBits = config.imageHash && Number.isInteger(config.imageHash.perceptualHashBits)
2929+ ? config.imageHash.perceptualHashBits
3030+ : 16;
3131+3232+ // Keep this conservative: even number and within a practical range for imghash.
3333+ if (configuredBits < 8 || configuredBits > 32 || configuredBits % 2 !== 0) {
3434+ return 16;
3535+ }
3636+3737+ return configuredBits;
3838+ }
3939+4040+ /**
2241 * Initialize the SQLite database and create tables if they don't exist
2342 */
2443 initDatabase() {
···3554 id INTEGER PRIMARY KEY AUTOINCREMENT,
3655 url TEXT NOT NULL,
3756 perceptual_hash TEXT NOT NULL,
5757+ content_hash TEXT,
3858 file_path TEXT,
3959 cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
4060 metadata TEXT,
···4565 CREATE INDEX IF NOT EXISTS idx_perceptual_hash ON image_hashes(perceptual_hash);
4666 CREATE INDEX IF NOT EXISTS idx_url ON image_hashes(url);
4767 `);
6868+6969+ // Backward-compat: add content_hash when upgrading existing databases.
7070+ const columns = this.db.prepare('PRAGMA table_info(image_hashes)').all();
7171+ const hasContentHash = columns.some(col => col.name === 'content_hash');
7272+ if (!hasContentHash) {
7373+ this.db.exec('ALTER TABLE image_hashes ADD COLUMN content_hash TEXT');
7474+ }
7575+7676+ // Create this after migration check to avoid failures on older DBs.
7777+ this.db.exec('CREATE INDEX IF NOT EXISTS idx_content_hash ON image_hashes(content_hash)');
48784949- console.log('Image hash database initialized');
7979+ console.log('[ImageHash] Image hash database initialized');
5080 } catch (error) {
5151- console.error('Error initializing image hash database:', error);
8181+ console.error('[ImageHash] Error initializing image hash database:', error);
5282 throw error;
5383 }
5484 }
···6292 try {
6393 // Use imghash to generate a perceptual hash
6494 // This creates a hash that is similar for similar-looking images
6565- const hash = await imghash.hash(filePath);
9595+ const hash = await imghash.hash(filePath, this.perceptualHashBits, 'hex');
6696 return hash;
6797 } catch (error) {
6898 console.error(`Error calculating hash for ${filePath}:`, error);
···71101 }
7210273103 /**
104104+ * Calculate strong content hash for exact image identity checks
105105+ * @param {string} filePath - Path to image file
106106+ * @returns {Promise<string>} - SHA256 hash string
107107+ */
108108+ async calculateContentHash(filePath) {
109109+ try {
110110+ const hash = crypto.createHash('sha256');
111111+112112+ return await new Promise((resolve, reject) => {
113113+ const stream = fs.createReadStream(filePath);
114114+ stream.on('error', reject);
115115+ stream.on('data', chunk => hash.update(chunk));
116116+ stream.on('end', () => resolve(hash.digest('hex')));
117117+ });
118118+ } catch (error) {
119119+ console.error(`Error calculating content hash for ${filePath}:`, error);
120120+ throw error;
121121+ }
122122+ }
123123+124124+ /**
74125 * Store image hash in the database
75126 * @param {string} url - Original URL of the image
76127 * @param {string} hash - Perceptual hash of the image
···78129 * @param {Object} metadata - Optional metadata to store
79130 * @returns {boolean} - Whether the operation was successful
80131 */
8181- storeHash(url, hash, filePath, metadata = null) {
132132+ storeHash(url, hash, filePath, metadata = null, contentHash = null) {
82133 try {
83134 const stmt = this.db.prepare(`
8484- INSERT INTO image_hashes (url, perceptual_hash, file_path, metadata)
8585- VALUES (?, ?, ?, ?)
135135+ INSERT INTO image_hashes (url, perceptual_hash, content_hash, file_path, metadata)
136136+ VALUES (?, ?, ?, ?, ?)
86137 ON CONFLICT(url) DO UPDATE SET
87138 perceptual_hash = excluded.perceptual_hash,
139139+ content_hash = excluded.content_hash,
88140 file_path = excluded.file_path,
89141 cached_at = CURRENT_TIMESTAMP,
90142 metadata = excluded.metadata
91143 `);
9214493145 const metadataJson = metadata ? JSON.stringify(metadata) : null;
9494- stmt.run(url, hash, filePath, metadataJson);
146146+ stmt.run(url, hash, contentHash, filePath, metadataJson);
9514796148 console.log(`Stored hash for URL: ${url.substring(0, 50)}...`);
97149 return true;
···117169118170 // Calculate perceptual hash
119171 const hash = await this.calculateHash(filePath);
172172+ const contentHash = await this.calculateContentHash(filePath);
120173121174 // Check if this URL already exists
122175 const existingByUrl = this.getHashByUrl(url);
···125178 return { hash, stored: true, isDuplicate: false };
126179 }
127180128128- // Check if this hash already exists with a different URL
129129- const existingByHash = this.getHashByHash(hash);
130130- if (existingByHash) {
181181+ // Exact duplicate check: only treat as duplicate when binary content matches.
182182+ const existingByContentHash = this.getHashByContentHash(contentHash);
183183+ if (existingByContentHash) {
131184 // Same image, different URL - add as alternate URL
132132- console.log(`Found duplicate image with hash ${hash}, adding alternate URL`);
133133- const added = this.addAlternateUrl(existingByHash.url, url);
134134- return { hash, stored: added, isDuplicate: true, primaryUrl: existingByHash.url };
185185+ console.log(`Found exact duplicate image with content hash ${contentHash.substring(0, 12)}..., adding alternate URL`);
186186+ const added = this.addAlternateUrl(existingByContentHash.url, url);
187187+ return { hash, stored: added, isDuplicate: true, primaryUrl: existingByContentHash.url };
135188 }
136189137190 // New image - store in database
138138- const stored = this.storeHash(url, hash, filePath, metadata);
191191+ const stored = this.storeHash(url, hash, filePath, metadata, contentHash);
139192140193 return { hash, stored, isDuplicate: false };
141194 } catch (error) {
···198251 return result;
199252 } catch (error) {
200253 console.error('Error getting hash by hash value:', error);
254254+ return null;
255255+ }
256256+ }
257257+258258+ /**
259259+ * Get all records by perceptual hash value
260260+ * @param {string} hash - Perceptual hash to look up
261261+ * @returns {Array} - Hash records (possibly empty)
262262+ */
263263+ getHashesByHash(hash) {
264264+ try {
265265+ const stmt = this.db.prepare(`
266266+ SELECT * FROM image_hashes WHERE perceptual_hash = ? ORDER BY cached_at DESC
267267+ `);
268268+269269+ const results = stmt.all(hash);
270270+271271+ return results.map(result => {
272272+ if (result.metadata) {
273273+ result.metadata = JSON.parse(result.metadata);
274274+ }
275275+ if (result.alternate_urls) {
276276+ result.alternate_urls = JSON.parse(result.alternate_urls);
277277+ }
278278+ return result;
279279+ });
280280+ } catch (error) {
281281+ console.error('Error getting hashes by hash value:', error);
282282+ return [];
283283+ }
284284+ }
285285+286286+ /**
287287+ * Get hash record by content hash value
288288+ * @param {string} contentHash - SHA256 content hash to look up
289289+ * @returns {Object|null} - Hash data or null if not found
290290+ */
291291+ getHashByContentHash(contentHash) {
292292+ try {
293293+ const stmt = this.db.prepare(`
294294+ SELECT * FROM image_hashes WHERE content_hash = ?
295295+ `);
296296+297297+ const result = stmt.get(contentHash);
298298+299299+ if (result) {
300300+ if (result.metadata) {
301301+ result.metadata = JSON.parse(result.metadata);
302302+ }
303303+ if (result.alternate_urls) {
304304+ result.alternate_urls = JSON.parse(result.alternate_urls);
305305+ }
306306+ }
307307+308308+ return result;
309309+ } catch (error) {
310310+ console.error('Error getting hash by content hash value:', error);
201311 return null;
202312 }
203313 }
···364474 }
365475 }
366476367367- console.log(`Cleaned up ${cleaned} orphaned hash records`);
477477+ console.log(`[ImageHash] Cleaned up ${cleaned} orphaned hash records`);
368478 return cleaned;
369479 } catch (error) {
370370- console.error('Error cleaning up orphaned hashes:', error);
480480+ console.error('[ImageHash] Error cleaning up orphaned hashes:', error);
371481 return 0;
372482 }
373483 }
+265-35
utils/mediaCache.js
···44const crypto = require('crypto-js');
55const ffmpeg = require('fluent-ffmpeg');
66const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
77+const sharp = require('sharp');
78const config = require('../config');
89const ImageHashManager = require('./imageHashManager');
910···20212122 // Maximum cache age in days (15 days by default)
2223 this.maxCacheAgeDays = config.maxCacheAgeDays || 15;
2424+2525+ // Retry ImageHashManager init periodically so recache can recover without restart.
2626+ this.imageHashManager = null;
2727+ this.imageHashLastRetryAt = 0;
2828+ this.imageHashRetryIntervalMs = 60 * 1000;
23292430 // Initialize image hash manager
2525- try {
2626- this.imageHashManager = new ImageHashManager();
2727- } catch (error) {
2828- console.error('Failed to initialize ImageHashManager:', error);
2929- this.imageHashManager = null;
3030- }
3131+ this.initializeImageHashManager();
31323233 // Initialize cache directories
3334 this.initCacheDirs();
···3738 }
38393940 /**
4141+ * Initialize ImageHashManager and report result
4242+ * @returns {boolean} - Whether initialization succeeded
4343+ */
4444+ initializeImageHashManager() {
4545+ try {
4646+ this.imageHashManager = new ImageHashManager();
4747+ console.log('[ImageHash] Hash manager initialized');
4848+ return true;
4949+ } catch (error) {
5050+ console.error('[ImageHash] Failed to initialize ImageHashManager:', error.message || error);
5151+ this.imageHashManager = null;
5252+ return false;
5353+ }
5454+ }
5555+5656+ /**
5757+ * Ensure the hash manager is available. Retries init on a cooldown.
5858+ * @returns {boolean} - Whether manager is available
5959+ */
6060+ ensureImageHashManager() {
6161+ if (this.imageHashManager) {
6262+ return true;
6363+ }
6464+6565+ const now = Date.now();
6666+ if (now - this.imageHashLastRetryAt < this.imageHashRetryIntervalMs) {
6767+ return false;
6868+ }
6969+7070+ this.imageHashLastRetryAt = now;
7171+ console.log('[ImageHash] Attempting to reinitialize hash manager...');
7272+ return this.initializeImageHashManager();
7373+ }
7474+7575+ /**
4076 * Initialize cache directories
4177 */
4278 async initCacheDirs() {
···4581 await fs.ensureDir(this.imageDir);
4682 await fs.ensureDir(this.videoDir);
4783 await fs.ensureDir(this.transcodedDir);
4848- console.log('Cache directories initialized');
8484+ console.log('[MediaCache] Cache directories initialized');
4985 } catch (error) {
5050- console.error('Error initializing cache directories:', error);
8686+ console.error('[MediaCache] Error initializing cache directories:', error);
5187 }
5288 }
5389···5995 const oneDayMs = 24 * 60 * 60 * 1000;
6096 setInterval(() => {
6197 this.cleanupCache().catch(err => {
6262- console.error('Error during cache cleanup:', err);
9898+ console.error('[MediaCache] Error during cache cleanup:', err);
6399 });
64100 }, oneDayMs);
6510166102 // Also run cleanup on startup
67103 this.cleanupCache().catch(err => {
6868- console.error('Error during initial cache cleanup:', err);
104104+ console.error('[MediaCache] Error during initial cache cleanup:', err);
105105+ });
106106+107107+ // Also compress oversized images on startup
108108+ this.compressOversizedCachedImages().catch(err => {
109109+ console.error('[Cache Compression] Error during oversized image compression:', err);
69110 });
70111 }
71112···93134 }
94135 }
951369696- console.log(`Found ${filesInUse.size} files currently in queue`);
137137+ console.log(`[MediaCache] Found ${filesInUse.size} files currently in queue`);
97138 return filesInUse;
98139 }
99140 } catch (error) {
100100- console.error('Error reading queue file for cleanup:', error);
141141+ console.error('[MediaCache] Error reading queue file for cleanup:', error);
101142 }
102143103144 return new Set();
···107148 * Clean up old cache files
108149 */
109150 async cleanupCache() {
110110- console.log('Starting cache cleanup...');
151151+ console.log('[MediaCache] Starting cache cleanup...');
111152 const now = Date.now();
112153 const maxAgeMs = this.maxCacheAgeDays * 24 * 60 * 60 * 1000;
113154···135176 // Check if file is older than max cache age
136177 if (now - stats.mtimeMs > maxAgeMs) {
137178 await fs.remove(filePath);
138138- console.log(`Removed old cache file: ${file}`);
179179+ console.log(`[MediaCache] Removed old cache file: ${file}`);
139180 }
140181 }
141182142183 if (skippedCount > 0) {
143143- console.log(`Skipped ${skippedCount} files in queue from ${path.basename(dir)}`);
184184+ console.log(`[MediaCache] Skipped ${skippedCount} files in queue from ${path.basename(dir)}`);
144185 }
145186 } catch (error) {
146146- console.error(`Error cleaning directory ${dir}:`, error);
187187+ console.error(`[MediaCache] Error cleaning directory ${dir}:`, error);
147188 }
148189 };
149190···153194 await cleanDir(this.transcodedDir);
154195155196 // Clean up orphaned hash records
197197+ this.ensureImageHashManager();
156198 if (this.imageHashManager) {
157199 try {
158200 await this.imageHashManager.cleanupOrphanedHashes();
159201 } catch (error) {
160160- console.error('Error cleaning orphaned hashes:', error);
202202+ console.error('[ImageHash] Error cleaning orphaned hashes:', error);
161203 }
162204 }
163205164164- console.log('Cache cleanup completed');
206206+ console.log('[MediaCache] Cache cleanup completed');
207207+ }
208208+209209+ /**
210210+ * Compress all oversized images already in the cache
211211+ * @returns {Promise<{checked: number, compressed: number}>}
212212+ */
213213+ async compressOversizedCachedImages() {
214214+ console.log('[Cache Compression] Scanning cached images for oversized files...');
215215+ let checked = 0;
216216+ let compressed = 0;
217217+218218+ try {
219219+ const files = await fs.readdir(this.imageDir);
220220+221221+ for (const file of files) {
222222+ const filePath = path.join(this.imageDir, file);
223223+ const ext = path.extname(file).toLowerCase();
224224+225225+ // Only check JPEG and PNG images
226226+ if (['.jpg', '.jpeg', '.png'].includes(ext)) {
227227+ checked++;
228228+ try {
229229+ const wasCompressed = await this.compressImageIfNeeded(filePath);
230230+ if (wasCompressed) {
231231+ compressed++;
232232+ }
233233+ } catch (error) {
234234+ console.error(`[Cache Compression] Error compressing ${file}:`, error.message);
235235+ }
236236+ }
237237+ }
238238+239239+ if (compressed > 0) {
240240+ console.log(`[Cache Compression] Compressed ${compressed} oversized images out of ${checked} checked`);
241241+ } else {
242242+ console.log(`[Cache Compression] No oversized images found (checked ${checked} files)`);
243243+ }
244244+ } catch (error) {
245245+ console.error('[Cache Compression] Error scanning image directory:', error);
246246+ }
247247+248248+ return { checked, compressed };
165249 }
166250167251 /**
···198282 }
199283200284 /**
285285+ * Get file size in bytes
286286+ * @param {string} filePath - Path to file
287287+ * @returns {Promise<number>} - File size in bytes
288288+ */
289289+ async getFileSize(filePath) {
290290+ const stats = await fs.stat(filePath);
291291+ return stats.size;
292292+ }
293293+294294+ /**
295295+ * Format bytes to human-readable string
296296+ * @param {number} bytes - Number of bytes
297297+ * @returns {string} - Formatted string
298298+ */
299299+ formatFileSize(bytes) {
300300+ if (bytes < 1024) return `${bytes} B`;
301301+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
302302+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
303303+ }
304304+305305+ /**
306306+ * Compress an image if it exceeds Telegram's 10 MB photo limit
307307+ * @param {string} filePath - Path to image file
308308+ * @returns {Promise<boolean>} - True if compression was performed
309309+ */
310310+ async compressImageIfNeeded(filePath) {
311311+ const MAX_SIZE = 10 * 1024 * 1024; // 10 MB in bytes
312312+ const ext = path.extname(filePath).toLowerCase();
313313+314314+ // Only compress JPEG and PNG images
315315+ if (!['.jpg', '.jpeg', '.png'].includes(ext)) {
316316+ return false;
317317+ }
318318+319319+ try {
320320+ const originalSize = await this.getFileSize(filePath);
321321+322322+ if (originalSize <= MAX_SIZE) {
323323+ return false; // No compression needed
324324+ }
325325+326326+ console.log(`[Compression] Image exceeds 10 MB limit: ${this.formatFileSize(originalSize)}`);
327327+ console.log(`[Compression] Starting compression for: ${path.basename(filePath)}`);
328328+329329+ const image = sharp(filePath);
330330+ const metadata = await image.metadata();
331331+332332+ let compressed = false;
333333+ let currentQuality = 90;
334334+335335+ // Stage 1: Try reducing quality
336336+ while (currentQuality >= 70) {
337337+ const tempPath = filePath + '.tmp';
338338+339339+ await image
340340+ .jpeg({ quality: currentQuality, mozjpeg: true })
341341+ .toFile(tempPath);
342342+343343+ const newSize = await this.getFileSize(tempPath);
344344+345345+ if (newSize <= MAX_SIZE) {
346346+ // Replace original with compressed version
347347+ await fs.move(tempPath, filePath, { overwrite: true });
348348+ console.log(`[Compression] Success with quality ${currentQuality}%: ${this.formatFileSize(originalSize)} → ${this.formatFileSize(newSize)} (${((1 - newSize/originalSize) * 100).toFixed(2)}% reduction)`);
349349+ compressed = true;
350350+ break;
351351+ }
352352+353353+ await fs.unlink(tempPath);
354354+ currentQuality -= 10;
355355+ }
356356+357357+ // Stage 2: If quality reduction isn't enough, try resizing
358358+ if (!compressed) {
359359+ console.log(`[Compression] Quality reduction insufficient, attempting resize`);
360360+361361+ const maxDimension = 4096;
362362+ let width = metadata.width;
363363+ let height = metadata.height;
364364+365365+ if (width > maxDimension || height > maxDimension) {
366366+ if (width > height) {
367367+ height = Math.round((height / width) * maxDimension);
368368+ width = maxDimension;
369369+ } else {
370370+ width = Math.round((width / height) * maxDimension);
371371+ height = maxDimension;
372372+ }
373373+ }
374374+375375+ const tempPath = filePath + '.tmp';
376376+ await sharp(filePath)
377377+ .resize(width, height, { fit: 'inside', withoutEnlargement: true })
378378+ .jpeg({ quality: 85, mozjpeg: true })
379379+ .toFile(tempPath);
380380+381381+ const newSize = await this.getFileSize(tempPath);
382382+383383+ if (newSize <= MAX_SIZE) {
384384+ await fs.move(tempPath, filePath, { overwrite: true });
385385+ console.log(`[Compression] Success with resize to ${width}x${height}: ${this.formatFileSize(originalSize)} → ${this.formatFileSize(newSize)} (${((1 - newSize/originalSize) * 100).toFixed(2)}% reduction)`);
386386+ compressed = true;
387387+ } else {
388388+ await fs.unlink(tempPath);
389389+ console.log(`[Compression] Warning: Could not compress below 10 MB limit. Final size: ${this.formatFileSize(newSize)}`);
390390+ }
391391+ }
392392+393393+ return compressed;
394394+ } catch (error) {
395395+ console.error(`[Compression] Error compressing image: ${error.message}`);
396396+ return false;
397397+ }
398398+ }
399399+400400+ /**
201401 * Get file extension from URL or content type
202402 * @param {string} url - URL to extract extension from
203403 * @param {string} contentType - Content-Type header
···320520 if (!isVideo) {
321521 isVideo = this.isVideoUrl(url, contentType);
322522 }
523523+524524+ // For images, retry hash manager init periodically if startup init failed.
525525+ if (!isVideo) {
526526+ this.ensureImageHashManager();
527527+ }
323528324529 // For videos, use URL hash as before
325530 // For images, we'll calculate perceptual hash after download
···391596 return new Promise((resolve, reject) => {
392597 writer.on('finish', async () => {
393598 try {
599599+ if (!isVideo && !this.imageHashManager) {
600600+ this.ensureImageHashManager();
601601+ }
602602+394603 // If it's an image, calculate perceptual hash and rename
395604 if (!isVideo && this.imageHashManager) {
396605 try {
···400609401610 // Calculate perceptual hash
402611 const perceptualHash = await this.imageHashManager.calculateHash(tempFilePath);
612612+ const contentHash = await this.imageHashManager.calculateContentHash(tempFilePath);
403613 console.log(`[ImageHash] Calculated perceptual hash: ${perceptualHash}`);
404614405615 // Rename file to use perceptual hash
406406- const hashFilename = `${perceptualHash}${ext}`;
407407- finalFilePath = path.join(storageDir, hashFilename);
408408-409409- // Check if file with this hash already exists
410410- if (await this.isValidCacheFile(finalFilePath)) {
411411- console.log(`[ImageHash] File with same perceptual hash already exists: ${hashFilename}`);
412412- // Delete temp file
616616+ const canonicalFilename = `${perceptualHash}${ext}`;
617617+ const canonicalFilePath = path.join(storageDir, canonicalFilename);
618618+ finalFilePath = canonicalFilePath;
619619+620620+ // Determine whether an existing file with same perceptual hash is an exact duplicate.
621621+ const hashMatches = this.imageHashManager.getHashesByHash(perceptualHash);
622622+ const exactMatch = hashMatches.find(record =>
623623+ record.content_hash && record.content_hash === contentHash
624624+ );
625625+626626+ if (exactMatch && await this.isValidCacheFile(exactMatch.file_path)) {
627627+ console.log(`[ImageHash] Exact duplicate detected for perceptual hash: ${perceptualHash}`);
413628 await fs.unlink(tempFilePath);
414414-415415- // Add alternate URL if this is a different source
416416- const existing = this.imageHashManager.getHashByHash(perceptualHash);
417417- if (existing && existing.url !== urlForHash) {
418418- console.log(`[ImageHash] Adding alternate URL for existing hash`);
419419- this.imageHashManager.addAlternateUrl(perceptualHash, urlForHash);
629629+ finalFilePath = exactMatch.file_path;
630630+631631+ if (exactMatch.url !== urlForHash) {
632632+ console.log('[ImageHash] Adding alternate URL for exact duplicate');
633633+ this.imageHashManager.addAlternateUrl(exactMatch.url, urlForHash);
634634+ }
635635+ } else if (await this.isValidCacheFile(canonicalFilePath)) {
636636+ // Collision case: same perceptual hash but different content.
637637+ const variantFilename = `${perceptualHash}_${contentHash.substring(0, 12)}${ext}`;
638638+ finalFilePath = path.join(storageDir, variantFilename);
639639+640640+ if (await this.isValidCacheFile(finalFilePath)) {
641641+ await fs.unlink(tempFilePath);
642642+ } else {
643643+ await fs.rename(tempFilePath, finalFilePath);
644644+ console.log(`[ImageHash] Perceptual collision detected, saved variant: ${variantFilename}`);
645645+ await this.compressImageIfNeeded(finalFilePath);
420646 }
421647 } else {
422422- // Rename temp file to final hash-based name
423423- await fs.rename(tempFilePath, finalFilePath);
424424- console.log(`[ImageHash] Renamed to: ${hashFilename}`);
648648+ await fs.rename(tempFilePath, canonicalFilePath);
649649+ console.log(`[ImageHash] Renamed to: ${canonicalFilename}`);
650650+ await this.compressImageIfNeeded(canonicalFilePath);
425651 }
426652427653 // Store hash in database
···429655 contentType,
430656 downloadUrl: url,
431657 downloadedAt: new Date().toISOString()
432432- });
658658+ }, contentHash);
433659 console.log(`[ImageHash] Successfully stored hash in database`);
434660 } catch (error) {
435661 // If hashing fails, fall back to URL hash naming
···441667 // Move temp file to fallback name if it doesn't exist
442668 if (!await this.isValidCacheFile(finalFilePath)) {
443669 await fs.rename(tempFilePath, finalFilePath);
670670+ // Compress image if needed
671671+ await this.compressImageIfNeeded(finalFilePath);
444672 } else {
445673 await fs.unlink(tempFilePath);
446674 }
···452680 const fallbackFilename = `${fallbackHash}${ext}`;
453681 finalFilePath = path.join(storageDir, fallbackFilename);
454682 await fs.rename(tempFilePath, finalFilePath);
683683+ // Compress image if needed
684684+ await this.compressImageIfNeeded(finalFilePath);
455685 }
456686457687 resolve({ filePath: finalFilePath, contentType, isVideo });