Skip to content

Commit ac2507c

Browse files
authored
Merge pull request #218 from rishirishhh/feat/remove-tagged-users
2 parents 2ca525c + 53f1444 commit ac2507c

File tree

7 files changed

+270
-0
lines changed

7 files changed

+270
-0
lines changed

src/constants/commands.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@ export const MENTION_EACH = {
3434
],
3535
};
3636

37+
export const REMOVE = {
38+
name: "remove",
39+
description: "remove user/users from the server",
40+
options: [
41+
{
42+
name: "role",
43+
description: "remove developers with specific role",
44+
type: 8, // User type
45+
required: true,
46+
},
47+
],
48+
};
49+
3750
export const LISTENING = {
3851
name: "listening",
3952
description: "mark user as listening",

src/controllers/baseHandler.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
NOTIFY_ONBOARDING,
2828
OOO,
2929
USER,
30+
REMOVE,
3031
} from "../constants/commands";
3132
import { updateNickName } from "../utils/updateNickname";
3233
import { discordEphemeralResponse } from "../utils/discordEphemeralResponse";
@@ -40,6 +41,7 @@ import {
4041
RETRY_COMMAND,
4142
} from "../constants/responses";
4243
import { DevFlag } from "../typeDefinitions/filterUsersByRole";
44+
import { kickEachUser } from "./kickEachUser";
4345

4446
export async function baseHandler(
4547
message: discordMessageRequest,
@@ -75,6 +77,15 @@ export async function baseHandler(
7577
return await mentionEachUser(transformedArgument, env, ctx);
7678
}
7779

80+
case getCommandName(REMOVE): {
81+
const data = message.data?.options as Array<messageRequestDataOptions>;
82+
const transformedArgument = {
83+
roleToBeRemovedObj: data[0],
84+
channelId: message.channel_id,
85+
};
86+
return await kickEachUser(transformedArgument, env, ctx);
87+
}
88+
7889
case getCommandName(LISTENING): {
7990
const data = message.data?.options;
8091
const setter = data ? data[0].value : false;

src/controllers/kickEachUser.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {
2+
MentionEachUserOptions,
3+
UserArray,
4+
} from "../typeDefinitions/filterUsersByRole";
5+
import { env } from "../typeDefinitions/default.types";
6+
import { getMembersInServer } from "../utils/getMembersInServer";
7+
import { filterUserByRoles } from "../utils/filterUsersByRole";
8+
import { discordTextResponse } from "../utils/discordResponse";
9+
import { removeUsers } from "../utils/removeUsers";
10+
11+
export async function kickEachUser(
12+
transformedArgument: {
13+
roleToBeRemovedObj: MentionEachUserOptions;
14+
channelId: number;
15+
},
16+
env: env,
17+
ctx: ExecutionContext
18+
) {
19+
const getMembersInServerResponse = await getMembersInServer(env);
20+
const roleId = transformedArgument.roleToBeRemovedObj.value;
21+
22+
const usersWithMatchingRole = filterUserByRoles(
23+
getMembersInServerResponse as UserArray[],
24+
roleId
25+
);
26+
27+
const usersText = usersWithMatchingRole
28+
.map((user) => {
29+
return user;
30+
})
31+
.join("\n");
32+
33+
if (usersWithMatchingRole.length === 0) {
34+
return discordTextResponse(
35+
`We couldn't find any user(s) assigned to <@&${roleId}> role.`
36+
);
37+
} else {
38+
ctx.waitUntil(
39+
removeUsers(env, usersWithMatchingRole, transformedArgument.channelId)
40+
);
41+
const responseText = `Following users will be removed shortly ..... :\n${usersText}`;
42+
return discordTextResponse(responseText);
43+
}
44+
}

src/register.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
NOTIFY_ONBOARDING,
99
OOO,
1010
USER,
11+
REMOVE,
1112
} from "./constants/commands";
1213
import { config } from "dotenv";
1314
import { DISCORD_BASE_URL } from "./constants/urls";
@@ -37,6 +38,7 @@ async function registerGuildCommands(
3738
USER,
3839
NOTIFY_OVERDUE,
3940
NOTIFY_ONBOARDING,
41+
REMOVE,
4042
];
4143

4244
try {

src/utils/removeUsers.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { env } from "../typeDefinitions/default.types";
2+
import { DISCORD_BASE_URL } from "../constants/urls";
3+
import {
4+
parseRateLimitRemaining,
5+
parseResetAfter,
6+
} from "./batchDiscordRequests";
7+
import {
8+
discordMessageRequest,
9+
discordMessageError,
10+
} from "../typeDefinitions/discordMessage.types";
11+
12+
export async function removeUsers(
13+
env: env,
14+
usersWithMatchingRole: string[],
15+
channelId: number
16+
) {
17+
const batchSize = 4;
18+
let waitTillNextAPICall = 0;
19+
20+
try {
21+
const failedUsers: Array<string> = [];
22+
for (let i = 0; i < usersWithMatchingRole.length; i += batchSize) {
23+
const batchwiseUsers = usersWithMatchingRole.slice(i, i + batchSize);
24+
const deleteRequests = batchwiseUsers.map((mention) => {
25+
const userId = mention.replace(/<@!*/g, "").replace(/>/g, "");
26+
const url = `${DISCORD_BASE_URL}/guilds/${env.DISCORD_GUILD_ID}/members/${userId}`;
27+
28+
return fetch(url, {
29+
method: "DELETE",
30+
headers: {
31+
"Content-Type": "application/json",
32+
Authorization: `Bot ${env.DISCORD_TOKEN}`,
33+
},
34+
}).then((response) => {
35+
const rateLimitRemaining = parseRateLimitRemaining(response);
36+
if (rateLimitRemaining === 0) {
37+
waitTillNextAPICall = Math.max(
38+
parseResetAfter(response),
39+
waitTillNextAPICall
40+
);
41+
}
42+
return response.json();
43+
}) as Promise<discordMessageRequest | discordMessageError>;
44+
});
45+
46+
const responses = await Promise.all(deleteRequests);
47+
responses.forEach((response, i) => {
48+
if (response && "message" in response) {
49+
failedUsers.push(batchwiseUsers[i]);
50+
console.error(`Failed to remove a user`);
51+
}
52+
});
53+
await sleep(waitTillNextAPICall * 1000);
54+
waitTillNextAPICall = 0;
55+
}
56+
57+
if (failedUsers.length > 0) {
58+
await fetch(`${DISCORD_BASE_URL}/channels/${channelId}/messages`, {
59+
method: "POST",
60+
headers: {
61+
"Content-Type": "application/json",
62+
Authorization: `Bot ${env.DISCORD_TOKEN}`,
63+
},
64+
body: JSON.stringify({
65+
content: `Failed to remove ${failedUsers}.`,
66+
}),
67+
});
68+
}
69+
} catch (error) {
70+
console.error("Error occurred while removing users:", error);
71+
}
72+
}
73+
74+
function sleep(ms: number) {
75+
return new Promise((resolve) => setTimeout(resolve, ms));
76+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { kickEachUser } from "../../../src/controllers/kickEachUser";
2+
import { transformedArgument, ctx } from "../../fixtures/fixture";
3+
4+
describe("kickEachUser", () => {
5+
it("should run when found no users with Matched Role", async () => {
6+
const env = {
7+
BOT_PUBLIC_KEY: "xyz",
8+
DISCORD_GUILD_ID: "123",
9+
DISCORD_TOKEN: "abc",
10+
};
11+
12+
const { roleToBeTaggedObj } = transformedArgument; // Extracting roleToBeTaggedObj
13+
const response = kickEachUser(
14+
{ roleToBeRemovedObj: roleToBeTaggedObj, channelId: 12345 },
15+
env,
16+
ctx
17+
);
18+
19+
const roleID = roleToBeTaggedObj.value;
20+
21+
expect(response).toBeInstanceOf(Promise);
22+
23+
const textMessage: { data: { content: string } } = await response.then(
24+
(res) => res.json()
25+
);
26+
expect(textMessage.data.content).toBe(
27+
`We couldn't find any user(s) assigned to <@&${roleID}> role.`
28+
);
29+
});
30+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { DISCORD_BASE_URL } from "../../../src/constants/urls";
2+
import JSONResponse from "../../../src/utils/JsonResponse";
3+
import { removeUsers } from "../../../src/utils/removeUsers";
4+
5+
describe("removeUsers", () => {
6+
const mockEnv = {
7+
BOT_PUBLIC_KEY: "xyz",
8+
DISCORD_GUILD_ID: "123",
9+
DISCORD_TOKEN: "abc",
10+
};
11+
12+
beforeEach(() => {
13+
jest.clearAllMocks();
14+
});
15+
afterEach(() => {
16+
jest.clearAllMocks();
17+
jest.resetAllMocks();
18+
});
19+
20+
test("removes users successfully", async () => {
21+
const usersWithMatchingRole = ["<@userId1>", "<@userId2>"];
22+
23+
jest
24+
.spyOn(global, "fetch")
25+
.mockImplementation(() =>
26+
Promise.resolve(new Response(null, { status: 204 }))
27+
);
28+
await removeUsers(mockEnv, usersWithMatchingRole, 21121);
29+
30+
expect(fetch).toHaveBeenCalledTimes(2);
31+
expect(fetch).toHaveBeenCalledWith(
32+
`${DISCORD_BASE_URL}/guilds/${mockEnv.DISCORD_GUILD_ID}/members/userId1`,
33+
{
34+
method: "DELETE",
35+
headers: {
36+
"Content-Type": "application/json",
37+
Authorization: `Bot ${mockEnv.DISCORD_TOKEN}`,
38+
},
39+
}
40+
);
41+
expect(fetch).toHaveBeenCalledWith(
42+
`${DISCORD_BASE_URL}/guilds/${mockEnv.DISCORD_GUILD_ID}/members/userId2`,
43+
{
44+
method: "DELETE",
45+
headers: {
46+
"Content-Type": "application/json",
47+
Authorization: `Bot ${mockEnv.DISCORD_TOKEN}`,
48+
},
49+
}
50+
);
51+
});
52+
test("handles errors", async () => {
53+
const usersWithMatchingRole = ["<@userId1>"];
54+
55+
// Mocking the fetch function to simulate a rejected promise with a 404 error response
56+
jest
57+
.spyOn(global, "fetch")
58+
.mockImplementation(() =>
59+
Promise.reject(new Response(null, { status: 404 }))
60+
);
61+
62+
// Calling the function under test
63+
await removeUsers(mockEnv, usersWithMatchingRole, 23231);
64+
65+
// Expectations
66+
expect(fetch).toHaveBeenCalledWith(
67+
`${DISCORD_BASE_URL}/guilds/${mockEnv.DISCORD_GUILD_ID}/members/userId1`,
68+
{
69+
method: "DELETE",
70+
headers: {
71+
"Content-Type": "application/json",
72+
Authorization: `Bot ${mockEnv.DISCORD_TOKEN}`,
73+
},
74+
}
75+
);
76+
});
77+
78+
it("should send a message of failed api calls at the end", async () => {
79+
let fetchCallCount = 0;
80+
const usersWithMatchingRole = ["<@userId1>", "<@userId2>", "<@userId3>"];
81+
82+
jest.spyOn(global, "fetch").mockImplementation(async () => {
83+
if (fetchCallCount < 3) {
84+
fetchCallCount++;
85+
return Promise.resolve(new JSONResponse({ message: "404: Not Found" }));
86+
} else {
87+
return Promise.resolve(new JSONResponse({ ok: true }));
88+
}
89+
});
90+
91+
await removeUsers(mockEnv, usersWithMatchingRole, 23231);
92+
expect(fetch).toHaveBeenCalledTimes(4); // should send a message of failed api calls at the end
93+
});
94+
});

0 commit comments

Comments
 (0)