3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-14 13:55:03 +00:00

feat: first batch of emojis 🎉

This commit is contained in:
Lily Bergonzat 2024-02-14 09:17:11 +01:00
parent dfb0e2c19d
commit a4c4b17a14
91 changed files with 3659 additions and 2032 deletions

View file

@ -0,0 +1,55 @@
import { Attachment, ChatInputCommandInteraction, GuildMember, TextBasedChannel, User } from "discord.js";
import { GuildPluginData } from "knub";
import { CaseTypes } from "../../../../data/CaseTypes";
import { Case } from "../../../../data/entities/Case";
import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils";
import { UnknownUser, renderUserUsername, resolveMember } from "../../../../utils";
import { CasesPlugin } from "../../../Cases/CasesPlugin";
import { LogsPlugin } from "../../../Logs/LogsPlugin";
import { ModActionsPluginType } from "../../types";
import { formatReasonWithAttachments } from "../formatReasonWithAttachments";
export async function actualAddCaseCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
author: GuildMember,
mod: GuildMember,
attachments: Array<Attachment>,
user: User | UnknownUser,
type: keyof CaseTypes,
reason: string,
) {
// If the user exists as a guild member, make sure we can act on them first
const member = await resolveMember(pluginData.client, pluginData.guild, user.id);
if (member && !canActOn(pluginData, author, member)) {
sendErrorMessage(pluginData, context, "Cannot add case on this user: insufficient permissions");
return;
}
const formattedReason = formatReasonWithAttachments(reason, attachments);
// Create the case
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const theCase: Case = await casesPlugin.createCase({
userId: user.id,
modId: mod.id,
type: CaseTypes[type],
reason: formattedReason,
ppId: mod.id !== author.id ? author.id : undefined,
});
if (user) {
sendSuccessMessage(pluginData, context, `Case #${theCase.case_number} created for **${renderUserUsername(user)}**`);
} else {
sendSuccessMessage(pluginData, context, `Case #${theCase.case_number} created`);
}
// Log the action
pluginData.getPlugin(LogsPlugin).logCaseCreate({
mod: mod.user,
userId: user.id,
caseNum: theCase.case_number,
caseType: type.toUpperCase(),
reason: formattedReason,
});
}

View file

@ -0,0 +1,182 @@
import { Attachment, ChatInputCommandInteraction, GuildMember, TextBasedChannel, User } from "discord.js";
import humanizeDuration from "humanize-duration";
import { GuildPluginData } from "knub";
import { getMemberLevel } from "knub/helpers";
import { CaseTypes } from "../../../../data/CaseTypes";
import { clearExpiringTempban, registerExpiringTempban } from "../../../../data/loops/expiringTempbansLoop";
import { canActOn, isContextInteraction, sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils";
import { UnknownUser, UserNotificationMethod, renderUserUsername, resolveMember } from "../../../../utils";
import { banLock } from "../../../../utils/lockNameHelpers";
import { waitForButtonConfirm } from "../../../../utils/waitForInteraction";
import { CasesPlugin } from "../../../Cases/CasesPlugin";
import { LogsPlugin } from "../../../Logs/LogsPlugin";
import { ModActionsPluginType } from "../../types";
import { banUserId } from "../banUserId";
import { formatReasonWithAttachments } from "../formatReasonWithAttachments";
import { isBanned } from "../isBanned";
export async function actualBanCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
user: User | UnknownUser,
time: number | null,
reason: string,
attachments: Attachment[],
author: GuildMember,
mod: GuildMember,
contactMethods?: UserNotificationMethod[],
deleteDays?: number,
) {
const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id);
const formattedReason = formatReasonWithAttachments(reason, attachments);
// acquire a lock because of the needed user-inputs below (if banned/not on server)
const lock = await pluginData.locks.acquire(banLock(user));
let forceban = false;
const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id);
if (!memberToBan) {
const banned = await isBanned(pluginData, user.id);
if (!banned) {
// Ask the mod if we should upgrade to a forceban as the user is not on the server
const reply = await waitForButtonConfirm(
context,
{ content: "User not on server, forceban instead?" },
{ confirmText: "Yes", cancelText: "No", restrictToId: author.id },
);
if (!reply) {
sendErrorMessage(pluginData, context, "User not on server, ban cancelled by moderator");
lock.unlock();
return;
} else {
forceban = true;
}
}
// Abort if trying to ban user indefinitely if they are already banned indefinitely
if (!existingTempban && !time) {
sendErrorMessage(pluginData, context, `User is already banned indefinitely.`);
return;
}
// Ask the mod if we should update the existing ban
const reply = await waitForButtonConfirm(
context,
{ content: "Failed to message the user. Log the warning anyway?" },
{ confirmText: "Yes", cancelText: "No", restrictToId: author.id },
);
if (!reply) {
sendErrorMessage(pluginData, context, "User already banned, update cancelled by moderator");
lock.unlock();
return;
}
// Update or add new tempban / remove old tempban
if (time && time > 0) {
if (existingTempban) {
await pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id);
} else {
await pluginData.state.tempbans.addTempban(user.id, time, mod.id);
}
const tempban = (await pluginData.state.tempbans.findExistingTempbanForUserId(user.id))!;
registerExpiringTempban(tempban);
} else if (existingTempban) {
clearExpiringTempban(existingTempban);
pluginData.state.tempbans.clear(user.id);
}
// Create a new case for the updated ban since we never stored the old case id and log the action
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const createdCase = await casesPlugin.createCase({
modId: mod.id,
type: CaseTypes.Ban,
userId: user.id,
reason: formattedReason,
noteDetails: [`Ban updated to ${time ? humanizeDuration(time) : "indefinite"}`],
});
if (time) {
pluginData.getPlugin(LogsPlugin).logMemberTimedBan({
mod: mod.user,
user,
caseNumber: createdCase.case_number,
reason: formattedReason,
banTime: humanizeDuration(time),
});
} else {
pluginData.getPlugin(LogsPlugin).logMemberBan({
mod: mod.user,
user,
caseNumber: createdCase.case_number,
reason: formattedReason,
});
}
sendSuccessMessage(
pluginData,
context,
`Ban updated to ${time ? "expire in " + humanizeDuration(time) + " from now" : "indefinite"}`,
);
lock.unlock();
return;
}
// Make sure we're allowed to ban this member if they are on the server
if (!forceban && !canActOn(pluginData, author, memberToBan!)) {
const ourLevel = getMemberLevel(pluginData, author);
const targetLevel = getMemberLevel(pluginData, memberToBan!);
sendErrorMessage(
pluginData,
context,
`Cannot ban: target permission level is equal or higher to yours, ${targetLevel} >= ${ourLevel}`,
);
lock.unlock();
return;
}
const matchingConfig = await pluginData.config.getMatchingConfig({
member: author,
channel: isContextInteraction(context) ? context.channel : context,
});
const deleteMessageDays = deleteDays ?? matchingConfig.ban_delete_message_days;
const banResult = await banUserId(
pluginData,
user.id,
formattedReason,
{
contactMethods,
caseArgs: {
modId: mod.id,
ppId: mod.id !== author.id ? author.id : undefined,
},
deleteMessageDays,
modId: mod.id,
},
time ?? undefined,
);
if (banResult.status === "failed") {
sendErrorMessage(pluginData, context, `Failed to ban member: ${banResult.error}`);
lock.unlock();
return;
}
let forTime = "";
if (time && time > 0) {
forTime = `for ${humanizeDuration(time)} `;
}
// Confirm the action to the moderator
let response = "";
if (!forceban) {
response = `Banned **${renderUserUsername(user)}** ${forTime}(Case #${banResult.case.case_number})`;
if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`;
} else {
response = `Member forcebanned ${forTime}(Case #${banResult.case.case_number})`;
}
lock.unlock();
sendSuccessMessage(pluginData, context, response);
}

View file

@ -0,0 +1,24 @@
import { ChatInputCommandInteraction, TextBasedChannel } from "discord.js";
import { GuildPluginData } from "knub";
import { sendContextResponse, sendErrorMessage } from "../../../../pluginUtils";
import { CasesPlugin } from "../../../Cases/CasesPlugin";
import { ModActionsPluginType } from "../../types";
export async function actualCaseCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
authorId: string,
caseNumber: number,
) {
const theCase = await pluginData.state.cases.findByCaseNumber(caseNumber);
if (!theCase) {
sendErrorMessage(pluginData, context, "Case not found");
return;
}
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const embed = await casesPlugin.getCaseEmbed(theCase.id, authorId);
sendContextResponse(context, embed);
}

View file

@ -0,0 +1,258 @@
import { APIEmbed, ChatInputCommandInteraction, TextBasedChannel, User } from "discord.js";
import { GuildPluginData } from "knub";
import { In } from "typeorm";
import { FindOptionsWhere } from "typeorm/find-options/FindOptionsWhere";
import { CaseTypes } from "../../../../data/CaseTypes";
import { Case } from "../../../../data/entities/Case";
import { sendContextResponse, sendErrorMessage } from "../../../../pluginUtils";
import {
UnknownUser,
chunkArray,
emptyEmbedValue,
renderUserUsername,
resolveUser,
trimLines,
} from "../../../../utils";
import { asyncMap } from "../../../../utils/async";
import { createPaginatedMessage } from "../../../../utils/createPaginatedMessage";
import { getChunkedEmbedFields } from "../../../../utils/getChunkedEmbedFields";
import { getGuildPrefix } from "../../../../utils/getGuildPrefix";
import { CasesPlugin } from "../../../Cases/CasesPlugin";
import { ModActionsPluginType } from "../../types";
const casesPerPage = 5;
const maxExpandedCases = 8;
async function sendExpandedCases(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
casesCount: number,
cases: Case[],
) {
if (casesCount > maxExpandedCases) {
await sendContextResponse(context, "Too many cases for expanded view. Please use compact view instead.");
return;
}
const casesPlugin = pluginData.getPlugin(CasesPlugin);
for (const theCase of cases) {
const embed = await casesPlugin.getCaseEmbed(theCase.id);
await sendContextResponse(context, embed);
}
}
async function casesUserCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
author: User,
modId: string | null,
user: User | UnknownUser,
modName: string,
typesToShow: CaseTypes[],
hidden: boolean | null,
expand: boolean | null,
) {
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const casesFilters: Omit<FindOptionsWhere<Case>, "guild_id" | "user_id"> = { type: In(typesToShow) };
if (modId) {
casesFilters.mod_id = modId;
}
const cases = await pluginData.state.cases.with("notes").getByUserId(user.id, casesFilters);
const normalCases = cases.filter((c) => !c.is_hidden);
const hiddenCases = cases.filter((c) => c.is_hidden);
const userName =
user instanceof UnknownUser && cases.length ? cases[cases.length - 1].user_name : renderUserUsername(user);
if (cases.length === 0) {
await sendContextResponse(context, `No cases found for **${userName}**${modId ? ` by ${modName}` : ""}.`);
return;
}
const casesToDisplay = hidden ? cases : normalCases;
if (!casesToDisplay.length) {
await sendContextResponse(
context,
`No normal cases found for **${userName}**. Use "-hidden" to show ${cases.length} hidden cases.`,
);
return;
}
if (expand) {
sendExpandedCases(pluginData, context, casesToDisplay.length, casesToDisplay);
return;
}
// Compact view (= regular message with a preview of each case)
const lines = await asyncMap(casesToDisplay, (c) => casesPlugin.getCaseSummary(c, true, author.id));
const prefix = getGuildPrefix(pluginData);
const linesPerChunk = 10;
const lineChunks = chunkArray(lines, linesPerChunk);
const footerField = {
name: emptyEmbedValue,
value: trimLines(`
Use \`${prefix}case <num>\` to see more information about an individual case
`),
};
for (const [i, linesInChunk] of lineChunks.entries()) {
const isLastChunk = i === lineChunks.length - 1;
if (isLastChunk && !hidden && hiddenCases.length) {
if (hiddenCases.length === 1) {
linesInChunk.push(`*+${hiddenCases.length} hidden case, use "-hidden" to show it*`);
} else {
linesInChunk.push(`*+${hiddenCases.length} hidden cases, use "-hidden" to show them*`);
}
}
const chunkStart = i * linesPerChunk + 1;
const chunkEnd = Math.min((i + 1) * linesPerChunk, lines.length);
const embed = {
author: {
name:
lineChunks.length === 1
? `Cases for ${userName}${modId ? ` by ${modName}` : ""} (${lines.length} total)`
: `Cases ${chunkStart}${chunkEnd} of ${lines.length} for ${userName}`,
icon_url: user instanceof User ? user.displayAvatarURL() : undefined,
},
fields: [
...getChunkedEmbedFields(emptyEmbedValue, linesInChunk.join("\n")),
...(isLastChunk ? [footerField] : []),
],
} satisfies APIEmbed;
sendContextResponse(context, { embeds: [embed] });
}
}
async function casesModCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
author: User,
modId: string | null,
mod: User | UnknownUser,
modName: string,
typesToShow: CaseTypes[],
hidden: boolean | null,
expand: boolean | null,
) {
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const caseFilters = { type: In(typesToShow), is_hidden: !!hidden };
const totalCases = await casesPlugin.getTotalCasesByMod(modId ?? author.id, caseFilters);
if (totalCases === 0) {
sendErrorMessage(pluginData, context, `No cases by **${modName}**`);
return;
}
const totalPages = Math.max(Math.ceil(totalCases / casesPerPage), 1);
const prefix = getGuildPrefix(pluginData);
if (expand) {
// Expanded view (= individual case embeds)
const cases = totalCases > 8 ? [] : await casesPlugin.getRecentCasesByMod(modId ?? author.id, 8, 0, caseFilters);
sendExpandedCases(pluginData, context, totalCases, cases);
return;
}
createPaginatedMessage(
pluginData.client,
context,
totalPages,
async (page) => {
const cases = await casesPlugin.getRecentCasesByMod(
modId ?? author.id,
casesPerPage,
(page - 1) * casesPerPage,
caseFilters,
);
const lines = await asyncMap(cases, (c) => casesPlugin.getCaseSummary(c, true, author.id));
const firstCaseNum = (page - 1) * casesPerPage + 1;
const lastCaseNum = firstCaseNum - 1 + Math.min(cases.length, casesPerPage);
const title = `Most recent cases ${firstCaseNum}-${lastCaseNum} of ${totalCases} by ${modName}`;
const embed = {
author: {
name: title,
icon_url: mod instanceof User ? mod.displayAvatarURL() : undefined,
},
fields: [
...getChunkedEmbedFields(emptyEmbedValue, lines.join("\n")),
{
name: emptyEmbedValue,
value: trimLines(`
Use \`${prefix}case <num>\` to see more information about an individual case
Use \`${prefix}cases <user>\` to see a specific user's cases
`),
},
],
} satisfies APIEmbed;
return { embeds: [embed] };
},
{
limitToUserId: author.id,
},
);
}
export async function actualCasesCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
modId: string | null,
user: User | UnknownUser | null,
author: User,
notes: boolean | null,
warns: boolean | null,
mutes: boolean | null,
unmutes: boolean | null,
bans: boolean | null,
unbans: boolean | null,
reverseFilters: boolean | null,
hidden: boolean | null,
expand: boolean | null,
) {
const mod = modId ? await resolveUser(pluginData.client, modId) : null;
const modName = modId ? (mod instanceof User ? renderUserUsername(mod) : modId) : renderUserUsername(author);
let typesToShow: CaseTypes[] = [];
if (notes) typesToShow.push(CaseTypes.Note);
if (warns) typesToShow.push(CaseTypes.Warn);
if (mutes) typesToShow.push(CaseTypes.Mute);
if (unmutes) typesToShow.push(CaseTypes.Unmute);
if (bans) typesToShow.push(CaseTypes.Ban);
if (unbans) typesToShow.push(CaseTypes.Unban);
if (typesToShow.length === 0) {
typesToShow = [CaseTypes.Note, CaseTypes.Warn, CaseTypes.Mute, CaseTypes.Unmute, CaseTypes.Ban, CaseTypes.Unban];
} else {
if (reverseFilters) {
typesToShow = [
CaseTypes.Note,
CaseTypes.Warn,
CaseTypes.Mute,
CaseTypes.Unmute,
CaseTypes.Ban,
CaseTypes.Unban,
].filter((t) => !typesToShow.includes(t));
}
}
user
? casesUserCmd(pluginData, context, author, modId!, user, modName, typesToShow, hidden, expand)
: casesModCmd(pluginData, context, author, modId!, mod!, modName, typesToShow, hidden, expand);
}

View file

@ -0,0 +1,95 @@
import { ChatInputCommandInteraction, GuildMember, TextBasedChannel } from "discord.js";
import { GuildPluginData, helpers } from "knub";
import { Case } from "../../../../data/entities/Case";
import {
isContextInteraction,
sendContextResponse,
sendErrorMessage,
sendSuccessMessage,
} from "../../../../pluginUtils";
import { SECONDS } from "../../../../utils";
import { CasesPlugin } from "../../../Cases/CasesPlugin";
import { LogsPlugin } from "../../../Logs/LogsPlugin";
import { TimeAndDatePlugin } from "../../../TimeAndDate/TimeAndDatePlugin";
import { ModActionsPluginType } from "../../types";
export async function actualDeleteCaseCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
author: GuildMember,
caseNumbers: number[],
force: boolean,
) {
const failed: number[] = [];
const validCases: Case[] = [];
let cancelled = 0;
for (const num of caseNumbers) {
const theCase = await pluginData.state.cases.findByCaseNumber(num);
if (!theCase) {
failed.push(num);
continue;
}
validCases.push(theCase);
}
if (failed.length === caseNumbers.length) {
sendErrorMessage(pluginData, context, "None of the cases were found!");
return;
}
for (const theCase of validCases) {
if (!force) {
const cases = pluginData.getPlugin(CasesPlugin);
const embedContent = await cases.getCaseEmbed(theCase);
sendContextResponse(context, {
...embedContent,
content: "Delete the following case? Answer 'Yes' to continue, 'No' to cancel.",
});
const reply = await helpers.waitForReply(
pluginData.client,
isContextInteraction(context) ? context.channel! : context,
author.id,
15 * SECONDS,
);
const normalizedReply = (reply?.content || "").toLowerCase().trim();
if (normalizedReply !== "yes" && normalizedReply !== "y") {
sendContextResponse(context, "Cancelled. Case was not deleted.");
cancelled++;
continue;
}
}
const deletedByName = author.user.tag;
const timeAndDate = pluginData.getPlugin(TimeAndDatePlugin);
const deletedAt = timeAndDate.inGuildTz().format(timeAndDate.getDateFormat("pretty_datetime"));
await pluginData.state.cases.softDelete(
theCase.id,
author.id,
deletedByName,
`Case deleted by **${deletedByName}** (\`${author.id}\`) on ${deletedAt}`,
);
const logs = pluginData.getPlugin(LogsPlugin);
logs.logCaseDelete({
mod: author,
case: theCase,
});
}
const failedAddendum =
failed.length > 0
? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}`
: "";
const amt = validCases.length - cancelled;
if (amt === 0) {
sendErrorMessage(pluginData, context, "All deletions were cancelled, no cases were deleted.");
return;
}
sendSuccessMessage(pluginData, context, `${amt} case${amt === 1 ? " was" : "s were"} deleted!${failedAddendum}`);
}

View file

@ -0,0 +1,60 @@
import { Attachment, ChatInputCommandInteraction, GuildMember, Snowflake, TextBasedChannel, User } from "discord.js";
import { GuildPluginData } from "knub";
import { CaseTypes } from "../../../../data/CaseTypes";
import { LogType } from "../../../../data/LogType";
import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils";
import { DAYS, MINUTES, UnknownUser } from "../../../../utils";
import { CasesPlugin } from "../../../Cases/CasesPlugin";
import { LogsPlugin } from "../../../Logs/LogsPlugin";
import { IgnoredEventType, ModActionsPluginType } from "../../types";
import { formatReasonWithAttachments } from "../formatReasonWithAttachments";
import { ignoreEvent } from "../ignoreEvent";
export async function actualForceBanCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
authorId: string,
user: User | UnknownUser,
reason: string,
attachments: Array<Attachment>,
mod: GuildMember,
) {
const formattedReason = formatReasonWithAttachments(reason, attachments);
ignoreEvent(pluginData, IgnoredEventType.Ban, user.id);
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id);
try {
// FIXME: Use banUserId()?
await pluginData.guild.bans.create(user.id as Snowflake, {
deleteMessageSeconds: (1 * DAYS) / MINUTES,
reason: formattedReason ?? undefined,
});
} catch {
sendErrorMessage(pluginData, context, "Failed to forceban member");
return;
}
// Create a case
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const createdCase = await casesPlugin.createCase({
userId: user.id,
modId: mod.id,
type: CaseTypes.Ban,
reason: formattedReason,
ppId: mod.id !== authorId ? authorId : undefined,
});
// Confirm the action
sendSuccessMessage(pluginData, context, `Member forcebanned (Case #${createdCase.case_number})`);
// Log the action
pluginData.getPlugin(LogsPlugin).logMemberForceban({
mod,
userId: user.id,
caseNumber: createdCase.case_number,
reason: formattedReason,
});
pluginData.state.events.emit("ban", user.id, formattedReason);
}

View file

@ -0,0 +1,38 @@
import { ChatInputCommandInteraction, TextBasedChannel } from "discord.js";
import { GuildPluginData } from "knub";
import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils";
import { ModActionsPluginType } from "../../types";
export async function actualHideCaseCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
caseNumbers: number[],
) {
const failed: number[] = [];
for (const num of caseNumbers) {
const theCase = await pluginData.state.cases.findByCaseNumber(num);
if (!theCase) {
failed.push(num);
continue;
}
await pluginData.state.cases.setHidden(theCase.id, true);
}
if (failed.length === caseNumbers.length) {
sendErrorMessage(pluginData, context, "None of the cases were found!");
return;
}
const failedAddendum =
failed.length > 0
? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}`
: "";
const amt = caseNumbers.length - failed.length;
sendSuccessMessage(
pluginData,
context,
`${amt} case${amt === 1 ? " is" : "s are"} now hidden! Use \`unhidecase\` to unhide them.${failedAddendum}`,
);
}

View file

@ -0,0 +1,89 @@
import { Attachment, ChatInputCommandInteraction, GuildMember, TextBasedChannel, User } from "discord.js";
import { GuildPluginData } from "knub";
import { LogType } from "../../../../data/LogType";
import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils";
import {
DAYS,
SECONDS,
UnknownUser,
UserNotificationMethod,
renderUserUsername,
resolveMember,
} from "../../../../utils";
import { IgnoredEventType, ModActionsPluginType } from "../../types";
import { formatReasonWithAttachments } from "../formatReasonWithAttachments";
import { ignoreEvent } from "../ignoreEvent";
import { isBanned } from "../isBanned";
import { kickMember } from "../kickMember";
export async function actualKickCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
author: GuildMember,
user: User | UnknownUser,
reason: string,
attachments: Attachment[],
mod: GuildMember,
contactMethods?: UserNotificationMethod[],
clean?: boolean,
) {
const memberToKick = await resolveMember(pluginData.client, pluginData.guild, user.id);
if (!memberToKick) {
const banned = await isBanned(pluginData, user.id);
if (banned) {
sendErrorMessage(pluginData, context, `User is banned`);
} else {
sendErrorMessage(pluginData, context, `User not found on the server`);
}
return;
}
// Make sure we're allowed to kick this member
if (!canActOn(pluginData, author, memberToKick)) {
sendErrorMessage(pluginData, context, "Cannot kick: insufficient permissions");
return;
}
const formattedReason = formatReasonWithAttachments(reason, attachments);
const kickResult = await kickMember(pluginData, memberToKick, formattedReason, {
contactMethods,
caseArgs: {
modId: mod.id,
ppId: mod.id !== author.id ? author.id : undefined,
},
});
if (clean) {
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, memberToKick.id);
ignoreEvent(pluginData, IgnoredEventType.Ban, memberToKick.id);
try {
await memberToKick.ban({ deleteMessageSeconds: (1 * DAYS) / SECONDS, reason: "kick -clean" });
} catch {
sendErrorMessage(pluginData, context, "Failed to ban the user to clean messages (-clean)");
}
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, memberToKick.id);
ignoreEvent(pluginData, IgnoredEventType.Unban, memberToKick.id);
try {
await pluginData.guild.bans.remove(memberToKick.id, "kick -clean");
} catch {
sendErrorMessage(pluginData, context, "Failed to unban the user after banning them (-clean)");
}
}
if (kickResult.status === "failed") {
sendErrorMessage(pluginData, context, `Failed to kick user`);
return;
}
// Confirm the action to the moderator
let response = `Kicked **${renderUserUsername(memberToKick.user)}** (Case #${kickResult.case.case_number})`;
if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`;
sendSuccessMessage(pluginData, context, response);
}

View file

@ -0,0 +1,163 @@
import { ChatInputCommandInteraction, GuildMember, Snowflake, TextBasedChannel } from "discord.js";
import { GuildPluginData } from "knub";
import { waitForReply } from "knub/helpers";
import { CaseTypes } from "../../../../data/CaseTypes";
import { LogType } from "../../../../data/LogType";
import { humanizeDurationShort } from "../../../../humanizeDurationShort";
import {
canActOn,
isContextInteraction,
sendContextResponse,
sendErrorMessage,
sendSuccessMessage,
} from "../../../../pluginUtils";
import { DAYS, MINUTES, SECONDS, noop } from "../../../../utils";
import { CasesPlugin } from "../../../Cases/CasesPlugin";
import { LogsPlugin } from "../../../Logs/LogsPlugin";
import { IgnoredEventType, ModActionsPluginType } from "../../types";
import { formatReasonWithAttachments } from "../formatReasonWithAttachments";
import { ignoreEvent } from "../ignoreEvent";
export async function actualMassBanCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
userIds: string[],
author: GuildMember,
) {
// Limit to 100 users at once (arbitrary?)
if (userIds.length > 100) {
sendErrorMessage(pluginData, context, `Can only massban max 100 users at once`);
return;
}
// Ask for ban reason (cleaner this way instead of trying to cram it into the args)
sendContextResponse(context, "Ban reason? `cancel` to cancel");
const banReasonReply = await waitForReply(
pluginData.client,
isContextInteraction(context) ? context.channel! : context,
author.id,
);
if (!banReasonReply || !banReasonReply.content || banReasonReply.content.toLowerCase().trim() === "cancel") {
sendErrorMessage(pluginData, context, "Cancelled");
return;
}
const banReason = formatReasonWithAttachments(banReasonReply.content, [...banReasonReply.attachments.values()]);
// Verify we can act on each of the users specified
for (const userId of userIds) {
const member = pluginData.guild.members.cache.get(userId as Snowflake); // TODO: Get members on demand?
if (member && !canActOn(pluginData, author, member)) {
sendErrorMessage(pluginData, context, "Cannot massban one or more users: insufficient permissions");
return;
}
}
// Show a loading indicator since this can take a while
const maxWaitTime = pluginData.state.massbanQueue.timeout * pluginData.state.massbanQueue.length;
const maxWaitTimeFormatted = humanizeDurationShort(maxWaitTime, { round: true });
const initialLoadingText =
pluginData.state.massbanQueue.length === 0
? "Banning..."
: `Massban queued. Waiting for previous massban to finish (max wait ${maxWaitTimeFormatted}).`;
const loadingMsg = await sendContextResponse(context, initialLoadingText);
const waitTimeStart = performance.now();
const waitingInterval = setInterval(() => {
const waitTime = humanizeDurationShort(performance.now() - waitTimeStart, { round: true });
loadingMsg
.edit(`Massban queued. Still waiting for previous massban to finish (waited ${waitTime}).`)
.catch(() => clearInterval(waitingInterval));
}, 1 * MINUTES);
pluginData.state.massbanQueue.add(async () => {
clearInterval(waitingInterval);
if (pluginData.state.unloaded) {
void loadingMsg.delete().catch(noop);
return;
}
void loadingMsg.edit("Banning...").catch(noop);
// Ban each user and count failed bans (if any)
const startTime = performance.now();
const failedBans: string[] = [];
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const messageConfig = isContextInteraction(context)
? await pluginData.config.getForInteraction(context)
: await pluginData.config.getForChannel(context);
const deleteDays = messageConfig.ban_delete_message_days;
for (const [i, userId] of userIds.entries()) {
if (pluginData.state.unloaded) {
break;
}
try {
// Ignore automatic ban cases and logs
// We create our own cases below and post a single "mass banned" log instead
ignoreEvent(pluginData, IgnoredEventType.Ban, userId, 30 * MINUTES);
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, userId, 30 * MINUTES);
await pluginData.guild.bans.create(userId as Snowflake, {
deleteMessageSeconds: (deleteDays * DAYS) / SECONDS,
reason: banReason,
});
await casesPlugin.createCase({
userId,
modId: author.id,
type: CaseTypes.Ban,
reason: `Mass ban: ${banReason}`,
postInCaseLogOverride: false,
});
pluginData.state.events.emit("ban", userId, banReason);
} catch {
failedBans.push(userId);
}
// Send a status update every 10 bans
if ((i + 1) % 10 === 0) {
loadingMsg.edit(`Banning... ${i + 1}/${userIds.length}`).catch(noop);
}
}
const totalTime = performance.now() - startTime;
const formattedTimeTaken = humanizeDurationShort(totalTime, { round: true });
// Clear loading indicator
loadingMsg.delete().catch(noop);
const successfulBanCount = userIds.length - failedBans.length;
if (successfulBanCount === 0) {
// All bans failed - don't create a log entry and notify the user
sendErrorMessage(pluginData, context, "All bans failed. Make sure the IDs are valid.");
} else {
// Some or all bans were successful. Create a log entry for the mass ban and notify the user.
pluginData.getPlugin(LogsPlugin).logMassBan({
mod: author.user,
count: successfulBanCount,
reason: banReason,
});
if (failedBans.length) {
sendSuccessMessage(
pluginData,
context,
`Banned ${successfulBanCount} users in ${formattedTimeTaken}, ${failedBans.length} failed: ${failedBans.join(
" ",
)}`,
);
} else {
sendSuccessMessage(
pluginData,
context,
`Banned ${successfulBanCount} users successfully in ${formattedTimeTaken}`,
);
}
}
});
}

View file

@ -0,0 +1,110 @@
import { ChatInputCommandInteraction, GuildMember, Snowflake, TextBasedChannel } from "discord.js";
import { GuildPluginData } from "knub";
import { waitForReply } from "knub/helpers";
import { LogType } from "../../../../data/LogType";
import { logger } from "../../../../logger";
import {
canActOn,
isContextInteraction,
sendContextResponse,
sendErrorMessage,
sendSuccessMessage,
} from "../../../../pluginUtils";
import { LogsPlugin } from "../../../Logs/LogsPlugin";
import { MutesPlugin } from "../../../Mutes/MutesPlugin";
import { ModActionsPluginType } from "../../types";
import { formatReasonWithAttachments } from "../formatReasonWithAttachments";
export async function actualMassMuteCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
userIds: string[],
author: GuildMember,
) {
// Limit to 100 users at once (arbitrary?)
if (userIds.length > 100) {
sendErrorMessage(pluginData, context, `Can only massmute max 100 users at once`);
return;
}
// Ask for mute reason
sendContextResponse(context, "Mute reason? `cancel` to cancel");
const muteReasonReceived = await waitForReply(
pluginData.client,
isContextInteraction(context) ? context.channel! : context,
author.id,
);
if (
!muteReasonReceived ||
!muteReasonReceived.content ||
muteReasonReceived.content.toLowerCase().trim() === "cancel"
) {
sendErrorMessage(pluginData, context, "Cancelled");
return;
}
const muteReason = formatReasonWithAttachments(muteReasonReceived.content, [
...muteReasonReceived.attachments.values(),
]);
// Verify we can act upon all users
for (const userId of userIds) {
const member = pluginData.guild.members.cache.get(userId as Snowflake);
if (member && !canActOn(pluginData, author, member)) {
sendErrorMessage(pluginData, context, "Cannot massmute one or more users: insufficient permissions");
return;
}
}
// Ignore automatic mute cases and logs for these users
// We'll create our own cases below and post a single "mass muted" log instead
userIds.forEach((userId) => {
// Use longer timeouts since this can take a while
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_MUTE, userId, 120 * 1000);
});
// Show loading indicator
const loadingMsg = await sendContextResponse(context, "Muting...");
// Mute everyone and count fails
const modId = author.id;
const failedMutes: string[] = [];
const mutesPlugin = pluginData.getPlugin(MutesPlugin);
for (const userId of userIds) {
try {
await mutesPlugin.muteUser(userId, 0, `Mass mute: ${muteReason}`, {
caseArgs: {
modId,
},
});
} catch (e) {
logger.info(e);
failedMutes.push(userId);
}
}
// Clear loading indicator
loadingMsg.delete();
const successfulMuteCount = userIds.length - failedMutes.length;
if (successfulMuteCount === 0) {
// All mutes failed
sendErrorMessage(pluginData, context, "All mutes failed. Make sure the IDs are valid.");
} else {
// Success on all or some mutes
pluginData.getPlugin(LogsPlugin).logMassMute({
mod: author.user,
count: successfulMuteCount,
});
if (failedMutes.length) {
sendSuccessMessage(
pluginData,
context,
`Muted ${successfulMuteCount} users, ${failedMutes.length} failed: ${failedMutes.join(" ")}`,
);
} else {
sendSuccessMessage(pluginData, context, `Muted ${successfulMuteCount} users successfully`);
}
}
}

View file

@ -0,0 +1,127 @@
import { ChatInputCommandInteraction, GuildMember, Snowflake, TextBasedChannel } from "discord.js";
import { GuildPluginData } from "knub";
import { waitForReply } from "knub/helpers";
import { CaseTypes } from "../../../../data/CaseTypes";
import { LogType } from "../../../../data/LogType";
import {
isContextInteraction,
sendContextResponse,
sendErrorMessage,
sendSuccessMessage,
} from "../../../../pluginUtils";
import { CasesPlugin } from "../../../Cases/CasesPlugin";
import { LogsPlugin } from "../../../Logs/LogsPlugin";
import { IgnoredEventType, ModActionsPluginType } from "../../types";
import { formatReasonWithAttachments } from "../formatReasonWithAttachments";
import { ignoreEvent } from "../ignoreEvent";
import { isBanned } from "../isBanned";
export async function actualMassUnbanCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
userIds: string[],
author: GuildMember,
) {
// Limit to 100 users at once (arbitrary?)
if (userIds.length > 100) {
sendErrorMessage(pluginData, context, `Can only mass-unban max 100 users at once`);
return;
}
// Ask for unban reason (cleaner this way instead of trying to cram it into the args)
sendContextResponse(context, "Unban reason? `cancel` to cancel");
const unbanReasonReply = await waitForReply(
pluginData.client,
isContextInteraction(context) ? context.channel! : context,
author.id,
);
if (!unbanReasonReply || !unbanReasonReply.content || unbanReasonReply.content.toLowerCase().trim() === "cancel") {
sendErrorMessage(pluginData, context, "Cancelled");
return;
}
const unbanReason = formatReasonWithAttachments(unbanReasonReply.content, [...unbanReasonReply.attachments.values()]);
// Ignore automatic unban cases and logs for these users
// We'll create our own cases below and post a single "mass unbanned" log instead
userIds.forEach((userId) => {
// Use longer timeouts since this can take a while
ignoreEvent(pluginData, IgnoredEventType.Unban, userId, 120 * 1000);
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, userId, 120 * 1000);
});
// Show a loading indicator since this can take a while
const loadingMsg = await sendContextResponse(context, "Unbanning...");
// Unban each user and count failed unbans (if any)
const failedUnbans: Array<{ userId: string; reason: UnbanFailReasons }> = [];
const casesPlugin = pluginData.getPlugin(CasesPlugin);
for (const userId of userIds) {
if (!(await isBanned(pluginData, userId))) {
failedUnbans.push({ userId, reason: UnbanFailReasons.NOT_BANNED });
continue;
}
try {
await pluginData.guild.bans.remove(userId as Snowflake, unbanReason ?? undefined);
await casesPlugin.createCase({
userId,
modId: author.id,
type: CaseTypes.Unban,
reason: `Mass unban: ${unbanReason}`,
postInCaseLogOverride: false,
});
} catch {
failedUnbans.push({ userId, reason: UnbanFailReasons.UNBAN_FAILED });
}
}
// Clear loading indicator
loadingMsg.delete();
const successfulUnbanCount = userIds.length - failedUnbans.length;
if (successfulUnbanCount === 0) {
// All unbans failed - don't create a log entry and notify the user
sendErrorMessage(pluginData, context, "All unbans failed. Make sure the IDs are valid and banned.");
} else {
// Some or all unbans were successful. Create a log entry for the mass unban and notify the user.
pluginData.getPlugin(LogsPlugin).logMassUnban({
mod: author.user,
count: successfulUnbanCount,
reason: unbanReason,
});
if (failedUnbans.length) {
const notBanned = failedUnbans.filter((x) => x.reason === UnbanFailReasons.NOT_BANNED);
const unbanFailed = failedUnbans.filter((x) => x.reason === UnbanFailReasons.UNBAN_FAILED);
let failedMsg = "";
if (notBanned.length > 0) {
failedMsg += `${notBanned.length}x ${UnbanFailReasons.NOT_BANNED}:`;
notBanned.forEach((fail) => {
failedMsg += " " + fail.userId;
});
}
if (unbanFailed.length > 0) {
failedMsg += `\n${unbanFailed.length}x ${UnbanFailReasons.UNBAN_FAILED}:`;
unbanFailed.forEach((fail) => {
failedMsg += " " + fail.userId;
});
}
sendSuccessMessage(
pluginData,
context,
`Unbanned ${successfulUnbanCount} users, ${failedUnbans.length} failed:\n${failedMsg}`,
);
} else {
sendSuccessMessage(pluginData, context, `Unbanned ${successfulUnbanCount} users successfully`);
}
}
}
enum UnbanFailReasons {
NOT_BANNED = "Not banned",
UNBAN_FAILED = "Unban failed",
}

View file

@ -0,0 +1,96 @@
import { Attachment, ChatInputCommandInteraction, GuildMember, TextBasedChannel, User } from "discord.js";
import humanizeDuration from "humanize-duration";
import { GuildPluginData } from "knub";
import { ERRORS, RecoverablePluginError } from "../../../../RecoverablePluginError";
import { logger } from "../../../../logger";
import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils";
import {
UnknownUser,
UserNotificationMethod,
asSingleLine,
isDiscordAPIError,
renderUserUsername,
} from "../../../../utils";
import { MutesPlugin } from "../../../Mutes/MutesPlugin";
import { MuteResult } from "../../../Mutes/types";
import { ModActionsPluginType } from "../../types";
import { formatReasonWithAttachments } from "../formatReasonWithAttachments";
/**
* The actual function run by both !mute and !forcemute.
* The only difference between the two commands is in target member validation.
*/
export async function actualMuteCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
user: User | UnknownUser,
attachments: Array<Attachment>,
mod: GuildMember,
ppId?: string,
time?: number,
reason?: string,
contactMethods?: UserNotificationMethod[],
) {
const timeUntilUnmute = time && humanizeDuration(time);
const formattedReason = reason ? formatReasonWithAttachments(reason, attachments) : undefined;
let muteResult: MuteResult;
const mutesPlugin = pluginData.getPlugin(MutesPlugin);
try {
muteResult = await mutesPlugin.muteUser(user.id, time, formattedReason, {
contactMethods,
caseArgs: {
modId: mod.id,
ppId,
},
});
} catch (e) {
if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
sendErrorMessage(pluginData, context, "Could not mute the user: no mute role set in config");
} else if (isDiscordAPIError(e) && e.code === 10007) {
sendErrorMessage(pluginData, context, "Could not mute the user: unknown member");
} else {
logger.error(`Failed to mute user ${user.id}: ${e.stack}`);
if (user.id == null) {
// FIXME: Debug
// tslint:disable-next-line:no-console
console.trace("[DEBUG] Null user.id for mute");
}
sendErrorMessage(pluginData, context, "Could not mute the user");
}
return;
}
// Confirm the action to the moderator
let response: string;
if (time) {
if (muteResult.updatedExistingMute) {
response = asSingleLine(`
Updated **${renderUserUsername(user)}**'s
mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number})
`);
} else {
response = asSingleLine(`
Muted **${renderUserUsername(user)}**
for ${timeUntilUnmute} (Case #${muteResult.case.case_number})
`);
}
} else {
if (muteResult.updatedExistingMute) {
response = asSingleLine(`
Updated **${renderUserUsername(user)}**'s
mute to indefinite (Case #${muteResult.case.case_number})
`);
} else {
response = asSingleLine(`
Muted **${renderUserUsername(user)}**
indefinitely (Case #${muteResult.case.case_number})
`);
}
}
if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`;
sendSuccessMessage(pluginData, context, response);
}

View file

@ -1,12 +1,12 @@
import { Attachment, ChatInputCommandInteraction, TextBasedChannel, User } from "discord.js";
import { GuildPluginData } from "knub";
import { CaseTypes } from "../../../data/CaseTypes";
import { sendSuccessMessage } from "../../../pluginUtils";
import { UnknownUser, renderUserUsername } from "../../../utils";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { ModActionsPluginType } from "../types";
import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
import { CaseTypes } from "../../../../data/CaseTypes";
import { sendSuccessMessage } from "../../../../pluginUtils";
import { UnknownUser, renderUserUsername } from "../../../../utils";
import { CasesPlugin } from "../../../Cases/CasesPlugin";
import { LogsPlugin } from "../../../Logs/LogsPlugin";
import { ModActionsPluginType } from "../../types";
import { formatReasonWithAttachments } from "../formatReasonWithAttachments";
export async function actualNoteCmd(
pluginData: GuildPluginData<ModActionsPluginType>,

View file

@ -0,0 +1,63 @@
import { Attachment, ChatInputCommandInteraction, GuildMember, Snowflake, TextBasedChannel, User } from "discord.js";
import { GuildPluginData } from "knub";
import { CaseTypes } from "../../../../data/CaseTypes";
import { LogType } from "../../../../data/LogType";
import { clearExpiringTempban } from "../../../../data/loops/expiringTempbansLoop";
import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils";
import { UnknownUser } from "../../../../utils";
import { CasesPlugin } from "../../../Cases/CasesPlugin";
import { LogsPlugin } from "../../../Logs/LogsPlugin";
import { IgnoredEventType, ModActionsPluginType } from "../../types";
import { formatReasonWithAttachments } from "../formatReasonWithAttachments";
import { ignoreEvent } from "../ignoreEvent";
export async function actualUnbanCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
authorId: string,
user: User | UnknownUser,
reason: string,
attachments: Array<Attachment>,
mod: GuildMember,
) {
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, user.id);
const formattedReason = formatReasonWithAttachments(reason, attachments);
try {
ignoreEvent(pluginData, IgnoredEventType.Unban, user.id);
await pluginData.guild.bans.remove(user.id as Snowflake, formattedReason ?? undefined);
} catch {
sendErrorMessage(pluginData, context, "Failed to unban member; are you sure they're banned?");
return;
}
// Create a case
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const createdCase = await casesPlugin.createCase({
userId: user.id,
modId: mod.id,
type: CaseTypes.Unban,
reason: formattedReason,
ppId: mod.id !== authorId ? authorId : undefined,
});
// Delete the tempban, if one exists
const tempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id);
if (tempban) {
clearExpiringTempban(tempban);
await pluginData.state.tempbans.clear(user.id);
}
// Confirm the action
sendSuccessMessage(pluginData, context, `Member unbanned (Case #${createdCase.case_number})`);
// Log the action
pluginData.getPlugin(LogsPlugin).logMemberUnban({
mod: mod.user,
userId: user.id,
caseNumber: createdCase.case_number,
reason: formattedReason ?? "",
});
pluginData.state.events.emit("unban", user.id);
}

View file

@ -0,0 +1,39 @@
import { ChatInputCommandInteraction, TextBasedChannel } from "discord.js";
import { GuildPluginData } from "knub";
import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils";
import { ModActionsPluginType } from "../../types";
export async function actualUnhideCaseCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
caseNumbers: number[],
) {
const failed: number[] = [];
for (const num of caseNumbers) {
const theCase = await pluginData.state.cases.findByCaseNumber(num);
if (!theCase) {
failed.push(num);
continue;
}
await pluginData.state.cases.setHidden(theCase.id, false);
}
if (failed.length === caseNumbers.length) {
sendErrorMessage(pluginData, context, "None of the cases were found!");
return;
}
const failedAddendum =
failed.length > 0
? `\nThe following cases were not found: ${failed.toString().replace(new RegExp(",", "g"), ", ")}`
: "";
const amt = caseNumbers.length - failed.length;
sendSuccessMessage(
pluginData,
context,
`${amt} case${amt === 1 ? " is" : "s are"} no longer hidden!${failedAddendum}`,
);
}

View file

@ -0,0 +1,55 @@
import { Attachment, ChatInputCommandInteraction, GuildMember, TextBasedChannel, User } from "discord.js";
import humanizeDuration from "humanize-duration";
import { GuildPluginData } from "knub";
import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils";
import { UnknownUser, asSingleLine, renderUserUsername } from "../../../../utils";
import { MutesPlugin } from "../../../Mutes/MutesPlugin";
import { ModActionsPluginType } from "../../types";
import { formatReasonWithAttachments } from "../formatReasonWithAttachments";
export async function actualUnmuteCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
user: User | UnknownUser,
attachments: Array<Attachment>,
mod: GuildMember,
ppId?: string,
time?: number,
reason?: string,
) {
const parsedReason = reason ? formatReasonWithAttachments(reason, attachments) : undefined;
const mutesPlugin = pluginData.getPlugin(MutesPlugin);
const result = await mutesPlugin.unmuteUser(user.id, time, {
modId: mod.id,
ppId: ppId ?? undefined,
reason: parsedReason,
});
if (!result) {
sendErrorMessage(pluginData, context, "User is not muted!");
return;
}
// Confirm the action to the moderator
if (time) {
const timeUntilUnmute = time && humanizeDuration(time);
sendSuccessMessage(
pluginData,
context,
asSingleLine(`
Unmuting **${renderUserUsername(user)}**
in ${timeUntilUnmute} (Case #${result.case.case_number})
`),
);
} else {
sendSuccessMessage(
pluginData,
context,
asSingleLine(`
Unmuted **${renderUserUsername(user)}**
(Case #${result.case.case_number})
`),
);
}
}

View file

@ -0,0 +1,61 @@
import { Attachment, ChatInputCommandInteraction, GuildMember, TextBasedChannel } from "discord.js";
import { GuildPluginData } from "knub";
import { CaseTypes } from "../../../../data/CaseTypes";
import { sendErrorMessage, sendSuccessMessage } from "../../../../pluginUtils";
import { UserNotificationMethod, renderUserUsername } from "../../../../utils";
import { waitForButtonConfirm } from "../../../../utils/waitForInteraction";
import { CasesPlugin } from "../../../Cases/CasesPlugin";
import { ModActionsPluginType } from "../../types";
import { formatReasonWithAttachments } from "../formatReasonWithAttachments";
import { warnMember } from "../warnMember";
export async function actualWarnCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
authorId: string,
mod: GuildMember,
memberToWarn: GuildMember,
reason: string,
attachments: Attachment[],
contactMethods?: UserNotificationMethod[],
) {
const config = pluginData.config.get();
const formattedReason = formatReasonWithAttachments(reason, attachments);
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const priorWarnAmount = await casesPlugin.getCaseTypeAmountForUserId(memberToWarn.id, CaseTypes.Warn);
if (config.warn_notify_enabled && priorWarnAmount >= config.warn_notify_threshold) {
const reply = await waitForButtonConfirm(
context,
{ content: config.warn_notify_message.replace("{priorWarnings}", `${priorWarnAmount}`) },
{ confirmText: "Yes", cancelText: "No", restrictToId: authorId },
);
if (!reply) {
sendErrorMessage(pluginData, context, "Warn cancelled by moderator");
return;
}
}
const warnResult = await warnMember(pluginData, memberToWarn, formattedReason, {
contactMethods,
caseArgs: {
modId: mod.id,
ppId: mod.id !== authorId ? authorId : undefined,
reason: formattedReason,
},
retryPromptContext: context,
});
if (warnResult.status === "failed") {
sendErrorMessage(pluginData, context, "Failed to warn user");
return;
}
const messageResultText = warnResult.notifyResult.text ? ` (${warnResult.notifyResult.text})` : "";
sendSuccessMessage(
pluginData,
context,
`Warned **${renderUserUsername(memberToWarn.user)}** (Case #${warnResult.case.case_number})${messageResultText}`,
);
}

View file

@ -1,110 +0,0 @@
import { GuildMember, GuildTextBasedChannel } from "discord.js";
import { GuildPluginData } from "knub";
import { hasPermission } from "knub/helpers";
import { LogType } from "../../../data/LogType";
import { canActOn, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { DAYS, SECONDS, errorMessage, renderUserUsername, resolveMember, resolveUser } from "../../../utils";
import { IgnoredEventType, ModActionsPluginType } from "../types";
import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
import { ignoreEvent } from "./ignoreEvent";
import { isBanned } from "./isBanned";
import { kickMember } from "./kickMember";
import { readContactMethodsFromArgs } from "./readContactMethodsFromArgs";
export async function actualKickMemberCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
msg,
args: {
user: string;
reason: string;
mod: GuildMember;
notify?: string;
"notify-channel"?: GuildTextBasedChannel;
clean?: boolean;
},
) {
const user = await resolveUser(pluginData.client, args.user);
if (!user.id) {
sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
}
const memberToKick = await resolveMember(pluginData.client, pluginData.guild, user.id);
if (!memberToKick) {
const banned = await isBanned(pluginData, user.id);
if (banned) {
sendErrorMessage(pluginData, msg.channel, `User is banned`);
} else {
sendErrorMessage(pluginData, msg.channel, `User not found on the server`);
}
return;
}
// Make sure we're allowed to kick this member
if (!canActOn(pluginData, msg.member, memberToKick)) {
sendErrorMessage(pluginData, msg.channel, "Cannot kick: insufficient permissions");
return;
}
// The moderator who did the action is the message author or, if used, the specified -mod
let mod = msg.member;
if (args.mod) {
if (!(await hasPermission(await pluginData.config.getForMessage(msg), "can_act_as_other"))) {
sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod");
return;
}
mod = args.mod;
}
let contactMethods;
try {
contactMethods = readContactMethodsFromArgs(args);
} catch (e) {
sendErrorMessage(pluginData, msg.channel, e.message);
return;
}
const reason = formatReasonWithAttachments(args.reason, msg.attachments);
const kickResult = await kickMember(pluginData, memberToKick, reason, {
contactMethods,
caseArgs: {
modId: mod.id,
ppId: mod.id !== msg.author.id ? msg.author.id : null,
},
});
if (args.clean) {
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, memberToKick.id);
ignoreEvent(pluginData, IgnoredEventType.Ban, memberToKick.id);
try {
await memberToKick.ban({ deleteMessageSeconds: (1 * DAYS) / SECONDS, reason: "kick -clean" });
} catch {
sendErrorMessage(pluginData, msg.channel, "Failed to ban the user to clean messages (-clean)");
}
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, memberToKick.id);
ignoreEvent(pluginData, IgnoredEventType.Unban, memberToKick.id);
try {
await pluginData.guild.bans.remove(memberToKick.id, "kick -clean");
} catch {
sendErrorMessage(pluginData, msg.channel, "Failed to unban the user after banning them (-clean)");
}
}
if (kickResult.status === "failed") {
msg.channel.send(errorMessage(`Failed to kick user`));
return;
}
// Confirm the action to the moderator
let response = `Kicked **${renderUserUsername(memberToKick.user)}** (Case #${kickResult.case.case_number})`;
if (kickResult.notifyResult.text) response += ` (${kickResult.notifyResult.text})`;
sendSuccessMessage(pluginData, msg.channel, response);
}

View file

@ -1,114 +0,0 @@
import { GuildMember, GuildTextBasedChannel, Message, User } from "discord.js";
import humanizeDuration from "humanize-duration";
import { GuildPluginData } from "knub";
import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
import { logger } from "../../../logger";
import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { UnknownUser, asSingleLine, isDiscordAPIError, renderUserUsername } from "../../../utils";
import { MutesPlugin } from "../../Mutes/MutesPlugin";
import { MuteResult } from "../../Mutes/types";
import { ModActionsPluginType } from "../types";
import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
import { readContactMethodsFromArgs } from "./readContactMethodsFromArgs";
/**
* The actual function run by both !mute and !forcemute.
* The only difference between the two commands is in target member validation.
*/
export async function actualMuteUserCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
user: User | UnknownUser,
msg: Message,
args: {
time?: number;
reason?: string;
mod: GuildMember;
notify?: string;
"notify-channel"?: GuildTextBasedChannel;
},
) {
// The moderator who did the action is the message author or, if used, the specified -mod
let mod: GuildMember = msg.member!;
let pp: User | null = null;
if (args.mod) {
if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg }))) {
sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod");
return;
}
mod = args.mod;
pp = msg.author;
}
const timeUntilUnmute = args.time && humanizeDuration(args.time);
const reason = args.reason ? formatReasonWithAttachments(args.reason, [...msg.attachments.values()]) : undefined;
let muteResult: MuteResult;
const mutesPlugin = pluginData.getPlugin(MutesPlugin);
let contactMethods;
try {
contactMethods = readContactMethodsFromArgs(args);
} catch (e) {
sendErrorMessage(pluginData, msg.channel, e.message);
return;
}
try {
muteResult = await mutesPlugin.muteUser(user.id, args.time, reason, {
contactMethods,
caseArgs: {
modId: mod.id,
ppId: pp ? pp.id : undefined,
},
});
} catch (e) {
if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
sendErrorMessage(pluginData, msg.channel, "Could not mute the user: no mute role set in config");
} else if (isDiscordAPIError(e) && e.code === 10007) {
sendErrorMessage(pluginData, msg.channel, "Could not mute the user: unknown member");
} else {
logger.error(`Failed to mute user ${user.id}: ${e.stack}`);
if (user.id == null) {
// FIXME: Debug
// tslint:disable-next-line:no-console
console.trace("[DEBUG] Null user.id for mute");
}
sendErrorMessage(pluginData, msg.channel, "Could not mute the user");
}
return;
}
// Confirm the action to the moderator
let response: string;
if (args.time) {
if (muteResult.updatedExistingMute) {
response = asSingleLine(`
Updated **${renderUserUsername(user)}**'s
mute to ${timeUntilUnmute} (Case #${muteResult.case.case_number})
`);
} else {
response = asSingleLine(`
Muted **${renderUserUsername(user)}**
for ${timeUntilUnmute} (Case #${muteResult.case.case_number})
`);
}
} else {
if (muteResult.updatedExistingMute) {
response = asSingleLine(`
Updated **${renderUserUsername(user)}**'s
mute to indefinite (Case #${muteResult.case.case_number})
`);
} else {
response = asSingleLine(`
Muted **${renderUserUsername(user)}**
indefinitely (Case #${muteResult.case.case_number})
`);
}
}
if (muteResult.notifyResult.text) response += ` (${muteResult.notifyResult.text})`;
sendSuccessMessage(pluginData, msg.channel, response);
}

View file

@ -1,65 +0,0 @@
import { GuildMember, Message, User } from "discord.js";
import humanizeDuration from "humanize-duration";
import { GuildPluginData } from "knub";
import { hasPermission, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { MutesPlugin } from "../../../plugins/Mutes/MutesPlugin";
import { UnknownUser, asSingleLine, renderUserUsername } from "../../../utils";
import { ModActionsPluginType } from "../types";
import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
export async function actualUnmuteCmd(
pluginData: GuildPluginData<ModActionsPluginType>,
user: User | UnknownUser,
msg: Message,
args: { time?: number; reason?: string; mod?: GuildMember },
) {
// The moderator who did the action is the message author or, if used, the specified -mod
let mod = msg.author;
let pp: User | null = null;
if (args.mod) {
if (!(await hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id }))) {
sendErrorMessage(pluginData, msg.channel, "You don't have permission to use -mod");
return;
}
mod = args.mod.user;
pp = msg.author;
}
const reason = args.reason ? formatReasonWithAttachments(args.reason, [...msg.attachments.values()]) : undefined;
const mutesPlugin = pluginData.getPlugin(MutesPlugin);
const result = await mutesPlugin.unmuteUser(user.id, args.time, {
modId: mod.id,
ppId: pp ? pp.id : undefined,
reason,
});
if (!result) {
sendErrorMessage(pluginData, msg.channel, "User is not muted!");
return;
}
// Confirm the action to the moderator
if (args.time) {
const timeUntilUnmute = args.time && humanizeDuration(args.time);
sendSuccessMessage(
pluginData,
msg.channel,
asSingleLine(`
Unmuting **${renderUserUsername(user)}**
in ${timeUntilUnmute} (Case #${result.case.case_number})
`),
);
} else {
sendSuccessMessage(
pluginData,
msg.channel,
asSingleLine(`
Unmuted **${renderUserUsername(user)}**
(Case #${result.case.case_number})
`),
);
}
}

View file

@ -1,44 +1,53 @@
import { Message } from "discord.js";
import { Attachment, ChatInputCommandInteraction, TextBasedChannel, User } from "discord.js";
import { GuildPluginData } from "knub";
import { CaseTypes } from "../../../data/CaseTypes";
import { Case } from "../../../data/entities/Case";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { CasesPlugin } from "../../../plugins/Cases/CasesPlugin";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { LogsPlugin } from "../../Logs/LogsPlugin";
import { ModActionsPluginType } from "../types";
import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
export async function updateCase(pluginData, msg: Message, args) {
let theCase: Case | undefined;
if (args.caseNumber != null) {
theCase = await pluginData.state.cases.findByCaseNumber(args.caseNumber);
export async function updateCase(
pluginData: GuildPluginData<ModActionsPluginType>,
context: TextBasedChannel | ChatInputCommandInteraction,
author: User,
caseNumber?: number,
note?: string,
attachments: Attachment[] = [],
) {
let theCase: Case | null;
if (caseNumber != null) {
theCase = await pluginData.state.cases.findByCaseNumber(caseNumber);
} else {
theCase = await pluginData.state.cases.findLatestByModId(msg.author.id);
theCase = await pluginData.state.cases.findLatestByModId(author.id);
}
if (!theCase) {
sendErrorMessage(pluginData, msg.channel, "Case not found");
sendErrorMessage(pluginData, context, "Case not found");
return;
}
if (!args.note && msg.attachments.size === 0) {
sendErrorMessage(pluginData, msg.channel, "Text or attachment required");
if (!note && attachments.length === 0) {
sendErrorMessage(pluginData, context, "Text or attachment required");
return;
}
const note = formatReasonWithAttachments(args.note, [...msg.attachments.values()]);
const formattedNote = formatReasonWithAttachments(note ?? "", attachments);
const casesPlugin = pluginData.getPlugin(CasesPlugin);
await casesPlugin.createCaseNote({
caseId: theCase.id,
modId: msg.author.id,
body: note,
modId: author.id,
body: formattedNote,
});
pluginData.getPlugin(LogsPlugin).logCaseUpdate({
mod: msg.author,
mod: author,
caseNumber: theCase.case_number,
caseType: CaseTypes[theCase.type],
note,
note: formattedNote,
});
sendSuccessMessage(pluginData, msg.channel, `Case \`#${theCase.case_number}\` updated`);
sendSuccessMessage(pluginData, context, `Case \`#${theCase.case_number}\` updated`);
}

View file

@ -1,6 +1,7 @@
import { GuildMember, Snowflake } from "discord.js";
import { GuildPluginData } from "knub";
import { CaseTypes } from "../../../data/CaseTypes";
import { isContextInteraction } from "../../../pluginUtils";
import { TemplateSafeValueContainer, renderTemplate } from "../../../templateFormatter";
import { UserNotificationResult, createUserNotificationError, notifyUser, resolveUser, ucfirst } from "../../../utils";
import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
@ -39,20 +40,23 @@ export async function warnMember(
}
if (!notifyResult.success) {
if (warnOptions.retryPromptChannel && pluginData.guild.channels.resolve(warnOptions.retryPromptChannel.id)) {
const reply = await waitForButtonConfirm(
warnOptions.retryPromptChannel,
{ content: "Failed to message the user. Log the warning anyway?" },
{ confirmText: "Yes", cancelText: "No", restrictToId: warnOptions.caseArgs?.modId },
);
const contextIsChannel = warnOptions.retryPromptContext && !isContextInteraction(warnOptions.retryPromptContext);
const isValidChannel = contextIsChannel && pluginData.guild.channels.resolve(warnOptions.retryPromptContext!.id);
if (!reply) {
return {
status: "failed",
error: "Failed to message user",
};
}
} else {
if (!warnOptions.retryPromptContext || !isValidChannel) {
return {
status: "failed",
error: "Failed to message user",
};
}
const reply = await waitForButtonConfirm(
warnOptions.retryPromptContext,
{ content: "Failed to message the user. Log the warning anyway?" },
{ confirmText: "Yes", cancelText: "No", restrictToId: warnOptions.caseArgs?.modId },
);
if (!reply) {
return {
status: "failed",
error: "Failed to message user",