import { RateLimitError } from "@buape/carbon"; import { 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"; vi.mock("../web/media.js", () => ({ loadWebMedia: vi.fn().mockResolvedValue({ buffer: Buffer.from("img"), fileName: "photo.jpg", contentType: "image/jpeg", kind: "image", }), loadWebMediaRaw: vi.fn().mockResolvedValue({ buffer: Buffer.from("img"), fileName: "asset.png", contentType: "image/png", kind: "image", }), })); const makeRest = () => { const postMock = vi.fn(); const putMock = vi.fn(); const getMock = vi.fn(); const patchMock = vi.fn(); const deleteMock = vi.fn(); return { rest: { post: postMock, put: putMock, get: getMock, patch: patchMock, delete: deleteMock, } as unknown as import("@buape/carbon").RequestClient, postMock, putMock, getMock, patchMock, deleteMock, }; }; describe("sendMessageDiscord", () => { beforeEach(() => { vi.clearAllMocks(); }); it("creates a thread", async () => { const { rest, postMock } = makeRest(); postMock.mockResolvedValue({ id: "t1" }); await createThreadDiscord("chan1", { name: "thread", messageId: "m1" }, { rest, token: "t" }); expect(postMock).toHaveBeenCalledWith( Routes.threads("chan1", "m1"), expect.objectContaining({ body: { name: "thread" } }), ); }); it("lists active threads by guild", async () => { const { rest, getMock } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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 } = makeRest(); 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); }); });