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
128 changes: 107 additions & 21 deletions packages/adapter-discord/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1990,6 +1990,79 @@ describe("startTyping", () => {
});
});

// ============================================================================
// openThread Tests
// ============================================================================

describe("openThread", () => {
const adapter = createDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
applicationId: "test-app-id",
logger: mockLogger,
});

it("creates a thread and returns encoded thread ID", async () => {
const mockResponse = new Response(
JSON.stringify({ id: "new-thread-id", name: "Thread" }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
const spy = vi
.spyOn(adapter as any, "discordFetch")
.mockResolvedValue(mockResponse);

const result = await adapter.openThread(
"discord:guild1:channel456",
"msg123"
);

expect(result).toBe("discord:guild1:channel456:new-thread-id");
expect(spy).toHaveBeenCalledWith(
"/channels/channel456/messages/msg123/threads",
"POST",
expect.objectContaining({ auto_archive_duration: 1440 })
);

spy.mockRestore();
});

it("returns existing thread ID when already in a thread", async () => {
const spy = vi.spyOn(adapter as any, "discordFetch");

const result = await adapter.openThread(
"discord:guild1:channel456:existing-thread",
"msg123"
);

expect(result).toBe("discord:guild1:channel456:existing-thread");
expect(spy).not.toHaveBeenCalled();

spy.mockRestore();
});

it("recovers from 160004 (thread already exists) by reusing message ID", async () => {
const { NetworkError } = await import("@chat-adapter/shared");

const spy = vi
.spyOn(adapter as any, "discordFetch")
.mockRejectedValue(
new NetworkError(
"discord",
JSON.stringify({ code: 160004, message: "A thread has already been created for this message" })
)
);

const result = await adapter.openThread(
"discord:guild1:channel456",
"msg123"
);

expect(result).toBe("discord:guild1:channel456:msg123");

spy.mockRestore();
});
});

// ============================================================================
// openDM Tests
// ============================================================================
Expand Down Expand Up @@ -3385,7 +3458,14 @@ describe("handleForwardedMessage - thread handling", () => {
fetchSpy.mockRestore();
});

it("creates thread when mentioned and not in a thread", async () => {
// Previously, the Discord adapter auto-created a thread when the bot was
// @mentioned in a channel. This was inconsistent with other adapters (Slack,
// Telegram, Teams, etc.) which all dispatch channel mentions with the channel
// thread ID (channelId === threadId). The SDK contract expects channel messages
// to have channelId === threadId — auto-creating a thread broke this, causing
// issues with subscribe() (subscribing to the channel instead of a thread) and
// handler routing. Thread creation is now explicit via thread.thread().
it("does not auto-create thread when mentioned in a channel", async () => {
const adapter = createDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
Expand All @@ -3401,14 +3481,7 @@ describe("handleForwardedMessage - thread handling", () => {
processReaction: vi.fn(),
} as unknown as ChatInstance);

const fetchSpy = vi
.spyOn(adapter as any, "discordFetch")
.mockResolvedValue(
new Response(
JSON.stringify({ id: "new-thread-id", name: "New Thread" }),
{ status: 200, headers: { "Content-Type": "application/json" } }
)
);
const fetchSpy = vi.spyOn(adapter as any, "discordFetch");

const body = JSON.stringify({
type: "GATEWAY_MESSAGE_CREATE",
Expand Down Expand Up @@ -3441,11 +3514,18 @@ describe("handleForwardedMessage - thread handling", () => {

await adapter.handleWebhook(request);

// Should have created a thread
expect(fetchSpy).toHaveBeenCalledWith(
// Should NOT auto-create a thread — thread creation is now explicit via thread.thread()
expect(fetchSpy).not.toHaveBeenCalledWith(
"/channels/channel456/messages/msg123/threads",
"POST",
expect.objectContaining({ auto_archive_duration: 1440 })
expect.anything()
);

// Should dispatch with channel-scoped thread ID (no thread part)
expect(handleIncomingMessage).toHaveBeenCalledWith(
adapter,
"discord:guild1:channel456",
expect.anything()
);

fetchSpy.mockRestore();
Expand Down Expand Up @@ -3903,7 +3983,11 @@ describe("handleForwardedMessage - DM messages", () => {
// ============================================================================

describe("mentionRoleIds handling", () => {
it("detects mention via role ID", async () => {
// Same rationale as "does not auto-create thread when mentioned in a channel" above.
// Role mentions follow the same path — the adapter detects the mention and sets
// isMention on the message, but does not create a thread. Consumers use
// thread.thread() to explicitly create one when desired.
it("detects mention via role ID without auto-creating thread", async () => {
const adapter = createDiscordAdapter({
botToken: "test-token",
publicKey: testPublicKey,
Expand All @@ -3913,12 +3997,7 @@ describe("mentionRoleIds handling", () => {
});

const handleIncomingMessage = vi.fn();
const fetchSpy = vi.spyOn(adapter as any, "discordFetch").mockResolvedValue(
new Response(JSON.stringify({ id: "new-thread", name: "Thread" }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
const fetchSpy = vi.spyOn(adapter as any, "discordFetch");

await adapter.initialize({
handleIncomingMessage,
Expand Down Expand Up @@ -3958,13 +4037,20 @@ describe("mentionRoleIds handling", () => {

await adapter.handleWebhook(request);

// Should create a thread because of role mention
expect(fetchSpy).toHaveBeenCalledWith(
// Should NOT auto-create a thread — thread creation is now explicit via thread.thread()
expect(fetchSpy).not.toHaveBeenCalledWith(
"/channels/channel456/messages/msg123/threads",
"POST",
expect.anything()
);

// Should still dispatch the message with mention detected
expect(handleIncomingMessage).toHaveBeenCalledWith(
adapter,
"discord:guild1:channel456",
expect.anything()
);

fetchSpy.mockRestore();
});
});
Expand Down
79 changes: 41 additions & 38 deletions packages/adapter-discord/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,24 +634,6 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
);
const isMentioned = isUserMentioned || isRoleMentioned;

// If mentioned and not in a thread, create one
if (!discordThreadId && isMentioned) {
try {
const newThread = await this.createDiscordThread(channelId, data.id);
discordThreadId = newThread.id;
this.logger.debug("Created Discord thread for forwarded mention", {
channelId,
messageId: data.id,
threadId: newThread.id,
});
} catch (error) {
this.logger.error("Failed to create Discord thread for mention", {
error: String(error),
messageId: data.id,
});
}
}

const threadId = this.encodeThreadId({
guildId,
channelId: parentChannelId,
Expand Down Expand Up @@ -1424,6 +1406,47 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
};
}

/**
* Open a thread for a message.
* Returns a thread ID that can be used to post threaded replies.
*
* For Discord, this creates a thread via the API if one doesn't exist.
* If the message already has a thread, returns the existing thread ID.
*/
async openThread(scopeId: string, messageId: string): Promise<string> {
const { guildId, channelId, threadId } = this.decodeThreadId(scopeId);

// Already in a thread — return the existing ID
if (threadId) {
this.logger.debug("openThread: already in a thread", {
scopeId,
threadId,
});
return scopeId;
}

// Channel scope — create or get a thread anchored to the message
this.logger.debug("openThread: creating thread", {
channelId,
messageId,
});

const thread = await this.createDiscordThread(channelId, messageId);

const newThreadId = this.encodeThreadId({
guildId,
channelId,
threadId: thread.id,
});

this.logger.debug("openThread: thread ready", {
threadId: thread.id,
encodedThreadId: newThreadId,
});

return newThreadId;
}

/**
* Open a DM with a user.
*/
Expand Down Expand Up @@ -1959,26 +1982,6 @@ export class DiscordAdapter implements Adapter<DiscordThreadId, unknown> {
parentChannelId = message.channel.parentId;
}

// If not in a thread and bot is mentioned, create a thread immediately
// This ensures the Thread object has the correct ID from the start
if (!discordThreadId && isMentioned) {
try {
const newThread = await this.createDiscordThread(channelId, message.id);
discordThreadId = newThread.id;
this.logger.debug("Created Discord thread for incoming mention", {
channelId,
messageId: message.id,
threadId: newThread.id,
});
} catch (error) {
this.logger.error("Failed to create Discord thread for mention", {
error: String(error),
messageId: message.id,
});
// Continue without thread - will use channel
}
}

const threadId = this.encodeThreadId({
guildId,
channelId: parentChannelId,
Expand Down
19 changes: 19 additions & 0 deletions packages/adapter-gchat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1691,6 +1691,25 @@ export class GoogleChatAdapter implements Adapter<GoogleChatThreadId, unknown> {
// Google Chat doesn't have a typing indicator API for bots
}

/**
* Open a thread for a message.
* Returns a thread ID that can be used to post threaded replies.
*
* For Google Chat, this encodes the message ID as the thread name.
* If already in a thread, returns the existing thread ID.
*/
async openThread(scopeId: string, messageId: string): Promise<string> {
const { spaceName, threadName, isDM } = this.decodeThreadId(scopeId);

// Already in a thread — return the existing ID
if (threadName) {
return scopeId;
}

// Space scope — encode with messageId as threadName
return this.encodeThreadId({ spaceName, threadName: messageId, isDM });
}

/**
* Open a direct message conversation with a user.
* Returns a thread ID that can be used to post messages.
Expand Down
Loading