Skip to main content

Discord Rich UX & Local Testing Plan

Plan for guided Discord bot UX (embeds, buttons, modals) and local/automated testing infrastructure.


Part 1: Discord "LocalStack" Equivalents

There is no official Discord-provided local emulator. Third-party options exist but are less mature than AWS LocalStack.

Option A: Fake Discord (Glide) — Best fit for Ledgerline

AspectDetails
Repoglideapps/fake-discord
What it doesMulti-tenant fake Discord REST API server. Impersonates Discord's API so you can run integration tests without real Discord.
RoutesDiscord API (/api/v10/..., /oauth2/...) + test control routes (/_test/...) for setup/assertions
AuthEd25519 signing (matches real Discord); tenant resolution via Bot token, Bearer token, or client_id
StorageSQLite (D1); state persists across restarts
Hostedhttps://fake-discord.flingit.run (or self-host)
FitHTTP-based interaction webhooks; no discord.js dependency. Point your app at fake-discord instead of Discord; use /_test/ to simulate interactions.

Caveat: Project has low stars/activity. Verify it supports interaction webhooks and the routes you need before committing.

Option B: Blurple

AspectDetails
Repoblurplejs/blurple
Docshttps://blurple.js.org
What it doesMock server for Discord APIs for integration testing
FitLess documented; may target discord.js. Worth checking if it exposes raw HTTP interaction endpoints.

Option C: Minicord

AspectDetails
Repominicord-dev/minicord
What it doesLocal emulator for Discord API + gateway
FitEarly-stage; gateway-focused. Ledgerline uses webhooks, not gateway. May not support interaction webhooks.

Option D: Discordemu

AspectDetails
Repovcokltfre/discordemu
What it doesEmulation of Discord gateway and API
FitGateway-oriented; similar caveats to Minicord.

Option E: WireMock + LocalStack (Generic)

LocalStack's WireMock extension can mock any HTTP API. You could:

  • Define WireMock stubs for Discord interaction webhooks
  • Your app receives POSTs; you control request/response
  • No Discord-specific features; you must hand-craft interaction payloads

Pros: Full control, no dependency on Discord emulators.
Cons: Manual setup; no real Discord client simulation.

Option F: In-Process Mock (Current Approach)

Ledgerline already has:

  • discord.endpoint.e2e.spec.ts — POSTs to /discord/interactions with mock payloads
  • discord-command-router.spec.ts — Unit tests with mocked services

Enhancement: Add a small script or Vitest helper that:

  1. Builds valid Discord interaction payloads (slash command, MESSAGE_COMPONENT, MODAL_SUBMIT)
  2. Signs them with a test key (or skip verification in test mode)
  3. POSTs to /discord/interactions
  4. Asserts on response shape (content, embeds, components)

Pros: No external services; works in CI; fast.
Cons: No real Discord client; no visual verification.


Recommendation: Layered Testing

LayerToolPurpose
UnitVitest + mocksRouter logic, command handlers
IntegrationIn-process POST + mock payloadsFull request/response flow; response shape
E2E (optional)fake-discord or cloudflared + real DiscordReal interaction flow; optional for CI

Phase 1: Strengthen in-process integration tests (interaction payload builders, response assertions).
Phase 2: Evaluate fake-discord if you need a full Discord-like environment for automation.


Part 2: Rich UX Implementation Plan

Current State

  • DiscordCommandResult = { content: string }
  • Response: { type: 4, data: { content, flags: 64 } }
  • All interactions: APPLICATION_COMMAND (type 2)

Target State

  • DiscordCommandResult supports content, embeds, components
  • MESSAGE_COMPONENT (type 3) and MODAL_SUBMIT (type 5) handled
  • Product list: embeds, "Add to order" buttons
  • Order flow: step-by-step with buttons; modals for forms
  • Address add: modal instead of slash command

Phase 2.1: Response Format Extension

Files: discord-command-router.ts, order.command.ts, discord.endpoint.ts

  1. Extend DiscordCommandResult (and order router's equivalent):

    type DiscordCommandResult = {
    content?: string
    embeds?: DiscordEmbed[]
    components?: DiscordActionRow[]
    flags?: number // e.g. EPHEMERAL
    }
  2. Define Discord types (new file src/data/discord-commands/discord-embed.types.ts):

    • DiscordEmbed (title, description, url, color, thumbnail, image, fields, footer)
    • DiscordActionRow (type 1, components: array)
    • DiscordButton (type 2, style, label, custom_id, url?)
    • DiscordStringSelect (type 3, custom_id, options, placeholder)
  3. Update endpoint to pass through embeds and components in the response data when present.


Phase 2.2: custom_id Convention

Purpose: Route button clicks and select menu choices to the right handler.

Format: {prefix}:{action}:{params}

Examples:

  • order:add:prod_abc123 — Add product to order
  • order:step:shipping — Go to shipping step
  • order:step:payment — Go to payment step
  • address:add — Open address modal
  • product:select:prod_xyz — Product selected from menu

Constraints: custom_id max 100 chars.


Phase 2.3: MESSAGE_COMPONENT Handler

Files: discord.endpoint.ts, discord-command-router.ts

  1. Extend interaction schema to parse type: 3 and data.custom_id, data.component_type, data.values (for select).

  2. Add component router in discord-command-router.ts:

    executeComponent: async (input: {
    tenantId
    guildId
    channelId
    discordUserId
    customId
    values?
    message?
    }) => Promise<DiscordCommandResult>
  3. Parse custom_id and dispatch to order:add, order:step, address:add, etc.

  4. Endpoint branches on interaction.type:

    • 2 → existing commands.execute (slash)
    • 3commands.executeComponent (button/select)

Phase 2.4: MODAL Handler

Files: Same as above

  1. Extend schema for type: 5 (MODAL_SUBMIT), data.custom_id, data.components (text inputs).

  2. Add modal router:

    executeModal: async (input: {
    tenantId
    discordUserId
    customId
    components: { custom_id; value }[]
    }) => Promise<DiscordCommandResult>
  3. Response type 9 (MODAL) when we want to show a form:

    • e.g. /address-add → respond with { type: 9, data: { custom_id: 'address:add', title: 'Add Address', components: [text inputs] } }
    • User submits → MODAL_SUBMITexecuteModal → process and respond with result message

Phase 2.5: Product List with Embeds + Buttons

Command: /product-list

Current: Plain text list.

New:

  • One embed per product (or paginated): title, description, price, optional thumbnail
  • Action row with "Add to order" button per product (custom_id: order:add:{productId})
  • Optional: select menu "Add to order" with product options

Phase 2.6: Order Flow with Buttons

Commands: /order-start, /order-item, /order-shipping, /order-payment, /order-submit

Current: User runs separate slash commands with many options.

New:

  1. /order-start → Embed "Start your order" + button "Browse products"
  2. "Browse products" → Product list with "Add" buttons (or reuse /product-list with components)
  3. After adding items → "Proceed to shipping" button
  4. Shipping → See Phase 2.6a (address reuse flow)
  5. Payment → Buttons for each method (PayPal, Cash App, etc.) or select menu
  6. Submit → "Confirm order" button → final summary + payment instructions

Phase 2.6a: Shipping Step — Address Reuse & Edit

Goal: Reuse saved addresses whenever possible, with option to edit and clear visibility of what will be used.

Data source: Customer.addresses and Customer.defaultAddressId (already persisted via /address-add).

Flow

  1. User reaches shipping step (e.g. clicks "Proceed to shipping" or runs /order-shipping).

  2. Look up customer by discordUserId. If no customer, prompt /register first.

  3. If customer has saved addresses:

    • Embed: "Select shipping address" with a summary of each saved address (name, line1, city, state).
    • Components:
      • Select menu or buttons: "Home (default)", "Work", etc. — one per address. custom_id: order:use-address:{addressId}
      • Button "Enter new address" → opens modal. custom_id: order:address:new
      • Button "Edit [address label]" → opens modal pre-filled with that address. custom_id: order:address:edit:{addressId}
  4. When user selects a saved address (order:use-address:{addressId}):

    • Confirm embed: Show the full address that will be used (name, line1, line2, city, state, postalCode, country).
    • Components:
      • Button "Use this address" → setShipping with selected address, advance to payment. custom_id: order:address:confirm:{addressId}
      • Button "Edit" → open modal pre-filled. custom_id: order:address:edit:{addressId}
  5. When user clicks "Enter new address" or "Edit":

    • Respond with MODAL (type 9): text inputs for name, line1, line2, city, state, postalCode, country, phone.
    • For Edit: pre-fill from Customer.addresses (Discord modals support value on text inputs).
    • custom_id: order:address:submit (new) or order:address:submit:{addressId} (edit).
  6. On modal submit:

    • Validate, call setShipping with submitted address.
    • Optional: If new address (not edit), offer "Save for next time?" button → customerService.addAddress on confirm.
    • Advance to payment step.
  7. If customer has no saved addresses:

    • Skip select; show "Enter shipping address" → modal directly.
    • On submit, optionally prompt "Save for next time?".

UX principles

  • Show before confirm: User always sees the full address that will be used before committing.
  • Edit path: Every choice has an "Edit" option so they can correct mistakes.
  • Reuse by default: Saved addresses are surfaced first; new address is opt-in.

custom_id additions

custom_idAction
order:use-address:{addressId}User selected address from list; show confirm embed
order:address:confirm:{addressId}User confirmed; set shipping, advance
order:address:newOpen modal for new address
order:address:edit:{addressId}Open modal pre-filled with address
order:address:submitModal submit (new address)
order:address:submit:{addressId}Modal submit (edit existing)

Backend wiring

  • setShipping already accepts Record<string, unknown>; pass the address object from Customer.addresses when using a saved address.
  • Component handler must resolve customer by discordUserId, then look up address by addressId from customer.addresses before calling setShipping.
  • Order flow needs customerService (or findByDiscordId) in the component handler deps to resolve addresses.

Phase 2.7: Address Add as Modal

Command: /address-add

Current: Slash command with many options.

New:

  • /address-add → Respond with MODAL (type 9): title "Add Address", text inputs for name, line1, line2, city, state, postalCode, country, phone
  • On submit → executeModal → validate, persist, respond with success embed/message

Phase 2.8: Deferred Responses (Optional)

For slow operations (e.g. fetching many products):

  1. Respond immediately with DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE (type 5)
  2. Use PATCH /webhooks/{application_id}/{interaction_token}/messages/@original to update with embeds/components when ready

Requires: DISCORD_BOT_TOKEN and interaction token from the request.


Implementation Order

StepDescriptionEffort
2.1Response format (embeds, components)Small
2.2custom_id conventionSmall
2.3MESSAGE_COMPONENT handlerMedium
2.4MODAL handlerMedium
2.5Product list embeds + buttonsMedium
2.6Order flow buttonsLarge
2.6aShipping: address reuse, confirm, editMedium
2.7Address modalSmall
2.8Deferred responsesSmall

Testing Strategy

  1. Unit: Command handlers return correct DiscordCommandResult (content, embeds, components).
  2. Integration: POST interaction payloads (slash, MESSAGE_COMPONENT, MODAL_SUBMIT) to /discord/interactions; assert response shape.
  3. Payload builders: Shared helpers to construct valid Discord interaction bodies for tests.
  4. Optional: Run against fake-discord for full API simulation; or use cloudflared + real Discord for manual E2E.

References