Skip to content

KingPsychopath/fete-bot

Repository files navigation

fete-bot

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.

What It Does

  • Moderates all joined groups by default
  • Can optionally restrict moderation to ALLOWED_GROUP_JIDS
  • Defaults to DRY_RUN=true so 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 /health for Railway

Stack

  • Node.js 24+
  • TypeScript
  • pnpm
  • @whiskeysockets/baileys
  • better-sqlite3
  • SQLite on a persistent Railway volume
  • Dockerfile deploy on Railway

Runtime Surfaces

WhatsApp surfaces

  • Group moderation in joined groups by default, or in ALLOWED_GROUP_JIDS when set
  • Owner / moderator DM command interface
  • Reply-based owner / moderator commands inside allowed groups

HTTP surface

  • GET /health
    • 200 OK when the process is running
    • returns WAITING_FOR_WHATSAPP until the bot is paired and connected
  • GET /ready
    • 200 OK when the bot socket is connected
    • 503 DISCONNECTED when the bot socket is not connected

Persistent storage

  • ./data/bot.db
    • logs
    • strikes
    • bans
    • mutes
    • audit_log
    • moderators
    • review_queue
    • spotlight_pending / spotlight_history
    • announcement_queue_items / announcement_cycles

Session storage

  • ./data/auth

Storage Contract

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.db stores the SQLite database
  • /app/data/auth stores 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_NAME and RAILWAY_VOLUME_MOUNT_PATH automatically at runtime
  • The app will prefer RAILWAY_VOLUME_MOUNT_PATH automatically when it is present
  • Volumes are mounted at runtime, not during build or pre-deploy steps

Railway Deploy Contract

  • numReplicas = 1 keeps one Railway deployment active for the service
  • overlapSeconds = 0 minimizes the window where two Baileys sockets can use the same WhatsApp session
  • drainingSeconds = 30 gives the bot time to handle SIGTERM, close the Baileys socket, and exit cleanly before Railway sends SIGKILL
  • The healthcheck should stay on /health; /ready waits for WhatsApp connection and can prolong deploy overlap for this single-session bot

Safety Defaults

  • DRY_RUN=true by default
  • ALLOWED_GROUP_JIDS is optional; when empty, the bot acts in all joined groups
  • GROUP_CALL_GUARD_ENABLED=true rejects incoming group calls where possible and enforces against repeat callers
  • GROUP_CALL_GUARD_GROUP_JIDS is optional; when empty, call guarding applies to all managed groups
  • TICKET_MARKETPLACE_MANAGEMENT=true by default
  • TICKET_MARKETPLACE_GROUP_JIDS is comma-separated and defaults to 120363418331899807@g.us
  • TICKET_MARKETPLACE_RULE_REMINDER_ENABLED=true sends a daily marketplace rules reminder after TICKET_MARKETPLACE_RULE_REMINDER_TIME in TICKET_MARKETPLACE_RULE_REMINDER_TIMEZONE; after a reminder, it waits for TICKET_MARKETPLACE_RULE_REMINDER_MIN_ACTIVITY_MESSAGES observed chat messages before sending another
  • TICKET_SPOTLIGHT_ENABLED=true by default; seller spotlights are enabled, buying spotlights are off by default for the first rollout
  • ANNOUNCEMENTS_ENABLED=false by default; when enabled, announcements are sent to ANNOUNCEMENTS_TARGET_GROUP_JID on a local wall-clock schedule
  • TICKET_SPOTLIGHT_TARGET_JIDS defaults to FDLM General 2, FDLM General, and FDLM Parties & Events
  • OWNER_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

Link Policy

The allowlist is intentionally hardcoded in src/linkChecker.ts. It is business logic, not env configuration.

Allowed:

  • spotify.com
  • open.spotify.com
  • music.apple.com
  • outofofficecollective.co.uk and all subdomains
  • music.youtube.com only
  • instagram.com profile URLs only
  • x.com profile URLs only
  • twitter.com profile URLs only
  • tiktok.com profile URLs only
  • soundcloud.com
  • mixcloud.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

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.com
  • vm.tiktok.com
  • youtu.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/username or twitter.com/username
  • Only music.youtube.com is allowed for YouTube
  • General youtube.com, www.youtube.com, m.youtube.com, and youtu.be are blocked

Spam and Moderation Rules

Spam detection

  • 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

Group call guard

  • Incoming audio and video group calls are rejected where possible when GROUP_CALL_GUARD_ENABLED=true
  • GROUP_CALL_GUARD_GROUP_JIDS follows 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_TEXT in 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_ON active violations within GROUP_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=true logs what would happen without rejecting the call or sending the warning

Ticket marketplace routing

  • Buying or selling intent outside FDLM Ticket Marketplace gets 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 on to delete ticket redirect messages after replying, and !ticketdelete off to 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_RUN and never creates strikes, mutes, bans, or review-queue entries

Ticket spotlight reposts

  • Enabled by default with TICKET_SPOTLIGHT_ENABLED=true; set it to false to 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, and TICKET_SPOTLIGHT_BUYING_MAX_PER_DAY
  • Selling spotlights have separate controls via TICKET_SPOTLIGHT_SELLING_ENABLED, TICKET_SPOTLIGHT_SELLING_MIN_LENGTH, and TICKET_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

Managed announcements

  • Enable with ANNOUNCEMENTS_ENABLED=true; ANNOUNCEMENTS_TARGET_GROUP_JID overrides the current code default
  • The schedule uses ANNOUNCEMENTS_START_DATE, ANNOUNCEMENTS_TIME, ANNOUNCEMENTS_INTERVAL_DAYS, and ANNOUNCEMENTS_TIMEZONE as a local wall-clock schedule
  • Queue items are text-only in v1 and have separate draft/published status plus persistent on/off state
  • 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 test still 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.us group JID tokens

Strike system

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

Ban system

  • Owner / moderator only
  • No auto-banning
  • If a banned user rejoins an allowlisted group, the bot auto-removes them and DMs owners

Mute system

  • Owner / moderator only
  • Muted users receive no warning
  • Their messages are silently deleted until the mute expires or is lifted

Identity Handling

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 @lid senders directly
  • Phone numbers, @s.whatsapp.net, and @lid JIDs 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

Owner And Moderator Commands

Permission levels work like this:

  • Owners come from OWNER_JIDS in the environment
  • Moderators come from the SQLite moderators table
  • 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 !addmod and !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:

  • !pardon and !resetstrikes currently 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

Owner-only DM commands

  • !addmod {number} {note?}
  • !removemod {number}
  • !mods
  • !ticketdelete {on|off|status}

Owner + moderator DM commands

  • !help
  • !status
  • !audit {limit?}
  • !test {url}
  • !undo
  • !announce list
  • !announce show {id|position}
  • !announce raw {id|position}
  • !announce preview
  • !announce next
  • !announce check
  • !announce add by 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-now owner-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-based commands in groups

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.

Bootstrap

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_JIDS and redeploy

Destructive command rate limit

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.

Undo window

After !ban, !mute, or !strike, the bot stores one undo action per owner or moderator for 5 minutes.

  • !undo reverses the last undoable destructive action if still available

Number Formats for Commands

Accepted:

  • +447911123456
  • 00447911123456
  • 07911123456 if DEFAULT_PHONE_REGION is configured
  • 447911123456@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 leading 0 are rejected as ambiguous
  • Local numbers beginning with 0 only work when DEFAULT_PHONE_REGION is configured
  • For multi-region or production-safe setups, prefer international format with +
  • Direct commands also accept explicit @lid JIDs when you have them
  • When in doubt, reply to the message instead of typing the number

Startup Health Checks

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_JIDS entry 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

Media Handling

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.

Audit and Logging

Moderation log

The logs table stores:

  • timestamp
  • group JID
  • user JID
  • push name
  • message text
  • URL found
  • action: DELETED, DRY_RUN, WARN, ERROR
  • reason

Audit log

The audit_log table stores:

  • timestamp
  • actor JID
  • actor role: owner or moderator
  • command
  • target JID
  • group JID
  • raw input
  • result: success, error, pending

Use:

  • !audit
  • !audit 50

Live logs

Runtime logs are emitted as single-line JSON with stable fields:

  • time
  • level
  • event
  • message
  • contextual fields such as groupJid, senderJid, userId, command, reason, and action
  • 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.log

Controls:

  • LOG_LEVEL=debug|info|warn|error|silent
  • LOG_ALLOWED_MESSAGES=true|false controls routine message.seen / message.allowed logs
  • LOG_MESSAGE_TEXT=true|false controls whether normal process logs include full message text; SQLite moderation/audit tables still keep the text they already recorded

Versioning and Status

On startup the bot logs:

  • bot name
  • version from GIT_COMMIT_SHA when available, otherwise dev
  • 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

Local Setup

  1. Copy .env.example to .env
  2. Fill in your env vars
  3. Use the repo-pinned toolchain:
mise install
mise use
  1. Install dependencies:
corepack pnpm install
  1. Start the bot:
corepack pnpm dev
  1. Scan the QR code using the WhatsApp Business account for the Lebara number

Local Admin CLI

For local testing and maintenance, there is a terminal helper:

pnpm admin:cli help

Useful 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 --yes

Notes:

  • 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, and mutes 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.
  • clear and reset are equivalent, so existing reset commands still work

Environment Variables

  • DRY_RUN=true|false
  • ALLOWED_GROUP_JIDS=120363...@g.us,120363...@g.us
  • OWNER_JIDS=447911123456@s.whatsapp.net,447922234567@s.whatsapp.net
  • BOT_NAME=Fete Bot
  • PORT=3000
  • GROUP_CALL_GUARD_ENABLED=true|false
  • GROUP_CALL_GUARD_GROUP_JIDS=120363...@g.us,120363...@g.us
  • GROUP_CALL_GUARD_WARNING_TEXT=Hey {mention} - calls aren't allowed in this group. Don't do that again. 🙏🏾
  • GROUP_CALL_GUARD_REMOVE_ON=2
  • GROUP_CALL_GUARD_WINDOW_HOURS=24
  • GROUP_CALL_GUARD_WARNING_COOLDOWN_SECONDS=30
  • GROUP_CALL_GUARD_RECENT_ACTIVITY_TTL_MINUTES=10
  • ANNOUNCEMENTS_ENABLED=false
  • ANNOUNCEMENTS_TARGET_GROUP_JID=120363...@g.us (optional while the code default is correct)
  • ANNOUNCEMENTS_START_DATE=2026-04-30
  • ANNOUNCEMENTS_TIME=10:00
  • ANNOUNCEMENTS_INTERVAL_DAYS=3
  • ANNOUNCEMENTS_TIMEZONE=Europe/London
  • ANNOUNCEMENTS_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

Getting Group JIDs

  1. Start the bot
  2. Pair the account
  3. Watch for Discovered group logs on connect
  4. Or send any message in a group and look for:
{"level":"info","time":"...","event":"message.seen","groupJid":"120363XXXXXXXXXX@g.us",...}
  1. Optionally add the chosen JIDs to ALLOWED_GROUP_JIDS if you want to restrict moderation to specific groups

Railway Deploy

  1. Create a Railway project from this repo
  2. Railway will use the included Dockerfile automatically
  3. Attach one Railway volume named fete-bot-data
  4. Mount that volume at /app/data
  5. 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
  1. Deploy
  2. Scan the QR code from Railway logs with the WhatsApp Business account
  3. Use /health for Railway health checks

Notes:

  • Do not use ADMIN_JIDS; the app reads OWNER_JIDS
  • data/ is intentionally excluded from the Docker build so the Railway volume stays authoritative for both the SQLite DB and WhatsApp auth files
  • railway.toml does not create or attach volumes; volume setup still happens in Railway
  • The container listens on Railway's PORT env var and falls back to 3000 locally
  • 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 = ON and PRAGMA journal_mode = WAL so canonical identity merges and other BEGIN IMMEDIATE writes behave correctly under concurrent handlers
  • Use /ready only if you want to confirm that the bot is fully connected to WhatsApp

Operational Notes

  • The bot must be an admin in each moderated group to delete messages or remove members
  • DRY_RUN=true still 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 on enables 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

Tests And Type Check

mise exec -- pnpm test
mise exec -- pnpm typecheck

About

A bot to manage our community chat and prevent spam.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors