Skip to content

Developing plugins

Teuk edited this page Jul 12, 2026 · 3 revisions

Developing plugins

This page is the maintainer guide for extending Mediabot v3.

Mediabot currently supports two different extension models:

  1. trusted in-process Perl plugins loaded by Mediabot::PluginManager;
  2. trusted external scripts written in Perl, Python or Tcl and executed through Mediabot::Plugin::ScriptDryRun.

They solve different problems and have different safety boundaries.


Choose the right extension model

Need Recommended model
Observe an internal Mediabot event In-process Perl plugin
Reuse Mediabot objects and methods directly In-process Perl plugin
Register a startup-time command handler In-process Perl plugin
Keep a small integration isolated from the bot process External script
Write the extension in Python or Tcl External script
Return a simple reply, notice or log action External script
Test command behavior without sending anything to IRC External script in dry-run mode
Run untrusted user-uploaded code Neither model

Both models are for code reviewed and installed by the bot administrator.

Neither model is a sandbox for code supplied by IRC users.


Part 1 — In-process Perl plugins

Architecture

An in-process plugin is a normal Perl module loaded by module name.

Example module:

Mediabot::Plugin::MyPlugin

Expected file:

Mediabot/Plugin/MyPlugin.pm

The loader does not accept arbitrary filesystem paths or Perl expressions. It accepts normal Perl module names only.

At startup:

mediabot.pl
  -> Mediabot plugin autoload gate
  -> Mediabot::PluginManager
  -> require Mediabot::Plugin::MyPlugin
  -> Mediabot::Plugin::MyPlugin->register(...)
  -> PluginManager stores the returned plugin object

The PluginManager calls:

$module->register(
    $bot,
    manager => $plugin_manager,
    name    => $configured_plugin_name,
);

A plugin should return an object representing its runtime state.


Minimal observational plugin

The safest first plugin observes an EventBus event and does not mutate command dispatch.

Create:

Mediabot/Plugin/MyObserver.pm

Example:

package Mediabot::Plugin::MyObserver;

use strict;
use warnings;
use utf8;

our $VERSION = '0.001';

sub register {
    my ($class, $bot, %opts) = @_;

    my $self = bless {
        bot         => $bot,
        manager     => $opts{manager},
        plugin_name => $opts{name} || __PACKAGE__,
        observed    => 0,
    }, $class;

    if ($bot && $bot->can('events') && $bot->events) {
        $self->{listener_entry} = $bot->events->on(
            public_command_observed => sub {
                my ($ctx) = @_;

                return unless $self->plugin_enabled;

                $self->{observed}++;

                my $nick    = $ctx->nick    // '';
                my $channel = $ctx->channel // '';
                my $command = $ctx->command // '';

                $ctx->log_debug(
                    3,
                    "MyObserver saw command=$command nick=$nick channel=$channel"
                );

                return;
            },
            name     => 'my-observer-public-command',
            plugin   => __PACKAGE__,
            priority => 0,
        );
    }

    return $self;
}

sub plugin_enabled {
    my ($self) = @_;

    my $manager = $self->{manager};
    return 1 unless $manager && $manager->can('is_enabled');

    my $name = $self->{plugin_name} || __PACKAGE__;
    return 1 unless $manager->plugin($name);

    return $manager->is_enabled($name) ? 1 : 0;
}

sub unregister {
    my ($self, %opts) = @_;

    my $entry = $self->{listener_entry};
    return 0 unless ref($entry) eq 'HASH';

    my $bot = $self->{bot};
    return 0 unless $bot && $bot->can('events') && $bot->events;

    my $removed = eval {
        $bot->events->off(public_command_observed => $entry)
    } || 0;

    delete $self->{listener_entry} if $removed;
    return $removed ? 1 : 0;
}

sub observed {
    my ($self) = @_;
    return $self->{observed} || 0;
}

1;

This follows the same important lifecycle pattern as the bundled demo and ScriptDryRun plugins.


Why unregister() matters

EventBus callbacks are closures.

A callback commonly captures $self, and $self commonly stores $bot. If the listener is not removed when the plugin is replaced or unregistered, the old callback can remain active and keep the old plugin object alive.

That causes:

  • duplicate event handling;
  • ghost replies or duplicated logs;
  • stale plugin state;
  • reference cycles;
  • confusing reload behavior.

Always store the exact listener entry returned by:

$bot->events->on(...)

and remove that same entry with:

$bot->events->off($event_name => $entry)

Do not clear every listener for the event.


Respecting the enabled flag

PluginManager->disable($name) changes the manager state, but it does not rebuild EventBus subscriptions.

A loaded callback therefore needs to check whether its plugin is still enabled before acting.

Recommended pattern:

return unless $self->plugin_enabled;

This is especially important for plugins that:

  • reply to IRC;
  • write to a database;
  • call external services;
  • modify runtime state;
  • consume or own commands.

Current EventBus event

The current plugin-facing core event is:

public_command_observed

The callback receives a Mediabot::Context object.

Useful context methods include:

$ctx->nick
$ctx->channel
$ctx->command
$ctx->args
$ctx->command_obj
$ctx->user
$ctx->is_private
$ctx->reply
$ctx->reply_private
$ctx->log_info
$ctx->log_error
$ctx->log_debug

Keep observers fast and defensive.

A failing listener is isolated by emit_report(), but an exception is still a plugin bug and will be logged.


EventBus registration options

Example:

my $entry = $bot->events->on(
    public_command_observed => sub {
        my ($ctx) = @_;
        ...
    },
    name     => 'my-listener-name',
    plugin   => __PACKAGE__,
    priority => 10,
);

Options:

Option Meaning
name Human-readable listener name used in diagnostics
plugin Plugin identity used in error reports
priority Higher values run first
once Remove the listener after its first emission

A one-shot listener can also be registered with:

$bot->events->once(...)

Listeners added while an event is being emitted do not run during that same emission. They remain registered for the next one.


Registering a command from a Perl plugin

Mediabot::CommandRegistry can register validated command handlers.

A handler receives a Mediabot::Context object:

my $entry = $bot->commands->register_command(
    name        => 'mycommand',
    source      => 'public',
    aliases     => [ 'mycmd' ],
    category    => 'plugin',
    description => 'Example command supplied by MyPlugin',
    plugin      => __PACKAGE__,
    handler     => sub {
        my ($ctx) = @_;

        return unless $self->plugin_enabled;

        my $args = $ctx->args;
        my $text = @$args ? join(' ', @$args) : 'no arguments';

        $ctx->reply("MyPlugin received: $text");
    },
);

Important limitations:

  • command and alias names must be plain scalar values;
  • the handler must be a code reference;
  • aliases cannot steal another command or alias;
  • replacement is atomic and requires replace => 1;
  • the current registry does not yet provide a plugin-oriented command-unregister lifecycle equivalent to EventBus off().

For that reason, command registration is best treated as a startup-time registration today.

Do not design a hot-reloadable command plugin that expects its registry entries to disappear automatically when the plugin is disabled or unloaded.

EventBus observation and the ScriptDryRun bridge are currently safer for reloadable experiments.


Plugin configuration

Enable the plugin only in the instance-specific configuration:

[plugins]
AUTOLOAD=1
ENABLED=Mediabot::Plugin::MyObserver

Multiple modules can be listed:

[plugins]
AUTOLOAD=1
ENABLED=Mediabot::Plugin::Demo, Mediabot::Plugin::MyObserver

Canonical keys:

plugins.AUTOLOAD
plugins.ENABLED

Compatibility aliases are still accepted, but new documentation and deployments should prefer the canonical keys.

Never enable an unreviewed module on a production bot.


Testing an in-process plugin

Syntax

cd /home/mediabot/mediabot_v3 || exit 1

perl -I. -c Mediabot/Plugin/MyObserver.pm

Focused unit test

Create a test under:

t/cases/

A useful test should verify:

  • the module loads;
  • register() returns an object;
  • exactly one listener is installed;
  • the listener receives the expected context;
  • disabling the plugin prevents side effects;
  • unregister() removes the exact listener;
  • replacing the plugin does not leave ghost listeners;
  • no shell execution or credential material was introduced.

Use Test::More for new tests.

Avoid legacy assertion callbacks.

Full plugin validation

Run the focused plugin/script group, then the complete public static/live workflow before publishing changes.

This is required for changes to:

Mediabot/PluginManager.pm
Mediabot/EventBus.pm
Mediabot/CommandRegistry.pm
Mediabot/Plugin/*
Mediabot/ScriptRunner.pm
Mediabot/ScriptActionRunner.pm
plugins/scripts/
mediabot.sample.conf

Part 2 — External Perl, Python and Tcl scripts

When to use an external script

Use the external script bridge when the extension:

  • can operate from a small JSON input object;
  • only needs to return replies, notices or log actions;
  • benefits from process isolation;
  • is easier to write in Python or Tcl;
  • should be tested in dry-run mode before any IRC output;
  • does not need unrestricted access to Mediabot internals.

Scripts are executed below:

plugins/scripts/

Supported extensions:

.pl
.py
.tcl

Step-by-step: create an external command

1. Choose a conflict-free command

Mediabot already has built-in commands such as:

roll
8ball
choose

The bundled external examples therefore use:

proll
p8ball
pchoose

Check the current public command list before choosing a name.


2. Create the script

Example path:

plugins/scripts/examples/myweather.py

Minimal Python example:

#!/usr/bin/env python3

import json
import sys

def fail(message: str) -> None:
    print(json.dumps({
        "protocol": "mediabot-script-v1",
        "ok": False,
        "errors": [message],
    }))
    raise SystemExit(0)

try:
    payload = json.load(sys.stdin)
except Exception:
    fail("invalid JSON input")

if payload.get("protocol") != "mediabot-script-v1":
    fail("unsupported protocol")

data = payload.get("data")
if not isinstance(data, dict):
    fail("missing data object")

command = data.get("command")
args = data.get("args", [])

if command != "myweather":
    fail("unexpected command")

city = " ".join(str(value) for value in args).strip()
if not city:
    fail("usage: myweather <city>")

print(json.dumps({
    "protocol": "mediabot-script-v1",
    "ok": True,
    "actions": [
        {
            "type": "reply",
            "text": "Weather lookup requested for: " + city,
        },
        {
            "type": "log",
            "level": "info",
            "text": "myweather.py completed",
        },
    ],
}))

STDOUT must contain exactly one JSON object.

Send diagnostics to STDERR, not STDOUT.


3. Add a dry-run route

Use the local runtime configuration, not mediabot.sample.conf:

[plugins]
AUTOLOAD=1
ENABLED=Mediabot::Plugin::ScriptDryRun

[plugins.ScriptDryRun]
COMMANDS=myweather
ROUTES=myweather=examples/myweather.py
ACTION_MODE=dry-run
ALLOW_IRC=no
APPLY_REQUIRE_SCOPE=yes

In dry-run mode:

  • the script executes;
  • JSON is decoded;
  • actions are validated;
  • no reply or notice is sent;
  • no runtime action is applied.

4. Restart the test instance

For the development instance:

systemctl restart mediabot@dev
systemctl status mediabot@dev --no-pager
journalctl -u mediabot@dev -n 80 --no-pager

Do not restart mediabot@undernet while testing changes on the development instance.


5. Inspect Partyline state

.plugins config
.plugins loaded
.scriptdryrun config
.scriptdryrun status

Run the IRC command:

m myweather Paris

Then inspect:

.scriptdryrun last

The last result should show:

  • a successful script result;
  • the selected route;
  • elapsed time;
  • a valid action plan;
  • zero applied IRC actions in dry-run mode.

6. Enable apply mode on the test instance

Only after dry-run is clean:

[plugins.ScriptDryRun]
COMMANDS=myweather
ROUTES=myweather=examples/myweather.py
ACTION_MODE=apply
ALLOW_IRC=yes
APPLY_REQUIRE_SCOPE=yes

Restart only the development instance and test again.

Both gates are required:

ACTION_MODE=apply
ALLOW_IRC=yes

APPLY_REQUIRE_SCOPE=yes should remain enabled.


Input contract

Scripts receive one JSON object:

{
  "protocol": "mediabot-script-v1",
  "event": "public_command",
  "data": {
    "channel": "#teuk",
    "target": "#teuk",
    "nick": "Te[u]K",
    "command": "myweather",
    "args": ["Paris"]
  }
}

Treat all input as untrusted IRC data.

Validate:

  • protocol;
  • event;
  • command;
  • argument count;
  • argument length;
  • allowed characters;
  • external URLs or identifiers before using them.

Do not assume optional fields are present.


Output contract

Successful response:

{
  "protocol": "mediabot-script-v1",
  "ok": true,
  "actions": [
    {
      "type": "reply",
      "text": "Result"
    }
  ]
}

Declared failure:

{
  "protocol": "mediabot-script-v1",
  "ok": false,
  "errors": [
    "refusing unsafe input"
  ]
}

Rules:

  • output one JSON object;
  • ok must be a real JSON boolean;
  • actions must be an array when present;
  • errors must be an array when present;
  • failed responses expose no actions;
  • malformed scalar values are rejected;
  • 0, "0" and "" are not accepted as action arrays;
  • null or a missing action list means no actions.

Supported actions

Reply

{
  "type": "reply",
  "text": "Public answer"
}

An explicit target is optional when a public channel is available.

Notice

{
  "type": "notice",
  "target": "Te[u]K",
  "text": "Private diagnostic"
}

Log

{
  "type": "log",
  "level": "info",
  "text": "Plugin completed"
}

Allowed levels:

debug
info
warn
error

Timer

{
  "type": "timer",
  "name": "later",
  "delay": 60
}

Timer actions are validated but not applied yet.


External script security rules

Never:

  • construct a shell command from IRC arguments;
  • write credentials to STDOUT or logs;
  • read mediabot.conf from a script;
  • use an absolute or parent-relative route;
  • return multiple IRC targets;
  • return CR, LF or NUL characters in IRC text;
  • make an unbounded network request;
  • ignore timeouts from external APIs;
  • store runtime data inside the Git tree.

Prefer:

  • native language HTTP libraries;
  • explicit timeouts;
  • small bounded responses;
  • deterministic error messages;
  • environment variables or instance-local configuration for credentials;
  • route-specific scripts;
  • dry-run first.

Testing an external script

Interpreter and syntax

Perl:

perl -c plugins/scripts/examples/hello_perl.pl

Python:

python3 -m py_compile plugins/scripts/examples/hello_python.py

Tcl:

tclsh plugins/scripts/examples/hello_tcl.tcl < /dev/null

Protocol test

Pipe a representative JSON object to the script:

printf '%s\n' \
  '{"protocol":"mediabot-script-v1","event":"public_command","data":{"command":"myweather","channel":"#test","target":"#test","nick":"tester","args":["Paris"]}}' \
  | python3 plugins/scripts/examples/myweather.py

The output must be valid JSON and contain no debug prefix or suffix.

Regression test

Add a test under t/cases/ that verifies:

  • valid input;
  • missing arguments;
  • malformed values;
  • special characters;
  • output protocol;
  • explicit ok;
  • expected action count;
  • no control characters;
  • no shell execution;
  • timeout or failure behavior when relevant.

Full validation

perl t/test_commands.pl \
  --filter '420_mb181|425_mb186|435_mb198|438_mb201|441_mb204|442_mb226|455_mb242|457_mb244|506_mb282|514_mb291|517_mb295|518_mb296'

Run the complete static/live test workflow before release.


Part 3 — Review and release checklist

Before enabling a new plugin or script:

  1. Confirm the extension model is appropriate.
  2. Review every source file.
  3. Keep credentials outside Git.
  4. Add syntax checks.
  5. Add a focused regression test.
  6. Verify plugin disable behavior.
  7. Verify unregister cleanup for EventBus listeners.
  8. Start external scripts in dry-run mode.
  9. Keep APPLY_REQUIRE_SCOPE=yes.
  10. Use a conflict-free command name.
  11. Inspect .scriptdryrun last.
  12. Run the focused plugin/script test group and the complete public validation workflow.
  13. Check git status --short.
  14. Confirm mp3/, real configs and local follow-up files are protected.
  15. Commit only after the full preflight passes.

Reference implementations

In-process plugins:

Mediabot/Plugin/Demo.pm
Mediabot/Plugin/ScriptDryRun.pm

External script examples:

plugins/scripts/examples/hello_perl.pl
plugins/scripts/examples/hello_python.py
plugins/scripts/examples/hello_tcl.tcl
plugins/scripts/examples/roll.py
plugins/scripts/examples/eightball.tcl
plugins/scripts/examples/choose.pl

Regression tests:

t/cases/408_mb169_plugin_manager_foundation.t
t/cases/410_mb171_demo_plugin_explicit_load.t
t/cases/411_mb172_plugin_autoload_gate.t
t/cases/420_mb181_multilang_script_examples.t
t/cases/435_mb198_scriptdryrun_multilang_apply_smoke.t
t/cases/455_mb242_plugin_replace_listener_cleanup.t
t/cases/457_mb244_plugin_manager_unregister_cleanup.t
t/cases/506_mb282_end_to_end_apply_chain.t
t/cases/514_mb291_plugin_bridge_docs_contract.t
t/cases/517_mb295_sample_plugin_config_contract.t
t/cases/518_mb296_action_list_false_scalar_contract.t

See also

Clone this wiki locally