diff --git a/src/constants/commands.ts b/src/constants/commands.ts index 7fa44026..f424ced2 100644 --- a/src/constants/commands.ts +++ b/src/constants/commands.ts @@ -34,6 +34,19 @@ export const MENTION_EACH = { ], }; +export const KICK = { + name: "kick", + description: "Kick a user from the server", + options: [ + { + name: "role", + description: "The role to kick", + type: 8, // User type + required: false, + }, + ], +}; + export const LISTENING = { name: "listening", description: "mark user as listening", diff --git a/src/controllers/baseHandler.ts b/src/controllers/baseHandler.ts index f41087fa..c827ab75 100644 --- a/src/controllers/baseHandler.ts +++ b/src/controllers/baseHandler.ts @@ -27,6 +27,7 @@ import { NOTIFY_ONBOARDING, OOO, USER, + KICK, } from "../constants/commands"; import { updateNickName } from "../utils/updateNickname"; import { discordEphemeralResponse } from "../utils/discordEphemeralResponse"; @@ -40,6 +41,7 @@ import { RETRY_COMMAND, } from "../constants/responses"; import { DevFlag } from "../typeDefinitions/filterUsersByRole"; +import { kickEachUser } from "./kickEachUser"; export async function baseHandler( message: discordMessageRequest, @@ -48,6 +50,8 @@ export async function baseHandler( ): Promise { const command = lowerCaseMessageCommands(message); + console.log("Message: ", JSON.stringify(message.data)); + console.log("Envior:", env); switch (command) { case getCommandName(HELLO): { return helloCommand(message.member.user.id); @@ -75,6 +79,14 @@ export async function baseHandler( return await mentionEachUser(transformedArgument, env, ctx); } + case getCommandName(KICK): { + const data = message.data?.options as Array; + const transformedArgument = { + roleToBeRemovedObj: data[0], + }; + return await kickEachUser(transformedArgument, env, ctx); + } + case getCommandName(LISTENING): { const data = message.data?.options; const setter = data ? data[0].value : false; diff --git a/src/controllers/kickEachUser.ts b/src/controllers/kickEachUser.ts new file mode 100644 index 00000000..192f57e4 --- /dev/null +++ b/src/controllers/kickEachUser.ts @@ -0,0 +1,34 @@ +import { + MentionEachUserOptions, + UserArray, +} from "../typeDefinitions/filterUsersByRole"; +import { env } from "../typeDefinitions/default.types"; +import { getMembersInServer } from "../utils/getMembersInServer"; +import { filterUserByRoles } from "../utils/filterUsersByRole"; +import { discordTextResponse } from "../utils/discordResponse"; +import { removeUsers } from "../utils/removeUsers"; + +export async function kickEachUser( + transformedArgument: { + roleToBeRemovedObj: MentionEachUserOptions; + }, + env: env, + ctx: ExecutionContext +) { + const getMembersInServerResponse = await getMembersInServer(env); + const roleId = transformedArgument.roleToBeRemovedObj.value; + + const usersWithMatchingRole = filterUserByRoles( + getMembersInServerResponse as UserArray[], + roleId + ); + + if (usersWithMatchingRole.length === 0) { + return discordTextResponse(`Found no users with the matched role.`); + } else { + ctx.waitUntil(removeUsers(env, usersWithMatchingRole)); + return discordTextResponse( + `Found ${usersWithMatchingRole.length} users with the matched role, removing them shortly...` + ); + } +} diff --git a/src/register.ts b/src/register.ts index a325aa3c..9631addc 100644 --- a/src/register.ts +++ b/src/register.ts @@ -8,6 +8,7 @@ import { NOTIFY_ONBOARDING, OOO, USER, + KICK, } from "./constants/commands"; import { config } from "dotenv"; import { DISCORD_BASE_URL } from "./constants/urls"; @@ -37,6 +38,7 @@ async function registerGuildCommands( USER, NOTIFY_OVERDUE, NOTIFY_ONBOARDING, + KICK, ]; try { diff --git a/src/utils/removeUsers.ts b/src/utils/removeUsers.ts new file mode 100644 index 00000000..24f010b3 --- /dev/null +++ b/src/utils/removeUsers.ts @@ -0,0 +1,27 @@ +import { env } from "../typeDefinitions/default.types"; +import { DISCORD_BASE_URL } from "../constants/urls"; + +export const removeUsers = async ( + env: env, + usersWithMatchingRole: string[] +) => { + const baseUrl = `${DISCORD_BASE_URL}/guilds/${env.DISCORD_GUILD_ID}/members`; + // Method : DELETE /guilds/{guild.id}/members/{user.id} + + for (const mention of usersWithMatchingRole) { + // Remove <@ and > symbols from the mention + const userId = mention.replace(/<@!*/g, "").replace(/>/g, ""); + const url = `${baseUrl}/${userId}`; + + const headers = { + "Content-Type": "application/json", + Authorization: `Bot ${env.DISCORD_TOKEN}`, + }; + + try { + await fetch(url, { method: "DELETE", headers }); + } catch (error) { + console.error(`Error removing user with ID ${userId}:`, error); + } + } +}; diff --git a/tests/unit/handlers/kickEachUser.test.ts b/tests/unit/handlers/kickEachUser.test.ts new file mode 100644 index 00000000..3d911ad7 --- /dev/null +++ b/tests/unit/handlers/kickEachUser.test.ts @@ -0,0 +1,64 @@ +import { kickEachUser } from "../../../src/controllers/kickEachUser"; +import { discordTextResponse } from "../../../src/utils/discordResponse"; +import { removeUsers } from "../../../src/utils/removeUsers"; +import { transformedArgument, ctx } from "../../fixtures/fixture"; + +describe("kickEachUser", () => { + it("should run when found no users with Matched Role", async () => { + const env = { + BOT_PUBLIC_KEY: "xyz", + DISCORD_GUILD_ID: "123", + DISCORD_TOKEN: "abc", + }; + + const { roleToBeTaggedObj } = transformedArgument; // Extracting roleToBeTaggedObj + const response = kickEachUser( + { roleToBeRemovedObj: roleToBeTaggedObj }, + env, + ctx + ); + + expect(response).toBeInstanceOf(Promise); + + const textMessage: { data: { content: string } } = await response.then( + (res) => res.json() + ); + expect(textMessage.data.content).toBe( + "Found no users with the matched role." + ); + }); + + it("should run when found users with Matched Role", async () => { + const env = { + BOT_PUBLIC_KEY: "xyz", + DISCORD_GUILD_ID: "123", + DISCORD_TOKEN: "abc", + }; + + const usersWithMatchingRole = [ + "<@282859044593598464>", + "<@725745030706364447>", + ] as string[]; + + const { roleToBeTaggedObj } = transformedArgument; // Extracting roleToBeTaggedObj + const response = kickEachUser( + { roleToBeRemovedObj: roleToBeTaggedObj }, + env, + ctx + ); + + expect(response).toEqual( + expect.objectContaining({ + data: { + content: + "Found 2 users with the matched role, removing them shortly...", + }, + }) + ); // Ensure correct response message + + // Check the arguments passed to removeUsers + expect(removeUsers).toHaveBeenCalledWith(env, usersWithMatchingRole); + + expect(response).toBeInstanceOf(Promise); + }); +}); diff --git a/tests/unit/utils/removeUsers.test.ts b/tests/unit/utils/removeUsers.test.ts new file mode 100644 index 00000000..b03c7069 --- /dev/null +++ b/tests/unit/utils/removeUsers.test.ts @@ -0,0 +1,70 @@ +import { DISCORD_BASE_URL } from "../../../src/constants/urls"; +import JSONResponse from "../../../src/utils/JsonResponse"; +import { removeUsers } from "../../../src/utils/removeUsers"; + +describe("removeUsers", () => { + const mockEnv = { + BOT_PUBLIC_KEY: "xyz", + DISCORD_GUILD_ID: "123", + DISCORD_TOKEN: "abc", + }; + + test("removes users successfully", async () => { + const usersWithMatchingRole = ["<@userId1>", "<@userId2>"]; + + jest + .spyOn(global, "fetch") + .mockImplementation(() => + Promise.resolve(new Response(null, { status: 204 })) + ); + await removeUsers(mockEnv, usersWithMatchingRole); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch).toHaveBeenCalledWith( + `${DISCORD_BASE_URL}/guilds/${mockEnv.DISCORD_GUILD_ID}/members/userId1`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${mockEnv.DISCORD_TOKEN}`, + }, + } + ); + expect(fetch).toHaveBeenCalledWith( + `${DISCORD_BASE_URL}/guilds/${mockEnv.DISCORD_GUILD_ID}/members/userId2`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${mockEnv.DISCORD_TOKEN}`, + }, + } + ); + }); + test("handles errors", async () => { + const usersWithMatchingRole = ["<@userId1>"]; + + // Mocking the fetch function to simulate a rejected promise with a 404 error response + jest + .spyOn(global, "fetch") + .mockImplementation(() => + Promise.reject(new Response(null, { status: 404 })) + ); + + // Calling the function under test + await removeUsers(mockEnv, usersWithMatchingRole); + + // Expectations + expect(fetch).toHaveBeenCalledTimes(3); + expect(fetch).toHaveBeenCalledWith( + `${DISCORD_BASE_URL}/guilds/${mockEnv.DISCORD_GUILD_ID}/members/userId1`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${mockEnv.DISCORD_TOKEN}`, + }, + } + ); + }); +});