Skip to content

Commit dee7e96

Browse files
committed
feat: add repel command and mini mod role support
1 parent 267e28c commit dee7e96

5 files changed

Lines changed: 238 additions & 21 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,5 @@ ONBOARDING_CHANNEL=
4040
JOIN_LOG_CHANNEL=
4141
INTRO_CHANNEL=
4242
INTRO_ROLE=
43+
REPEL_ROLE_NAME=MiniMod # The name of the role that is used for MiniMods
44+
REPEL_DELETE_COUNT=2 # The number of messages to delete when using the repel command

src/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,7 @@ export const { ONBOARDING_CHANNEL } = process.env;
4242
export const { JOIN_LOG_CHANNEL } = process.env;
4343
export const { INTRO_CHANNEL } = process.env;
4444
export const { INTRO_ROLE } = process.env;
45+
46+
export const { REPEL_ROLE_NAME } = process.env;
47+
export const REPEL_DELETE_COUNT =
48+
Number.parseInt(process.env.REPEL_DELETE_COUNT) || 2;

src/v2/commands/index.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { resourceInteraction } from './resource/index.js';
3636
import { shitpostInteraction } from './shitpost/index.js';
3737
// import { warn } from './warn/index.js';
3838
import { whynoInteraction } from './whyno/index.js';
39+
import { repelInteraction } from './repel';
3940

4041
export const guildCommands = new Map(
4142
[
@@ -50,8 +51,9 @@ export const guildCommands = new Map(
5051
whynoInteraction,
5152
roleCommands,
5253
setupCommands,
54+
repelInteraction,
5355
// warn // Not used atm
54-
].map(command => [command.name, command])
56+
].map(command => [command.name, command]),
5557
); // placeholder for now
5658

5759
export const applicationCommands = new Collection<
@@ -88,7 +90,7 @@ const stripNullish = <T>(obj: T): T => {
8890
return Object.fromEntries(
8991
Object.entries(obj)
9092
.map(([a, b]) => [a, stripNullish(b)])
91-
.filter(([, b]) => b != null)
93+
.filter(([, b]) => b != null),
9294
) as T;
9395
};
9496

@@ -121,7 +123,7 @@ export const registerCommands = async (client: Client): Promise<void> => {
121123
content: 'Something went wrong when trying to execute the command',
122124
});
123125
}
124-
})
126+
}),
125127
);
126128

127129
for (const { onAttach } of applicationCommands.values()) {
@@ -152,7 +154,7 @@ export const registerCommands = async (client: Client): Promise<void> => {
152154
await addCommands(
153155
discordCommandsById,
154156
applicationCommands,
155-
client.application.commands
157+
client.application.commands,
156158
);
157159

158160
console.log('General Commands All Added');
@@ -170,14 +172,14 @@ async function addCommands(
170172
ApplicationCommand<{ guild: GuildResolvable }>
171173
>,
172174
commandDescriptions: Map<string, CommandDataWithHandler>,
173-
commandManager: ApplicationCommandManager | GuildApplicationCommandManager
175+
commandManager: ApplicationCommandManager | GuildApplicationCommandManager,
174176
) {
175177
const discordChatInputCommandsById = serverCommands.filter(
176-
x => x.type === ApplicationCommandType.ChatInput
178+
x => x.type === ApplicationCommandType.ChatInput,
177179
);
178180

179181
const discordCommands = new Collection(
180-
discordChatInputCommandsById.map(value => [value.name, value])
182+
discordChatInputCommandsById.map(value => [value.name, value]),
181183
);
182184

183185
const validCommands = pipe<
@@ -188,22 +190,22 @@ async function addCommands(
188190
([key, val]: [string, CommandDataWithHandler]) =>
189191
'guild' in commandManager && val.guildValidate
190192
? val.guildValidate(commandManager.guild)
191-
: true
193+
: true,
192194
),
193195
map(([key]) => key),
194196
]);
195197

196198
const newCommands = difference(
197199
validCommands(commandDescriptions),
198-
discordCommands.keys()
200+
discordCommands.keys(),
199201
);
200202
const existingCommands = intersection(
201203
validCommands(commandDescriptions),
202-
discordCommands.keys()
204+
discordCommands.keys(),
203205
);
204206
const deletedCommands = difference<string>(
205207
discordCommands.keys(),
206-
validCommands(commandDescriptions)
208+
validCommands(commandDescriptions),
207209
);
208210

209211
// const new = await client.application.commands.create()
@@ -213,15 +215,15 @@ async function addCommands(
213215
editExistingCommands(
214216
commandDescriptions,
215217
commandManager,
216-
discordCommands
218+
discordCommands,
217219
)(existingCommands),
218-
deleteRemovedCommands(commandManager, discordCommands)(deletedCommands)
219-
)
220+
deleteRemovedCommands(commandManager, discordCommands)(deletedCommands),
221+
),
220222
);
221223
}
222224

223225
function getDestination(
224-
commandManager: ApplicationCommandManager | GuildApplicationCommandManager
226+
commandManager: ApplicationCommandManager | GuildApplicationCommandManager,
225227
) {
226228
return 'guild' in commandManager
227229
? `Guild: ${commandManager.guild.name}`
@@ -230,7 +232,7 @@ function getDestination(
230232

231233
function createNewCommands(
232234
cmdDescriptions: Map<string, CommandDataWithHandler>,
233-
cmdMgr: ApplicationCommandManager | GuildApplicationCommandManager
235+
cmdMgr: ApplicationCommandManager | GuildApplicationCommandManager,
234236
) {
235237
const destination = getDestination(cmdMgr);
236238
return map(async (name: string) => {
@@ -248,7 +250,7 @@ function createNewCommands(
248250
function editExistingCommands(
249251
cmdDescriptions: Map<string, CommandDataWithHandler>,
250252
cmdMgr: ApplicationCommandManager | GuildApplicationCommandManager,
251-
existingCommands: Map<string, ApplicationCommand>
253+
existingCommands: Map<string, ApplicationCommand>,
252254
) {
253255
const destination = getDestination(cmdMgr);
254256
return map((name: string) => {
@@ -260,7 +262,7 @@ function editExistingCommands(
260262
if (
261263
!isEqual(
262264
getRelevantCmdProperties(cmd),
263-
getRelevantCmdProperties(existing)
265+
getRelevantCmdProperties(existing),
264266
)
265267
) {
266268
console.info(`Updating ${name} for ${destination}`);
@@ -272,7 +274,7 @@ function editExistingCommands(
272274

273275
function deleteRemovedCommands(
274276
cmdMgr: ApplicationCommandManager | GuildApplicationCommandManager,
275-
existingCommands: Map<string, ApplicationCommand>
277+
existingCommands: Map<string, ApplicationCommand>,
276278
) {
277279
const destination = getDestination(cmdMgr);
278280
return map(async (name: string) => {

src/v2/commands/repel/index.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import {
2+
ApplicationCommandOptionType,
3+
ChannelType,
4+
PermissionFlagsBits,
5+
User,
6+
type Client,
7+
type CommandInteraction,
8+
type GuildMember,
9+
type TextChannel,
10+
} from 'discord.js';
11+
import type { CommandDataWithHandler } from '../../../types';
12+
import { REPEL_DELETE_COUNT, REPEL_ROLE_NAME } from '../../env';
13+
14+
const TARGET_KEY = 'target';
15+
const MESSAGE_LINK_KEY = 'message_link';
16+
const DAY = 24 * 60 * 60 * 1000;
17+
const TIMEOUT_DURATION = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
18+
19+
const reply = (
20+
interaction: CommandInteraction,
21+
content: string,
22+
ephemeral = true,
23+
) => interaction.reply({ content, ephemeral });
24+
25+
const getTargetFromMessage = async (
26+
client: Client,
27+
guild: any,
28+
messageLink: string,
29+
) => {
30+
const match = messageLink.match(/(?:channels|@me)\/(?:(\d+)\/)?(\d+)\/(\d+)/);
31+
if (!match) throw new Error('Invalid message link format.');
32+
const messageId = match[3];
33+
const channelId = match[2];
34+
35+
const channel = channelId ? await client.channels.fetch(channelId) : null;
36+
if (channel?.type !== ChannelType.GuildText)
37+
throw new Error('Invalid channel for message link.');
38+
39+
const message = await (channel as TextChannel).messages.fetch(messageId);
40+
return await guild.members.fetch(message.author.id);
41+
};
42+
43+
export const repelInteraction: CommandDataWithHandler = {
44+
name: 'repel',
45+
description:
46+
'Remove recent messages and timeout a user (requires timeout permissions)',
47+
options: [
48+
{
49+
name: TARGET_KEY,
50+
description: 'The user to repel',
51+
type: ApplicationCommandOptionType.User,
52+
required: false,
53+
},
54+
{
55+
name: MESSAGE_LINK_KEY,
56+
description: 'Message link to identify the user to repel',
57+
type: ApplicationCommandOptionType.String,
58+
required: false,
59+
},
60+
],
61+
62+
handler: async (client: Client, interaction: CommandInteraction) => {
63+
if (!interaction.inGuild() || !interaction.guild) {
64+
await reply(interaction, 'This command can only be used in a server.');
65+
}
66+
const repelRole = interaction.guild.roles.cache.find(
67+
role => role.name === REPEL_ROLE_NAME,
68+
);
69+
70+
if (!repelRole) {
71+
await reply(
72+
interaction,
73+
`${REPEL_ROLE_NAME || 'Repel'} role not found. Please contact an admin.`,
74+
);
75+
return;
76+
}
77+
const member = interaction.member as GuildMember;
78+
79+
const canUseCommand =
80+
member.permissions.has(PermissionFlagsBits.ModerateMembers) ||
81+
member.roles.cache.has(repelRole.id) ||
82+
member.roles.cache.some(role => role.position >= repelRole.position);
83+
84+
if (!canUseCommand) {
85+
await reply(
86+
interaction,
87+
`You do not have permission to use this command. You need the ${REPEL_ROLE_NAME} role or moderate members permission.`,
88+
);
89+
return;
90+
}
91+
92+
const targetUser = interaction.options.get(TARGET_KEY, false)?.user as
93+
| User
94+
| undefined;
95+
const messageLink = interaction.options.get(MESSAGE_LINK_KEY, false)
96+
?.value as string | undefined;
97+
98+
if (!targetUser && !messageLink) {
99+
await reply(
100+
interaction,
101+
'You must specify either a user or a message link.',
102+
);
103+
}
104+
105+
try {
106+
let targetMember: GuildMember;
107+
108+
if (targetUser) {
109+
targetMember = await interaction.guild.members.fetch(targetUser.id);
110+
} else if (messageLink) {
111+
targetMember = await getTargetFromMessage(
112+
client,
113+
interaction.guild,
114+
messageLink!,
115+
);
116+
}
117+
118+
if (targetMember.id === member.id) {
119+
await reply(interaction, 'You cannot repel yourself.');
120+
return;
121+
}
122+
123+
const botMember = await interaction.guild.members.fetch(client.user!.id);
124+
const isOwner = interaction.guild.ownerId === member.id;
125+
126+
if (targetMember.id === interaction.guild.ownerId) {
127+
await reply(interaction, 'Cannot moderate the server owner.');
128+
}
129+
130+
if (
131+
!isOwner &&
132+
targetMember.roles.highest.position >= member.roles.highest.position
133+
) {
134+
await reply(
135+
interaction,
136+
'You cannot moderate this user due to role hierarchy.',
137+
);
138+
}
139+
140+
if (
141+
targetMember.roles.highest.position >= botMember.roles.highest.position
142+
) {
143+
await reply(
144+
interaction,
145+
'I cannot moderate this user due to role hierarchy.',
146+
);
147+
}
148+
149+
await interaction.deferReply({ ephemeral: true });
150+
151+
let deletedCount = 0;
152+
const textChannels = interaction.guild.channels.cache.filter(
153+
ch => ch.type === ChannelType.GuildText,
154+
);
155+
156+
for (const [, channel] of textChannels) {
157+
if (deletedCount >= REPEL_DELETE_COUNT) break;
158+
159+
try {
160+
const messages = await channel.messages.fetch({
161+
limit: 100,
162+
});
163+
const userMessages = messages
164+
.filter(
165+
m =>
166+
m.author.id === targetMember.id &&
167+
Date.now() - m.createdTimestamp < 14 * DAY,
168+
)
169+
.first(
170+
Math.min(REPEL_DELETE_COUNT - deletedCount, REPEL_DELETE_COUNT),
171+
);
172+
if (userMessages.length > 0) {
173+
userMessages.length === 1
174+
? await userMessages[0].delete()
175+
: await (channel as TextChannel).bulkDelete(userMessages);
176+
deletedCount += userMessages.length;
177+
}
178+
} catch {}
179+
}
180+
181+
const isUserTimedOut = targetMember.communicationDisabledUntilTimestamp
182+
? targetMember.communicationDisabledUntilTimestamp > Date.now()
183+
: false;
184+
185+
if (!isUserTimedOut) {
186+
await targetMember.timeout(
187+
TIMEOUT_DURATION,
188+
`Repel command used by ${member.user.tag}`,
189+
);
190+
await interaction.editReply({
191+
content: `Successfully repelled ${targetMember.user.tag}. Removed ${deletedCount} messages and timed out for 6 hours.`,
192+
});
193+
} else {
194+
await interaction.editReply({
195+
content: `Successfully repelled ${targetMember.user.tag}. Removed ${deletedCount} messages.`,
196+
});
197+
}
198+
} catch (error: any) {
199+
const errorMsg =
200+
error.message || 'An error occurred while executing this command.';
201+
202+
if (interaction.deferred) {
203+
await interaction.editReply({ content: errorMsg });
204+
} else {
205+
await reply(interaction, errorMsg + ' Please try again later.');
206+
}
207+
}
208+
},
209+
};

src/v2/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { stripMarkdownQuote } from './utils/content_format.js';
4040
const NON_COMMAND_MSG_TYPES = new Set([
4141
ChannelType.GuildText,
4242
ChannelType.PrivateThread,
43-
ChannelType.PublicThread
43+
ChannelType.PublicThread,
4444
]);
4545

4646
if (IS_PROD) {
@@ -121,7 +121,7 @@ client.once('ready', async (): Promise<void> => {
121121

122122
try {
123123
await client.user.setAvatar('./logo.png');
124-
} catch { }
124+
} catch {}
125125
});
126126

127127
const detectVarLimited = limitFnByUser(detectVar, {

0 commit comments

Comments
 (0)