Discord reminder bot in Rust: slash commands, relative and absolute times, multi-user pings
  • Rust 98%
  • Dockerfile 2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Emily 6d577352e4
Some checks are pending
docker / publish (push) Waiting to run
Fix backup snippet: override the entrypoint when running a shell in the image
2026-11-11 00:51:30 +01:00
.gitea/workflows Fix image build: force rebuild of the real binary, add OCI labels 2026-11-11 00:44:20 +01:00
deploy Drop PrivateUsers from the unit: unprivileged user namespaces are restricted on some distros 2026-11-11 00:51:15 +01:00
src Discord reminder bot with relative/absolute time parsing and multi-user pings 2026-11-11 00:21:05 +01:00
.dockerignore Discord reminder bot with relative/absolute time parsing and multi-user pings 2026-11-11 00:21:05 +01:00
.env.example Discord reminder bot with relative/absolute time parsing and multi-user pings 2026-11-11 00:21:05 +01:00
.gitignore Discord reminder bot with relative/absolute time parsing and multi-user pings 2026-11-11 00:21:05 +01:00
Cargo.lock Discord reminder bot with relative/absolute time parsing and multi-user pings 2026-11-11 00:21:05 +01:00
Cargo.toml Discord reminder bot with relative/absolute time parsing and multi-user pings 2026-11-11 00:21:05 +01:00
docker-compose.yml Add container image, compose file, systemd unit and hosting guides 2026-11-11 00:30:01 +01:00
Dockerfile Fix image build: force rebuild of the real binary, add OCI labels 2026-11-11 00:44:20 +01:00
LICENSE Discord reminder bot with relative/absolute time parsing and multi-user pings 2026-11-11 00:21:05 +01:00
README.md Fix backup snippet: override the entrypoint when running a shell in the image 2026-11-11 00:51:30 +01:00

RemindMe — a Discord reminder bot in Rust

A small, dependency-light Discord bot that remembers things for you. It uses slash commands only, stores reminders in SQLite so they survive restarts, and can ping one or more people when a reminder fires.

/remind in   timespan:1h30m  message:Take the pizza out of the oven  ping:@alex
/remind at   when:tomorrow 18:00  message:Standup  pings:@alex, @sam
/remind list
/remind cancel   id:3
/remind snooze   id:3  timespan:10m
/timezone        zone:Europe/Berlin

Features

  • Relative reminders — anything from 10m to 2 weeks from the moment you run the command.
  • Absolute reminders — 18:30, tomorrow 9am, fri 18:00, 2025-01-05 14:30, 05.01. 18:00, jan 5 14:30, or a full ISO timestamp.
  • One or more pings — pick a person with Discord's native user picker (ping:), and add as many more as you like in the pings: field (@mentions, raw user IDs, or names with autocomplete).
  • Timezones — each user can save an IANA timezone with /timezone, and /remind at accepts a timezone override. Rendering is done with Discord timestamps, so everyone sees the reminder in their own local time.
  • Persistent — reminders live in SQLite and are re-scheduled on startup.
  • Safe mentions — the fired message only allows the pinged users to be mentioned, so a reminder text can never ping @everyone.

Quick start

1. Create the application

  1. Go to the Discord Developer Portal and create a new application.
  2. Under Bot, copy the token. That is your DISCORD_TOKEN.
  3. Under Installation (or OAuth2 → URL Generator), use the scopes bot and applications.commands. The bot needs these permissions:
    • Send Messages
    • View Channels (and Read Message History if you want replies to work nicely)
  4. Invite the bot with the generated URL.

Optional: if you want name-based pings (pings:@alex) and member autocomplete, enable the Server Members Intent on the Bot page and set ENABLE_MEMBER_INTENT=1. Without it the bot still works; pings then need @mentions or user IDs (the user picker in ping: always works).

2. Configure

cp .env.example .env
# edit .env and put your token in

3. Run

With Docker (recommended, see Hosting):

docker compose up -d

Or straight from source:

cargo run --release

The slash commands are registered on startup. Global commands can take up to an hour to appear the first time; set GUILD_ID while developing to register them instantly in one server.

Configuration

All settings are environment variables (.env is loaded automatically, and Docker/systemd pass them in as usual).

Variable Default Meaning
DISCORD_TOKEN — (required) Bot token
DATABASE_PATH reminders.sqlite3 SQLite file; parent folders are created
DEFAULT_TIMEZONE UTC Timezone used when a user has not saved one
DATE_ORDER dmy How 05/01/2025 is read: dmy or mdy
DEFAULT_DATE_TIME 09:00 Time of day used when only a date is given
MAX_REMINDER_DAYS 3650 Reject reminders further in the future
MAX_REMINDERS_PER_USER 50 Pending reminders per user
GUILD_ID — Register commands in this guild only (instant, for development)
ENABLE_MEMBER_INTENT false Enable the Server Members privileged intent
RUST_LOG info,remindme=debug Log filter

Commands

/remind in

Option Required Description
timespan yes Timespan from now, e.g. 10m, 1h30m, 2 days
message yes What to remind you about
ping no One person to ping (native user picker)
pings no More people: @mentions, IDs or names, comma separated

/remind at

Option Required Description
when yes Point in time, e.g. tomorrow 18:00, 2025-01-05 14:30, fri 9am
message yes What to remind you about
ping no One person to ping
pings no More people
timezone no Timezone to interpret when in (defaults to your saved one)

/remind list, /remind cancel, /remind snooze

list shows your next 20 pending reminders with their ids, cancel id:<n> removes one, snooze id:<n> timespan:<…> moves one further into the future.

/timezone

/timezone shows the timezone currently in use, /timezone zone:Europe/Berlin saves a new one, /timezone reset:true forgets it.

Time syntax

Both /remind in and /remind at accept all of these — the subcommand only decides how ambiguous input like 1:30 is read (1h30m vs. half past one).

Timespans

Input Meaning
10m, 10 min, 10 minutes 10 minutes
1h30m, 1h 30m, 1 hour and 30 minutes 1 hour 30 minutes
1:30, 1:30:15 1h 30m, 1h 30m 15s
90s, 2d, 1w, 3mo, 1y seconds, days, weeks, months (30d), years (365d)
1.5h 90 minutes
an hour, half an hour, a moment, next week 1h, 30m, 1m, 7d

Prefixes in , after , + and suffixes from now, later are ignored, so in 10 minutes, +10m and 10 minutes from now all work.

Absolute times

Input Meaning
18:30, 18:30:15, 6:30pm, 9am today, or tomorrow if already passed
tomorrow, tomorrow 9, tomorrow 9am, tmrw 21:00 next day
today 20:00, tonight 20:00 today (errors if already passed)
fri 18:00, next monday 8:00 next occurrence of that weekday
2025-01-05 14:30, 2025-01-05 ISO date
05.01.2025 14:30, 05.01. 18:00, 05/01/2025 DATE_ORDER dependent
jan 5 14:30, 5 january 2025 9am, july 1st 2025 month names
2025-01-05T14:30:00Z, …+02:00 RFC 3339, timezone from the input

Rules:

  • A bare time that already passed today rolls over to tomorrow.
  • A date without a year that already passed rolls over to next year.
  • An explicitly given date/time that is in the past is rejected.
  • Only a date (no time) uses DEFAULT_DATE_TIME (09:00 by default).
  • DST gaps and overlaps are resolved to the earliest valid instant.

Who gets pinged

The person who created the reminder is always pinged. Additional people can be given as:

  • <@123456789012345678> / <@!123456789012345678> (mentions)
  • 123456789012345678 (raw user ID)
  • @alex, alex, Alex Smith (resolved from the member cache, or the member search endpoint when the Server Members intent is enabled)

Separate multiple people with commas or spaces. If a name cannot be resolved the reminder is not created, so you never silently lose a ping.

Hosting

The bot has no inbound traffic: it only opens an outbound WebSocket/HTTPS connection to Discord, so no ports have to be published and it runs happily behind NAT or on a VPS without a reverse proxy. All state is one SQLite file.

Prebuilt images are published to the Gitea registry by .gitea/workflows/docker.yml:

Tag Built from
git.emilyh.de/emily/remindme:latest newest v* tag
git.emilyh.de/emily/remindme:1.2.3 git tag v1.2.3
git.emilyh.de/emily/remindme:edge head of main
git.emilyh.de/emily/remindme:sha-abc1234 one specific commit

Images run as an unprivileged user (uid 10001), store their database in /data, and need no entrypoint arguments. The published image is linux/amd64 (around 110 MB, based on debian:bookworm-slim).

1. Pull the image

docker pull git.emilyh.de/emily/remindme:latest

The package is public; if you made it private, log in first with a Gitea token that has read:package:

echo "$GITEA_TOKEN" | docker login git.emilyh.de -u Emily --password-stdin

2. Create .env next to the compose file

DISCORD_TOKEN=your-token-here
DEFAULT_TIMEZONE=Europe/Berlin
chmod 600 .env

3. Start it

Either with the bundled docker-compose.yml:

docker compose up -d
docker compose logs -f

or with plain docker run:

docker run -d \
  --name remindme \
  --restart unless-stopped \
  --env-file .env \
  -e DATABASE_PATH=/data/reminders.sqlite3 \
  -v remindme-data:/data \
  --log-opt max-size=10m --log-opt max-file=3 \
  git.emilyh.de/emily/remindme:latest

4. Updating

docker compose pull && docker compose up -d
# or: docker pull … && docker restart remindme

5. Backup

The whole state is /data/reminders.sqlite3:

docker run --rm --entrypoint sh \
  -v remindme-data:/data -v "$PWD":/backup \
  git.emilyh.de/emily/remindme:latest \
  -c 'cp /data/reminders.sqlite3* /backup/'

(or simply docker cp remindme:/data/reminders.sqlite3 ., which also works on a stopped container).

Build it yourself instead of pulling:

docker build -t remindme .
docker compose up -d --build     # compose file already has `build: .` commented out

Note on the volume: the container runs as uid 10001. If you bind-mount a host directory instead of a named volume, chown -R 10001:10001 it first.

Option B — systemd (native binary)

Use this if you prefer a plain binary over a container. The unit file and an env template ship in deploy/.

1. Build (on the server, needs a Rust toolchain, or build elsewhere and copy the binary):

git clone https://git.emilyh.de/Emily/RemindMeRustDiscordBot.git
cd RemindMeRustDiscordBot
cargo build --release

2. Create the service user and install

sudo useradd --system --home /var/lib/remindme --shell /usr/sbin/nologin remindme
sudo install -Dm755 target/release/remindme /opt/remindme/remindme
sudo install -Dm644 deploy/remindme.service /etc/systemd/system/remindme.service

3. Provide the token

sudo install -Dm600 -o root -g root deploy/remindme.env.example /etc/remindme/remindme.env
sudoedit /etc/remindme/remindme.env     # set DISCORD_TOKEN

The unit reads this file as root (EnvironmentFile=), so it does not need to be readable by the remindme user — keep it 0600.

4. Start it

sudo systemctl daemon-reload
sudo systemctl enable --now remindme
systemctl status remindme
journalctl -u remindme -f

5. Updating

cd RemindMeRustDiscordBot && git pull
cargo build --release
sudo install -Dm755 target/release/remindme /opt/remindme/remindme
sudo systemctl restart remindme

What the unit does

  • Runs as the unprivileged remindme user with an empty capability set and NoNewPrivileges, ProtectSystem=strict, ProtectHome, a system call filter and a 0077 umask.
  • StateDirectory=remindme creates /var/lib/remindme (mode 0750, owned by the service user) and is the only writable path; the database lives there.
  • Restart=always with RestartSec=5s, so a crash or a Discord hiccup is retried automatically. Pending reminders are in SQLite and are re-scheduled on startup, so restarts never lose a reminder.
  • Logs go to the journal (journalctl -u remindme).

How it works

File Responsibility
src/main.rs env/config loading, poise framework, client startup
src/commands.rs the slash commands, autocomplete, error replies
src/timeparse.rs timespan and absolute-time parser (heavily unit tested)
src/pings.rs parsing/resolving the ping list
src/db.rs SQLite storage (reminders, user_settings)
src/scheduler.rs one background task that sleeps until the next due reminder
Dockerfile, docker-compose.yml container build + deployment example
deploy/remindme.service hardened systemd unit
.gitea/workflows/docker.yml builds and publishes the container image

The scheduler never spawns a task per reminder: it asks the database for the earliest pending due_at, sleeps until then (interruptible by a Notify that commands trigger after inserting), delivers everything that is due, and repeats. Reminders are marked done/failed after delivery, so nothing is sent twice across restarts.

Development

cargo test          # 36 unit tests: parser, pings, db, command tree
cargo clippy --all-targets
cargo fmt

The command tree is validated by tests (names/descriptions within Discord's limits, required options before optional ones), so a bad command definition fails cargo test instead of failing at runtime.

Releasing a new container image

git tag v0.1.0
git push origin v0.1.0

The Gitea workflow builds and pushes :v0.1.0, :0.1.0, :latest and :sha-<commit>. Pushes to main produce :edge.

Limits

  • Reminder text is truncated to 1500 characters by the slash command and to ~1600 when delivered.
  • Messages are delivered to the channel the reminder was created in; if that channel is gone the reminder is marked failed and logged.
  • Reminders are not repeated (no cron syntax).