The C# plugin API for NucleusBot Companion allows for customization and integration.
- Discord Call Overlay (TBA)
- BTTV Emotes (TBA)
In order for your DLL to be loaded you need to create a plugin.json
file in your plugins folder.
Below is the minimum required configuration
{
"schema": 1,
"id": "example-plugin",
"version": "1.0.0",
"name": "Example Plugin",
"entrypoints": [
"MyAssembly.dll"
]
}
The plugin.json
file can contain the following types:
Property | Type | Description |
---|---|---|
schema |
Number | Schema Version (Currently only supports 1 ) |
id |
String | Unique identifier for your Plugin |
version |
SemVer | Version of your plugin to determine updates |
name |
String | Name of your plugin that shows on the plugins page |
description |
String | Description of what your plugin does |
icon |
String | A local path to the icon used for your plugin |
entrypoints |
String / String[] | DLLs that should be loaded from your plugin folder |
contributors |
String / String[] | Contributors credited for the plugin |
contact |
Object | Contact details for support with your plugin |
license |
String | The license for your plugin (eg; MIT /GPL /other ) |
You can start your plugin by creating a class
that implements the IPlugin
interface. Type
s that meet the following rules will automatically be called when your plugin DLL is loaded:
- Has to implement
IPlugin
- Cannot be static
- Cannot be an interface
- Cannot be abstract
If your class has a constructor that accepts an IPluginContext
, it will be passed in during initialization
class MyPlugin : IPlugin {
private readonly IPluginContext Context;
public MyPlugin(IPluginContext context) {
this.Context = context;
}
}
IPlugin
contains a Register
method that allows for asynchronous Plugin initialization after the constructor has been called.
class MyPlugin : IPlugin {
public async ValueTask Register() {
// ... Write your async code here
}
}
When your Plugin is unloaded or disabled by the user, you can clean up any resources being used by your plugin by implementing IDisposable
or IAsyncDisposable
. Any Disposable objects returned by the IPluginContext
will be disposed of automatically after the Plugin class is disposed.
The Companion app provides a local HTTP server to allow providing embeddable HTML/JavaScript pages (eg; for adding as OBS Browser Sources) using Blazor server/Razor pages.
To make sure that your Plugin has access to the tools necessary for creating razor pages, make sure that you change your Console Application
s .csproj
to use the Razor SDK:
<Project Sdk="Microsoft.NET.Sdk.Razor">
<!-- Your project details -->
</Project>
⚠️ The URL for accessing your plugins content will always start with/{your-plugin-id}
The local HTTP Server runs on a variable port that the user can change at any time. You can get a URI
for pages created by your Plugin by using the IServerHelper
provided in the IPluginContext
IServerHelper helper = ...;
Uri uri = helper.GetPath<MyPageModel>();
string host = uri.Host;
int port = uri.Port;
string path = uri.PathAndQuery;
Plugins can make use of Blazor by creating .razor
files as part of your project. The Companion will load your compiled classes automatically
@page "my-page"
<html>
<head>
</head>
<body>
<p>Hello World!</p>
</body>
</html>
If your plugin needs to host static CSS, HTML, Images, etc. you can do so by moving them into the www
folder of your plugin. Static files will become available at the root of your plugin /{your-plugin-id}
on the webserver.
If you're making use of Blazor pages and want to be able to live reload CSS files you can use the PluginSource
razor component. Modifying the content of the CSS file will trigger a File Watcher that will trigger a reload on that file.
@page "my-page"
<html>
<head>
<PluginSource Path="styles/my-style.css" />
</head>
</html>
Services that you configured using the IPluginContext
, as well as several services provided by the Companion, can be injected into your Razor pages if you need to access them from there
@page "my-page"
@inject IPluginContext Plugin
@inject IServerHelper ServerHelper
@inject IFileSystemHelper IO
@inject IComponentsHelper Components
If your pages need to access to variables provided by your Plugin classes, you can also inject those too.
MyCoolPlugin.class
class MyCoolPlugin { public readonly IList<object> SharedObject = new List<object>(); }
SomePage.razor
@page "my-page" @inject MyCoolPlugin Plugin @{ IList<object> items = this.Plugin.SharedObject; }
One core functionality present in many chat bots is Commands. The Companion app supports Auto-Complete for command in the Input box.
The Companion app supports handling chat commands as slash commands as long a handler has been created to receive the commands to a web API
// TODO: Write something here
throw new NotImplementedException();
The Companion app is capable of supporting custom emotes. Emotes only require a name
and a url
(url
s must be loaded over HTTPS).
ValueTask Register() {
Uri uri = new Uri("https://test.dev/emotes");
// Register Emotes with the NucleusBot format
this.Context.RegisterEmotes(uri);
// Register Emotes with the NucleusBot format and add an Authorization header
// (If your API requires authorization to access files)
this.Context.RegisterEmotes(uri, headers => {
headers.Add("Authorization", "Bearer xxxxxxxxxxx");
});
// Register Emotes with a custom format
this.Context.RegisterEmotes(uri, converter: (content) => {
IList<Emote> emotes = new List<Emote>();
// If your 'content' is a JSONArray
if (content.TryParseJSON(out JsonArray? json)) {
// ... Parse your Emotes here
string name = "...";
string name = new Uri("https://...");
emotes.add(new Emote(name, uri));
}
return emotes;
});
// ... Or a mix of both
this.Context.RegisterEmotes(uri,
headers => {
// ... Add headers
},
content => {
// ... Parse the response body
}
);
return ValueTask.CompletedTask;
}
Emotes
and Commands
can be registered in two different scopes:
- Globally
- Per Channel
Registration can be done on the IScopedRegistryContext
interface, which is implemented by both IPluginContext
(For Global) and IChannelContext
(For Channels).
Registering globally is done when Registering your plugin, or can be done later if the IPluginContext
is stored.
ValueTask Register() {
// Register your global emotes here
this.Context.RegisterEmotes(new Uri("https://test.dev/emotes"));
}
Registering per channel is done after Joining to a channel. Emotes are stored on the channel for as long as it is Joined, and are automatically discarded when leaving the channel. Per-channel Emotes
and Commands
are only shown as long as that Channel is currently selected.
ValueTask Register() {
// Listen for Channel joins
this.Context.ChannelJoinEvent(channel => {
// Register channel emotes with the channel
channel.RegisterCommands(new Uri($"https://test.dev/emotes/{channel.Id}"));
});
}
Plugins are capable of updating, modifying, or completely cancelling chat messages. For example, if your plugin introduced new emotes, you'll need to replace the text contents with an Image
To intercept messages you'll need to create a MessageHandler middleware in your Register
method.
ValueTask Register() {
this.Context.MessageHandlerMiddleware(async (context, next) => {
// Do what you need to do here
// Call the next middleware
await next();
});
}
Your Middleware can be assigned one of three priorities, FIRST
, INSERT
, or LAST
(The default is INSERT
).
- After all priority stages are completed (At the end of
LAST
) is when the Message is populated to the UI. After this point it cannot be modified or cancelled.
private async ValueTask MyMethod(IMessageContext context, Func<ValueTask> next)
=> await next();
ValueTask Register() {
// This will run last
this.Context.MessageHandlerMiddleware(MyMethod, Ordering.LAST);
// This will run first
this.Context.MessageHandlerMiddleware(MyMethod, Ordering.FIRST);
// This will run in the middle
this.Context.MessageHandlerMiddleware(MyMethod, Ordering.INSERT);
}
Priority | Description |
---|---|
FIRST |
Will run with highest priority. Anything that modifies the message should run here so that Middleware that reads the final message will do so after these modifications are run |
INSERT |
Runs based off Insert-Order , plugins that are loaded first will be called first. |
LAST |
Runs after all other processing is completed, if your plugin reads messages in their final form, it should be done here |
Message middleware runs in a circuit. When the first Middleware is run it is passed a delegate next
which will begin the next middleware.
- If you do not invoke the
next
middleware, short-circuiting (see below) will occur. - Calling
next
multiple times may cause unintended side effects.- If the next middleware short-circuits, invoking
next
will fix the short-circuit - If short-circuiting has not occurred, invoking
next
will return aValueTask.CompletedTask
- If the next middleware short-circuits, invoking
Where you invoke next
may change how your middleware runs.
If you invoke next
at the start of your method, all other middleware will run (Including populating the message to the UI):
this.Context.MessageHandlerMiddleware(async (context, next) => {
await next();
// Do read-only operations here
});
It is possible to "short-circuit" the middleware by simply not calling the next
delegate. Doing so can prevent the message from populating in the UI, which may be the desired effect.
A number of indexers and methods exist for updating the contents of a Message.
Messages consist of Components
that are implemented by the main Companion application. Some examples of Components are Text
or Image
. Since components are only available as interfaces in the API, they must be constructed and accessed through the IPluginContext
(IPluginContext
provides an IComponentsHelper
interface)
IPluginContext context = ...;
IComponentsHelper components = context.Components;
// Text is created using a String
ITextComponent text = components.Text("This is text");
// Images require a "text" and an Image URI
IImageComponent image = components.Image("Kappa", new Uri("https://static-cdn.jtvnw.net/emoticons/v2/25/default/dark/2.0"));
// Text and Image are both `IComponent`s
IComponent component = text;
Once we have an IComponent
, sections of messages can easily be replaced using a Range
.
async (context, next) => {
// This will replace the start of every message with "Neat!"
context.Contents[..4] = components.Text("Neat!");
};