plugins/plugin-discord/README.md
A Discord plugin implementation for ElizaOS, enabling rich integration with Discord servers for managing interactions, voice, and message handling.
As this is a workspace package, it's installed as part of the ElizaOS monorepo:
bun install
The plugin requires the following environment variables:
# Discord API Credentials (Required)
DISCORD_APPLICATION_ID=your_application_id
DISCORD_API_TOKEN=your_api_token
# Channel Restrictions (Optional)
# Comma-separated list of Discord channel IDs to restrict the bot to.
# If not set, the bot operates in all channels.
# These channels cannot be removed via the leaveChannel action.
CHANNEL_IDS=123456789012345678,987654321098765432
# Listen-only channels (Optional)
# Comma-separated list of channel IDs where the bot will only listen (not respond).
DISCORD_LISTEN_CHANNEL_IDS=123456789012345678
# Voice Channel (Optional)
# ID of the voice channel the bot should auto-join when scanning a guild.
# If not set, the bot selects based on member activity.
DISCORD_VOICE_CHANNEL_ID=123456789012345678
# Behavior Settings (Optional)
# If true, ignore messages from other bots (default: true)
DISCORD_SHOULD_IGNORE_BOT_MESSAGES=true
# If true, ignore direct messages by default (default: true).
# DMs can still be allowed explicitly via DISCORD_ALLOW_FROM / pairing allowlist.
DISCORD_SHOULD_IGNORE_DIRECT_MESSAGES=true
# If true, only respond when explicitly @mentioned (default: true)
DISCORD_SHOULD_RESPOND_ONLY_TO_MENTIONS=true
# Testing (Optional)
DISCORD_TEST_CHANNEL_ID=123456789012345678
Settings can also be configured in your character file under settings.discord:
{
"settings": {
"discord": {
"shouldIgnoreBotMessages": true,
"shouldIgnoreDirectMessages": true,
"shouldRespondOnlyToMentions": true,
"allowedChannelIds": ["123456789012345678"]
}
}
}
{
"plugins": ["@elizaos/plugin-discord"]
}
The plugin uses a hybrid permission system that combines Discord's native features with ElizaOS-specific controls.
Commands go through multiple permission checks in this order:
Discord Native Checks (before interaction fires):
ElizaOS Channel Whitelist (if CHANNEL_IDS is set):
bypassChannelWhitelist: trueCustom Validator (if provided):
import { PermissionFlagsBits } from "discord.js";
// Simple command (works everywhere)
const helpCommand = {
name: "help",
description: "Show help information",
};
// Guild-only command
const serverInfoCommand = {
name: "serverinfo",
description: "Show server information",
guildOnly: true,
};
// Requires Discord permission
const configCommand = {
name: "config",
description: "Configure bot settings",
requiredPermissions: PermissionFlagsBits.ManageGuild,
};
// Bypasses channel whitelist
const utilityCommand = {
name: "export",
description: "Export data",
bypassChannelWhitelist: true,
};
// Advanced: custom validation
const adminCommand = {
name: "admin",
description: "Admin-only command",
validator: async (interaction, runtime) => {
const adminIds = runtime.getSetting("ADMIN_USER_IDS")?.split(",") ?? [];
return adminIds.includes(interaction.user.id);
},
};
// Register commands
await runtime.emitEvent(["DISCORD_REGISTER_COMMANDS"], {
commands: [
helpCommand,
serverInfoCommand,
configCommand,
utilityCommand,
adminCommand,
],
});
| Option | Type | Description |
|---|---|---|
guildOnly | boolean | If true, command only works in guilds (not DMs) |
bypassChannelWhitelist | boolean | If true, bypasses CHANNEL_IDS restrictions |
requiredPermissions | bigint | string | Discord permission bitfield (e.g., PermissionFlagsBits.ManageGuild) |
contexts | number[] | Raw Discord contexts (0=Guild, 1=BotDM, 2=PrivateChannel) |
guildIds | string[] | Register only in specific guilds (instant updates) |
validator | function | Custom validation function for advanced logic |
From Discord.js PermissionFlagsBits:
ManageGuild - Server settingsManageChannels - Channel managementManageMessages - Delete messagesBanMembers - Ban usersKickMembers - Kick usersModerateMembers - Timeout usersManageRoles - Role managementAdministrator - Full accessWhy Hybrid Approach?
Why Simple Flags?
guildOnly: true is clearer than contexts: [0]Why Keep Channel Whitelist?
The plugin exposes shared connector actions only:
| Action | Description |
|---|---|
| MESSAGE | Send, read, search, list, react, edit, delete, pin, join, leave, or get user info through the Discord message connector |
Credential pairing is handled by connector account providers and owner-only slash commands, not by a Discord-specific planner action.
Discord message context is exposed through the MESSAGE connector hooks rather than Discord-specific prompt providers.
The plugin emits the following Discord-specific events:
| Event | Description |
|---|---|
DISCORD_MESSAGE_RECEIVED | When a message is received |
DISCORD_MESSAGE_SENT | When a message is sent |
DISCORD_SLASH_COMMAND | When a slash command is invoked |
DISCORD_MODAL_SUBMIT | When a modal form is submitted |
DISCORD_REACTION_RECEIVED | When a reaction is added to a message |
DISCORD_REACTION_REMOVED | When a reaction is removed from a message |
DISCORD_WORLD_JOINED | When the bot joins a guild |
DISCORD_SERVER_CONNECTED | When connected to a server |
DISCORD_USER_JOINED | When a user joins a guild |
DISCORD_USER_LEFT | When a user leaves a guild |
DISCORD_VOICE_STATE_CHANGED | When voice state changes |
DISCORD_CHANNEL_PERMISSIONS_CHANGED | When channel permissions change |
DISCORD_ROLE_PERMISSIONS_CHANGED | When role permissions change |
DISCORD_MEMBER_ROLES_CHANGED | When a member's roles change |
DISCORD_ROLE_CREATED | When a role is created |
DISCORD_ROLE_DELETED | When a role is deleted |
Main service class that extends ElizaOS Service:
Register slash commands via the DISCORD_REGISTER_COMMANDS event, then listen for interactions:
// Register custom slash commands
await runtime.emitEvent(["DISCORD_REGISTER_COMMANDS"], {
commands: [
{
name: "mycommand",
description: "My custom command",
options: [
{
name: "input",
description: "User input",
type: 3, // STRING type
required: true,
},
],
},
{
name: "serverinfo",
description: "Get server information",
guildOnly: true, // Only works in guilds, not DMs
},
],
});
// Listen for slash command events to handle the interaction
runtime.registerEvent({
name: "DISCORD_SLASH_COMMAND",
handler: async (payload) => {
const { interaction, client, commands } = payload;
if (interaction.commandName === "mycommand") {
const input = interaction.options.getString("input");
await interaction.reply(`You said: ${input}`);
}
},
});
The DISCORD_LISTEN_CHANNEL_IDS setting creates "listen-only" channels where the bot receives messages but doesn't respond. This is useful for:
// Check if a channel is listen-only
const listenChannels = runtime.getSetting("DISCORD_LISTEN_CHANNEL_IDS");
const listenChannelIds = listenChannels?.split(",").map((s) => s.trim()) || [];
runtime.registerEvent({
name: "DISCORD_MESSAGE_RECEIVED",
handler: async (payload) => {
const { message } = payload;
const channelId = message.content.channelId;
if (listenChannelIds.includes(channelId)) {
// This is a listen-only channel - process without responding
await processMessageSilently(message);
}
},
});
Modal submits and message components (buttons, select menus) bypass channel whitelists to support multi-step UI flows:
// Listen for modal submissions
runtime.registerEvent({
name: "DISCORD_MODAL_SUBMIT",
handler: async (payload) => {
const { interaction } = payload;
const fieldValue = interaction.fields.getTextInputValue("myField");
await interaction.reply(`Received: ${fieldValue}`);
},
});
The plugin includes a comprehensive permission audit system that tracks all permission changes with full audit log integration. This is useful for:
DISCORD_CHANNEL_PERMISSIONS_CHANGED - When channel overwrites change:
interface ChannelPermissionsChangedPayload {
runtime: IAgentRuntime;
guild: { id: string; name: string };
channel: { id: string; name: string };
target: { type: "role" | "user"; id: string; name: string };
action: "CREATE" | "UPDATE" | "DELETE";
changes: Array<{
permission: string; // e.g., 'ManageMessages', 'Administrator'
oldState: "ALLOW" | "DENY" | "NEUTRAL";
newState: "ALLOW" | "DENY" | "NEUTRAL";
}>;
audit: {
executorId: string;
executorTag: string;
reason: string | null;
} | null;
}
DISCORD_ROLE_PERMISSIONS_CHANGED - When role permissions change:
interface RolePermissionsChangedPayload {
runtime: IAgentRuntime;
guild: { id: string; name: string };
role: { id: string; name: string };
changes: PermissionDiff[];
audit: AuditInfo | null;
}
DISCORD_MEMBER_ROLES_CHANGED - When a member's roles change:
interface MemberRolesChangedPayload {
runtime: IAgentRuntime;
guild: { id: string; name: string };
member: { id: string; tag: string };
added: Array<{ id: string; name: string; permissions: string[] }>;
removed: Array<{ id: string; name: string; permissions: string[] }>;
audit: AuditInfo | null;
}
DISCORD_ROLE_CREATED / DISCORD_ROLE_DELETED - Role lifecycle:
interface RoleLifecyclePayload {
runtime: IAgentRuntime;
guild: { id: string; name: string };
role: { id: string; name: string; permissions: string[] };
audit: AuditInfo | null;
}
import { DiscordEventTypes } from "@elizaos/plugin-discord";
// Alert on dangerous permission grants
runtime.registerEvent({
name: DiscordEventTypes.CHANNEL_PERMISSIONS_CHANGED,
handler: async (payload) => {
const dangerousPerms = ["Administrator", "ManageGuild", "ManageRoles"];
for (const change of payload.changes) {
if (
dangerousPerms.includes(change.permission) &&
change.newState === "ALLOW"
) {
console.warn(`⚠️ Dangerous permission granted!`, {
channel: payload.channel.name,
target: payload.target.name,
permission: change.permission,
grantedBy: payload.audit?.executorTag || "Unknown",
});
}
}
},
});
// Track role escalations
runtime.registerEvent({
name: DiscordEventTypes.MEMBER_ROLES_CHANGED,
handler: async (payload) => {
const adminRoles = payload.added.filter((r) =>
r.permissions.includes("Administrator"),
);
if (adminRoles.length > 0) {
console.warn(`⚠️ Admin role granted to ${payload.member.tag}`, {
roles: adminRoles.map((r) => r.name),
grantedBy: payload.audit?.executorTag || "Unknown",
});
}
},
});
// Log all role creations
runtime.registerEvent({
name: DiscordEventTypes.ROLE_CREATED,
handler: async (payload) => {
console.log(`New role created: ${payload.role.name}`, {
permissions: payload.role.permissions,
createdBy: payload.audit?.executorTag || "Unknown",
});
},
});
Monitor when the bot's own permissions change:
runtime.registerEvent({
name: DiscordEventTypes.MEMBER_ROLES_CHANGED,
handler: async (payload) => {
const botId = runtime.getSetting("DISCORD_APPLICATION_ID");
if (payload.member.id === botId && payload.removed.length > 0) {
console.error(`🚨 Bot roles removed!`, {
removed: payload.removed.map((r) => r.name),
by: payload.audit?.executorTag || "Unknown",
});
// Could trigger alerts, notifications, etc.
}
},
});
This plugin includes a compatibility layer (compat.ts) that allows it to work with both old and new versions of @elizaos/core. The compatibility layer:
serverId vs messageServerId differencesWhen migrating to a new core version, see the comments in compat.ts for removal instructions.
The plugin includes a test suite for validating functionality:
bun run test
.env file includes the required DISCORD_API_TOKEN