Spaces:
Paused
Paused
| 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); | |
| }); | |
| }); | |