This guide provides comprehensive instructions for creating custom extensions for PlexBot. Extensions allow you to add new features, commands, music providers, and functionality to the bot without modifying the core codebase.
- Overview
- Extension Lifecycle
- Extension Structure
- Creating a Basic Extension
- Required Properties and Methods
- Creating Slash Commands
- Registering Services
- Adding a Music Provider
- Using the Event Bus
- Extension Configuration
- Example Extension: Custom Music Source
- Packaging and Distribution
- Best Practices
- Troubleshooting
Extensions in PlexBot are modular components that inherit from the Extension base class. They are built from source at startup — PlexBot automatically runs dotnet build for each extension project it finds. Extensions use shared .props files to reference the host without a ProjectReference, keeping them fully decoupled. The extension system provides:
- Build-at-startup: Extensions are compiled automatically from source when the bot starts
- Isolation: Extensions operate independently of the core bot code
- Dependency Management: Extensions can depend on other extensions (loaded in topological order)
- Service Registration: Extensions register services into the real DI container
- Command Discovery: Slash commands in extension assemblies are automatically registered with Discord
- Music Providers: Extensions can add new music sources (SoundCloud, Spotify, etc.) via
IMusicProvider - Event Bus: Extensions can subscribe to bot lifecycle events (track started, bot ready, etc.)
- Configuration: Per-extension config via
extensions.{id}.*keys inconfig.fds
ServiceRegistration.AddExtensionServices()
├── ExtensionManager created (source dir + bin dir)
├── DiscoverAndInstantiateAsync()
│ ├── For each extension folder with a .csproj:
│ │ ├── dotnet build (passes HostOutputDir for DLL reference)
│ │ ├── Load compiled DLL via ExtensionLoadContext
│ │ └── Discover Extension subtypes, instantiate them
│ └── Returns list of discovered extensions
├── extension.RegisterServices(IServiceCollection) ← registers into REAL DI container
└── (container is built)
BotHostedService.StartAsync()
├── extensionManager.InitializeAllAsync(IServiceProvider) ← extensions get the built container
├── Music providers registered with MusicProviderRegistry
└── (Discord Ready)
├── interactions.AddModulesAsync(ext.SourceAssembly) ← auto-discovers slash commands
└── eventBus.PublishAsync(BotReady)
A typical extension consists of:
.csprojfile: ImportsPlexBot.extension.propsfor build configuration- Main Extension Class: Inherits from
PlexBot.Core.Extensions.Extension - Commands Module(s): Classes that implement slash commands using Discord.NET's interaction framework
- Services: Additional services that provide business logic
- Music Providers: Optional
IMusicProviderimplementations for new music sources
Extensions live in subdirectories under the Extensions/ folder at the project root. Each extension must have its own directory containing a .csproj file.
PlexBot/
├── Extensions/
│ ├── README.md
│ ├── MyExtension/
│ │ ├── MyExtension.csproj
│ │ ├── MyExtension.cs
│ │ └── MyCommands.cs
│ └── AnotherExtension/
│ ├── AnotherExtension.csproj
│ └── ...
├── PlexBot.extension.props ← shared build properties
├── PlexBot.deps.props ← shared NuGet dependencies
└── PlexBot.csproj ← host project (no extension references)
Create a directory for your extension inside the Extensions folder:
Extensions/MyFirstExtension/
Create a minimal .csproj that imports the shared props file. This gives your extension access to PlexBot's types and all shared NuGet dependencies without managing references manually:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>MyFirstExtension</AssemblyName>
</PropertyGroup>
<Import Project="../../PlexBot.extension.props" />
</Project>That's it for project setup. PlexBot.extension.props handles:
- Target framework (net9.0)
- Reference to the pre-built
PlexBot.dll(viaHostOutputDirpassed at build time) - Shared NuGet packages (Discord.Net, Lavalink4NET, etc.) via
PlexBot.deps.props - Build settings (nullable, implicit usings, etc.)
If your extension needs additional NuGet packages not in the shared deps, add them directly:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>MyFirstExtension</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<Import Project="../../PlexBot.extension.props" />
</Project>using PlexBot.Core.Extensions;
using PlexBot.Utils;
namespace MyFirstExtension;
public class MyExtension : Extension
{
public override string Id => "my-first-extension";
public override string Name => "My First Extension";
public override string Version => "1.0.0";
public override string Author => "Your Name";
public override string Description => "A simple extension for PlexBot";
// Optional
public override string MinimumBotVersion => "1.0.0";
public override IEnumerable<string> Dependencies => [];
protected override async Task<bool> OnInitializeAsync(IServiceProvider services)
{
Logs.Info($"{Name} is initializing...");
// Your initialization logic here
// The full DI container is available via 'services'
return true; // Return true for successful initialization
}
public override void RegisterServices(IServiceCollection services)
{
// Register services BEFORE the container is built
// These become available to your commands and other extensions
services.AddSingleton<MyService>();
}
public override async Task ShutdownAsync()
{
Logs.Info($"{Name} is shutting down...");
await base.ShutdownAsync();
}
}| Property | Description | Example |
|---|---|---|
Id |
Unique identifier (no spaces, lowercase) | "my-extension" |
Name |
User-friendly display name | "My Extension" |
Version |
Semantic version (MAJOR.MINOR.PATCH) | "1.0.0" |
Author |
Creator's name | "Your Name" |
Description |
Brief functionality explanation | "Adds weather commands" |
| Property | Description | Default |
|---|---|---|
MinimumBotVersion |
Minimum compatible bot version | "1.0.0" |
Dependencies |
IDs of required extensions | [] |
| Method | Description | Return Value |
|---|---|---|
OnInitializeAsync |
Initialization logic (receives built IServiceProvider) | Task<bool> (true = success) |
| Method | Description |
|---|---|
RegisterServices |
Register services into DI container (called before container is built) |
ShutdownAsync |
Clean up resources on shutdown |
Extensions can read per-extension configuration from config.fds using built-in helpers:
// Reads from config key "extensions.my-extension.apiKey"
string apiKey = GetConfig("apiKey", "default-value");
bool enabled = GetConfigBool("enabled", true);
int maxResults = GetConfigInt("maxResults", 25);
double threshold = GetConfigDouble("threshold", 0.5);Slash commands in extension assemblies are automatically discovered during Discord's Ready event. Just create a public class inheriting from InteractionModuleBase<SocketInteractionContext>:
using Discord;
using Discord.Interactions;
namespace MyFirstExtension;
[Group("myext", "My extension commands")]
public class MyCommands : InteractionModuleBase<SocketInteractionContext>
{
private readonly MyService _myService;
public MyCommands(MyService myService)
{
_myService = myService;
}
[SlashCommand("ping", "Returns a pong response")]
public async Task PingCommand()
{
await RespondAsync("Pong! Extension is working!");
}
[SlashCommand("greet", "Greets a user")]
public async Task GreetCommand(
[Summary("user", "The user to greet")] IUser user,
[Summary("greeting", "The greeting to use")] string greeting = "Hello")
{
await RespondAsync($"{greeting}, {user.Mention}!");
}
}No manual registration needed — PlexBot scans your extension's assembly automatically.
Register services in RegisterServices() — this runs before the DI container is built, so your services are available everywhere:
public override void RegisterServices(IServiceCollection services)
{
services.AddSingleton<MyService>();
services.AddScoped<MyOtherService>();
services.AddTransient<MyDisposableService>();
}Extensions can add new music sources by implementing IMusicProvider and registering it. The provider will automatically appear in /search autocomplete and handle search/browse operations.
using PlexBot.Core.Models;
using PlexBot.Core.Models.Media;
using PlexBot.Core.Services.Music;
namespace MyMusicExtension;
public class SoundCloudProvider(IHttpClientFactory httpClientFactory) : IMusicProvider
{
public string Id => "soundcloud";
public string DisplayName => "SoundCloud";
public bool IsAvailable => true;
public int Priority => 20; // Lower = appears first in autocomplete
public MusicProviderCapabilities Capabilities =>
MusicProviderCapabilities.Search | MusicProviderCapabilities.UrlPlayback;
public async Task<SearchResults> SearchAsync(string query, CancellationToken ct = default)
{
// Implement search logic
SearchResults results = new() { Query = query, SourceSystem = "soundcloud" };
// ... add tracks to results.Tracks
return results;
}
public async Task<Track?> GetTrackDetailsAsync(string trackKey, CancellationToken ct = default)
{
// Return track details or null if not supported
return null;
}
// Implement other methods (return empty lists/null for unsupported features)
public Task<List<Track>> GetTracksAsync(string containerKey, CancellationToken ct = default) =>
Task.FromResult(new List<Track>());
public Task<List<Album>> GetAlbumsAsync(string artistKey, CancellationToken ct = default) =>
Task.FromResult(new List<Album>());
public Task<List<Track>> GetAllArtistTracksAsync(string artistKey, CancellationToken ct = default) =>
Task.FromResult(new List<Track>());
public Task<List<Playlist>> GetPlaylistsAsync(CancellationToken ct = default) =>
Task.FromResult(new List<Playlist>());
public Task<Playlist?> GetPlaylistDetailsAsync(string playlistKey, CancellationToken ct = default) =>
Task.FromResult<Playlist?>(null);
}public override void RegisterServices(IServiceCollection services)
{
// Register as IMusicProvider — PlexBot auto-registers it with MusicProviderRegistry
services.AddSingleton<IMusicProvider, SoundCloudProvider>();
}| Flag | Description |
|---|---|
Search |
Provider supports text search |
TrackDetails |
Can retrieve individual track details |
Albums |
Can browse albums |
Playlists |
Can browse playlists |
ArtistBrowse |
Can browse artist discographies |
UrlPlayback |
Can play from direct URLs |
Extensions can subscribe to bot lifecycle events via BotEventBus:
using PlexBot.Core.Events;
protected override async Task<bool> OnInitializeAsync(IServiceProvider services)
{
BotEventBus eventBus = services.GetRequiredService<BotEventBus>();
// Subscribe to track events
eventBus.Subscribe(BotEvents.TrackStarted, async (evt) =>
{
string title = evt.Data["title"] as string ?? "Unknown";
string artist = evt.Data["artist"] as string ?? "Unknown";
ulong guildId = (ulong)evt.Data["guildId"];
Logs.Info($"Now playing: {title} by {artist} in guild {guildId}");
});
eventBus.Subscribe(BotEvents.BotReady, async (evt) =>
{
int guildCount = (int)evt.Data["guildCount"];
Logs.Info($"Bot is ready! Connected to {guildCount} guilds");
});
return true;
}| Event | Data Keys | Description |
|---|---|---|
track.started |
title, artist, guildId |
A track started playing |
track.ended |
title, guildId, endReason |
A track finished |
queue.changed |
guildId |
Queue was modified |
player.created |
guildId |
Player was created |
player.destroyed |
guildId |
Player was destroyed |
extension.loaded |
extensionId, extensionName |
An extension was loaded |
bot.ready |
guildCount |
Bot connected to Discord |
Add per-extension configuration in config.fds:
extensions:
my-extension:
apiKey: your-api-key-here
maxResults: 25
enabled: true
Access it using the built-in helpers in your extension class:
string apiKey = GetConfig("apiKey");
int maxResults = GetConfigInt("maxResults", 25);Here's a complete extension that adds a hypothetical music source:
Extensions/
└── MyMusicSource/
├── MyMusicSource.csproj
├── MyMusicExtension.cs
├── MyMusicProvider.cs
└── MyMusicCommands.cs
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>MyMusicSource</AssemblyName>
</PropertyGroup>
<Import Project="../../PlexBot.extension.props" />
</Project>using Microsoft.Extensions.DependencyInjection;
using PlexBot.Core.Events;
using PlexBot.Core.Extensions;
using PlexBot.Core.Services.Music;
using PlexBot.Utils;
namespace MyMusicSource;
public class MyMusicExtension : Extension
{
public override string Id => "my-music-source";
public override string Name => "My Music Source";
public override string Version => "1.0.0";
public override string Author => "Your Name";
public override string Description => "Adds a custom music source to PlexBot";
public override void RegisterServices(IServiceCollection services)
{
// Register the music provider — automatically picked up by MusicProviderRegistry
services.AddSingleton<IMusicProvider, MyMusicProvider>();
}
protected override async Task<bool> OnInitializeAsync(IServiceProvider services)
{
string apiKey = GetConfig("apiKey");
if (string.IsNullOrEmpty(apiKey))
{
Logs.Error($"{Name}: No API key configured. Set extensions.{Id}.apiKey in config.fds");
return false;
}
// Subscribe to events
BotEventBus eventBus = services.GetRequiredService<BotEventBus>();
eventBus.Subscribe(BotEvents.TrackStarted, OnTrackStarted);
Logs.Info($"{Name} v{Version} initialized");
return true;
}
private Task OnTrackStarted(BotEvent evt)
{
// React to tracks from our provider
Logs.Debug($"Track started: {evt.Data["title"]}");
return Task.CompletedTask;
}
}Extensions are distributed as source code, not pre-compiled DLLs. PlexBot builds them automatically at startup.
- Create your extension folder under
Extensions/ - Add a
.csprojimportingPlexBot.extension.props - Start PlexBot — it runs
dotnet buildfor your extension automatically
- Share your extension folder (the directory with
.csprojand source files) - Users place it under their
Extensions/directory - PlexBot builds and loads it on next startup
Rename the folder with a .disabled suffix:
Extensions/MyExtension.disabled/
PlexBot skips directories ending in .disabled during discovery.
- Debug mode: Extensions are rebuilt every startup to pick up source changes
- Release mode: Extensions are only built if no cached DLL exists. Delete the compiled output under the bin directory to force a rebuild.
PlexBot provides two shared MSBuild files that extensions import:
Sets up the build environment and references the host DLL. The HostOutputDir MSBuild property is passed automatically by the ExtensionManager at build time so extensions always reference the correct host output.
Provides shared NuGet package references matching the host's versions (Discord.Net, Lavalink4NET, ImageSharp, etc.). These use PrivateAssets="all" so they aren't copied to extension output — the host already has them loaded at runtime.
If you need a package not listed in PlexBot.deps.props, add it directly to your .csproj. If it's a private dependency (not shared with the host), it will be copied to your extension's output and loaded via the extension's AssemblyLoadContext.
- Follow semantic versioning
- Add comprehensive command descriptions and parameter summaries
- Implement proper error handling and logging using the
Logsclass - Clean up resources in
ShutdownAsync - Use
RegisterServicesfor DI — don't create services manually - Use
GetConfig()helpers for extension settings - Return empty collections (not null) from unsupported
IMusicProvidermethods - Import
PlexBot.extension.propsinstead of manually adding references
- Don't add a
ProjectReferencetoPlexBot.csproj— use the shared props files - Don't create a separate
ServiceCollection— use the one passed toRegisterServices - Don't block the main thread with long operations
- Don't create global static state that might conflict with other extensions
- Don't override Discord events directly — use the
BotEventBusinstead - Don't hardcode values for IDs, channels, or messages
- Check console logs for
dotnet builderror output - Verify your
.csprojimports../../PlexBot.extension.props - Ensure the host has been built first (extensions reference the host DLL)
- Check that required NuGet packages are available
- Check console logs for error messages
- Verify the directory is under
Extensions/and contains a.csproj - Ensure the class inherits from
Extension(singular, notExtensions) - Check
MinimumBotVersioncompatibility - Verify all dependency extensions are present
- Make sure the folder name doesn't end in
.disabled
- Ensure command modules are
publicand inherit fromInteractionModuleBase<SocketInteractionContext> - Verify the extension loaded successfully (check logs)
- In Development mode, commands register per-guild (instant). In Production, global commands can take up to an hour to propagate.
- Make sure services are registered in
RegisterServices(), not inOnInitializeAsync() RegisterServicesruns before the container is built — that's the only place to add services- Check service lifetimes (Singleton vs Scoped vs Transient)
- Verify you registered it as
services.AddSingleton<IMusicProvider, YourProvider>() - Check that
IsAvailablereturnstrue - Look for registration logs: "Music provider registered: YourProvider (your-id)"