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
| Aspect | Details |
|---|---|
| Repo | glideapps/fake-discord |
| What it does | Multi-tenant fake Discord REST API server. Impersonates Discord's API so you can run integration tests without real Discord. |
| Routes | Discord API (/api/v10/..., /oauth2/...) + test control routes (/_test/...) for setup/assertions |
| Auth | Ed25519 signing (matches real Discord); tenant resolution via Bot token, Bearer token, or client_id |
| Storage | SQLite (D1); state persists across restarts |
| Hosted | https://fake-discord.flingit.run (or self-host) |
| Fit | HTTP-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
| Aspect | Details |
|---|---|
| Repo | blurplejs/blurple |
| Docs | https://blurple.js.org |
| What it does | Mock server for Discord APIs for integration testing |
| Fit | Less documented; may target discord.js. Worth checking if it exposes raw HTTP interaction endpoints. |
Option C: Minicord
| Aspect | Details |
|---|---|
| Repo | minicord-dev/minicord |
| What it does | Local emulator for Discord API + gateway |
| Fit | Early-stage; gateway-focused. Ledgerline uses webhooks, not gateway. May not support interaction webhooks. |
Option D: Discordemu
| Aspect | Details |
|---|---|
| Repo | vcokltfre/discordemu |
| What it does | Emulation of Discord gateway and API |
| Fit | Gateway-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/interactionswith mock payloadsdiscord-command-router.spec.ts— Unit tests with mocked services
Enhancement: Add a small script or Vitest helper that:
- Builds valid Discord interaction payloads (slash command, MESSAGE_COMPONENT, MODAL_SUBMIT)
- Signs them with a test key (or skip verification in test mode)
- POSTs to
/discord/interactions - 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
| Layer | Tool | Purpose |
|---|---|---|
| Unit | Vitest + mocks | Router logic, command handlers |
| Integration | In-process POST + mock payloads | Full request/response flow; response shape |
| E2E (optional) | fake-discord or cloudflared + real Discord | Real 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
DiscordCommandResultsupportscontent,embeds,componentsMESSAGE_COMPONENT(type 3) andMODAL_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
-
Extend
DiscordCommandResult(and order router's equivalent):type DiscordCommandResult = {content?: stringembeds?: DiscordEmbed[]components?: DiscordActionRow[]flags?: number // e.g. EPHEMERAL} -
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)
-
Update endpoint to pass through
embedsandcomponentsin the responsedatawhen 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 orderorder:step:shipping— Go to shipping steporder:step:payment— Go to payment stepaddress:add— Open address modalproduct: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
-
Extend interaction schema to parse
type: 3anddata.custom_id,data.component_type,data.values(for select). -
Add component router in
discord-command-router.ts:executeComponent: async (input: {tenantIdguildIdchannelIddiscordUserIdcustomIdvalues?message?}) => Promise<DiscordCommandResult> -
Parse
custom_idand dispatch toorder:add,order:step,address:add, etc. -
Endpoint branches on
interaction.type:2→ existingcommands.execute(slash)3→commands.executeComponent(button/select)
Phase 2.4: MODAL Handler
Files: Same as above
-
Extend schema for
type: 5(MODAL_SUBMIT),data.custom_id,data.components(text inputs). -
Add modal router:
executeModal: async (input: {tenantIddiscordUserIdcustomIdcomponents: { custom_id; value }[]}) => Promise<DiscordCommandResult> -
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_SUBMIT→executeModal→ process and respond with result message
- e.g.
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:
/order-start→ Embed "Start your order" + button "Browse products"- "Browse products" → Product list with "Add" buttons (or reuse
/product-listwith components) - After adding items → "Proceed to shipping" button
- Shipping → See Phase 2.6a (address reuse flow)
- Payment → Buttons for each method (PayPal, Cash App, etc.) or select menu
- 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
-
User reaches shipping step (e.g. clicks "Proceed to shipping" or runs
/order-shipping). -
Look up customer by
discordUserId. If no customer, prompt/registerfirst. -
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}
- Select menu or buttons: "Home (default)", "Work", etc. — one per address.
-
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" →
setShippingwith selected address, advance to payment.custom_id: order:address:confirm:{addressId} - Button "Edit" → open modal pre-filled.
custom_id: order:address:edit:{addressId}
- Button "Use this address" →
-
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 supportvalueon text inputs). custom_id:order:address:submit(new) ororder:address:submit:{addressId}(edit).
-
On modal submit:
- Validate, call
setShippingwith submitted address. - Optional: If new address (not edit), offer "Save for next time?" button →
customerService.addAddresson confirm. - Advance to payment step.
- Validate, call
-
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_id | Action |
|---|---|
order:use-address:{addressId} | User selected address from list; show confirm embed |
order:address:confirm:{addressId} | User confirmed; set shipping, advance |
order:address:new | Open modal for new address |
order:address:edit:{addressId} | Open modal pre-filled with address |
order:address:submit | Modal submit (new address) |
order:address:submit:{addressId} | Modal submit (edit existing) |
Backend wiring
setShippingalready acceptsRecord<string, unknown>; pass the address object fromCustomer.addresseswhen using a saved address.- Component handler must resolve customer by
discordUserId, then look up address byaddressIdfromcustomer.addressesbefore callingsetShipping. - Order flow needs
customerService(orfindByDiscordId) 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):
- Respond immediately with
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE(type 5) - Use
PATCH /webhooks/{application_id}/{interaction_token}/messages/@originalto update with embeds/components when ready
Requires: DISCORD_BOT_TOKEN and interaction token from the request.
Implementation Order
| Step | Description | Effort |
|---|---|---|
| 2.1 | Response format (embeds, components) | Small |
| 2.2 | custom_id convention | Small |
| 2.3 | MESSAGE_COMPONENT handler | Medium |
| 2.4 | MODAL handler | Medium |
| 2.5 | Product list embeds + buttons | Medium |
| 2.6 | Order flow buttons | Large |
| 2.6a | Shipping: address reuse, confirm, edit | Medium |
| 2.7 | Address modal | Small |
| 2.8 | Deferred responses | Small |
Testing Strategy
- Unit: Command handlers return correct
DiscordCommandResult(content, embeds, components). - Integration: POST interaction payloads (slash, MESSAGE_COMPONENT, MODAL_SUBMIT) to
/discord/interactions; assert response shape. - Payload builders: Shared helpers to construct valid Discord interaction bodies for tests.
- Optional: Run against fake-discord for full API simulation; or use cloudflared + real Discord for manual E2E.