Voice Logger Plugin
An advanced Plexcord plugin that tracks voice channel activity using DataStore for persistence. Demonstrates Flux events, DataStore, settings, and multi-event handling.
Example: Voice Logger Plugin
Difficulty: Advanced | Concepts: DataStore, multiple Flux events, Webpack stores, settings, async persistence
This plugin logs voice channel join/leave activity and persists the history using DataStore. When someone you care about joins a voice channel, you get notified. All activity is stored so you can review who has been in voice channels even after restarting Discord.
Full Source
// File: src/plugins/voiceLogger/index.ts
import definePlugin, { OptionType } from "@utils/types";
import { definePluginSettings } from "@api/Settings";
import { DataStore } from "@api/DataStore";
import { showNotification } from "@api/Notifications";
import { UserStore, GuildStore, ChannelStore } from "@webpack/common";
import { Logger } from "@utils/Logger";
const logger = new Logger("VoiceLogger");
// Types
interface VoiceEntry {
userId: string;
username: string;
channelId: string;
channelName: string;
guildId: string | null;
guildName: string | null;
action: "join" | "leave" | "move";
timestamp: number;
}
// DataStore key
const STORE_KEY = "VoiceLogger_history";
const MAX_HISTORY = 500;
const settings = definePluginSettings({
notifyJoins: {
type: OptionType.BOOLEAN,
description: "Show a notification when someone joins a voice channel",
default: true,
restartNeeded: false
},
notifyLeaves: {
type: OptionType.BOOLEAN,
description: "Show a notification when someone leaves a voice channel",
default: false,
restartNeeded: false
},
watchedUsers: {
type: OptionType.STRING,
description: "Only notify for these user IDs (comma-separated). Leave blank for everyone.",
default: "",
restartNeeded: false
},
ignoreSelf: {
type: OptionType.BOOLEAN,
description: "Don't log or notify for your own voice state changes",
default: true,
restartNeeded: false
}
});
export default definePlugin({
name: "VoiceLogger",
description: "Logs voice channel activity and notifies you about watched users",
authors: [
{
name: "YourName",
id: 0n
}
],
settings,
// Track previous voice states to detect "move" vs "join/leave"
previousStates: new Map<string, string>(), // userId -> channelId
start() {
logger.info("VoiceLogger started");
},
stop() {
this.previousStates.clear();
logger.info("VoiceLogger stopped");
},
flux: {
VOICE_STATE_UPDATES({ voiceStates }) {
if (!Array.isArray(voiceStates)) return;
for (const state of voiceStates) {
try {
this.handleVoiceStateUpdate(state);
} catch (e) {
logger.error("Error handling voice state update:", e);
}
}
}
},
handleVoiceStateUpdate(state: any) {
const { userId, channelId, guildId } = state;
const me = UserStore.getCurrentUser();
// Ignore self if setting enabled
if (settings.store.ignoreSelf && userId === me?.id) return;
const prevChannelId = this.previousStates.get(userId);
const action: VoiceEntry["action"] = !channelId
? "leave"
: !prevChannelId
? "join"
: prevChannelId !== channelId
? "move"
: null as any;
// No change
if (!action) return;
// Update tracked state
if (channelId) {
this.previousStates.set(userId, channelId);
} else {
this.previousStates.delete(userId);
}
const user = UserStore.getUser(userId);
const channel = ChannelStore.getChannel(channelId || prevChannelId || "");
const guild = guildId ? GuildStore.getGuild(guildId) : null;
const entry: VoiceEntry = {
userId,
username: user?.username ?? userId,
channelId: channelId || prevChannelId || "",
channelName: channel?.name ?? "Unknown Channel",
guildId: guildId ?? null,
guildName: guild?.name ?? null,
action,
timestamp: Date.now()
};
// Save to DataStore (async, no await needed: fire and forget)
this.saveEntry(entry);
// Should we notify?
const watchedIds = settings.store.watchedUsers
.split(",")
.map(id => id.trim())
.filter(Boolean);
const shouldNotify = watchedIds.length === 0 || watchedIds.includes(userId);
if (shouldNotify) {
if (action === "join" && settings.store.notifyJoins) {
showNotification({
title: `🎤 ${entry.username} joined voice`,
body: `${entry.channelName}${entry.guildName ? ` · ${entry.guildName}` : ""}`,
color: "#43B581"
});
} else if (action === "leave" && settings.store.notifyLeaves) {
showNotification({
title: `🔇 ${entry.username} left voice`,
body: `${entry.channelName}${entry.guildName ? ` · ${entry.guildName}` : ""}`,
color: "#4F545C"
});
} else if (action === "move" && settings.store.notifyJoins) {
showNotification({
title: `↔️ ${entry.username} moved channels`,
body: `Now in: ${entry.channelName}${entry.guildName ? ` · ${entry.guildName}` : ""}`,
color: "#5865F2"
});
}
}
},
async saveEntry(entry: VoiceEntry) {
const history: VoiceEntry[] = (await DataStore.get(STORE_KEY)) ?? [];
history.push(entry);
// Trim to max history size
if (history.length > MAX_HISTORY) {
history.splice(0, history.length - MAX_HISTORY);
}
await DataStore.set(STORE_KEY, history);
},
// Utility: retrieve history (can be called from DevTools)
async getHistory(): Promise<VoiceEntry[]> {
return (await DataStore.get(STORE_KEY)) ?? [];
},
// Utility: clear history
async clearHistory(): Promise<void> {
await DataStore.del(STORE_KEY);
logger.info("Voice history cleared");
}
});Key Concepts Used
DataStore for Persistence
import { DataStore } from "@api/DataStore";
// Read history
const history = await DataStore.get("VoiceLogger_history") ?? [];
// Write history
await DataStore.set("VoiceLogger_history", history);Data persists across Discord restarts, stored in IndexedDB. Always provide a default (?? []) because the key won't exist on first run.
Multiple Flux Events
The VOICE_STATE_UPDATES event fires with an array of all changed states. Iterating with error handling per entry prevents one bad state from crashing all updates.
Tracking Previous State
previousStates: new Map<string, string>()This plugin-instance property stores each user's last known channel ID. By comparing it to the current channelId, we can distinguish:
join: had no previous channel, now has oneleave: had a channel, now has none (channelIdisnull)move: had a channel, now has a different one
The $self Pattern (not used here)
If this plugin needed patches, methods like saveEntry and getHistory would be accessible via $self.saveEntry(...) in replacement strings.
Accessing From DevTools
You can inspect stored history from the DevTools console:
// Get all stored history
Vencord.Plugins.plugins.VoiceLogger.getHistory().then(console.log);
// Clear history
Vencord.Plugins.plugins.VoiceLogger.clearHistory();Improvement Ideas
- Add a settings UI component (
OptionType.COMPONENT) to display history inline - Export history as a CSV or JSON file
- Filter by guild or time range
- Add mute/unmute and deafen/undeafen tracking (use
VOICE_STATE_UPDATESwithselfMute,selfDeaffields)