State & Persistence Architecture

Last Updated: 2026-05-26 • For System Architects & State Management Engineers

Overview

This guide details the core state management systems, persistence layer, azkar automation engine, and voice idle detection mechanisms.

  • Guild State Map & Facade
  • Firebase + Redis Persistence
  • Azkar Timer & Retry Engine
  • Voice Idle Detection

Guild State Architecture

QuranBot manages per-server runtime data via a centralized Map in src/state/guild-state-store.js, accessed through a facade in GuildStateManager.js.

Storage & Access

// src/state/guild-state-store.js
const guildStates = new Map();
global.guildStates = guildStates;

function getGuildStateById(guildId) {
    return guildStates.get(guildId);
}

Lazy Initialization

When getGuildState(guildId) is called and no state exists, the manager creates a default state:

// src/state/GuildStateManager.js
function getGuildState(guildId) {
    const store = getStore();
    
    if (!store.hasGuildState(guildId)) {
        const { createNewPlayer } = getPlayer();
        const player = createNewPlayer();
        const defaultReciter = Object.keys(global.reciters)[0] || 'reciter_1_ar';
        
        const newState = {
            player,
            connection: null,
            channelId: null,
            currentSurah: 1,
            currentReciter: defaultReciter,
            playbackMode: 'radio',
            controlMode: 'everyone',
            isPaused: true,
            savedQuranState: null,
            savedRadioState: null,
        };
        
        store.setGuildState(guildId, newState);
    }
    
    return store.getGuildStateById(guildId);
}

State Object Structure

The default state object contains all fields necessary for voice, UI, and azkar functionality:

Field Type Description
player LavalinkPlayer Lavalink audio player instance
connection object Discord voice connection wrapper
channelId string Voice channel ID bot is connected to
playbackMode 'surah' | 'radio' Current playback mode
currentReciter string Key in global.reciters
currentSurah number 1-114 surah index
controlMode 'admins' | 'everyone' Permission mode for UI
isPaused boolean Player paused state
savedQuranState object Saved surah/reciter/offset for mode switching
savedRadioState object Saved radio index/page/offset

Persistence & Serialization

State is persisted to Firebase via src/state/PersistentStateManager.js with debounced saves to prevent API rate limits.

Debounce & Throttle

// src/state/persist-storage.js
const save_debounce_ms = 60000;    // 1 min
const save_throttle_ms = 10000;    // 10s min between saves

function scheduleSave(guildId, guildStates, cleanStateFn) {
    if (saveTimeouts.has(guildId)) {
        clearTimeout(saveTimeouts.get(guildId));
    }
    
    const timeout = setTimeout(async () => {
        await saveGuildState(guildId, guildStates, cleanStateFn);
        saveTimeouts.delete(guildId);
    }, save_debounce_ms);
    
    saveTimeouts.set(guildId, timeout);
}

Serialization & Deep Merge

src/state/persist-utils.js provides cycle-safe cloning and merging:

// Cycle-aware clone
function deepCloneForFirebase(obj, seen = new WeakSet()) {
    if (seen.has(obj)) return undefined;
    seen.add(obj);
    
    if (Array.isArray(obj)) {
        return obj.map((item) => deepCloneForFirebase(item, seen));
    }
    // ... object handling
}

Redis Integration

When Redis is available, it acts as a hot cache alongside Firebase cold storage:

// src/state/PersistentStateManager.js
async function updateGuildState(guildId, updates) {
    const state = this.getGuildState(guildId);
    deepMerge(state, updates);
    
    // Schedule debounced Firebase save
    scheduleSave(guildId, this.guildStates, cleanState);
    
    // Immediate Redis update
    if (redis.isRedisReady) {
        redis.set(`quranbot:guild:${guildId}`, cleanState(state))
            .catch((err) => logger.debug(`Redis update failed for ${guildId}`, err));
    }
}

Azkar Automation Engine

The Azkar system handles timed reminders with randomization, image/audio delivery, and self-healing on failure.

Timer Management

  • Interval: 30 minutes (azkar_interval_ms)
  • Expiry: Azkar messages expire after 10 days
  • Retries: Up to 5 attempts with exponential backoff
  • Self-Heal: Disables timer if channel missing or 10 consecutive permission failures

Delivery Logic (src/state/azkar-sender.js)

// Random selection + retry loop
async function sendRandomAzkar(cid, gid, maxRetry = 5, forceImg = false) {
    const data = global.azkarData || [];
    const cat = data[Math.floor(Math.random() * data.length)];
    const dhikr = cat.array[Math.floor(Math.random() * cat.array.length)];
    const ts = Date.now();
    
    // 50% chance to send image if available
    const useImg = forceImg || (global.azkarImages?.length && Math.random() > 0.5);
    
    if (useImg && global.azkarImages?.length) {
        const img = global.azkarImages[Math.floor(Math.random() * global.azkarImages.length)];
        return sendImageAzkar(ch, img, ts, gid, maxRetry, cid);
    }
    
    // Fallback to audio button
    if (dhikr.audio) {
        return sendAudioAzkar(ch, dhikr, clean_Dhikr(dhikr.text), ts, gid, maxRetry, cid);
    }
}
Self-Healing

If a channel is deleted, the timer clears immediately. If permissions fail 10 times, the channel ID is nulled and the timer stops to prevent log spam.

Cooldown System

Two-layer cooldown system: per-command and global rate limits.

Command Cooldowns

Command Duration Scope
/تحكم 25s Per user
/دخول 15s Per user
/إعداد 60s Per server
/مواقيت_الصلاة 50s Per user

Cleanup

Expired entries are purged every 60 seconds via initCleanup() in commandCooldown.js.

Voice Idle Detection

src/state/voice-idle.js monitors voice channels and pauses playback if empty for 30 seconds, resuming when users return.

// 30s idle timer
const t = setTimeout(async () => {
    const latest = getCtx(guildId);
    if (!latest || latest.isPaused) return;
    
    const ch = await resolveChannel(client, latest.channelId);
    if (ch && users(ch) === 0) {
        await pause(latest, guildId, client);
    }
    
    timers.delete(guildId);
}, 30000);

Troubleshooting State Issues

Azkar timer stopped unexpectedly

Causes:

  • Channel deleted or permissions lost
  • 10 consecutive send failures
  • Bot restart without state restoration

Check guildState.azkarChannelId and restart via /إعداد.

Playback pauses automatically

The Voice Idle system pauses playback if the channel is empty for 30s. This is intentional to save resources. Playback resumes automatically when a human joins.