Developer Documentation

Last Updated: 2026-05-26 • For Contributors & Developers

Quick Start

This documentation covers the core architecture of QuranBot and guides you through extending its functionality.

  • Node.js v22 required
  • Firebase + Redis backend
  • Lavalink v4 audio engine

Project Architecture

QuranBot follows a modular, alias-based architecture designed for scalability and maintainability.

Key Components:

  • @startup/botSetup.js - Client initialization, Lavalink setup, global state
  • @ready/readyHandler.js - Post-login orchestration and data loading
  • @interactions/ - Command/button/menu interaction routing
  • @audio/ - Lavalink player management and resource creation
  • @state/ - Guild state persistence and voice idle handling
  • @database/ - Firebase + Redis data layer abstraction

All modules use pathlra-aliaser for clean imports:

// Example: Importing from aliased paths
require('pathlra-aliaser')();

const logger = require('@logging/logger');
const { createSurahResource } = require('@audio');
const { getGuildState } = require('@state/GuildStateManager');

Folder Structure Overview

QuranBot/
├── src/
│   ├── audio/           # Lavalink player, resource creation, recovery
│   ├── auth/            # Permission checks and authorization logic
│   ├── bot/             # Bootstrap and core entry point
│   ├── commands/        # Slash command definitions (/دخول, /تحكم, etc.)
│   ├── config/          # Constants, env switching, HTTP config
│   ├── data/            # Data loaders for Quran, reciters, radios
│   ├── database/        # Firebase, Redis, local backup services
│   ├── events/          # Voice state, guild events, welcome messages
│   ├── helpers/         # Utility functions (formatting, emojis)
│   ├── interactions/    # Button/menu/modal handlers + routing
│   ├── logging/         # Structured logging with rotation
│   ├── ready/           # Post-login initialization routines
│   ├── registry/        # Command registration and permission application
│   ├── runtime/         # Runtime state persistence
│   ├── startup/         # Client setup, Lavalink configuration
│   ├── state/           # Guild state, cooldowns, voice idle logic
│   ├── statistics/      # Firebase-backed usage metrics
│   └── ui/              # Embed builders, component factories
├── .env.example         # Environment variable template
├── development.env.example         # Environment variable development
├── production.env.example         # Environment variable production
├── package.json         # Dependencies and scripts
└── ecosystem.config.js  # PM2 process management

Adding a New Slash Command

Step 1: Create the Command File

Create a new file in src/commands/, e.g., mycommand.js:

// src/commands/mycommand.js
require('pathlra-aliaser')();

const { wrapInteraction, safeReply } = require('@interactions/flow/responder');
const { resolveGuildState } = require('@auth/guard');
const logger = require('@logging/logger');

module.exports = {
    async execute(interaction) {
        await wrapInteraction(
            interaction,
            async () => {
                const { guildId, guildState } = resolveGuildState(interaction);
                
                // Your command logic here
                await safeReply(
                    interaction, 
                    { content: 'Command executed successfully!', flags: 64 },
                    'mycommand_executed'
                );
            },
            { ephemeral: true, label: 'mycommand' }
        );
    },
};
Always use wrapInteraction

This wrapper handles defer/reply logic, error boundaries, and ephemeral messaging consistently across all commands.

Step 2: Register the Command

Add your command to src/registry/commandregistry.js:

// Inside registerCommands() function
const cmds = [
    // ... existing commands
    new SlashCommandBuilder()
        .setName('mycommand')
        .setDescription('Description of what my command does')
        .setDefaultMemberPermissions('0', // '0' = everyone, '8' = admin
].map(c => c.toJSON());

Step 3: Add to Bootstrap Exports

Export your command in src/bot/bootstrap.js for interaction routing:

// src/bot/bootstrap.js
const myCommand = require('@commands/mycommand');

// ... later in module.exports
module.exports.myCommand = myCommand;

Command Registration Flow

Commands are registered globally via Discord API and permissions are applied per-guild:

  1. Global Registration - registerCommands() pushes slash commands to Discord via REST API
  2. Guild Permissions - applyCommandPermissions() restricts admin commands to specific roles/users
  3. Runtime Routing - interactionProcessor.js routes interactions to handlers via coreLoader

Permission Levels:

  • .setDefaultMemberPermissions('0') - Everyone can use
  • .setDefaultMemberPermissions('8') - Administrator only
  • Runtime auth checks in @auth/auth-manager.js add granular control

Ready Handler Setup

The readyHandler.js orchestrates post-login initialization. To integrate new functionality:

Typical Initialization Sequence:

// src/ready/readyHandler.js - simplified flow
loadData() // Load Quran data, reciters, radios
    .then(async () => {
        await persistentStateManager.initialize();
        await initializeStats();
        
        client.once('clientReady', async () => {
            // 1. Initialize Lavalink
            await client.lavalink.init(client.user);
            
            // 2. Restore runtime states
            await runtimeStates.restoreRuntimeStates(client);
            
            // 3. Process guild recovery
            for (const guildId of guildsToProcess) {
                await recoverAzkarTimers(guild, setupData, guildId);
                await recoverVoiceConnection(guild, setupData, guildId);
            }
            
            // 4. Register commands
            await registerAllCommands(client);
        });
        
        // 5. Login to Discord
        await client.login(global.token);
    });

Adding Custom Ready Logic:

Create a new file in src/ready/ and import it in readyHandler.js:

// src/ready/myFeature.js
require('pathlra-aliaser')();
const logger = require('@logging/logger');

async function initializeMyFeature(client) {
    // Your initialization logic
    logger.info('My feature initialized');
    return true;
}

module.exports.initializeMyFeature = initializeMyFeature;

Then in readyHandler.js, call it after loadData() resolves.

Cooldown System

QuranBot implements a dual-layer cooldown system: per-command and global rate limiting.

Command Cooldowns (src/state/commandCooldown.js)

// Define cooldowns in COMMAND_COOLDOWNS
const COMMAND_COOLDOWNS = {
    control: { duration: 25000, type: 'user' },   // 25s per user
    join: { duration: 15000, type: 'user' },        // 15s per user
    setup: { duration: 60000, type: 'server' },     // 60s per server
};

// Map Arabic command names to keys
const CMD_MAP = {
    تحكم: 'control',
    دخول: 'join',
    إعداد: 'setup',
};

Using Cooldowns in Commands:

// Inside your command execute function
const cdResult = coreLoader.checkCooldown(
    interaction.user.id, 
    interaction.guildId, 
    'mycommand'
);

if (!cdResult.allowed) {
    await safeError(
        interaction, 
        coreLoader.getCooldownResponse(cdResult.remaining, cdResult.type)
    );
    return;
}

// ... execute command logic ...

// Set cooldown AFTER successful execution
coreLoader.setCooldown(interaction.user.id, interaction.guildId, 'mycommand');

Global Rate Limiting:

Prevents spam across all interactions via checkRateLimit() in interactionProcessor.js:

// Default: 100 actions per 60 seconds per user+guild
const rateLimitResult = coreLoader.checkRateLimit(
    userId, 
    guildId, 
    100,   // maxActions
    60000  // windowMs
);

if (!rateLimitResult.valid) {
    // Block interaction with rate limit message
}

State Management

Guild-specific state is managed through a layered system:

State Layers:

  • Runtime - global.guildStates Map (in-memory, fast access)
  • Persistent - Firebase Realtime Database (survives restarts)
  • Cache - Redis (distributed, optional fallback)

Accessing Guild State:

const { getGuildState } = require('@state/GuildStateManager');

const guildState = getGuildState(guildId);
// Returns object with: player, connection, currentSurah, playbackMode, etc.

// Update state (auto-persists with debounce)
guildState.currentSurah = 114;
await global.saveRuntimeStates(); // Force immediate save if needed

Key State Properties:

Property Type Description
playbackMode 'surah' | 'radio' Current playback mode
currentSurah number 1-114 surah index
currentReciter string Reciter key (e.g., 'reciter_1_ar')
controlMode 'admins' | 'everyone' Permission mode for control panel
playedOffset number Playback position in ms for resume

Audio Integration (Lavalink)

QuranBot uses Lavalink v4 for stable, scalable audio playback.

Creating Audio Resources:

const { createSurahResource, createRadioResource } = require('@audio');

// Create surah resource (auto-handles fallbacks)
const track = await createSurahResource(guildState, surahIndex);

// Create radio resource
const radioTrack = await createRadioResource(radioUrl);

// Play via Lavalink player
if (track) {
    guildState.player.play({ track });
    
    // Optional: seek to offset for resume
    if (guildState.playedOffset > 0) {
        await guildState.player.seek(guildState.playedOffset);
    }
}

Node Selection & Load Balancing:

Use getBestNode() for automatic least-loaded node selection:

const { getBestNode } = require('@startup/botSetup');

const bestNode = getBestNode(client.lavalink);
// Returns node with lowest player count that's under maxPlayers limit
Environment Configuration

Configure Lavalink nodes via LAVALINK_NODES_ env var (see .env.example). The bot supports multiple nodes with automatic failover.

Best Practices

Do:

  • Use wrapInteraction for all command/button handlers to ensure consistent error handling
  • Log with context - Use logger.info(guildId, 'message') for traceability
  • Validate inputs - Check global.reciters, global.surahNames exist before use
  • Defer early - Call deferUpdate() or deferReply() before async operations
  • Use aliases - Stick to @module/path imports for consistency

Testing Your Changes:

# Development mode with hot reload
NODE_ENV=development pnpm run dev

# Production mode (requires full env setup)
NODE_ENV=production pnpm run start

# Run with PM2 for process management
pnpm run pm2:start

# View logs
pnpm run pm2:logs

Troubleshooting Guide

Common Issues:

Commands not appearing in Discord

Global command registration can take up to 1 hour to propagate. For testing, use a guild-specific registration by modifying Routes.applicationCommands to Routes.applicationGuildCommands with your test server ID.

Voice connection fails with "No compatible encryption modes"

This indicates a Lavalink node issue. Check:

  • Node is running and reachable
  • Password matches in .env and Lavalink config
  • Node version is compatible (v4.x required)

Use /سرعة command to check node status.

State not persisting after restart

Verify Firebase credentials in .env. The bot auto-saves state with a 60s debounce. Force save with global.saveRuntimeStates() for immediate persistence during testing.

Debug Logging:

Enable verbose logging by setting LOG_LEVEL=debug in your environment. Logs are written to storage/logs/ with automatic rotation.