Discord Bot

Last updated: 2026-06-16

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 with interactive_value for programmatic branching (no separate emotion/dropdown endpoints)
  • Fully async HTTP via aiohttp (replaces synchronous requests)
  • 6-language i18n for bot-specific UI strings
  • SSE intentionally disabled — no real-time event consumer on the bot side

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                        Discord Bot                               │
│                                                                  │
│  ┌──────────┐  ┌───────────────┐  ┌─────────────────────────┐  │
│  │ bot.py   │  │ bot_feedback  │  │ bot_language_choice.py  │  │
│  │ (main)   │  │ (thumbs/modal)│  │ (LEGACY — deprecated)   │  │
│  └────┬─────┘  └───────┬───────┘  └─────────────────────────┘  │
│       │                │                                         │
│  ┌────┴────┐    ┌──────┴──────┐                                 │
│  │ config  │    │ health_server│                                 │
│  └────┬────┘    └──────┬──────┘                                 │
│       │                │                                         │
│  ┌────┴────┐    ┌──────┴──────┐                                 │
│  │ i18n.json│   │ Dockerfile  │                                 │
│  └─────────┘    └─────────────┘                                 │
│                                                                  │
└──────────────────────────┬──────────────────────────────────────┘
                            │ HTTP (async aiohttp)
                            ▼
              ┌─────────────────────────┐
              │   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, SSE deferred
discord_bot/bot_feedback.py Feedback UI — thumbs-up/down buttons, text modal, backend submission (sync requests)
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, feedback, and steering-text prompts
discord_bot/Dockerfile Docker build for python:3.12-slim
discord_bot/requirements.txt Python dependencies (discord.py, aiohttp, langdetect, langid)
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.

HTTP Client

All backend communication uses a single, globally-shared aiohttp.ClientSession, created in on_ready() and closed on shutdown:

# Global — created once
http_session: aiohttp.ClientSession | None = None

@client.event
async def on_ready():
    global http_session
    http_session = aiohttp.ClientSession()

# Every API call uses: async with http_session.post(...) as resp:

The previous synchronous requests library has been fully replaced. All helper functions (get_bearer_token, start_discord_session, call_backend_chat, call_backend_choice) are async def and use await resp.json() for response parsing.

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,
        "pending_interactive_value": null | "steering_text" | "both"
    }
}
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
pending_interactive_value Button callback (steering_text / both) Defers the backend call until the user types their actual text; consumed by on_message

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

API Helper Functions

The bot uses two distinct HTTP helpers for different message types:

# Free text — no interactive_value
async def call_backend_chat(session_id, user_uuid, message_content):
    async with http_session.post("/chat", json={
        "message": message_content,
        "session_id": session_id
    }) as resp: ...

# Interactive choice — message + interactive_value for backend branching
async def call_backend_choice(selection, value, session_id, user_uuid):
    async with http_session.post("/chat", json={
        "message": selection,
        "interactive_value": value,
        "session_id": session_id
    }) as resp: ...

The interactive_value field (added in Phase 1.5 unified options refactor, epic #249) enables programmatic branching on the backend. The backend routes to the correct agent state based on the value (e.g., "confirmed", "rejected", "steering_text", "both").

Free-Text Messages

When a user sends a text message in DM:

  1. Disable any active choice buttons from the previous interaction
  2. Check for pending_interactive_value in the session — if set (e.g., from a prior “steering_text” button click), route via call_backend_choice(selection=message.content, value=pending_value, ...) so the user’s typed text is sent alongside the programmatic branch value
  3. Otherwise, call_backend_chat() → POST /chat with {"message": content, "session_id": session_id}
  4. 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):
    label = opt["label"]
    value = opt["value"]
    button = discord.ui.Button(label=label, style=ButtonStyle.primary, row=index // 4)

    async def callback(interaction, value=value, btn=button, v=view):
        # Grey out all buttons, highlight clicked one green
        for item in v.children:
            item.disabled = True
        btn.style = ButtonStyle.success
        await interaction.response.edit_message(view=v)

        # Steering-text options: defer the backend call
        if value in ("steering_text", "both"):
            session_data["pending_interactive_value"] = value
            await interaction.followup.send(msgs["type_your_message"])
            return

        # All other options: send immediately
        new_data = await call_backend_choice(
            selection=value, value=value,
            session_id=session_data["session_id"], user_uuid=user_uuid
        )
        await send_backend_response(interaction.channel, new_data, session_data, user_uuid)

    button.callback = callback
    view.add_item(button)
  • Buttons are arranged in rows of 4
  • When clicked: all buttons grey out, the selected one turns green
  • Normal options ("confirmed", "rejected", "more_questions", "explain_better"): value is sent immediately to POST /chat
  • Steering-text options ("steering_text", "both"): the backend call is deferred — pending_interactive_value is stored, a "Type your message" follow-up is sent, and the next free-text message from the user is routed through call_backend_choice with both the text and the stored value
  • 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
type_your_message Follow-up prompt when user selects a steering-text option (e.g., “Écris ton message”)

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",
        "type_your_message": "Écris ton message"
    }
}

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: supports_sse is intentionally false in capabilities — there is no SSE consumer on the bot side. Tool call visibility and real-time monster changes are surfaced via REST response metadata only. Full SSE consumption is deferred (see Discord SSE Integration Plan).
  • bot_feedback.py still uses sync requests: The feedback module has not been migrated to aiohttp — it blocks the event loop briefly on feedback submission.
  • 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.