- Multi-model AI — Switch between OpenAI, Gemini, and Claude via
AI_PROVIDER. - Vision — Analyze images and GIFs from your message and the reply chain (file attachments and embed previews; capped per request).
- Image generation —
/imagineslash command using Gemini Image (gemini-3.1-flash-image). - Slash chat —
/chatslash command with the same AI pipeline as @mentioning the bot (optional image attachment). - Shared channel memory — Per-channel conversation history for collaborative threads.
- Reply-chain context — Traces Discord reply chains and injects quoted parent-message text alongside per-channel history when you reply to the bot.
- Web search and maps — Optional live search (OpenAI/Gemini) and Google Maps grounding (Gemini).
- Prompt caching — Optional context caching to reduce cost and latency for long conversations.
- Anti-spam — Per-user and per-channel cooldowns, per-channel queue backpressure, safe mention defaults.
- Memory optimized — Bounded reply-chain cache, capped reply-chain vision downloads, cooldown pruning, and aggressive Discord.js cache limits for container deployments.
- Production observability — Sentry errors, traces, profiling, logs, and custom metrics via Pino.
- Secure secrets — Doppler injects environment variables at runtime in Docker; local dev uses the Doppler CLI.
- Discord Developer Portal — bot token and application (client) ID
- API key for your chosen provider: OpenAI, Gemini, or Anthropic
- Doppler for secrets (recommended)
- Docker and Docker Compose for production deployment
| Secret | Purpose |
|---|---|
DISCORD_BOT_TOKEN |
Bot token |
DISCORD_CLIENT_ID |
Application ID (slash command registration) |
OPENAI_API_KEY |
When AI_PROVIDER=openai |
GEMINI_API_KEY |
When AI_PROVIDER=gemini |
ANTHROPIC_API_KEY |
When AI_PROVIDER=claude |
Generate a Doppler service token for the config you deploy (e.g. prd).
The repository includes docker-compose.yml:
services:
ai:
image: ghcr.io/doubleangels/ai:latest
container_name: ai
restart: unless-stopped
cap_drop:
- ALL
cap_add:
- CHOWN
- SETGID
- SETUID
security_opt:
- no-new-privileges:true
read_only: true
environment:
- DOPPLER_TOKEN=${DOPPLER_TOKEN}
- NODE_OPTIONS=--max-old-space-size=256
tmpfs:
- /tmpexport DOPPLER_TOKEN="dp.st.config.your_token_here"
docker compose up -dThe production image is built from the multi-stage Dockerfile. The entrypoint runs doppler run -- node index.js as an unprivileged user. Set DOPPLER_TOKEN at runtime; secrets are not baked into the image.
Set variables in Doppler, or copy .env.example to .env for local experiments without Doppler. Invalid model names cause the process to exit at startup with a logged error.
| Variable | Description | Default |
|---|---|---|
DISCORD_BOT_TOKEN |
Bot token | required |
DISCORD_CLIENT_ID |
Application ID | required for deploy |
ALLOWED_GUILD_IDS |
Comma-separated guild IDs. Empty = all guilds and DMs. Non-empty = listed guilds only (DMs blocked); the bot leaves any other server on startup and when it receives a message or slash command there. Set in production. | all servers + DMs |
LOG_LEVEL |
Pino log level | info |
| Variable | Description | Default |
|---|---|---|
AI_PROVIDER |
openai, gemini, or claude |
openai |
OPENAI_MODEL_NAME |
OpenAI model (also fallback for other providers if their model env is unset) | gpt-5.4-nano |
GEMINI_MODEL_NAME |
Gemini model | gemini-3-flash-preview |
CLAUDE_MODEL_NAME |
Claude model | claude-sonnet-4-6 |
Supported model IDs are validated in config.js. Unsupported values exit the process.
| Variable | Description | Default |
|---|---|---|
ENABLE_WEB_SEARCH |
Live internet search (OpenAI/Gemini) | false |
ENABLE_GOOGLE_MAPS |
Google Maps grounding (Gemini) | false |
ENABLE_CONTEXT_CACHE |
Provider context / prompt caching (system + stable turns) | true |
GEMINI_CACHE_TTL_SECONDS |
Gemini cache TTL, seconds (60–2073600, i.e. up to 24 days) | 3600 |
GEMINI_SAFETY_SETTINGS |
JSON array of { category, threshold } safety settings |
API defaults |
REASONING_EFFORT |
OpenAI reasoning: none, low, medium, high, xhigh |
none |
RESPONSES_VERBOSITY |
OpenAI text verbosity: low, medium, high |
low |
CLAUDE_THINKING_BUDGET_TOKENS |
Claude extended thinking budget (0 = off, max 32000) |
0 |
OPENAI_TIMEOUT_MS |
OpenAI client timeout (5000–300000) | 60000 |
OPENAI_MAX_RETRIES |
OpenAI client retries (0–5) | 2 |
GEMINI_TIMEOUT_MS |
Gemini request timeout (5000–300000) | 60000 |
CLAUDE_TIMEOUT_MS |
Claude request timeout (5000–300000) | 60000 |
| Variable | Description | Default |
|---|---|---|
MAX_OUTPUT_TOKENS |
Response token cap (256–65536) | 1024 |
MAX_HISTORY_TOKENS |
Channel history token cap (0 = disabled) |
0 |
MAX_HISTORY_LENGTH |
Max messages per channel (plus system); minimum 1 |
10 |
USER_COOLDOWN_MS |
Per-user per-channel cooldown (0 = disabled) |
4000 |
SECONDARY_MODEL_NAME |
Secondary model when the primary returns busy/overloaded errors | unset |
SECONDARY_AI_PROVIDER |
Provider for SECONDARY_MODEL_NAME; auto-detected from model ID when unset |
auto |
TERTIARY_MODEL_NAME |
Tertiary model (tried after the secondary model fails) | unset |
TERTIARY_AI_PROVIDER |
Provider for TERTIARY_MODEL_NAME; auto-detected when unset |
auto |
DISCORD_SHARD_COUNT |
Shard count (auto, 2, …); omit or 1 for single process |
single process |
CHANNEL_COOLDOWN_MS |
Per-channel cooldown (0 = disabled) |
1500 |
MAX_PENDING_PER_CHANNEL |
Queue depth before a “busy” reply (0 = disabled) |
3 |
CONVERSATION_HISTORY_MAX_CHANNELS |
Max in-memory channel histories (LRU; 0 = no cap; 0–10000) |
500 |
CONVERSATION_HISTORY_IDLE_MS |
Drop idle channel history (0 = disabled; max 604800000, i.e. 7 days) |
86400000 (24h) |
| Variable | Description | Default | Range |
|---|---|---|---|
MAX_REPLY_CHAIN_DEPTH |
Parent messages to fetch when tracing a reply chain | 15 |
1–50 |
MESSAGE_CACHE_MAX_SIZE |
LRU cache size for reply-chain message fetches | 500 |
10–10000 |
MESSAGE_CACHE_TTL_MS |
Message cache TTL (ms) | 1800000 (30 min) |
60000–86400000 |
IMAGE_DOWNLOAD_TIMEOUT_MS |
Vision image download timeout | 8000 |
— |
MAX_IMAGE_BYTES |
Max bytes per downloaded image | 6000000 |
— |
MAX_REPLY_CHAIN_IMAGES |
Max images/GIFs collected from a reply chain per request | 4 |
1–10 |
| Variable | Description | Default |
|---|---|---|
GEMINI_API_KEY |
API key from Google AI Studio (also used when AI_PROVIDER=gemini) |
unset |
GEMINI_IMAGE_MODEL_NAME |
Gemini Image model for /imagine |
gemini-3.1-flash-image |
SECONDARY_GEMINI_IMAGE_MODEL_NAME |
Backup Gemini Image model when the primary is rate-limited or unavailable | unset |
IMAGE_GENERATION_TIMEOUT_MS |
Image request timeout (10000–300000) | 120000 |
IMAGE_USER_COOLDOWN_MS |
Per-user per-channel cooldown for /imagine (0 = disabled) |
30000 |
The bot starts without GEMINI_API_KEY; /imagine is disabled until the key is set (same key as Gemini chat when using AI_PROVIDER=gemini). Set SECONDARY_GEMINI_IMAGE_MODEL_NAME (e.g. gemini-2.5-flash-image) to retry /imagine on rate limits or server errors.
Behavior notes:
- When you reply to the bot, quoted text from the reply chain is prepended to your message in addition to stored
conversationHistory(useful for translation and thread grounding). - Images and GIFs (attachments and embed previews) are collected from non-bot messages in the reply chain, oldest first, up to
MAX_REPLY_CHAIN_IMAGES. Video attachments are not analyzed. - Set
MAX_HISTORY_TOKENSin production for long threads to cap API payload size. - With
ENABLE_CONTEXT_CACHE=true(default), the system prompt and prior turns are marked for provider prompt caching (~90% off cached input tokens). Each bot process creates its own Gemini cache entry; multiple replicas each pay a one-time cache-creation cost on cold start. SetENABLE_CONTEXT_CACHE=falseto disable. - History staleness:
conversationHistorydoes not track Discord message IDs. Edited or deleted user messages may remain in memory until/reset, idle eviction, or token/length trimming. Deleting a bot reply removes the matching last assistant turn when content still matches. - SVG images are excluded from vision (raster formats only).
- Set
SECONDARY_MODEL_NAME(and optionallyTERTIARY_MODEL_NAME) to lighter models if your primary often returns rate-limit or overload errors. Provider is inferred from the model ID; override withSECONDARY_AI_PROVIDER/TERTIARY_AI_PROVIDER. Each backup can use a different provider; its API key must be configured.
- Mention:
@AI What is the capital of France?(direct user mention or a role that includes the bot) - Reply: Use Discord’s reply feature on a bot message to continue a thread.
- The bot ignores
@hereand@everyoneunless it is also mentioned. - Reply style: Answers default to short TLDR form (direct answer first). Ask for more detail, steps, or code if you need a longer reply.
Attach an image with a caption such as @AI describe this chart for multimodal analysis.
Generate an image with /imagine prompt:a sunset over mountains (requires GEMINI_API_KEY from Google AI Studio). Optional size (aspect ratio) is available.
Chat with /chat message:What is the capital of France? instead of @mentioning the bot. Optional image attachment works like sending an image with a mention.
| Command | Description | Permission |
|---|---|---|
/chat |
Chat with the bot (same as @mentioning it) | Everyone |
/imagine |
Generate an image from a text prompt (Gemini Image) | Everyone |
/reset |
Clear history for a channel (including threads) or this server only | Administrator |
Slash commands are registered automatically when the bot starts (index.js calls deploy-commands.js before connecting). To deploy without starting the bot:
pnpm commands:deploySet SENTRY_DSN in Doppler to enable Sentry. instrument.js loads before other application modules.
| Variable | Default | Purpose |
|---|---|---|
SENTRY_DSN |
unset | Enable reporting |
SENTRY_TRACES_SAMPLE_RATE |
0.1 prod / 1.0 dev |
Performance traces (0.0–1.0) |
SENTRY_ENABLE_PROFILING |
false |
Enable the CPU profiling integration (adds per-trace sampling overhead; opt-in) |
SENTRY_PROFILE_SESSION_SAMPLE_RATE |
0.1 prod / 1.0 dev |
Code profiling sample rate (0.0–1.0); only applies when profiling is enabled |
SENTRY_PROFILE_LIFECYCLE |
trace |
Profile lifecycle (e.g. trace, manual); only applies when profiling is enabled |
SENTRY_ENABLE_LOGS |
true |
Forward Pino logs to Sentry |
SENTRY_ENABLE_METRICS |
true |
Emit custom metrics |
SENTRY_SEND_DEFAULT_PII |
false |
Send default PII (user IDs, etc.) |
NODE_ENV |
production |
Sentry environment tag; local variable capture is enabled only when not production |
"prod" means NODE_ENV=production, which is the Docker image default — set SENTRY_TRACES_SAMPLE_RATE=1.0 explicitly if you want full sampling in production. Sample rates are clamped to [0, 1]; invalid values fall back to the default for the current environment.
Local variable capture on errors (includeLocalVariables) is enabled only outside production (NODE_ENV other than production). In Docker with NODE_ENV=production (the default), Sentry does not open the Node inspector, so container logs stay free of “Debugger listening on ws://…”.
captureError(error, tags) from instrument.js attaches stringified tags and calls captureException. Used across startup (index.js), the Discord client (bot.js), configuration (config.js), command deploy (deploy-commands.js), AI generation (aiService.js, geminiImageService.js), chat handling (channelChatHandler.js, messageCreate.js), slash commands (chat.js, imagine.js, reset.js), and lifecycle events (ready.js, messageDelete.js).
startSpan(options, callback) wraps async work. If Sentry.startSpan is unavailable, the callback runs directly.
| Span | Module | Purpose |
|---|---|---|
| Startup command deploy | index.js |
Register slash commands before login |
| Client login | bot.js |
Discord gateway connection |
| Slash command handler | bot.js |
Outer wrapper per /chat, /imagine, /reset |
| Chat / mention AI response | channelChatHandler.js |
End-to-end text chat (mentions, replies, /chat) |
| AI provider call | aiService.js |
OpenAI / Gemini / Claude request |
| Gemini image generation | geminiImageService.js |
/imagine image API call |
| Command deploy | deploy-commands.js |
Slash command registration |
| Client ready setup | ready.js |
Post-login setup |
Profiling uses @sentry/profiling-node when available and SENTRY_ENABLE_PROFILING=true is set; it's off by default to avoid its sampling overhead, and the bot continues without profiling if the integration fails to load.
logger.js wraps Pino and forwards info, warn, error, debug, trace, and fatal to Sentry.logger when logs are enabled. Forwarding failures are swallowed and logged locally at debug level.
Set LOG_LEVEL=debug in development to see ignore/rejection reasons (disallowed guilds, messages without mentions, cooldown skips, and command validation failures).
Structured logging conventions
- Log messages use plain English with terminal punctuation (
.!?). Context goes in the structured object, not in the message string. info— request lifecycle: command initiated/completed, chat request received, AI generation started, reply delivered, history reset.debug— skipped or rejected inputs, queue depth, history load/update, intermediate steps.warn— recoverable failures: cooldown replies, partial delivery, empty AI responses, backpressure.error— failures withserializeError(...)for stack traces; always paired withcaptureErrorwhere appropriate.
Per-surface log flow
| Surface | Initiated | In progress | Completed / outcome |
|---|---|---|---|
| @mention / reply | messageCreate.js (gate) → channelChatHandler.js |
Generating/sending AI response | Finished processing chat request. |
/chat |
Chat command initiated. |
Same shared handler | Chat command completed. |
/imagine |
Imagine command initiated. |
geminiImageService.js |
Imagine command completed. (+ image bytes/model on success) |
/reset |
Reset command initiated. |
Channel/guild lock + history delete | Reset command completed. |
| Slash wrapper | bot.js Executing command. |
Command execute() |
Command executed successfully. |
| Startup | deploy-commands.js |
— | Slash commands deployed on startup. |
Metrics are no-ops when Sentry is disabled or metrics are turned off. Attributes are normalized (objects JSON-stringified; null/undefined dropped).
Counters (recordCount)
| Metric | Attributes | Source |
|---|---|---|
ai.generate.requests |
provider, outcome |
aiService.js |
discord.message.received |
provider, trigger (mention, reply, slash) |
channelChatHandler.js |
discord.message.responded |
provider, outcome |
channelChatHandler.js |
discord.message.rejected |
reason |
messageCreate.js, chat.js |
discord.command.executed |
command, outcome |
bot.js |
discord.command.chat |
outcome |
chat.js |
discord.command.chat.rejected |
reason |
chat.js |
discord.command.imagine |
outcome |
imagine.js |
discord.command.imagine.cooldown |
— | imagine.js |
discord.context_menu.executed |
command, outcome |
bot.js |
discord.api.failure |
location, httpStatus, … |
multiple |
discord.api.rate_limit |
location, … |
multiple |
discord.login |
— | index.js |
discord.ready |
outcome |
ready.js |
discord.deploy_commands |
outcome |
deploy-commands.js |
discord.reset.executed |
scope, outcome |
reset.js |
Gauges (recordGauge)
| Metric | Attributes | Source |
|---|---|---|
discord.channel.queue_depth |
provider |
channelChatHandler.js |
Distributions (recordDistribution)
| Metric | Unit | Source |
|---|---|---|
ai.generate.duration_ms |
ms | aiService.js |
discord.message.processing_ms |
ms | channelChatHandler.js |
discord.message.response_chars |
— | channelChatHandler.js |
discord.command.duration_ms |
ms | bot.js |
discord.command.chat.duration_ms |
ms | chat.js |
discord.command.imagine.duration_ms |
ms | imagine.js |
discord.context_menu.duration_ms |
ms | bot.js |
gemini.image.success / .error / .filtered / .fallback |
model, … |
geminiImageService.js |
gemini.image.duration_ms |
ms | geminiImageService.js |
discord.deploy_commands.duration_ms |
ms | deploy-commands.js |
discord.reset.duration_ms |
ms | reset.js |
discord.ready.duration_ms |
ms | ready.js |
closeSentry() calls Sentry.close(2000) on process shutdown (see index.js signal handlers) to flush pending events.
After deploying with SENTRY_DSN set:
- Issues — trigger a handled error or exercise error paths (e.g.
/resetwithout permission). - Performance — mention the bot and inspect spans for message handling and
ai.generate. - Logs — confirm structured lines when
SENTRY_ENABLE_LOGSis true. - Metrics — filter by names above (e.g.
discord.message.received).
- Node.js 24.x (matches CI and the Docker image)
- pnpm 10.28 via Corepack
- Optional: Doppler CLI for local secrets
corepack enable
pnpm install --frozen-lockfileWith Doppler:
pnpm dev # nodemon + Doppler
pnpm start # deploy slash commands + start bot (Doppler)
pnpm commands:deploy # deploy slash commands only
pnpm predeploy # test + deploy commandspnpm test # full Jest suite (CI runs this)
pnpm test:coverage # coverage report in coverage/
pnpm test:coverage:check # 100% coverage gate (local / maintainer)- Tests live under
test/with shared setup intest/jest.setup.cjsandtest/jest.afterEnv.cjs, which stub Discord, AI SDKs, and Sentry. - Coverage thresholds (lines, branches, functions, statements) are 100% in
jest.config.cjs. - CI does not upload coverage — only
pnpm testruns in GitHub Actions. - On Windows, run coverage commands directly in PowerShell without piping output; piping can cause Jest to hang.
| Script | Command | Purpose |
|---|---|---|
audit:logs |
pnpm audit:logs |
Enforce log message style (no stray colons; terminal punctuation) |
The project is monitored on DeepScan (badge above).
| Workflow | Branch | Actions |
|---|---|---|
build-docker.yml |
main |
pnpm test, pnpm audit, Trivy FS + image scan, build and push ghcr.io/<owner>/ai |
build-dev-docker.yml |
dev |
Same pipeline for the dev branch image |
Images are published to GitHub Container Registry as ghcr.io/doubleangels/ai:latest on the default branch.
.dockerignore excludes from the build context:
test/,jest.config.cjs,coverage/,*.lcov,.nyc_output/- Documentation, CI configs, and dev tooling
The runtime stage contains application code and production dependencies only (pnpm install --prod --frozen-lockfile in the builder). The image runs as user discordbot (UID 1001), includes dumb-init and the Doppler CLI, and exposes a health check on the Node process.
| Path | Purpose |
|---|---|
index.js |
Startup: deploy slash commands, then spawn shards or load bot.js |
bot.js |
Discord client, command/event loading, slash interaction router |
config.js |
Environment-driven configuration and model validation |
instrument.js |
Sentry initialization, spans, metrics, captureError |
logger.js |
Pino logging with optional Sentry log forwarding |
deploy-commands.js |
Slash command registration with Discord REST API |
events/ |
Discord event handlers (messageCreate, messageDelete, ready) |
commands/ |
Slash commands (chat, imagine, reset) |
utils/channelChatHandler.js |
Shared chat pipeline for mentions, replies, and /chat |
utils/aiService.js |
OpenAI, Gemini, and Claude response generation |
utils/geminiImageService.js |
Gemini Image generation for /imagine |
utils/aiUtils.js |
Message splitting, vision download, history trimming, error classification |
utils/replyChainTracer.js |
Reply-chain traversal and message LRU cache |
utils/discordApi.js |
Discord REST call wrapper with retry/backoff on rate limits |
utils/guildAccess.js |
ALLOWED_GUILD_IDS enforcement; leaves disallowed guilds |
utils/imagineMessage.js |
Detects bot-generated /imagine image posts for reply handling |
utils/logSanitize.js |
Redacts secrets (tokens, keys) from log and error output |
scripts/audit-log-messages.js |
Log message style audit (maintainer) |
test/ |
Jest suite (not shipped in Docker images) |
Dockerfile |
Multi-stage production image (Node 24 Alpine) |
docker-compose.yml |
Example production compose stack |
