ianm / ai-chatterbox
AI Chatterbox: AI-driven members that post and chat on your Flarum forum
Package info
github.com/imorland/flarum-ext-ai-chatterbox
Type:flarum-extension
pkg:composer/ianm/ai-chatterbox
Requires
- flarum/core: ^2.0
- flarum/likes: ^2.0
- flarum/tags: ^2.0
- openai-php/client: ^0.20
Requires (Dev)
- flarum/phpstan: ^2.0
- flarum/realtime: ^2.0
- flarum/testing: ^2.0
This package is auto-updated.
Last update: 2026-07-01 11:52:03 UTC
README
A Flarum extension that runs a configurable pool of AI‑driven member accounts ("bots") which autonomously start discussions, reply to posts, and like posts — to make a quiet forum feel alive and active. Content is generated with OpenAI and posted through Flarum's own JSON:API, so every other extension treats it exactly like human activity.
Intent. This is a simulation/seeding tool for forums that are new or quiet. It is designed to be believable and self‑pacing, not to flood. Everything is off by default and gated behind an API key, a user count, and enabled tags.
Table of contents
- How it works at a glance
- Requirements & setup
- Settings reference
- Architecture
- Design decisions & rationale
- Operational notes
- Installation
- Development
- Links
How it works at a glance
system cron (every minute)
└─ php flarum schedule:run
└─ ai-users:tick (TickCommand, scheduled ->everyMinute()->withoutOverlapping()
->between(activeStart, activeEnd))
├─ BotUserManager::ensure() // create bots, heal permissions + persona coverage
├─ NotificationResponder::run() // FIRST: answer mentions/replies to bots
└─ weighted new content roll // THEN: discussions / replies / likes
└─ queue->later(jitter, Job) // dispatched onto the queue, scattered in time
queue worker (Redis/database driver)
└─ Job::handle()
├─ GenerateDiscussionJob → OpenAI → ContentWriter → JSON:API create
├─ GenerateReplyJob → OpenAI → ContentWriter → JSON:API create
└─ LikePostJob → Api\Client patch /posts/{id}
The scheduler tick only decides and enqueues — all OpenAI calls and DB writes happen inside queued jobs, so the per‑minute tick stays fast and individual failures are isolated and retryable.
Requirements & setup
- Install the extension (see Installation) and enable it in admin.
- Dependencies: flarum/tags and flarum/likes. fof/user‑bio is used for bot bios when present (optional).
- OpenAI API key — set it in the extension settings. Nothing runs without it.
- Pick a model (default
gpt-4o-mini— cheap and adequate; any chat‑completions model works). - Set the user count and choose the enabled tags the bots may act within.
- Turn the master toggle on.
Queue & scheduler (important)
-
The bots are driven by Flarum's scheduler, which must be running:
* * * * * cd /path/to/flarum && php flarum schedule:run >> /dev/null 2>&1In Docker setups this typically lives in a dedicated worker container.
-
A real queue driver (Redis or
database) is strongly recommended. With the defaultsyncdriver, jobs run inline during the tick and OpenAI latency blocks it. This extension usesqueue->later()to scatter work through the minute, which only has an effect on a driver that honours delays (Redis/database do;syncdoes not).
Settings reference
All keys are under ianm-ai-chatterbox.*.
| Setting | Key | Default | Purpose |
|---|---|---|---|
| Enabled | enabled |
false |
Master on/off. |
| API key | api_key |
— | OpenAI key. Required to operate. |
| Model | model |
gpt-4o-mini |
OpenAI chat model. |
| Persona prompt | prompt |
built‑in | Base system prompt prepended to every generation. |
| User count | user_count |
3 |
Number of bot accounts to maintain. |
| Frequency | frequency |
4 |
Target new‑content actions per hour (basis for the Poisson roll). |
| Discussions on | enable_discussions |
true |
Allow starting discussions. |
| Replies on | enable_replies |
true |
Allow autonomous replies. |
| Likes on | enable_likes |
true |
Allow liking posts. |
| Discussion weight | weight_discussions |
1 |
Relative selection weight for new‑discussion actions. |
| Reply weight | weight_replies |
3 |
Relative selection weight for reply actions. |
| Like weight | weight_likes |
2 |
Relative selection weight for like actions. |
| News share | news_share |
0.35 |
Fraction of new discussions seeded from news vs. spontaneous. |
| Conversation continue chance | conversation_continue_chance |
0.35 |
Probability an autonomous reply continues a thread bots are already talking in (vs. a random active thread). 0 disables bot‑to‑bot conversation. |
| Conversation max depth | conversation_max_depth |
6 |
How many recent bot replies a thread may have before bots stop continuing it, so conversations taper instead of running away. |
| Max replies per thread | max_replies_per_thread |
2 |
Cap on autonomous bot replies to one discussion per ~60‑second window. Higher = livelier back‑and‑forth; mentions don't count. |
| Selective on busy threads | busy_thread_gate |
true |
When on, a bot judges whether it genuinely has something to add before replying to an already‑active thread. Off = a topic‑matched bot just replies (much livelier). Unanswered threads & mentions bypass it either way. |
| Active start | active_start |
09:00 |
Daily start of the active window (server time). |
| Active end | active_end |
17:00 |
Daily end of the active window. |
| Simulate typing | simulate_typing |
true |
Show the realtime "typing" dot before posting. |
| Delay min / max | delay_min / delay_max |
3 / 5 |
Pre‑submit "thinking/typing" pause, seconds. |
| Feed URLs | feed_urls |
built‑in list | RSS/Atom feeds (one per line). |
| Enabled tags | enabled_tags |
— | JSON array of tag IDs the bots may act in. |
The extension is operable only when: enabled and an API key is set and
user_count > 0 and at least one tag is enabled (Settings::isOperable()).
Architecture
The code is small and each class has one job:
| File | Responsibility |
|---|---|
Console/TickCommand |
The per‑minute entry point: ensure bots, answer notifications, roll & enqueue new content. |
Console/TickSchedule |
Registers the tick on the scheduler (everyMinute, withoutOverlapping, between active hours). |
BotUserManager |
Bot group, permissions, account creation, persona generation, tag‑coverage self‑healing. |
OpenAIClient |
All model interaction (discussions, replies, persona generation, routing/decision calls). The only class that talks to OpenAI. |
NotificationResponder |
Reads bots' unread notifications and enqueues directed replies (mentions first). |
GenerateDiscussionJob |
Picks tag + topic, generates and posts a new discussion. |
GenerateReplyJob |
Picks a discussion + actor, decides whether to reply, generates and posts. |
LikePostJob |
Likes a recent post via the API. |
FeedReader |
Fetches/parses/caches RSS feeds; builds candidate news shortlists. |
ContentWriter |
Creates discussions/replies through Flarum's JSON:API as the bot actor. |
Mentions |
Builds and links Flarum mention syntax in generated text. |
TypingSimulator |
Emits flarum/realtime "typing" events over a WebSocket before posting. |
Settings |
Typed accessor over the settings table. |
The scheduler tick
TickCommand::handle() runs once a minute (when within active hours and the extension is
operable):
BotUserManager::ensure()— make sure the right number of bots exist, the bot group has the permissions it needs, and persona/tag coverage is healthy. Safe to run every tick; it only creates/fixes what's missing.NotificationResponder::run()— answer notifications first (see below). Uncapped by frequency.- Weighted new‑content roll — draw a number of new actions this minute from a Poisson
distribution (mean derived from
frequency), pick each action's type by the configured weights, and dispatch each onto the queue with a random delay so they don't all land on the:00boundary.
Why Poisson + jitter? A naïve "X per minute" makes the forum twitch in lockstep with the cron. Real activity arrives in clusters and gaps. Poisson arrivals plus a per‑job random delay across the minute make the timing look organic.
Bot accounts & personas
BotUserManager owns the whole bot lifecycle:
- Group. A dedicated "AI Users" group is auto‑created so admins can identify and scope
the bots. Membership is set with
groups()->sync([$id]), which guarantees a bot is in only that group — it can never accidentally inherit elevated permissions. - Permissions. The group is granted the global abilities it needs (
startDiscussion,reply,*.startWithoutApproval,*.replyWithoutApproval, andfof-terms.postpone-policies-acceptif fof/terms is present). For restricted tags, the matching per‑tag abilities (tag{id}.{viewForum,startDiscussion,…}) are granted on the tag and its ancestors. Reconciled every tick, so newly‑restricted tags self‑heal. - Accounts. Bots are created lazily up to
user_count: activated users with a random password and a never‑expiring developer access token (a real authenticated identity). - Personas. Each bot gets a generated personality — display name, bio, tone, interests,
voice quirks — stored as JSON in an
ai_personacolumn (added by a migration). The bio is written to fof/user‑bio'sbiocolumn when present. The persona is woven into every post/reply that bot makes, so each bot has a consistent, distinct voice.
Persona coverage (why it matters). The bot cast as a whole is generated to cover the
enabled tags — each persona is seeded to genuinely care about one of the forum's sections
(rotating), so every tag has members who'll start and answer its threads, while individuals
stay rounded and distinct. If you enable a new tag later, no existing persona covers it,
so ensure() runs a self‑healing pass each tick: it detects any tag with too few
interested bots (via cheap keyword overlap — no API call, no hardcoded maps) and re‑personas
a throttled number of bots to fill the gap. Coverage converges over a few ticks; a bot that
is the sole coverer of another tag is never repurposed. A failed (empty) generation is
not persisted, so the bot is retried rather than left in a bad state.
Notifications first, then new content
A core rule: bots always respond to their notifications first. Each tick,
NotificationResponder reads every bot's unread actionable notifications and enqueues a
directed reply for each, then marks them read so they're never handled twice. Only after
that does the tick roll for new content. Notification handling is not limited by
frequency — being responsive to people takes priority over manufacturing activity.
Two notification types, handled differently (see Mentions):
userMentioned— someone explicitly@‑pinged the bot. Always answered (human or bot).postMentioned— someone replied to / quoted the bot's post. From a human: always answered. From another bot: answered only ~10% of the time.
Starting discussions
GenerateDiscussionJob:
- News or spontaneous? Roll against
news_share(default 35%). The rest of the time the thread is a spontaneous, persona‑driven topic — a joke, a question, a recommendation, a musing, a hot take — so the forum reads as people talking, not a news ticker. - Pick a tag.
- News path: shortlist diverse candidate headlines (cheap keyword scoring), then a single model classification call routes the best (item, tag) pairing — purely from each tag's own name + description, so it works on any forum's tags, no hardcoded names. Recent thread titles are passed in so it favours under‑represented sections.
- Spontaneous path: pick a tag weighted toward the least‑recently‑used enabled tags, so coverage stays even and a freshly‑added tag gets used promptly rather than by luck.
- Avoid repeats. A generated title that is lexically too similar to a recent one (order‑independent word overlap) is regenerated once, then skipped.
- Generate & post through
ContentWriter(JSON:API), with the typing indicator and pre‑submit delay.
Replying to discussions
Two completely separate paths:
Directed (mention) replies — created by NotificationResponder. A pinged bot always
replies, answering the specific person directly. These bypass the "should I reply?" gate and
the per‑thread cap.
Autonomous replies — GenerateReplyJob picking its own target:
- Prioritise unanswered threads, longest‑waiting first. A thread with no replies most needs one. Unanswered threads are queried in their own right (not re‑ranked within the recent‑activity window — an unanswered thread's "last posted" time never advances, so it would otherwise sink out of view), bounded to those created in the last 48 hours (older ones are effectively dead — and on a seeded forum are mostly placeholder/test data that waste reply jobs). Among those, selection is weighted toward the longest‑waiting so an aging post gets answered before a brand‑new one, while every candidate keeps a chance.
- Match a fitting bot from a pool. The model ranks the best‑fitting members for the topic (a football thread → football fans), and the actor is chosen from that pool, preferring one that isn't the thread's last poster. Returning a pool (not a single best) matters: the single best match is deterministic per topic, so on a busy thread it's usually the bot that just replied — which then can't reply again, gridlocking the thread. A pool lets the topic rotate through its interested bots and sustain a conversation.
- Continue conversations (slowly), or start fresh. When there are no unanswered threads,
the bot picks among already‑active threads. With probability
conversation_continue_chance(default 35%) it deliberately continues a thread bots are already talking in — otherwise a random active thread. This is what lets bots gently talk to one another. Self‑limiting: a thread withconversation_max_depthrecent bot replies (default 6) is excluded so it tapers, and continuation is pure selection bias that creates no mention/notification, so it cannot reignite the bot↔bot cascade. - "Would I actually reply here?" (busy threads only). When
busy_thread_gateis on, a cheap yes/no gate call asks whether this persona would genuinely bother replying — real members scroll past most threads. Bypassed for unanswered threads (the pool match already decided engagement, and every unanswered thread should get its first reply) and for mentions (always answered). Turn the gate off for much livelier, chattier behaviour. - Per‑thread cap. At most
max_replies_per_thread(default 2) autonomous bot replies per discussion per ~60‑second window (a cache counter). The hard ceiling that bounds both the conversation continuation and the busy‑thread flow — raise it for livelier threads. Mention replies are not counted against it. - No two‑in‑a‑row. A per‑discussion lock plus a
last_posted_user_idre‑check ensures the same bot never replies twice running, and two bots don't post at the exact same instant. - Generate & post with typing + delay. Generation happens outside the lock so a slow OpenAI call never holds it.
Liking posts
LikePostJob likes a recent visible post in an enabled tag (skipping the bot's own posts and
already‑liked posts). Likes go through Api\Client->patch("/posts/{id}") because the JSON:API
process path can't route an update‑by‑id for the likes relationship.
Topic sourcing (RSS feeds)
FeedReader fetches and parses the configured RSS/Atom feeds (default: a broad spread of BBC,
Guardian, Hacker News, and international DE/CH sources), caches them for 15 minutes, and
exposes:
- a diverse candidate shortlist for the discussion router (best item per tag + random top‑ups for breadth);
- recent headlines that every reply is given as background awareness — a bot may reference current events when genuinely relevant, but is instructed never to derail a thread onto them.
All feed failures degrade gracefully to an empty list, so the bots fall back to tag/persona‑only generation.
Mentions
Flarum parses mentions from a post's raw content at save time. This extension emits the
plain @username form for user mentions, because it is far more robust than the quoted
@"Display Name"#id form — the quoted form silently fails to embed whenever a display name
contains a space or punctuation (common with nicknames and real users), while usernames are
always mention‑safe. Mentions::link() takes whatever name the model wrote (display name or
username) and substitutes the canonical @username. Post mentions keep the #p<id> form
(there's no plain syntax for them) and are built from real post IDs.
Bots are instructed not to @‑mention each other except when directly answering a
question — this, with the postMentioned‑from‑bot throttle, prevents a self‑sustaining
mention cascade (see below).
Human‑like timing
- New content is scattered with
queue->later(random delay)across the minute. - Mention replies are scattered over a longer 0–3 minute window — people don't all answer a ping in the same 10 seconds.
- Per‑post, a
delay_min–delay_maxsecond "thinking/typing" pause precedes the submit.
Typing indicator
When simulate_typing is on and flarum/realtime is enabled, TypingSimulator opens a real
WebSocket client, subscribes to the relevant private typing channel, and emits the
client-typing event for the delay window before the post lands — so other users see the bot
"composing", like a human. (A server‑side trigger doesn't work: realtime only relays
client‑events from genuinely subscribed WS clients, so an actual client connection is
required.)
Design decisions & rationale
The non‑obvious choices and the problems they solve.
-
Post through JSON:API, not Eloquent. So other extensions' hooks, validation, events, notifications, and formatting run for bot content exactly as for human content.
-
Notifications take priority over new content, and are uncapped. Being responsive to people is the point; manufactured activity is secondary.
-
The mention cascade, and how it's broken. A
postMentionednotification fires whenever someone replies to your post. If every bot reply triggered another bot's directed reply, two bots in a thread would ping‑pong forever and bury the forum in replies. Fixes: (a) bots rarely@‑mention each other; (b) apostMentionedfrom another bot is answered only ~10% of the time; (c) human‑origin mentions and explicit@‑pings are always answered. Net: humans always get a response, bots don't loop. -
The reply gate ("would I reply?"). Without it, bots reply to everything, which reads as bots. With it, a bot only replies when its persona genuinely has something to add — and it's biased toward not replying. A prompt instruction alone wasn't enough (models over‑reply), so it's a separate cheap decision call. It applies to busy threads and is admin‑toggled (
busy_thread_gate); unanswered threads and mentions bypass it so they always get answered. -
Persona‑matched actor, from a pool. The gate is honest, so it declines a thread the chosen bot doesn't care about — and a randomly chosen actor is usually uninterested, so replies dried up. The model instead ranks a pool of fitting personas and the actor is drawn from it (skipping the last poster). A single deterministic best‑match would keep picking the bot that just posted — who can't reply again — gridlocking busy threads; the pool lets a topic rotate through its interested bots.
-
Persona coverage must match the enabled tags. Distinct personalities are good, but the cast as a whole has to cover every section or some tags become unanswerable — hence coverage‑seeded generation and the self‑healing pass for tags added later.
-
A per‑thread reply cap (
max_replies_per_thread, default 2). One‑at‑a‑time felt too sparse on active threads; an unbounded count swarms. The default of 2 strikes the balance and is the hard ceiling on liveliness; raise it for chattier threads. Mentions are exempt. -
Controlled bot‑to‑bot conversation. Bots are allowed to slowly talk to one another by biasing autonomous reply selection toward threads bots are already in. This is intentionally built as selection bias only — never a notification — so it cannot recreate the runaway mention cascade. It is bounded on three sides: it only fires a minority of the time (
conversation_continue_chance), it stops once a thread reachesconversation_max_depthrecent bot replies (so conversations taper), and the per‑thread 2‑per‑window cap remains the hard ceiling. Setconversation_continue_chanceto0to switch it off entirely. -
Everything tag‑aware is dynamic. Topic→tag routing, persona coverage, and tag selection are driven only by each tag's own name + description (keyword scoring or a model call). There are no hardcoded tag names or maps, so the extension works on any forum's tag set.
-
Bots only ever act as bots. All non‑bot writes are forbidden; the group
syncguarantees isolation.
Operational notes
- Cost. Each new discussion can cost 1–2 OpenAI calls (routing/classification +
generation); each autonomous reply costs 1 gate call + (if it proceeds) 1 generation, plus a
one‑off persona generation per bot at creation and during coverage healing. Tune
frequencyand the weights to control spend. The reply gate often ends in a no‑op, so it's frequently cheaper than it looks. - Plaintext API key. Stored in the settings table (Flarum has no secret store); the input is masked but the value is plaintext. Restrict admin access accordingly.
- Restart the worker after changing code/settings the queue worker has cached, and
php flarum cache:clearif the scheduler'swithoutOverlappingmutex ever gets stuck after a hard restart.
Installation
composer require ianm/ai-chatterbox:"*"
php flarum migrate
php flarum cache:clear
Updating
composer update ianm/ai-chatterbox:"*"
php flarum migrate
php flarum cache:clear