A small Discord bot for a study server. It rewards you for showing up. No streaks, no pressure.
The unit is a cup. One cup is the amount of coding you get done in the time it takes to drink a cup of coffee or tea, around 10 minutes. A cup of TypeScript. A cup of NestJS. A cup of whatever you are learning.
You open the server, click a button, write one short line about what you did, and post it. The first cup of the day gives you XP. XP unlocks roles. If you show up 5 days or more in a week, you get a bonus. You can also set a quest to track a focus area and celebrate when you finish it.
- TypeScript
- discord.js v14
- Prisma + MySQL
- Vitest for tests
- ESLint, Prettier, Husky for code quality
You only need two channels:
#cuppa(locked). The bot pins one message here with three buttons:- Code Session
- Study Session
- Set Quest
#cups(read only for humans). The bot posts every cup here as a clean embed, plus shoutouts for quest completions and the weekly bonus.
- One cup is about 10 minutes of real coding.
- Your first Code Session of the day gives you +50 XP.
- A second code session the same day still posts in the feed but gives 0 XP. The goal is to show up, not to grind.
- A Study Session (videos, docs, reading) also posts in the feed, but it never gives XP.
- If you have 5 days or more with at least one earning cup in a week, the bot gives you +150 XP and shouts you out in the feed at the start of the next week.
- The week starts on Saturday by default, but every member can change their own with
/settings week-start. - Every member can set their own timezone with
/settings timezone. Your daily XP boundary resets at your own local midnight, so a member in Tokyo and a member in Cairo each get the right "new day" at the right moment.
A quest is what you are currently focused on. Examples: NestJS, AWS exam, a side project, anything. Quests are optional. New users start with no quest set.
How to use them:
- Click 🎯 Set Quest on the panel, or run
/quest name:... target:.... - You can give a quest an optional target number of cups. When you hit the target, the bot celebrates it in
#cups. There is no penalty for missing. - Want to change the target later? Run
/questagain with the same name and a new target. Your cup count is kept. (This only works while the quest is still active. After it is completed, the same name starts a fresh cycle.) - You can keep logging past your target. If you set target 2 and end up doing 5 cups, you will see
5/2 ✅on the cup. The extra cups are not lost: they help you plan a better target next time. - Cups you log get tagged with your active quest. The tag is frozen at log time, so renaming or switching later does not rewrite history.
- Past quests stay in your history, even ones you abandoned by switching to something else.
| You are on... | You submit... | What happens |
|---|---|---|
| Nothing | A name | New quest, count starts at 0. |
NestJS x 10, 4 cups |
NestJS x 10 (no change) |
Nothing changes. |
NestJS x 10, 4 cups |
NestJS x 15 |
Same quest, target updated, your 4 cups stay (now 4/15). |
NestJS x 10, 4 cups |
NestJS x 3 |
Same quest, target shrunk, completes right away because 4 >= 3. |
NestJS x 10, 4 cups |
React x 5 |
Old NestJS quest paused (kept in your history). New React quest starts. |
NestJS x 10, completed |
NestJS x 10 |
New cycle. Fresh quest row, count restarts. |
| Anything | Blank name | Quest cleared. The row stays in your history. |
| Level | XP needed | Role |
|---|---|---|
| 0 | 0 | Novice |
| 1 | 100 | Apprentice |
| 2 | 300 | Crafter |
| 3 | 700 | Wizard |
| 4 | 1500 | 1x Developer |
| 5 | 3000 | Archmage |
The names play on the project's name: the bot is "0.1x developer," so reaching "1x Developer" at level 4 is the wink. Archmage is the peak tier, the master mage who sees beyond.
The bot adds the role to you when you level up. You need to create these roles in your Discord server (names are matched case-insensitively). Place them BELOW the bot's own role, so it has permission to assign them.
Type these in either #cuppa or #cups. The channels are locked for free-form messages but slash commands work there because they use a separate permission. Every reply is ephemeral (only you see it), so commands never clutter the channel for other members.
/statsshows your level, XP, current quest with progress, this week's days, and a small breakdown of this week's cups by quest./quest name:... target:...sets or changes your quest. Both options are optional. Leavenameblank to clear. Autocomplete onnamesuggests common focuses, but you can type anything you want./questslists your quest history: active, completed, and paused, each with cup counts and when you started them./last count:...shows your most recent cups (default 5, max 15). Useful when you want to pick up where you left off without scrolling the feed./settings viewshows your current timezone, week start, and digest opt-in. The one-stop place to audit your preferences./settings timezone name:Asia/Tokyosets your IANA timezone so your daily XP boundary resets at your own local midnight. Autocomplete suggests common zones. Default isAfrica/Cairountil you set it./settings week-start day:Mondaypicks the day your week resets on. Affects only what you see in/statsand the feed embed footer. Default is Saturday./settings digest state:on|offturns on or off a weekly DM that recaps your week. The DM arrives on your week-start day (set via/settings week-start, default Saturday) in your timezone. Off by default./settings nudge state:on|offturns on or off a one-time "we missed you" DM after a 30-day quiet stretch. No streak language, no threat, just a warm note. Off by default./setup panel(admin only) posts the panel buttons into#cuppa./setup welcomeposts the welcome and house-rules messages into your welcome channel. Both subcommands are idempotent: re-running deletes the bot's previous messages first.
- Milestone reactions. When you log your 25th, 50th, 100th, 250th, 500th, or 1000th cup, the bot drops a 🏆 reaction on the feed embed. Sparse on purpose so the reaction stays meaningful.
- Quest completion shoutout. When a quest with a target hits its cup count, the bot calls it out in
#cupsonce. - Weekly digest DM (opt-in). Run
/settings digest state:onand you get a short DM each week with your last seven days. The DM arrives on your own week-start day in your own timezone, so a Monday-start user gets a Monday recap and a Saturday-start user gets a Saturday recap. Stays in DMs, never bloats the server. Off by default. The bot never DMs unless you ask. - Comeback nudge DM (opt-in). Run
/settings nudge state:onand the bot will send one warm DM if you go quiet for 30 days. Never a second DM in the same absence cycle, never any "log or else" language. Off by default. - Quest paused, not lost. Switching to a new quest while a non-completed one is active does not delete the old quest. You can see it under "Paused" in
/questsand pick a similar focus up later.
-
Copy the env file:
cp .env.example .env
Fill in the values. You need a bot token, a client ID, the two channel IDs, and a MySQL connection string.
-
Install dependencies. The Prisma client is generated automatically after install.
pnpm install
-
Create the database tables:
pnpm prisma:migrate
-
Register the slash commands with Discord:
pnpm deploy:commands
-
Start the bot:
pnpm dev
-
In your Discord server, type
/setup panelto drop the pinned panel into#cuppa, then/setup welcometo post the three welcome messages into#welcome(setWELCOME_CHANNEL_IDin.envfirst). Pin each bot message so it stays at the top.
In the Discord Developer Portal, also turn on the Server Members Intent under "Bot". The bot needs this to assign roles.
Four small tables:
usersholds your XP, level, a pointer (current_quest_id) to your active quest (or null), opt-ins for the two optional DMs (weekly_digest_opt_in,absence_nudge_opt_in), your IANAtimezoneandweek_startday, aprefs_confirmedflag (true once you've run any/settingssubcommand, used to silence the one-time first-cup nudge), alast_cup_attimestamp (denormalized snapshot of your most recent cup), idempotency timestamps for the DMs (weekly_digest_sent_at,absence_nudged_at), and adms_blocked_atflag set automatically when a DM fails because you have the bot blocked or your DMs closed (cleared the next time you interact with the bot, so future DMs resume automatically).questsis an append-only history of every quest you have ever set. The state of a quest is read from its columns: it is active ifusers.current_quest_idpoints at it, completed ifcompleted_atis set, and paused if neither.cupsis one row per session. Each cup carries aquest_idpointing to the quest active when it was logged (or null if you had no quest set).weekly_bonus_logsis one row per paid-out week. It keeps the bonus from paying twice if the cron runs again or the bot restarts.
| Command | What it does |
|---|---|
pnpm dev |
Start the bot in watch mode |
pnpm build |
Generate the Prisma client, compile to dist |
pnpm start |
Run the compiled bot |
pnpm test |
Run all tests |
pnpm test:watch |
Tests in watch mode |
pnpm test:coverage |
Tests with coverage report |
pnpm typecheck |
TypeScript with no emit |
pnpm lint |
Run ESLint |
pnpm lint:fix |
ESLint with auto fix |
pnpm format |
Prettier write |
pnpm check |
Typecheck + lint + tests (used in CI) |
pnpm deploy:commands |
Push slash commands to Discord |
pnpm prisma:migrate |
Run DB migrations in dev |
pnpm prisma:deploy |
Apply DB migrations in prod |
pnpm prisma:studio |
Open Prisma Studio |
Every user-facing string lives in src/lib/copy.ts. If you want to rebrand for a different theme (workout tracker, language learning, whatever), that file is the main place to edit. Two adjacent files hold the rest of the brand surface: src/lib/levels.ts for XP tiers and role names, src/lib/quests.ts for the autocomplete suggestions. The data layer and routing are already abstract, so no schema migration is needed for a rebrand.
- Husky pre-commit runs
lint-staged. It formats and lints only the files you changed. - Husky pre-push runs
pnpm typecheck && pnpm test. So broken code never leaves your machine.
My editor says Module '"@prisma/client"' has no exported member 'PrismaClient'.
Run pnpm prisma:generate once. The Prisma client is generated, not committed. After that, restart your editor's TypeScript server (in VS Code: Ctrl+Shift+P then "TypeScript: Restart TS Server").
The weekly bonus did not run because my bot was offline.
The bot also tries to run a catch up once on startup. If it sees the previous week was never paid out, it pays out then. Safe to restart any time.
I want the week to start on Monday instead of Saturday.
If you want your personal "this week" counter to reset on Monday, run /settings week-start day:Monday. That only affects what you see. The server-wide weekly bonus cron still fires on the day set by WEEK_START in .env (default Saturday). To shift the whole server bonus payout, set WEEK_START=1 in .env. 0 is Sunday, 6 is Saturday.
I want to use a different timezone than Africa/Cairo.
Run /settings timezone name:America/New_York (or any IANA name). That changes only your daily-cup boundary. The SERVER_TIMEZONE env var still controls the timing of the weekly bonus cron only.
I added or renamed a slash command, and Discord still shows the old one.
Run pnpm deploy:commands again so Discord picks up the new command list. If you scoped commands to a guild (via DISCORD_GUILD_ID, your server ID), they update instantly. Global commands can take up to an hour. In production you can instead trigger the register-commands GitHub Action (Actions → Run workflow); it needs the DISCORD_TOKEN, DISCORD_CLIENT_ID, and DISCORD_GUILD_ID repo secrets.
I pulled new code and the bot crashes about missing columns.
A migration changed in place. Run pnpm prisma migrate reset to wipe your dev database and replay every migration. This only deletes data in dev. Never run reset in production.
Things that are intentionally not built yet, to keep the system tiny:
- Leaderboards. They cause comparison anxiety. The feed already shows social proof.
- Public daily streaks. Punishing missed days kills motivation. The weekly bonus is the only public reward.
- Multiple XP types. Keep it one currency.
- Comments under cups. The feed is read only on purpose.
- Daily hall-of-fame channels. Naming who showed up creates implicit shame for who did not.
- Tomorrow-I-will channel. The quest mechanic already covers per-cycle intentions.
- Karma XP for reactions. It cheapens XP and turns genuine reactions into a points game.