diff --git a/src/constants/commands.ts b/src/constants/commands.ts index a488de33..7fa44026 100644 --- a/src/constants/commands.ts +++ b/src/constants/commands.ts @@ -25,6 +25,12 @@ export const MENTION_EACH = { type: 3, require: false, }, + { + name: "dev", + description: "want to tag them individually?", + type: 5, + require: false, + }, ], }; diff --git a/src/controllers/baseHandler.ts b/src/controllers/baseHandler.ts index 639a74d8..22219b80 100644 --- a/src/controllers/baseHandler.ts +++ b/src/controllers/baseHandler.ts @@ -39,10 +39,12 @@ import { REMOVED_LISTENING_MESSAGE, RETRY_COMMAND, } from "../constants/responses"; +import { DevFlag } from "../typeDefinitions/filterUsersByRole"; export async function baseHandler( message: discordMessageRequest, - env: env + env: env, + ctx: ExecutionContext ): Promise { const command = lowerCaseMessageCommands(message); @@ -65,9 +67,11 @@ export async function baseHandler( // data[1] is message obj const transformedArgument = { roleToBeTaggedObj: data[0], - displayMessageObj: data[1] ?? {}, + 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); } case getCommandName(LISTENING): { diff --git a/src/controllers/mentionEachUser.ts b/src/controllers/mentionEachUser.ts index c9450768..ebe63602 100644 --- a/src/controllers/mentionEachUser.ts +++ b/src/controllers/mentionEachUser.ts @@ -6,27 +6,53 @@ import { env } from "../typeDefinitions/default.types"; import { UserArray, MentionEachUserOptions, + DevFlag, } from "../typeDefinitions/filterUsersByRole"; +import { mentionEachUserInMessage } from "../utils/guildRole"; import { checkDisplayType } from "../utils/checkDisplayType"; export async function mentionEachUser( transformedArgument: { roleToBeTaggedObj: MentionEachUserOptions; displayMessageObj?: MentionEachUserOptions; + channelId: number; + dev?: DevFlag; }, - env: env + env: env, + ctx: ExecutionContext ) { const getMembersInServerResponse = await getMembersInServer(env); const roleId = transformedArgument.roleToBeTaggedObj.value; const msgToBeSent = transformedArgument?.displayMessageObj?.value; + const dev = transformedArgument?.dev?.value || false; // optional chaining here only because display message obj is optional argument const usersWithMatchingRole = filterUserByRoles( getMembersInServerResponse as UserArray[], roleId ); - const responseData = checkDisplayType({ + const payload = { + channelId: transformedArgument.channelId, + roleId: roleId, + message: msgToBeSent, usersWithMatchingRole, - msgToBeSent, - }); - return discordTextResponse(responseData); + }; + if (!dev || usersWithMatchingRole.length === 0) { + const responseData = checkDisplayType({ + usersWithMatchingRole, + msgToBeSent, + }); + return discordTextResponse(responseData); + } 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/typeDefinitions/filterUsersByRole.d.ts b/src/typeDefinitions/filterUsersByRole.d.ts index 4d42fd2a..a54fd748 100644 --- a/src/typeDefinitions/filterUsersByRole.d.ts +++ b/src/typeDefinitions/filterUsersByRole.d.ts @@ -10,3 +10,8 @@ export type MentionEachUserOptions = { type: number; value: string; }; +export type DevFlag = { + name: string; + type: number; + value: boolean; +}; diff --git a/src/utils/batchDiscordRequests.ts b/src/utils/batchDiscordRequests.ts index bb333bc9..455c63e9 100644 --- a/src/utils/batchDiscordRequests.ts +++ b/src/utils/batchDiscordRequests.ts @@ -19,7 +19,7 @@ interface ResponseDetails { data: RequestDetails; } -const parseRateLimitRemaining = (response: Response) => { +export const parseRateLimitRemaining = (response: Response) => { let rateLimitRemaining = Number.parseInt( response.headers.get(DISCORD_HEADERS.RATE_LIMIT_REMAINING) || "0" ); @@ -27,7 +27,7 @@ const parseRateLimitRemaining = (response: Response) => { return rateLimitRemaining; }; -const parseResetAfter = (response: Response) => { +export const parseResetAfter = (response: Response) => { let resetAfter = Number.parseFloat( response.headers.get(DISCORD_HEADERS.RATE_LIMIT_RESET_AFTER) || "0" ); diff --git a/src/utils/guildRole.ts b/src/utils/guildRole.ts index 389762f2..11eb34cb 100644 --- a/src/utils/guildRole.ts +++ b/src/utils/guildRole.ts @@ -6,14 +6,22 @@ import { ROLE_REMOVED, } from "../constants/responses"; import { DISCORD_BASE_URL } from "../constants/urls"; +import { + parseRateLimitRemaining, + parseResetAfter, +} from "../utils/batchDiscordRequests"; + import { env } from "../typeDefinitions/default.types"; import { createNewRole, + discordMessageError, + discordMessageRequest, guildRoleResponse, memberGroupRole, } from "../typeDefinitions/discordMessage.types"; import { GuildRole, Role } from "../typeDefinitions/role.types"; import createDiscordHeaders from "./createDiscordHeaders"; +import { sleep } from "./sleep"; export async function createGuildRole( body: createNewRole, @@ -135,3 +143,68 @@ 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 = 5; + let waitTillNextAPICall = 0; + try { + const failedUsers: Array = []; + 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) => { + const rateLimitRemaining = parseRateLimitRemaining(response); + if (rateLimitRemaining === 0) { + waitTillNextAPICall = Math.max( + parseResetAfter(response), + waitTillNextAPICall + ); + } + return response.json(); + }) as Promise; + }); + const responses = await Promise.all(messageRequest); + responses.forEach((response, i) => { + if (response && "message" in response) { + failedUsers.push(batchwiseUserIds[i]); + console.error(`Failed to mention a user`); + } + }); + await sleep(waitTillNextAPICall * 1000); + waitTillNextAPICall = 0; + } + if (failedUsers.length > 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 ${failedUsers} individually.`, + }), + }); + } + } catch (error) { + console.log("Error occured while running mentionEachUserInMessage", error); + } +} diff --git a/src/utils/sleep.ts b/src/utils/sleep.ts new file mode 100644 index 00000000..9e46de17 --- /dev/null +++ b/src/utils/sleep.ts @@ -0,0 +1,5 @@ +export function sleep(delay = 1000) { + return new Promise((resolve) => { + setTimeout(resolve, delay); + }); +} diff --git a/tests/fixtures/fixture.ts b/tests/fixtures/fixture.ts index 9d31bc8f..a6aadcbd 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,19 @@ export const onlyRoleToBeTagged = { type: 8, value: "1118201414078976192", }, + channelId: 1244, + dev: { + name: "dev", + type: 4, + value: false, + }, }; +export const ctx = { + /* eslint-disable @typescript-eslint/no-empty-function */ + waitUntil: (promise: void | Promise): void => {}, + passThroughOnException: (): void => {}, +}; export const generateDummyRequestObject = ({ url, method, @@ -165,6 +179,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..dc23bc22 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,52 @@ 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 in dev mode", async () => { + const env = { + BOT_PUBLIC_KEY: "xyz", + DISCORD_GUILD_ID: "123", + DISCORD_TOKEN: "abc", + }; + const response = mentionEachUser( + { + ...onlyRoleToBeTagged, + dev: { + name: "dev", + type: 4, + value: true, + }, + }, + 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 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..047a2c1a 100644 --- a/tests/unit/utils/guildRole.test.ts +++ b/tests/unit/utils/guildRole.test.ts @@ -6,13 +6,16 @@ import { removeGuildRole, getGuildRoles, getGuildRoleByName, + mentionEachUserInMessage, } from "../../../src/utils/guildRole"; import { dummyAddRoleBody, dummyCreateBody, guildEnv, + mockMessageResponse, rolesMock, } from "../../fixtures/fixture"; +import { DiscordMessageResponse } 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 +312,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 + }); +}); diff --git a/tests/unit/utils/sleep.test.ts b/tests/unit/utils/sleep.test.ts new file mode 100644 index 00000000..70a57005 --- /dev/null +++ b/tests/unit/utils/sleep.test.ts @@ -0,0 +1,18 @@ +import { sleep } from "../../../src/utils/sleep"; +jest.useFakeTimers(); + +describe("sleep function", () => { + afterAll(() => jest.useRealTimers()); + test("should resolve after the specified delay", async () => { + const delay = 2000; + const promise = sleep(delay); + jest.advanceTimersByTime(delay); + await expect(promise).resolves.toBeUndefined(); + }); + + test("should resolve after default delay if no delay is provided", async () => { + const promise = sleep(); + jest.advanceTimersByTime(1000); + await expect(promise).resolves.toBeUndefined(); + }); +});