Multi-turn Discord chatbot using Google Gemini. DM or @mention it in a server. It reads attached files, searches the web, stores per-user memories, and writes daily audit logs. Plain Node.js ESM, no build step, no framework.
- Node.js ≥ 22 (24 LTS recommended;
.nvmrcpins 24) - ffmpeg + pngquant on PATH (for image processing). The bot still works without them; images are passed through unprocessed.
# Debian/Ubuntu
sudo apt install ffmpeg pngquant
# Termux (Android)
pkg install ffmpeg pngquant- Discord bot
- Create an app at https://discord.com/developers/applications
- Bot tab → Reset Token → copy
- Enable Message Content Intent under Privileged Gateway Intents
- OAuth2 → URL Generator → scope
bot→ permissions: View Channels, Send Messages, Read Message History → add to server - DMs work without extra setup. The gateway needs
DirectMessagesintent +Partials.Channel, both already enabled inbot.js.
- Gemini key: https://aistudio.google.com/apikey
- Environment
cp .env.example .env # Fill in DISCORD_TOKEN and GEMINI_API_KEY # Optional: STATUS_CHANNEL_ID, GEMINI_MODEL, GEMINI_TIMEOUT_MS, ...
- Install & run
npm install npm start # loads .env automatically npm run dev # auto-restart on file change
| Command | Description |
|---|---|
!search <query> |
DuckDuckGo web search |
!remember <key> <value> |
Store a fact |
!recall <key> |
Retrieve a stored fact |
!forget <key> |
Delete a stored fact |
!memory |
List all stored facts |
!restart |
Restart the bot (admin, server only) |
!help |
Show this help |
Attach text files (.txt, .js, .py, .md, ...) to include them as snippets, or images (.jpg, .png, .webp, .gif, ...) to send them to Gemini. Images at or under 1024px and under the inline byte cap are sent as-is. Small PNGs go through pngquant. Everything else is resized to 1024px and converted to JPEG q3 / WebP q80. Unsupported types return an error listing the skipped file.
- Context compression: conversation summarized via Gemini after
CONTEXT_COMPRESS_AFTER * 2messages (default 20) - Memory: per-user JSON store (atomic writes, main + backup)
- Skills: loads
skills/<name>/SKILL.mdand injects keyword-matched hints into the prompt.skills/is gitignored except.gitkeep. Force-add curated skills before publishing:git add -f skills/<name>. - Backpressure: per-channel message serialization, bounded queue (rejects when full), Gemini concurrency semaphore (3 by default)
- Web search: DuckDuckGo Lite with 10s timeout and 300s cache
- File handling: text snippets with BOM/encoding detection; image fast-path (header sniff, no subprocess for small images), ffmpeg/pngquant compression, per-file + per-message download budgets enforced before download starts
- Audit logging: buffered daily JSONL files in
logs/, 30-day retention - Anti-spam: rate limiting (10 msgs/30s/user), duplicate guard (content + message-id dedup), input sanitization
- Graceful shutdown: drains queue, flushes memory + audit, 30s force-exit fallback
bot.js # Entry point: Discord events, per-channel queue, Gemini calls
config.js # System prompt + env-driven config
commands.js # Command handlers
rateLimiter.js # Per-user rate limit + duplicate guard
conversationManager.js # Per-channel history with TTL eviction + GC
memoryStore.js # Persistent per-user memory (JSON)
auditLogger.js # Buffered JSONL audit logging
skillLoader.js # Loads opencode skills, matches by keyword
search.js # DuckDuckGo Lite search (parse5, 300s cache)
fileHandler.js # Text/image detection + ffmpeg/pngquant processing
All knobs live in config.js with defaults; see .env.example for the full list (timeouts, download caps, memory/conversation/rate-limit/audit limits). Notables:
GEMINI_MODEL: defaults to a rolling alias (gemini-flash-lite-latest); pin a concrete model ID when stability mattersMAX_DOWNLOAD_SIZE/MAX_TOTAL_DOWNLOAD_BYTES: per-file and per-message attachment caps (10 MB / 50 MB default), host allowlist restricted to Discord CDN. The total budget is reserved up front from Discord-reported sizes, so concurrent downloads can never overshoot it.IMAGE_MAX_DIM(1024): images at/below this size skip re-encoding entirelyPNG_COMPRESS_ENABLED: setfalseto skip pngquant and use ffmpeg for small PNGs
Entry flow per message:
guards (rate/duplicate/capacity/audit) → per-channel queue (messageQueue, semaphore GEMINI_MAX_CONCURRENT=3) → processAttachments (worker pool of 3, runWithConcurrency in bot.js) → prompt build → Gemini → history append → chunked reply.
- History is NOT the prompt: per-user memory is injected every turn but never stored in history.
conversationManager.get()returns a copy; mutate +set()to persist. bot.jshas 0% test coverage (Discord client side effects at import). Test pure logic in the module files instead; use thedestroy()/stopAutoGC()/clearCache()hooks to avoid timer/cache leaks across test files.- Prettier print width is 100; eslint expects 0 errors (
no-await-in-loop/no-shadowwarnings are accepted).
- Docker:
Dockerfileincluded (node:24-alpine + ffmpeg/pngquant). Data/logs live in the container; set env vars externally. - Free hosts: Railway or Fly.io: push repo, set env vars.
- pm2 (VPS):
npm install -g pm2 pm2 start bot.js --name clanker pm2 save && pm2 startup
npm test # node --test tests/
npm run lint # eslint, 0 errors expected
npm run format # prettierCI runs eslint + prettier + npm audit + tests on a Node 22/24 matrix.
data/ and logs/ are gitignored. They hold per-user memories and daily JSONL audit logs with message content. Never force-add them.
MIT. See LICENSE.