Skip to content

Plugin and script bridge

teuk edited this page Jun 17, 2026 · 3 revisions

Plugin and script bridge

This page documents the Mediabot v3 plugin foundation and the external script bridge used by ScriptDryRun.

For a step-by-step guide to writing both native Perl plugins and external Perl/Python/Tcl scripts, see Developing plugins.

The goal of this integration is to let trusted maintainers extend Mediabot with small external scripts written in Perl, Python or Tcl, while keeping the historical IRC bot safe by default.

The bridge is intentionally conservative:

  • plugins are disabled by default;
  • script actions are dry-run by default;
  • real application requires ACTION_MODE=apply;
  • IRC output requires the separate ALLOW_IRC=yes gate;
  • production apply mode should be scoped with COMMANDS or ROUTES;
  • scripts communicate with Mediabot through a strict JSON protocol;
  • external scripts run out-of-process, with a timeout and no shell execution.

This is a major extension point, but it is not a free-for-all dynamic execution engine. Treat it like a wand cupboard in Hogwarts: powerful, useful, and absolutely not something to leave open in the corridor.


What was added

The plugin/script bridge is built around these components:

Component Purpose
Mediabot::CommandRegistry Internal command registry foundation. It allows command metadata and aliases to be represented cleanly instead of being scattered only in dispatch logic.
Mediabot::EventBus Internal event bus. It lets the bot publish events such as public_command_observed without hard-wiring every future feature into the main dispatch block.
Mediabot::PluginManager Controlled plugin loader for trusted Perl modules. It loads named Perl modules, not arbitrary paths.
Mediabot::Plugin::Demo Minimal in-process Perl plugin showing safe EventBus registration and exact unregister cleanup.
Mediabot::Plugin::ScriptDryRun In-process bridge plugin observing public commands and mapping them to external scripts.
Mediabot::ScriptRunner Safe out-of-process runner for Perl, Python and Tcl scripts. It builds the JSON event envelope, starts the interpreter with an argv array, enforces limits, and decodes JSON responses.
Mediabot::ScriptActionRunner Validates and optionally applies actions returned by scripts. In dry-run mode it only plans actions. In apply mode it can apply log actions and, if allowed, IRC actions.
plugins/scripts/examples/hello_perl.pl Minimal Perl external-script example.
plugins/scripts/examples/hello_python.py Minimal Python external-script example.
plugins/scripts/examples/hello_tcl.tcl Minimal dependency-free Tcl external-script example.
plugins/scripts/examples/roll.py Dice example routed through the conflict-free proll command.
plugins/scripts/examples/eightball.tcl Magic 8-Ball example routed through p8ball.
plugins/scripts/examples/choose.pl Choice example routed through pchoose.

The important architectural point is that scripts do not call random Mediabot internals. They receive JSON on STDIN and return JSON on STDOUT. Mediabot remains the authority that validates and applies actions.


High-level data flow

For a public IRC command routed to ScriptDryRun, the flow is:

IRC user
  -> Mediabot public command dispatch
  -> EventBus event: public_command_observed
  -> Mediabot::Plugin::ScriptDryRun
  -> Mediabot::ScriptRunner
  -> external script: Perl / Python / Tcl
  -> JSON response on stdout
  -> Mediabot::ScriptActionRunner
  -> dry-run plan or gated apply
  -> optional IRC output only if ACTION_MODE=apply and ALLOW_IRC=yes

The legacy command dispatcher is protected because ScriptDryRun only owns a command after it accepts the command and finds a script route for it. For dedicated script commands such as m pyhello, this prevents the old fallback path from also saying that the command was not found.


Default safety model

By design, a normal existing Mediabot instance is not changed just because the code exists.

Default posture:

plugins autoload: disabled
ScriptDryRun loaded: no
ScriptDryRun action mode: dry-run
IRC output from scripts: disabled
apply scope guard: recommended

This means:

  • existing public commands keep working;
  • no external script runs unless the plugin is enabled;
  • no script can write to IRC unless apply mode and IRC permission are both enabled;
  • invalid JSON, timeouts, non-zero exit codes and declared failures are blocked before actions are applied.

Configuration overview

The documented sample configuration uses commented examples. Keep it that way in mediabot.sample.conf; enable only in the local runtime config used by the instance.

Safe documentation example:

#[plugins]
#AUTOLOAD=0
#ENABLED=Mediabot::Plugin::ScriptDryRun

#[plugins.ScriptDryRun]
#ACTION_MODE=dry-run
#ALLOW_IRC=no
#APPLY_REQUIRE_SCOPE=yes
#ROUTES=hello=examples/hello_perl.pl, pyhello=examples/hello_python.py, tclhello=examples/hello_tcl.tcl, proll=examples/roll.py, p8ball=examples/eightball.tcl, pchoose=examples/choose.pl

A live test instance can use:

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

[plugins.ScriptDryRun]
ROUTES=hello=examples/hello_perl.pl, pyhello=examples/hello_python.py, tclhello=examples/hello_tcl.tcl, proll=examples/roll.py, p8ball=examples/eightball.tcl, pchoose=examples/choose.pl
ACTION_MODE=apply
ALLOW_IRC=yes
APPLY_REQUIRE_SCOPE=yes

Recommended live smoke test from IRC:

m hello
m pyhello
m tclhello
m proll 2d6+1
m p8ball Will this work?
m pchoose tea | coffee | pumpkin juice

Expected output:

Perl script bridge OK for command: hello
Python script bridge OK for command: pyhello
Tcl script bridge OK for command: tclhello
A dice result from proll
A Magic 8-Ball answer from p8ball
One selected option from pchoose

Configuration keys

Plugin loader

Key Meaning
plugins.AUTOLOAD Enables plugin loading at startup when truthy. Default should stay disabled in the sample.
plugins.ENABLED Comma/list of plugin modules to load. For this bridge: Mediabot::Plugin::ScriptDryRun.
PLUGIN_AUTOLOAD / PLUGINS_AUTOLOAD Compatibility keys accepted by the autoload gate.
PLUGIN_ENABLED / PLUGINS_ENABLED / PLUGINS Compatibility keys for enabled plugins.

ScriptDryRun routing

Key Meaning
plugins.ScriptDryRun.ROUTES Recommended mode. Maps public commands to script paths, for example pyhello=examples/hello_python.py.
plugins.ScriptDryRun.COMMANDS Optional allow-list for commands handled by the fallback SCRIPT.
plugins.ScriptDryRun.SCRIPT Optional fallback script used when no route matches. Use carefully.
SCRIPT_DRYRUN_ROUTES Compatibility key for routes.
SCRIPT_DRYRUN_COMMANDS Compatibility key for command allow-list.
SCRIPT_DRYRUN_SCRIPT / SCRIPT_DRYRUN_PATH Compatibility keys for fallback script path.

Action gates

Key Meaning
plugins.ScriptDryRun.ACTION_MODE dry-run or apply. Default is dry-run.
plugins.ScriptDryRun.ALLOW_IRC Enables IRC output only when action mode is already apply. Default is false.
plugins.ScriptDryRun.APPLY_REQUIRE_SCOPE When enabled, refuses ACTION_MODE=apply unless COMMANDS or ROUTES restrict the public-command scope. Recommended: yes.
SCRIPT_DRYRUN_ACTION_MODE Compatibility key for action mode.
SCRIPT_DRYRUN_ALLOW_IRC Compatibility key for IRC gate.
SCRIPT_DRYRUN_APPLY_REQUIRE_SCOPE Compatibility key for apply-scope guard.

Truthy values include:

1 yes true on enabled enable

False values include:

0 no false off disabled disable

ROUTES versus COMMANDS versus SCRIPT

Recommended: ROUTES

Use ROUTES for dedicated script commands:

[plugins.ScriptDryRun]
ROUTES=hello=examples/hello_perl.pl, pyhello=examples/hello_python.py, tclhello=examples/hello_tcl.tcl, proll=examples/roll.py, p8ball=examples/eightball.tcl, pchoose=examples/choose.pl
ACTION_MODE=apply
ALLOW_IRC=yes
APPLY_REQUIRE_SCOPE=yes

This is the safest operational model because only explicitly routed commands are intercepted.

COMMANDS with SCRIPT

Use COMMANDS only when one script intentionally handles a small allow-list:

[plugins.ScriptDryRun]
COMMANDS=hello pyhello
SCRIPT=examples/router.py
ACTION_MODE=dry-run
ALLOW_IRC=no
APPLY_REQUIRE_SCOPE=yes

The script receives the command name in the JSON payload and can branch internally.

SCRIPT fallback without scope

Avoid this in production:

[plugins.ScriptDryRun]
SCRIPT=examples/router.py

With a fallback SCRIPT and no COMMANDS or ROUTES, ScriptDryRun can treat every public command as eligible for the fallback script. This is useful for low-level experiments, but it is dangerous on a bot with existing legacy commands because it can swallow commands that users expect to be handled by the old dispatcher.

If you ever use fallback mode, use it first in dry-run on a test channel and watch .scriptdryrun status.


Two extension models

The word “plugin” covers two related but distinct mechanisms.

In-process Perl plugins

Loaded by Mediabot::PluginManager:

Mediabot/Plugin/MyPlugin.pm

They can use Mediabot objects directly, subscribe to EventBus events and register startup-time command handlers.

They must implement a clean lifecycle:

register()
enabled-state guard
unregister()

Any plugin that adds an EventBus listener must keep the exact listener entry and remove it during unregister/replacement.

External script plugins

Executed by Mediabot::ScriptRunner through ScriptDryRun:

plugins/scripts/example.pl
plugins/scripts/example.py
plugins/scripts/example.tcl

They do not access Mediabot internals. They exchange JSON and return validated actions.

Use Developing plugins for complete native-plugin and external-script tutorials.


JSON protocol

External scripts must communicate through JSON.

The contract is:

  • Mediabot writes one JSON object to the script's STDIN.
  • The script writes one JSON object to STDOUT.
  • STDOUT must be JSON only.
  • STDERR may contain diagnostics; it is captured and bounded.
  • Scripts should exit with code 0 only when the JSON response is valid.
  • A script that cannot safely answer should return ok: false and an errors array.

The protocol version is currently:

mediabot-script-v1

JSON input envelope sent to scripts

For public commands, Mediabot sends this kind of object to STDIN:

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

Field rules:

Field Meaning
protocol Protocol marker. Scripts should accept only mediabot-script-v1 unless intentionally written for multiple versions.
event Event name. For the current public command bridge: public_command.
data.channel IRC channel when available.
data.target Reply target. Usually the same as channel for public commands.
data.nick Nick that triggered the command.
data.command Normalized command name accepted by ScriptDryRun.
data.args Array of parsed command arguments when available.

Scripts should not assume extra fields exist. Future versions may add more data, but existing scripts should remain valid by reading only the fields they need.


JSON output response from scripts

Recommended successful response:

{
  "protocol": "mediabot-script-v1",
  "ok": true,
  "actions": [
    {
      "type": "reply",
      "text": "Python script bridge OK for command: pyhello"
    },
    {
      "type": "log",
      "level": "info",
      "text": "Python example script produced an action plan"
    }
  ]
}

ok is recommended. Older demo scripts may omit it, and a response with valid actions can still be accepted for compatibility. New scripts should always include it because it makes intent explicit.

Recommended failure response:

{
  "protocol": "mediabot-script-v1",
  "ok": false,
  "errors": [
    "script refused this command"
  ]
}

When a script explicitly returns ok: false, or returns a non-empty errors array, Mediabot treats the response as failed and exposes no actions to the action layer. This is important: a failed script cannot smuggle actions alongside its error response.

Invalid response examples:

{
  "errors": "this should be an array"
}
{
  "actions": "this should be an array"
}

Both are rejected.


Supported action types

Scripts return an actions array. Mediabot validates every action before planning or applying anything.

reply

A reply sends a public PRIVMSG when applied with IRC allowed.

{
  "type": "reply",
  "target": "#teuk",
  "text": "Hello from the script bridge"
}

target is optional when the context has a default channel/target. If omitted, Mediabot uses the public command channel.

notice

A notice sends an IRC NOTICE when applied with IRC allowed.

{
  "type": "notice",
  "target": "Te[u]K",
  "text": "This is a private notice from a trusted script"
}

Use notices for quiet diagnostics. Use replies for public command answers.

log

A log action records a log line through the bot logger when applied.

{
  "type": "log",
  "level": "info",
  "text": "Script completed successfully"
}

Allowed levels:

debug info warn error

Unknown log levels fall back to info.

timer

A timer action is reserved in the validation layer, but timer application is not implemented yet.

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

At the moment, timer actions are validated but not applied. The action runner returns an explicit "timer actions are not implemented yet" apply error rather than silently doing the wrong thing.


Dry-run mode versus apply mode

dry-run

ACTION_MODE=dry-run is the default.

In dry-run mode:

  • the script can be executed;
  • JSON is decoded;
  • actions are validated and planned;
  • no IRC messages are sent;
  • no timer is created;
  • no runtime state is mutated by the action layer.

This is the right mode for first tests.

apply

ACTION_MODE=apply enables real action application, but IRC output is still blocked unless ALLOW_IRC=yes.

In apply mode with ALLOW_IRC=no:

  • log actions can be applied;
  • reply and notice actions are blocked with explicit apply errors;
  • no IRC messages are sent.

In apply mode with ALLOW_IRC=yes:

  • log actions can be applied;
  • reply actions send PRIVMSG;
  • notice actions send NOTICE;
  • timers still return "not implemented".

Recommended production-style test setup:

[plugins.ScriptDryRun]
ROUTES=hello=examples/hello_perl.pl, pyhello=examples/hello_python.py, tclhello=examples/hello_tcl.tcl, proll=examples/roll.py, p8ball=examples/eightball.tcl, pchoose=examples/choose.pl
ACTION_MODE=apply
ALLOW_IRC=yes
APPLY_REQUIRE_SCOPE=yes

Example: Perl script

use strict;
use warnings;
use utf8;
use JSON::PP qw(decode_json encode_json);

my $input = do { local $/; <STDIN> };
my $payload = eval { decode_json($input || '{}') } || {};

my $command = $payload->{data}{command} || 'unknown';
my $channel = $payload->{data}{channel} || $payload->{data}{target} || '';

print encode_json({
    protocol => 'mediabot-script-v1',
    ok       => JSON::PP::true,
    actions => [
        {
            type   => 'reply',
            target => $channel,
            text   => "Perl script bridge OK for command: $command",
        },
        {
            type  => 'log',
            level => 'info',
            text  => 'Perl example script produced an action plan',
        },
    ],
});

Example: Python script

#!/usr/bin/env python3
import json
import sys

try:
    payload = json.load(sys.stdin)
except Exception:
    payload = {}

data = payload.get("data", {})
command = data.get("command") or "unknown"

print(json.dumps({
    "protocol": "mediabot-script-v1",
    "ok": True,
    "actions": [
        {
            "type": "reply",
            "text": "Python script bridge OK for command: " + command
        },
        {
            "type": "log",
            "level": "info",
            "text": "Python example script produced an action plan"
        }
    ]
}))

Example: Tcl script

The bundled Tcl example avoids external JSON packages so it can run on minimal systems. A production Tcl script can use a JSON package if one is installed, but the output must still be valid JSON.

set input [read stdin]
set command "unknown"

if {[regexp {"command"[ \t\r\n]*:[ \t\r\n]*"([^"\\]*)"} $input -> extracted_command]} {
    set command $extracted_command
}

proc json_escape {value} {
    return [string map [list \\\\ \\\\\\\\ \" \\\" \n \\n \r \\r \t \\t] $value]
}

set reply_text "Tcl script bridge OK for command: $command"
set reply_json [json_escape $reply_text]

puts [format {{"protocol":"mediabot-script-v1","ok":true,"actions":[{"type":"reply","text":"%s"}]}} $reply_json]

Security boundary

The script bridge deliberately avoids shell execution.

Important protections:

Protection Behavior
No shell command string Scripts are launched with argv-style open3, not through system("...") or shell backticks.
Relative script paths only Absolute paths are rejected.
No parent traversal .. path traversal is rejected.
No backslash paths Windows-style/backslash paths are rejected.
Extension allow-list Only .pl, .py and .tcl are accepted.
Symlink containment Existing script paths are resolved; symlinks must remain inside script_dir.
Timeout Scripts are killed if they exceed the configured deadline.
Bounded output stdout/stderr are capped.
Non-blocking stdin write Large stdin payloads cannot block the bot indefinitely.
Declared script failure ok: false or non-empty errors blocks all actions.
Action validation Every returned action is validated before it can be planned or applied.
Scalar contracts Command names, paths, protocol fields, action fields and diagnostics reject references or malformed structured values.
Action-list contract actions must be an array when present; false scalars such as 0, "0" and "" are rejected.
Plugin lifecycle In-process plugins keep exact listener entries, honour enabled state and unregister hooks during replacement.
IRC gate IRC output needs both ACTION_MODE=apply and ALLOW_IRC=yes.
Apply scope guard Recommended APPLY_REQUIRE_SCOPE=yes prevents broad apply mode without COMMANDS or ROUTES.

The bridge is for trusted local scripts maintained with the bot. It is not a sandbox for untrusted users.


Runtime dependencies

Depending on which languages you use, the host needs:

Language Interpreter
Perl Current Perl interpreter used by Mediabot
Python python3
Tcl tclsh

Minimal checks:

which perl
which python3
which tclsh

Syntax checks:

perl -c plugins/scripts/examples/hello_perl.pl
python3 -m py_compile plugins/scripts/examples/hello_python.py
tclsh plugins/scripts/examples/hello_tcl.tcl < /dev/null

The Tcl example expects JSON on STDIN for meaningful output, but the interpreter check still proves tclsh is available.


Partyline diagnostics

The bridge adds read-only Partyline visibility. These commands do not load plugins, execute scripts, apply actions, send IRC messages or touch the database.

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

Use them to answer:

  • is the PluginManager initialized?
  • is plugin autoload enabled?
  • is ScriptDryRun loaded?
  • which routes are configured?
  • is action mode dry-run or apply?
  • is IRC output allowed?
  • is apply scope restricted?
  • what happened during the last routed command?

Typical workflow:

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

After a test command:

.scriptdryrun last

Logging

When a command is accepted by ScriptDryRun, DEBUG-level logs include:

PUBLIC(scriptdryrun): accepted command=pyhello script=examples/hello_python.py mode=apply allow_irc=1
PUBLIC(scriptdryrun): script_result command=pyhello elapsed_ms=...
PUBLIC(scriptdryrun): action_plan command=pyhello elapsed_ms=...

These are internal diagnostic logs, not script log actions. Tests distinguish the two.


Test coverage

Important regression tests for the bridge include:

Test Purpose
t/cases/400_mb220_scriptrunner_timeout_hang.t Ensures ScriptRunner cannot hang after a child closes descriptors then keeps running.
t/cases/401_mb221_scriptrunner_symlink_and_stdin_hardening.t Tests symlink containment and non-blocking stdin writes.
t/cases/402_mb222_scriptrunner_no_backtick_comments.t Keeps shell-execution hygiene checks clean.
t/cases/417_mb178_script_pipeline_dryrun.t Validates dry-run pipeline behavior.
t/cases/420_mb181_multilang_script_examples.t Validates Perl/Python/Tcl example scripts.
t/cases/423_mb184_scriptdryrun_command_routes.t Validates routes.
t/cases/425_mb186_script_action_runner_apply_gate.t Validates apply and IRC gates.
t/cases/426_mb187_scriptdryrun_action_mode.t Validates action mode behavior.
t/cases/428_mb189_scriptdryrun_apply_scope_guard.t Validates apply scope guard.
t/cases/435_mb198_scriptdryrun_multilang_apply_smoke.t End-to-end multilingual apply smoke test.
t/cases/438_mb201_script_runner_failure_modes.t Invalid path, invalid JSON, non-zero exit, timeout and unsupported action coverage.
t/cases/442_mb226_scriptrunner_declared_failure.t Ensures script-declared failures do not expose actions.
t/cases/455_mb242_plugin_replace_listener_cleanup.t Ensures plugin replacement removes only the old EventBus listener.
t/cases/457_mb244_plugin_manager_unregister_cleanup.t Validates explicit plugin unregister cleanup.
t/cases/506_mb282_end_to_end_apply_chain.t Exercises the complete subprocess-to-action apply chain.
t/cases/514_mb291_plugin_bridge_docs_contract.t Keeps docs, aliases and apply semantics coherent.
t/cases/517_mb295_sample_plugin_config_contract.t Keeps mediabot.sample.conf synchronized with runtime aliases and safety defaults.
t/cases/518_mb296_action_list_false_scalar_contract.t Rejects malformed false scalar action lists.
t/cases/519_mb297_commit_secret_scanner_precision.t Keeps the staged-secret scanner strict without blocking legitimate sample settings.

Recommended pre-commit check:

./commit.sh --preflight-full

Manual focused check:

perl -I. t/cases/420_mb181_multilang_script_examples.t
perl -I. t/cases/435_mb198_scriptdryrun_multilang_apply_smoke.t
perl -I. t/cases/438_mb201_script_runner_failure_modes.t
perl -I. t/cases/441_mb204_precommit_repository_hygiene.t
perl -I. t/cases/442_mb226_scriptrunner_declared_failure.t
perl -I. t/cases/455_mb242_plugin_replace_listener_cleanup.t
perl -I. t/cases/457_mb244_plugin_manager_unregister_cleanup.t
perl -I. t/cases/506_mb282_end_to_end_apply_chain.t
perl -I. t/cases/514_mb291_plugin_bridge_docs_contract.t
perl -I. t/cases/517_mb295_sample_plugin_config_contract.t
perl -I. t/cases/518_mb296_action_list_false_scalar_contract.t
perl -I. t/cases/519_mb297_commit_secret_scanner_precision.t

Troubleshooting quick reference

Public command 'pyhello' not found

Check:

.plugins loaded
.scriptdryrun status
.scriptdryrun config

Likely causes:

  • plugin autoload disabled;
  • Mediabot::Plugin::ScriptDryRun not listed in enabled plugins;
  • no route for the command;
  • wrong script path;
  • apply scope guard warning.

Command is accepted but no IRC message appears

Check:

.scriptdryrun last

Likely causes:

  • ACTION_MODE=dry-run;
  • ALLOW_IRC=no;
  • action plan invalid;
  • script returned ok:false;
  • script returned errors;
  • script timed out.

Python works but Tcl fails

Check:

which tclsh

If tclsh is missing, install Tcl or remove the Tcl route from the live configuration.

Script returns invalid JSON

Make sure the script writes only JSON to STDOUT. Debug text belongs on STDERR or in a log action.

Bad:

starting...
{"actions":[]}

Good:

{"ok":true,"actions":[]}

Script needs to fail intentionally

Return a declared failure:

{
  "ok": false,
  "errors": ["refusing unsafe input"]
}

Do not return actions with a failure response; Mediabot will ignore them anyway.


Maintainer rules

When adding a new script:

  1. Put it under plugins/scripts/.
  2. Use .pl, .py or .tcl.
  3. Read JSON from STDIN.
  4. Write exactly one JSON object to STDOUT.
  5. Include ok:true on success.
  6. Use ok:false and errors:[...] on failure.
  7. Return actions only from successful responses.
  8. Start in ACTION_MODE=dry-run.
  9. Use ROUTES rather than a broad fallback SCRIPT.
  10. Enable ALLOW_IRC=yes only on a test instance first.
  11. Check .scriptdryrun last.
  12. Run ./commit.sh --preflight-full before committing.

See also

Clone this wiki locally