-
Notifications
You must be signed in to change notification settings - Fork 1
Plugin and script bridge
This page documents the Mediabot v3 plugin foundation and the external script bridge used by ScriptDryRun.
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=yesgate; - production apply mode should be scoped with
COMMANDSorROUTES; - 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.
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::ScriptDryRun |
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 example. |
plugins/scripts/examples/hello_python.py |
Minimal Python example. |
plugins/scripts/examples/hello_tcl.tcl |
Minimal dependency-free Tcl example. |
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.
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.
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.
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.tclA 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
ACTION_MODE=apply
ALLOW_IRC=yes
APPLY_REQUIRE_SCOPE=yesRecommended live smoke test from IRC:
m hello
m pyhello
m tclhello
Expected output:
Perl script bridge OK for command: hello
Python script bridge OK for command: pyhello
Tcl script bridge OK for command: tclhello
| 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. |
PLUGINS_AUTOLOAD |
Compatibility key accepted by the loader. |
PLUGINS_ENABLED / PLUGINS
|
Compatibility keys for enabled plugins. |
| 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. |
| 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
Use ROUTES for dedicated script commands:
[plugins.ScriptDryRun]
ROUTES=hello=examples/hello_perl.pl, pyhello=examples/hello_python.py, tclhello=examples/hello_tcl.tcl
ACTION_MODE=apply
ALLOW_IRC=yes
APPLY_REQUIRE_SCOPE=yesThis is the safest operational model because only explicitly routed commands are intercepted.
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=yesThe script receives the command name in the JSON payload and can branch internally.
Avoid this in production:
[plugins.ScriptDryRun]
SCRIPT=examples/router.pyWith 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.
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
0only when the JSON response is valid. - A script that cannot safely answer should return
ok: falseand anerrorsarray.
The protocol version is currently:
mediabot-script-v1
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.
Recommended successful response:
{
"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:
{
"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.
Scripts return an actions array. Mediabot validates every action before planning or applying anything.
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.
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.
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.
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.
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.
ACTION_MODE=apply enables real action application, but IRC output is still blocked unless ALLOW_IRC=yes.
In apply mode with ALLOW_IRC=no:
-
logactions can be applied; -
replyandnoticeactions are blocked with explicit apply errors; - no IRC messages are sent.
In apply mode with ALLOW_IRC=yes:
-
logactions can be applied; -
replyactions sendPRIVMSG; -
noticeactions sendNOTICE; - 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
ACTION_MODE=apply
ALLOW_IRC=yes
APPLY_REQUIRE_SCOPE=yesuse 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({
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',
},
],
});#!/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({
"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"
}
]
}))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 {{"ok":true,"actions":[{"type":"reply","text":"%s"}]}} $reply_json]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. |
| 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.
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 tclshSyntax 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/nullThe Tcl example expects JSON on STDIN for meaningful output, but the interpreter check still proves tclsh is available.
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
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.
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. |
Recommended pre-commit check:
./commit.sh --preflight-fullManual 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.tCheck:
.plugins loaded
.scriptdryrun status
.scriptdryrun config
Likely causes:
- plugin autoload disabled;
-
Mediabot::Plugin::ScriptDryRunnot listed in enabled plugins; - no route for the command;
- wrong script path;
- apply scope guard warning.
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.
Check:
which tclshIf tclsh is missing, install Tcl or remove the Tcl route from the live configuration.
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":[]}Return a declared failure:
{
"ok": false,
"errors": ["refusing unsafe input"]
}Do not return actions with a failure response; Mediabot will ignore them anyway.
When adding a new script:
- Put it under
plugins/scripts/. - Use
.pl,.pyor.tcl. - Read JSON from STDIN.
- Write exactly one JSON object to STDOUT.
- Include
ok:trueon success. - Use
ok:falseanderrors:[...]on failure. - Return actions only from successful responses.
- Start in
ACTION_MODE=dry-run. - Use
ROUTESrather than a broad fallbackSCRIPT. - Enable
ALLOW_IRC=yesonly on a test instance first. - Check
.scriptdryrun last. - Run
./commit.sh --preflight-fullbefore committing.