Skip to main content
The bot uses dfx (Discord Effects) — an Effect-native Discord library — for all communication with Discord. The @chat/discord package wraps dfx into reusable layers that every feature can consume.

Configuration

DiscordConfig is the entry point for credentials and gateway intent flags.
// packages/discord/src/DiscordConfig.ts
import { DiscordConfig, Intents } from "dfx"
import { Config } from "effect"

export const DiscordConfigLayer = DiscordConfig.layerConfig({
  token: Config.redacted("DISCORD_BOT_TOKEN"),
  gateway: {
    intents: Config.succeed(
      Intents.fromList(["GuildMessages", "MessageContent", "Guilds"]),
    ),
  },
})
  • GuildMessages — receive MESSAGE_CREATE / MESSAGE_UPDATE events in guild channels. Required for AutoThreads and NoEmbed.
  • MessageContent — access the content field of messages sent by other users. Without this intent the content is always an empty string for bots that are not verified.
  • Guilds — receive guild and channel metadata events (GUILD_CREATE, CHANNEL_CREATE, etc.). Required for the channel cache to stay warm.

Gateway — receiving Discord events

DiscordGatewayLayer opens a WebSocket connection to Discord’s gateway using dfx’s DiscordIxLive. It also starts the InteractionsRegistry used by slash commands.
// packages/discord/src/DiscordGateway.ts
import { DiscordIxLive } from "dfx/gateway"
import { Layer } from "effect"
import { DiscordConfigLayer } from "./DiscordConfig.ts"
import { DiscordApplication } from "./DiscordRest.ts"

const DiscordLayer = DiscordIxLive.pipe(
  Layer.provideMerge(NodeHttpClient.layerUndici),
  Layer.provide(NodeSocket.layerWebSocketConstructor),
  Layer.provide(DiscordConfigLayer),
)

export const DiscordGatewayLayer = Layer.merge(
  DiscordLayer,
  DiscordApplication.layer,
)
Features listen to gateway events via gateway.handleDispatch:
// packages/discord-bot/src/AutoThreads.ts
const handleMessages = gateway.handleDispatch(
  "MESSAGE_CREATE",
  Effect.fnUntraced(
    function* (event) {
      if (!isEligibleMessage(event)) return
      const channel = yield* channels.get(event.guild_id!, event.channel_id)
      if (!isEligibleChannel(channel)) return
      // ... create thread
    },
    (effect, event) =>
      Effect.withSpan(effect, "AutoThreads.handleMessages", {
        attributes: { messageId: event.id },
      }),
    Effect.catchCause(Effect.logError),
  ),
)

yield* Effect.forkScoped(handleMessages)

REST API

DiscordRESTMemoryLive provides an in-memory rate-limited HTTP client for the Discord REST API. It is exposed through DiscordApplication, which also fetches the bot’s own application object at startup.
// packages/discord/src/DiscordRest.ts
import { DiscordREST, DiscordRESTMemoryLive } from "dfx"
import { Effect, Layer, ServiceMap } from "effect"
import { DiscordConfigLayer } from "./DiscordConfig.ts"

const DiscordLayer = DiscordRESTMemoryLive.pipe(
  Layer.provide(NodeHttpClient.layerUndici),
  Layer.provide(DiscordConfigLayer),
)

export class DiscordApplication extends ServiceMap.Service<DiscordApplication>()(
  "app/DiscordApplication",
  {
    make: Effect.gen(function* () {
      const rest = yield* DiscordREST
      return yield* rest.getMyApplication()
    }).pipe(Effect.orDie),
  },
) {
  static readonly layer = Layer.effect(this, this.make).pipe(
    Layer.provide(DiscordLayer),
  )
}
Common REST calls used across features:
CallUsed in
rest.createMessageAutoThreads, AiResponse
rest.createThreadFromMessageAutoThreads
rest.updateChannelAutoThreads (rename/archive)
rest.listMessagesAiHelpers (build AI prompt)
rest.getMessageAiHelpers
rest.updateOriginalWebhookMessageAiResponse, Summarizer
rest.deleteOriginalWebhookMessageAiResponse (on error)
rest.getGuildMemberMemberCache

Interaction system (slash commands & components)

All slash commands, buttons, modals, and autocomplete handlers are registered via dfx’s Ix API and the InteractionsRegistry.
// Global slash command
const command = Ix.global(
  {
    name: "summarize",
    description: "Create a summary of the current thread",
    options: [/* ... */],
  },
  Effect.fn("Summarizer.command")(function* (ix) {
    const context = yield* Ix.Interaction      // access the raw interaction
    const small = ix.optionValueOrElse("small", constTrue)
    // ...
    return Ix.response({ type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE, data: { /* ... */ } })
  }),
)
// Button / select handler
const cancel = Ix.messageComponent(
  Ix.id("ai_cancel"),
  Effect.gen(function* () {
    const context = yield* Ix.Interaction
    const data = yield* Ix.MessageComponentData    // access component custom_id
    yield* FiberMap.remove(fiberMap, context.message!.interaction_metadata!.id)
    return Ix.response({ type: Discord.InteractionCallbackTypes.DEFERRED_UPDATE_MESSAGE })
  }),
)
// Modal submit handler
const editSubmit = Ix.modalSubmit(
  Ix.id("edit"),
  Effect.gen(function* () {
    const context = yield* Ix.Interaction
    const title = yield* Ix.modalValue("title")
    yield* rest.updateChannel(context.channel!.id, { name: title })
    return Ix.response({ type: Discord.InteractionCallbackTypes.DEFERRED_UPDATE_MESSAGE })
  }),
)
Handlers are composed with Ix.builder, which chains them and attaches error handling:
const ix = Ix.builder
  .add(command)
  .add(cancel)
  .add(accept)
  .catchTagRespond("PermissionsError", (_) =>
    Effect.succeed(
      Ix.response({
        type: Discord.InteractionCallbackTypes.CHANNEL_MESSAGE_WITH_SOURCE,
        data: {
          flags: Discord.MessageFlags.Ephemeral,
          content: `You don't have permission to ${_.action} this ${_.subject}.`,
        },
      }),
    ),
  )
  .catchAllCause(Effect.logError)

yield* registry.register(ix)
Ix.idStartsWith matches buttons whose custom_id starts with a prefix (used for per-user buttons like edit_{userId} and archive_{userId}), while Ix.id matches an exact custom_id.

Caches

ChannelsCache

A TTL-based cache for Discord channel objects, powered by dfx’s CachePrelude.channels.
// packages/discord-bot/src/ChannelsCache.ts
export class ChannelsCache extends ServiceMap.Service<ChannelsCache>()(
  "app/ChannelsCache",
  {
    make: CachePrelude.channels(
      Cache.memoryTTLParentDriver({
        ttl: Duration.minutes(30),
        strategy: "activity",
      }),
    ),
  },
) {
  static readonly layer = Layer.effect(this, this.make).pipe(
    Layer.provide(DiscordGatewayLayer),
  )
}
strategy: "activity" resets the TTL on each cache hit, so frequently accessed channels stay warm indefinitely while idle ones expire after 30 minutes.

MemberCache

Caches guild member info (nickname, avatar, roles) for up to 1,000 members with a 1-day TTL, backed by Cache.make from Effect core.
// packages/discord/src/MemberCache.ts
const cache = yield* Cache.make({
  capacity: 1000,
  timeToLive: Duration.days(1),
  lookup: ({ guildId, userId }: GetMember) =>
    rest.getGuildMember(guildId, userId),
})

Messages service

Messages provides clean, paginated message retrieval for a channel. It handles thread-starter messages, replaces <@userId> mentions with display names, and normalises code-block markdown.
// packages/discord/src/Messages.ts
const cleanForChannel = (
  channel: Discord.ThreadResponse | Discord.GuildChannelResponse,
) =>
  pipe(
    regularForChannel(channel.id),
    Stream.map((msg) => ({ ...msg, content: cleanupMarkdown(msg.content) })),
    Stream.mapEffect(
      (msg) =>
        Effect.map(
          replaceMentions(channel.guild_id, msg.content),
          (content): Discord.MessageResponse => ({ ...msg, content }),
        ),
      { concurrency: "unbounded" },
    ),
  )
The Summarizer consumes Messages.cleanForChannel to build human-readable thread archives.

Build docs developers (and LLMs) love