Mediabot v3 β Development Journal, March 2026
Every long-lived project has one: a file that started small and quietly grew into something terrifying. For Mediabot v3, that file was Mediabot.pm β a 15,530-line monolith containing everything: IRC dispatching, database commands, channel management, user authentication, radio streaming, external API calls, quote management, admin tools, and more.
Reading it felt like opening the Restricted Section at Hogwarts. Fixing a bug meant searching through thousands of lines. Adding a feature risked breaking six unrelated things. It was time to cast the Accio Modularization spell.
The core idea was straightforward: identify logical domains, extract their functions into dedicated modules, and wire them back together through clean imports. What followed was weeks of careful surgery.
| Module | Responsibility | Lines |
|---|---|---|
Mediabot::Helpers |
Shared utilities: botNotice, logBot, checkUserLevel, DNS, flood control |
2,747 |
Mediabot::ChannelCommands |
Channel management: join/part, chanset, badwords, access lists | 3,157 |
Mediabot::DBCommands |
Custom commands, ignores, timers, responders, CRUD | 2,286 |
Mediabot::UserCommands |
User management: add/del, auth, levels, seen, greet | 1,409 |
Mediabot::Radio |
Icecast/Liquidsoap: play, queue, listeners, metadata | 2,268 |
Mediabot::External |
YouTube, TMDB, ChatGPT, weather, URL titles | 1,120 |
Mediabot::LoginCommands |
Authentication: login, autologin, hostmask matching | 621 |
Mediabot::AdminCommands |
Bot control: status, rehash, restart, jump, exec | 466 |
Mediabot::Hailo |
Hailo AI brain: learn, reply, spike responses | 545 |
Mediabot::Quotes |
Quote database: add, search, random, stats | 457 |
Result: Mediabot.pm went from 15,530 lines down to 1,068. The core now contains only what it should: the constructor, IRC event loop integration, and the two main dispatchers (mbCommandPublic / mbCommandPrivate).
Every module that needs shared helpers simply declares:
use Mediabot::Helpers;
Mediabot::Helpers exports all shared functions via @EXPORT β botNotice, botPrivmsg, logBot, checkUserLevel, getIdChansetList, and 50+ more. Each module also declares its own use statements independently, because Perl does not inherit use declarations β a lesson learned the hard way through a cascade of Bareword not allowed errors.
Alongside the modularization, a full MariaDB schema migration was applied across all Mediabot instances.
USER.hostmasks CSV column extracted into a proper USER_HOSTMASK table with foreign keysCASCADE / SET NULL behaviorsWEBLOG.password column removedCHANNEL_LOG.publictext upgraded to TEXTUSER table encoding migrated to utf8mb4USER.auth normalized to TINYINT(1)bigint(20) columns converted to BIGINT UNSIGNEDA production-safe install/db_migrate.sh was written to handle this automatically: it reads the configuration file, creates a full backup via mysqldump, applies each migration step idempotently, and validates foreign keys using a stored procedure compatible with MariaDB 10.x.
β οΈ This migration is required before deploying this version. Run
install/db_migrate.shon every instance before starting the bot.
require_level Phantom CrashThe most insidious bug: throughout the codebase, authenticated commands were written as:
my $user = $ctx->require_level("Administrator") or return;
# ... later ...
$sth->execute($user->id, ...);
The problem: require_level() returns 1 (boolean) on success, not the user object. Calling "1"->id in Perl throws a fatal exception that kills the bot process with an EOF from client. Fixed by separating the authorization check from the user object retrieval:
return unless $ctx->require_level("Administrator");
my $user = $ctx->user;
return unless $user;
This was present in 5 different modules.
The resolve command used gethostbyname() β a blocking system call that freezes the entire Net::Async::IRC event loop. The bot would hang, miss PINGs, and get disconnected by the server.
The fix uses open(my $pipe, '-|', ...) to spawn a child Perl process for the DNS lookup, sets the pipe to non-blocking mode with Fcntl, and collects the result 3 seconds later via IO::Async::Timer::Countdown β keeping the event loop free throughout.
$self->{db}->dbh GhostuserLogin_ctx was calling $self->{db}->dbh to get a database handle. But $self->{db} is never initialized β the bot exposes $self->{dbh} directly. The eval {} wrapper was silently swallowing the crash and returning undef, causing every login attempt to fail with βInternal error (DB unavailable)β instead of authenticating. Fixed by using $self->{dbh} directly and replacing a nonexistent level_id_to_desc() method with a direct USER_LEVEL query.
A full live testing framework was built to validate bot behavior against a real IRC server and a real (test) database.
t/test_live.pl is the runner. It:
mediabot_test database from t/live/schema_test.sqltest.conf from a template with randomized bot/spy nicks.t test files in order, with die_last always executed last| # | File | Coverage |
|---|---|---|
| 01 | 01_connect.t |
WHOIS identity, version string |
| 02 | 02_routing.t |
PRIVMSG vs NOTICE routing |
| 03 | 03_auth.t |
Login success/failure, whoami |
| 04 | 04_dispatch_public.t |
Public command dispatch |
| 05 | 05_dispatch_private.t |
Private command dispatch |
| 06 | 06_commands_auth.t |
Authenticated command responses |
| 07 | 07_channel_commands.t |
chaninfo, chanset, access, seen |
| 08 | 08_user_commands.t |
users, userinfo, whoami, greet |
| 09 | 09_quotes.t |
q add/search/random/view/del |
| 10 | 10_external_commands.t |
date, leet, colors, echo, status |
| 11 | 11_ignores_responders.t |
ignore/unignore, yomomma, timers |
| 12 | 12_db_commands.t |
addcmd/showcmd/delcmd/searchcmd |
| 13 | 13_die_last.t |
Clean bot shutdown via !die |
--from N / --to N β Run only a subset of tests (e.g. --from 10 to debug the last few)die_last guarantee β Files matching die_last always run last, regardless of numeric prefixkill(0, $bot_pid) checks the bot is still alive; remaining tests are skipped with a clear message if it has crashed$drain closure is passed to each test file to flush residual multi-line responses between commands# Full suite
perl t/test_live.pl --verbose --server localhost --port 6667 --channel '#mbtest'
# From test 10 onwards
perl t/test_live.pl --verbose --server localhost --port 6667 --channel '#mbtest' --from 10
# A specific range
perl t/test_live.pl --verbose --server localhost --port 6667 --channel '#mbtest' --from 07 --to 09
A few items are flagged for future work:
rehash crash β The command kills the bot under certain conditions; cause under investigationresolve/whereis in tests β Excluded from the test suite pending a stable non-blocking implementationignores list verification β The βlist after addβ test is skipped; a DB scope mismatch is suspected| Metric | Before | After |
|---|---|---|
Mediabot.pm lines |
15,530 | 1,068 |
| Modules | 1 monolith | 10 focused modules |
| DB tables with FK | 0 | 19 |
| Live test assertions | 0 | 68 |
| Known crash bugs fixed | 0 | 3 |
βIt is our choices, Harry, that show what we truly are, far more than our abilities.β β This commit chose modularity.
You must be logged in to reply.