diff --git a/package-lock.json b/package-lock.json index 7f88fd0c..a6270d30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@typescript-eslint/parser": "^5.47.1", "eslint": "^8.31.0", "jest": "^29.3.1", + "jest-fetch-mock": "^3.0.3", "ngrok": "^5.0.0-beta.2", "pre-commit": "^1.2.2", "prettier": "^2.8.1", @@ -2880,6 +2881,35 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4507,6 +4537,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "dependencies": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "node_modules/jest-get-type": { "version": "29.2.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", @@ -5807,6 +5847,12 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "dev": true + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -6520,6 +6566,12 @@ "node": ">=8.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, "node_modules/ts-jest": { "version": "29.0.5", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.0.5.tgz", @@ -6802,6 +6854,22 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9086,6 +9154,26 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "requires": { + "node-fetch": "^2.6.12" + }, + "dependencies": { + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "requires": { + "whatwg-url": "^5.0.0" + } + } + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -10284,6 +10372,16 @@ "jest-util": "^29.3.1" } }, + "jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "requires": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "jest-get-type": { "version": "29.2.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.2.0.tgz", @@ -11281,6 +11379,12 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "dev": true + }, "prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -11804,6 +11908,12 @@ "is-number": "^7.0.0" } }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, "ts-jest": { "version": "29.0.5", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.0.5.tgz", @@ -11986,6 +12096,22 @@ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==" }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index ed1a11d8..b769be4c 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@typescript-eslint/parser": "^5.47.1", "eslint": "^8.31.0", "jest": "^29.3.1", + "jest-fetch-mock": "^3.0.3", "ngrok": "^5.0.0-beta.2", "pre-commit": "^1.2.2", "prettier": "^2.8.1", diff --git a/src/controllers/baseHandler.ts b/src/controllers/baseHandler.ts index 89129e1f..ab2995ef 100644 --- a/src/controllers/baseHandler.ts +++ b/src/controllers/baseHandler.ts @@ -6,7 +6,7 @@ import { taskCommand } from "./taskCommand"; import { notifyCommand } from "./notifyCommand"; import { oooCommand } from "./oooCommand"; import { userCommand } from "./userCommand"; - +import { muteUser, unmuteUser } from "../utils/userMuteUnmuteActions"; import { getCommandName } from "../utils/getCommandName"; import JSONResponse from "../utils/JsonResponse"; import { lowerCaseMessageCommands } from "../utils/lowerCaseMessageCommand"; @@ -40,6 +40,14 @@ import { REMOVED_LISTENING_MESSAGE, RETRY_COMMAND, } from "../constants/responses"; + +import { DISCORD_BASE_URL } from "../constants/urls"; +import { + createMutedRole, + assignRoleToUser, + removeRoleFromUser, + getMutedRoleId, +} from "../utils/discordAPI"; import { DevFlag } from "../typeDefinitions/filterUsersByRole"; import { kickEachUser } from "./kickEachUser"; @@ -66,14 +74,13 @@ export async function baseHandler( } case getCommandName(MENTION_EACH): { const data = message.data?.options as Array; - // data[0] is role obj - // data[1] is message obj const transformedArgument = { roleToBeTaggedObj: data[0], displayMessageObj: data.find((item) => item.name === "message"), channelId: message.channel_id, dev: data.find((item) => item.name === "dev") as unknown as DevFlag, }; + return await mentionEachUser(transformedArgument, env); return await mentionEachUser(transformedArgument, env, ctx); } @@ -89,52 +96,57 @@ export async function baseHandler( case getCommandName(LISTENING): { const data = message.data?.options; const setter = data ? data[0].value : false; + const memberId = message.member.user.id.toString(); const nickname = removeListening(message.member.nick || ""); let discordEphemeral; let updateNickNameData = ""; try { if (setter) { - if ( - message.member.nick && - !message.member.nick.includes(NICKNAME_SUFFIX) - ) { - updateNickNameData = - NICKNAME_PREFIX + message.member.nick + NICKNAME_SUFFIX; - discordEphemeral = LISTENING_SUCCESS_MESSAGE; - } else if (!message.member.nick) { - (updateNickNameData = NICKNAME_PREFIX + "" + NICKNAME_SUFFIX), - (discordEphemeral = LISTENING_SUCCESS_MESSAGE); - } else { - updateNickNameData = message.member.nick; - discordEphemeral = ALREADY_LISTENING; + updateNickNameData = + NICKNAME_PREFIX + message.member.user.username + NICKNAME_SUFFIX; + discordEphemeral = LISTENING_SUCCESS_MESSAGE; + const mutedRoleId = await getMutedRoleId( + message.guild.id, + env.DISCORD_TOKEN + ); + if (!mutedRoleId) { + console.error("Muted role not found."); + return commandNotFound(); } - } else if ( - !setter && - !message.member.nick && - message.member.user.username.includes(NICKNAME_SUFFIX) - ) { - updateNickNameData = message.member.user.username + NICKNAME_SUFFIX; - - discordEphemeral = REMOVED_LISTENING_MESSAGE; + await assignRoleToUser( + message.guild.id, + memberId, + mutedRoleId, + env.DISCORD_TOKEN + ); + await muteUser(memberId, message.guild.id, env.DISCORD_TOKEN); } else { - if (message.member.nick?.includes(NICKNAME_SUFFIX)) { - updateNickNameData = nickname; - discordEphemeral = REMOVED_LISTENING_MESSAGE; - } else { - updateNickNameData = nickname; - discordEphemeral = NOTHING_CHANGED; + updateNickNameData = nickname; + discordEphemeral = REMOVED_LISTENING_MESSAGE; + const mutedRoleId = await getMutedRoleId( + message.guild.id, + env.DISCORD_TOKEN + ); + if (!mutedRoleId) { + console.error("Muted role not found."); + return commandNotFound(); } + await removeRoleFromUser( + message.guild.id, + memberId, + mutedRoleId, + env.DISCORD_TOKEN + ); + await unmuteUser(memberId, message.guild.id, env.DISCORD_TOKEN); } - await updateNickName( - message.member.user.id.toString(), - updateNickNameData, - env - ); + await updateNickName(memberId, updateNickNameData, env); return discordEphemeralResponse(discordEphemeral); } catch (err) { + console.error("Error:", err); return discordEphemeralResponse(RETRY_COMMAND); } } + case getCommandName(TASK): { const data = message.data?.options as Array; return await taskCommand(data[0].value); @@ -151,7 +163,6 @@ export async function baseHandler( const data = message.data?.options as Array; return await oooCommand(data[0].value); } - case getCommandName(USER): { const data = message.data?.options as Array; return await userCommand(data[0].value, env); diff --git a/src/typeDefinitions/discordMessage.types.d.ts b/src/typeDefinitions/discordMessage.types.d.ts index 451a712d..4c7da315 100644 --- a/src/typeDefinitions/discordMessage.types.d.ts +++ b/src/typeDefinitions/discordMessage.types.d.ts @@ -1,4 +1,5 @@ export interface discordMessageRequest { + guild: any; type: number; data: messageRequestData; member: messageRequestMember; diff --git a/src/utils/discordAPI.ts b/src/utils/discordAPI.ts new file mode 100644 index 00000000..ca07e11a --- /dev/null +++ b/src/utils/discordAPI.ts @@ -0,0 +1,125 @@ +import { DISCORD_BASE_URL } from "../constants/urls"; + +interface CreateRoleResponse { + id: string; +} + +export async function createMutedRole( + guildId: string, + token: string +): Promise { + try { + const response = await fetch( + `${DISCORD_BASE_URL}/guilds/${guildId}/roles`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${token}`, + }, + body: JSON.stringify({ + name: "Muted", + permissions: "0", + color: 0, + hoist: false, + mentionable: false, + }), + } + ); + + if (response.ok) { + const data: CreateRoleResponse = await response.json(); + return data.id; + } else { + console.error( + `Error creating muted role: ${response.status} - ${response.statusText}` + ); + return null; + } + } catch (error) { + console.error("Error occurred while creating muted role:", error); + return null; + } +} +export async function assignRoleToUser( + guildId: string, + userId: string, + roleId: string, + token: string +): Promise { + try { + await fetch( + `${DISCORD_BASE_URL}/guilds/${guildId}/members/${userId}/roles/${roleId}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${token}`, + }, + } + ); + } catch (error) { + console.error("Error occurred while assigning role to user:", error); + } +} + +export async function removeRoleFromUser( + guildId: string, + userId: string, + roleId: string, + token: string +): Promise { + try { + await fetch( + `${DISCORD_BASE_URL}/guilds/${guildId}/members/${userId}/roles/${roleId}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${token}`, + }, + } + ); + } catch (error) { + console.error("Error occurred while removing role from user:", error); + } +} + +interface Role { + id: string; + name: string; +} + +export async function getMutedRoleId( + guildId: string, + token: string +): Promise { + try { + const response = await fetch( + `${DISCORD_BASE_URL}/guilds/${guildId}/roles`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${token}`, + }, + } + ); + + if (response.ok) { + const roles: Role[] = await response.json(); + const mutedRole = roles.find( + (role) => role.name.toLowerCase() === "muted" + ); + return mutedRole ? mutedRole.id : null; + } else { + console.error( + `Error fetching roles: ${response.status} - ${response.statusText}` + ); + return null; + } + } catch (error) { + console.error("Error occurred while fetching roles:", error); + return null; + } +} diff --git a/src/utils/userMuteUnmuteActions.ts b/src/utils/userMuteUnmuteActions.ts new file mode 100644 index 00000000..24cb26a4 --- /dev/null +++ b/src/utils/userMuteUnmuteActions.ts @@ -0,0 +1,65 @@ +import { DISCORD_BASE_URL } from "../constants/urls"; + +export async function muteUser( + userId: string, + guildId: string, + token: string +): Promise { + try { + const response = await fetch( + `${DISCORD_BASE_URL}/guilds/${guildId}/members/${userId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${token}`, + }, + body: JSON.stringify({ + mute: true, + channel_id: null, + }), + } + ); + + if (response.ok) { + console.log("User muted successfully"); + } else { + console.error(`Error: ${response.status} - ${response.statusText}`); + } + } catch (error) { + console.error("Error occurred:", error); + throw error; + } +} + +export async function unmuteUser( + userId: string, + guildId: string, + token: string +): Promise { + try { + const response = await fetch( + `${DISCORD_BASE_URL}/guilds/${guildId}/members/${userId}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${token}`, + }, + body: JSON.stringify({ + mute: false, + channel_id: null, + }), + } + ); + + if (response.ok) { + console.log("User unmuted successfully"); + } else { + console.error(`Error: ${response.status} - ${response.statusText}`); + } + } catch (error) { + console.error("Error occurred:", error); + throw error; + } +} diff --git a/tests/fixtures/fixture.ts b/tests/fixtures/fixture.ts index ecc0453c..6f9ad172 100644 --- a/tests/fixtures/fixture.ts +++ b/tests/fixtures/fixture.ts @@ -24,6 +24,9 @@ export const dummyHelloMessage: discordMessageRequest = { joined_at: "2021-07-25T19:25:16.172000+00:00", }, guild_id: 123456, + + guild: undefined, + channel_id: 123456, }; @@ -42,6 +45,9 @@ export const dummyVerifyMessage: discordMessageRequest = { joined_at: "2021-07-25T19:25:16.172000+00:00", }, guild_id: 123456, + + guild: undefined, + channel_id: 123456, }; diff --git a/tests/unit/utils/userMuteUnmuteActions.test.ts b/tests/unit/utils/userMuteUnmuteActions.test.ts new file mode 100644 index 00000000..64d07f08 --- /dev/null +++ b/tests/unit/utils/userMuteUnmuteActions.test.ts @@ -0,0 +1,93 @@ +import { DISCORD_BASE_URL } from "../../../src/constants/urls"; +import { muteUser, unmuteUser } from "../../../src/utils/userMuteUnmuteActions"; +import fetchMock from "jest-fetch-mock"; +jest.mock("node-fetch", () => fetchMock); +const mockEnv = { + DISCORD_BASE_URL: DISCORD_BASE_URL, + DISCORD_TOKEN: "mockToken", +}; + +describe("User Mute and Unmute Actions", () => { + let userId: string; + let guildId: string; + + beforeEach(() => { + userId = "123456789"; + guildId = "987654321"; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const assertFetchCall = (url: string, bodyObj: any) => { + expect(fetch).toHaveBeenCalledWith(url, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${mockEnv.DISCORD_TOKEN}`, + }, + body: JSON.stringify(bodyObj), + }); + }; + + it("should mute a user successfully", async () => { + const url = `${mockEnv.DISCORD_BASE_URL}/guilds/${guildId}/members/${userId}`; + const bodyObj = { + mute: true, + channel_id: null, + }; + + jest + .spyOn(global, "fetch") + .mockImplementation(() => Promise.resolve(new Response())); + + await muteUser(userId, guildId, mockEnv.DISCORD_TOKEN); + + assertFetchCall(url, bodyObj); + }); + + it("should handle errors when muting a user", async () => { + const url = `${mockEnv.DISCORD_BASE_URL}/guilds/${guildId}/members/${userId}`; + + jest + .spyOn(global, "fetch") + .mockRejectedValueOnce(new Error("Failed to mute user")); + + await expect( + muteUser(userId, guildId, mockEnv.DISCORD_TOKEN) + ).rejects.toThrowError("Failed to mute user"); + + expect(fetch).toHaveBeenCalledWith(url, expect.any(Object)); + }); + + it("should unmute a user successfully", async () => { + const url = `${mockEnv.DISCORD_BASE_URL}/guilds/${guildId}/members/${userId}`; + const bodyObj = { + mute: false, + channel_id: null, + }; + + jest + .spyOn(global, "fetch") + .mockImplementation(() => Promise.resolve(new Response())); + + await unmuteUser(userId, guildId, mockEnv.DISCORD_TOKEN); + + assertFetchCall(url, bodyObj); + }); + + it("should handle errors when unmuting a user", async () => { + const url = `${mockEnv.DISCORD_BASE_URL}/guilds/${guildId}/members/${userId}`; + + jest + .spyOn(global, "fetch") + .mockRejectedValueOnce(new Error("Failed to unmute user")); + + await expect( + unmuteUser(userId, guildId, mockEnv.DISCORD_TOKEN) + ).rejects.toThrowError("Failed to unmute user"); + + expect(fetch).toHaveBeenCalledWith(url, expect.any(Object)); + }); +});