- Python 100%
| .planning | ||
| question-sets-template | ||
| tests | ||
| .gitignore | ||
| __init__.py | ||
| config.py | ||
| plugin.py | ||
| pytest.ini | ||
| README.md | ||
| requirements-dev.txt | ||
| test.py | ||
Herodotus
A Limnoria/Supybot plugin that lets registered players of the Inquisitor trivia bot file question reports, bug reports, and feature ideas as Forgejo issues directly from IRC. Reports also export the affected question's data as YAML to a Forgejo branch so editors can review and merge changes through a normal pull request, with CI importing approved edits back into the live database.
Requirements
- Python 3.10+
- Limnoria (Supybot framework)
- The Inquisitor plugin loaded in the same bot instance — Herodotus delegates DB lookups and permission checks to it
- A Forgejo (or Gitea 1.20+) instance reachable from the bot, with an API token that has
write:issueandwrite:repositoryscopes - A companion
question-setsrepo with the validate / import workflows installed (templates ship inquestion-sets-template/— see Setting up the question-sets repo below)
The plugin itself uses only the Python standard library (urllib, json, base64, re, datetime) — no third-party HTTP clients. The CI scripts in question-sets-template/scripts/ need PyYAML and mariadb, but those run in the question-sets repo's workflow container, not in the bot.
Installation
1. Install the plugin in your bot
Clone into your bot's plugin directory:
cd /path/to/limnoria/plugins
git clone https://git.ptp-trivia.me/Inquisitor/Herodotus.git
Then load it in the bot:
@load Herodotus
Inquisitor must already be loaded for permission and DB access to work.
2. Set up the question-sets repo
The report → edit → import loop needs CI on the other side of the wire. Follow Setting up the question-sets repo before you start filing reports — without it, exports will land in branches but never make it back into the database.
Configuration
All settings are registered as Limnoria plugin config values. Set them with @config plugins.Herodotus.<key> <value>.
| Key | Default | Description |
|---|---|---|
forgejoToken |
(empty) | Forgejo API token. Private — set via @config plugins.Herodotus.forgejoToken <token> in a private query. Required scopes: write:issue, write:repository. |
forgejoUrl |
https://git.ptp-trivia.me |
Base URL of the Forgejo instance. No trailing slash. |
questionSetsRepo |
Inquisitor/question-sets |
owner/repo for question reports and feature ideas. Also the destination for YAML exports. |
bugRepo |
Inquisitor/Inquisitor |
owner/repo for bug reports. |
logChannel |
(empty) | IRC channel that receives Forgejo API failure notices. Format network:#channel (e.g. libera:#ops). Leave empty to log only to the file log. |
Commands
report <num> <text> — flag a bad question
Files an issue against questionSetsRepo for question <num>. Requires PLAY permission (registered Inquisitor player).
The plugin looks up the question in Inquisitor's database, embeds the question text, accepted answer, and any alt answers in the issue body, and tags it with issue and a label slugified from the question's set name (e.g. general-knowledge).
Once the report is filed, the question is temporarily parked (is_active = 0) so the bot stops asking it while it is under review. The park is reversed automatically by CI on merge: a merged edit reactivates the question (is_active = 1), while a merged delete (delete: true) leaves it inactive, retiring it for good. If the review PR is never merged, the question stays parked until it is — reopen or merge the PR (or set delete: true) to resolve it.
If an open issue with Q#<num> already exists, the new report is appended as a comment instead of opening a duplicate. A parked question is still reportable, so follow-up reports are captured on the existing issue.
<player> .report 4231 the answer says "Mercury" but should be "Venus"
<bot> Reported. Issue #87 filed.
The same report also stages the question's YAML at reports/<id>.yaml for review. The baseline — the question's current content, the "before" side of the diff — is committed to main with [skip ci] (so the import workflow ignores it). The long-lived editing branch is then synced to descend from main, which is what makes a later PR editing → main render the editor's changes as a real before/after diff instead of a file-addition: the baseline has to sit in the PR's merge-base, not merely on main's tip. Editors change the file on editing; the issue body links straight to it.
Keeping editing descended from main is automatic and needs no pull request from the plugin — editors open the editing → main PR themselves whenever they are ready. While editing carries no un-merged edits, each report recreates it from main, so it accumulates every pending baseline and all those questions stay editable. Once an editor makes the first change, editing is left untouched to protect that work; such a question parks its baseline on main, and its issue says the edit link will be posted there automatically. The reset is driven entirely by CI: when the review PR merges, CI deletes editing and, if reported questions are still waiting, recreates it from main at once (now carrying the parked baselines) and comments the edit link on each waiting question's issue. Otherwise the next report recreates it. Waiting questions stay parked until their own edit merges or an admin runs unpark. A branch freshly recreated from main already has its questions' baselines in the merge-base, so a manually opened PR shows correct before/after diffs with no merge plumbing.
bug <text> — file a bot bug
Files an issue against bugRepo. Requires PLAY permission. Tagged bug and triage. No deduplication — every invocation creates a new issue.
<player> .bug bot stops responding after a netsplit
<bot> Bug filed: https://git.ptp-trivia.me/Inquisitor/Inquisitor/issues/204
idea <text> — submit a feature idea
Files an issue against questionSetsRepo. Requires PLAY permission. Tagged idea. No deduplication.
<player> .idea per-channel difficulty levels
<bot> Idea filed. Issue #88.
bulkexport [--branch <name>] [--set <name>] [--category <value>] [--text <substr>] [--answer <substr>] [--dry-run] — admin batch export
Exports every question matching the supplied filters to reports/<id>.yaml files on <branch> in questionSetsRepo, in a single Forgejo batch commit. Requires ADMIN permission. --branch is mandatory and at least one filter flag must be provided.
Baselines are first written to main (with [skip ci]), then <branch> is created as a fresh descendant of that main — so the baselines sit in the PR merge-base and any edits made on <branch> show as real before/after diffs in a PR back to main. The files are already present on <branch> via main's history; editors change them in place and open the PR. If <branch> already exists with un-merged edits, the command refuses and asks for a new name (so it never clobbers in-progress work). The baseline/staging step is skipped when --branch main, which instead commits directly to main and applies the questions through CI.
Filters combine with AND. --text and --answer are SQL LIKE substrings (wildcards in user input are escaped). --dry-run prints a count and a three-question preview without contacting Forgejo.
<admin> .bulkexport --branch geography-cleanup --category Geography --dry-run
<bot> Would export 47 questions. Preview: #102 'Largest country by area?', #211 ...
<admin> .bulkexport --branch geography-cleanup --category Geography
<bot> Exporting 47 questions to 'geography-cleanup'...
<bot> Exported 47 questions (0 skipped). https://git.ptp-trivia.me/Inquisitor/question-sets/src/branch/geography-cleanup
A file already present on the target branch is skipped — re-running the command won't clobber edits in flight.
createpr <branch> [<title>] — open a review PR
Opens a pull request from <branch> to main in questionSetsRepo, so edits staged on a branch can be reviewed and merged. Requires MANAGE permission. The plugin does not open PRs automatically, so this is how a staged editing (or a bulkexport target branch) gets turned into a reviewable PR from IRC.
<title> is optional free-form text used as the PR title; if omitted, a default (Question edits from <branch>) is used. The command checks the branch exists and has changes against main before calling the API; if a PR from that branch is already open, it replies with the existing PR's URL instead of erroring.
<manager> .createpr editing Fix Venus/Mercury mixups in astronomy set
<bot> Pull request opened: https://git.ptp-trivia.me/Inquisitor/question-sets/pulls/93
unpark <num> — reactivate a parked question
Sets is_active = 1 for question <num>, so the bot resumes asking it. Requires MANAGE permission.
Filing a report parks the question (is_active = 0) so it stops being asked while under review; the import CI reactivates it when the report's PR merges. unpark is the manual backstop for the case where that PR is never merged — closed unmerged, or the issue is abandoned — which otherwise leaves the question parked indefinitely.
unpark is idempotent: on a question that is already active it still replies Q#<num> reactivated. Only an unknown number gets Question #<num> not found.
<manager> .unpark 4231
<bot> Q#4231 reactivated.
Permissions
Herodotus does not use Limnoria's native capability system for player commands. Instead it asks Inquisitor:
report,bug,idearequirePermission.PLAY(registered player)createprandunparkrequirePermission.MANAGEbulkexportrequiresPermission.ADMIN
Permission checks fail closed: any error during the check denies the command. The Inquisitor plugin must be loaded — if it is not, every command replies with Inquisitor plugin is not loaded. and aborts.
The report → edit → import loop
- A player runs
.report <num> <text>. - Herodotus looks up the question, opens (or comments on) a Forgejo issue, parks the question inactive (
is_active = 0) so the bot stops asking it while it is under review, commits the question's current YAML as a baseline onmain(with[skip ci]), and syncseditingto descend frommainso the baseline lands in the PR merge-base. - An editor edits
reports/<id>.yamlonediting(or setsdelete: trueto retire the question), then opens a PRediting → mainwhenever they are ready. The plugin creates no PR itself; a branch recreated frommainalready has the right merge-base, so the diff is correct. Reports arriving after editing has begun park their baseline onmain; when the current round merges, CI recreateseditingand posts their edit links (see step 5). - The
validate-reports.ymlworkflow lints the YAML on the PR. - On merge, the
import-reports.ymlworkflow runsimport_reports.py, which reads the merged YAML and updates the corresponding row in Inquisitor's database in a single transaction. An edit writes the new fields and reactivates the question (is_active = 1), undoing the report-time park so it returns to the asking pool; a delete (delete: true) leaves it inactive, soft-deleting it. The script then comments the result on the PR, removes the imported baseline files frommain, deletes the PR's head branch (the disposableeditingbranch), and closes the PR. Last, the script recreateseditingfrommainwhen reported questions are still waiting, and comments the edit link on each one's open issue.
The CI workflows live in the question-sets repo and are intentionally outside the plugin codebase — Herodotus only writes the YAML; everything else is git-driven. Templates for those workflows ship in question-sets-template/; see the next section.
Setting up the question-sets repo
Herodotus is one half of a two-repo system. The other half is the question-sets Forgejo repo, which holds two CI workflows and the scripts they call. The plugin pushes YAML into branches of that repo; the CI lints PRs and applies merged edits back to the database.
The full template — kept in lockstep with the plugin's expectations — lives at question-sets-template/ inside this repository. That directory has its own README with detailed walkthrough; the steps below are the short version.
Files to copy
question-sets-template/
├── scripts/
│ ├── validate_reports.py
│ └── import_reports.py
└── .forgejo/
└── workflows/
├── validate-reports.yml
└── import-reports.yml
One-time bootstrap
-
Create the repo at
<owner>/question-setson the same Forgejo instance the bot talks to. The path here must matchplugins.Herodotus.questionSetsRepoexactly. -
Copy
scripts/and.forgejo/fromquestion-sets-template/into the repo's root, preserving the directory structure. -
Add a placeholder
reports/.gitkeepand commit it so Herodotus's first export does not race a missing parent directory. -
(Optional) the
editingbranch is managed automatically. Herodotus creates it frommainon the first report, keeps it descended frommain, and CI deletes it on each merge and recreates it straight away when reported questions are waiting — you do not need to create it by hand. The bot's token therefore needs push access tomain(it is committed to directly for baselines). If you protectmain, which is recommended so the validation check gates merges, pick Whitelist restricted push and add the bot's user and the CI token's user, because both commit tomaindirectly. See the template README. -
import-reports.ymlneeds no repo-specific edit. The import step setsREPO: ${{ forgejo.repository }}, so cleanup, the PR comment and the PR close always target the repo the workflow runs in. (Removing that env line makes the script fall back toInquisitor/question-sets.) -
Configure repo secrets under Settings → Actions → Secrets:
Secret Used by Notes DB_HOSTboth workflows MariaDB host for Inquisitor's DB DB_PORTboth optional; defaults to 3306 DB_USERboth validateonly needs SELECT;importneeds UPDATE onquestionsDB_PASSboth DB_NAMEboth FORGEJO_URLimport-reportsonlythe base URL users open in a browser, no trailing slash, because recovery posts edit links built from it FORGEJO_TOKENimport-reportsonlyscopes write:issue+write:repository; can be a CI-only token, separate from the bot's -
Mint and configure the bot's token with the same
write:issue+write:repositoryscopes, and set it on the bot side via@config plugins.Herodotus.forgejoToken <token>. This is a different value from the CI'sFORGEJO_TOKEN.
After this you can run .report <num> <text> from IRC and watch a fresh issue + a reports/<id>.yaml on editing appear in your new repo.
Container choice and actions/checkout
Both workflows run in python:3.11-slim, which has git but not Node.js. They therefore use raw git clone rather than actions/checkout — switching to a node-bearing image lets you use the action again, but the raw-clone form is the supported path. validate-reports.yml clones with --no-single-branch so git checkout "$GITHUB_HEAD_REF" resolves the PR ref; import-reports.yml does a normal full clone because the merged commit is already on main by the time the job starts.
Keeping the template in sync
If you patch the live CI scripts in your question-sets repo, mirror the change back into question-sets-template/ here and commit it alongside any related plugin change. Pinning both halves in one repo is how the plugin and CI stay coupled across releases.
Logging
Failures during Forgejo API calls are always logged to Limnoria's plugin log file (supybot.plugins.Herodotus). When logChannel is configured, the same details are also sent to the named IRC channel — useful for live ops alerts.
The IRC log line format is:
[Herodotus] Forgejo API failure | nick=<nick> net=<network> chan=<channel> text='<user text>' | Q#<id> set=<set>
The Q#<id> and set= fields are present only when the failure is associated with a known question.
Repository layout
Herodotus/
├── __init__.py # Plugin entry; reload-safe imports
├── config.py # Limnoria registry definitions for all settings
├── plugin.py # Commands and Forgejo API client
├── test.py # Limnoria test bootstrap (supybot-test hook, not run by pytest)
├── pytest.ini # pytest config: collects tests/ only
├── requirements-dev.txt # Dev dependencies (pytest, PyYAML)
├── tests/ # pytest suite with supybot/mariadb stubs and Forgejo/DB fakes
├── question-sets-template/ # CI templates for the companion repo
│ ├── scripts/ # validate_reports.py, import_reports.py
│ └── .forgejo/workflows/ # validate-reports.yml, import-reports.yml
├── README.md
└── .planning/ # Project planning artifacts (GSD workflow)
Development
plugin.py is single-file by design and uses only the standard library, so no virtualenv is required to read or modify it. The class is threaded = True because every command makes blocking HTTPS calls; do not remove that flag without restructuring around irc.replyAsync or similar.
Inquisitor imports are deliberately lazy (inside method bodies, not module top) so that @reload Herodotus and @reload Inquisitor interact correctly — Herodotus picks up Inquisitor's freshly reloaded modules on the next command rather than holding a stale reference.
To run Limnoria's test scaffolding against the plugin:
supybot-test Herodotus
Running the tests
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt && .venv/bin/pytest
The suite needs no Limnoria, MariaDB or Forgejo: supybot, mariadb, the Inquisitor DB and Forgejo are stubbed or faked in-process. A test that reaches the real urlopen, mariadb.connect, a socket connect or a git subprocess fails by design; other channels are not guarded. test.py is Limnoria's own hook for supybot-test and is not part of the pytest suite.
Versioning
See __init__.py for the current __version__. Milestones and phase summaries are tracked under .planning/.