From 330ae225d4a2951cce3282b1db36b402bc2269ed Mon Sep 17 00:00:00 2001 From: Dean Mauro Date: Fri, 15 May 2026 17:30:26 -0400 Subject: [PATCH 1/2] Add flags support to discord adapter to make ephemeral messaging possible --- packages/adapter-discord/src/index.test.ts | 135 ++++++++++++++++++++- packages/adapter-discord/src/index.ts | 73 ++++++++--- packages/adapter-discord/src/types.ts | 44 +++++++ 3 files changed, 235 insertions(+), 17 deletions(-) diff --git a/packages/adapter-discord/src/index.test.ts b/packages/adapter-discord/src/index.test.ts index 4f20e6f2..2ddbd45e 100644 --- a/packages/adapter-discord/src/index.test.ts +++ b/packages/adapter-discord/src/index.test.ts @@ -10,7 +10,11 @@ import { Actions, Button, Card } from "chat"; import { type Client, Events } from "discord.js"; import { InteractionType } from "discord-api-types/v10"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createDiscordAdapter, DiscordAdapter } from "./index"; +import { + createDiscordAdapter, + DiscordAdapter, + DiscordMessageFlag, +} from "./index"; import { DiscordFormatConverter } from "./markdown"; const AT_ME_REGEX = /\/@me$/; @@ -518,6 +522,63 @@ describe("handleWebhook - APPLICATION_COMMAND", () => { expect(responseBody).toEqual({ type: 5 }); // DeferredChannelMessageWithSource }); + it("sets initial deferred slash command flags from config", async () => { + const flags = vi.fn().mockReturnValue(DiscordMessageFlag.Ephemeral); + const flaggedAdapter = createDiscordAdapter({ + botToken: "test-token", + publicKey: testPublicKey, + applicationId: "test-app-id", + logger: mockLogger, + flags, + }); + + const body = JSON.stringify({ + type: InteractionType.ApplicationCommand, + id: "interaction123", + application_id: "test-app-id", + token: "interaction-token", + version: 1, + guild_id: "guild123", + channel_id: "channel456", + member: { + user: { + id: "user789", + username: "testuser", + discriminator: "0001", + }, + roles: ["role123"], + joined_at: "2021-01-01T00:00:00.000Z", + }, + data: { + id: "cmd123", + name: "test", + type: 1, + }, + }); + const request = createWebhookRequest(body); + + const response = await flaggedAdapter.handleWebhook(request); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + type: 5, + data: { flags: DiscordMessageFlag.Ephemeral }, + }); + expect(flags).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "discord:guild123:channel456", + command: "/test", + interaction: expect.objectContaining({ + id: "interaction123", + member: expect.objectContaining({ roles: ["role123"] }), + }), + text: "", + user: expect.objectContaining({ id: "user789" }), + }) + ); + }); + it("dispatches slash command to chat core", async () => { const processSlashCommand = vi.fn(); await adapter.initialize({ @@ -3315,6 +3376,78 @@ describe("legacy gateway interactions", () => { ); }); + it("sets gateway deferred slash command flags from config", async () => { + const processSlashCommand = vi.fn(); + const flags = vi.fn().mockReturnValue(DiscordMessageFlag.Ephemeral); + const adapter = new TestGatewayDiscordAdapter({ + botToken: "test-token", + publicKey: testPublicKey, + applicationId: "test-app-id", + logger: mockLogger, + flags, + }); + + await adapter.initialize({ + handleIncomingMessage: vi.fn(), + processSlashCommand, + processAction: vi.fn(), + processReaction: vi.fn(), + } as unknown as ChatInstance); + + const client = createGatewayClient(); + const deferReply = vi.fn().mockResolvedValue(undefined); + + adapter.listen(client); + client.emit(Events.InteractionCreate, { + id: "interaction123", + applicationId: "test-app-id", + token: "interaction-token", + type: InteractionType.ApplicationCommand, + version: 1, + guildId: "guild123", + channelId: "channel456", + channel: { + id: "channel456", + type: 0, + }, + user: { + id: "user789", + username: "testuser", + discriminator: "0001", + globalName: "Test User", + bot: false, + }, + commandName: "test", + commandType: 1, + options: { + data: [{ name: "topic", type: 3, value: "status" }], + }, + isChatInputCommand: () => true, + isMessageComponent: () => false, + deferReply, + }); + await waitForGatewayHandlers(); + + expect(deferReply).toHaveBeenCalledWith({ + flags: DiscordMessageFlag.Ephemeral, + }); + expect(flags).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "discord:guild123:channel456", + command: "/test", + text: "status", + user: expect.objectContaining({ id: "user789" }), + }) + ); + expect(processSlashCommand).toHaveBeenCalledWith( + expect.objectContaining({ + command: "/test", + text: "status", + }), + undefined + ); + }); + it("handles component interactions from the gateway", async () => { const processAction = vi.fn(); const adapter = new TestGatewayDiscordAdapter({ diff --git a/packages/adapter-discord/src/index.ts b/packages/adapter-discord/src/index.ts index 16a018f7..252dc87d 100644 --- a/packages/adapter-discord/src/index.ts +++ b/packages/adapter-discord/src/index.ts @@ -72,6 +72,7 @@ import { type DiscordGatewayMessageData, type DiscordGatewayReactionData, type DiscordInteraction, + type DiscordInteractionFlagsContext, type DiscordInteractionResponse, type DiscordMessagePayload, type DiscordRequestContext, @@ -103,6 +104,7 @@ export class DiscordAdapter implements Adapter { protected readonly publicKey: string; protected readonly applicationId: string; protected readonly mentionRoleIds: string[]; + protected readonly flags?: DiscordAdapterConfig["flags"]; protected chat: ChatInstance | null = null; protected readonly logger: Logger; protected readonly formatConverter = new DiscordFormatConverter(); @@ -149,6 +151,7 @@ export class DiscordAdapter implements Adapter { ? process.env.DISCORD_MENTION_ROLE_IDS.split(",").map((id) => id.trim()) : []); this.botUserId = applicationId; // Discord app ID is the bot's user ID + this.flags = config.flags; this.logger = config.logger ?? new ConsoleLogger("info").child("discord"); this.userName = config.userName ?? "bot"; @@ -276,8 +279,11 @@ export class DiscordAdapter implements Adapter { // Handle APPLICATION_COMMAND (slash commands) if (interaction.type === InteractionType.ApplicationCommand) { - this.handleApplicationCommandInteraction(interaction, options); + const context = this.getApplicationCommandContext(interaction); + const flags = this.getInteractionFlags(context); + this.handleApplicationCommandInteraction(context, options); return this.respondToInteraction({ + ...(flags === undefined ? {} : { data: { flags } }), type: InteractionResponseType.DeferredChannelMessageWithSource, }); } @@ -440,31 +446,25 @@ export class DiscordAdapter implements Adapter { /** * Handle APPLICATION_COMMAND interactions (slash commands). */ - protected handleApplicationCommandInteraction( - interaction: DiscordInteraction, - options?: WebhookOptions - ): void { - if (!this.chat) { - this.logger.warn("Chat instance not initialized, ignoring interaction"); - return; - } - + protected getApplicationCommandContext( + interaction: DiscordInteraction + ): DiscordInteractionFlagsContext | null { const commandName = interaction.data?.name; if (!commandName) { this.logger.warn("No command name in application command interaction"); - return; + return null; } const user = interaction.member?.user || interaction.user; if (!user) { this.logger.warn("No user in application command interaction"); - return; + return null; } const interactionChannelId = interaction.channel_id; if (!interactionChannelId) { this.logger.warn("Missing channel_id in application command interaction"); - return; + return null; } const guildId = interaction.guild_id || "@me"; @@ -489,6 +489,39 @@ export class DiscordAdapter implements Adapter { interaction.data?.options ); + return { + channelId, + command, + interaction, + text, + user, + }; + } + + protected getInteractionFlags( + context: DiscordInteractionFlagsContext | null + ): DiscordMessagePayload["flags"] { + if (!(context && this.flags)) { + return undefined; + } + return this.flags(context); + } + + protected handleApplicationCommandInteraction( + context: DiscordInteractionFlagsContext | null, + options?: WebhookOptions + ): void { + if (!this.chat) { + this.logger.warn("Chat instance not initialized, ignoring interaction"); + return; + } + + if (!context) { + return; + } + + const { channelId, command, interaction, text, user } = context; + this.logger.debug("Processing Discord slash command", { command, text, @@ -574,10 +607,12 @@ export class DiscordAdapter implements Adapter { interaction: DiscordJsInteraction ): Promise { if (interaction.isChatInputCommand()) { - await interaction.deferReply(); - this.handleApplicationCommandInteraction( + const context = this.getApplicationCommandContext( this.normalizeGatewaySlashCommandInteraction(interaction) ); + const flags = this.getInteractionFlags(context); + await interaction.deferReply(flags === undefined ? undefined : { flags }); + this.handleApplicationCommandInteraction(context); return; } @@ -2671,4 +2706,10 @@ export { DiscordFormatConverter as DiscordMarkdownConverter, } from "./markdown"; // Re-export types -export type { DiscordAdapterConfig, DiscordThreadId } from "./types"; +export type { + DiscordAdapterConfig, + DiscordInteractionFlagsContext, + DiscordMessageFlagValue, + DiscordThreadId, +} from "./types"; +export { DiscordMessageFlag } from "./types"; diff --git a/packages/adapter-discord/src/types.ts b/packages/adapter-discord/src/types.ts index da05969b..b043b315 100644 --- a/packages/adapter-discord/src/types.ts +++ b/packages/adapter-discord/src/types.ts @@ -21,6 +21,10 @@ export interface DiscordAdapterConfig { applicationId?: string; /** Discord bot token. Defaults to DISCORD_BOT_TOKEN env var. */ botToken?: string; + /** Return message flags for the initial deferred slash command response. */ + flags?: ( + context: DiscordInteractionFlagsContext + ) => DiscordMessagePayload["flags"]; /** Logger instance for error reporting. Defaults to ConsoleLogger. */ logger?: Logger; /** Role IDs that should trigger mention handlers (in addition to direct user mentions). Defaults to DISCORD_MENTION_ROLE_IDS env var (comma-separated). */ @@ -31,6 +35,22 @@ export interface DiscordAdapterConfig { userName?: string; } +/** + * Context passed to the Discord adapter flags callback for slash commands. + */ +export interface DiscordInteractionFlagsContext { + /** Chat SDK channel ID where the command was invoked. */ + channelId: string; + /** Parsed slash command name, including subcommands (e.g. "/project issue create"). */ + command: string; + /** Raw Discord interaction payload. */ + interaction: DiscordInteraction; + /** Flattened slash command option text. */ + text: string; + /** User who invoked the command. */ + user: DiscordUser; +} + /** * Discord thread ID components. * Used for encoding/decoding thread IDs. @@ -171,12 +191,36 @@ export interface DiscordMessagePayload { components?: DiscordActionRow[]; content?: string; embeds?: APIEmbed[]; + flags?: number; message_reference?: { message_id: string; fail_if_not_exists?: boolean; }; } +/** + * Discord message flags. + * See https://discord.com/developers/docs/resources/message#message-object-message-flags + */ +export const DiscordMessageFlag = { + Crossposted: 1, + IsCrosspost: 2, + SuppressEmbeds: 4, + SourceMessageDeleted: 8, + Urgent: 16, + HasThread: 32, + Ephemeral: 64, + Loading: 128, + FailedToMentionSomeRolesInThread: 256, + SuppressNotifications: 4096, + IsVoiceMessage: 8192, + HasSnapshot: 16_384, + IsComponentsV2: 32_768, +} as const; + +export type DiscordMessageFlagValue = + (typeof DiscordMessageFlag)[keyof typeof DiscordMessageFlag]; + /** * Discord interaction response types. * Note: Only the types currently used are defined here. From 169e054df550aba732623420706acb277b19fd91 Mon Sep 17 00:00:00 2001 From: Dean Mauro Date: Fri, 15 May 2026 17:30:31 -0400 Subject: [PATCH 2/2] Update documentation --- .changeset/discord-interaction-flags.md | 5 +++ .../content/adapters/official/discord.mdx | 32 ++++++++++++++++++- apps/docs/content/docs/adapters.mdx | 2 +- apps/docs/content/docs/ephemeral-messages.mdx | 4 ++- apps/docs/content/docs/slash-commands.mdx | 2 ++ packages/adapter-discord/README.md | 24 +++++++++++++- 6 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 .changeset/discord-interaction-flags.md diff --git a/.changeset/discord-interaction-flags.md b/.changeset/discord-interaction-flags.md new file mode 100644 index 00000000..6c2a4f4f --- /dev/null +++ b/.changeset/discord-interaction-flags.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/discord": patch +--- + +Add a Discord adapter flags callback and message flag constants for initial deferred slash command responses. diff --git a/apps/docs/content/adapters/official/discord.mdx b/apps/docs/content/adapters/official/discord.mdx index 425f76ea..caae6095 100644 --- a/apps/docs/content/adapters/official/discord.mdx +++ b/apps/docs/content/adapters/official/discord.mdx @@ -34,7 +34,7 @@ features: removeReactions: yes typingIndicator: yes directMessages: yes - ephemeralMessages: no + ephemeralMessages: yes userLookup: yes customApiEndpoint: yes fetchMessages: yes @@ -95,6 +95,11 @@ bot.onNewMention(async (thread, message) => { description: "Role IDs that should trigger mention handlers. Auto-detected from `DISCORD_MENTION_ROLE_IDS` (comma-separated).", }, + flags: { + type: "(context) => number | undefined", + description: + "Return Discord message flags for the initial deferred slash command response.", + }, apiUrl: { type: "string", description: "Override the Discord API base URL.", @@ -104,6 +109,31 @@ bot.onNewMention(async (thread, message) => { `botToken`, `publicKey`, and `applicationId` are required. +## Interaction flags + +Discord slash commands are acknowledged with `DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE` before your handler posts the final response. Use the `flags` option to set Discord message flags on that initial deferred response. + +Return Discord's `EPHEMERAL` flag to make the loading state and original interaction response visible only to the user who invoked the command: + +```typescript +import { createDiscordAdapter, DiscordMessageFlag } from "@chat-adapter/discord"; + +createDiscordAdapter({ + flags: ({ command, interaction }) => { + if ( + command === "/admin" || + interaction.member?.roles.includes("1457473602180878604") + ) { + return DiscordMessageFlag.Ephemeral; + } + + return undefined; + }, +}); +``` + +The callback receives the parsed command path, flattened option text, invoking user, normalized Chat SDK channel ID, and raw Discord interaction. Later calls to `event.channel.post()` continue through the normal Discord interaction response flow. + ## Authentication ### 1. Create the application diff --git a/apps/docs/content/docs/adapters.mdx b/apps/docs/content/docs/adapters.mdx index ca31f6e9..f5a85ed2 100644 --- a/apps/docs/content/docs/adapters.mdx +++ b/apps/docs/content/docs/adapters.mdx @@ -49,7 +49,7 @@ Ready to build your own? Follow the [building](/docs/contributing/building) guid | Remove reactions | | | | | | | | | | | Typing indicator | | | | | | | Agent sessions | | | | DMs | | | | | | | | | | -| Ephemeral messages | Native | | Native | | | | | | | +| Ephemeral messages | Native | | Native | | | | | | | | User lookup ([`getUser`](/docs/api/chat#getuser)) | | Cached | Cached | | Seen users | | | | | | Parent subject ([`message.subject`](/docs/subject)) | | | | | | | | | | | Native client ([`.webClient` / `.octokit` / `.linearClient`](/docs/api/chat#getadapter)) | | | | | | | | | | diff --git a/apps/docs/content/docs/ephemeral-messages.mdx b/apps/docs/content/docs/ephemeral-messages.mdx index 35f728a3..4df2f866 100644 --- a/apps/docs/content/docs/ephemeral-messages.mdx +++ b/apps/docs/content/docs/ephemeral-messages.mdx @@ -29,9 +29,11 @@ The `fallbackToDM` option is required and controls behavior on platforms without |----------|---------------|----------|-------------| | Slack | Yes | Ephemeral in channel | Session-only (disappears on reload) | | Google Chat | Yes | Private message in space | Persists until deleted | -| Discord | No | DM fallback | Persists in DM | +| Discord | Yes | Ephemeral in channel | Session-only (disappears on reload) | | Teams | No | DM fallback | Persists in DM | +Discord ephemeral support is established in the adapter config by returning `DiscordMessageFlag.Ephemeral` from the Discord adapter's [`flags` option](/adapters/official/discord#interaction-flags). Outside that interaction flow, `postEphemeral` still follows the fallback behavior. + ## Check for fallback ```typescript title="lib/bot.ts" lineNumbers diff --git a/apps/docs/content/docs/slash-commands.mdx b/apps/docs/content/docs/slash-commands.mdx index 2626382e..3baaceec 100644 --- a/apps/docs/content/docs/slash-commands.mdx +++ b/apps/docs/content/docs/slash-commands.mdx @@ -116,6 +116,8 @@ bot.onModalSubmit("feedback_form", async (event) => { Discord slash commands are received via [HTTP Interactions](/adapters/official/discord#http-interactions-vs-gateway) — no Gateway connection is needed. The adapter automatically sends a deferred response to Discord, then resolves it when your handler calls `event.channel.post()`. +To control Discord message flags on the initial deferred response, such as returning `DiscordMessageFlag.Ephemeral` for selected commands, use the Discord adapter's [`flags` option](/adapters/official/discord#interaction-flags). + ### Subcommands Discord supports subcommand groups and subcommands. The adapter flattens these into the `event.command` path: diff --git a/packages/adapter-discord/README.md b/packages/adapter-discord/README.md index bf0eb639..8979f984 100644 --- a/packages/adapter-discord/README.md +++ b/packages/adapter-discord/README.md @@ -152,6 +152,27 @@ createDiscordAdapter({ Or set `DISCORD_MENTION_ROLE_IDS` as a comma-separated string in your environment variables. +## Interaction flags + +Discord interactions return a transient "thinking..." message, which then gets replaced with your content. Because of limitations in Discord's API, your message's ephemerality must be set on this initial response, not later e.g. in an `onSlashCommand`. + +Use the `flags` option to make the loading state and your custom response visible only to the user who invoked the command: + +```typescript +import { createDiscordAdapter, DiscordMessageFlag } from "@chat-adapter/discord"; + +createDiscordAdapter({ + flags: ({ command }) => { + if (command === "/admin") { + return DiscordMessageFlag.Ephemeral; + } + }, +}); +``` + +Later calls to `event.channel.post()` will share the same ephemeral message. +Calls to `event.channel.postEphemeral()` will fallback to a private DM. + ## Configuration All options are auto-detected from environment variables when not provided. @@ -162,6 +183,7 @@ All options are auto-detected from environment variables when not provided. | `publicKey` | No* | Application public key. Auto-detected from `DISCORD_PUBLIC_KEY` | | `applicationId` | No* | Discord application ID. Auto-detected from `DISCORD_APPLICATION_ID` | | `mentionRoleIds` | No | Array of role IDs that trigger mention handlers. Auto-detected from `DISCORD_MENTION_ROLE_IDS` (comma-separated) | +| `flags` | No | Function returning Discord message flags for the initial deferred slash command response | | `apiUrl` | No | Override the Discord API base URL. Auto-detected from `DISCORD_API_URL` | | `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | @@ -213,7 +235,7 @@ CRON_SECRET=your-random-secret # For Gateway cron | Remove reactions | Yes | | Typing indicator | Yes | | DMs | Yes | -| Ephemeral messages | No (DM fallback) | +| Ephemeral messages | Yes | ### Message history