Confirmation prompt when kicking/banning via reply

master
Lea 2023-03-15 20:21:28 +01:00
parent 2f9792c616
commit 5c3479268d
Signed by: Lea
GPG Key ID: 1BAFFE8347019C42
4 changed files with 783 additions and 291 deletions

View File

@ -1,5 +1,5 @@
{
"editor.formatOnSave": true,
"editor.formatOnSave": false,
"editor.formatOnSaveMode": "modifications",
"prettier.tabWidth": 4
}

View File

@ -5,9 +5,22 @@ import InfractionType from "automod/dist/types/antispam/InfractionType";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import { fetchUsername, logModAction } from "../../modules/mod_logs";
import { storeTempBan } from "../../modules/tempbans";
import { dedupeArray, embed, EmbedColor, generateInfractionDMEmbed, getDmChannel, isModerator, NO_MANAGER_MSG, parseUserOrId, sanitizeMessageContent, storeInfraction } from "../../util";
import Day from 'dayjs';
import RelativeTime from 'dayjs/plugin/relativeTime';
import {
dedupeArray,
embed,
EmbedColor,
generateInfractionDMEmbed,
getDmChannel,
getMembers,
isModerator,
NO_MANAGER_MSG,
parseUserOrId,
sanitizeMessageContent,
storeInfraction,
yesNoMessage,
} from "../../util";
import Day from "dayjs";
import RelativeTime from "dayjs/plugin/relativeTime";
import CommandCategory from "../../../struct/commands/CommandCategory";
import { SendableEmbed } from "revolt-api";
import { User } from "@janderedev/revolt.js";
@ -16,30 +29,40 @@ import logger from "../../logger";
Day.extend(RelativeTime);
export default {
name: 'ban',
aliases: [ 'eject' ],
description: 'Ban a member from the server',
syntax: '/ban @username [10m|1h|...?] [reason?]',
name: "ban",
aliases: ["eject"],
description: "Ban a member from the server",
syntax: "/ban @username [10m|1h|...?] [reason?]",
removeEmptyArgs: true,
category: CommandCategory.Moderation,
run: async (message, args, serverConfig) => {
if (!await isModerator(message))
return message.reply(NO_MANAGER_MSG);
if (!message.serverContext.havePermission('BanMembers')) {
return await message.reply({ embeds: [
embed(`Sorry, I do not have \`BanMembers\` permission.`, '', EmbedColor.SoftError)
] });
if (!(await isModerator(message))) return message.reply(NO_MANAGER_MSG);
if (!message.serverContext.havePermission("BanMembers")) {
return await message.reply({
embeds: [
embed(
`Sorry, I do not have \`BanMembers\` permission.`,
"",
EmbedColor.SoftError
),
],
});
}
const userInput = !message.reply_ids?.length ? args.shift() || '' : undefined;
if (!userInput && !message.reply_ids?.length) return message.reply({ embeds: [
embed(
`Please specify one or more users by replying to their message while running this command or ` +
`by specifying a comma-separated list of usernames.`,
'No target user specified',
EmbedColor.SoftError,
),
] });
const userInput = !message.reply_ids?.length
? args.shift() || ""
: undefined;
if (!userInput && !message.reply_ids?.length)
return message.reply({
embeds: [
embed(
`Please specify one or more users by replying to their message while running this command or ` +
`by specifying a comma-separated list of usernames.`,
"No target user specified",
EmbedColor.SoftError
),
],
});
let banDuration = 0;
let durationStr = args.shift();
@ -49,48 +72,84 @@ export default {
// Being able to specify the same letter multiple times
// (e.g. 1s1s) and having their values stack is a feature
for (const piece of pieces) {
let [ num, letter ] = [ Number(piece.slice(0, piece.length - 1)), piece.slice(piece.length - 1) ];
let [num, letter] = [
Number(piece.slice(0, piece.length - 1)),
piece.slice(piece.length - 1),
];
let multiplier = 0;
switch(letter) {
case 's': multiplier = 1000; break;
case 'm': multiplier = 1000 * 60; break;
case 'h': multiplier = 1000 * 60 * 60; break;
case 'd': multiplier = 1000 * 60 * 60 * 24; break;
case 'w': multiplier = 1000 * 60 * 60 * 24 * 7; break;
case 'y': multiplier = 1000 * 60 * 60 * 24 * 365; break;
switch (letter) {
case "s":
multiplier = 1000;
break;
case "m":
multiplier = 1000 * 60;
break;
case "h":
multiplier = 1000 * 60 * 60;
break;
case "d":
multiplier = 1000 * 60 * 60 * 24;
break;
case "w":
multiplier = 1000 * 60 * 60 * 24 * 7;
break;
case "y":
multiplier = 1000 * 60 * 60 * 24 * 365;
break;
}
banDuration += num * multiplier;
}
} else if (durationStr) args.unshift(durationStr);
let reason = args.join(' ')
?.replace(new RegExp('`', 'g'), '\'')
?.replace(new RegExp('\n', 'g'), ' ');
let reason = args
.join(" ")
?.replace(new RegExp("`", "g"), "'")
?.replace(new RegExp("\n", "g"), " ");
if (reason.length > 500) return message.reply({
embeds: [ embed('Ban reason may not be longer than 500 characters.', null, EmbedColor.SoftError) ]
});
if (reason.length > 500)
return message.reply({
embeds: [
embed(
"Ban reason may not be longer than 500 characters.",
null,
EmbedColor.SoftError
),
],
});
const embeds: SendableEmbed[] = [];
const handledUsers: string[] = [];
const targetUsers: User|{ _id: string }[] = [];
const targetUsers: User | { _id: string }[] = [];
const targetInput = dedupeArray(
message.reply_ids?.length
? (await Promise.allSettled(
message.reply_ids.map(msg => message.channel?.fetchMessage(msg))
))
.filter(m => m.status == 'fulfilled').map(m => (m as any).value.author_id)
: userInput!.split(','),
? (
await Promise.allSettled(
message.reply_ids.map((msg) =>
message.channel?.fetchMessage(msg)
)
)
)
.filter((m) => m.status == "fulfilled")
.map((m) => (m as any).value.author_id)
: userInput!.split(",")
);
for (const userStr of targetInput) {
try {
let user = await parseUserOrId(userStr);
if (!user) {
embeds.push(embed(`I can't resolve \`${sanitizeMessageContent(userStr).trim()}\` to a user.`, null, EmbedColor.SoftError));
embeds.push(
embed(
`I can't resolve \`${sanitizeMessageContent(
userStr
).trim()}\` to a user.`,
null,
EmbedColor.SoftError
)
);
continue;
}
@ -99,27 +158,57 @@ export default {
handledUsers.push(user._id);
if (user._id == message.author_id) {
embeds.push(embed('I recommend against banning yourself :yeahokayyy:', null, EmbedColor.Warning));
embeds.push(
embed(
"I recommend against banning yourself :yeahokayyy:",
null,
EmbedColor.Warning
)
);
continue;
}
if (user._id == client.user!._id) {
embeds.push(embed('I\'m not going to ban myself :flushee:', null, EmbedColor.Warning));
embeds.push(
embed(
"I'm not going to ban myself :flushee:",
null,
EmbedColor.Warning
)
);
continue;
}
targetUsers.push(user);
} catch(e) {
} catch (e) {
console.error(e);
embeds.push(embed(
`Failed to ban target \`${sanitizeMessageContent(userStr).trim()}\`: ${e}`,
`Failed to ban: An error has occurred`,
EmbedColor.Error,
));
embeds.push(
embed(
`Failed to ban target \`${sanitizeMessageContent(
userStr
).trim()}\`: ${e}`,
`Failed to ban: An error has occurred`,
EmbedColor.Error
)
);
}
}
const members = await message.serverContext.fetchMembers();
if (message.reply_ids?.length && targetUsers.length) {
let res = await yesNoMessage(
message.channel!,
message.author_id,
`This will ban the author${targetUsers.length > 1 ? 's' : ''} `
+ `of the message${message.reply_ids.length > 1 ? 's' : ''} you replied to.\n`
+ `The following user${targetUsers.length > 1 ? 's' : ''} will be affected: `
+ `${targetUsers.map(u => `<@${u._id}>`).join(', ')}.\n`
+ `Are you sure?`,
'Confirm action'
);
if (!res) return;
}
const members = getMembers(message.serverContext._id);
for (const user of targetUsers) {
try {
@ -129,64 +218,106 @@ export default {
_id: infId,
createdBy: message.author_id,
date: Date.now(),
reason: reason || 'No reason provided',
reason: reason || "No reason provided",
server: message.serverContext._id,
type: InfractionType.Manual,
user: user._id,
actionType: 'ban',
actionType: "ban",
expires: Infinity,
}
};
const { userWarnCount } = await storeInfraction(infraction);
const member = members.members.find(m => m._id.user == user._id);
const member = members.find((m) => m._id.user == user._id);
if (member && message.member && !member.inferiorTo(message.member)) {
embeds.push(embed(
`\`${member.user?.username}\` has an equally or higher ranked role than you; refusing to ban.`,
'Missing permission',
EmbedColor.SoftError
));
if (
member &&
message.member &&
!member.inferiorTo(message.member)
) {
embeds.push(
embed(
`\`${member.user?.username}\` has an equally or higher ranked role than you; refusing to ban.`,
"Missing permission",
EmbedColor.SoftError
)
);
continue;
}
if (member && !member.bannable) {
embeds.push(embed(
`I don't have permission to ban \`${member?.user?.username || user._id}\`.`,
null,
EmbedColor.SoftError
));
embeds.push(
embed(
`I don't have permission to ban \`${
member?.user?.username || user._id
}\`.`,
null,
EmbedColor.SoftError
)
);
continue;
}
if (serverConfig?.dmOnKick) {
try {
const embed = generateInfractionDMEmbed(message.serverContext, serverConfig, infraction, message);
const embed = generateInfractionDMEmbed(
message.serverContext,
serverConfig,
infraction,
message
);
const dmChannel = await getDmChannel(user);
if (dmChannel.havePermission('SendMessage') || dmChannel.havePermission('SendEmbeds')) {
await dmChannel.sendMessage({ embeds: [ embed ] });
}
else logger.warn('Missing permission to DM user.');
} catch(e) {
if (
dmChannel.havePermission("SendMessage") ||
dmChannel.havePermission("SendEmbeds")
) {
await dmChannel.sendMessage({
embeds: [embed],
});
} else
logger.warn("Missing permission to DM user.");
} catch (e) {
console.error(e);
}
}
await message.serverContext.banUser(user._id, {
reason: reason + ` (by ${await fetchUsername(message.author_id)} ${message.author_id})`
reason:
reason +
` (by ${await fetchUsername(message.author_id)} ${
message.author_id
})`,
});
await logModAction('ban', message.serverContext, message.member!, user._id, reason, infraction._id, `Ban duration: **Permanent**`);
await logModAction(
"ban",
message.serverContext,
message.member!,
user._id,
reason,
infraction._id,
`Ban duration: **Permanent**`
);
embeds.push({
title: `User ${Math.random() > 0.8 ? 'ejected' : 'banned'}`,
icon_url: user instanceof User ? user.generateAvatarURL() : undefined,
title: `User ${
Math.random() > 0.8 ? "ejected" : "banned"
}`,
icon_url:
user instanceof User
? user.generateAvatarURL()
: undefined,
colour: EmbedColor.Success,
description: `This is ${userWarnCount == 1 ? '**the first infraction**' : `infraction number **${userWarnCount}**`}` +
description:
`This is ${
userWarnCount == 1
? "**the first infraction**"
: `infraction number **${userWarnCount}**`
}` +
` for ${await fetchUsername(user._id)}.\n` +
`**User ID:** \`${user._id}\`\n` +
`**Infraction ID:** \`${infraction._id}\`\n` +
`**Reason:** \`${infraction.reason}\``
`**Reason:** \`${infraction.reason}\``,
});
} else {
const banUntil = Date.now() + banDuration;
@ -196,31 +327,47 @@ export default {
_id: infId,
createdBy: message.author_id,
date: Date.now(),
reason: (reason || 'No reason provided') + ` (${durationStr})`,
reason:
(reason || "No reason provided") +
` (${durationStr})`,
server: message.serverContext._id,
type: InfractionType.Manual,
user: user._id,
actionType: 'ban',
actionType: "ban",
expires: banUntil,
}
};
const { userWarnCount } = await storeInfraction(infraction);
if (serverConfig?.dmOnKick) {
try {
const embed = generateInfractionDMEmbed(message.serverContext, serverConfig, infraction, message);
const embed = generateInfractionDMEmbed(
message.serverContext,
serverConfig,
infraction,
message
);
const dmChannel = await getDmChannel(user);
if (dmChannel.havePermission('SendMessage') || dmChannel.havePermission('SendEmbeds')) {
await dmChannel.sendMessage({ embeds: [ embed ] });
}
else logger.warn('Missing permission to DM user.');
} catch(e) {
if (
dmChannel.havePermission("SendMessage") ||
dmChannel.havePermission("SendEmbeds")
) {
await dmChannel.sendMessage({
embeds: [embed],
});
} else
logger.warn("Missing permission to DM user.");
} catch (e) {
console.error(e);
}
}
await message.serverContext.banUser(user._id, {
reason: reason + ` (by ${await fetchUsername(message.author_id)} ${message.author_id}) (${durationStr})`
reason:
reason +
` (by ${await fetchUsername(message.author_id)} ${
message.author_id
}) (${durationStr})`,
});
await Promise.all([
@ -231,7 +378,7 @@ export default {
until: banUntil,
}),
logModAction(
'ban',
"ban",
message.serverContext,
message.member!,
user._id,
@ -243,23 +390,36 @@ export default {
embeds.push({
title: `User temporarily banned`,
icon_url: user instanceof User ? user.generateAvatarURL() : undefined,
icon_url:
user instanceof User
? user.generateAvatarURL()
: undefined,
colour: EmbedColor.Success,
description: `This is ${userWarnCount == 1 ? '**the first infraction**' : `infraction number **${userWarnCount}**`}` +
description:
`This is ${
userWarnCount == 1
? "**the first infraction**"
: `infraction number **${userWarnCount}**`
}` +
` for ${await fetchUsername(user._id)}.\n` +
`**Ban duration:** ${banDurationFancy}\n` +
`**User ID:** \`${user._id}\`\n` +
`**Infraction ID:** \`${infraction._id}\`\n` +
`**Reason:** \`${infraction.reason}\``
`**Reason:** \`${infraction.reason}\``,
});
}
} catch(e) {
} catch (e) {
console.error(e);
embeds.push(embed(
`Failed to ban target \`${await fetchUsername(user._id, user._id)}\`: ${e}`,
'Failed to ban: An error has occurred',
EmbedColor.Error,
));
embeds.push(
embed(
`Failed to ban target \`${await fetchUsername(
user._id,
user._id
)}\`: ${e}`,
"Failed to ban: An error has occurred",
EmbedColor.Error
)
);
}
}
@ -268,11 +428,14 @@ export default {
const targetEmbeds = embeds.splice(0, 10);
if (firstMsg) {
await message.reply({ embeds: targetEmbeds, content: 'Operation completed.' }, false);
await message.reply(
{ embeds: targetEmbeds, content: "Operation completed." },
false
);
} else {
await message.channel?.sendMessage({ embeds: targetEmbeds });
}
firstMsg = false;
}
}
},
} as SimpleCommand;

View File

@ -8,58 +8,99 @@ import CommandCategory from "../../../struct/commands/CommandCategory";
import SimpleCommand from "../../../struct/commands/SimpleCommand";
import logger from "../../logger";
import { fetchUsername, logModAction } from "../../modules/mod_logs";
import { dedupeArray, embed, EmbedColor, generateInfractionDMEmbed, getDmChannel, isModerator, NO_MANAGER_MSG, parseUser, parseUserOrId, sanitizeMessageContent, storeInfraction } from "../../util";
import {
dedupeArray,
embed,
EmbedColor,
generateInfractionDMEmbed,
getDmChannel,
getMembers,
isModerator,
NO_MANAGER_MSG,
parseUser,
parseUserOrId,
sanitizeMessageContent,
storeInfraction,
yesNoMessage,
} from "../../util";
export default {
name: 'kick',
aliases: [ 'yeet', 'vent' ],
description: 'Kick a member from the server',
syntax: '/kick @username [reason?]',
name: "kick",
aliases: ["yeet", "vent"],
description: "Kick a member from the server",
syntax: "/kick @username [reason?]",
removeEmptyArgs: true,
category: CommandCategory.Moderation,
run: async (message, args, serverConfig) => {
if (!await isModerator(message))
return message.reply(NO_MANAGER_MSG);
if (!message.serverContext.havePermission('KickMembers')) {
return await message.reply(`Sorry, I do not have \`KickMembers\` permission.`);
if (!(await isModerator(message))) return message.reply(NO_MANAGER_MSG);
if (!message.serverContext.havePermission("KickMembers")) {
return await message.reply(
`Sorry, I do not have \`KickMembers\` permission.`
);
}
const userInput = !message.reply_ids?.length ? args.shift() || '' : undefined;
if (!userInput && !message.reply_ids?.length) return message.reply({ embeds: [
embed(
`Please specify one or more users by replying to their message while running this command or ` +
`by specifying a comma-separated list of usernames.`,
'No target user specified',
EmbedColor.SoftError,
),
] });
let reason = args.join(' ')
?.replace(new RegExp('`', 'g'), '\'')
?.replace(new RegExp('\n', 'g'), ' ');
const userInput = !message.reply_ids?.length
? args.shift() || ""
: undefined;
if (!userInput && !message.reply_ids?.length)
return message.reply({
embeds: [
embed(
`Please specify one or more users by replying to their message while running this command or ` +
`by specifying a comma-separated list of usernames.`,
"No target user specified",
EmbedColor.SoftError
),
],
});
if (reason.length > 500) return message.reply({
embeds: [ embed('Kick reason may not be longer than 500 characters.', null, EmbedColor.SoftError) ]
});
let reason = args
.join(" ")
?.replace(new RegExp("`", "g"), "'")
?.replace(new RegExp("\n", "g"), " ");
if (reason.length > 500)
return message.reply({
embeds: [
embed(
"Kick reason may not be longer than 500 characters.",
null,
EmbedColor.SoftError
),
],
});
const embeds: SendableEmbed[] = [];
const handledUsers: string[] = [];
const targetUsers: User|{ _id: string }[] = [];
const targetUsers: User | { _id: string }[] = [];
const targetInput = dedupeArray(
message.reply_ids?.length
? (await Promise.allSettled(
message.reply_ids.map(msg => message.channel?.fetchMessage(msg))
))
.filter(m => m.status == 'fulfilled').map(m => (m as any).value.author_id)
: userInput!.split(','),
? (
await Promise.allSettled(
message.reply_ids.map((msg) =>
message.channel?.fetchMessage(msg)
)
)
)
.filter((m) => m.status == "fulfilled")
.map((m) => (m as any).value.author_id)
: userInput!.split(",")
);
for (const userStr of targetInput) {
try {
let user = await parseUserOrId(userStr);
if (!user) {
embeds.push(embed(`I can't resolve \`${sanitizeMessageContent(userStr).trim()}\` to a user.`, null, EmbedColor.SoftError));
embeds.push(
embed(
`I can't resolve \`${sanitizeMessageContent(
userStr
).trim()}\` to a user.`,
null,
EmbedColor.SoftError
)
);
continue;
}
@ -68,33 +109,69 @@ export default {
handledUsers.push(user._id);
if (user._id == message.author_id) {
embeds.push(embed('You might want to avoid kicking yourself...', null, EmbedColor.Warning));
embeds.push(
embed(
"You might want to avoid kicking yourself...",
null,
EmbedColor.Warning
)
);
continue;
}
if (user._id == client.user!._id) {
embeds.push(embed('I won\'t allow you to get rid of me this easily :trol:', null, EmbedColor.Warning));
embeds.push(
embed(
"I won't allow you to get rid of me this easily :trol:",
null,
EmbedColor.Warning
)
);
continue;
}
targetUsers.push(user);
} catch(e) {
} catch (e) {
console.error(e);
embeds.push(embed(
`Failed to kick target \`${sanitizeMessageContent(userStr).trim()}\`: ${e}`,
`Failed to kick: An error has occurred`,
EmbedColor.Error,
));
embeds.push(
embed(
`Failed to kick target \`${sanitizeMessageContent(
userStr
).trim()}\`: ${e}`,
`Failed to kick: An error has occurred`,
EmbedColor.Error
)
);
}
}
const members = await message.serverContext.fetchMembers();
if (message.reply_ids?.length && targetUsers.length) {
let res = await yesNoMessage(
message.channel!,
message.author_id,
`This will kick the author${targetUsers.length > 1 ? 's' : ''} `
+ `of the message${message.reply_ids.length > 1 ? 's' : ''} you replied to.\n`
+ `The following user${targetUsers.length > 1 ? 's' : ''} will be affected: `
+ `${targetUsers.map(u => `<@${u._id}>`).join(', ')}.\n`
+ `Are you sure?`,
'Confirm action'
);
if (!res) return;
}
const members = getMembers(message.serverContext._id);
for (const user of targetUsers) {
try {
const member = members.members.find(m => m._id.user == user._id);
const member = members.find((m) => m._id.user == user._id);
if (!member) {
embeds.push(embed(''));
embeds.push(
embed(
`\`${await fetchUsername(
user._id
)}\` is not a member of this server.`
)
);
continue;
}
@ -103,45 +180,75 @@ export default {
_id: infId,
createdBy: message.author_id,
date: Date.now(),
reason: reason || 'No reason provided',
reason: reason || "No reason provided",
server: message.serverContext._id,
type: InfractionType.Manual,
user: user._id,
actionType: 'kick',
}
actionType: "kick",
};
if (serverConfig?.dmOnKick) {
try {
const embed = generateInfractionDMEmbed(message.serverContext, serverConfig, infraction, message);
const embed = generateInfractionDMEmbed(
message.serverContext,
serverConfig,
infraction,
message
);
const dmChannel = await getDmChannel(user);
if (dmChannel.havePermission('SendMessage') || dmChannel.havePermission('SendEmbeds')) {
await dmChannel.sendMessage({ embeds: [ embed ] });
}
else logger.warn('Missing permission to DM user.');
} catch(e) {
if (
dmChannel.havePermission("SendMessage") ||
dmChannel.havePermission("SendEmbeds")
) {
await dmChannel.sendMessage({ embeds: [embed] });
} else logger.warn("Missing permission to DM user.");
} catch (e) {
console.error(e);
}
}
let [ { userWarnCount } ] = await Promise.all([
let [{ userWarnCount }] = await Promise.all([
storeInfraction(infraction),
logModAction('kick', message.serverContext, message.member!, user._id, reason, infraction._id),
logModAction(
"kick",
message.serverContext,
message.member!,
user._id,
reason,
infraction._id
),
member.kick(),
]);
embeds.push({
title: `User kicked`,
icon_url: user instanceof User ? user.generateAvatarURL() : undefined,
icon_url:
user instanceof User
? user.generateAvatarURL()
: undefined,
colour: EmbedColor.Success,
description: `This is ${userWarnCount == 1 ? '**the first infraction**' : `infraction number **${userWarnCount}**`}` +
description:
`This is ${
userWarnCount == 1
? "**the first infraction**"
: `infraction number **${userWarnCount}**`
}` +
` for ${await fetchUsername(user._id)}.\n` +
`**User ID:** \`${user._id}\`\n` +
`**Infraction ID:** \`${infraction._id}\`\n` +
`**Reason:** \`${infraction.reason}\``
`**Reason:** \`${infraction.reason}\``,
});
} catch(e) {
embeds.push(embed(`Failed to kick user ${await fetchUsername(user._id)}: ${e}`, 'Failed to kick user', EmbedColor.Error));
} catch (e) {
embeds.push(
embed(
`Failed to kick user ${await fetchUsername(
user._id
)}: ${e}`,
"Failed to kick user",
EmbedColor.Error
)
);
}
}
@ -150,11 +257,14 @@ export default {
const targetEmbeds = embeds.splice(0, 10);
if (firstMsg) {
await message.reply({ embeds: targetEmbeds, content: 'Operation completed.' }, false);
await message.reply(
{ embeds: targetEmbeds, content: "Operation completed." },
false
);
} else {
await message.channel?.sendMessage({ embeds: targetEmbeds });
}
firstMsg = false;
}
}
},
} as SimpleCommand;

View File

@ -17,21 +17,25 @@ import { isSudo } from "./commands/admin/botadm";
import { SendableEmbed } from "revolt-api";
import MessageCommandContext from "../struct/MessageCommandContext";
import ServerConfig from "automod/dist/types/ServerConfig";
import { ClientboundNotification } from "@janderedev/revolt.js";
const NO_MANAGER_MSG = '🔒 Missing permission';
const NO_MANAGER_MSG = "🔒 Missing permission";
const ULID_REGEX = /^[0-9A-HJ-KM-NP-TV-Z]{26}$/i;
const USER_MENTION_REGEX = /^<@[0-9A-HJ-KM-NP-TV-Z]{26}>$/i;
const CHANNEL_MENTION_REGEX = /^<#[0-9A-HJ-KM-NP-TV-Z]{26}>$/i;
const RE_HTTP_URI = /^http(s?):\/\//g;
const RE_MAILTO_URI = /^mailto:/g;
let autumn_url: string|null = null;
let apiConfig: any = axios.get(client.apiURL).then(res => {
let autumn_url: string | null = null;
let apiConfig: any = axios.get(client.apiURL).then((res) => {
autumn_url = (res.data as any).features.autumn.url;
});
async function getAutumnURL() {
return autumn_url || ((await axios.get(client.apiURL)).data as any).features.autumn.url;
return (
autumn_url ||
((await axios.get(client.apiURL)).data as any).features.autumn.url
);
}
/**
@ -40,20 +44,20 @@ async function getAutumnURL() {
* @param text
* @returns null if not found, otherwise user object
*/
async function parseUser(text: string): Promise<User|null> {
async function parseUser(text: string): Promise<User | null> {
if (!text) return null;
let uid: string|null = null;
let uid: string | null = null;
if (USER_MENTION_REGEX.test(text)) {
uid = text.replace(/<@|>/g, '').toUpperCase();
uid = text.replace(/<@|>/g, "").toUpperCase();
} else if (/^[0-9A-HJ-KM-NP-TV-Z]{26}$/gi.test(text)) {
uid = text.toUpperCase();
} else {
if (text.startsWith('@')) text = text.substr(1);
if (text.startsWith("@")) text = text.substr(1);
// Why is there no .find() or .filter()
let user: User|null = null;
client.users.forEach(u => {
let user: User | null = null;
client.users.forEach((u) => {
if (u.username?.toLowerCase() == text.toLowerCase()) {
user = u;
}
@ -63,16 +67,20 @@ async function parseUser(text: string): Promise<User|null> {
}
try {
if (uid) return await client.users.fetch(uid) || null;
if (uid) return (await client.users.fetch(uid)) || null;
else return null;
} catch(e) { return null; }
} catch (e) {
return null;
}
}
/**
* Does the exact same as `parseUser`, but returns only `_id` instead
* of null if the user was not found and the input is also an ID
*/
async function parseUserOrId(text: string): Promise<User|{_id: string}|null> {
async function parseUserOrId(
text: string
): Promise<User | { _id: string } | null> {
let parsed = await parseUser(text);
if (parsed) return parsed;
if (ULID_REGEX.test(text)) return { _id: text.toUpperCase() };
@ -80,63 +88,87 @@ async function parseUserOrId(text: string): Promise<User|{_id: string}|null> {
}
async function isModerator(message: Message, announceSudo?: boolean) {
let member = message.member!, server = message.channel!.server!;
let member = message.member!,
server = message.channel!.server!;
if (hasPerm(member, 'KickMembers')) return true;
if (hasPerm(member, "KickMembers")) return true;
const [ isManager, mods, isSudo ] = await Promise.all([
const [isManager, mods, isSudo] = await Promise.all([
isBotManager(message),
dbs.SERVERS.findOne({ id: server._id }),
checkSudoPermission(message, announceSudo),
]);
return isManager
|| (mods?.moderators?.indexOf(member.user?._id!) ?? -1) > -1
|| isSudo;
return (
isManager ||
(mods?.moderators?.indexOf(member.user?._id!) ?? -1) > -1 ||
isSudo
);
}
async function isBotManager(message: Message, announceSudo?: boolean) {
let member = message.member!, server = message.channel!.server!;
let member = message.member!,
server = message.channel!.server!;
if (hasPerm(member, 'ManageServer')) return true;
if (hasPerm(member, "ManageServer")) return true;
const [ managers, isSudo ] = await Promise.all([
const [managers, isSudo] = await Promise.all([
dbs.SERVERS.findOne({ id: server._id }),
checkSudoPermission(message, announceSudo),
]);
return (managers?.botManagers?.indexOf(member.user?._id!) ?? -1) > -1
|| isSudo;
return (
(managers?.botManagers?.indexOf(member.user?._id!) ?? -1) > -1 || isSudo
);
}
async function checkSudoPermission(message: Message, announce?: boolean): Promise<boolean> {
async function checkSudoPermission(
message: Message,
announce?: boolean
): Promise<boolean> {
const hasPerm = isSudo(message.author!);
if (!hasPerm) return false;
else {
if (announce !== false) {
await message.reply(`# :unlock: Bypassed permission check\n`
+ `Sudo mode is enabled for @${message.author!.username}.\n`);
await message.reply(
`# :unlock: Bypassed permission check\n` +
`Sudo mode is enabled for @${message.author!.username}.\n`
);
}
return true;
}
}
async function getPermissionLevel(user: User|Member, server: Server): Promise<0|1|2|3> {
if (isSudo(user instanceof User ? user : (user.user || await client.users.fetch(user._id.user)))) return 3;
async function getPermissionLevel(
user: User | Member,
server: Server
): Promise<0 | 1 | 2 | 3> {
if (
isSudo(
user instanceof User
? user
: user.user || (await client.users.fetch(user._id.user))
)
)
return 3;
const member = user instanceof User ? await server.fetchMember(user) : user;
if (user instanceof Member) user = user.user!;
if (hasPerm(member, 'ManageServer')) return 3;
if (hasPerm(member, "ManageServer")) return 3;
const config = await dbs.SERVERS.findOne({ id: server._id });
if (config?.botManagers?.includes(user._id)) return 2;
if (config?.moderators?.includes(user._id) || hasPerm(member, 'KickMembers')) return 1;
if (
config?.moderators?.includes(user._id) ||
hasPerm(member, "KickMembers")
)
return 1;
return 0;
}
function getPermissionBasedOnRole(member: Member): 0|1|2|3 {
if (hasPerm(member, 'ManageServer')) return 3;
if (hasPerm(member, 'KickMembers')) return 1;
function getPermissionBasedOnRole(member: Member): 0 | 1 | 2 | 3 {
if (hasPerm(member, "ManageServer")) return 3;
if (hasPerm(member, "KickMembers")) return 1;
return 0;
}
@ -153,85 +185,117 @@ function hasPerm(member: Member, perm: keyof typeof Permission): boolean {
/**
* @deprecated Unnecessary
*/
function hasPermForChannel(member: Member, channel: Channel, perm: keyof typeof Permission): boolean {
if (!member.server) throw 'hasPermForChannel(): Server is undefined';
function hasPermForChannel(
member: Member,
channel: Channel,
perm: keyof typeof Permission
): boolean {
if (!member.server) throw "hasPermForChannel(): Server is undefined";
return member.hasPermission(channel, perm);
}
async function getOwnMemberInServer(server: Server): Promise<Member> {
return client.members.getKey({ server: server._id, user: client.user!._id })
|| await server.fetchMember(client.user!._id);
return (
client.members.getKey({ server: server._id, user: client.user!._id }) ||
(await server.fetchMember(client.user!._id))
);
}
async function storeInfraction(infraction: Infraction): Promise<{ userWarnCount: number }> {
async function storeInfraction(
infraction: Infraction
): Promise<{ userWarnCount: number }> {
let r = await Promise.all([
dbs.INFRACTIONS.insert(infraction, { castIds: false }),
dbs.INFRACTIONS.find({
server: infraction.server,
user: infraction.user,
_id: { $not: { $eq: infraction._id } } },
),
_id: { $not: { $eq: infraction._id } },
}),
]);
return { userWarnCount: (r[1].length ?? 0) + 1 }
return { userWarnCount: (r[1].length ?? 0) + 1 };
}
async function uploadFile(file: any, filename: string): Promise<string> {
let data = new FormData();
data.append("file", file, { filename: filename });
let req = await axios.post(await getAutumnURL() + '/attachments', data, { headers: data.getHeaders() });
return (req.data as any)['id'] as string;
let req = await axios.post((await getAutumnURL()) + "/attachments", data, {
headers: data.getHeaders(),
});
return (req.data as any)["id"] as string;
}
async function sendLogMessage(config: LogConfig, content: LogMessage) {
if (config.discord?.webhookUrl) {
let c = { ...content, ...content.overrides?.discord }
let c = { ...content, ...content.overrides?.discord };
const embed = new MessageEmbed();
if (c.title) embed.setTitle(content.title);
if (c.description) embed.setDescription(c.description);
if (c.color?.match(/^#[0-9a-fA-F]+$/)) embed.setColor(c.color as ColorResolvable);
if (c.color?.match(/^#[0-9a-fA-F]+$/))
embed.setColor(c.color as ColorResolvable);
if (c.fields?.length) {
for (const field of c.fields) {
embed.addField(field.title, field.content.trim() || "\u200b", field.inline);
embed.addField(
field.title,
field.content.trim() || "\u200b",
field.inline
);
}
}
if (content.image) {
if (content.image.type == 'THUMBNAIL') embed.setThumbnail(content.image.url);
else if (content.image.type == 'BIG') embed.setImage(content.image.url);
if (content.image.type == "THUMBNAIL")
embed.setThumbnail(content.image.url);
else if (content.image.type == "BIG")
embed.setImage(content.image.url);
}
if (content.attachments?.length) {
embed.setFooter(`Attachments: ${content.attachments.map(a => a.name).join(', ')}`);
embed.setFooter(
`Attachments: ${content.attachments
.map((a) => a.name)
.join(", ")}`
);
}
let data = new FormData();
content.attachments?.forEach(a => {
content.attachments?.forEach((a) => {
data.append(`files[${ulid()}]`, a.content, { filename: a.name });
});
data.append("payload_json", JSON.stringify({ embeds: [ embed.toJSON() ] }), { contentType: 'application/json' });
data.append(
"payload_json",
JSON.stringify({ embeds: [embed.toJSON()] }),
{ contentType: "application/json" }
);
axios.post(config.discord.webhookUrl, data, {headers: data.getHeaders() })
.catch(e => logger.error(`Failed to send log message (discord): ${e}`));
axios
.post(config.discord.webhookUrl, data, {
headers: data.getHeaders(),
})
.catch((e) =>
logger.error(`Failed to send log message (discord): ${e}`)
);
}
if (config.revolt?.channel) {
let c = { ...content, ...content.overrides?.revolt };
try {
const channel = client.channels.get(config.revolt.channel) || await client.channels.fetch(config.revolt.channel);
const channel =
client.channels.get(config.revolt.channel) ||
(await client.channels.fetch(config.revolt.channel));
let message = '';
let embed: SendableEmbed|undefined = undefined;
switch(config.revolt.type) {
case 'EMBED':
let message = "";
let embed: SendableEmbed | undefined = undefined;
switch (config.revolt.type) {
case "EMBED":
c = { ...c, ...content.overrides?.revoltEmbed };
embed = {
title: c.title,
description: c.description,
colour: c.color,
}
};
if (c.fields?.length) {
for (const field of c.fields) {
@ -241,35 +305,55 @@ async function sendLogMessage(config: LogConfig, content: LogMessage) {
break;
default: // QUOTEBLOCK, PLAIN or unspecified
// Wrap entire message in quotes
// please disregard this mess
c = { ...c, ...content.overrides?.revoltQuoteblock };
const quote = config.revolt.type == 'PLAIN' ? '' : '>';
const quote = config.revolt.type == "PLAIN" ? "" : ">";
if (c.title) message += `## ${c.title}\n`;
if (c.description) message += `${c.description}\n`;
if (c.fields?.length) {
for (const field of c.fields) {
message += `${quote ? '\u200b\n' : ''}${quote}### ${field.title}\n` +
`${quote}${field.content.trim().split('\n').join('\n' + quote)}\n${quote ? '\n' : ''}`;
message +=
`${quote ? "\u200b\n" : ""}${quote}### ${
field.title
}\n` +
`${quote}${field.content
.trim()
.split("\n")
.join("\n" + quote)}\n${quote ? "\n" : ""}`;
}
}
message = message.trim().split('\n').join('\n' + quote); // Wrap entire message in quotes
if (c.image?.url) message += `\n[Attachment](${c.image.url})`;
message = message
.trim()
.split("\n")
.join("\n" + quote);
if (c.image?.url)
message += `\n[Attachment](${c.image.url})`;
break;
}
channel.sendMessage({
content: message,
embeds: embed ? [ embed ] : undefined,
attachments: content.attachments ?
await Promise.all(content.attachments?.map(a => uploadFile(a.content, a.name))) :
undefined
}).catch(e => logger.error(`Failed to send log message (revolt): ${e}`));
} catch(e) {
logger.error(`Failed to send log message in ${config.revolt.channel}: ${e}`);
channel
.sendMessage({
content: message,
embeds: embed ? [embed] : undefined,
attachments: content.attachments
? await Promise.all(
content.attachments?.map((a) =>
uploadFile(a.content, a.name)
)
)
: undefined,
})
.catch((e) =>
logger.error(`Failed to send log message (revolt): ${e}`)
);
} catch (e) {
logger.error(
`Failed to send log message in ${config.revolt.channel}: ${e}`
);
}
}
}
@ -278,17 +362,17 @@ async function sendLogMessage(config: LogConfig, content: LogMessage) {
* Attempts to escape a message's markdown content (qoutes, headers, **bold** / *italic*, etc)
*/
function sanitizeMessageContent(msg: string): string {
let str = '';
for (let line of msg.split('\n')) {
let str = "";
for (let line of msg.split("\n")) {
line = line.trim();
if (line.startsWith('#') || // headers
line.startsWith('>') || // quotes
line.startsWith('|') || // tables
line.startsWith('*') || // unordered lists
line.startsWith('-') || // ^
line.startsWith('+') // ^
if (
line.startsWith("#") || // headers
line.startsWith(">") || // quotes
line.startsWith("|") || // tables
line.startsWith("*") || // unordered lists
line.startsWith("-") || // ^
line.startsWith("+") // ^
) {
line = `\\${line}`;
}
@ -299,14 +383,17 @@ function sanitizeMessageContent(msg: string): string {
line = `\u200b${line}`;
}
for (const char of ['_', '!!', '~', '`', '*', '^', '$']) {
line = line.replace(new RegExp(`(?<!\\\\)\\${char}`, 'g'), `\\${char}`);
for (const char of ["_", "!!", "~", "`", "*", "^", "$"]) {
line = line.replace(
new RegExp(`(?<!\\\\)\\${char}`, "g"),
`\\${char}`
);
}
// Mentions
line = line.replace(/<@/g, `<\\@`);
str += line + '\n';
str += line + "\n";
}
return str;
@ -319,12 +406,16 @@ enum EmbedColor {
Success = "var(--success)",
}
function embed(content: string, title?: string|null, color?: string|EmbedColor): SendableEmbed {
function embed(
content: string,
title?: string | null,
color?: string | EmbedColor
): SendableEmbed {
return {
description: content,
title: title,
colour: color,
}
};
}
function dedupeArray<T>(...arrays: T[][]): T[] {
@ -342,65 +433,191 @@ function dedupeArray<T>(...arrays: T[][]): T[] {
function getMutualServers(user: User) {
const servers: Server[] = [];
for (const member of client.members) {
if (member[1]._id.user == user._id && member[1].server) servers.push(member[1].server);
if (member[1]._id.user == user._id && member[1].server)
servers.push(member[1].server);
}
return servers;
}
const awaitClient = () => new Promise<void>(async resolve => {
if (!client.user) client.once('ready', () => resolve());
else resolve();
});
const awaitClient = () =>
new Promise<void>(async (resolve) => {
if (!client.user) client.once("ready", () => resolve());
else resolve();
});
const getDmChannel = async (user: string|{_id: string}|User) => {
if (typeof user == 'string') user = client.users.get(user) || await client.users.fetch(user);
if (!(user instanceof User)) user = client.users.get(user._id) || await client.users.fetch(user._id);
const getDmChannel = async (user: string | { _id: string } | User) => {
if (typeof user == "string")
user = client.users.get(user) || (await client.users.fetch(user));
if (!(user instanceof User))
user =
client.users.get(user._id) || (await client.users.fetch(user._id));
return Array.from(client.channels).find(
c => c[1].channel_type == 'DirectMessage' && c[1].recipient?._id == (user as User)._id
)?.[1] || await (user as User).openDM();
}
return (
Array.from(client.channels).find(
(c) =>
c[1].channel_type == "DirectMessage" &&
c[1].recipient?._id == (user as User)._id
)?.[1] || (await (user as User).openDM())
);
};
const generateInfractionDMEmbed = (server: Server, serverConfig: ServerConfig, infraction: Infraction, message: MessageCommandContext) => {
const generateInfractionDMEmbed = (
server: Server,
serverConfig: ServerConfig,
infraction: Infraction,
message: MessageCommandContext
) => {
const embed: SendableEmbed = {
title: message.serverContext.name,
icon_url: message.serverContext.generateIconURL({ max_side: 128 }),
colour: '#ff9e2f',
colour: "#ff9e2f",
url: message.url,
description: 'You have been ' +
description:
"You have been " +
(infraction.actionType
? `**${infraction.actionType == 'ban' ? 'banned' : 'kicked'}** from `
? `**${
infraction.actionType == "ban" ? "banned" : "kicked"
}** from `
: `**warned** in `) +
`'${sanitizeMessageContent(message.serverContext.name).trim()}' <t:${Math.round(infraction.date / 1000)}:R>.\n` +
`'${sanitizeMessageContent(
message.serverContext.name
).trim()}' <t:${Math.round(infraction.date / 1000)}:R>.\n` +
`**Reason:** ${infraction.reason}\n` +
`**Moderator:** [@${sanitizeMessageContent(message.author?.username || 'Unknown')}](/@${message.author_id})\n` +
`**Moderator:** [@${sanitizeMessageContent(
message.author?.username || "Unknown"
)}](/@${message.author_id})\n` +
`**Infraction ID:** \`${infraction._id}\`` +
(infraction.actionType == 'ban' && infraction.expires
? (infraction.expires == Infinity
? '\n**Ban duration:** Permanent'
: `\n**Ban expires** <t:${Math.round(infraction.expires / 1000)}:R>`)
: '') +
(infraction.actionType == 'ban'
? '\n\n**Reminder:** Circumventing this ban by using another account is a violation of the Revolt [Terms of Service](<https://revolt.chat/terms>) ' +
'and may result in your accounts getting suspended from the platform.'
: '')
}
(infraction.actionType == "ban" && infraction.expires
? infraction.expires == Infinity
? "\n**Ban duration:** Permanent"
: `\n**Ban expires** <t:${Math.round(
infraction.expires / 1000
)}:R>`
: "") +
(infraction.actionType == "ban"
? "\n\n**Reminder:** Circumventing this ban by using another account is a violation of the Revolt [Terms of Service](<https://revolt.chat/terms>) " +
"and may result in your accounts getting suspended from the platform."
: ""),
};
if (serverConfig.contact) {
if (RE_MAILTO_URI.test(serverConfig.contact)) {
embed.description += `\n\nIf you wish to appeal this decision, you may contact the server's moderation team at ` +
`[${serverConfig.contact.replace(RE_MAILTO_URI, '')}](${serverConfig.contact}).`
}
else if (RE_HTTP_URI.test(serverConfig.contact)) {
embed.description += `\n\nIf you wish to appeal this decision, you may do so [here](${serverConfig.contact}).`
}
else {
embed.description +=
`\n\nIf you wish to appeal this decision, you may contact the server's moderation team at ` +
`[${serverConfig.contact.replace(RE_MAILTO_URI, "")}](${
serverConfig.contact
}).`;
} else if (RE_HTTP_URI.test(serverConfig.contact)) {
embed.description += `\n\nIf you wish to appeal this decision, you may do so [here](${serverConfig.contact}).`;
} else {
embed.description += `\n\n${serverConfig.contact}`;
}
}
return embed;
}
};
// Copied from https://github.com/janderedev/feeds-bot/blob/master/src/util.ts
const yesNoMessage = (
channel: Channel,
allowedUser: string,
message: string,
title?: string,
messageYes?: string,
messageNo?: string
): Promise<boolean> =>
new Promise(async (resolve, reject) => {
const EMOJI_YES = "✅",
EMOJI_NO = "❌";
try {
const msg = await channel.sendMessage({
embeds: [
{
colour: "var(--status-streaming)",
title: title,
description: message,
},
],
interactions: {
reactions: [EMOJI_YES, EMOJI_NO],
restrict_reactions: true,
},
});
let destroyed = false;
const cb = async (packet: ClientboundNotification) => {
if (packet.type != "MessageReact") return;
if (packet.id != msg._id) return;
if (packet.user_id != allowedUser) return;
switch (packet.emoji_id) {
case EMOJI_YES:
channel.client.removeListener("packet", cb);
destroyed = true;
resolve(true);
msg.edit({
embeds: [
{
colour: "var(--success)",
title: title,
description: `${EMOJI_YES} ${
messageYes ?? "Confirmed!"
}`,
},
],
}).catch((e) => console.error(e));
break;
case EMOJI_NO:
channel.client.removeListener("packet", cb);
destroyed = true;
resolve(false);
msg.edit({
embeds: [
{
colour: "var(--error)",
title: title,
description: `${EMOJI_NO} ${
messageNo ?? "Cancelled."
}`,
},
],
}).catch((e) => console.error(e));
break;
default:
logger.warn(
"Received unexpected reaction: " + packet.emoji_id
);
}
};
channel.client.on("packet", cb);
setTimeout(() => {
if (!destroyed) {
resolve(false);
channel.client.removeListener("packet", cb);
msg.edit({
embeds: [
{
colour: "var(--error)",
title: title,
description: `${EMOJI_NO} Timed out`,
},
],
}).catch((e) => console.error(e));
}
}, 30000);
} catch (e) {
reject(e);
}
});
// Get all cached members of a server. Whoever put STRINGIFIED JSON as map keys is now on my hit list.
const getMembers = (id: string) =>
Array.from(client.members.entries())
.filter((item) => item[0].includes(`"${id}"`))
.map((entry) => entry[1]);
export {
getAutumnURL,
@ -423,9 +640,11 @@ export {
getMutualServers,
getDmChannel,
generateInfractionDMEmbed,
yesNoMessage,
getMembers,
EmbedColor,
NO_MANAGER_MSG,
ULID_REGEX,
USER_MENTION_REGEX,
CHANNEL_MENTION_REGEX,
}
};