Files
Moltbot/src/discord/send.creates-thread.test.ts
Pushkar Kathayat 7f4d1b7531 fix(discord): support applied_tags parameter for forum thread creation
Forum channels that require tags fail with "A tag is required" when
creating threads because there was no way to pass tag IDs. Add
appliedTags parameter to the thread-create action so forum posts can
include required tags from the channel's available_tags list.
2026-03-02 02:05:11 +00:00

521 lines
16 KiB
TypeScript

import { RateLimitError } from "@buape/carbon";
import { ChannelType, Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
addRoleDiscord,
banMemberDiscord,
createThreadDiscord,
listGuildEmojisDiscord,
listThreadsDiscord,
reactMessageDiscord,
removeRoleDiscord,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
timeoutMemberDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
} from "./send.js";
import { makeDiscordRest } from "./send.test-harness.js";
vi.mock("../web/media.js", async () => {
const { discordWebMediaMockFactory } = await import("./send.test-harness.js");
return discordWebMediaMockFactory();
});
describe("sendMessageDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a thread", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord("chan1", { name: "thread", messageId: "m1" }, { rest, token: "t" });
expect(getMock).not.toHaveBeenCalled();
expect(postMock).toHaveBeenCalledWith(
Routes.threads("chan1", "m1"),
expect.objectContaining({ body: { name: "thread" } }),
);
});
it("creates forum threads with an initial message", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
getMock.mockResolvedValue({ type: ChannelType.GuildForum });
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord("chan1", { name: "thread" }, { rest, token: "t" });
expect(getMock).toHaveBeenCalledWith(Routes.channel("chan1"));
expect(postMock).toHaveBeenCalledWith(
Routes.threads("chan1"),
expect.objectContaining({
body: {
name: "thread",
message: { content: "thread" },
},
}),
);
});
it("creates media threads with provided content", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
getMock.mockResolvedValue({ type: ChannelType.GuildMedia });
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord(
"chan1",
{ name: "thread", content: "initial forum post" },
{ rest, token: "t" },
);
expect(postMock).toHaveBeenCalledWith(
Routes.threads("chan1"),
expect.objectContaining({
body: {
name: "thread",
message: { content: "initial forum post" },
},
}),
);
});
it("passes applied_tags for forum threads", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
getMock.mockResolvedValue({ type: ChannelType.GuildForum });
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord(
"chan1",
{ name: "tagged post", appliedTags: ["tag1", "tag2"] },
{ rest, token: "t" },
);
expect(postMock).toHaveBeenCalledWith(
Routes.threads("chan1"),
expect.objectContaining({
body: {
name: "tagged post",
message: { content: "tagged post" },
applied_tags: ["tag1", "tag2"],
},
}),
);
});
it("omits applied_tags for non-forum threads", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
getMock.mockResolvedValue({ type: ChannelType.GuildText });
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord(
"chan1",
{ name: "thread", appliedTags: ["tag1"] },
{ rest, token: "t" },
);
expect(postMock).toHaveBeenCalledWith(
Routes.threads("chan1"),
expect.objectContaining({
body: expect.not.objectContaining({ applied_tags: expect.anything() }),
}),
);
});
it("falls back when channel lookup is unavailable", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
getMock.mockRejectedValue(new Error("lookup failed"));
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord("chan1", { name: "thread" }, { rest, token: "t" });
expect(postMock).toHaveBeenCalledWith(
Routes.threads("chan1"),
expect.objectContaining({
body: expect.objectContaining({ name: "thread", type: ChannelType.PublicThread }),
}),
);
});
it("respects explicit thread type for standalone threads", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
getMock.mockResolvedValue({ type: ChannelType.GuildText });
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord(
"chan1",
{ name: "thread", type: ChannelType.PrivateThread },
{ rest, token: "t" },
);
expect(getMock).toHaveBeenCalledWith(Routes.channel("chan1"));
expect(postMock).toHaveBeenCalledWith(
Routes.threads("chan1"),
expect.objectContaining({
body: expect.objectContaining({ name: "thread", type: ChannelType.PrivateThread }),
}),
);
});
it("sends initial message for non-forum threads with content", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
getMock.mockResolvedValue({ type: ChannelType.GuildText });
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord(
"chan1",
{ name: "thread", content: "Hello thread!" },
{ rest, token: "t" },
);
expect(postMock).toHaveBeenCalledTimes(2);
// First call: create thread
expect(postMock).toHaveBeenNthCalledWith(
1,
Routes.threads("chan1"),
expect.objectContaining({
body: expect.objectContaining({ name: "thread", type: ChannelType.PublicThread }),
}),
);
// Second call: send message to thread
expect(postMock).toHaveBeenNthCalledWith(
2,
Routes.channelMessages("t1"),
expect.objectContaining({
body: { content: "Hello thread!" },
}),
);
});
it("sends initial message for message-attached threads with content", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord(
"chan1",
{ name: "thread", messageId: "m1", content: "Discussion here" },
{ rest, token: "t" },
);
// Should not detect channel type for message-attached threads
expect(getMock).not.toHaveBeenCalled();
expect(postMock).toHaveBeenCalledTimes(2);
// First call: create thread from message
expect(postMock).toHaveBeenNthCalledWith(
1,
Routes.threads("chan1", "m1"),
expect.objectContaining({ body: { name: "thread" } }),
);
// Second call: send message to thread
expect(postMock).toHaveBeenNthCalledWith(
2,
Routes.channelMessages("t1"),
expect.objectContaining({
body: { content: "Discussion here" },
}),
);
});
it("lists active threads by guild", async () => {
const { rest, getMock } = makeDiscordRest();
getMock.mockResolvedValue({ threads: [] });
await listThreadsDiscord({ guildId: "g1" }, { rest, token: "t" });
expect(getMock).toHaveBeenCalledWith(Routes.guildActiveThreads("g1"));
});
it("times out a member", async () => {
const { rest, patchMock } = makeDiscordRest();
patchMock.mockResolvedValue({ id: "m1" });
await timeoutMemberDiscord(
{ guildId: "g1", userId: "u1", durationMinutes: 10 },
{ rest, token: "t" },
);
expect(patchMock).toHaveBeenCalledWith(
Routes.guildMember("g1", "u1"),
expect.objectContaining({
body: expect.objectContaining({
communication_disabled_until: expect.any(String),
}),
}),
);
});
it("adds and removes roles", async () => {
const { rest, putMock, deleteMock } = makeDiscordRest();
putMock.mockResolvedValue({});
deleteMock.mockResolvedValue({});
await addRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, { rest, token: "t" });
await removeRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, { rest, token: "t" });
expect(putMock).toHaveBeenCalledWith(Routes.guildMemberRole("g1", "u1", "r1"));
expect(deleteMock).toHaveBeenCalledWith(Routes.guildMemberRole("g1", "u1", "r1"));
});
it("bans a member", async () => {
const { rest, putMock } = makeDiscordRest();
putMock.mockResolvedValue({});
await banMemberDiscord(
{ guildId: "g1", userId: "u1", deleteMessageDays: 2 },
{ rest, token: "t" },
);
expect(putMock).toHaveBeenCalledWith(
Routes.guildBan("g1", "u1"),
expect.objectContaining({ body: { delete_message_days: 2 } }),
);
});
});
describe("listGuildEmojisDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("lists emojis for a guild", async () => {
const { rest, getMock } = makeDiscordRest();
getMock.mockResolvedValue([{ id: "e1", name: "party" }]);
await listGuildEmojisDiscord("g1", { rest, token: "t" });
expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1"));
});
});
describe("uploadEmojiDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uploads emoji assets", async () => {
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "e1" });
await uploadEmojiDiscord(
{
guildId: "g1",
name: "party_blob",
mediaUrl: "file:///tmp/party.png",
roleIds: ["r1"],
},
{ rest, token: "t" },
);
expect(postMock).toHaveBeenCalledWith(
Routes.guildEmojis("g1"),
expect.objectContaining({
body: {
name: "party_blob",
image: "data:image/png;base64,aW1n",
roles: ["r1"],
},
}),
);
});
});
describe("uploadStickerDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uploads sticker assets", async () => {
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "s1" });
await uploadStickerDiscord(
{
guildId: "g1",
name: "openclaw_wave",
description: "OpenClaw waving",
tags: "👋",
mediaUrl: "file:///tmp/wave.png",
},
{ rest, token: "t" },
);
expect(postMock).toHaveBeenCalledWith(
Routes.guildStickers("g1"),
expect.objectContaining({
body: {
name: "openclaw_wave",
description: "OpenClaw waving",
tags: "👋",
files: [
expect.objectContaining({
name: "asset.png",
contentType: "image/png",
}),
],
},
}),
);
});
});
describe("sendStickerDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("sends sticker payloads", async () => {
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
const res = await sendStickerDiscord("channel:789", ["123"], {
rest,
token: "t",
content: "hiya",
});
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({
body: {
content: "hiya",
sticker_ids: ["123"],
},
}),
);
});
});
describe("sendPollDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("sends polls with answers", async () => {
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
const res = await sendPollDiscord(
"channel:789",
{
question: "Lunch?",
options: ["Pizza", "Sushi"],
},
{
rest,
token: "t",
},
);
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({
body: expect.objectContaining({
poll: {
question: { text: "Lunch?" },
answers: [{ poll_media: { text: "Pizza" } }, { poll_media: { text: "Sushi" } }],
duration: 24,
allow_multiselect: false,
layout_type: 1,
},
}),
}),
);
});
});
function createMockRateLimitError(retryAfter = 0.001): RateLimitError {
const response = new Response(null, {
status: 429,
headers: {
"X-RateLimit-Scope": "user",
"X-RateLimit-Bucket": "test-bucket",
},
});
return new RateLimitError(response, {
message: "You are being rate limited.",
retry_after: retryAfter,
global: false,
});
}
describe("retry rate limits", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retries on Discord rate limits", async () => {
const { rest, postMock } = makeDiscordRest();
const rateLimitError = createMockRateLimitError(0);
postMock
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
const res = await sendMessageDiscord("channel:789", "hello", {
rest,
token: "t",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
});
expect(res.messageId).toBe("msg1");
expect(postMock).toHaveBeenCalledTimes(2);
});
it("uses retry_after delays when rate limited", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
const { rest, postMock } = makeDiscordRest();
const rateLimitError = createMockRateLimitError(0.5);
postMock
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
const promise = sendMessageDiscord("channel:789", "hello", {
rest,
token: "t",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toEqual({
messageId: "msg1",
channelId: "789",
});
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(500);
setTimeoutSpy.mockRestore();
vi.useRealTimers();
});
it("stops after max retry attempts", async () => {
const { rest, postMock } = makeDiscordRest();
const rateLimitError = createMockRateLimitError(0);
postMock.mockRejectedValue(rateLimitError);
await expect(
sendMessageDiscord("channel:789", "hello", {
rest,
token: "t",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
}),
).rejects.toBeInstanceOf(RateLimitError);
expect(postMock).toHaveBeenCalledTimes(2);
});
it("does not retry non-rate-limit errors", async () => {
const { rest, postMock } = makeDiscordRest();
postMock.mockRejectedValueOnce(new Error("network error"));
await expect(sendMessageDiscord("channel:789", "hello", { rest, token: "t" })).rejects.toThrow(
"network error",
);
expect(postMock).toHaveBeenCalledTimes(1);
});
it("retries reactions on rate limits", async () => {
const { rest, putMock } = makeDiscordRest();
const rateLimitError = createMockRateLimitError(0);
putMock.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce(undefined);
const res = await reactMessageDiscord("chan1", "msg1", "ok", {
rest,
token: "t",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
});
expect(res.ok).toBe(true);
expect(putMock).toHaveBeenCalledTimes(2);
});
it("retries media upload without duplicating overflow text", async () => {
const { rest, postMock } = makeDiscordRest();
const rateLimitError = createMockRateLimitError(0);
const text = "a".repeat(2005);
postMock
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" })
.mockResolvedValueOnce({ id: "msg2", channel_id: "789" });
const res = await sendMessageDiscord("channel:789", text, {
rest,
token: "t",
mediaUrl: "https://example.com/photo.jpg",
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
});
expect(res.messageId).toBe("msg1");
expect(postMock).toHaveBeenCalledTimes(3);
});
});