Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/discord-interaction-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/discord": patch
---

Add a Discord adapter flags callback and message flag constants for initial deferred slash command responses.
32 changes: 31 additions & 1 deletion apps/docs/content/adapters/official/discord.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ features:
removeReactions: yes
typingIndicator: yes
directMessages: yes
ephemeralMessages: no
ephemeralMessages: yes
userLookup: yes
customApiEndpoint: yes
fetchMessages: yes
Expand Down Expand Up @@ -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.",
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/adapters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Ready to build your own? Follow the [building](/docs/contributing/building) guid
| Remove reactions | <Check /> | <Cross /> | <Check /> | <Check /> | <Check /> | <Warn /> | <Warn /> | <Check /> | <Cross /> |
| Typing indicator | <Check /> | <Check /> | <Cross /> | <Check /> | <Check /> | <Cross /> | <Warn /> Agent sessions | <Cross /> | <Check /> |
| DMs | <Check /> | <Check /> | <Check /> | <Check /> | <Check /> | <Cross /> | <Cross /> | <Check /> | <Check /> |
| Ephemeral messages | <Check /> Native | <Cross /> | <Check /> Native | <Cross /> | <Cross /> | <Cross /> | <Cross /> | <Cross /> | <Cross /> |
| Ephemeral messages | <Check /> Native | <Cross /> | <Check /> Native | <Check /> | <Cross /> | <Cross /> | <Cross /> | <Cross /> | <Cross /> |
| User lookup ([`getUser`](/docs/api/chat#getuser)) | <Check /> | <Warn /> Cached | <Warn /> Cached | <Check /> | <Warn /> Seen users | <Check /> | <Check /> | <Cross /> | <Cross /> |
| Parent subject ([`message.subject`](/docs/subject)) | <Cross /> | <Cross /> | <Cross /> | <Cross /> | <Cross /> | <Check /> | <Check /> | <Cross /> | <Cross /> |
| Native client ([`.webClient` / `.octokit` / `.linearClient`](/docs/api/chat#getadapter)) | <Check /> | <Cross /> | <Cross /> | <Cross /> | <Cross /> | <Check /> | <Check /> | <Cross /> | <Cross /> |
Expand Down
4 changes: 3 additions & 1 deletion apps/docs/content/docs/ephemeral-messages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/docs/slash-commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 23 additions & 1 deletion packages/adapter-discord/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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")`) |

Expand Down Expand Up @@ -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

Expand Down
135 changes: 134 additions & 1 deletion packages/adapter-discord/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
Loading