Skip to content
Closed
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/tender-falcons-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/discord": minor
---

Add support for slash command interactions — `onSlashCommand` handlers are now invoked when a Discord user triggers an application command, and the deferred "thinking" response is resolved automatically via the interaction token.
208 changes: 181 additions & 27 deletions packages/adapter-discord/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@

import { generateKeyPairSync, sign } from "node:crypto";
import { ValidationError } from "@chat-adapter/shared";
import type { Logger } from "chat";
import type { ChatInstance, Logger, StateAdapter } from "chat";
import { InteractionType } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDiscordAdapter, DiscordAdapter } from "./index";
import { DiscordFormatConverter } from "./markdown";

Expand Down Expand Up @@ -67,6 +67,52 @@ function createWebhookRequest(
});
}

function createMockState(): StateAdapter & { cache: Map<string, unknown> } {
const cache = new Map<string, unknown>();
return {
cache,
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn().mockResolvedValue(undefined),
unsubscribe: vi.fn().mockResolvedValue(undefined),
isSubscribed: vi.fn().mockResolvedValue(false),
acquireLock: vi.fn().mockResolvedValue(null),
releaseLock: vi.fn().mockResolvedValue(undefined),
extendLock: vi.fn().mockResolvedValue(true),
get: vi
.fn()
.mockImplementation((key: string) =>
Promise.resolve(cache.get(key) ?? null)
),
set: vi.fn().mockImplementation((key: string, value: unknown) => {
cache.set(key, value);
return Promise.resolve();
}),
delete: vi.fn().mockImplementation((key: string) => {
cache.delete(key);
return Promise.resolve();
}),
};
}

function createMockChatInstance(state: StateAdapter): ChatInstance {
return {
processMessage: vi.fn(),
handleIncomingMessage: vi.fn().mockResolvedValue(undefined),
processReaction: vi.fn(),
processAction: vi.fn(),
processAppHomeOpened: vi.fn(),
processAssistantContextChanged: vi.fn(),
processAssistantThreadStarted: vi.fn(),
processModalSubmit: vi.fn().mockResolvedValue(undefined),
processModalClose: vi.fn(),
processSlashCommand: vi.fn(),
getState: () => state,
getUserName: () => "test-bot",
getLogger: () => mockLogger,
};
}

// ============================================================================
// Factory Function Tests
// ============================================================================
Expand Down Expand Up @@ -374,38 +420,146 @@ describe("handleWebhook - APPLICATION_COMMAND", () => {
logger: mockLogger,
});

it("handles slash command interaction", async () => {
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: [],
joined_at: "2021-01-01T00:00:00.000Z",
},
data: {
id: "cmd123",
name: "test",
type: 1,
const mockState = createMockState();
const mockChat = createMockChatInstance(mockState);

adapter.initialize(mockChat);

const slashCommandBody = 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",
},
});
const request = createWebhookRequest(body);
roles: [],
joined_at: "2021-01-01T00:00:00.000Z",
},
data: {
id: "cmd123",
name: "test",
type: 1,
},
});

it("ACKs with DeferredChannelMessageWithSource", async () => {
const request = createWebhookRequest(slashCommandBody);
const response = await adapter.handleWebhook(request);
expect(response.status).toBe(200);

const responseBody = await response.json();
expect(responseBody).toEqual({ type: 5 }); // DeferredChannelMessageWithSource
});

it("invokes processSlashCommand with correct event", async () => {
const processSlashCommand = mockChat.processSlashCommand as ReturnType<
typeof vi.fn
>;
processSlashCommand.mockClear();
const request = createWebhookRequest(slashCommandBody);
await adapter.handleWebhook(request);

expect(processSlashCommand).toHaveBeenCalledOnce();
const [event] = processSlashCommand.mock.calls[0] as [
Record<string, unknown>,
];
expect(event.command).toBe("/test");
expect(event.channelId).toBe("discord:guild123:channel456");
expect(event.triggerId).toBe("interaction-token");
expect((event.user as { userId: string }).userId).toBe("user789");
expect(event.raw).toMatchObject({ id: "interaction123" });
});
});

// ============================================================================
// postChannelMessage - Interaction Token Resolution Tests
// ============================================================================

describe("postChannelMessage - interaction token resolution", () => {
const channelId = "discord:guild123:channel456";
const stateKey = `discord:interaction-token:${channelId}`;
const msgResponse = { id: "msg-1", channel_id: "channel456" };

let adapter: InstanceType<typeof DiscordAdapter>;
let mockState: ReturnType<typeof createMockState>;

beforeEach(() => {
adapter = createDiscordAdapter({
botToken: "test-bot-token",
publicKey: testPublicKey,
applicationId: "test-app-id",
logger: mockLogger,
});
mockState = createMockState();
adapter.initialize(createMockChatInstance(mockState));
});

afterEach(() => {
vi.restoreAllMocks();
});

it("PATCHes @original with JSON when token is in state", async () => {
await mockState.set(stateKey, "my-token");
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify(msgResponse), { status: 200 })
);

const result = await adapter.postChannelMessage(channelId, "Hello!");

const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit];
expect(url).toContain("/webhooks/test-app-id/my-token/messages/@original");
expect(init.method).toBe("PATCH");
expect(init.body).not.toBeInstanceOf(FormData);
expect(result.id).toBe("msg-1");
expect(await mockState.get(stateKey)).toBeNull();
});

it("PATCHes @original with multipart when token is in state and files are present", async () => {
await mockState.set(stateKey, "my-token");
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify(msgResponse), { status: 200 })
);

await adapter.postChannelMessage(channelId, {
raw: "Here is a file",
files: [
{
filename: "test.txt",
data: Buffer.from("hello"),
mimeType: "text/plain",
},
],
});

const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit];
expect(url).toContain("/webhooks/test-app-id/my-token/messages/@original");
expect(init.method).toBe("PATCH");
expect(init.body).toBeInstanceOf(FormData);
expect(await mockState.get(stateKey)).toBeNull();
});

it("deletes token and falls back to channel POST when PATCH fails", async () => {
await mockState.set(stateKey, "bad-token");
vi.spyOn(global, "fetch")
.mockResolvedValueOnce(new Response("Server Error", { status: 500 }))
.mockResolvedValueOnce(
new Response(JSON.stringify(msgResponse), { status: 200 })
);

const result = await adapter.postChannelMessage(channelId, "Hello!");

expect(vi.mocked(fetch)).toHaveBeenCalledTimes(2);
const [secondUrl] = vi.mocked(fetch).mock.calls[1] as [string, RequestInit];
expect(secondUrl).toContain("/channels/channel456/messages");
expect(result.id).toBe("msg-1");
expect(await mockState.get(stateKey)).toBeNull();
});
});

// ============================================================================
Expand Down
Loading