Forum teuk.org

The Marauder’s Map Opens: Command Registry, Event Bus, Plugins and Script Runner

in Mediabot · started by TeuK · 1w ago

TeuK · 1w ago

Over the last development pass, Mediabot v3 received a major architectural foundation for its next generation of extensibility.

This was not a flashy “add one command and hope it works” change. It was a careful step-by-step refactoring pass designed to prepare Mediabot for a plugin and script system inspired by Eggdrop, while keeping the current bot stable, predictable, and compatible with existing commands.

The key rule was simple: add the new architecture without breaking the old runtime.

What changed

This pass introduced four major internal foundations:

CommandRegistry
EventBus
PluginManager
ScriptRunner

and one safety layer for script-produced actions:

ScriptActionRunner

Together, these pieces prepare Mediabot for a future where internal Perl plugins and controlled external scripts can react to bot events without being hardwired into the main command dispatcher.

CommandRegistry foundation

The first brick was Mediabot::CommandRegistry.

It gives Mediabot a clean place to register public and private commands with metadata, aliases, levels, categories, and handlers.

The first real integration moved only a tiny safe group of public commands into the registry path:

version
uptime
help
commands

Everything else still falls back to the legacy dispatch path.

This was intentional. The goal was not to rewrite the bot in one dangerous jump. The goal was to prove that new registry-based commands and old legacy commands can coexist safely.

EventBus foundation

The second brick was Mediabot::EventBus.

It provides a generic internal event system with:

event name normalization
listener registration
listener priority
one-shot listeners
safe emit_report()
structured listener errors

The first real runtime event added was:

public_command_observed

This event is emitted after the public command context and command object are built, but before the registry or legacy dispatch runs.

With no listeners, this changes nothing. With listeners later, plugins can observe commands safely without owning the dispatcher.

Listener failures are reported without breaking normal command execution.

PluginManager foundation

The third brick was Mediabot::PluginManager.

It introduced a structured way to register trusted in-process Perl plugins:

register_plugin()
unregister_plugin()
plugin()
object_for()
enable()
disable()
list()
names()
count()
load_perl_module()

The manager rejects path-like module names and accepts only normal Perl module names such as:

Mediabot::Plugin::Demo

No arbitrary file path loading was added.

A deliberately tiny demo plugin was also introduced:

Mediabot::Plugin::Demo

It listens to public_command_observed and only increments an internal counter. It does not send IRC messages, touch the database, mutate context, or modify dispatch.

Plugin autoload was then added behind an explicit configuration gate:

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

Without AUTOLOAD=1, no plugin is loaded at boot.

This keeps the default runtime unchanged.

Partyline visibility

Before adding plugin control commands, a read-only partyline command was added:

.plugins
.plugins loaded
.plugins config

This gives operator visibility into:

plugin autoload status
registered plugin count
enabled plugin count
disabled plugin count
configured plugin loading rules

The command is strictly read-only. It does not load, unload, enable, disable, write config, touch the database, or send IRC messages.

That matters because visibility should come before power.

ScriptRunner foundation

The fourth brick was Mediabot::ScriptRunner.

This prepares Mediabot for controlled external scripts written in:

Perl
Python
Tcl

Supported extensions:

.pl
.py
.tcl

The initial foundation added:

path validation
language detection
JSON event envelope
JSON response validation
safe action type validation

Unsafe paths are rejected:

/tmp/foo.py
../evil.py
foo\bar.py
script.sh

Valid relative paths are resolved under the script directory:

plugins/scripts/games/duckhunt.tcl

The JSON event protocol is versioned:

{
  "protocol": "mediabot-script-v1",
  "event": "public_command",
  "data": {
    "channel": "#test",
    "nick": "Te[u]K",
    "command": "demo",
    "args": ["a", "b"]
  }
}

Dry-run execution contract

Before executing anything, Mediabot gained a dry-run execution plan.

For example:

$runner->build_execution_plan('demo/hello.py', $payload)

returns a plan like:

{
    ok               => 1,
    dry_run          => 1,
    language         => 'python',
    script           => 'demo/hello.py',
    full_path        => 'plugins/scripts/demo/hello.py',
    command          => [ 'python3', 'plugins/scripts/demo/hello.py' ],
    stdin            => '<JSON envelope>',
    timeout          => 5,
    max_stdout_bytes => 8192,
}

The command is built as an argv array, not a shell string.

That is the right safety model.

Controlled subprocess execution

After the dry-run contract was validated, real subprocess execution was added using:

open3($child_in, $child_out, $child_err, @cmd)

The important part is @cmd: no shell string is involved.

The runner now supports:

JSON stdin
JSON stdout response
stderr capture
timeout
stdout cap
stderr cap
non-zero exit reporting
timeout killing
structured result objects

The tests cover:

valid script execution
invalid JSON output
non-zero exit with stderr
timeout kill
unsafe path rejection before spawn

This is still not exposed directly to IRC users or partyline operators. It is only a safe internal capability.

ScriptActionRunner dry-run layer

The last part of this pass introduced:

Mediabot::ScriptActionRunner

This is the safety layer between a script response and real Mediabot side effects.

It validates and plans script actions, but does not execute them yet.

Accepted initial action types:

reply
notice
log
timer

Rejected action types include:

raw_irc
exec
shell
file_write
db_write
anything unknown

This module is dry-run only. It does not send IRC messages, create timers, touch the database, or mutate dispatch.

That gives us a clean safety checkpoint before allowing external scripts to affect the bot.

Tests added

This pass added focused tests for each step:

404_mb165_command_registry_foundation.t
405_mb166_registry_public_core_dispatch.t
406_mb167_event_bus_foundation.t
407_mb168_public_command_observed_event.t
408_mb169_plugin_manager_foundation.t
409_mb170_plugin_config_loader_foundation.t
410_mb171_demo_plugin_explicit_load.t
411_mb172_plugin_autoload_gate.t
412_mb173_partyline_plugins_status.t
413_mb174_script_runner_foundation.t
414_mb175_script_runner_execution_plan.t
415_mb176_script_runner_subprocess.t
416_mb177_script_action_runner_dryrun.t

These tests validate the new architecture without requiring risky live IRC behavior.

Runtime validation

The bot was restarted successfully after the changes.

A minimal runtime validation on the dev IRC network should include:

m version
m uptime
m help
m commands
m channels
m seen Te[u]K

and partyline checks:

.help
.plugins
.plugins loaded
.plugins config

Expected default plugin state:

autoload: disabled
registered: 0
plugins: none loaded

Why this matters

This pass creates the foundation for a much more extensible Mediabot without sacrificing stability.

The future direction is now clear:

core commands can move gradually into CommandRegistry
plugins can observe events through EventBus
trusted Perl plugins can load through PluginManager
external Perl/Python/Tcl scripts can run through ScriptRunner
script actions must pass through ScriptActionRunner before side effects

That last point is crucial.

Mediabot is getting more powerful, but not by becoming reckless. Each new capability is being placed behind a boundary, tested, and introduced in small safe steps.

Next steps

The next logical pass is to connect the pieces in a controlled integration test:

external script
-> subprocess execution
-> JSON response
-> ScriptActionRunner dry-run action plan

Only after that should real IRC-side effects be considered.

The architecture is now in place. The door is open, but the wards are up.

You must be logged in to reply.