A professional networking and community-development Discord bot built with nextcord and backed by PostgreSQL. It provides profile/networking tools, a marketplace, curated RSS resource feeds, and operational tooling designed to keep running in a degraded state when its database or any single feature is unavailable.
- Tech Stack
- Features
- Architecture
- Project Structure
- Database
- Security
- Configuration
- Setup
- Commands
- Development
- CI/CD
- Known Issues
- License
- Python
>=3.13(nextcord 3.x supports 3.12–3.14) - nextcord 3.2.0 — Discord API wrapper
- asyncpg 0.31.0 — async PostgreSQL driver (the only datastore; no MongoDB, no Redis)
- aiohttp / feedparser / beautifulsoup4 — RSS resource feeds
- python-dotenv / validators — config and input validation
Package management is done via uv — uv.lock is the lockfile.
pyproject.toml is the source of truth for dependencies. A requirements.txt
is kept for Docker images that don't have uv.
Loaded modules (see EXTENSIONS in src/core/app.py):
- 🤝 Networking — professional profiles, connection requests, and connections (
/profile,/connect, plus!prefix equivalents) - 🛒 Marketplace — listings, reviews, and enhanced marketplace features
- 📚 Resource Feeds — curated RSS (tech news, jobs, dev blogs) refreshed every 15 minutes
- 🎮 RPG / Leveling — XP from messages, voice time, and commands; level-up system; activity role evaluation; background tasks for leaderboard auto-update, inactivity monitoring, and activity role assessment
- 📊 Leaderboard & Stats — auto-updating leaderboard embed (
/setupleaderboard),/leaderboard [stat],/level [@user],/most streamed|played|listened|codedactivity leaderboards - 🔍 Activity Tracking — streaming, gaming, listening, and coding activity duration tracking with detailed activity logging (game names, songs, coding apps)
- 🛠️ Admin / Health — administrative commands, help, health/diagnostics, moderation, notifications, and honeypot anti-spam
- 📋 Mentorship — mentorship matching and management
- 💼 Portfolio — portfolio management and display
- 📻 Radio — voice channel radio/audio features
- 🔗 External — info and export commands
- 📡 Status — bot status and system information
Additional cogs exist in src/cogs/ (quiz, gamification, workshops) but are not loaded until added to EXTENSIONS. Gamification is an intentional disabled stub.
The entrypoint chain is main.py → src/core/app.py:run_bot(), which loads
.env, configures logging, builds the bot, registers events, and starts it.
Degraded-mode design is the central theme — the bot is built to keep running when the database, a cog, or a background task fails, rather than crashing:
src/core/runtime_state.py— a single globalruntime_statedataclass (attached asbot.runtime_state) trackingdb_available,loaded_cogs,failed_cogs,degraded_features, startup-check results, and an alert cache. This is the source of truth for health/observability.- DB failures don't abort boot —
initialize_database()catches errors, setsdb_available = False, and marksdatabasedegraded. - A 1-minute
db_health_checkloop pings the DB, flipsdb_available, and fires admin alerts on lost/recovered transitions. - Cog load failures are isolated per-extension and recorded in
failed_cogs; other cogs still load.
Layering: cogs/ (thin Discord command handlers) → services/ (business
logic) → database/ (data access). Keep logic in services.
Safety wrappers (src/utils/safety.py) carry the degraded-mode contract into
command code:
@safe_command(requires_db=True)/@safe_slash_command(requires_db=True)— short-circuit withDatabaseUnavailableErrorwhen the DB is down, catch unexpected exceptions, log with context, and reply with an error embed.safe_send(target, ...)— unified reply helper for bothcommands.Contextandnextcord.Interaction. Prefer it over rawctx.send/interaction.response.@safe_background_task(name=...)— wrap@tasks.loopbodies; tracks consecutive failures and alerts admins after 3, with recovery notices.- Domain exceptions (
DatabaseUnavailableError,ValidationError,ExternalRequestError) map to user-facing messages centrally inapp.py's error handlers.
Embeds (src/utils/embeds.py) — all user-facing output uses veka_embed,
success_embed, error_embed, info_embed, alert_embed. Pass
contributor_source=__name__ from a cog so attribution resolves.
main.py → entrypoint (delegates to src.core.app:run_bot)
src/
├── core/
│ ├── app.py → bot bootstrap, EXTENSIONS allowlist, event handlers
│ ├── runtime_state.py → global runtime_state (health/observability)
│ └── checks.py → StartupChecks run on on_ready
├── config/config.py → all env vars, constants, RSS feeds, rate limits
├── database/
│ ├── database.py → asyncpg pool, global `db` singleton, run_migrations()
│ └── migrations.py → migration file discovery (schema_migrations table)
├── cogs/
│ ├── admin/ → basic.py, help.py, health.py
│ ├── networking/ → networking.py (profiles, connections)
│ ├── marketplace/ → marketplace.py, reviews.py
│ └── resources/feeds.py → RSS (@tasks.loop every 15 min)
├── services/ → networking, mentorship, quiz, rss
└── utils/
├── safety.py → safe_command / safe_send / safe_background_task
├── embeds.py → VEKA embed builders
├── security/ → rbac.py, rate_limiter.py, audit.py, validation.py
└── marketplace/ → fraud_detection.py
migrations/ → SQL files, auto-applied on startup
- PostgreSQL via asyncpg. Access goes through the global singleton:
from src.database.database import db. Use the$1,$2(asyncpg) parameter style. - All
dbmethods (fetch,fetch_one/fetchrow,fetchval,execute,execute_many) raiseDatabaseUnavailableErroron failure and flipruntime_state.db_available = False. Never assume a query succeeded. - Migrations are plain
.sqlfiles inmigrations/, applied automatically on connect bydb.run_migrations()and tracked in theschema_migrationstable. Add a new one asmigrations/00N_name.sql; it runs on next startup.
Key tables: users, profiles, connections, connection_requests,
mentorships, quizzes, quiz_attempts, workshops, portfolios,
resources, rss_cache, guild_config, marketplace_listings,
marketplace_offers, marketplace_reviews, marketplace_transactions,
and the security tables (audit_logs, user_security, security_events).
Security utilities live in src/utils/security/ and are exported from the
package:
from src.utils.security import (
rate_limit, rate_limiter, # rate limiting
InputValidator, sanitize, is_safe, validate_id, # input validation
audit_log, audit_action, # audit logging
rbac, Role, require_mod, require_admin, require_verified, # RBAC
)Rate limiting — @rate_limit('marketplace') on a command; named buckets
(default, quiz, marketplace, mentorship, admin). Manual check via
await rate_limiter.is_rate_limited(user_id, 'quiz').
Input validation — sanitize(text, max_length=...), is_safe(text),
InputValidator.validate_discord_id/validate_url/validate_marketplace_item(...).
Audit logging — @audit_action('quiz_completed') decorator, or
await audit_log.record(user_id=..., action=..., details={...}, severity=...).
Persisted to audit_logs; user_security and security_events track
warnings/blocks. Suspicious-activity detection:
await audit_log.detect_suspicious_activity(user_id).
RBAC — hierarchy USER < VERIFIED < MODERATOR < ADMIN < OWNER, mapped from
Discord role names (everyone, verified, mod/moderator,
admin/administrator, and guild owner). Gate commands with @require_mod(),
@require_admin(), @require_verified(), or check rbac.get_user_role(ctx).
Note:
safety.pyalso exposes a simpleradmin_only()check keyed offADMIN_IDS/OWNER_IDSfrom config — two parallel auth mechanisms exist.
Checklist for new commands: apply rate limiting where resource-intensive, validate and length-limit all user input, add an RBAC gate if restricted, and audit-log security-relevant actions.
Create a .env in the project root (see .env.example). Variables actually read
by src/config/config.py:
| Variable | Required | Default | Notes |
|---|---|---|---|
DISCORD_TOKEN |
✅ | — | Bot token |
DATABASE_URL |
✅* | composed from POSTGRES_* |
Full PostgreSQL DSN |
POSTGRES_HOST/PORT/DB/USER/PASSWORD |
✅* | localhost/5432/veka_bot/veka_bot_user/example |
Used to compose DATABASE_URL if it isn't set |
ADMIN_IDS |
— | empty | Comma-separated Discord user IDs |
OWNER_IDS |
— | empty | Comma-separated Discord user IDs |
ADMIN_ALERT_CHANNEL_ID |
— | none | Channel for operational alerts |
MASSUNBAN_LOG_CHANNEL_ID |
— | none | Dedicated channel for per-user mass-unban audit logs (separate from admin alert channel) |
BOT_VERSION |
— | 1.0.0 |
|
LOG_LEVEL |
— | INFO |
|
ENVIRONMENT |
— | development |
* Provide either DATABASE_URL directly, or the individual POSTGRES_* vars.
The command prefix is hardcoded to ! in config.py and is not read from
the environment.
git clone <repo> && cd discord-bot
cp .env.example .env # then fill in DISCORD_TOKEN and DATABASE_URL
# Install all deps (runtime + dev) via uv
uv sync --extra dev
# Activate pre-commit hooks (once after clone)
pre-commit install
python main.py # needs a reachable PostgreSQLdocker-compose.dev.yml is a fully self-contained local stack (bot + a bundled
PostgreSQL). docker-compose.yml (production) runs only the bot and expects
an external/managed PostgreSQL reachable via DATABASE_URL.
# Local: bot + bundled postgres
docker compose -f docker-compose.dev.yml up -d --build
# Production: bot only (set DATABASE_URL to your managed DB)
docker compose up -d --build
docker logs veka-discord-botThe bot does not require Docker — python main.py runs it directly against any
reachable PostgreSQL. Docker is simply how it is packaged and deployed.
Both slash commands and !-prefix commands are provided, often side by side for
the same feature — keep them in sync when changing behavior.
| Command | Description |
|---|---|
/profile [@user] / !profile |
View a professional profile |
!setupprofile |
Set up your profile |
/connect @user / !connect @user [message] |
Send a connection request |
/leaderboard [stat] / !leaderboard |
View leaderboard (xp, messages, voice, streaming, gaming, listening, coded) |
/level [@user] / !level |
View your or another user's level and XP |
/most streamed|played|listened|coded |
Activity-specific leaderboards |
/setupleaderboard |
(Admin) Configure the auto-updating leaderboard channel |
/massunban |
(Admin) Bulk-unban users within a date range with double confirmation, resumable job tracking, and per-user audit logs |
/massunban status <job_id> |
(Admin) Check status of a running or completed mass-unban job |
/massunban cancel <job_id> |
(Admin) Cancel a pending or running mass-unban job |
/massunban recent |
(Admin) List recent mass-unban jobs for this server |
/help / !help [command] |
List commands / command detail |
Run these before every push:
ruff check . # lint (no auto-fixes)
ruff check --fix . && ruff format . # auto-fix then format
mypy src/ main.py --explicit-package-bases # static type check
pre-commit run --all-files # all checks (ruff + mypy + misc)Pre-commit hooks run automatically on git commit. Config in
.pre-commit-config.yaml. Install them once with pre-commit install.
All tool config lives in pyproject.toml ([tool.ruff], [tool.mypy]).
- Every module logs via
logging.getLogger('VEKA.<area>'), configured bysetup_logging()insrc/utils/logger.py. - To enable an unloaded cog, add its module path to
EXTENSIONSinsrc/core/app.py. Each cog module needs a top-levelsetup(bot). - Startup health checks (
src/core/checks.py) run fromon_ready; results are stored inruntime_state.startup_check_results. Admin alerts and startup summaries go throughsrc/services/admin_notifier.py(bot.notifier). CLAUDE.mdcontains condensed architecture guidance for AI coding agents.
.github/workflows/deploy-discord-bot.yml deploys on push to main/production,
but only when the commit message starts with Merge pull request (i.e.
merged PRs). It runs on a self-hosted runner (self-hosted, X64, Linux, Veka)
using Docker (docker compose up -d --build), writes a .env from GitHub
secrets/variables, and gates success on the log line "is ready. DB available"
appearing within 60s.
Configure these in the repository's GitHub settings:
- Secrets (sensitive):
DISCORD_TOKEN,DATABASE_URL - Variables (non-sensitive):
ADMIN_IDS,OWNER_IDS,ADMIN_ALERT_CHANNEL_ID,LOG_LEVEL(optional)
BOT_VERSION is set automatically to the commit SHA and ENVIRONMENT to
production.
Production uses an external/managed database — ensure the
DATABASE_URLsecret points at a reachable host (not@postgres:5432).
CRITICAL — Activity tracking broken.FIXED. Listener renamed fromon_member_updatetoon_presence_update. Activity tracking now fires on the correct Discord event.- HIGH — Leaderboard auto-update requires setup.
LEADERBOARD_CHANNEL_IDdefaults toNone. Must set the env var or run/setupleaderboard(admin-only). MEDIUM —FIXED. Migrationjoined_atcolumn missing.015_add_joined_at.sqladds the column.inactivity_service.pyusesCOALESCE(joined_at, created_at)as fallback.- NOTE —
/massunbanbanned_byfilter only works for bans logged by the bot after its moderation audit system was in place. Historical bans placed before the bot was running cannot be filtered by moderator — Discord's audit log API is ephemeral and not stored retroactively.
MIT — see the LICENSE file.