State & Persistence Architecture
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);
}
}
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.