A Discord bot for giving and tracking karma points across server members and teams.
Mention a user followed by ++ (two or more plus signs) to give them karma:
@alice +++ # gives Alice 3 karma
@bob +++++ # gives Bob 5 karma
Use -- (two or more minus signs) or em dashes to remove karma:
@alice --- # removes 3 karma
@alice — # removes 2 karma (em dash counts as 2)
@alice ——— # removes 6 karma
Mention a team role to give karma to all members of that team:
@Frontend +++ # gives +3 karma to every member with the Frontend role
Team role karma allows self-karma (if you're on the team) and bypasses the global rate limit, but per-user limits still apply.
- Global limit — max total karma you can give/take in a rolling interval (
LIMIT_MAX_KARMA_SEND/LIMIT_MIN_KARMA_SEND) - Per-user limit — max karma you can give/take to the same person per interval (
LIMIT_MAX_KARMA_USER_SEND/LIMIT_MIN_KARMA_USER_SEND) - The bot tells you exactly why karma was capped (per-user limit, global budget, or rate limited with reset time)
Karma accumulates in configurable time-based windows (semiannual by default). When a window ends, the leaderboard is automatically posted and a new window begins. Historical windows are preserved.
| Command | Description |
|---|---|
/leaderboard |
View the karma leaderboard (with period selector dropdown) |
/team-leaderboard |
View the team karma leaderboard |
/ping |
Check bot latency |
| Command | Description |
|---|---|
/config set-leaderboard-channel #channel |
Set where leaderboards are posted on window rollover |
/config add-karma-role @role |
Add a role that can give and receive karma |
/config remove-karma-role @role |
Remove a karma role |
/config view-karma-roles |
List all karma roles |
/config add-team-role @role |
Add a role as a team for the team leaderboard |
/config remove-team-role @role |
Remove a team role |
/config view-team-roles |
List all team roles |
When no karma roles are configured, everyone can use the bot.
src/
bot.py # Entry point, error handler, persistent views
core/
config.py # Rate limits, karma window rrule
checks.py # @has_karma_role() decorator + member check
emojis.py # Custom Discord emoji constants
functions/karma.py # Leaderboard embed builders
message_utils/paginator.py # Persistent paginated embeds
backend/
db.py # Database singleton (async SQLModel + asyncpg)
cache.py # Stale-while-revalidate cache manager
models.py # User, KarmaWindow, KarmaTransaction, GuildConfig, UserTeamRole
tables/
base.py # BaseDB[T] — generic CRUD
db_user.py # UserDB — increment_karma
db_karma_window.py # KarmaWindowDB — leaderboard + window queries
db_karma_transaction.py # KarmaTransactionDB — rate limit queries
db_guild_config.py # GuildConfigDB — per-guild settings (cached)
db_user_team_role.py # UserTeamRoleDB — team role cache + aggregation
cogs/
commands/
general.py # /ping
karma.py # Karma listener, /leaderboard, /team-leaderboard
config.py # /config command group
workers/
karma_history.py # Auto-post leaderboard on window rollover
tests/ # 61 tests (in-memory SQLite, no Docker needed)
- Python 3.12+
- Docker & Docker Compose (for the database)
cp .env.example .env
# Edit .env with your DISCORD_TOKEN
# Start the bot + Postgres
docker compose up --build
# Or run locally (with Postgres already running)
pip install -r requirements.txt
python -m src.bot| Variable | Description |
|---|---|
DISCORD_TOKEN |
Your Discord bot token |
DATABASE_URL |
PostgreSQL connection string (dev override sets this automatically) |
Enable these Privileged Gateway Intents for your bot:
- Message Content Intent — needed to read the
++/--karma pattern - Server Members Intent — needed for
guild.get_member()in leaderboards
Tests use in-memory SQLite — no database setup required.
pip install -r requirements.txt
python -m pytest tests/ -vEdit src/core/config.py to adjust rate limits:
| Setting | Default | Description |
|---|---|---|
LIMIT_MAX_KARMA_SEND |
100 | Max total karma a user can give per interval |
LIMIT_MIN_KARMA_SEND |
-100 | Max total karma a user can remove per interval |
LIMIT_KARMA_SEND_INTERVAL |
6 hours | Rolling window for the global rate limit |
LIMIT_MAX_KARMA_USER_SEND |
5 | Max karma a user can give to one person per interval |
LIMIT_MIN_KARMA_USER_SEND |
-5 | Max karma a user can remove from one person per interval |
LIMIT_KARMA_USER_SEND_INTERVAL |
6 hours | Rolling window for the per-user rate limit |
KARMA_WINDOW_RULE |
Semiannual (Jan/Jul) | dateutil.rrule defining karma reset periods |
The KARMA_WINDOW_RULE accepts any dateutil.rrule:
from dateutil.rrule import MONTHLY, WEEKLY, MO, rrule
# Semiannual (default)
KARMA_WINDOW_RULE = rrule(MONTHLY, interval=6, bymonth=(1, 7), dtstart=...)
# Fortnightly starting Monday
KARMA_WINDOW_RULE = rrule(WEEKLY, interval=2, byweekday=MO, dtstart=...)
# Monthly on the 1st
KARMA_WINDOW_RULE = rrule(MONTHLY, dtstart=...)- SQLModel — typed models that double as Pydantic + SQLAlchemy
- asyncpg — async PostgreSQL driver
- BaseDB[T] — generic CRUD base class; per-table classes add domain methods
- cached() — stale-while-revalidate wrapper for any table (used on
guild_config) - Singleton pattern —
from src.backend.tables import user, karma_windowand use directly - Persistent paginator — embed buttons survive bot restarts via
PersistentPaginatorView