WhatsApp moderation bot for the Fete event groups. It runs through Baileys, applies hardcoded link and spam rules, logs everything to SQLite, and exposes a small Railway health endpoint.
- Moderates all joined groups by default
- Can optionally restrict moderation to
ALLOWED_GROUP_JIDS - Defaults to
DRY_RUN=trueso nothing is deleted by accident - Deletes blocked links and selected spam when live
- Warns users with friendlier reason-specific messages
- Tracks strikes per user per group with 7-day expiry
- Lets owners and moderators ban, mute, pardon, remove, undo, and inspect state by DM
- Lets owners and moderators manage a recurring announcements bundle by DM
- Silently deletes messages from muted users
- Auto-removes banned users if they rejoin
- Logs moderation actions and owner/moderator actions to SQLite
- Exposes
GET /healthfor Railway
- Node.js 24+
- TypeScript
pnpm@whiskeysockets/baileysbetter-sqlite3- SQLite on a persistent Railway volume
- Dockerfile deploy on Railway
- Group moderation in joined groups by default, or in
ALLOWED_GROUP_JIDSwhen set - Owner / moderator DM command interface
- Reply-based owner / moderator commands inside allowed groups
GET /health200 OKwhen the process is running- returns
WAITING_FOR_WHATSAPPuntil the bot is paired and connected
GET /ready200 OKwhen the bot socket is connected503 DISCONNECTEDwhen the bot socket is not connected
./data/bot.dblogsstrikesbansmutesaudit_logmoderatorsreview_queuespotlight_pending/spotlight_historyannouncement_queue_items/announcement_cycles
./data/auth
Use one Railway volume for this service.
- Recommended volume name:
fete-bot-data - Mount path:
/app/data - Number of volumes required:
1
Inside that volume:
/app/data/bot.dbstores the SQLite database/app/data/authstores the Baileys multi-file WhatsApp session
Notes:
- The volume name is a Railway label for humans; the app only depends on the mount path
- Railway exposes
RAILWAY_VOLUME_NAMEandRAILWAY_VOLUME_MOUNT_PATHautomatically at runtime - The app will prefer
RAILWAY_VOLUME_MOUNT_PATHautomatically when it is present - Volumes are mounted at runtime, not during build or pre-deploy steps
numReplicas = 1keeps one Railway deployment active for the serviceoverlapSeconds = 0minimizes the window where two Baileys sockets can use the same WhatsApp sessiondrainingSeconds = 30gives the bot time to handleSIGTERM, close the Baileys socket, and exit cleanly before Railway sendsSIGKILL- The healthcheck should stay on
/health;/readywaits for WhatsApp connection and can prolong deploy overlap for this single-session bot
DRY_RUN=trueby defaultALLOWED_GROUP_JIDSis optional; when empty, the bot acts in all joined groupsGROUP_CALL_GUARD_ENABLED=truerejects incoming group calls where possible and enforces against repeat callersGROUP_CALL_GUARD_GROUP_JIDSis optional; when empty, call guarding applies to all managed groupsTICKET_MARKETPLACE_MANAGEMENT=trueby defaultTICKET_MARKETPLACE_GROUP_JIDSis comma-separated and defaults to120363418331899807@g.usTICKET_MARKETPLACE_RULE_REMINDER_ENABLED=truesends a daily marketplace rules reminder afterTICKET_MARKETPLACE_RULE_REMINDER_TIMEinTICKET_MARKETPLACE_RULE_REMINDER_TIMEZONE; after a reminder, it waits forTICKET_MARKETPLACE_RULE_REMINDER_MIN_ACTIVITY_MESSAGESobserved chat messages before sending anotherTICKET_SPOTLIGHT_ENABLED=trueby default; seller spotlights are enabled, buying spotlights are off by default for the first rolloutANNOUNCEMENTS_ENABLED=falseby default; when enabled, announcements are sent toANNOUNCEMENTS_TARGET_GROUP_JIDon a local wall-clock scheduleTICKET_SPOTLIGHT_TARGET_JIDSdefaults to FDLM General 2, FDLM General, and FDLM Parties & EventsOWNER_JIDS, database moderators, and WhatsApp group admins are never moderated- The bot never responds in 1:1 chats unless the sender is an owner or moderator using a command
- The bot never acts on its own messages, with an extra self-ID check as defence in depth
The allowlist is intentionally hardcoded in src/linkChecker.ts. It is business logic, not env configuration.
Allowed:
spotify.comopen.spotify.commusic.apple.comoutofofficecollective.co.ukand all subdomainsmusic.youtube.comonlyinstagram.comprofile URLs onlyx.comprofile URLs onlytwitter.comprofile URLs onlytiktok.comprofile URLs onlysoundcloud.commixcloud.com- Accommodation links in all groups:
- Brand-matched across TLDs:
booking,airbnb,hostelworld,trivago,expedia - Exact registered domains only:
trip.com,trip.fr,hotels.com
- Brand-matched across TLDs:
Explicitly blocked:
- Ticketing/event platforms:
ra.co,dice.fm,eventbrite.com,skiddle.com,ticketmaster.com,ticketweb.com,seetickets.com,billetto.co.uk,fixr.co - URL shorteners:
bit.ly,t.co,tinyurl.com,ow.ly,buff.ly,shorturl.at,is.gd,rebrand.ly,cutt.ly,rb.gy,tiny.cc,lnkd.in chat.whatsapp.comvm.tiktok.comyoutu.be
Special rules:
- TikTok only allows profile pages like
tiktok.com/@username - TikTok video links like
tiktok.com/@username/video/...are blocked - TikTok short share links like
tiktok.com/t/...are blocked - Instagram only allows profile pages like
instagram.com/username - X / Twitter only allow profile pages like
x.com/usernameortwitter.com/username - Only
music.youtube.comis allowed for YouTube - General
youtube.com,www.youtube.com,m.youtube.com, andyoutu.beare blocked
- WhatsApp invite links are removed
- Same message sent 3+ times within 5 minutes by the same sender in the same group is treated as duplicate spam, but only when the message is at least 20 characters
- 20+ messages within 60 seconds by the same sender in the same group gets a flood warning
- 25+ messages within 60 seconds by the same sender in the same group is treated as flooding and deleted
- Phone numbers trigger a warning only, not a deletion
- Forwarded / heavily forwarded messages are logged for audit only
- Incoming audio and video group calls are rejected where possible when
GROUP_CALL_GUARD_ENABLED=true GROUP_CALL_GUARD_GROUP_JIDSfollows the same empty-list behavior as group JID config: empty applies to all managed groups; a comma-separated list applies only to those groups- The bot sends
GROUP_CALL_GUARD_WARNING_TEXTin the group after rejecting the call; use{mention}where the caller mention should appear - Known-group call attempts are persisted, and the caller is removed on
GROUP_CALL_GUARD_REMOVE_ONactive violations withinGROUP_CALL_GUARD_WINDOW_HOURS - If Baileys reports a group call without a group JID, the bot rejects it when globally scoped and only warns a chat when recent activity points to exactly one managed group
DRY_RUN=truelogs what would happen without rejecting the call or sending the warning
- Buying or selling intent outside
FDLM Ticket Marketplacegets a reply redirecting the poster there without a strike - The marketplace receives a daily reminder to follow the rules and check the pinned message and group description
- Ticket redirect replies are rate-limited per user per group for 30 minutes; repeated matches during cooldown are allowed silently
- Owners can run
!ticketdelete onto delete ticket redirect messages after replying, and!ticketdelete offto return to reply-only mode - General ticket or event discussion is still allowed outside the marketplace
- Seller posts inside the marketplace must include a price,
face value/FV, or free-equivalent wording - Marketplace routing respects
DRY_RUNand never creates strikes, mutes, bans, or review-queue entries
- Enabled by default with
TICKET_SPOTLIGHT_ENABLED=true; set it tofalseto opt out - Priced selling posts in the marketplace can be reposted after a delay to the configured spotlight target groups
- Selling spotlights default to a 20 minute delay; buying spotlights default to a 30 minute delay
- Target groups default to a 60 minute cooldown between spotlight posts
- Buying spotlights have a separate kill switch, stricter minimum length, and lower daily cap via
TICKET_SPOTLIGHT_BUYING_ENABLED,TICKET_SPOTLIGHT_BUYING_MIN_LENGTH, andTICKET_SPOTLIGHT_BUYING_MAX_PER_DAY - Selling spotlights have separate controls via
TICKET_SPOTLIGHT_SELLING_ENABLED,TICKET_SPOTLIGHT_SELLING_MIN_LENGTH, andTICKET_SPOTLIGHT_SELLING_MAX_PER_DAY - Announcement groups are hard-blocked from spotlight sends even if accidentally configured as targets
- Spotlight has per-user, per-target-group, quiet-hours, daily-cap, URL, reply/quote, and blocklist gates
- Pending spotlight jobs and send history are stored in SQLite so restarts do not lose queued posts
- Delete events cancel pending spotlights on a best-effort basis
- Enable with
ANNOUNCEMENTS_ENABLED=true;ANNOUNCEMENTS_TARGET_GROUP_JIDoverrides the current code default - The schedule uses
ANNOUNCEMENTS_START_DATE,ANNOUNCEMENTS_TIME,ANNOUNCEMENTS_INTERVAL_DAYS, andANNOUNCEMENTS_TIMEZONEas a local wall-clock schedule - Queue items are text-only in v1 and have separate
draft/publishedstatus plus persistenton/offstate - Each cycle snapshots the active published bundle before sending, so retries use the same text even if a mod edits the live queue after a partial failure
- Empty active bundles are recorded as skipped and the schedule advances
- In
DRY_RUN=true, scheduled and forced real sends do not post or advance the schedule;!announce teststill DMs the requesting mod - Optional group mentions use JSON, for example
ANNOUNCEMENTS_GROUP_MENTIONS_JSON=[{"label":"FDLM General","jid":"120363...@g.us"}] - Mention labels are matched case-insensitively when message text contains
@FDLM General; the bot also tries known group subjects and exact@120363...@g.usgroup JID tokens
Deleted violations add a strike for that user in that group. Strikes expire after 7 days.
- Strike 1: normal warning
- Strike 2: warning plus a final-warning notice
- Strike 3: user is flagged for owner review; the bot does not auto-remove them
- Owner / moderator only
- No auto-banning
- If a banned user rejoins an allowlisted group, the bot auto-removes them and DMs owners
- Owner / moderator only
- Muted users receive no warning
- Their messages are silently deleted until the mute expires or is lifted
WhatsApp group traffic may come through as @lid JIDs instead of @s.whatsapp.net. The bot handles both:
- Direct-number owner / moderator commands resolve to a canonical user identity
- Reply-based commands can target
@lidsenders directly - Phone numbers,
@s.whatsapp.net, and@lidJIDs all resolve to the same user when a mapping is available - Strike, ban, mute, review-queue, and moderator lookups all try every known alias for that user
Permission levels work like this:
- Owners come from
OWNER_JIDSin the environment - Moderators come from the SQLite
moderatorstable - WhatsApp group admins are protected from moderation, but do not automatically get bot command access
In practice:
- Owners can run all bot commands and manage moderators with
!addmodand!removemod - Moderators can run moderation and info commands, but cannot manage moderators
- WhatsApp group admins are exempt from bot moderation actions unless they are also an owner or moderator
Command note:
!pardonand!resetstrikescurrently do the same thing: clear active strikes, remove any pending review entry, and lift any mute for that user in the targeted group(s)
Owners and moderators can control the bot in two ways:
- By DM to the bot
- By replying to a message in a managed group
OWNER_JIDS must be full WhatsApp user JIDs such as:
447911123456@s.whatsapp.net
!addmod {number} {note?}!removemod {number}!mods!ticketdelete {on|off|status}
!help!status!audit {limit?}!test {url}!undo!announce list!announce show {id|position}!announce raw {id|position}!announce preview!announce next!announce check!announce addby replying to the text to store!announce edit {id|position}by replying to replacement text!announce publish {id|position}!announce on {id|position}/!announce off {id|position}!announce move {id|position} {newPosition}!announce remove {id|position}!announce schedule YYYY-MM-DD HH:mm!announce pause/!announce resume!announce test!announce test-group {groupJid|target}owner-only, sends a test to a real group without advancing the schedule!announce send-nowowner-only!ban {jid or number} {groupJid?} {reason?}!unban {jid or number} {groupJid}!bans {groupJid?}!mute {jid or number} {duration?} {groupJid?}!unmute {jid or number} {groupJid}!mutes {groupJid}!remove/!kick {jid or number} {groupJid}!pardon {jid or number} {groupJid?}!strikes {jid, lid, or number}!strike {jid, lid, or number} {reason?} {groupJid?}
If exactly one managed group is available, commands that take {groupJid?} can omit it. If multiple managed groups are available, pass the raw group JID when you want to target just one group.
For !bans, omitting {groupJid} lists every group that currently has active bans.
Reply to the target message, then send:
!mute {duration?}!unmute!ban {reason?}!remove/!kick!strike {reason?}!pardon!strikes!undo
Reply context always wins over typed numbers.
On first deploy, only OWNER_JIDS users exist.
- An owner should DM
!addmod {number} {note?}for each person who needs access - Owners cannot be removed via commands
- To remove an owner, change
OWNER_JIDSand redeploy
Per owner or moderator:
- max 10 destructive commands per minute across
!ban,!mute,!strike,!remove, and!kick
If exceeded, the bot replies:
Slow down — you've run 10 commands in the last minute. Try again shortly.
After !ban, !mute, or !strike, the bot stores one undo action per owner or moderator for 5 minutes.
!undoreverses the last undoable destructive action if still available
Accepted:
+4479111234560044791112345607911123456ifDEFAULT_PHONE_REGIONis configured447911123456@s.whatsapp.net- international formats like
+1 212 555 0123,+33 6 12 34 56 78,+234 701 234 5678
Notes:
- Bare numbers without
+or a leading0are rejected as ambiguous - Local numbers beginning with
0only work whenDEFAULT_PHONE_REGIONis configured - For multi-region or production-safe setups, prefer international format with
+ - Direct commands also accept explicit
@lidJIDs when you have them - When in doubt, reply to the message instead of typing the number
After the bot connects, it runs a non-fatal health check:
- every monitored group can be resolved
- the bot is an admin in each monitored group
- every
OWNER_JIDSentry is valid - SQLite is writable
If a critical check fails:
- the bot keeps running
- the failure is logged loudly
- all owners are DM’d with the failure details
The bot extracts moderation text from:
- plain text messages
- extended text messages
- image captions
- video captions
- document captions
- document-with-caption wrapper messages
This matters because promo spam often hides links in captions rather than body text.
The logs table stores:
- timestamp
- group JID
- user JID
- push name
- message text
- URL found
- action:
DELETED,DRY_RUN,WARN,ERROR - reason
The audit_log table stores:
- timestamp
- actor JID
- actor role:
ownerormoderator - command
- target JID
- group JID
- raw input
- result:
success,error,pending
Use:
!audit!audit 50
Runtime logs are emitted as single-line JSON with stable fields:
timeleveleventmessage- contextual fields such as
groupJid,senderJid,userId,command,reason, andaction - error fields:
errorName,errorMessage,errorStack
Useful filters:
rg '"event":"message.allowed"' railway.log
rg '"event":"deleted.disallowed.link"' railway.log
rg '"groupJid":"120363XXXXXXXXXX@g.us"' railway.log
rg '"level":"error"' railway.logControls:
LOG_LEVEL=debug|info|warn|error|silentLOG_ALLOWED_MESSAGES=true|falsecontrols routinemessage.seen/message.allowedlogsLOG_MESSAGE_TEXT=true|falsecontrols whether normal process logs include full message text; SQLite moderation/audit tables still keep the text they already recorded
On startup the bot logs:
- bot name
- version from
GIT_COMMIT_SHAwhen available, otherwisedev - startup timestamp
!status includes:
- version
- started timestamp
- current mode
- monitored groups
- configured owner count
- configured moderator count
- strikes issued today
- total active strikes
- total active bans
- total active mutes
- forwarded messages seen today
- Copy
.env.exampleto.env - Fill in your env vars
- Use the repo-pinned toolchain:
mise install
mise use- Install dependencies:
corepack pnpm install- Start the bot:
corepack pnpm dev- Scan the QR code using the WhatsApp Business account for the Lebara number
For local testing and maintenance, there is a terminal helper:
pnpm admin:cli helpUseful examples:
pnpm admin:cli status
pnpm admin:cli test-url "https://ra.co/events/123"
pnpm admin:cli mods list
pnpm admin:cli mods add 07911123456 "sound team"
pnpm admin:cli mods remove 07911123456
pnpm admin:cli ban 07911123456 "repeated promo links"
pnpm admin:cli ban 07911123456 120363408759548644@g.us "repeated promo links"
pnpm admin:cli mute 07911123456 2h "cool-off"
pnpm admin:cli mute 07911123456 120363408759548644@g.us 2h "cool-off"
pnpm admin:cli strikes list 07911123456
pnpm admin:cli strikes list lid-user-123@lid
pnpm admin:cli strikes clear 07911123456 120363408759548644@g.us
pnpm admin:cli strikes clear-all 120363408759548644@g.us
pnpm admin:cli strikes clear-all
pnpm admin:cli bans list
pnpm admin:cli bans list 120363408759548644@g.us
pnpm admin:cli bans add 07911123456 "repeated promo links"
pnpm admin:cli bans add 07911123456 120363408759548644@g.us "repeated promo links"
pnpm admin:cli bans clear 07911123456
pnpm admin:cli bans clear 07911123456 120363408759548644@g.us
pnpm admin:cli bans clear-all 120363408759548644@g.us
pnpm admin:cli bans clear-all
pnpm admin:cli mutes list 120363408759548644@g.us
pnpm admin:cli mutes add 07911123456 2h "cool-off"
pnpm admin:cli mutes add 07911123456 120363408759548644@g.us 2h "cool-off"
pnpm admin:cli mutes clear 07911123456
pnpm admin:cli mutes clear 07911123456 120363408759548644@g.us
pnpm admin:cli mutes clear-all 120363408759548644@g.us
pnpm admin:cli mutes clear-all
pnpm admin:cli reset-all 120363408759548644@g.us
pnpm admin:cli reset-all
pnpm admin:cli audit 20
pnpm admin:cli db flush --yesNotes:
- This CLI talks directly to
./data/bot.db; it does not connect to WhatsApp - It is intended for testing, inspection, and cleanup
- For
ban,mute,bans add, andmutes add, omitting{groupJid}stores a global action. The running bot treats global bans and mutes as active in every joined group. - The running bot checks global bans on messages and rejoins, and also sweeps joined groups periodically to remove globally banned users who are already present.
clearandresetare equivalent, so existingresetcommands still work
DRY_RUN=true|falseALLOWED_GROUP_JIDS=120363...@g.us,120363...@g.usOWNER_JIDS=447911123456@s.whatsapp.net,447922234567@s.whatsapp.netBOT_NAME=Fete BotPORT=3000GROUP_CALL_GUARD_ENABLED=true|falseGROUP_CALL_GUARD_GROUP_JIDS=120363...@g.us,120363...@g.usGROUP_CALL_GUARD_WARNING_TEXT=Hey {mention} - calls aren't allowed in this group. Don't do that again. 🙏🏾GROUP_CALL_GUARD_REMOVE_ON=2GROUP_CALL_GUARD_WINDOW_HOURS=24GROUP_CALL_GUARD_WARNING_COOLDOWN_SECONDS=30GROUP_CALL_GUARD_RECENT_ACTIVITY_TTL_MINUTES=10ANNOUNCEMENTS_ENABLED=falseANNOUNCEMENTS_TARGET_GROUP_JID=120363...@g.us(optional while the code default is correct)ANNOUNCEMENTS_START_DATE=2026-04-30ANNOUNCEMENTS_TIME=10:00ANNOUNCEMENTS_INTERVAL_DAYS=3ANNOUNCEMENTS_TIMEZONE=Europe/LondonANNOUNCEMENTS_GROUP_MENTIONS_JSON=[{"label":"FDLM General","jid":"120363...@g.us"}]
Migration note:
- Schema v4 adds announcement tables only; it does not alter existing moderation, identity, spotlight, or audit data
- Rollback is forward-only operationally: deploy older code only after restoring a pre-v4 database backup or leaving the unused v4 tables in place
- Start the bot
- Pair the account
- Watch for
Discovered grouplogs on connect - Or send any message in a group and look for:
{"level":"info","time":"...","event":"message.seen","groupJid":"120363XXXXXXXXXX@g.us",...}
- Optionally add the chosen JIDs to
ALLOWED_GROUP_JIDSif you want to restrict moderation to specific groups
- Create a Railway project from this repo
- Railway will use the included Dockerfile automatically
- Attach one Railway volume named
fete-bot-data - Mount that volume at
/app/data - Set these variables in Railway:
DRY_RUN=true
BOT_NAME=FeteBot
OWNER_JIDS=447911123456@s.whatsapp.net
ALLOWED_GROUP_JIDS=120363408759548644@g.us
NODE_ENV=production
- Deploy
- Scan the QR code from Railway logs with the WhatsApp Business account
- Use
/healthfor Railway health checks
Notes:
- Do not use
ADMIN_JIDS; the app readsOWNER_JIDS data/is intentionally excluded from the Docker build so the Railway volume stays authoritative for both the SQLite DB and WhatsApp auth filesrailway.tomldoes not create or attach volumes; volume setup still happens in Railway- The container listens on Railway's
PORTenv var and falls back to3000locally - Railway deploy health checks should target
/health, not/ready, so the container can become active before the WhatsApp QR has been scanned - SQLite is opened with
PRAGMA foreign_keys = ONandPRAGMA journal_mode = WALso canonical identity merges and otherBEGIN IMMEDIATEwrites behave correctly under concurrent handlers - Use
/readyonly if you want to confirm that the bot is fully connected to WhatsApp
- The bot must be an admin in each moderated group to delete messages or remove members
DRY_RUN=truestill logs what would happen, but does not delete or send moderation replies- With
DRY_RUN=false, group call guard rejects calls where possible, warns callers, and removes repeat callers when the group is known - Ticket marketplace routing replies to buying/selling intent without adding strikes;
!ticketdelete onenables deletion after the reply - Ticket spotlight reposts are enabled by default and use SQLite claims to avoid duplicate sends across overlapping bot processes
- Ticket-platform links get a specific redirect message to
fete.outofofficecollective.co.uk - Phone-number spam warns but does not delete
- Muted users are silent-delete only
- Ban and mute enforcement is local to the configured group
- DM commands from non-authorised users get a short "not authorised" reply and are ignored
mise exec -- pnpm test
mise exec -- pnpm typecheck