Discord Bot

Last updated: 2026-05-26

Overview

The Monstermessenger Discord bot provides a fully integrated chatbot experience through Discord Direct Messages. It authenticates users via the same backend API as the web frontend, supports the full interactive conversation flow (free text + choice buttons), and offers language detection, feedback collection, and monster switching — all within Discord’s UI primitives.

Key characteristics:

  • DM-only (ignores server messages)
  • Deterministic user identity via Discord ID → UUID
  • Automatic consent registration for Discord users
  • Per-user in-memory session store (discord_sessions dict)
  • Unified POST /chat endpoint (no separate emotion/dropdown endpoints)
  • 6-language i18n for bot-specific UI strings

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                        Discord Bot                               │
│                                                                  │
│  ┌──────────┐  ┌───────────────┐  ┌─────────────────────────┐  │
│  │ bot.py   │  │ bot_feedback  │  │ bot_language_choice.py  │  │
│  │ (main)   │  │ (thumbs/modal)│  │ (LEGACY — deprecated)   │  │
│  └────┬─────┘  └───────┬───────┘  └─────────────────────────┘  │
│       │                │                                         │
│  ┌────┴────┐    ┌──────┴──────┐                                 │
│  │ config  │    │ health_server│                                 │
│  └────┬────┘    └──────┬──────┘                                 │
│       │                │                                         │
│  ┌────┴────┐    ┌──────┴──────┐                                 │
│  │ i18n.json│   │ Dockerfile  │                                 │
│  └─────────┘    └─────────────┘                                 │
│                                                                  │
└──────────────────────────┬──────────────────────────────────────┘
                           │ HTTP (REST)
                           ▼
              ┌─────────────────────────┐
              │   FastAPI Backend       │
              │   ┌──────────────────┐  │
              │   │ POST /auth/login  │  │
              │   │ POST /chat/init   │  │
              │   │ POST /chat        │  │
              │   │ POST /feedback/*  │  │
              │   │ PATCH /auth/profile│ │
              │   └──────────────────┘  │
              └─────────────────────────┘

File Map

File Role
discord_bot/bot.py Main bot — event handlers, auth, chat, interactive prompts, monster switching
discord_bot/bot_feedback.py Feedback UI — thumbs-up/down buttons, text modal, backend submission
discord_bot/bot_language_choice.py LEGACY — pre-unification bot with separate /chat/emotion and /chat/dropdown endpoints. Kept for reference; all active features merged into bot.py
discord_bot/config.py Environment variable loader (DISCORD_BOT_TOKEN, BACKEND_URL, etc.)
discord_bot/health_server.py Lightweight HTTP health check server (:8080), runs in daemon thread
discord_bot/i18n.json Localized strings (6 languages) for monster switch messages and feedback UI
discord_bot/Dockerfile Docker build for python:3.12-slim
discord_bot/requirements.txt Python dependencies
discord_bot/monsters_emojis/ SVG + PNG emoji assets for monster avatars

Authentication Flow

The bot uses the same mock-IDP authentication pipeline as the web client, but hardcodes certain assumptions suitable for the Discord audience:

# Step 1 — Deterministic UUID from Discord ID
user_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, str(discord_id)))

# Step 2 — Get JWT from mock IDP
POST /api/v1/mock-idp/authenticate  {"idp_user_id": user_uuid}
→ {"access_token": "..."}

# Step 3 — Exchange JWT for bearer token
POST /api/v1/auth/login  {
    "idp_token": "...",
    "id_provider": "mock-idp",
    "user_type": "teenager",
    "age_class": "teenager 15-17",     # hardcoded for Discord
    "language": "fr",                   # from --language CLI arg
    "origin": "discord",
    "capabilities": {"supports_sse": false}  # Discord has no SSE
}
→ {"bearer_token": "...", "profile": {...}}

Token Caching

The bearer_token is cached in discord_sessions[user_uuid]["bearer_token"] for the lifetime of the process. On restart, tokens are regenerated.

Session Management

All session state is stored in an in-memory Python dictionary:

discord_sessions = {
    "<uuid5>": {
        "bearer_token": "...",
        "session_id": "...",
        "monster_name": "betty",
        "last_button_msg_id": 123456789
    }
}
Field Set by Purpose
bearer_token get_bearer_token() Authenticated API calls
session_id start_discord_session() Backend conversation session ID
monster_name send_backend_response() Current active monster for emoji display + switching
last_button_msg_id send_backend_response() Most recent button message ID — disabled when user sends free text

Session initialization happens on first DM from a user:

  1. discord_id_to_uuid() converts the Discord user ID to a deterministic UUID
  2. start_discord_session() calls POST /chat/init with {"resume_conversation_id": null}
  3. The session ID from the response is stored

Ephemeral note: Sessions are NOT persisted across bot restarts. On restart, all users start new sessions. For long-running deployments, consider adding a session persistence layer (Redis, etc.).

Chat Flow

Free-Text Messages

When a user sends a text message in DM:

  1. Disable any active choice buttons from the previous interaction
  2. call_backend_chat()POST /chat with {"message": content, "session_id": session_id}
  3. send_backend_response() renders the backend reply, checks for monster switch, and creates interactive buttons if present

Interactive Prompts (Choice Buttons)

The bot uses the unified InteractivePrompt system (see Client Development Guide). When the backend response includes an interactive_prompt field, the bot renders Discord buttons:

prompt = data.get("interactive_prompt")
if not prompt:
    return  # no choices to render

options = prompt.get("options", [])
prompt_style = prompt.get("prompt_style", "choice")  # "choice" or "emotion"

view = discord.ui.View()
for index, opt in enumerate(options):
    button = discord.ui.Button(label=opt["label"], style=ButtonStyle.primary, row=index // 4)
    # callback: disable all buttons, highlight clicked one green,
    # call POST /chat with the selected value, render response
    view.add_item(button)
  • Buttons are arranged in rows of 4
  • When clicked: all buttons grey out, the selected one turns green, the selection value is sent to POST /chat
  • The response from the backend may contain another interactive_prompt (sequential questions) or free text

Button Disable-on-Text

If a user types free text while choice buttons are still active, the bot fetches the button message, disables all buttons, and edits the message — preventing stale interactions.

Feedback System

When the backend sends a message with metadata.action == "user_feedback", the bot attaches a feedbackView to the message:

┌─────────────────────────────────┐
│ betty: Thanks for sharing...    │
│                                 │
│  [👍]  [👎]  [➕]               │
└─────────────────────────────────┘
Button Action
👍 Opens FeedbackModal — user types feedback, is_positive=True
👎 Opens FeedbackModal — user types feedback, is_positive=False
Triggers on_new_session callback — reinitializes POST /chat/init for a fresh conversation

The modal submits to POST /api/v1/feedback/{session_id} with:

{
    "session_id": "...",
    "user_id": "...",
    "value": 10,
    "is_positive": true,
    "comment": "User's feedback text",
    "incident_type": null
}

All feedback UI strings are localized (see I18n).

Monster Switching

The bot detects monster changes by comparing the previous monster name (from discord_sessions) with the monster field in the backend response:

previous_monster = session_data.get('monster_name')
current_monster = data.get("monster", DEFAULT_MONSTER)

if previous_monster != current_monster:
    await monster_switch(channel, previous_monster, current_monster)
    session_data['monster_name'] = current_monster

The switch message uses a localized template: "{monster} will help you" (English) / "{monster} va t'aider" (French).

Emoji Display

Each message is prefixed with the monster’s Discord emoji (if available on any server the bot is in):

monster_emoji = discord.utils.get(client.emojis, name=monster_name)
prefix = f"{monster_emoji} **{monster_name}:**" if monster_emoji else f"**{monster_name}:**"

Emoji assets (betty.svg, emoji_betty.png) are in discord_bot/monsters_emojis/. To add a new monster, upload its emoji to a server the bot is a member of, then reference it by name.

Language Detection

The bot auto-detects the user’s language on their first message using the langid library:

from langid import classify

def detect_language(text: str):
    lang = classify(text)[0].upper()
    return lang if lang in SUPPORTED_LANGS else DEFAULT_LANGUAGE

SUPPORTED_LANGS = ['DE', 'EN', 'ES', 'FR', 'IT', 'PT']

The detected language is sent in the POST /auth/login payload ("language": lang.lower()). After the first message, detection_lang is set to False and the language is locked for the session.

CLI Language Override

The bot accepts a --language / -l CLI argument for a default language:

python bot.py -l FR   # French default
python bot.py -l EN   # English default

The Dockerfile defaults to French: CMD ["python", "bot.py", "-l", "FR"]

I18n

Bot-specific UI strings (monster switch message, feedback labels) are stored in i18n.json with 6 language keys (FR, EN, ES, DE, IT, PT). Each language provides:

Key Used in
monster_switch Monster switching message template
feedback_label Feedback modal title
feedback_placeholder Feedback modal placeholder text
feedback_submit Ephemeral confirmation after feedback submission

Example (French):

{
    "FR": {
        "monster_switch": "{monster} va t'aider",
        "feedback_label": "Ton retour",
        "feedback_placeholder": "Donne ton avis",
        "feedback_submit": "Merci pour ton retour ! Tu peux continuer la conversation maintenant"
    }
}

Configuration

All configuration is via environment variables, loaded in config.py:

Variable Default Description
DISCORD_BOT_TOKEN (required) Discord application bot token
BACKEND_URL http://localhost:8000/api/v1 Backend API base URL (with /api/v1)
DEFAULT_MONSTER betty Default monster character name
DEFAULT_LANGUAGE EN Fallback language code

Docker Deployment

FROM python:3.12-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "bot.py", "-l", "FR"]

Build and run:

docker build -t discord-bot -f discord_bot/Dockerfile discord_bot/
docker run -e DISCORD_BOT_TOKEN="..." -e BACKEND_URL="https://..." discord-bot

Note: The bot runs as a single long-lived process. For production, consider running it behind a process manager (systemd, supervisord) or deploying to a cloud VM.

Health Server

A lightweight HTTP health check server runs on port 8080 in a daemon thread:

HealthServer().start()  # starts before client.run()

The server responds 200 OK with body OK to any GET request. This is useful for: - Kubernetes/Docker health probes - Cloud load balancer health checks - Monitoring uptime

Error Handling

The @handle_errors decorator wraps all async functions, catching exceptions and sending a user-facing error message:

@handle_errors
async def call_backend_chat(...):
    ...

On failure, the user sees: "An error occurred while processing your request". Errors are logged to stdout with [ERROR] function_name failed: exception_message.

If the decorator cannot find a channel to send to, it falls back to printing the error.

Comparison: bot.py vs bot_language_choice.py

Feature bot.py (current) bot_language_choice.py (legacy)
Chat endpoint POST /chat (unified) POST /chat, POST /chat/emotion, POST /chat/dropdown
Interactive prompts Unified InteractivePrompt with prompt_style Separate emotion_options / dropdown_options
Auth Mock-IDP → bearer token with consent auto-registration Direct user_id in request body (no auth)
Monster support All monsters via emoji name match Betty only (hardcoded)
Language langid detection + --language override langid detection only
Feedback Full feedbackView with thumbs + modal + new session Basic feedbackView (no new session button)
Error handling @handle_errors decorator with recovery @handle_errors with recovery
Health server Included Not included
Button UX Row-of-4 layout, green highlight, disable-on-text Row-of-4 for emotions only, no disable-on-text

bot_language_choice.py is deprecated and kept for reference only. All active development targets bot.py.

Limitations & Future Work

  • No session persistence: Sessions are lost on bot restart. Consider Redis or database-backed session store.
  • No SSE streaming: Responses are delivered as complete messages (no token-by-token streaming like the web frontend).
  • Single monster emoji per name: If multiple servers have different emoji versions, only the first found is used.
  • Hardcoded age class: All users are assumed "teenager 15-17". Future versions could ask the user.
  • No rich embeds: Messages use plain text with emoji prefixes. Discord Embeds could provide richer formatting.