A Discord bot that monitors Counter-Strike: Global Offensive (and other supported) servers, keeps a channel message in sync with their status, and DMs users when a followed map appears.
- Server monitoring: queries every configured server on one interval and keeps a channel message updated with a rich embed of their status
- Map notifications: DMs everyone following a map when it appears on a server, falling back to a configured channel for recipients whose DMs are closed
- Slash commands: all interaction is through slash commands, rate limited per user
- Automatic cleanup: a member's follows are removed when they leave the guild (needs the privileged Server Members Intent, see Discord Application)
- Resilient: game-server queries and Discord calls retry with exponential backoff and jitter, while permanent failures are reported once with a remediation hint instead of being retried forever
| Command | Description |
|---|---|
/players |
Display currently connected players on a specified server |
/keywords |
List all available server keywords for searching |
/follow |
Follow a map to receive DM notifications when it appears on a server |
/unfollow |
Stop following a specific map or all maps |
/listfollows |
Display all maps you are currently following |
/help |
Show a list of all available commands. Lists the administrator commands only for those authorized to run them |
/ping |
Check bot latency |
| Command | Description |
|---|---|
/listallfollows |
List all users and their followed maps |
/testnotify <map> |
Test the map notification system |
/removeuser <userID> |
Remove all map follows for a specific user |
The bot accepts these from a holder of ADMIN_ROLE_ID, from anyone with the Discord
Administrator permission, and from the guild owner. Discord itself only shows them to the
latter two, so a role holder who is not an Administrator has to be given visibility separately:
either grant the role the Administrator permission, or add a channel or server permission
override for the commands under Server Settings -> Integrations. Until then the commands
work but do not appear in their picker. All replies are ephemeral.
- Node.js v24 or newer, or Docker
- A Discord application with the
botandapplications.commandsOAuth2 scopes
-
Permissions, in every channel listed in
EMBEDSand in the fallback channel:- View Channel and Embed Links, in both
- Read Message History, in the
EMBEDSchannels, to fetch the message being edited - Send Messages, in the fallback channel
DMs to followers need no permission.
-
Intents: Guilds, plus GuildMembers, which is privileged. Enable Server Members Intent under Bot → Privileged Gateway Intents in the Discord Developer Portal before starting the bot, otherwise login fails with
Used disallowed intents. It powers the follow cleanup above, which is scoped toDISCORD_GUILD_IDwhen that is set, so a user leaving another guild the bot shares keeps their follows; with it unset, leaving any shared guild clears them.
Both ways to run the bot use the same two configuration files, neither of which is tracked in git.
-
Clone the repository
git clone https://github.com/Sneaks-Community/discordCSGOServerBot.git cd discordCSGOServerBot -
Create the configuration files from their examples
cp .env.example .env cp servers.json.example servers.json
-
Edit both.
.envneeds at leastDISCORD_TOKEN(see Environment Variables), andservers.jsonneeds your game servers (see Server Configuration).
npm install
npm startdocker-compose.yml bind-mounts servers.json and keeps the database in a named volume, so
both files from Setup must exist before the container starts.
docker compose up -d # build and start
docker compose logs -f # follow the logsThe image is a multi-stage node:24-alpine build, roughly 100MB. To run it without Compose:
docker build -t discord-csgo-bot .
docker run -d \
--name csgo-server-bot \
--env-file .env \
-e DATABASE_PATH=/app/data/db.sqlite \
-v $(pwd)/servers.json:/app/servers.json:ro \
-v bot-data:/app/data \
discord-csgo-botFollows are the only state on disk, and they live in the SQLite file at DATABASE_PATH, so it
has to resolve inside the bot-data volume mounted at /app/data. Under docker run above,
anywhere else is the container's writable layer, which is discarded whenever the container is
recreated; under Compose it fails outright, because docker-compose.yml sets
read_only: true and the only writable path is the volume. The image and
docker-compose.yml both default it correctly, but an uncommented DATABASE_PATH in
.env would override that through --env-file, which is why the docker run above passes it
explicitly: an explicit -e wins.
git pull
npm install # Node
docker compose up -d --build # DockerEvery value is validated at startup. A malformed or out-of-range one aborts startup with a message naming the variable and its accepted range rather than being silently corrected, so the bot never runs half configured. Leaving a variable unset, or setting it to an empty string, selects its default.
| Variable | Required | Default | Valid range | Description |
|---|---|---|---|---|
DISCORD_TOKEN |
Yes | - | non-empty | Your Discord bot token |
DISCORD_GUILD_ID |
Recommended | - | snowflake or empty | Guild to register slash commands in. Empty registers them globally, which propagates slowly and exposes them in every guild the bot joins. ADMIN_ROLE_ID only ever resolves in the guild the command was run in |
ADMIN_ROLE_ID |
Recommended | - | snowflake or empty | Role granting access to the admin commands. Empty leaves them to Discord Administrators and the guild owner |
BOT_ACTIVITY_TEXT |
No | /follow <map> for map change alerts |
up to 128 characters, or empty | Activity text shown under the bot's name. Empty shows no activity. Sent with the gateway identify, so it survives reconnects |
BOT_ACTIVITY_TYPE |
No | custom |
custom, playing, listening, watching, competing |
How BOT_ACTIVITY_TEXT renders. custom shows it verbatim; the others have their verb prepended by Discord (Playing ..., Listening to ...) |
LOG_LEVEL |
No | info |
trace, debug, info, warn, error, fatal, silent |
stdout verbosity. An unrecognized value falls back to info with a warning rather than aborting, since logging is how problems get reported |
FALLBACK_GUILD_ID |
No | - | snowflake or empty | Guild holding the fallback channel. Empty disables fallback notifications |
FALLBACK_CHANNEL_ID |
No | - | snowflake or empty | Channel used when a DM cannot be delivered. Must be in FALLBACK_GUILD_ID. Empty disables fallback notifications |
EMBEDS |
No | [] |
JSON array of {channelID, messageID} |
Messages the bot keeps the server list in: [{"channelID":"xxx","messageID":"yyy"}]. Each message must have been posted by the bot itself, since Discord forbids editing anyone else's. Empty disables embed updates; malformed JSON is a startup error |
EMBED_COLOR |
No | 7980240 |
0 to 16777215 | Embed color as a 24-bit decimal |
DATABASE_PATH |
No | db.sqlite |
non-empty | SQLite file path. In Docker it must stay on the mounted volume (/app/data/db.sqlite, the image default) |
SERVER_UPDATE_INTERVAL |
No | 90 |
30 to 86400 | How often the bot queries the servers, updates the embeds and checks for map changes, in that order, on one timer (seconds). The embed states this interval in its description |
MAX_CONCURRENT_QUERIES |
No | 10 |
1 to 100 | Maximum concurrent server queries |
USER_CACHE_TTL |
No | 300 |
1 to 86400 | User cache TTL (seconds) |
RETRY_MAX_RETRIES |
No | 3 |
1 to 10 | Attempts for a retried Discord operation. At least 1, since 0 would mean never attempting it |
RETRY_BASE_DELAY |
No | 1 |
0 to 60 | Base delay for exponential backoff (seconds) |
GAMEDIG_MAX_RETRIES |
No | 4 |
0 to 10 | Maximum retries for GameDig queries |
FALLBACK_AVATAR_URL |
No | https://i.imgur.com/cBiDnMi.png |
http(s) URL | Icon used in embed footers |
OFFLINE_SERVER_IMAGE |
No | https://i.imgur.com/WnS0Biz.png |
http(s) URL | Image used for an offline server |
MAP_IMAGE_BASE_URL |
No | https://bans.snksrv.com/images/maps/ |
http(s) URL ending in /, or empty |
Map thumbnails are requested as <base><mapname>.jpg. A map the host has no image for simply renders without one. Empty disables map images |
RATE_LIMIT_FOLLOW_PER_MINUTE |
No | 5 |
1 to 1000 | Max follow commands per minute per user |
RATE_LIMIT_UNFOLLOW_PER_MINUTE |
No | 5 |
1 to 1000 | Max unfollow commands per minute per user |
RATE_LIMIT_NOTIFICATION_PER_MINUTE |
No | 10 |
1 to 1000 | Max map-change DMs per minute per user. Repeats of the same map (for example one map live on two servers) are always collapsed to one DM and do not count against this |
MAX_FOLLOWS_PER_USER |
No | 50 |
1 to 10000 | Maximum maps a single user may follow at once |
MAX_NOTIFICATION_RECIPIENTS |
No | 200 |
1 to 10000 | Maximum users DMed for a single map change; a truncated fanout is logged |
Rate limits, the user cache and the per-map notification history are all held in memory, so a restart clears every rate limit currently in effect.
Add your servers to servers.json:
{
"ServerName": {
"ip": "IP_ADDRESS:PORT",
"nick": "Display Name",
"protocol": "csgo",
"keywords": ["keyword1", "keyword2"]
}
}Maximum of 25 servers, because the embed adds one field per server and Discord caps an embed at 25 fields.
| Field | Type | Required | Description |
|---|---|---|---|
ip |
string | Yes | Server IP/FQDN, optionally with a port (e.g., 127.0.0.1:27015). The port defaults to 27015 when omitted. IPv6 is not supported |
nick |
string | Yes | Display name shown in embeds (max 100 characters) |
protocol |
string | No | Game protocol (default: csgo), from the supported games list |
keywords |
array | Yes | Search keywords, at least one. Each must be lowercase, free of leading and trailing whitespace, at most 32 characters, and unique across all servers, since a lookup returns the first match |