Skip to main content
Answer Overflow’s Discord bot is built with discord.js and Effect for robust error handling and service composition.

Project Structure

The bot code is located in apps/discord-bot/src/:
apps/discord-bot/src/
├── commands/           # Slash commands and context menus
├── services/          # Background services (indexing, auto-thread, etc.)
├── sync/              # Data synchronization logic
├── core/              # Discord service and utilities
└── utils/             # Helper functions

Creating Commands

Commands use context menu interactions for marking solutions, managing settings, etc.

Command Structure

Example from apps/discord-bot/src/commands/mark-solution.ts:
import { Database } from "@packages/database/database";
import type { ContextMenuCommandInteraction } from "discord.js";
import { MessageFlags } from "discord.js";
import { Effect, Metric } from "effect";
import { Discord } from "../core/discord-service";
import { commandExecuted } from "../metrics";

export const handleMarkSolutionCommand = Effect.fn("mark_solution_command")(
  function* (interaction: ContextMenuCommandInteraction) {
    yield* Effect.annotateCurrentSpan({
      "discord.guild_id": interaction.guildId ?? "unknown",
      "discord.channel_id": interaction.channelId ?? "unknown",
    });
    yield* Metric.increment(commandExecuted("mark_solution"));

    const database = yield* Database;
    const discord = yield* Discord;

    // Defer reply immediately
    yield* discord.callClient(() =>
      interaction.deferReply({ flags: MessageFlags.Ephemeral })
    ).pipe(Effect.withSpan("defer_reply"));

    // Fetch target message
    const targetMessage = yield* discord.callClient(() =>
      interaction.channel?.messages.fetch(interaction.targetId)
    );

    if (!targetMessage) {
      yield* discord.callClient(() =>
        interaction.editReply({ content: "Failed to fetch message" })
      );
      return;
    }

    // Get server data
    const server = yield* database.private.servers
      .getServerByDiscordId({ discordId: BigInt(targetMessage.guildId) })
      .pipe(Effect.withSpan("get_server"));

    // Process mark solution logic
    // ...
  }
);

Key Patterns

1

Use Effect.fn for command handlers

This enables distributed tracing and better error handling:
export const handleCommand = Effect.fn("command_name")(function* (interaction) {
  // Command logic using generator syntax
});
2

Always defer replies

Discord requires responses within 3 seconds:
yield* discord.callClient(() =>
  interaction.deferReply({ flags: MessageFlags.Ephemeral })
);
3

Add distributed tracing

Annotate spans for better observability:
yield* Effect.annotateCurrentSpan({
  "discord.guild_id": interaction.guildId,
  "discord.user_id": interaction.user.id,
});
4

Track metrics

Record command usage:
yield* Metric.increment(commandExecuted("command_name"));

Event Handling

The bot listens to Discord events and processes them using services.

Message Events

From apps/discord-bot/src/services/indexing.ts:
import { Effect, Schedule, Duration } from "effect";
import { Discord } from "../core/discord-service";
import { Database } from "@packages/database/database";

export function indexChannel(channel: TextChannel) {
  return Effect.gen(function* () {
    const discord = yield* Discord;
    const database = yield* Database;

    // Get channel settings
    const settings = yield* database.private.channels
      .getChannelSettings({ channelId: BigInt(channel.id) });

    if (!settings?.indexingEnabled) {
      return;
    }

    // Fetch messages
    let lastMessageId = settings.lastIndexedSnowflake ?? 0n;
    const messages: Message[] = [];

    yield* discord.callClient(async () => {
      const fetched = await channel.messages.fetch({
        limit: 100,
        after: lastMessageId.toString(),
      });
      messages.push(...fetched.values());
    });

    // Process messages in batches
    const batches = chunk(messages, 10);
    
    for (const batch of batches) {
      yield* database.private.messages.upsertManyMessages({
        messages: batch.map(toUpsertMessageArgs),
      });
      
      // Rate limiting
      yield* Effect.sleep(Duration.millis(200));
    }
  });
}

Working with Effect Services

Effect provides dependency injection and error handling.

Discord Service

The Discord service wraps discord.js with Effect:
import { Effect, Context } from "effect";
import type { Client } from "discord.js";

export class Discord extends Context.Tag("Discord")<
  Discord,
  {
    readonly client: Client;
    readonly callClient: <A>(fn: () => A | Promise<A>) => Effect.Effect<A>;
  }
>() {}

// Usage in commands
const discord = yield* Discord;
const message = yield* discord.callClient(() =>
  channel.messages.fetch(messageId)
);

Database Service

The Database service provides typed access to Convex:
import { Database } from "@packages/database/database";

// In an Effect generator
const database = yield* Database;

const server = yield* database.private.servers.getServerByDiscordId({
  discordId: BigInt(guildId),
});

Error Handling

Effect enables comprehensive error handling:
import {
  catchAllWithReport,
  catchAllDefectWithReport,
} from "../utils/error-reporting";

export const processMessage = Effect.gen(function* () {
  // Processing logic
}).pipe(
  Effect.withSpan("process_message"),
  catchAllWithReport((error) =>
    Effect.gen(function* () {
      yield* Effect.logError("Failed to process message", error);
      // Graceful degradation
    })
  )
);

Background Services

Services run continuously to handle tasks like indexing and auto-threading.

Creating a Service

Example structure:
import { Effect, Schedule, Duration, Layer } from "effect";
import { Discord } from "../core/discord-service";

export const AutoThreadService = Layer.effectDiscard(
  Effect.gen(function* () {
    const discord = yield* Discord;
    
    yield* Effect.logInfo("Starting auto-thread service");

    // Listen to message create events
    discord.client.on("messageCreate", (message) => {
      if (message.author.bot) return;
      
      const processEffect = Effect.gen(function* () {
        // Check if channel has auto-thread enabled
        const shouldCreate = yield* checkAutoThreadSettings(
          message.channel
        );
        
        if (shouldCreate) {
          yield* createThread(message);
        }
      }).pipe(
        Effect.catchAll((error) =>
          Effect.logError("Auto-thread error", error)
        )
      );

      // Run the effect
      Effect.runPromise(processEffect.pipe(
        Effect.provide(/* layers */)
      ));
    });
  })
).pipe(Layer.provide(/* dependencies */));

Service Composition

Combine services with layers:
import { Layer } from "effect";

const MainLayer = Layer.mergeAll(
  DiscordLayer,
  DatabaseLayer,
  IndexingServiceLayer,
  AutoThreadServiceLayer
);

// Start the bot
Effect.runPromise(
  program.pipe(Effect.provide(MainLayer))
);

Data Synchronization

The bot syncs Discord data to Convex.

Syncing Servers

From apps/discord-bot/src/sync/server.ts:
import { Effect } from "effect";
import type { Guild } from "discord.js";

export const syncGuild = (guild: Guild) =>
  Effect.gen(function* () {
    const database = yield* Database;

    // Upsert server
    yield* database.private.servers.upsertServer({
      discordId: BigInt(guild.id),
      name: guild.name,
      icon: guild.icon,
      description: guild.description,
      approximateMemberCount: guild.memberCount,
    });

    // Sync channels
    const channels = Array.from(guild.channels.cache.values());
    
    yield* Effect.forEach(channels, (channel) =>
      syncChannel(channel),
      { concurrency: 5 }
    );
  });

Testing

Use Vitest with Effect’s testing utilities.

Unit Tests

import { expect, it } from "@effect/vitest";
import { Effect, Layer } from "effect";
import { syncGuild } from "./server";

it.scoped("syncs guild data", () =>
  Effect.gen(function* () {
    const database = yield* Database;
    const discord = yield* DiscordClientMock;

    const guild = discord.utilities.createMockGuild({
      description: "Test guild",
    });

    yield* syncGuild(guild);

    const server = yield* database.private.servers.getServerByDiscordId({
      discordId: BigInt(guild.id),
    });

    expect(server).not.toBeNull();
    expect(server?.name).toBe(guild.name);
  }).pipe(Effect.provide(TestLayer))
);

E2E Tests

From apps/bot-e2e/tests/smoke.test.ts:
import { expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { Selfbot, E2ELayer } from "../src/core";

it.scopedLive(
  "can invoke mark solution command",
  () =>
    Effect.gen(function* () {
      const selfbot = yield* Selfbot;
      yield* selfbot.client.login();

      const guild = yield* selfbot.getGuild("Test Server");
      const channel = yield* selfbot.getTextChannel(guild, "playground");

      const message = yield* selfbot.sendMessage(channel, "Test message");
      const command = yield* selfbot.findCommand(guild.id, "✅ Mark Solution");

      yield* selfbot.invokeMessageContextMenu(
        guild.id,
        channel.id,
        message.id,
        command
      );
    }).pipe(Effect.provide(E2ELayer)),
  { timeout: 30000 }
);

Troubleshooting

Bot not responding to commands

  1. Check if the bot has the required permissions
  2. Verify commands are registered:
cd apps/discord-bot
bun run dev

Rate limiting errors

Add delays between Discord API calls:
yield* Effect.sleep(Duration.millis(500));

Effect runtime errors

Ensure all services are provided:
Effect.runPromise(
  program.pipe(
    Effect.provide(DiscordLayer),
    Effect.provide(DatabaseLayer)
  )
);

Next Steps

Build docs developers (and LLMs) love