Developer Documentation
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' }
);
},
};
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:
-
Global Registration -
registerCommands()pushes slash commands to Discord via REST API -
Guild Permissions -
applyCommandPermissions()restricts admin commands to specific roles/users -
Runtime Routing -
interactionProcessor.jsroutes interactions to handlers viacoreLoader
Permission Levels:
.setDefaultMemberPermissions('0')- Everyone can use.setDefaultMemberPermissions('8')- Administrator only- Runtime auth checks in
@auth/auth-manager.jsadd 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.guildStatesMap (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
Configure Lavalink nodes via LAVALINK_NODES_ env var (see .env.example).
The bot supports multiple nodes with automatic failover.
Best Practices
Do:
-
Use
wrapInteractionfor 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.surahNamesexist before use -
Defer early - Call
deferUpdate()ordeferReply()before async operations - Use aliases - Stick to
@module/pathimports 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
.envand 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.