Skip to main content
The Answer Overflow Discord bot is built with discord.js 14 and Effect, providing a functional, composable architecture for managing Discord communities focused on Q&A.

Architecture Overview

The bot uses Effect for dependency injection, error handling, and functional composition. All services are organized as Effect Layers that can be composed together.

Core Stack

  • discord.js 14.25+ - Discord API client
  • Effect - Functional effects system for composition and error handling
  • Reacord - React-based UI components for Discord interactions
  • Convex - Real-time database (via @packages/database)
  • Sentry - Error tracking and observability

Project Structure

src/
├── bot.ts              # Main bot composition with all layers
├── commands/           # Slash and context menu commands
├── interactions/       # Button, modal, and select menu handlers
├── services/           # Background services (indexing, DM forwarding, etc.)
├── sync/              # Data sync handlers (server, channel, message, user)
├── core/              # Core services (Discord client wrapper)
└── utils/             # Shared utilities and error handling

Bot Service Layer

The bot composes all feature layers in src/bot.ts:38:
export const BotLayers = Layer.mergeAll(
  // Data sync layers
  ServerParityLayer,
  ChannelParityLayer,
  MessageParityLayer,
  UserParityLayer,
  BotPermissionsSyncLayer,
  
  // Service layers
  AutoThreadHandlerLayer,
  IndexingHandlerLayer,
  DMForwardingHandlerLayer,
  StatusUpdateHandlerLayer,
  
  // Command layers
  MarkSolutionCommandHandlerLayer,
  LeaderboardCommandHandlerLayer,
  ChannelSettingsCommandHandlerLayer,
  IndexCommandHandlerLayer,
  
  // Interaction layers
  ConsentButtonHandlerLayer,
  QuickActionCommandHandlerLayer,
  // ... more layers
);

Discord Service

The Discord service (src/core/discord-service.ts) wraps discord.js with Effect for type-safe error handling and observability.

Key Features

  • Automatic metrics - API calls and errors tracked via Prometheus metrics
  • Effect-based event handlers - Event handlers return Effect for composable error handling
  • Fiber tracking - Active event handler fibers are tracked for graceful shutdown
  • Unified error types - DiscordAPIError and UnknownDiscordError for all Discord operations

Usage Example

import { Discord } from "./core/discord-service";
import { Effect } from "effect";

const myFeature = Effect.gen(function* () {
  const discord = yield* Discord;
  
  // Call Discord API with automatic error handling
  const guild = yield* discord.getGuild("123456789");
  
  // Listen to events with Effect handlers
  yield* discord.client.on("messageCreate", (message) =>
    Effect.gen(function* () {
      if (message.author.bot) return;
      // Handle message
    })
  );
});

Commands

Mark Solution

Location: src/commands/mark-solution.ts Context menu command that marks a message as the solution to a thread question.
  • Permission checking (question author, ManageThreads, or Admin)
  • Automatic tag management (apply solution tag, remove “needs help” tags)
  • Thread archiving/locking based on server preferences
  • Checkmark reactions on solution and question
  • Analytics tracking via PostHog
  • 25-second timeout with error reporting

Channel Settings

Location: src/commands/channel-settings.ts Slash command that generates a dashboard link for channel configuration.
// Usage: /channel-settings
export const handleChannelSettingsCommand = Effect.fn(
  "channel_settings_command"
)(function* (interaction: ChatInputCommandInteraction) {
  const database = yield* Database;
  const discord = yield* Discord;
  
  const dashboardUrl = getDashboardUrl(interaction.guildId, channelId);
  
  const embed = new EmbedBuilder()
    .setTitle("Channel Settings")
    .setDescription(`Configure settings for <#${channelId}> in the dashboard.`)
    .setColor("#89D3F8");
    
  const actionRow = new ActionRowBuilder<MessageActionRowComponentBuilder>()
    .addComponents(
      new ButtonBuilder()
        .setLabel("Open Dashboard")
        .setStyle(ButtonStyle.Link)
        .setURL(dashboardUrl)
    );
    
  yield* discord.callClient(() =>
    interaction.reply({ embeds: [embed], components: [actionRow] })
  );
});

Leaderboard

Location: src/commands/leaderboard.ts Displays top question solvers for the server.
// Usage: /leaderboard [ephemeral:boolean]
const topSolvers = yield* database.private.messages
  .getTopQuestionSolversByServerId({
    serverId: server.discordId,
    limit: 10,
  });

const embedDescription = topSolvers
  .map((solver, i) => {
    const medal = medalMap.get(i); // 🥇🥈🥉 for top 3
    return `${medal}: <@${solver.authorId}> - ${solver.count} solved`;
  })
  .join("\n");

Index Command

Location: src/commands/index-command.ts Superuser-only DM command for triggering manual indexing.
1

Commands

  • !index start all - Index all servers
  • !index start <serverId> - Index specific server
  • !index status - Check if indexing is running
  • !index help - Show help message
2

Locking

Uses Effect’s Semaphore for mutual exclusion:
const acquired = yield* indexingLock.withPermitsIfAvailable(1)(
  Effect.gen(function* () {
    yield* runIndexingCore();
    return true;
  })
);

if (Option.isNone(acquired)) {
  yield* discord.callClient(() =>
    message.reply("Indexing is already in progress.")
  );
}

Services

Indexing Service

Location: src/services/indexing.ts Backfills messages from Discord into the database.
  • Iterates through all guilds the bot is in
  • Fetches messages from enabled channels
  • Batches upserts to database
  • Uses semaphore for concurrency control

Auto Thread Service

Location: src/services/auto-thread.ts Automatically creates threads on messages in configured channels.

DM Forwarding

Location: src/services/dm-forwarding.ts Forwards DMs to the bot to a configured channel for support.

Gateway Health

Location: src/services/gateway-health.ts Monitors Discord gateway connection health and reports metrics.

Sync Services

Sync services maintain data parity between Discord and the database.

Server Sync

Location: src/sync/server.ts Listens to:
  • guildCreate - Bot joins new server
  • guildUpdate - Server name/icon changes
  • guildDelete - Bot leaves server

Channel Sync

Location: src/sync/channel.ts Syncs channel creates, updates, deletes, and thread lifecycle events.

Message Sync

Location: src/sync/message.ts Syncs message creates, updates, and deletes to database.

User Sync

Location: src/sync/user.ts Tracks user join/leave events and profile updates.

Metrics

The bot exposes Prometheus metrics:
// API call tracking
export const discordApiCalls = Metric.counter("discord_api_calls_total");
export const discordApiErrors = Metric.counter("discord_api_errors_total");

// Command execution
export const commandExecuted = (name: string) =>
  Metric.counter("command_executed_total", { tags: { command: name } });

// Business metrics
export const solutionsMarked = Metric.counter("solutions_marked_total");

Error Handling

All commands use standardized error handling utilities from src/utils/error-reporting.ts:
import { catchAllWithReport, catchAllSilentWithReport } from "../utils/error-reporting";

// Report error to Sentry and handle gracefully
yield* myEffect.pipe(
  catchAllWithReport((error) =>
    Effect.gen(function* () {
      // Show user-friendly error message
    })
  )
);

// Report but don't fail the effect
yield* nonCriticalEffect.pipe(
  catchAllSilentWithReport
);

Development

Running Locally

cd apps/discord-bot
bun run dev  # Runs with --watch for hot reload

Environment Variables

DISCORD_TOKEN=your_bot_token
NEXT_PUBLIC_CONVEX_URL=https://your-convex-url.convex.cloud
SENTRY_DSN=your_sentry_dsn

Testing

bun test           # Run unit tests
bun test:watch     # Watch mode

Deployment

The bot is deployed as a Docker container:
FROM oven/bun:latest
WORKDIR /app
COPY . .
RUN bun install
CMD ["bun", "run", "index.ts"]