From 49ee62cc14ccc35661191176c33bd4c6652f38c2 Mon Sep 17 00:00:00 2001 From: Joy Date: Fri, 8 Mar 2024 01:05:41 +0530 Subject: [PATCH] Feat : Updated mention each flow --- src/controllers/baseHandler.ts | 6 +- src/controllers/mentionEachUser.ts | 30 +++++++-- src/index.ts | 12 ++-- src/typeDefinitions/discordMessage.types.d.ts | 53 ++++++++++++++++ src/utils/guildRole.ts | 63 +++++++++++++++++++ tests/fixtures/fixture.ts | 58 +++++++++++++++++ tests/unit/handlers/mentionEachUser.test.ts | 13 +++- tests/unit/utils/guildRole.test.ts | 47 ++++++++++++++ 8 files changed, 267 insertions(+), 15 deletions(-) diff --git a/src/controllers/baseHandler.ts b/src/controllers/baseHandler.ts index 639a74d8..835ea589 100644 --- a/src/controllers/baseHandler.ts +++ b/src/controllers/baseHandler.ts @@ -42,7 +42,8 @@ import { export async function baseHandler( message: discordMessageRequest, - env: env + env: env, + ctx: ExecutionContext ): Promise { const command = lowerCaseMessageCommands(message); @@ -66,8 +67,9 @@ export async function baseHandler( const transformedArgument = { roleToBeTaggedObj: data[0], displayMessageObj: data[1] ?? {}, + channelId: message.channel_id, }; - return await mentionEachUser(transformedArgument, env); + return await mentionEachUser(transformedArgument, env, ctx); } case getCommandName(LISTENING): { diff --git a/src/controllers/mentionEachUser.ts b/src/controllers/mentionEachUser.ts index c9450768..724af258 100644 --- a/src/controllers/mentionEachUser.ts +++ b/src/controllers/mentionEachUser.ts @@ -7,14 +7,16 @@ import { UserArray, MentionEachUserOptions, } from "../typeDefinitions/filterUsersByRole"; -import { checkDisplayType } from "../utils/checkDisplayType"; +import { mentionEachUserInMessage } from "../utils/guildRole"; export async function mentionEachUser( transformedArgument: { roleToBeTaggedObj: MentionEachUserOptions; displayMessageObj?: MentionEachUserOptions; + channelId: number; }, - env: env + env: env, + ctx: ExecutionContext ) { const getMembersInServerResponse = await getMembersInServer(env); const roleId = transformedArgument.roleToBeTaggedObj.value; @@ -24,9 +26,25 @@ export async function mentionEachUser( getMembersInServerResponse as UserArray[], roleId ); - const responseData = checkDisplayType({ + const payload = { + channelId: transformedArgument.channelId, + roleId: roleId, + message: msgToBeSent, usersWithMatchingRole, - msgToBeSent, - }); - return discordTextResponse(responseData); + }; + if (usersWithMatchingRole.length === 0) { + return discordTextResponse("Sorry no user found under this role."); + } else { + ctx.waitUntil( + mentionEachUserInMessage({ + message: payload.message, + userIds: payload.usersWithMatchingRole, + channelId: payload.channelId, + env, + }) + ); + return discordTextResponse( + `Found ${usersWithMatchingRole.length} users with matched role, mentioning them shortly...` + ); + } } diff --git a/src/index.ts b/src/index.ts index c3a5881a..e3e7bb4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,7 +57,7 @@ router.delete("/roles", removeGuildRoleHandler); router.post("/profile/blocked", sendProfileBlockedMessage); -router.post("/", async (request, env) => { +router.post("/", async (request, env, ctx: ExecutionContext) => { const message: discordMessageRequest = await request.json(); if (message.type === InteractionType.PING) { @@ -66,7 +66,7 @@ router.post("/", async (request, env) => { }); } if (message.type === InteractionType.APPLICATION_COMMAND) { - return baseHandler(message, env); + return baseHandler(message, env, ctx); } return new JSONResponse(response.UNKNOWN_INTERACTION, { status: 400 }); }); @@ -78,7 +78,11 @@ router.all("*", async () => { }); export default { - async fetch(request: Request, env: env): Promise { + async fetch( + request: Request, + env: env, + ctx: ExecutionContext + ): Promise { const apiUrls = ["/invite", "/roles", "/profile/blocked"]; const url = new URL(request.url); if (request.method === "POST" && !apiUrls.includes(url.pathname)) { @@ -87,7 +91,7 @@ export default { return new JSONResponse(response.BAD_SIGNATURE, { status: 401 }); } } - return router.handle(request, env); + return router.handle(request, env, ctx); }, async scheduled(req: Request, env: env, ctx: ExecutionContext) { diff --git a/src/typeDefinitions/discordMessage.types.d.ts b/src/typeDefinitions/discordMessage.types.d.ts index 032d1811..f46be204 100644 --- a/src/typeDefinitions/discordMessage.types.d.ts +++ b/src/typeDefinitions/discordMessage.types.d.ts @@ -3,6 +3,59 @@ export interface discordMessageRequest { data: messageRequestData; member: messageRequestMember; guild_id: number; + channel_id: number; +} + +export interface DiscordMessageResponse { + id: string; + type: number; + content: string; + channel_id: string; + author: { + id: string; + username: string; + avatar: string | null; + discriminator: string; + public_flags: number; + premium_type: number; + flags: number; + bot: boolean; + banner: string | null; + accent_color: string | null; + global_name: string | null; + avatar_decoration_data: string | null; + banner_color: string | null; + }; + attachments: Array; + embeds: Array; + mentions: { + id: string; + username: string; + avatar: string | null; + discriminator: string; + public_flags: number; + premium_type: number; + flags: number; + banner: string | null; + accent_color: string | null; + global_name: string | null; + avatar_decoration_data: string | null; + banner_color: string | null; + }[]; + mention_roles: Array; + pinned: boolean; + mention_everyone: boolean; + tts: boolean; + timestamp: string; + edited_timestamp: string | null; + flags: number; + components: Array; + referenced_message: Arra | null; +} + +export interface discordMessageError { + code: number; + message: string; } export interface messageRequestData { diff --git a/src/utils/guildRole.ts b/src/utils/guildRole.ts index 389762f2..860a94b5 100644 --- a/src/utils/guildRole.ts +++ b/src/utils/guildRole.ts @@ -8,7 +8,10 @@ import { import { DISCORD_BASE_URL } from "../constants/urls"; import { env } from "../typeDefinitions/default.types"; import { + DiscordMessageResponse, createNewRole, + discordMessageError, + discordMessageRequest, guildRoleResponse, memberGroupRole, } from "../typeDefinitions/discordMessage.types"; @@ -135,3 +138,63 @@ export async function getGuildRoleByName( const roles = await getGuildRoles(env); return roles?.find((role) => role.name === roleName); } + +export async function mentionEachUserInMessage({ + message, + userIds, + channelId, + env, +}: { + message?: string; + userIds: string[]; + channelId: number; + env: env; +}) { + const batchSize = 10; + let failedAPICalls = 0; + try { + for (let i = 0; i < userIds.length; i += batchSize) { + const batchwiseUserIds = userIds.slice(i, i + batchSize); + const messageRequest = batchwiseUserIds.map((userId) => { + return fetch(`${DISCORD_BASE_URL}/channels/${channelId}/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${env.DISCORD_TOKEN}`, + }, + body: JSON.stringify({ + content: `${message ? message + " " : ""} ${userId}`, + }), + }).then((response) => response.json()) as Promise< + discordMessageRequest | discordMessageError + >; + }); + const responses = await Promise.all(messageRequest); + responses.forEach((response) => { + if ( + response && + "message" in response && + response.message === "404: Not Found" + ) { + failedAPICalls += 1; + console.error(`Failed to mention a user`); + } + }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + if (failedAPICalls > 0) { + await fetch(`${DISCORD_BASE_URL}/channels/${channelId}/messages`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${env.DISCORD_TOKEN}`, + }, + body: JSON.stringify({ + content: `Failed to tag ${failedAPICalls} users`, + }), + }); + } + } catch (error) { + console.log("Error occured while running mentionEachUserInMessage", error); + } +} diff --git a/tests/fixtures/fixture.ts b/tests/fixtures/fixture.ts index 9d31bc8f..668cfe71 100644 --- a/tests/fixtures/fixture.ts +++ b/tests/fixtures/fixture.ts @@ -23,6 +23,7 @@ export const dummyHelloMessage: discordMessageRequest = { }, }, guild_id: 123456, + channel_id: 123456, }; export const dummyVerifyMessage: discordMessageRequest = { @@ -39,6 +40,7 @@ export const dummyVerifyMessage: discordMessageRequest = { }, }, guild_id: 123456, + channel_id: 123456, }; export const dummyCreateBody: createNewRole = { @@ -95,6 +97,7 @@ export const transformedArgument = { value: "1118201414078976192", }, displayMessageObj: { name: "message", type: 3, value: "hello" }, + channelId: 1244, }; export const onlyRoleToBeTagged = { @@ -103,8 +106,14 @@ export const onlyRoleToBeTagged = { type: 8, value: "1118201414078976192", }, + channelId: 1244, }; +export const ctx = { + /* eslint-disable @typescript-eslint/no-empty-function */ + waitUntil: (promise: void | Promise): void => {}, + passThroughOnException: (): void => {}, +}; export const generateDummyRequestObject = ({ url, method, @@ -165,6 +174,55 @@ export const discordUserData = { }, }; +export const mockMessageResponse = { + id: "1215369965792665620", + type: 0, + content: "<@849364584674492426>", + channel_id: "868936963456126991", + author: { + id: "1205843978088620144", + username: "Joy Bot", + avatar: null, + discriminator: "7363", + public_flags: 524288, + premium_type: 0, + flags: 524288, + bot: true, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + banner_color: null, + }, + attachments: [], + embeds: [], + mentions: [ + { + id: "849364584674492426", + username: "Aniket", + avatar: null, + discriminator: "1514", + public_flags: 0, + premium_type: 0, + flags: 0, + banner: null, + accent_color: null, + global_name: null, + avatar_decoration_data: null, + banner_color: null, + }, + ], + mention_roles: [], + pinned: false, + mention_everyone: false, + tts: false, + timestamp: "2024-03-07T18:46:20.327000+00:00", + edited_timestamp: null, + flags: 0, + components: [], + referenced_message: null, +}; + export const userBackendMock: UserBackend = { message: "User returned successfully", user: { diff --git a/tests/unit/handlers/mentionEachUser.test.ts b/tests/unit/handlers/mentionEachUser.test.ts index 89aa552f..6ad03eb9 100644 --- a/tests/unit/handlers/mentionEachUser.test.ts +++ b/tests/unit/handlers/mentionEachUser.test.ts @@ -4,6 +4,7 @@ import { filterUserByRoles } from "../../../src/utils/filterUsersByRole"; import { onlyRoleToBeTagged, transformedArgument, + ctx, } from "../../fixtures/fixture"; describe("Test mention each function", () => { @@ -14,19 +15,25 @@ describe("Test mention each function", () => { DISCORD_TOKEN: "abc", }; - const response = mentionEachUser(transformedArgument, env); + const response = mentionEachUser(transformedArgument, env, ctx); expect(response).toBeInstanceOf(Promise); }); - it("should run without displayMessageObj argument", () => { + it("should run without displayMessageObj argument", async () => { const env = { BOT_PUBLIC_KEY: "xyz", DISCORD_GUILD_ID: "123", DISCORD_TOKEN: "abc", }; - const response = mentionEachUser(onlyRoleToBeTagged, env); + const response = mentionEachUser(onlyRoleToBeTagged, env, ctx); expect(response).toBeInstanceOf(Promise); + const textMessage: { data: { content: string } } = await response.then( + (res) => res.json() + ); + expect(textMessage.data.content).toBe( + "Sorry no user found under this role." + ); }); it("should return users with matching roles", () => { diff --git a/tests/unit/utils/guildRole.test.ts b/tests/unit/utils/guildRole.test.ts index 618e0f17..0ee8fca7 100644 --- a/tests/unit/utils/guildRole.test.ts +++ b/tests/unit/utils/guildRole.test.ts @@ -6,13 +6,19 @@ import { removeGuildRole, getGuildRoles, getGuildRoleByName, + mentionEachUserInMessage, } from "../../../src/utils/guildRole"; import { dummyAddRoleBody, dummyCreateBody, guildEnv, + mockMessageResponse, rolesMock, } from "../../fixtures/fixture"; +import { + DiscordMessageResponse, + discordMessageRequest, +} from "../../../src/typeDefinitions/discordMessage.types"; describe("createGuildRole", () => { it("should pass the reason to discord as a X-Audit-Log-Reason header if provided", async () => { @@ -309,3 +315,44 @@ describe("getGuildRolesByName", () => { expect(role).toBeUndefined(); }); }); +describe("mentionEachUserInMessage", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + afterEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + it("should send messages to users in batches", async () => { + const mockResponse: DiscordMessageResponse = mockMessageResponse; + jest + .spyOn(global, "fetch") + .mockReturnValueOnce(Promise.resolve(new JSONResponse(mockResponse))); + + const message = "Test message"; + const userIds = ["user1", "user2", "user3"]; + const channelId = 123; + const env = { DISCORD_TOKEN: "your_token_here" }; + + await mentionEachUserInMessage({ message, userIds, channelId, env }); + expect(fetch).toHaveBeenCalledTimes(3); + }); + it("should send a message of failed api calls at the end", async () => { + let fetchCallCount = 0; + jest.spyOn(global, "fetch").mockImplementation(async () => { + if (fetchCallCount < 3) { + fetchCallCount++; + return Promise.resolve(new JSONResponse({ message: "404: Not Found" })); + } else { + return Promise.resolve(new JSONResponse({ ok: true })); + } + }); + const message = "Test message"; + const userIds = ["user1", "user2", "user3"]; + const channelId = 123; + const env = { DISCORD_TOKEN: "your_token_here" }; + + await mentionEachUserInMessage({ message, userIds, channelId, env }); + expect(fetch).toHaveBeenCalledTimes(4); // should send a message of failed api calls at the end + }); +});