A full-stack Discord bot with a modern dashboard. Moderation, automod, leveling, economy, tickets, welcome cards, music (Lavalink), AI chat, Minecraft / Roblox lookups, TMDB movies, Tenor GIFs — plus a multilingual React dashboard (en / es / de / fr / hi / ru).
- Features
- Architecture
- Prerequisites
- Quick Start — Self-Host in 3 Minutes
- Detailed Setup
- Environment Reference
- Discord Application Setup
- Database & Prisma
- Development vs Production
- Dashboard & Hosting Notes
- Commands
- Optional Integrations
- Languages
- Troubleshooting
- Project Structure
- Scripts
- Security
- Credits
Bot
- Slash, prefix, and context-menu commands (registry auto-discovers
packages/bot/src/commands/*.ts) - Moderation: ban / kick / timeout / massrole (batched
guild.members.fetch()with hierarchy checks), slowmode, purge - Automod: word & link filters, spam / raid protection, invite blocks
- Leveling with XP cards (
@napi-rs/canvas+ Cloudinary), leaderboard & auto-role - Economy: cash / bank / daily / shop / inventory
- Tickets & transcripts, welcome / goodbye messages with auto-role
- Music via Lavalink failover pool (
LAVALINK_NODES) +@discordjs/voice - AI chat with OpenAI-compatible failover (
AI_PROVIDERSpool) - Lookups: Minecraft, Roblox (proxied via API), TMDB movie search, Tenor GIFs, image generation
Dashboard (packages/dashboard — Vite + React 19 + Tailwind 4 + React Router + TanStack Query)
- Discord OAuth (
identify+guilds), persistentaeris.logincookie +LoginSessiontable - Server picker (only guilds where you have
Manage Serveror own the guild) - Per-guild settings: automod, leveling, economy, welcome, tickets, language
- Real-time status, leaderboard, economy views, integrations page
- Multilingual:
enesdefrhiru— saved per-guild inGuild.locale(also persists inlocalStorage+document.documentElement.lang)
API (packages/api — Express + Prisma + cookie-session)
- Restores durable
aeris.logincookie via middleware before routes so parallelGET /auth/me+GET /api/guildsnever races to401/ “Managing 0 servers” - Serves the built dashboard in production (
packages/dashboard/dist)
aeris/
├── server.ts # Supervisor — spawns API + bot together (tsx in dev, dist in prod)
├── load-env.ts # Root env loader — precedence: injected env > non-empty .env.local > .env
├── packages/
│ ├── shared/ # Prisma schema + @prisma/client singleton
│ │ └── prisma/schema.prisma # Guild.locale, LoginSession, Leveling/Economy/Tickets etc.
│ ├── bot/ # discord.js client, GatewayIntents: Guilds, GuildMessages, MessageContent, GuildVoiceStates, GuildMembers
│ │ └── src/commands/ # admin, automod, moderationx, music, ai, lookup, media, image, fun (≤25 subcommands), …
│ ├── api/ # Express (CORS + cookie-session + prisma), /auth, /api, /search, /api/lookups
│ └── dashboard/ # Vite React app, proxy /api + /auth + /search → API in dev
└── env.example # Documented env template
- Dev:
pnpm devrunsbot+apiviatsx+dashboardvia Vite concurrently. - Prod:
pnpm buildbuildsshared→bot+api(tsc) +dashboard(tsc -b && vite build) + rootdist/server.js(tsconfig.server.json);pnpm start:prodrunsnode dist/server.jswhich supervisors bothdist/index.jsandpackages/bot/dist/index.jsand kills the peer if either exits.
| Requirement | Version / Notes |
|---|---|
| Node.js | >= 20 (22 recommended). Check with node -v |
| pnpm | 9.15.4 (npm i -g pnpm@9.15.4 or corepack enable) |
| PostgreSQL | 14+ — local, Docker, Neon, Supabase, etc. |
| Discord Application | Bot token + OAuth2 application.commands credentials |
| Git | Any recent version |
Optional services (all gracefully disabled when unset): Lavalink node(s), OpenAI-compatible AI, TMDB, Tenor, Cloudinary, Meilisearch.
# 1. Clone
git clone https://github.com/developer51709/aeris.git
cd aeris
# 2. Install
pnpm install
# 3. Configure env — copy template and fill at least the 4 required keys
cp env.example .env
# then edit .env (see Environment Reference below)
# 4. Database
pnpm db:generate # generate Prisma client
pnpm db:push # push schema to Postgres (or: pnpm --filter @aeris/shared exec prisma migrate dev)
# 5. Build & run
pnpm build
pnpm start:prod # → API on http://0.0.0.0:3001, dashboard served by API, bot logs in
# Dev instead: pnpm dev → dashboard http://localhost:5173, API http://localhost:3001Open the dashboard at http://localhost:5173 (dev) or http://localhost:3001 (prod). Log in with Discord, pick a server where you have Manage Server, and configure it.
Update slash commands: they register automatically on ClientReady. Invite the bot first, then restart once so registerCommands() can read client.application.id.
git clone https://github.com/developer51709/aeris.git
cd aeris
corepack enable # if pnpm not found
pnpm installThe repo uses
pnpm-workspace.yamlwithpackages/*. Usingnpm/yarn/bun installwill not resolveworkspace:*correctly.
- Go to https://discord.com/developers/applications → New Application.
- Bot tab → Reset Token → copy — this is
DISCORD_TOKEN(also accepted asDISCORD_BOT_TOKEN). - Bot tab → enable Privileged Gateway Intents:
Presence Intent(optional),Server Members Intent(required formassrole/welcome/GuildMembers),Message Content Intent(required for prefix + automod message handling). - OAuth2 → General → copy Client ID →
BOT_OAUTH_CLIENT_ID(alsoDISCORD_CLIENT_IDfallback) and Client Secret →BOT_OAUTH_CLIENT_SECRET. - OAuth2 → General → Redirects → add:
- Dev:
http://localhost:5173/auth/callback - Prod:
https://your-domain.com/auth/callback(must matchBOT_OAUTH_REDIRECT_URIexactly)
- Dev:
- Invite URL: OAuth2 → URL Generator → Scopes:
bot+applications.commands+identify+guilds→ Bot Permissions:Manage Roles,Ban Members,Kick Members,Moderate Members,Manage Messages,Manage Channels,Send Messages,Embed Links,Attach Files,Connect,Speak(for music) → copy generated URL and open it to add the bot to your server. Put the bot’s role above any role it needs to assign.
Copy the template:
cp env.example .env| Variable | Required | Description | Example |
|---|---|---|---|
DATABASE_URL |
Yes | Postgres connection string | postgresql://aeris:aeris@localhost:5432/aeris |
DISCORD_TOKEN |
Yes | Bot token from Developer Portal → Bot | MTQy... |
BOT_OAUTH_CLIENT_ID |
Yes | Application → General → Application ID | 123456789012345678 |
BOT_OAUTH_CLIENT_SECRET |
Yes | OAuth2 → Client Secret | abc... |
BOT_OAUTH_REDIRECT_URI |
Yes | Must exactly match portal redirect | http://localhost:5173/auth/callback |
SESSION_SECRET |
Yes | Random 32+ char string for cookie-session |
openssl rand -hex 32 |
DASHBOARD_URL |
Yes | Public dashboard origin (CORS origin, OAuth redirect base) |
http://localhost:5173 (dev) / https://your-domain.com (prod) |
API_PORT |
No | Port API listens on (0.0.0.0) |
3001 |
NODE_ENV |
No | development / production |
production |
BOT_OWNER_ID |
No | Comma-separated owner IDs for /admin broadcast (owner-only) |
123,456 |
GUILD_ID |
No | If set, slash commands register to this guild instantly (useful in dev) | 987... |
Aliases: DISCORD_BOT_TOKEN is accepted as DISCORD_TOKEN; DISCORD_CLIENT_ID is accepted as BOT_OAUTH_CLIENT_ID in the command registry fallback.
Env precedence (implemented in load-env.ts, packages/api/src/env.ts, packages/bot/src/env.ts, packages/dashboard/vite.config.ts):
- Already-injected process env (hosting provider) wins — never overwritten.
- Non-empty
.env.localwins over.env. - Blank values are ignored (so a blank
BOT_OAUTH_REDIRECT_URI=in.env.localno longer masks a valid value in.env— a prior production outage).
Never commit
.env/.env.local. They are gitignored. Keep secrets private and never share them with anyone.
# Generate client (needed after every schema change or fresh clone)
pnpm db:generate
# Fastest for self-host: push schema directly
pnpm db:push
# Or use migrations (recommended if you will evolve the schema)
pnpm --filter @aeris/shared exec prisma migrate dev --name init
pnpm --filter @aeris/shared exec prisma migrate deploy # on the serverSchema lives at packages/shared/prisma/schema.prisma (Postgres, Guild.locale default en, LoginSession for durable logins).
Need a local Postgres quickly?
docker run --name aeris-db -e POSTGRES_USER=aeris -e POSTGRES_PASSWORD=aeris \
-e POSTGRES_DB=aeris -p 5432:5432 -d postgres:16-alpine
# then DATABASE_URL=postgresql://aeris:aeris@localhost:5432/aeris# Development — hot reload for bot + api + dashboard
pnpm dev
# → bot via tsx watch, api via tsx watch, dashboard Vite on http://0.0.0.0:${PORT:-5173}
# dashboard proxies /api, /auth, /search → http://127.0.0.1:3001
# Production — single supervisor process (used by hosting)
pnpm build
NODE_ENV=production pnpm start:prod
# or: NODE_ENV=production node dist/server.js
# → single origin on API_PORT (dashboard is served as static from packages/dashboard/dist)
# One-off command deploy (normally automatic on bot ready)
pnpm --filter @aeris/bot exec tsx src/deploy.tsenv.example is the source of truth — copy it:
NODE_ENV=development
DATABASE_URL=postgresql://aeris:aeris@localhost:5432/aeris
BOT_OAUTH_CLIENT_ID=
BOT_OAUTH_CLIENT_SECRET=
BOT_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback
# Required for owner-only administrative broadcasts. Use one or more Discord user IDs separated by commas.
BOT_OWNER_ID=
DASHBOARD_URL=http://localhost:5173
API_PORT=3001
SESSION_SECRET=replace-with-a-long-random-string
CLOUDINARY_CLOUD_NAME=
CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=
CLOUDINARY_ASSET_FOLDER=aeris
# Optional OpenAI-compatible AI provider (keep the key in the environment, never in source)
AI_API_KEY=
AI_API_URL=https://api.openai.com/v1
AI_MODEL=gpt-4o-mini
# Optional AI failover pool. Use a JSON array of {name,url,key,model} objects.
AI_PROVIDERS=[]
TMDB_API_KEY=
TENOR_API_KEY=
# Optional Lavalink failover pool. Use a JSON array of {name,url,password} objects.
LAVALINK_NODES=[]
LAVALINK_NODE_URLS=
LAVALINK_PASSWORD=youshallnotpassExtra env vars recognized by the bot (not in env.example but supported in code):
| Variable | Purpose |
|---|---|
DISCORD_TOKEN / DISCORD_BOT_TOKEN |
Bot login token (preferred DISCORD_TOKEN) |
DISCORD_CLIENT_ID |
Fallback application ID for command registration |
GUILD_ID |
Register slash commands to one guild instantly |
AI_API_KEY / AI_API_URL / AI_MODEL |
Single OpenAI-compatible provider |
AI_PROVIDERS |
JSON array [{name,url,key,model}] — failover pool |
LAVALINK_NODES |
JSON array [{name,url,password}] — music failover |
LAVALINK_NODE_URLS / LAVALINK_NODE_1_URL / LAVALINK_PASSWORD |
Legacy Lavalink envs |
TMDB_API_KEY |
Movie / TV lookups |
TENOR_API_KEY |
GIF search |
CLOUDINARY_* |
Level card / image uploads |
VITE_PORT / PORT |
Dashboard Vite dev port |
API_PROXY_URL |
Dashboard Vite proxy target (default http://127.0.0.1:3001) |
Leave any optional key empty to disable that integration — the bot starts fine.
- Invite scopes:
bot+applications.commands+identify+guilds - Intents to enable:
Guilds,Guild Messages,Message Content,Guild Voice States,Guild Members. Code requests exactlyGatewayIntentBits.Guilds | GuildMessages | MessageContent | GuildVoiceStates | GuildMembers. - Redirect URI: path must be
/auth/callbackon the same origin asDASHBOARD_URL. For production, also add the domain to OAuth2 → General → Redirects. Mismatched URI → Discord returnsinvalid_grantduring token exchange (the API redirects back to/login?oauthError=…). BOT_OWNER_ID: set your Discord user ID (Developer Mode → right-click → Copy ID). Separate multiple with commas. Required for/admin broadcast; without it that subcommand rejects with an owner-only error.
- Provider:
postgresql(SQLite not supported — schema uses Postgres) - Client singleton:
packages/shared/src/index.tsexportsprisma - Models:
Guild(withlocale),User,GuildMember,AutomodSettings,LevelingSettings/LevelingData,EconomySettings/EconomyUser/EconomyInventory/EconomyShopItem,TicketConfig/Ticket,WelcomeConfig,MusicQueue,ModerationLog,LoginSession - After changing
schema.prisma:pnpm db:generate && pnpm db:push(orprisma migrate dev)
Development (pnpm dev) |
Production (pnpm build && pnpm start:prod) |
|
|---|---|---|
| Processes | 3 concurrent via concurrently: pnpm --filter @aeris/bot dev, pnpm --filter @aeris/api dev, pnpm --filter @aeris/dashboard dev on 0.0.0.0:${PORT:-5173} |
1 supervisor (dist/server.js) on 0.0.0.0:${API_PORT:-3001} |
| Bot | tsx watch src/index.ts |
node packages/bot/dist/index.js |
| API | tsx watch src/index.ts |
node dist/index.js (serves packages/dashboard/dist) |
| Env | Reads .env → .env.local per-package via dotenv.parse |
Same loader; production env from host inject wins |
| Cookies | secure: false, sameSite: lax |
secure: true when NODE_ENV=production |
Freebuff / container hosts must bind to 0.0.0.0 and use the injected PORT — the config already does (server: { host: "0.0.0.0", port: Number(process.env.VITE_PORT ?? process.env.PORT ?? 5173) } and app.listen(PORT, "0.0.0.0")).
- Vite proxy (dev only):
/api,/auth,/search→API_PROXY_URL(defaulthttp://127.0.0.1:3001). SetVITE_API_URL/VITE_AUTH_URLviaimport.meta.envif you split the deploy. - Build output:
packages/dashboard/dist(static). In production the API serves it + falls back toindex.htmlfor SPA routes;/auth,/api,/search,/healthare excluded from the fallback. - CORS:
origin: DASHBOARD_URL,credentials: true. - Sessions:
cookie-session(aeris.session, 7 days) + durableaeris.login(30 days,LoginSessionrow, restored by middleware before routes — fixes the “Managing 0 servers” race). - Node-only deploy: builder expects
pnpm installthenvite build/tscproducingdist/and exiting. Nouv/pip/python/apt/cargoin install/build; never invoke scripts as./script.sh(execute bit is stripped) — usesh ./scripts/foo.sh.
Commands are defined in packages/bot/src/commands/*.ts and registered on ClientReady via registerCommands(client) (REST PUT to Routes.applicationCommands globally or Routes.applicationGuildCommands when GUILD_ID is set). Discord caches globally up to ~1 hour — use GUILD_ID in dev for instant updates.
Top-level commands include: admin (status/stats/shards/announce/broadcast), automod, moderation / moderationx (including massrole), leveling, economy/shop, ticket, welcome, music, ai, lookup (Minecraft/Roblox), movies (TMDB), media/image (Tenor/Cloudinary canvas), fun, social, server, utility, voicemaster, blackjack, help/ping/health, plus context-menu commands.
Discord limit: ≤25 subcommands per slash command.
funis capped at 25 (extra games live inblackjacketc.).
All are optional — the bot boots without them and skips related features.
| Integration | Env | Notes |
|---|---|---|
| AI chat | AI_API_KEY, AI_API_URL, AI_MODEL or AI_PROVIDERS=[{name,url,key,model}] |
OpenAI-compatible; pool tries next provider on failure |
| Music | LAVALINK_NODES=[{name,url,password}] (or LAVALINK_NODE_URLS comma list) |
@discordjs/voice + Lavalink; no node → music commands reply with setup hint |
| Movies | TMDB_API_KEY |
TMDB v3 search |
| GIFs | TENOR_API_KEY |
Tenor v2 |
| Images | CLOUDINARY_*, @napi-rs/canvas |
Level cards and image commands |
Generate keys: OpenAI (or any compatible), TMDB, Tenor, Cloudinary, self-hosted Lavalink.
Dashboard + bot responses support en es de fr hi ru.
- Dashboard: auto-detects
navigator.languages, persisted inlocalStorage(aeris-locale) anddocument.documentElement.lang; footer popover to switch. - Bot: per-guild locale stored in
Guild.locale, exposed viaGET/PUT /api/settings/:guildId/language, resolved inpackages/bot/src/locale.tsand applied incomponents.tsreplyV2/editV2. - Docs & Integrations pages are localized via
packages/dashboard/src/i18n.tsx(SUPPORTED_LOCALES,LANDING_MESSAGES,DASHBOARD_EXTRA,DocsMessages).
Add a locale by extending SUPPORTED_LOCALES, LOCALE_LABELS, LANDING_MESSAGES in packages/dashboard/src/i18n.tsx and adding the Prisma Guild.locale default if desired, then pnpm db:generate.
Managing 0 servers / dashboard shows 0 guilds
- Make sure the OAuth account has Manage Server or is owner. The API filters by
MANAGE_GUILD(1 << 5). - Wait ~2 min after inviting the bot — Discord/DB sync is on
GuildCreate/ ready events. - The API now restores
aeris.loginbefore routes and refreshesguildIdsfrom Discord whileaccessTokenis present. If you still see 0, open DevTools → Application → Cookies and confirmaeris.loginexists; then hitGET /api/guilds— server logs showoauthManagedGuildCount/botSyncedGuildCount.
OAuth loops back to /login?oauthError=…
OAuth is not configured→BOT_OAUTH_CLIENT_ID/SECRET/REDIRECT_URImissing. Checkenv.examplevs your.envand that.env.localdoes not contain a blankBOT_OAUTH_REDIRECT_URI=masking the real value.State mismatch→ session cookie lost (third-party cookies blocked,DASHBOARD_URLmismatch, or running dashboard on a different origin). KeepDASHBOARD_URLexactly the origin that serves the dashboard.Discord rejected the OAuth exchange→REDIRECT_URIin.envdoes not exactly match the portal’s Redirects entry (includinghttps, host, and/auth/callback).
Slash commands don’t appear
- Global commands take up to an hour. Set
GUILD_ID=<your test guild>in.envand restart for instant guild commands. - Ensure the bot was invited with
applications.commandsscope.
massrole says 0 changed
- Fixed: command now uses
guild.members.fetch()(notmembers.cache), skips bots/owner/role.managed/roles above the bot’s highest role/already-has-role, batchesPromise.allSettledin groups of 5, and reports skipped/failed counts with reasons. Ensure the bot’s role is above the target role.
Database connection errors / Can't reach database
- Verify
DATABASE_URLis reachable from the host. For Docker Postgres, usehost.docker.internalwhen the bot runs outside the container. - Run
pnpm db:generateafter checkout — stalepackages/shared/node_modules/.prismacauses type errors.
Port already in use / EADDRINUSE
- Change
API_PORT(API) orVITE_PORT/PORT(dashboard) in.env.
Dashboard is blank / Failed to load servers 500
- Check API logs (
Health: Prisma connection error). In dev ensure API is on3001(Vite proxies there); in prod ensureDASHBOARD_URLmatches the origin.
.
├── env.example
├── load-env.ts
├── server.ts
├── package.json # root scripts: dev, build, build:root, db:*, start, start:prod
├── pnpm-workspace.yaml
├── tsconfig.json / tsconfig.server.json
└── packages/
├── shared/
│ ├── prisma/schema.prisma
│ ├── src/index.ts # prisma singleton export
│ └── package.json
├── bot/
│ ├── src/
│ │ ├── index.ts
│ │ ├── env.ts
│ │ ├── locale.ts
│ │ ├── components.ts
│ │ ├── deploy.ts
│ │ ├── commands/ # registry.ts, interaction-router.ts, moderationx.ts, …
│ │ ├── events/ # ready, guild, member, message, prefix
│ │ ├── lib/embeds.ts
│ │ ├── image/ # cards.ts, cloudinary.ts
│ │ └── music/lavalink.ts
│ └── package.json
├── api/
│ ├── src/
│ │ ├── index.ts # Express + session restore middleware + static dashboard
│ │ ├── env.ts
│ │ └── routes/ # auth.ts, dashboard.ts, search.ts, lookups.ts
│ └── package.json
└── dashboard/
├── vite.config.ts # parseProjectEnv + proxy + 0.0.0.0 host
├── src/
│ ├── App.tsx
│ ├── main.tsx
│ ├── i18n.tsx
│ ├── lib/api.ts
│ ├── pages/ # landing.tsx, auth.tsx, dashboard.tsx, docs.tsx
│ ├── views/ # overview, guildList, guildSettings, integrations, economy, …
│ └── components/layout.tsx
└── package.json
| Script | Where | What |
|---|---|---|
pnpm dev |
root | concurrently bot + api (tsx watch) + dashboard (Vite) |
pnpm build |
root | shared build → bot build + api build + dashboard build → build:root (dist/server.js) |
pnpm build:root |
root | tsc -p tsconfig.server.json |
pnpm db:generate |
root | pnpm --filter @aeris/shared db:generate (prisma generate) |
pnpm db:push |
root | pnpm --filter @aeris/shared db:push (prisma db push) |
pnpm start |
root | tsx server.ts (dev supervisor) |
pnpm start:prod |
root | NODE_ENV=production node dist/server.js |
pnpm --filter @aeris/bot build |
bot | tsc → packages/bot/dist |
pnpm --filter @aeris/api build |
api | tsc → packages/api/dist |
pnpm --filter @aeris/dashboard build |
dashboard | tsc -b && vite build → packages/dashboard/dist |
Typecheck without emitting: pnpm --filter @aeris/api exec tsc --noEmit, pnpm --filter @aeris/bot exec tsc --noEmit, pnpm --filter @aeris/dashboard exec tsc -b --noEmit, pnpm --filter @aeris/shared exec tsc --noEmit.
- Keep
DISCORD_TOKEN/BOT_OAUTH_CLIENT_SECRET/SESSION_SECRET/DATABASE_URL/ provider keys in the host env — never commit.env. SESSION_SECRETshould be a long random value; it signsaeris.session. Rotate it to invalidate all sessions.- In production, cookies are
httpOnly+secure+sameSite: lax. Always serve overhttpswhenNODE_ENV=production. - The
GET /auth/me→GET /api/guildsrace was fixed by restoringaeris.loginin global middleware — do not move session restoration into a single route.
Built and maintained with care. Special thanks to:
- sorenthedev on Discord — creator & maintainer
- Website: https://sorenthedev.indevs.in
- GitHub: https://github.com/developer51709
If Aeris helps your server, consider starring the repo and sharing feedback with sorenthedev on Discord — it keeps the project moving.
MIT — see LICENSE. You are free to self-host, fork, and modify — keep the Credits section intact when redistributing.