diff --git a/.github/workflows/register-commands-production.yaml b/.github/workflows/register-commands-production.yaml index 9c5de0ef..7f67b36b 100644 --- a/.github/workflows/register-commands-production.yaml +++ b/.github/workflows/register-commands-production.yaml @@ -1,7 +1,7 @@ name: Register and deploy Slash Commands on: push: - branches: main + branches: test-0923 jobs: Register-Commands: runs-on: ubuntu-latest diff --git a/src/constants/requestsActions.ts b/src/constants/requestsActions.ts new file mode 100644 index 00000000..95b78919 --- /dev/null +++ b/src/constants/requestsActions.ts @@ -0,0 +1,3 @@ +export const GROUP_ROLE_ADD = { + ADD_ROLE: "add-role", +}; diff --git a/src/controllers/guildRoleHandler.ts b/src/controllers/guildRoleHandler.ts index c52134e9..981b17f4 100644 --- a/src/controllers/guildRoleHandler.ts +++ b/src/controllers/guildRoleHandler.ts @@ -14,6 +14,9 @@ import { memberGroupRole, } from "../typeDefinitions/discordMessage.types"; import { verifyAuthToken } from "../utils/verifyAuthToken"; +import { batchDiscordRequests } from "../utils/batchDiscordRequests"; +import { DISCORD_BASE_URL } from "../constants/urls"; +import { GROUP_ROLE_ADD } from "../constants/requestsActions"; export async function createGuildRoleHandler(request: IRequest, env: env) { const authHeader = request.headers.get("Authorization"); @@ -46,6 +49,96 @@ export async function addGroupRoleHandler(request: IRequest, env: env) { } } +export async function getGuildRolesPostHandler(request: IRequest, env: env) { + const authHeader = request.headers.get("Authorization"); + if (!authHeader) { + return new JSONResponse(response.BAD_SIGNATURE); + } + + try { + await verifyAuthToken(authHeader, env); + const { action } = request.query; + + switch (action) { + case GROUP_ROLE_ADD.ADD_ROLE: { + const memberGroupRoleList = await request.json(); + const res = await bulkAddGroupRoleHandler(memberGroupRoleList, env); + return res; + } + default: { + return new JSONResponse(response.BAD_SIGNATURE); + } + } + } catch (err) { + console.error(err); + return new JSONResponse(response.INTERNAL_SERVER_ERROR); + } +} + +export async function bulkAddGroupRoleHandler( + memberGroupRoleList: memberGroupRole[], + env: env +): Promise { + try { + if (!Array.isArray(memberGroupRoleList)) { + return new JSONResponse(response.BAD_SIGNATURE, { + status: 400, + statusText: "Expecting an array for user id and role id as payload", + }); + } + if (memberGroupRoleList.length < 1) { + return new JSONResponse(response.BAD_SIGNATURE, { + status: 400, + statusText: "Minimum length of request is 1", + }); + } + if (memberGroupRoleList.length > 25) { + return new JSONResponse(response.BAD_SIGNATURE, { + status: 400, + statusText: "Max requests length is 25", + }); + } + + const addGroupRoleRequests = []; + for (const memberGroupRole of memberGroupRoleList) { + const addRoleRequest = async () => { + const { userid, roleid } = memberGroupRole; + try { + const createGuildRoleUrl = `${DISCORD_BASE_URL}/guilds/${env.DISCORD_GUILD_ID}/members/${userid}/roles/${roleid}`; + const options = { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${env.DISCORD_TOKEN}`, + }, + }; + return await fetch(createGuildRoleUrl, options); + } catch (error) { + console.error( + `Error occurred while trying to add role: ${roleid} to user: ${userid}`, + error + ); + throw error; + } + }; + addGroupRoleRequests.push(addRoleRequest); + } + const responseList = await batchDiscordRequests(addGroupRoleRequests); + + const responseBody = memberGroupRoleList.map((memberGroupRole, index) => { + return { + userid: memberGroupRole.userid, + roleid: memberGroupRole.roleid, + success: responseList[index].ok, + }; + }); + return new JSONResponse(responseBody); + } catch (e) { + console.error(e); + throw e; + } +} + export async function removeGuildRoleHandler(request: IRequest, env: env) { const authHeader = request.headers.get("Authorization"); if (!authHeader) { diff --git a/src/index.ts b/src/index.ts index 5eaf430d..3f5b4ca0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { removeGuildRoleHandler, getGuildRoleByRoleNameHandler, getGuildRolesHandler, + getGuildRolesPostHandler, } from "./controllers/guildRoleHandler"; import { getMembersInServerHandler } from "./controllers/getMembersInServer"; import { changeNickname } from "./controllers/changeNickname"; @@ -33,6 +34,8 @@ router.put("/roles/create", createGuildRoleHandler); router.put("/roles/add", addGroupRoleHandler); +router.post("/roles", getGuildRolesPostHandler); + router.delete("/roles", removeGuildRoleHandler); router.get("/roles", getGuildRolesHandler); @@ -73,7 +76,7 @@ router.all("*", async () => { export default { async fetch(request: Request, env: env): Promise { - const apiUrls = ["/invite"]; + const apiUrls = ["/invite", "/roles"]; const url = new URL(request.url); if (request.method === "POST" && !apiUrls.includes(url.pathname)) { const isVerifiedRequest = await verifyBot(request, env); diff --git a/src/utils/batchDiscordRequests.ts b/src/utils/batchDiscordRequests.ts new file mode 100644 index 00000000..bb333bc9 --- /dev/null +++ b/src/utils/batchDiscordRequests.ts @@ -0,0 +1,133 @@ +import JSONResponse from "./JsonResponse"; +import { addDelay, convertSecondsToMillis } from "./timeUtils"; +export const DISCORD_HEADERS = { + RATE_LIMIT_RESET_AFTER: "X-RateLimit-Reset-After", + RATE_LIMIT_REMAINING: "X-RateLimit-Remaining", + RETRY_AFTER: "Retry-After", +}; + +const MAX_RETRY = 1; +const LIMIT_BUFFER = 0.2; + +interface RequestDetails { + retries: number; + request: () => Promise; + index: number; +} +interface ResponseDetails { + response: Response; + data: RequestDetails; +} + +const parseRateLimitRemaining = (response: Response) => { + let rateLimitRemaining = Number.parseInt( + response.headers.get(DISCORD_HEADERS.RATE_LIMIT_REMAINING) || "0" + ); + rateLimitRemaining = Math.floor(rateLimitRemaining * (1 - LIMIT_BUFFER)); + return rateLimitRemaining; +}; + +const parseResetAfter = (response: Response) => { + let resetAfter = Number.parseFloat( + response.headers.get(DISCORD_HEADERS.RATE_LIMIT_RESET_AFTER) || "0" + ); + resetAfter = Math.ceil(resetAfter); + return resetAfter; +}; + +export const batchDiscordRequests = async ( + requests: { (): Promise }[] +): Promise => { + try { + const requestsQueue: RequestDetails[] = requests.map((request, index) => { + return { + retries: 0, + request: request, + index: index, + }; + }); + + const responseList: Response[] = new Array(requestsQueue.length); + let resetAfter = 0; + let nextMinimumResetAfter = Infinity; + let rateLimitRemaining = 1; + let nextMinimumRateLimitRemaining = Infinity; + + const handleResponse = async ( + response: JSONResponse, + data: RequestDetails + ): Promise => { + if (response.ok) { + nextMinimumResetAfter = Math.min( + nextMinimumResetAfter, + parseResetAfter(response) + ); + nextMinimumRateLimitRemaining = Math.min( + nextMinimumRateLimitRemaining, + parseRateLimitRemaining(response) + ); + + responseList[data.index] = response; + } else { + nextMinimumResetAfter = Math.min( + nextMinimumResetAfter, + parseResetAfter(response) + ); + rateLimitRemaining = 0; + if (data.retries >= MAX_RETRY) { + responseList[data.index] = response; + } else { + data.retries++; + requestsQueue.push(data); + } + } + }; + + const executeRequest = async ( + data: RequestDetails + ): Promise<{ response: Response; data: RequestDetails }> => { + let response; + try { + response = await data.request(); + } catch (e: unknown) { + console.error(`Error executing request at index ${data.index}:`, e); + response = new JSONResponse({ error: e }, { status: 500 }); + } + return { response, data }; + }; + + let promises: Promise<{ response: Response; data: RequestDetails }>[] = []; + + while (requestsQueue.length > 0) { + const requestData = requestsQueue.pop(); + if (!requestData) continue; + promises.push(executeRequest(requestData)); + rateLimitRemaining--; + + if (rateLimitRemaining <= 0 || requestsQueue.length === 0) { + const resultList: ResponseDetails[] = await Promise.all(promises); + promises = []; + for (const result of resultList) { + const { response, data } = result; + await handleResponse(response, data); + } + if (nextMinimumRateLimitRemaining !== Infinity) { + rateLimitRemaining = nextMinimumRateLimitRemaining; + } + if (nextMinimumResetAfter !== Infinity) { + resetAfter = nextMinimumResetAfter; + } + nextMinimumRateLimitRemaining = Infinity; + nextMinimumResetAfter = Infinity; + if (rateLimitRemaining <= 0 && resetAfter) { + await addDelay(convertSecondsToMillis(resetAfter)); + rateLimitRemaining = 1; + } + } + } + return responseList; + } catch (e) { + console.error("Error in batchDiscordRequests:", e); + throw e; + } +}; diff --git a/src/utils/timeUtils.ts b/src/utils/timeUtils.ts new file mode 100644 index 00000000..065047f9 --- /dev/null +++ b/src/utils/timeUtils.ts @@ -0,0 +1,7 @@ +export const addDelay = async (millisecond: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, millisecond)); +}; + +export const convertSecondsToMillis = (seconds: number): number => { + return Math.ceil(seconds * 1000); +}; diff --git a/tests/fixtures/fixture.ts b/tests/fixtures/fixture.ts index 5158220b..c56dceda 100644 --- a/tests/fixtures/fixture.ts +++ b/tests/fixtures/fixture.ts @@ -248,3 +248,15 @@ export const userFutureStatusMock: UserStatus = { }, message: "User Status found successfully.", }; + +export const memberGroupRoleList: memberGroupRole[] = [ + { userid: "XXXX", roleid: "XXXX" }, + { userid: "YYYY", roleid: "YYYY" }, + { userid: "ZZZZ", roleid: "ZZZZ" }, +]; + +export const memberGroupRoleResponseList = [ + { userid: "XXXX", roleid: "XXXX", success: true }, + { userid: "YYYY", roleid: "YYYY", success: true }, + { userid: "ZZZZ", roleid: "ZZZZ", success: true }, +]; diff --git a/tests/unit/handlers/guildRoleHandler.test.ts b/tests/unit/handlers/guildRoleHandler.test.ts index ad579564..cd7b89a5 100644 --- a/tests/unit/handlers/guildRoleHandler.test.ts +++ b/tests/unit/handlers/guildRoleHandler.test.ts @@ -1,16 +1,20 @@ import { getGuildRoleByRoleNameHandler, getGuildRolesHandler, + getGuildRolesPostHandler, } from "../../../src/controllers/guildRoleHandler"; import { Role } from "../../../src/typeDefinitions/role.types"; import JSONResponse from "../../../src/utils/JsonResponse"; import { generateDummyRequestObject, guildEnv, + memberGroupRoleList, + memberGroupRoleResponseList, rolesMock, } from "../../fixtures/fixture"; import * as responseConstants from "../../../src/constants/responses"; import * as guildRoleUtils from "../../../src/utils/guildRole"; +import { GROUP_ROLE_ADD } from "../../../src/constants/requestsActions"; jest.mock("../../../src/utils/verifyAuthToken", () => ({ verifyAuthToken: jest.fn().mockReturnValue(true), @@ -247,3 +251,98 @@ describe("get role by role name", () => { expect(role).toEqual(resultMock); }); }); + +describe("getGuildRolesPostHandler", () => { + beforeEach(() => { + jest.spyOn(global, "fetch").mockImplementation( + () => + new Promise((resolve) => { + return resolve(new JSONResponse({}, { status: 200 })); + }) + ); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + it("should return response with user id and status for bulk add group roles", async () => { + const mockRequest = generateDummyRequestObject({ + url: "/roles", + method: "POST", + headers: { Authorization: "Bearer testtoken" }, + json: () => Promise.resolve(memberGroupRoleList), + query: { action: GROUP_ROLE_ADD.ADD_ROLE }, + }); + const response = await getGuildRolesPostHandler(mockRequest, guildEnv); + expect(response).toBeInstanceOf(JSONResponse); + const responseBody = await response.json(); + expect(responseBody).toEqual(memberGroupRoleResponseList); + }); + + it("should return Bad Signature object if no auth headers provided", async () => { + const mockRequest = generateDummyRequestObject({ + url: "/roles", + method: "POST", + json: () => Promise.resolve(memberGroupRoleList), + query: { action: GROUP_ROLE_ADD.ADD_ROLE }, + }); + const response: JSONResponse = await getGuildRolesPostHandler( + mockRequest, + guildEnv + ); + const jsonResponse: { error: string } = await response.json(); + expect(jsonResponse).toEqual(responseConstants.BAD_SIGNATURE); + }); + + it("should return Bad Signature object if invalid action is provided", async () => { + const mockRequest = generateDummyRequestObject({ + url: "/roles", + method: "POST", + headers: { Authorization: "Bearer testtoken" }, + json: () => Promise.resolve(memberGroupRoleList), + query: { action: "INVALID_ACTION" }, + }); + const response: JSONResponse = await getGuildRolesPostHandler( + mockRequest, + guildEnv + ); + const jsonResponse: { error: string } = await response.json(); + expect(jsonResponse).toEqual(responseConstants.BAD_SIGNATURE); + }); + + it("should return Bad Signature if request body length is above 25", async () => { + const requestList = new Array(26).fill(memberGroupRoleList[0]); + const mockRequest = generateDummyRequestObject({ + url: "/roles", + method: "POST", + headers: { Authorization: "Bearer testtoken" }, + json: () => Promise.resolve(requestList), + query: { action: GROUP_ROLE_ADD.ADD_ROLE }, + }); + const response: JSONResponse = await getGuildRolesPostHandler( + mockRequest, + guildEnv + ); + const jsonResponse: { error: string } = await response.json(); + expect(jsonResponse).toEqual(responseConstants.BAD_SIGNATURE); + expect(response.statusText).toBe("Max requests length is 25"); + }); + + it("should return internal server error when theres an error", async () => { + const mockRequest = generateDummyRequestObject({ + url: "/roles", + method: "POST", + headers: { Authorization: "Bearer testtoken" }, + json: [], + query: { action: GROUP_ROLE_ADD.ADD_ROLE }, + }); + const response: JSONResponse = await getGuildRolesPostHandler( + mockRequest, + guildEnv + ); + const jsonResponse: { error: string } = await response.json(); + expect(jsonResponse).toEqual(responseConstants.INTERNAL_SERVER_ERROR); + }); +}); diff --git a/tests/unit/utils/batchDiscordRequests.test.ts b/tests/unit/utils/batchDiscordRequests.test.ts new file mode 100644 index 00000000..65f7b6c4 --- /dev/null +++ b/tests/unit/utils/batchDiscordRequests.test.ts @@ -0,0 +1,201 @@ +import { + batchDiscordRequests, + DISCORD_HEADERS, +} from "../../../src/utils/batchDiscordRequests"; +import JSONResponse from "../../../src/utils/JsonResponse"; + +describe("Utils | batchDiscordRequests", () => { + const rateLimitingHeaders = { + [DISCORD_HEADERS.RATE_LIMIT_REMAINING]: "9", + [DISCORD_HEADERS.RATE_LIMIT_RESET_AFTER]: "1.1", // seconds + }; + + const rateLimitExceededHeaders = { + [DISCORD_HEADERS.RETRY_AFTER]: "1.2", // seconds + }; + + let fetchSpy: jest.SpyInstance; + let setTimeoutSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest.spyOn(global, "fetch"); + setTimeoutSpy = jest.spyOn(global, "setTimeout"); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.restoreAllMocks(); + }); + + test("should execute requests when there are no headers", async () => { + fetchSpy.mockImplementation(() => + Promise.resolve(new JSONResponse({}, {})) + ); + const singleRequest = () => fetch("/abc", { method: "GET" }); + await batchDiscordRequests([singleRequest]); + expect(global.fetch).toHaveBeenCalledWith("/abc", { method: "GET" }); + expect(global.fetch).toBeCalledTimes(1); + }); + + test("should execute multiple requests when there are no headers", async () => { + fetchSpy.mockImplementation(() => + Promise.resolve(new JSONResponse({}, {})) + ); + const singleRequest = () => fetch("/abc", { method: "GET" }); + await batchDiscordRequests(new Array(20).fill(singleRequest)); + expect(global.fetch).toHaveBeenCalledWith("/abc", { method: "GET" }); + expect(global.fetch).toBeCalledTimes(20); + }); + + test("should execute requests when there are headers and input size is 40 with a limit of 3", async () => { + const maxRateLimit = 3; + const inputSize = 40; + let remainingRateLimit = maxRateLimit; + const headers = { ...rateLimitingHeaders }; + fetchSpy.mockImplementation( + () => + new Promise((resolve) => { + headers[DISCORD_HEADERS.RATE_LIMIT_REMAINING] = + remainingRateLimit.toString(); + remainingRateLimit--; + return resolve(new JSONResponse({}, { headers: headers })); + }) + ); + setTimeoutSpy.mockImplementation((resolve: any) => { + remainingRateLimit = maxRateLimit; + return resolve(); + }); + const singleRequest = () => fetch("/abc", { method: "GET" }); + await batchDiscordRequests(new Array(inputSize).fill(singleRequest)); + expect(global.fetch).toHaveBeenCalledWith("/abc", { method: "GET" }); + expect(global.fetch).toBeCalledTimes(inputSize); + }); + + test("should execute requests when there are headers and input size is 6 with a limit of 2", async () => { + const maxRateLimit = 3; + const inputSize = 6; + let remainingRateLimit = maxRateLimit; + const headers = { ...rateLimitingHeaders }; + fetchSpy.mockImplementation( + () => + new Promise((resolve) => { + headers[DISCORD_HEADERS.RATE_LIMIT_REMAINING] = + remainingRateLimit.toString(); + remainingRateLimit--; + return resolve(new JSONResponse({}, { headers: headers })); + }) + ); + setTimeoutSpy.mockImplementation((resolve: any) => { + remainingRateLimit = maxRateLimit; + return resolve(); + }); + const singleRequest = () => fetch("/abc", { method: "GET" }); + await batchDiscordRequests(new Array(inputSize).fill(singleRequest)); + expect(global.fetch).toHaveBeenCalledWith("/abc", { method: "GET" }); + expect(global.fetch).toBeCalledTimes(inputSize); + }); + + test("should retry fetch call when the API fails", async () => { + const headers = { ...rateLimitExceededHeaders }; + fetchSpy.mockImplementation( + () => + new Promise((resolve) => { + return resolve( + new JSONResponse({}, { headers: headers, status: 500 }) + ); + }) + ); + setTimeoutSpy.mockImplementation((resolve: any) => { + return resolve(); + }); + const singleRequest = () => fetch("/abc", { method: "GET" }); + await batchDiscordRequests([singleRequest]); + expect(global.fetch).toHaveBeenCalledWith("/abc", { method: "GET" }); + expect(global.fetch).toBeCalledTimes(2); + }); + + test("should retry only failed fetch calls", async () => { + const maxRateLimit = 3; + const inputSize = 6; + let remainingRateLimit = maxRateLimit; + let retries = 5; + const headers = { ...rateLimitingHeaders }; + fetchSpy.mockImplementation( + () => + new Promise((resolve) => { + const status = retries > 0 ? 500 : 200; + retries--; + headers[DISCORD_HEADERS.RATE_LIMIT_REMAINING] = + remainingRateLimit.toString(); + remainingRateLimit--; + return resolve( + new JSONResponse({}, { headers: headers, status: status }) + ); + }) + ); + setTimeoutSpy.mockImplementation((resolve: any) => { + remainingRateLimit = maxRateLimit; + return resolve(); + }); + const singleRequest = () => fetch("/abc", { method: "GET" }); + await batchDiscordRequests(new Array(inputSize).fill(singleRequest)); + expect(global.fetch).toHaveBeenCalledWith("/abc", { method: "GET" }); + expect(global.fetch).toBeCalledTimes(inputSize + 3); + }); + test("should retry only failed fetch calls", async () => { + const maxRateLimit = 3; + const inputSize = 6; + let remainingRateLimit = maxRateLimit; + let retries = 5; + const headers = { ...rateLimitingHeaders }; + fetchSpy.mockImplementation( + () => + new Promise((resolve) => { + const status = retries > 0 ? 500 : 200; + retries--; + headers[DISCORD_HEADERS.RATE_LIMIT_REMAINING] = + remainingRateLimit.toString(); + remainingRateLimit--; + return resolve( + new JSONResponse({}, { headers: headers, status: status }) + ); + }) + ); + setTimeoutSpy.mockImplementation((resolve: any) => { + remainingRateLimit = maxRateLimit; + return resolve(); + }); + const singleRequest = () => fetch("/abc", { method: "GET" }); + await batchDiscordRequests(new Array(inputSize).fill(singleRequest)); + expect(global.fetch).toHaveBeenCalledWith("/abc", { method: "GET" }); + expect(global.fetch).toBeCalledTimes(inputSize + 3); + }); + + test("should retry even for the rate limited exceeded headers", async () => { + const inputSize = 4; + const headers = { ...rateLimitExceededHeaders }; + fetchSpy.mockImplementation( + () => + new Promise((resolve) => { + return resolve( + new JSONResponse({}, { headers: headers, status: 500 }) + ); + }) + ); + setTimeoutSpy.mockImplementation((resolve: any) => { + return resolve(); + }); + const singleRequest = () => fetch("/abc", { method: "GET" }); + await batchDiscordRequests(new Array(inputSize).fill(singleRequest)); + expect(global.fetch).toHaveBeenCalledWith("/abc", { method: "GET" }); + expect(global.fetch).toBeCalledTimes(inputSize * 2); + }); + test("should handle network errors and continue processing", async () => { + const inputSize = 3; + fetchSpy.mockImplementation(() => Promise.reject("Network error")); + const singleRequest = () => fetch("/abc", { method: "GET" }); + await batchDiscordRequests(new Array(inputSize).fill(singleRequest)); + expect(global.fetch).toHaveBeenCalledWith("/abc", { method: "GET" }); + expect(global.fetch).toBeCalledTimes(inputSize * 2); + }); +});