- Rust 98%
- Dockerfile 2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .gitea/workflows | ||
| deploy | ||
| src | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| README.md | ||
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
- Repository: https://git.emilyh.de/Emily/RemindMeRustDiscordBot
- Container image:
git.emilyh.de/emily/remindme
Features
- Relative reminders — anything from
10mto2 weeksfrom 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 thepings:field (@mentions, raw user IDs, or names with autocomplete). - Timezones — each user can save an IANA timezone with
/timezone, and/remind ataccepts atimezoneoverride. 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
- Go to the Discord Developer Portal and create a new application.
- Under Bot, copy the token. That is your
DISCORD_TOKEN. - Under Installation (or OAuth2 → URL Generator), use the scopes
botandapplications.commands. The bot needs these permissions:- Send Messages
- View Channels (and Read Message History if you want replies to work nicely)
- 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 setENABLE_MEMBER_INTENT=1. Without it the bot still works; pings then need@mentionsor user IDs (the user picker inping: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.
Option A — Docker (recommended)
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:10001it 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
remindmeuser with an empty capability set andNoNewPrivileges,ProtectSystem=strict,ProtectHome, a system call filter and a0077umask. StateDirectory=remindmecreates/var/lib/remindme(mode0750, owned by the service user) and is the only writable path; the database lives there.Restart=alwayswithRestartSec=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
failedand logged. - Reminders are not repeated (no cron syntax).