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
- 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 and reviews
- 📚 Resource Feeds — curated RSS (tech news, jobs, dev blogs) refreshed every 15 minutes
- 🛠️ Admin / Health — administrative commands, help, and health/diagnostics
Additional cogs exist in src/cogs/ (quiz, mentorship, fun, gamification,
portfolio, workshops, enhanced marketplace) 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 |
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 |
/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).
MIT — see the LICENSE file.