From de267ccc6f425c44440283b6131746cd906354fe Mon Sep 17 00:00:00 2001 From: LunaUrsa <1836049+LunaUrsa@users.noreply.github.com> Date: Fri, 12 Jan 2024 09:30:44 -0600 Subject: [PATCH] Mod Updates * Clean Init * Update timer * Update Datafiles * Linting * Save progress * Save progress * Saving progress * Separating button function * oooo boyo * Minor change to text * Little more polish, not the sausage * Fix ban message * Update migration file * Update drug defs --- assets/data/combinedDB.json | 10 + assets/data/tripsitCombos.json | 20 + assets/data/tripsitDB.json | 20 + package.json | 4 +- .../u.info.ts => archive/u.underban.ts} | 30 +- src/discord/commands/global/d.ai.ts | 465 +--- src/discord/commands/guild/d.cooperative.ts | 715 +++++ src/discord/commands/guild/d.last.ts | 2 +- src/discord/commands/guild/d.moderate.ts | 2383 +++++++++++++--- src/discord/commands/guild/d.report.ts | 104 +- src/discord/commands/guild/m.note.ts | 62 - src/discord/commands/guild/m.report.ts | 69 +- src/discord/commands/guild/m.timeout.ts | 87 - src/discord/commands/guild/m.warn.ts | 71 - src/discord/commands/guild/u.ban.ts | 83 - src/discord/commands/guild/u.kick.ts | 64 - src/discord/commands/guild/u.report.ts | 45 + src/discord/commands/guild/u.underban.ts | 74 - src/discord/events/buttonClick.ts | 18 +- src/discord/events/guildBanAdd.ts | 224 +- src/discord/events/guildMemberAdd.ts | 243 +- src/discord/events/guildMemberRemove.ts | 37 +- src/discord/events/messageDelete.ts | 4 +- src/discord/utils/embedTemplate.ts | 11 +- src/discord/utils/guildMemberLookup.ts | 34 +- src/global/commands/archive/d.moderate new.ts | 2447 +++++++++++++++++ src/global/commands/g.last.ts | 10 +- src/global/commands/g.moderate.ts | 1088 -------- src/global/utils/env.config.ts | 1 + src/prisma/seed.ts | 46 +- .../migration.sql | 15 + src/prisma/tripbot/schema.prisma | 14 +- 32 files changed, 5836 insertions(+), 2664 deletions(-) rename src/discord/commands/{guild/u.info.ts => archive/u.underban.ts} (53%) create mode 100644 src/discord/commands/guild/d.cooperative.ts delete mode 100644 src/discord/commands/guild/m.note.ts delete mode 100644 src/discord/commands/guild/m.timeout.ts delete mode 100644 src/discord/commands/guild/m.warn.ts delete mode 100644 src/discord/commands/guild/u.ban.ts delete mode 100644 src/discord/commands/guild/u.kick.ts create mode 100644 src/discord/commands/guild/u.report.ts delete mode 100644 src/discord/commands/guild/u.underban.ts create mode 100644 src/global/commands/archive/d.moderate new.ts delete mode 100644 src/global/commands/g.moderate.ts create mode 100644 src/prisma/tripbot/migrations/20240111205448_better_moderation/migration.sql diff --git a/assets/data/combinedDB.json b/assets/data/combinedDB.json index bc6998ab8..220a2459e 100644 --- a/assets/data/combinedDB.json +++ b/assets/data/combinedDB.json @@ -32305,6 +32305,11 @@ "name": "ketamine", "note": "No unexpected interactions, though likely to increase blood pressure but not an issue with sensible doses. Moving around on high doses of this combination may be ill advised due to risk of physical injury." }, + { + "status": "Dangerous", + "name": "lithium", + "note": "High risk of serotonin syndrome" + }, { "status": "Low Risk & Synergy", "name": "lsd" @@ -58418,6 +58423,11 @@ "name": "lsd", "note": "There is a large number of reports indicating a high seizure and psychosis risk from this combination." }, + { + "status": "Dangerous", + "name": "mdma", + "note": "High risk of serotonin syndrome" + }, { "status": "Dangerous", "name": "mescaline", diff --git a/assets/data/tripsitCombos.json b/assets/data/tripsitCombos.json index f357daeaa..be42bcf9e 100644 --- a/assets/data/tripsitCombos.json +++ b/assets/data/tripsitCombos.json @@ -1533,6 +1533,16 @@ ], "status": "Dangerous" }, + "mdma": { + "note": "High risk of serotonin syndrome", + "sources": [ + "https://doi.org/10.1176/appi.neuropsych.11080196", + "https://www.biologicalpsychiatryjournal.com/article/S0006-3223(98)00161-9/pdf", + "https://www.nature.com/articles/1395366", + "https://www.cambridge.org/core/journals/advances-in-psychiatric-treatment/article/new-drugs-old-problems-revisiting-pharmacological-management-of-treatmentresistant-depression/D44091E25F9537B93CD6F4580F340645" + ], + "status": "Dangerous" + }, "mescaline": { "note": "There is a large number of reports indicating a high seizure and psychosis risk from this combination.", "sources": [ @@ -1822,6 +1832,16 @@ "note": "No unexpected interactions, though likely to increase blood pressure but not an issue with sensible doses. Moving around on high doses of this combination may be ill advised due to risk of physical injury.", "status": "Low Risk & Synergy" }, + "lithium": { + "note": "High risk of serotonin syndrome", + "sources": [ + "https://doi.org/10.1176/appi.neuropsych.11080196", + "https://www.biologicalpsychiatryjournal.com/article/S0006-3223(98)00161-9/pdf", + "https://www.nature.com/articles/1395366", + "https://www.cambridge.org/core/journals/advances-in-psychiatric-treatment/article/new-drugs-old-problems-revisiting-pharmacological-management-of-treatmentresistant-depression/D44091E25F9537B93CD6F4580F340645" + ], + "status": "Dangerous" + }, "lsd": { "status": "Low Risk & Synergy" }, diff --git a/assets/data/tripsitDB.json b/assets/data/tripsitDB.json index cd3db3a45..7cc9c1063 100644 --- a/assets/data/tripsitDB.json +++ b/assets/data/tripsitDB.json @@ -23095,6 +23095,16 @@ ], "status": "Dangerous" }, + "mdma": { + "note": "High risk of serotonin syndrome", + "sources": [ + "https://doi.org/10.1176/appi.neuropsych.11080196", + "https://www.biologicalpsychiatryjournal.com/article/S0006-3223(98)00161-9/pdf", + "https://www.nature.com/articles/1395366", + "https://www.cambridge.org/core/journals/advances-in-psychiatric-treatment/article/new-drugs-old-problems-revisiting-pharmacological-management-of-treatmentresistant-depression/D44091E25F9537B93CD6F4580F340645" + ], + "status": "Dangerous" + }, "mescaline": { "note": "There is a large number of reports indicating a high seizure and psychosis risk from this combination.", "sources": [ @@ -24196,6 +24206,16 @@ "note": "No unexpected interactions, though likely to increase blood pressure but not an issue with sensible doses. Moving around on high doses of this combination may be ill advised due to risk of physical injury.", "status": "Low Risk & Synergy" }, + "lithium": { + "note": "High risk of serotonin syndrome", + "sources": [ + "https://doi.org/10.1176/appi.neuropsych.11080196", + "https://www.biologicalpsychiatryjournal.com/article/S0006-3223(98)00161-9/pdf", + "https://www.nature.com/articles/1395366", + "https://www.cambridge.org/core/journals/advances-in-psychiatric-treatment/article/new-drugs-old-problems-revisiting-pharmacological-management-of-treatmentresistant-depression/D44091E25F9537B93CD6F4580F340645" + ], + "status": "Dangerous" + }, "lsd": { "status": "Low Risk & Synergy" }, diff --git a/package.json b/package.json index f89fb235c..8fccfbf3a 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "db:formatSchema": "docker exec -it tripbot npx prisma format", "db:validateSchema": "docker exec -it tripbot npx prisma validate", "db:generateClient": "npx prisma generate && docker exec -it tripbot npx prisma generate", - "db:pushDev": "docker exec -it tripbot npx prisma db push && npx prisma generate", - "db:migrateDev": "docker exec -it tripbot npx prisma migrate dev -n betterImageLogging", + "db:pushDev": "docker exec -it tripbot npx prisma db push && npm run tripbot:db:generate", + "db:migrateDev": "docker exec -it tripbot npx prisma migrate dev -n betterModeration", "db:seed": "docker exec -it tripbot npx prisma db seed", "## PGADMIN ##": "", "pgadmin": "docker compose --project-name tripbot up -d --force-recreate --build tripbot_pgadmin", diff --git a/src/discord/commands/guild/u.info.ts b/src/discord/commands/archive/u.underban.ts similarity index 53% rename from src/discord/commands/guild/u.info.ts rename to src/discord/commands/archive/u.underban.ts index f162a27b4..553865af7 100644 --- a/src/discord/commands/guild/u.info.ts +++ b/src/discord/commands/archive/u.underban.ts @@ -1,34 +1,40 @@ import { + ActionRowBuilder, + ModalBuilder, + TextInputBuilder, ContextMenuCommandBuilder, GuildMember, + ModalSubmitInteraction, } from 'discord.js'; import { ApplicationCommandType, + TextInputStyle, } from 'discord-api-types/v10'; import { UserCommand } from '../../@types/commandDef'; // import log from '../../../global/utils/log'; import { moderate } from '../../../global/commands/g.moderate'; import commandContext from '../../utils/context'; +// import {startLog} from '../../utils/startLog'; +import { UserActionType } from '../../../global/@types/database'; +import { embedTemplate } from '../../utils/embedTemplate'; +import { ban } from '../guild/d.moderate'; const F = f(__filename); -export const uInfo: UserCommand = { +export const uUnderban: UserCommand = { data: new ContextMenuCommandBuilder() - .setName('Info') + .setName('Underban') .setType(ApplicationCommandType.User), async execute(interaction) { log.info(F, await commandContext(interaction)); - await interaction.deferReply({ ephemeral: true }); - await interaction.editReply(await moderate( - interaction.member as GuildMember, - 'INFO', - (interaction.options.data[0].member as GuildMember).id, - null, - null, - null, - )); + await ban( + interaction, + (interaction.targetMember as GuildMember).id, + + ) + return true; }, }; -export default uInfo; +export default uUnderban; diff --git a/src/discord/commands/global/d.ai.ts b/src/discord/commands/global/d.ai.ts index fb33e48c4..08981fed7 100644 --- a/src/discord/commands/global/d.ai.ts +++ b/src/discord/commands/global/d.ai.ts @@ -1,11 +1,8 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { ActionRowBuilder, - ModalBuilder, - TextInputBuilder, Colors, SlashCommandBuilder, - ModalSubmitInteraction, ChatInputCommandInteraction, GuildMember, time, @@ -18,7 +15,6 @@ import { ButtonBuilder, ButtonStyle, ButtonInteraction, - APIEmbedField, EmbedBuilder, APIButtonComponent, APIActionRowComponent, @@ -28,7 +24,6 @@ import { import { APIInteractionDataResolvedChannel, ChannelType, - TextInputStyle, } from 'discord-api-types/v10'; import { stripIndents } from 'common-tags'; import { @@ -36,7 +31,6 @@ import { ai_model, ai_moderation, ai_personas, - user_action_type, } from '@prisma/client'; import OpenAI from 'openai'; import { Run } from 'openai/resources/beta/threads/runs/runs'; @@ -44,12 +38,11 @@ import { MessageContentText } from 'openai/resources/beta/threads/messages/messa import { SlashCommand } from '../../@types/commandDef'; import { embedTemplate } from '../../utils/embedTemplate'; import commandContext from '../../utils/context'; -import { moderate } from '../../../global/commands/g.moderate'; import { sleep } from '../guild/d.bottest'; import aiChat, { aiModerate, createMessage, getAssistant, getMessages, getThread, readRun, runThread, } from '../../../global/commands/g.ai'; -import { parseDuration } from '../../../global/utils/parseDuration'; +import { modModal } from '../guild/d.moderate'; const F = f(__filename); @@ -483,454 +476,6 @@ async function adjustThreshold( }); } -async function noteUser( - interaction: ButtonInteraction, -): Promise { - log.debug(F, 'noteUser started'); - const buttonID = interaction.customId; - log.debug(F, `buttonID: ${buttonID}`); - - if (!(interaction.member as GuildMember).roles.cache.has(env.ROLE_MODERATOR)) return; - - const embed = interaction.message.embeds[0].toJSON(); - - const flagsField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Flags') as APIEmbedField; - - await interaction.showModal(new ModalBuilder() - .setCustomId(`noteModal~${interaction.id}`) - .setTitle('Tripbot Note') - .addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('What are you noting about this person?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Tell the team why you are noting this user.') - .setValue(`This user's message was flagged by the AI for ${flagsField.value}`) - .setMaxLength(1000) - .setRequired(true) - .setCustomId('internalNote')))); - const filter = (i: ModalSubmitInteraction) => i.customId.includes('noteModal'); - - interaction.awaitModalSubmit({ filter, time: 0 }) - .then(async i => { - if (i.customId.split('~')[1] !== interaction.id) return; - await i.deferReply({ ephemeral: true }); - - const messageField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Message') as APIEmbedField; - const memberField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Member') as APIEmbedField; - const urlField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Channel') as APIEmbedField; - - await moderate( - interaction.member as GuildMember, - 'NOTE' as user_action_type, - memberField.value.slice(2, -1), - stripIndents` - ${i.fields.getTextInputValue('internalNote')} - - **The offending message** - > ${messageField.value} - ${urlField.value} - `, - null, - null, - ); - - const buttonRows = interaction.message.components.map(row => ActionRowBuilder.from(row.toJSON())); - - const actionField = embed.fields?.find(field => field.name === 'Actions'); - - if (actionField) { - // Add the action to the list of actions - const newActionFiled = actionField?.value.concat(` - - ${interaction.user.toString()} noted this user: - > ${i.fields.getTextInputValue('internalNote')} - - Message sent to user: - > **No message sent to user on notes** - `); - // log.debug(F, `newActionFiled: ${newActionFiled}`); - - // Replace the action field with the new one - embed.fields?.splice(embed.fields?.findIndex(field => field.name === 'Actions'), 1, { - name: 'Actions', - value: newActionFiled, - inline: true, - }); - } else { - embed.fields?.push( - { - name: 'Actions', - value: stripIndents`${interaction.user.toString()} noted this user: - > ${i.fields.getTextInputValue('internalNote')} - - Message sent to user: - > ${i.fields.getTextInputValue('description')}`, - inline: true, - }, - ); - } - embed.color = Colors.Green; - - await i.editReply('User was noted'); - - await interaction.message.edit({ - embeds: [embed], - components: buttonRows as ActionRowBuilder[], - }); - }); -} - -async function muteUser( - interaction: ButtonInteraction, -): Promise { - log.debug(F, 'muteUser started'); - const buttonID = interaction.customId; - log.debug(F, `buttonID: ${buttonID}`); - if (!(interaction.member as GuildMember).roles.cache.has(env.ROLE_MODERATOR)) return; - - const embed = interaction.message.embeds[0].toJSON(); - - const flagsField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Flags') as APIEmbedField; - const messageField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Message') as APIEmbedField; - const memberField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Member') as APIEmbedField; - const urlField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Channel') as APIEmbedField; - - await interaction.showModal(new ModalBuilder() - .setCustomId(`timeoutModal~${interaction.id}`) - .setTitle('Tripbot Timeout') - .addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('Why are you muting this person?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Tell the team why you are muting this user.') - .setValue(`This user breaks TripSit's policies regarding ${flagsField.value} topics.`) - .setMaxLength(1000) - .setRequired(true) - .setCustomId('internalNote')), - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('What should we tell the user?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('This will be sent to the user!') - .setValue(stripIndents` - Your recent messages have broken TripSit's policies regarding ${flagsField.value} topics. - - The offending message - > ${messageField.value} - ${urlField.value}`) - .setMaxLength(1000) - .setRequired(false) - .setCustomId('description')), - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('Timeout for how long? (Max/default 7 days)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('4 days 3hrs 2 mins 30 seconds') - .setRequired(false) - .setCustomId('timeoutDuration')), - )); - const filter = (i: ModalSubmitInteraction) => i.customId.includes('timeoutModal'); - - interaction.awaitModalSubmit({ filter, time: 0 }) - .then(async i => { - if (i.customId.split('~')[1] !== interaction.id) return; - await i.deferReply({ ephemeral: true }); - - const duration = i.fields.getTextInputValue('timeoutDuration') - ? await parseDuration(i.fields.getTextInputValue('timeoutDuration')) - : 604800000; - - if (duration > 604800000) { - await i.editReply('Cannot remove messages older than 7 days.'); - return; - } - - await moderate( - interaction.member as GuildMember, - 'TIMEOUT' as user_action_type, - memberField.value.slice(2, -1), - stripIndents` - ${i.fields.getTextInputValue('internalNote')} - - **The offending message** - > ${messageField.value} - ${urlField.value} - `, - i.fields.getTextInputValue('description'), - duration, - ); - - const buttonRows = interaction.message.components.map(row => ActionRowBuilder.from(row.toJSON())); - - const actionField = embed.fields?.find(field => field.name === 'Actions'); - - if (actionField) { - // Add the action to the list of actions - const newActionFiled = actionField?.value.concat(` - - ${interaction.user.toString()} muted this user: - > ${i.fields.getTextInputValue('internalNote')} - - Message sent to user: - > ${i.fields.getTextInputValue('description')}`); - // log.debug(F, `newActionFiled: ${newActionFiled}`); - - // Replace the action field with the new one - embed.fields?.splice(embed.fields?.findIndex(field => field.name === 'Actions'), 1, { - name: 'Actions', - value: newActionFiled, - inline: true, - }); - } else { - embed.fields?.push( - { - name: 'Actions', - value: stripIndents`${interaction.user.toString()} muted this user: - > ${i.fields.getTextInputValue('internalNote')} - - Message sent to user: - > ${i.fields.getTextInputValue('description')}`, - inline: true, - }, - ); - } - embed.color = Colors.Green; - - await i.editReply('User was muted'); - - await interaction.message.edit({ - embeds: [embed], - components: buttonRows as ActionRowBuilder[], - }); - }); -} - -async function warnUser( - interaction: ButtonInteraction, -): Promise { - log.debug(F, 'warnUser started'); - const buttonID = interaction.customId; - log.debug(F, `buttonID: ${buttonID}`); - if (!(interaction.member as GuildMember).roles.cache.has(env.ROLE_MODERATOR)) return; - - const embed = interaction.message.embeds[0].toJSON(); - - const flagsField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Flags') as APIEmbedField; - const messageField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Message') as APIEmbedField; - const memberField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Member') as APIEmbedField; - const urlField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Channel') as APIEmbedField; - - await interaction.showModal(new ModalBuilder() - .setCustomId(`warnModal~${interaction.id}`) - .setTitle('Tripbot Warn') - .addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('Why are you warning this person?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Tell the team why you are warning this user.') - .setValue(`This user breaks TripSit's policies regarding ${flagsField.value} topics.`) - .setMaxLength(1000) - .setRequired(true) - .setCustomId('internalNote')), - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('What should we tell the user?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('This will be sent to the user!') - .setValue(stripIndents`Your recent messages have broken TripSit's policies regarding ${flagsField.value} topics. - - The offending message - > ${messageField.value} - ${urlField.value}`) - .setMaxLength(1000) - .setRequired(true) - .setCustomId('description')), - )); - const filter = (i: ModalSubmitInteraction) => i.customId.includes('warnModal'); - - interaction.awaitModalSubmit({ filter, time: 0 }) - .then(async i => { - if (i.customId.split('~')[1] !== interaction.id) return; - await i.deferReply({ ephemeral: true }); - - await moderate( - interaction.member as GuildMember, - 'WARNING' as user_action_type, - memberField.value.slice(2, -1), - stripIndents` - ${i.fields.getTextInputValue('internalNote')} - - **The offending message** - > ${messageField.value} - ${urlField.value} - `, - i.fields.getTextInputValue('description'), - null, - ); - - const buttonRows = interaction.message.components.map(row => ActionRowBuilder.from(row.toJSON())); - - const actionField = embed.fields?.find(field => field.name === 'Actions'); - - if (actionField) { - // Add the action to the list of actions - const newActionFiled = actionField?.value.concat(` - - ${interaction.user.toString()} warned this user: - > ${i.fields.getTextInputValue('internalNote')} - - Message sent to user: - > ${i.fields.getTextInputValue('description')}`); - // log.debug(F, `newActionFiled: ${newActionFiled}`); - - // Replace the action field with the new one - embed.fields?.splice(embed.fields?.findIndex(field => field.name === 'Actions'), 1, { - name: 'Actions', - value: newActionFiled, - inline: true, - }); - } else { - embed.fields?.push( - { - name: 'Actions', - value: stripIndents`${interaction.user.toString()} warned this user: - > ${i.fields.getTextInputValue('internalNote')} - - Message sent to user: - > ${i.fields.getTextInputValue('description')}`, - inline: true, - }, - ); - } - embed.color = Colors.Green; - - await i.editReply('User was warned'); - - await interaction.message.edit({ - embeds: [embed], - components: buttonRows as ActionRowBuilder[], - }); - }); -} - -async function banUser( - interaction: ButtonInteraction, -): Promise { - log.debug(F, 'banUser started'); - const buttonID = interaction.customId; - log.debug(F, `buttonID: ${buttonID}`); - if (!(interaction.member as GuildMember).roles.cache.has(env.ROLE_MODERATOR)) return; - - const embed = interaction.message.embeds[0].toJSON(); - - const flagsField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Flags') as APIEmbedField; - const messageField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Message') as APIEmbedField; - const memberField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Member') as APIEmbedField; - const urlField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Channel') as APIEmbedField; - - await interaction.showModal(new ModalBuilder() - .setCustomId(`banModal~${interaction.id}`) - .setTitle('Tripbot Ban') - .addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('Why are you banning this user?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Tell the team why you are banning this user.') - .setValue(`This user breaks TripSit's policies regarding ${flagsField.value} topics.`) - .setMaxLength(1000) - .setRequired(true) - .setCustomId('internalNote')), - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('What should we tell the user?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('This will be sent to the user!') - .setValue(stripIndents`Your recent messages have broken TripSit's policies regarding ${flagsField.value} topics. - - The offending message - > ${messageField.value} - ${urlField.value}`) - .setMaxLength(1000) - .setRequired(false) - .setCustomId('description')), - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('How many days of msg to remove?') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Between 0 and 7 days (Default 0)') - .setRequired(false) - .setCustomId('duration')), - )); - const filter = (i: ModalSubmitInteraction) => i.customId.includes('banModal'); - - interaction.awaitModalSubmit({ filter, time: 0 }) - .then(async i => { - if (i.customId.split('~')[1] !== interaction.id) return; - await i.deferReply({ ephemeral: true }); - - const duration = i.fields.getTextInputValue('duration') - ? await parseDuration(i.fields.getTextInputValue('duration')) - : 0; - - if (duration > 604800000) { - await i.editReply('Cannot remove messages older than 7 days.'); - return; - } - - await moderate( - interaction.member as GuildMember, - 'FULL_BAN' as user_action_type, - memberField.value.slice(2, -1), - stripIndents` - ${i.fields.getTextInputValue('internalNote')} - - **The offending message** - > ${messageField.value} - ${urlField.value} - `, - i.fields.getTextInputValue('description'), - duration, - ); - - const buttonRows = interaction.message.components.map(row => ActionRowBuilder.from(row.toJSON())); - - const actionField = embed.fields?.find(field => field.name === 'Actions'); - - if (actionField) { - // Add the action to the list of actions - const newActionFiled = actionField?.value.concat(` - - ${interaction.user.toString()} banned this user: - > ${i.fields.getTextInputValue('internalNote')} - - Message sent to user: - > ${i.fields.getTextInputValue('description')}`); - // log.debug(F, `newActionFiled: ${newActionFiled}`); - - // Replace the action field with the new one - embed.fields?.splice(embed.fields?.findIndex(field => field.name === 'Actions'), 1, { - name: 'Actions', - value: newActionFiled, - inline: true, - }); - } else { - embed.fields?.push( - { - name: 'Actions', - value: stripIndents`${interaction.user.toString()} noted this user: - > ${i.fields.getTextInputValue('internalNote')} - - Message sent to user: - > ${i.fields.getTextInputValue('description')}`, - inline: true, - }, - ); - } - embed.color = Colors.Green; - - await i.editReply('User was banned'); - - await interaction.message.edit({ - embeds: [embed], - components: buttonRows as ActionRowBuilder[], - }); - }); -} - async function aiAudit( aiPersona: ai_personas, messages: Message[], @@ -1747,16 +1292,16 @@ export async function aiModButton( await saveThreshold(interaction); break; case 'note': - await noteUser(interaction); + await modModal(interaction); break; case 'warn': - await warnUser(interaction); + await modModal(interaction); break; case 'timeout': - await muteUser(interaction); + await modModal(interaction); break; case 'ban': - await banUser(interaction); + await modModal(interaction); break; default: break; diff --git a/src/discord/commands/guild/d.cooperative.ts b/src/discord/commands/guild/d.cooperative.ts new file mode 100644 index 000000000..4e1f29623 --- /dev/null +++ b/src/discord/commands/guild/d.cooperative.ts @@ -0,0 +1,715 @@ +import { + ActionRowBuilder, + ModalBuilder, + TextInputBuilder, + SlashCommandBuilder, + ModalSubmitInteraction, + InteractionEditReplyOptions, + ButtonBuilder, + ChatInputCommandInteraction, + ButtonInteraction, + Guild, + EmbedBuilder, + TextChannel, + Role, + PermissionResolvable, + ChannelType, +} from 'discord.js'; +import { + ButtonStyle, + TextInputStyle, +} from 'discord-api-types/v10'; +import { stripIndent, stripIndents } from 'common-tags'; +import { SlashCommand } from '../../@types/commandDef'; +import { embedTemplate } from '../../utils/embedTemplate'; +import { checkGuildPermissions } from '../../utils/checkPermissions'; +import commandContext from '../../utils/context'; + +const F = f(__filename); + +const guildOnlyError = 'This command can only be used in a guild!'; + +async function info(): Promise { + return { + embeds: [ + embedTemplate({ + title: 'TripSit Discord Cooperative Info', + description: stripIndent` + This command will set up your guild when you first join the cooperative. + It will perform the following tasks: + * Create a channel called '#moderators' + - This channel will be used for cooperative moderation. + - Ban messages will be sent here when you ban someone for the rest of the cooperative to see + - You can reach out to other guilds through this channel to clarify bans. + * Create a channel called '#modlog' + - This will be used to track moderation actions *by your own team* and to keep them accountable. + - Only ban alerts and messages are sent to #coop-mod for other guilds to see. + * Create a channel called '#helpdesk' + - This is a moderation ticketing system + + + **** TBD **** + * Create a channel called '#coop-gen' + - This channel will be used for general cooperative chat. + - Talk about moderation policies or whatever with other moderators. + * Create a channel called '#coop-announce' + - Announcements impacting the entire cooperative will be posted here + * Create a channel called '#coop-offtopic' + - This channel will be used for general cooperative chat, get to know others! + + Once setup is complete you can modify the category and channels as you wish. + You can move the channels outside the category if you wish, just make sure TripBot keeps the same permissions. + + If you have any questions, feel free to reach out to the TripSit team! + + Here is a list of all the regulations for the TripSit Discord Cooperative:`, + fields: [ + { + name: '1. Be kind and respectful to others.', + value: stripIndents`This is the most important rule. We are all here to help each other and have a good time. + If someone from a member organization is not kind and respectful to others, their entire guild may removed from the cooperative. + Harassment of any kind will not be tolerated, please don't try to find the line. If you are unsure if something is harassment, it probably is.`, + inline: false, + }, + { + name: '2. Promote harm reduction', + value: stripIndents`Ever guild in the cooperative is expected to promote harm reduction in their own way. + This can be done through education, moderation, or any other means. + Guilds that glorify or encourage drug use will be removed from the cooperative.`, + inline: false, + }, + { + name: '3. Keep your ban descriptions accurate and descriptive when possible.', + value: stripIndents`Every guild is free to set their rules and choose who to ban. + You can ban anyone for any reason and say as little or as much as you want in the ban reason. + However be prepared to explain your ban reason if asked if they are vague.`, + inline: false, + }, + ], + }), + ], + }; +} + +async function apply(interaction:ChatInputCommandInteraction): Promise { + if (!interaction.guild) { + return { + embeds: [ + embedTemplate({ + title: guildOnlyError, + }), + ], + }; + } + + const guildData = await db.discord_guilds.upsert({ + where: { + id: interaction.guild?.id, + }, + create: { + id: interaction.guild?.id, + }, + update: {}, + }); + + if (guildData.cooperative) { + return { + embeds: [ + embedTemplate({ + title: 'You are already part of the cooperative!', + }), + ], + }; + } + return { + embeds: [ + embedTemplate({ + title: 'Join the TripSit Discord Cooperative', + description: stripIndents` + Thanks for your interest! At this time (April 3rd) this is a brand-new system \ + so there is no application process.... yet! + However, if you are interested in joining the cooperative, please fill out the form below and we will keep you in mind \ + and perhaps reach out in the future!`, + }), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId('cooperativeApply') + .setLabel('Apply') + .setStyle(ButtonStyle.Primary), + ), + ], + }; +} + +export async function cooperativeApplyButton( + interaction:ButtonInteraction, +) { + await interaction.showModal(new ModalBuilder() + .setTitle('Apply to Join the TripSit Discord Cooperative') + .setCustomId(`cooperativeApply~${interaction.id}`) + .addComponents( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setLabel('Does your guild have a website?') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setCustomId('desire'), + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setLabel('Why do you want to join the cooperative?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Please be descriptive!') + .setRequired(true) + .setCustomId('desire'), + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setLabel('Enter your guild invite link') + .setStyle(TextInputStyle.Short) + .setPlaceholder('People from the cooperative may join and check out your guild!') + .setRequired(true) + .setCustomId('link'), + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setLabel('Have you read the regulations and agree to abide them?') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setCustomId('agree'), + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setLabel('Any other info you want to share?') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setCustomId('info'), + ), + )); + const filter = (i:ModalSubmitInteraction) => i.customId.includes('reportModal'); + interaction.awaitModalSubmit({ filter, time: 0 }) + .then(async i => { + if (i.customId.split('~')[1] !== interaction.id) return; + await i.deferReply({ ephemeral: true }); + + await i.editReply({ + embeds: [ + embedTemplate({ + title: 'Thanks for your interest!', + description: 'We will keep you in mind and perhaps reach out in the future!', + }), + ], + }); + }); + return true; +} + +async function setup(interaction:ChatInputCommandInteraction):Promise { + if (!interaction.guild) { + return { + embeds: [ + embedTemplate({ + title: guildOnlyError, + }), + ], + }; + } + + const guildData = await db.discord_guilds.upsert({ + where: { + id: interaction.guild?.id, + }, + create: { + id: interaction.guild?.id, + }, + update: {}, + }); + + if (!guildData.cooperative) { + return { + embeds: [ + embedTemplate({ + title: 'You are not part of the cooperative!', + }), + ], + }; + } + + if (!interaction.guild) { + return { + embeds: [ + embedTemplate({ + title: guildOnlyError, + }), + ], + }; + } + + const perms = await checkGuildPermissions(interaction.guild, [ + 'ViewAuditLog' as PermissionResolvable, + ]); + + if (!perms.hasPermission) { + log.error(F, `Missing permission ${perms.permission} in ${interaction.guild}!`); + return { content: `Please make sure I can ${perms.permission} in ${interaction.guild} so I can run ${F}!` }; + } + + // Finished checks, lets set this up! + + // Get the IDs of the channels + + let helpdeskRoom = interaction.options.getChannel('helpdesk_channel'); + if (!helpdeskRoom) { + // If the channel wasn't provided, create it: + helpdeskRoom = await interaction.guild.channels.create({ + name: 'πŸ™Šβ”‚talk-to-mods', + type: ChannelType.GuildText, + topic: 'This channel is used to make tickets.', + permissionOverwrites: [ + { + id: interaction.guild.roles.everyone.id, + deny: ['ViewChannel'], + }, + ], + }); + } + + const trustScoreLimit = interaction.options.getInteger('trust_score_limit', true); + await db.discord_guilds.update({ + where: { id: interaction.guild.id }, + data: { trust_score_limit: trustScoreLimit }, + }); + + let modRoom = interaction.options.getChannel('mod_channel'); + if (!modRoom) { + // If the channel wasn't provided, create it: + modRoom = await interaction.guild.channels.create({ + name: 'coop-mod', + type: ChannelType.GuildText, + topic: 'This channel is used for cooperative moderation.', + permissionOverwrites: [ + { + id: interaction.guild.roles.everyone.id, + deny: ['ViewChannel'], + }, + ], + }); + } + + await db.discord_guilds.update({ + where: { + id: interaction.guild.id, + }, + data: { + channel_moderators: modRoom.id, + }, + }); + + let modLog = interaction.options.getChannel('modlog_channel'); + if (!modLog) { + // If the channel wasn't provided, create it: + modLog = await interaction.guild.channels.create({ + name: 'modlog', + type: ChannelType.GuildText, + topic: 'This channel is used for moderation logs.', + permissionOverwrites: [ + { + id: interaction.guild.roles.everyone.id, + deny: ['ViewChannel'], + }, + ], + }); + } + + await db.discord_guilds.update({ + where: { + id: interaction.guild.id, + }, + data: { + channel_mod_log: modLog.id, + }, + }); + // const helpdesk = interaction.options.getChannel('helpdesk_channel', true); + // const coopGen = interaction.options.getChannel('coop_gen_channel', true); + // const coopAnnounce = interaction.options.getChannel('coop_announce_channel', true); + // const coopOfftopic = interaction.options.getChannel('coop_offtopic_channel', true); + + let modRole = interaction.options.getRole('mod_role'); + if (!modRole) { + // If the role wasn't provided, create it: + modRole = await interaction.guild.roles.create({ + name: 'Cooperative Moderator', + color: '#00ff00', + mentionable: true, + }); + } + + await db.discord_guilds.update({ + where: { + id: interaction.guild.id, + }, + data: { + role_moderator: modRole.id, + }, + }); + + log.debug(F, `modRoomId: ${modRoom.name}`); + log.debug(F, `modLogId: ${modLog.name}`); + + async function getRoleId( + role:Role | undefined, + ):Promise { + // This will create the role if it doesn't exist + // Either way it will update the database with the role ID + if (!interaction.guild) return ''; + if (!role) { + // If the role wasn't provided, create it: + const newRole = await interaction.guild.roles.create({ + name: 'Cooperative Moderator', + color: '#00ff00', + mentionable: true, + }); + await db.discord_guilds.update({ + where: { + id: interaction.guild.id, + }, + data: { + role_moderator: newRole.id, + }, + }); + return newRole.id; + } + await db.discord_guilds.update({ + where: { + id: interaction.guild.id, + }, + data: { + role_moderator: role.id, + }, + }); + return role.id; + } + + const modRoleId = await getRoleId(modRole as Role | undefined); + + log.debug(F, `modRoleId: ${modRoleId}`); + + return { + embeds: [ + embedTemplate({ + title: 'Cooperative setup complete!', + description: stripIndents` + I will make new threads in `, + }), + ], + }; +} + +async function leave(interaction:ChatInputCommandInteraction): Promise { + if (!interaction.guild) { + return { + embeds: [ + embedTemplate({ + title: guildOnlyError, + }), + ], + }; + } + + const guildData = await db.discord_guilds.upsert({ + where: { + id: interaction.guild?.id, + }, + create: { + id: interaction.guild?.id, + }, + update: {}, + }); + + if (!guildData.cooperative) { + return { + embeds: [ + embedTemplate({ + title: 'You are not part of the cooperative!', + }), + ], + }; + } + + return { + embeds: [ + embedTemplate({ + title: 'Leave the TripSit Discord Cooperative', + description: stripIndents` + Are you sure you want to leave the cooperative? + This will remove your guild from the cooperative and remove your guild from the list of cooperative members. + You will no longer be able to use the cooperative commands. + If you change your mind, you can rejoin the cooperative at any time.`, + }), + ], + components: [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId('cooperativeLeave') + .setLabel('Leave') + .setStyle(ButtonStyle.Danger), + ), + ], + }; +} + +export async function cooperativeLeaveButton( + interaction:ButtonInteraction, +) { + await interaction.deferReply({ ephemeral: true }); + const guild = interaction.guild as Guild; + await db.discord_guilds.upsert({ + where: { + id: guild.id, + }, + create: { + id: guild.id, + cooperative: false, + }, + update: { + cooperative: false, + }, + }); + await interaction.editReply({ + embeds: [ + embedTemplate({ + title: 'You have left the cooperative!', + }), + ], + }); +} + +async function add( + interaction:ChatInputCommandInteraction, +):Promise { + if (interaction.user.id !== env.DISCORD_OWNER_ID) { + return { + embeds: [ + embedTemplate({ + title: 'This action is restricted!', + }), + ], + }; + } + + const guild = await discordClient.guilds.fetch(interaction.options.getString('guild_id', true)); + await db.discord_guilds.upsert({ + where: { + id: guild.id, + }, + create: { + id: guild.id, + cooperative: true, + }, + update: { + cooperative: true, + }, + }); + + return { + embeds: [ + embedTemplate({ + title: `I added ${guild.name} to the cooperation`, + }), + ], + }; +} + +async function remove( + interaction:ChatInputCommandInteraction, +):Promise { + if (interaction.user.id !== env.DISCORD_OWNER_ID) { + return { + embeds: [ + embedTemplate({ + title: 'This action is restricted!', + }), + ], + }; + } + + // Sets the guild cooperative status to false + const guild = await discordClient.guilds.fetch(interaction.options.getString('guild_id', true)); + await db.discord_guilds.upsert({ + where: { + id: guild.id, + }, + create: { + id: guild.id, + cooperative: false, + }, + update: { + cooperative: false, + }, + }); + return { + embeds: [ + embedTemplate({ + title: `I removed ${guild.name} from the cooperation`, + }), + ], + }; +} + +export async function sendCooperativeMessage( + embed: EmbedBuilder, + pingGuilds: string[], +) { + await Promise.all(pingGuilds.map(async guildId => { + const guildData = await db.discord_guilds.upsert({ + where: { + id: guildId, + }, + create: { + id: guildId, + cooperative: false, + }, + update: { + cooperative: false, + }, + }); + const guild = await discordClient.guilds.fetch(guildId); + if (guildData.channel_moderators && guildData.role_moderator) { + let channelCoopMod = {} as TextChannel; + try { + channelCoopMod = await discordClient.channels.fetch(guildData.channel_moderators) as TextChannel; + } catch (e) { + guildData.channel_moderators = null; + await db.discord_guilds.update({ + where: { + id: guildId, + }, + data: guildData, + }); + } + + let roleMod = {} as Role; + try { + roleMod = await guild.roles.fetch(guildData.role_moderator) as Role; + } catch (e) { + guildData.role_moderator = null; + await db.discord_guilds.update({ + where: { + id: guildId, + }, + data: guildData, + }); + } + + await channelCoopMod.send({ + content: `Hey ${roleMod}!`, + embeds: [embed], + }); + } + })); +} + +export const dCooperative: SlashCommand = { + data: new SlashCommandBuilder() + .setName('cooperative') + .setDescription('TripSit Discord Cooperative Commands') + .addSubcommand(subcommand => subcommand + .setDescription('Help for the TripSit Discord Cooperative Commands') + .setName('info')) + .addSubcommand(subcommand => subcommand + .setDescription('Apply to join the TripSit Discord Cooperative') + .setName('apply')) + .addSubcommand(subcommand => subcommand + .setDescription('Setup the TripSit Discord Cooperative on your guild') + .addChannelOption(option => option + .setRequired(true) + .setDescription('The channel to use for moderation') + .setName('mod_channel')) + .addChannelOption(option => option + .setRequired(true) + .setDescription('The channel to use for moderation logs') + .setName('modlog_channel')) + .addRoleOption(option => option + .setRequired(true) + .setDescription('The role to use for moderators') + .setName('mod_role')) + .addChannelOption(option => option + .setRequired(true) + .setDescription('The channel to use for moderation tickets') + .setName('helpdesk_channel')) + .addIntegerOption(option => option + .setRequired(true) + .setDescription('Below this number sends alerts') + .setName('trust_score_limit')) + .setName('setup')) + .addSubcommand(subcommand => subcommand + .setDescription('Leave the TripSit Discord Cooperative') + .setName('leave')) + .addSubcommand(subcommand => subcommand + .setDescription('Add a guild to the TripSit Discord Cooperative') + .addStringOption(option => option + .setName('guild_id') + .setDescription('The ID of the guild to add') + .setRequired(true)) + .setName('add')) + .addSubcommand(subcommand => subcommand + .setDescription('Remove a guild from the TripSit Discord Cooperative') + .setName('remove') + .addStringOption(option => option + .setName('guild_id') + .setDescription('The ID of the guild to remove') + .setRequired(true))), + async execute(interaction) { + log.info(F, await commandContext(interaction)); + await interaction.deferReply({ ephemeral: true }); + if (!interaction.guild) { + await interaction.editReply({ + embeds: [ + embedTemplate({ + title: guildOnlyError, + }), + ], + }); + return false; + } + + let response = {} as InteractionEditReplyOptions; + const command = interaction.options.getSubcommand(); + switch (command) { + case 'info': + response = await info(); + break; + case 'apply': + response = await apply(interaction); + break; + case 'setup': + response = await setup(interaction); + break; + case 'leave': + response = await leave(interaction); + break; + case 'add': + response = await add(interaction); + break; + case 'remove': + response = await remove(interaction); + break; + default: + break; + } + + await interaction.editReply(response); + return true; + }, +}; + +export default dCooperative; diff --git a/src/discord/commands/guild/d.last.ts b/src/discord/commands/guild/d.last.ts index 765ab4485..70e2859e9 100644 --- a/src/discord/commands/guild/d.last.ts +++ b/src/discord/commands/guild/d.last.ts @@ -38,7 +38,7 @@ export const dLast: SlashCommand = { const roleModerator = await interaction.guild.roles.fetch(env.ROLE_MODERATOR) as Role; const actorIsMod = actor.roles.cache.has(roleModerator.id); - const response = await last(target); + const response = await last(target.user, interaction.guild); await interaction.editReply({ content: `${response.lastMessage}` }); diff --git a/src/discord/commands/guild/d.moderate.ts b/src/discord/commands/guild/d.moderate.ts index 61c1d5638..3026c08ba 100644 --- a/src/discord/commands/guild/d.moderate.ts +++ b/src/discord/commands/guild/d.moderate.ts @@ -1,7 +1,8 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +/* eslint-disable max-len */ import { SlashCommandBuilder, ChatInputCommandInteraction, - // Colors, GuildMember, ModalBuilder, TextInputBuilder, @@ -9,181 +10,2067 @@ import { ModalSubmitInteraction, Colors, User, + time, + ButtonBuilder, + TextChannel, + Role, + InteractionReplyOptions, + EmbedBuilder, + ThreadChannel, + MessageComponentInteraction, + PermissionResolvable, + DiscordAPIError, + GuildBan, + Guild, + Message, + ButtonInteraction, + UserContextMenuCommandInteraction, + MessageContextMenuCommandInteraction, + APIEmbedField, + Snowflake, + BaseMessageOptions, + APIActionRowComponent, + APIButtonComponentWithCustomId, + DiscordErrorData, } from 'discord.js'; import { TextInputStyle, + ButtonStyle, } from 'discord-api-types/v10'; import { stripIndents } from 'common-tags'; -import { user_action_type } from '@prisma/client'; +import { user_action_type, user_actions, users } from '@prisma/client'; import { SlashCommand } from '../../@types/commandDef'; -// import {embedTemplate} from '../../utils/embedTemplate'; import { parseDuration } from '../../../global/utils/parseDuration'; -import { moderate, linkThread } from '../../../global/commands/g.moderate'; import commandContext from '../../utils/context'; // eslint-disable-line -import { getDiscordMember, getDiscordUser } from '../../utils/guildMemberLookup'; +import { getDiscordMember } from '../../utils/guildMemberLookup'; import { embedTemplate } from '../../utils/embedTemplate'; +// import { last } from '../../../global/commands/g.last'; +import { checkGuildPermissions } from '../../utils/checkPermissions'; +import { last } from '../../../global/commands/g.last'; + +/* TODO: +add dates to bans +- Need to start recording when bans happened + +link accounts to transfer warnings and experience +- eventually +*/ + const F = f(__filename); +type UndoAction = 'UN-FULL_BAN' | 'UN-TICKET_BAN' | 'UN-DISCORD_BOT_BAN' | 'UN-BAN_EVASION' | 'UN-UNDERBAN' | 'UN-TIMEOUT' | 'UN-HELPER_BAN' | 'UN-CONTRIBUTOR_BAN'; + +type ModAction = user_action_type | UndoAction | 'INFO' | 'LINK'; +// type BanAction = 'FULL_BAN' | 'TICKET_BAN' | 'DISCORD_BOT_BAN' | 'BAN_EVASION' | 'UNDERBAN'; + +const disableButtonTime = env.NODE_ENV !== 'production' ? 1000 * 60 * 1 : 1000 * 60 * 5; // 1 minute in dev, 5 minute in prod + +const noReason = 'No reason provided'; +// const internalNotePlaceholder = 'Tell other moderators why you\'re doing this'; +// const descriptionLabel = 'What should we tell the user?'; +// const descriptionPlaceholder = 'Tell the user why you\'re doing this'; +const mepWarning = 'You cannot use the word "MEP" here.'; +const noMessageSent = '*No message sent to user*'; +const cooperativeExplanation = stripIndents`This is a suite of moderation tools for guilds to use, \ +this includes the ability to ban, warn, report, and more! + +Currently these tools are only available to a limited number of partner guilds, \ +use /cooperative info for more information.`; +// const noUserError = 'Could not find that member/user!'; +const beMoreSpecific = stripIndents` +Be more specific: +> **Mention:** <@${env.DISCORD_CLIENT_ID}> +> **ID:** ${env.DISCORD_CLIENT_ID} +> **Username:** moonbear +> **Nickname:** Moony`; + +const embedVariables = { + NOTE: { + embedColor: Colors.Yellow, + embedTitle: 'Note!', + pastVerb: 'noted', + presentVerb: 'noting', + emoji: 'πŸ“ƒ', + }, + WARNING: { + embedColor: Colors.Yellow, + embedTitle: 'Warned!', + pastVerb: 'warned', + presentVerb: 'warning', + emoji: 'πŸ™…', + }, + FULL_BAN: { + embedColor: Colors.Red, + embedTitle: 'Banned!', + pastVerb: 'banned', + presentVerb: 'banning', + emoji: 'πŸ”¨', + }, + 'UN-FULL_BAN': { + embedColor: Colors.Green, + embedTitle: 'Un-banned!', + pastVerb: 'un-banned', + presentVerb: 'un-banning', + emoji: 'πŸ”¨', + }, + TICKET_BAN: { + embedColor: Colors.Red, + embedTitle: 'Ticket Banned!', + pastVerb: 'banned from using tickets', + presentVerb: 'banning from using tickets', + emoji: '🎫', + }, + 'UN-TICKET_BAN': { + embedColor: Colors.Green, + embedTitle: 'Un-Ticket Banned!', + pastVerb: 'allowed to submit tickets again', + presentVerb: 'allowing to submit tickets again', + emoji: '🎫', + }, + DISCORD_BOT_BAN: { + embedColor: Colors.Red, + embedTitle: 'Discord Bot Banned!', + pastVerb: 'banned from using the Discord bot', + presentVerb: 'banning from using the Discord bot', + emoji: 'πŸ€–', + }, + 'UN-DISCORD_BOT_BAN': { + embedColor: Colors.Green, + embedTitle: 'Un-Discord Bot Banned!', + pastVerb: 'allowed to use the Discord bot again', + presentVerb: 'allowing to use the Discord bot again', + emoji: 'πŸ€–', + }, + HELPER_BAN: { + embedColor: Colors.Red, + embedTitle: 'Helper Role Banned!', + pastVerb: 'banned from using the Helper role', + presentVerb: 'banning from using the Helper role', + emoji: 'πŸ•β€πŸ¦Ί', + }, + 'UN-HELPER_BAN': { + embedColor: Colors.Green, + embedTitle: 'Un-Helper Role Banned!', + pastVerb: 'allowed to use the Helper role again', + presentVerb: 'allowing to use the Helper role again', + emoji: 'πŸ•β€πŸ¦Ί', + }, + CONTRIBUTOR_BAN: { + embedColor: Colors.Red, + embedTitle: 'Contributor Role Banned!', + pastVerb: 'banned from using the Contributor role', + presentVerb: 'banning from using the Contributor role', + emoji: 'πŸ§‘β€πŸ’»', + }, + 'UN-CONTRIBUTOR_BAN': { + embedColor: Colors.Green, + embedTitle: 'Un-Contributor Role Banned!', + pastVerb: 'allowed to use the Contributor role again', + presentVerb: 'allowing to use the Contributor role again', + emoji: 'πŸ§‘β€πŸ’»', + }, + BAN_EVASION: { + embedColor: Colors.Red, + embedTitle: 'Ban Evasion!', + pastVerb: 'banned for evasion', + presentVerb: 'banning for evasion', + emoji: 'πŸ”¨', + }, + 'UN-BAN_EVASION': { + embedColor: Colors.Green, + embedTitle: 'Un-Ban Evasion!', + pastVerb: 'un-banned for evasion', + presentVerb: 'un-banning for evasion', + emoji: 'πŸ”¨', + }, + UNDERBAN: { + embedColor: Colors.Red, + embedTitle: 'Underban!', + pastVerb: 'banned for being underage', + presentVerb: 'banning for being underage', + emoji: 'πŸ”¨', + }, + 'UN-UNDERBAN': { + embedColor: Colors.Green, + embedTitle: 'Un-Underban!', + pastVerb: 'un-banned for being underage', + presentVerb: 'un-banning for being underage', + emoji: 'πŸ”¨', + }, + TIMEOUT: { + embedColor: Colors.Yellow, + embedTitle: 'Timeout!', + pastVerb: 'timed out', + presentVerb: 'timing out', + emoji: '⏳', + }, + 'UN-TIMEOUT': { + embedColor: Colors.Green, + embedTitle: 'Untimeout!', + pastVerb: 'removed from time-out', + presentVerb: 'removing from time-out', + emoji: '⏳', + }, + KICK: { + embedColor: Colors.Orange, + embedTitle: 'Kicked!', + pastVerb: 'kicked', + presentVerb: 'kicking', + emoji: 'πŸ‘’', + }, + REPORT: { + embedColor: Colors.Orange, + embedTitle: 'Report!', + pastVerb: 'reported', + presentVerb: 'reporting', + emoji: 'πŸ“', + }, + INFO: { + embedColor: Colors.Green, + embedTitle: 'Info!', + pastVerb: 'got info on', + presentVerb: 'getting info on', + emoji: 'ℹ️', + }, +}; + +const warnButtons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('acknowledgeButton') + .setLabel('I understand, it wont happen again!') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('refusalButton') + .setLabel('Nah, I do what I want!') + .setStyle(ButtonStyle.Danger), +); + +function isSnowflake(id: string): boolean { + return /^[0-9]{17,19}$/.test(id); +} + +function isMention(id: string): boolean { + return /^<@!?[0-9]{17,19}>$/.test(id); +} + +// Various action type checks +function isTimeout(command: ModAction): command is 'TIMEOUT' { return command === 'TIMEOUT'; } + +function isUnTimeout(command: ModAction): command is 'UN-TIMEOUT' { return command === 'UN-TIMEOUT'; } + +function isWarning(command: ModAction): command is 'WARNING' { return command === 'WARNING'; } + +function isBan(command: ModAction): command is 'FULL_BAN' | 'BAN_EVASION' | 'UNDERBAN' | 'TICKET_BAN' | 'DISCORD_BOT_BAN' | 'HELPER_BAN' | 'CONTRIBUTOR_BAN' { + return command === 'FULL_BAN' || command === 'BAN_EVASION' || command === 'UNDERBAN'; +} + +function isUnBan(command: ModAction): command is 'UN-FULL_BAN' | 'UN-BAN_EVASION' | 'UN-UNDERBAN' | 'UN-TICKET_BAN' | 'UN-DISCORD_BOT_BAN' | 'UN-HELPER_BAN' | 'UN-CONTRIBUTOR_BAN' { + return command === 'UN-FULL_BAN' || command === 'UN-BAN_EVASION' || command === 'UN-UNDERBAN'; +} + +function sendsMessageToUser(command: ModAction): command is 'WARNING' | 'FULL_BAN' | 'TICKET_BAN' | 'DISCORD_BOT_BAN' | 'BAN_EVASION' | 'UNDERBAN' | 'TIMEOUT' | 'KICK' { + return command === 'WARNING' || command === 'FULL_BAN' || command === 'TICKET_BAN' || command === 'DISCORD_BOT_BAN' || command === 'BAN_EVASION' || command === 'UNDERBAN' || command === 'TIMEOUT' || command === 'KICK'; +} + +function isFullBan(command: ModAction): command is 'FULL_BAN' { return command === 'FULL_BAN'; } + +function isUnFullBan(command: ModAction): command is 'UN-FULL_BAN' { return command === 'UN-FULL_BAN'; } + +function isUnderban(command: ModAction): command is 'UNDERBAN' { return command === 'UNDERBAN'; } + +function isUnUnderban(command: ModAction): command is 'UN-UNDERBAN' { return command === 'UN-UNDERBAN'; } + +function isTicketBan(command: ModAction): command is 'TICKET_BAN' { return command === 'TICKET_BAN'; } + +function isUnTicketBan(command: ModAction): command is 'UN-TICKET_BAN' { return command === 'UN-TICKET_BAN'; } + +function isDiscordBotBan(command: ModAction): command is 'DISCORD_BOT_BAN' { return command === 'DISCORD_BOT_BAN'; } + +function isUnDiscordBotBan(command: ModAction): command is 'UN-DISCORD_BOT_BAN' { return command === 'UN-DISCORD_BOT_BAN'; } + +function isHelperBan(command: ModAction): command is 'HELPER_BAN' { return command === 'HELPER_BAN'; } + +function isUnHelperBan(command: ModAction): command is 'UN-HELPER_BAN' { return command === 'UN-HELPER_BAN'; } + +function isContributorBan(command: ModAction): command is 'CONTRIBUTOR_BAN' { return command === 'CONTRIBUTOR_BAN'; } + +function isUnContributorBan(command: ModAction): command is 'UN-CONTRIBUTOR_BAN' { return command === 'UN-CONTRIBUTOR_BAN'; } + +function isBanEvasion(command: ModAction): command is 'BAN_EVASION' { return command === 'BAN_EVASION'; } + +function isUnBanEvasion(command: ModAction): command is 'UN-BAN_EVASION' { return command === 'UN-BAN_EVASION'; } + +function isKick(command: ModAction): command is 'KICK' { return command === 'KICK'; } + +function isReport(command: ModAction): command is 'REPORT' { return command === 'REPORT'; } + +function isNote(command: ModAction): command is 'NOTE' { return command === 'NOTE'; } + +function isLink(command: ModAction): command is 'LINK' { return command === 'LINK'; } + +function isInfo(command: ModAction): command is 'INFO' { return command === 'INFO'; } + +function isDiscussable(command: ModAction): command is 'DISCORD_BOT_BAN' | 'TICKET_BAN' | 'WARNING' | 'KICK' { + return command === 'DISCORD_BOT_BAN' || command === 'TICKET_BAN' || command === 'WARNING' || command === 'KICK'; +} + +function isRepeatable(command: ModAction): command is 'KICK' | 'WARNING' | 'TIMEOUT' { + return command === 'KICK' || command === 'WARNING' || command === 'TIMEOUT'; +} + +export const modButtonNote = (discordId: string) => new ButtonBuilder() + .setCustomId(`moderate~NOTE~${discordId}`) + .setLabel('Note') + .setEmoji('πŸ—’οΈ') + .setStyle(ButtonStyle.Success); + +export const modButtonInfo = (discordId: string) => new ButtonBuilder() + .setCustomId(`moderate~INFO~${discordId}`) + .setLabel('Info') + .setEmoji('ℹ️') + .setStyle(ButtonStyle.Primary); + +export const modButtonReport = (discordId: string) => new ButtonBuilder() + .setCustomId(`moderate~REPORT~${discordId}`) + .setLabel('Report') + .setEmoji('πŸ“') + .setStyle(ButtonStyle.Primary); + +export const modButtonWarn = (discordId: string) => new ButtonBuilder() + .setCustomId(`moderate~WARNING~${discordId}`) + .setLabel('Warn') + .setEmoji('⚠️') + .setStyle(ButtonStyle.Primary); + +export const modButtonTimeout = (discordId: string) => new ButtonBuilder() + .setCustomId(`moderate~TIMEOUT~${discordId}`) + .setLabel('Mute') + .setEmoji('⏳') + .setStyle(ButtonStyle.Secondary); + +export const modButtonBan = (discordId: string) => new ButtonBuilder() + .setCustomId(`moderate~FULL_BAN~${discordId}`) + .setLabel('Ban') + .setEmoji('πŸ”¨') + .setStyle(ButtonStyle.Danger); + +export const modButtonUnBan = (discordId: string) => new ButtonBuilder() + .setCustomId(`moderate~UN-FULL_BAN~${discordId}`) + .setLabel('Unban') + .setEmoji('πŸ”¨') + .setStyle(ButtonStyle.Success); + +export const modButtonUnTimeout = (discordId: string) => new ButtonBuilder() + .setCustomId(`moderate~UN-TIMEOUT~${discordId}`) + .setLabel('Unmute') + .setEmoji('⏳') + .setStyle(ButtonStyle.Success); + +export async function tripSitTrustScore( + targetId: string, +): Promise<{ + trustScore: number; + tsReasoning: string; + }> { + let trustScore = 0; + let tsReasoning = ''; + const target = await discordClient.users.fetch(targetId); + + // Calculate how like it is that this user is a trust. + // This is based off of factors like, how old is their account, do they have a profile picture, etc. + const diff = Math.abs(Date.now() - Date.parse(target.createdAt.toString())); + const years = Math.floor(diff / (1000 * 60 * 60 * 24 * 365)); + const months = Math.floor(diff / (1000 * 60 * 60 * 24 * 30)); + const weeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7)); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + if (years > 0) { + trustScore += 6; + tsReasoning += '+6 | Account was created at least a year ago\n'; + } else if (years === 0 && months > 0) { + trustScore += 5; + tsReasoning += '+5 | Account was created months ago\n'; + } else if (months === 0 && weeks > 0) { + trustScore += 4; + tsReasoning += '+4 | Account was created weeks ago\n'; + } else if (weeks === 0 && days > 0) { + trustScore += 3; + tsReasoning += '+3 | Account was created days ago\n'; + } else if (days === 0 && hours > 0) { + trustScore += 2; + tsReasoning += '+2 | Account was created hours ago\n'; + } else if (hours === 0 && minutes > 0) { + trustScore += 1; + tsReasoning += '+1 | Account was created minutes ago\n'; + } else if (minutes === 0 && seconds > 0) { + trustScore += 0; + tsReasoning += '+0 | Account was created seconds ago\n'; + } + + if (target.avatarURL()) { + trustScore += 1; + tsReasoning += '+1 | Account has a profile picture\n'; + } else { + trustScore += 0; + tsReasoning += '+0 | Account does not have a profile picture\n'; + } + + if (target.bannerURL() !== null) { + trustScore += 1; + tsReasoning += '+1 | Account has a banner\n'; + } else { + trustScore += 0; + tsReasoning += '+0 | Account does not have a banner\n'; + } + + // Check how many guilds the member is in + await discordClient.guilds.fetch(); + const targetInGuilds = await Promise.all(discordClient.guilds.cache.map(async guild => { + try { + await guild.members.fetch(target.id); + // log.debug(F, `User is in guild: ${guild.name}`); + return guild; + } catch (err: unknown) { + return null; + } + })); + const mutualGuilds = targetInGuilds.filter(item => item); + + if (mutualGuilds.length > 0) { + trustScore += mutualGuilds.length; + tsReasoning += `+${mutualGuilds.length} | I currently share ${mutualGuilds.length} guilds with them\n`; + } else { + trustScore += 0; + tsReasoning += '+0 | Account is only in this guild, that i can tell\n'; + } + + await discordClient.guilds.fetch(); + const noPermissionGuilds = [] as Guild[]; + const notFoundGuilds = [] as Guild[]; + const errorGuilds = [] as Guild[]; + const bannedTest = await Promise.all(discordClient.guilds.cache.map(async guild => { + // log.debug(F, `Checking guild: ${guild.name}`); + const guildPerms = await checkGuildPermissions(guild, [ + 'BanMembers' as PermissionResolvable, + ]); + + if (!guildPerms) { + // log.debug(F, `No permission to check guild: ${guild.name}`); + noPermissionGuilds.push(guild); + return null; + } + + try { + return await guild.bans.fetch(target.id); + // log.debug(F, `User is banned in guild: ${guild.name}`); + // return guild.name; + } catch (err: unknown) { + if ((err as DiscordAPIError).code === 10026) { + // log.debug(F, `User is not banned in guild: ${guild.name}`); + notFoundGuilds.push(guild); + return null; + } + // log.debug(F, `Error checking guild: ${guild.name}`); + errorGuilds.push(guild); + return null; + } + })); + + // count how many 'banned' appear in the array + const bannedGuilds = bannedTest.filter(item => item) as GuildBan[]; + // log.debug(F, `Banned Guilds: ${bannedGuilds.join(', ')}`); + + // count how many i didn't have permission to check + // log.debug(F, `No Permission Guilds: ${noPermissionGuilds.map(guild => guild.name).join(', ')}`); + // log.debug(F, `Not Found Guilds: ${notFoundGuilds.map(guild => guild.name).join(', ')}`); + // log.debug(F, `Error Guilds: ${errorGuilds.map(guild => guild.name).join(', ')}`); + const checkedGuildNumber = bannedTest.length - noPermissionGuilds.length; + + if (bannedGuilds.length === 0) { + trustScore += 0; + tsReasoning += stripIndents`+0 | Not banned in ${checkedGuildNumber} other guilds that I can see.`; + } else { + trustScore -= (bannedGuilds.length * 5); + // eslint-disable-next-line max-len + + const tsBanReasons = (await Promise.all(bannedGuilds.map(async banData => { + if (banData.partial) { + await banData.fetch(); + } + + let reasonStr = ': No reason found.'; + if (banData.reason) { + reasonStr = `: ${banData.reason}`; + } + + return `${banData.guild.name}${reasonStr}`; + }))).join('\n'); + + tsReasoning += stripIndents`-${(bannedGuilds.length * 5)} | Banned in least ${bannedGuilds.length} of the ${checkedGuildNumber} guilds I can check. + ${tsBanReasons} + `; + } + + return { + trustScore, + tsReasoning, + }; +} + +export async function userInfoEmbed( + actor: GuildMember | null, + target:GuildMember | User | string, + targetData:users, + command: ModAction, + showModInfo: boolean, +):Promise { + log.debug(F, `[userInfoEmbed] actor: ${actor} | target: ${target} | targetData: ${targetData} | command: ${command}`); + const targetActionList = { + NOTE: [] as string[], + WARNING: [] as string[], + REPORT: [] as string[], + TIMEOUT: [] as string[], + KICK: [] as string[], + FULL_BAN: [] as string[], + UNDERBAN: [] as string[], + TICKET_BAN: [] as string[], + DISCORD_BOT_BAN: [] as string[], + HELPER_BAN: [] as string[], + CONTRIBUTOR_BAN: [] as string[], + }; + // Populate targetActionList from the db + + // const targetActionListRaw = await database.actions.get(targetData.id); + const targetActionListRaw = await db.user_actions.findMany({ + where: { + user_id: targetData.id, + }, + }); + + // log.debug(F, `targetActionListRaw: ${JSON.stringify(targetActionListRaw, null, 2)}`); + + // for (const action of targetActionListRaw) { + targetActionListRaw.forEach(action => { + // log.debug(F, `action: ${JSON.stringify(action, null, 2)}`); + const actionString = `${time(action.created_at, 'R')}: ${action.internal_note ?? 'No note provided'}`; + targetActionList[action.type as keyof typeof targetActionList].push(actionString); + }); + + // log.debug(F, `targetActionList: ${JSON.stringify(targetActionList, null, 2)}`); + + log.debug(F, `Target: ${JSON.stringify(target, null, 2)}`); + + const targetDisplayName = (target as GuildMember).displayName ?? null; + // let targetUserName = null as string | null; + // if ((target as GuildMember).user) { + // targetUserName = (target as GuildMember).user.username; + // } + // if ((target as User).username) { + // targetUserName = (target as User).username; + // } + + log.debug(F, `targetDisplayName: ${targetDisplayName}`); + + const targetId = (target as User | GuildMember).id ?? target; + + // // Construct the author string that includes the display name and username if available + // let targetString = ''; + // if (targetDisplayName) { + // targetString += `${targetDisplayName} `; + // } + // if (targetUserName) { + // targetString += `(${targetUserName}) `; + // } + // targetString += `(${targetId})`; + + // log.debug(F, `targetString: ${targetString}`); + // const tag = (target as GuildMember).user ? (target as GuildMember).user.tag : (target as User).tag; + let userAvatar = null; + try { + if ((target as GuildMember).user) { + userAvatar = (target as GuildMember).user.displayAvatarURL(); + } + } catch (err: unknown) { + try { + if ((target as User).displayAvatarURL()) { + userAvatar = (target as User).displayAvatarURL(); + } + } catch (error: unknown) { + // Ignore + } + } + + // log.debug(F, `userAvatar: ${userAvatar}`); + const modlogEmbed = new EmbedBuilder() + .setFooter(null) + // .setAuthor({ name: 'Report a user', iconURL: userAvatar }) + .setThumbnail(userAvatar) + .setColor(command ? embedVariables[command as keyof typeof embedVariables].embedColor : Colors.DarkOrange); + // .addFields( + // // { name: tag, value: `${target.id}`, inline: true }, + // { + // name: 'Created', + // value: `${time(((target as GuildMember).user + // ?? (target as User)).createdAt, 'R')}`, + // inline: true, + // }, + // { + // name: 'Joined', + // value: `${(target as GuildMember).joinedAt + // ? time((target as GuildMember).joinedAt as Date, 'R') + // : 'Unknown'}`, + // inline: true, + // }, + // { + // name: 'ID', + // value: `${(target as User | GuildMember).id ?? target}`, + // inline: true, + // }, + // ); + + const description = `Report <@${targetId}>`; + if (command === 'REPORT') { + modlogEmbed.setDescription(`${description} + + Click the button below, fill out the form, and a submit the report. + A moderator will review it as soon as possible. + While under review, avoid engaging with the user, and consider blocking. + `); + } + if (showModInfo) { + // log.debug(F, `[] actor: ${actor}`); + // // If the actor is a moderator + // const guildData = await db.discord_guilds.upsert({ + // where: { + // id: actor.guild.id, + // }, + // create: { + // id: actor.guild.id, + // }, + // update: { + // }, + // }); + + // log.debug(F, 'Generating trust score'); + const trustScore = await tripSitTrustScore(targetId); + // // If the actor is a moderator + // if (showModInfo && guildData.role_moderator && actor.roles.cache.has(guildData.role_moderator)) { + let infoString = stripIndents` + ${targetActionList.FULL_BAN.length > 0 ? `**Bans**\n${targetActionList.FULL_BAN.join('\n')}` : ''} + ${targetActionList.UNDERBAN.length > 0 ? `**Underbans**\n${targetActionList.UNDERBAN.join('\n')}` : ''} + ${targetActionList.KICK.length > 0 ? `**Kicks**\n${targetActionList.KICK.join('\n')}` : ''} + ${targetActionList.TIMEOUT.length > 0 ? `**Timeouts**\n${targetActionList.TIMEOUT.join('\n')}` : ''} + ${targetActionList.WARNING.length > 0 ? `**Warns**\n${targetActionList.WARNING.join('\n')}` : ''} + ${targetActionList.REPORT.length > 0 ? `**Reports**\n${targetActionList.REPORT.join('\n')}` : ''} + ${targetActionList.NOTE.length > 0 ? `**Notes**\n${targetActionList.NOTE.join('\n')}` : ''} + `; + if (infoString.length === 0) { + infoString = 'Squeaky clean!'; + } + if (targetActionList.NOTE.length > 0) { + modlogEmbed.addFields({ name: '# of Notes', value: `${targetActionList.NOTE.length}`, inline: true }); + } + if (targetActionList.WARNING.length > 0) { + modlogEmbed.addFields({ name: '# of Warns', value: `${targetActionList.WARNING.length}`, inline: true }); + } + if (targetActionList.REPORT.length > 0) { + modlogEmbed.addFields({ name: '# of Reports', value: `${targetActionList.REPORT.length}`, inline: true }); + } + if (targetActionList.TIMEOUT.length > 0) { + modlogEmbed.addFields({ name: '# of Timeouts', value: `${targetActionList.TIMEOUT.length}`, inline: true }); + } + if (targetActionList.KICK.length > 0) { + modlogEmbed.addFields({ name: '# of Kicks', value: `${targetActionList.KICK.length}`, inline: true }); + } + if (targetActionList.FULL_BAN.length > 0) { + modlogEmbed.addFields({ name: '# of Bans', value: `${targetActionList.FULL_BAN.length}`, inline: true }); + } + if (targetActionList.UNDERBAN.length > 0) { + modlogEmbed.addFields({ name: '# of Underbans', value: `${targetActionList.UNDERBAN.length}`, inline: true }); + } + modlogEmbed.setDescription(`${description} + + **TripSit TrustScore: ${trustScore.trustScore}** + `); + if (isInfo(command)) { + modlogEmbed.setDescription(`${description} + + ${infoString} + + **TripSit TrustScore: ${trustScore.trustScore}** + \`\`\`${trustScore.tsReasoning}\`\`\` + `); + } + } + + // log.debug(F, `modlogEmbed: ${JSON.stringify(modlogEmbed, null, 2)}`); + return modlogEmbed; +} + +export async function modResponse( + interaction: ChatInputCommandInteraction + | MessageContextMenuCommandInteraction + | UserContextMenuCommandInteraction + | ButtonInteraction, + command: ModAction, + showModButtons: boolean, +):Promise { + const actionRow = new ActionRowBuilder(); + if (!interaction.guild || !interaction.member) { + return { + embeds: [embedTemplate() + .setColor(Colors.Red) + .setTitle('This command can only be used in a guild!')], + }; + } + + let targetString = ''; + let target = {} as GuildMember; + const modEmbedObj = embedTemplate(); + + const { embedColor } = embedVariables[command as keyof typeof embedVariables]; + + // Get the actor + const actor = interaction.member as GuildMember; + + // Determine the target + if (interaction.isChatInputCommand() || interaction.isButton()) { + if (interaction.isButton()) { + [,, targetString] = interaction.customId.split('~'); + } else { + targetString = interaction.options.getString('target', true); + } + // log.debug(F, `Target string: ${targetString}`); + const targets = await getDiscordMember(interaction, targetString); + if (targets.length > 1) { + log.debug(F, `Found multiple targets: ${targets}`); + return { + embeds: [modEmbedObj + .setColor(embedColor) + .setTitle(`${targetString}" returned ${targets.length} results!`) + .setDescription(beMoreSpecific)], + }; + } + + if (targets.length === 0) { + // If we didn't find a member, the likely left the guild already + // If so, we can only ban or note them + // We can only do that if the discordID was provided + if ((isSnowflake(targetString) || isMention(targetString))) { + const userId = isSnowflake(targetString) ? targetString : targetString.replace(/[<@!>]/g, ''); + + let targetObj = userId as Snowflake | User | GuildMember; + try { + targetObj = await actor.guild.members.fetch(userId); + } catch (err) { + try { + targetObj = await discordClient.users.fetch(userId); + } catch (error) { + // Ignore + } + } + + const targetData = await db.users.upsert({ + where: { discord_id: userId }, + create: { discord_id: userId }, + update: {}, + }); + + actionRow.addComponents( + modButtonNote(userId), + ); + + let banVerb = 'ban'; + let userBan = {} as GuildBan; + try { + userBan = await interaction.guild.bans.fetch(userId); + } catch (err: unknown) { + // log.debug(F, `Error fetching ban: ${err}`); + } + if (userBan.guild) { + actionRow.addComponents( + modButtonUnBan(userId), + ); + banVerb = 'un-ban'; + } else { + actionRow.addComponents( + modButtonBan(userId), + ); + } + + actionRow.addComponents( + modButtonInfo(userId), + ); + + if (isReport(command)) { + modEmbedObj.setDescription(stripIndents` + User ID '${userId}' is not in the guild, but I can still Note or ${banVerb} them!`); + } else { + log.debug(F, '[modResponse] generating user info'); + const modlogEmbed = await userInfoEmbed(actor, targetObj, targetData, command, showModButtons); + log.debug(F, `modlogEmbed: ${JSON.stringify(modlogEmbed, null, 2)}`); + actionRow.setComponents([ + modButtonInfo(userId), + ]); + if (isBan(command)) { + actionRow.addComponents( + modButtonUnBan(userId), + ); + } + return { + embeds: [modlogEmbed], + components: [actionRow], + }; + } + return { + embeds: [modEmbedObj], + components: [actionRow], + }; + } + modEmbedObj + .setColor(embedColor) + .setTitle(`"${targetString}" returned no results!`) + .setDescription(beMoreSpecific); + return { + embeds: [modEmbedObj], + }; + } + + // log.debug(F, `Assigning target from string: ${targets}`); + [target] = targets; + } + if (interaction.isUserContextMenuCommand() && interaction.targetMember) { + // log.debug(F, `User context target member: ${interaction.targetMember}`); + target = interaction.targetMember as GuildMember; + } else if (interaction.isMessageContextMenuCommand() && interaction.targetMessage) { + // log.debug(F, `Message context target message member: ${interaction.targetMessage.member}`); + target = interaction.targetMessage.member as GuildMember; + } + const targetData = await db.users.upsert({ + where: { + discord_id: target.id, + }, + create: { + discord_id: target.id, + }, + update: { + }, + }); + + // Get the guild + // const { guild } = interaction; + // const guildData = await db.discord_guilds.upsert({ + // where: { + // id: guild.id, + // }, + // create: { + // id: guild.id, + // }, + // update: { + // }, + // }); + + // Determine if the actor is a mod + // const actorIsMod = (!!guildData.role_moderator && actor.roles.cache.has(guildData.role_moderator)); + + const timeoutTime = target.communicationDisabledUntilTimestamp; + + if (showModButtons) { + if (isInfo(command) || isReport(command)) { + actionRow.addComponents( + modButtonNote(target.id), + modButtonWarn(target.id), + modButtonTimeout(target.id), + modButtonBan(target.id), + modButtonInfo(target.id), + ); + } else if (isTimeout(command) || (timeoutTime && timeoutTime > Date.now())) { + actionRow.addComponents( + modButtonUnTimeout(target.id), + modButtonInfo(target.id), + ); + } else if (isBan(command)) { + actionRow.addComponents( + modButtonUnBan(target.id), + modButtonInfo(target.id), + ); + } else { + actionRow.addComponents( + modButtonInfo(target.id), + ); + } + } else { + actionRow.addComponents( + modButtonReport(target.id), + ); + } + + log.debug(F, '[modResponse1] generating user info'); + const modlogEmbed = await userInfoEmbed(actor, target, targetData, 'REPORT', showModButtons); + + if (interaction.isMessageContextMenuCommand() && interaction.targetMessage) { + modlogEmbed.addFields( + { + name: 'Message', + value: stripIndents`> ${interaction.targetMessage.content} + - ${interaction.targetMessage.url}`, + }, + ); + } + + return { + embeds: [modlogEmbed], + components: [actionRow], + }; +} + +export async function linkThread( + discordId: string, + threadId: string, + override: boolean | null, +): Promise { + // Get the targetData from the db + const userData = await db.users.upsert({ + where: { + discord_id: discordId, + }, + create: { + discord_id: discordId, + }, + update: {}, + }); + + if (userData.mod_thread_id === null || override) { + // log.debug(F, `targetData.mod_thread_id is null, updating it`); + await db.users.update({ + where: { + id: userData.id, + }, + data: { + mod_thread_id: threadId, + }, + }); + return null; + } + // log.debug(F, `targetData.mod_thread_id is not null, not updating it`); + return userData.mod_thread_id; +} + +async function messageModThread( + interaction: ChatInputCommandInteraction + | MessageContextMenuCommandInteraction + | UserContextMenuCommandInteraction + | ButtonInteraction, + actor: GuildMember, + target: string | GuildMember | User, + command: ModAction, + internalNote: string, + description: string, + extraMessage: string, + duration: string, +): Promise { + // log.debug(F, `[messageModThread] actor: ${actor} | target: ${target} | command: ${command} | internalNote: ${internalNote} | description: ${description} | extraMessage: ${extraMessage} | duration: ${duration}`); + const targetId = (target as User | GuildMember).id ?? target; + const targetName = (target as GuildMember).displayName ?? (target as User).username ?? target; + + const targetData = await db.users.upsert({ + where: { discord_id: targetId }, + create: { discord_id: targetId }, + update: { }, + }); + const guildData = await db.discord_guilds.upsert({ + where: { id: actor.guild.id }, + create: { id: actor.guild.id }, + update: { }, + }); + + if (!guildData.channel_moderators) throw new Error('Moderator log room id is null'); + + if (!guildData.channel_mod_log) throw new Error('Moderator log room id is null'); + + const { pastVerb, emoji } = embedVariables[command as keyof typeof embedVariables]; + let summary = `${actor.displayName} ${pastVerb} ${targetName}`; + let anonSummary = `${targetName} was ${pastVerb}`; + + if (isTimeout(command)) { + summary = summary.concat(duration); + anonSummary = anonSummary.concat(duration); + } + + // log.debug(F, `summary: ${summary}`); + + log.debug(F, '[messageModThread] generating user info'); + const modlogEmbed = await userInfoEmbed(actor, target, targetData, command, true); + + try { + const modEmbedObj = (interaction as ButtonInteraction).message.embeds[0].toJSON(); + const messageField = (modEmbedObj.fields as APIEmbedField[]).find(field => field.name === 'Message'); + if (messageField) { + modlogEmbed.addFields(messageField); + } + } catch (err) { + // log.debug(F, `No message field found: ${err}`); + } + + log.debug(F, 'Sending message to mod log'); + const modLogChan = await discordClient.channels.fetch(guildData.channel_mod_log) as TextChannel; + await modLogChan.send({ + content: stripIndents` + ${anonSummary} + **Reason:** ${internalNote ?? noReason} + **Note sent to user:** ${(description !== '' && description !== null) ? description : noMessageSent} + `, + embeds: [modlogEmbed], + }); + + if (extraMessage) { + await modLogChan.send({ content: extraMessage }); + } + + let modThread = null as ThreadChannel | null; + const vendorBan = internalNote?.toLowerCase().includes('vendor') && isFullBan(command); + if (!vendorBan) { + const guild = await discordClient.guilds.fetch(guildData.id); + if (targetData.mod_thread_id) { + // log.debug(F, `Mod thread id exists: ${targetData.mod_thread_id}`); + try { + modThread = await guild.channels.fetch(targetData.mod_thread_id) as ThreadChannel | null; + // log.debug(F, 'Mod thread exists'); + } catch (err) { + // log.debug(F, 'Mod thread does not exist'); + } + } + + // log.debug(F, `Mod thread: ${JSON.stringify(modThread, null, 2)}`); + + let newModThread = false; + if (!modThread) { + // If the mod thread doesn't exist for whatever reason, `maybe it got deleted, make a new one + // If the user we're banning is a vendor, don't make a new one + // Create a new thread in the mod channel + // log.debug(F, 'creating mod thread'); + if (guildData.channel_moderators === null) { + throw new Error('Moderator room id is null'); + } + const modChan = await discordClient.channels.fetch(guildData.channel_moderators) as TextChannel; + modThread = await modChan.threads.create({ + name: `${emoji}β”‚${targetName}`, + autoArchiveDuration: 60, + }) as ThreadChannel; + // log.debug(F, 'created mod thread'); + // Save the thread id to the user + targetData.mod_thread_id = modThread.id; + await db.users.update({ + where: { + discord_id: targetId, + }, + data: { + mod_thread_id: modThread.id, + }, + }); + log.debug(F, 'saved mod thread id to user'); + newModThread = true; + } + + if (!guildData.role_moderator) { + throw new Error('Moderator role id is null'); + } + const roleModerator = await guild.roles.fetch(guildData.role_moderator) as Role; + + await modThread.send({ + content: stripIndents` + ${summary} + **Reason:** ${internalNote ?? noReason} + **Note sent to user:** ${(description !== '' && description !== null) ? description : noMessageSent} + ${command === 'NOTE' && !newModThread ? '' : roleModerator} + `, + ...await modResponse(interaction, command, true), + }); + + await modThread.setName(`${emoji}β”‚${targetName}`); + + if (extraMessage) { + await modThread.send({ content: extraMessage }); + } + } + return modThread; +} + +async function messageUser( + target: User, + guild: Guild, + command: ModAction, + messageToUser: string, + addButtons?: boolean, +) { + // log.debug(F, `Message user: ${target.username}`); + const embed = embedTemplate() + .setColor(embedVariables[command as keyof typeof embedVariables].embedColor) + .setTitle(embedVariables[command as keyof typeof embedVariables].embedTitle) + .setDescription(messageToUser); + + let message = {} as Message; + try { + if (addButtons) { + message = await target.send({ embeds: [embed], components: [warnButtons] }); + } else { + message = await target.send({ embeds: [embed] }); + } + } catch (error) { + return; + } + + if (addButtons) { + const targetData = await db.users.upsert({ + where: { + discord_id: target.id, + }, + create: { + discord_id: target.id, + }, + update: { + }, + }); + + const messageFilter = (mi: MessageComponentInteraction) => mi.user.id === target.id; + const collector = message.createMessageComponentCollector({ filter: messageFilter, time: 0 }); + + collector.on('collect', async (mi: MessageComponentInteraction) => { + if (mi.customId.startsWith('acknowledgeButton')) { + const targetChan = await discordClient.channels.fetch(targetData.mod_thread_id as Snowflake) as TextChannel; + if (targetChan) { + await targetChan.send({ + embeds: [embedTemplate() + .setColor(Colors.Green) + .setDescription(`${target.username} has acknowledged their warning.`)], + }); + } + // remove the components from the message + await mi.update({ components: [] }); + mi.user.send('Thanks for understanding! We appreciate your cooperation and will consider this in the future!'); + } else if (mi.customId.startsWith('refusalButton')) { + const targetChan = await discordClient.channels.fetch(targetData.mod_thread_id as Snowflake) as TextChannel; + await targetChan.send({ + embeds: [embedTemplate() + .setColor(Colors.Red) + .setDescription(`${target.username} has refused their timeout and was kicked.`)], + }); + // remove the components from the message + await mi.update({ components: [] }); + mi.user.send(stripIndents`Thanks for admitting this, you\'ve been removed from the guild. + You can rejoin if you ever decide to cooperate.`); + await guild.members.kick(target, 'Refused to acknowledge timeout'); + } + }); + } +} + +export async function acknowledgeButton( + interaction:ButtonInteraction, +) { + const targetData = await db.users.upsert({ + where: { + discord_id: interaction.user.id, + }, + create: { + discord_id: interaction.user.id, + }, + update: { + }, + }); + const targetChan = await discordClient.channels.fetch(targetData.mod_thread_id as Snowflake) as TextChannel; + if (targetChan) { + await targetChan.send({ + embeds: [embedTemplate() + .setColor(Colors.Green) + .setDescription(`${interaction.user.username} has acknowledged their warning.`)], + }); + } + // remove the components from the message + await interaction.update({ components: [] }); + interaction.user.send('Thanks for understanding! We appreciate your cooperation and will consider this in the future!'); +} + +export async function refusalButton( + interaction:ButtonInteraction, +) { + const targetData = await db.users.upsert({ + where: { + discord_id: interaction.user.id, + }, + create: { + discord_id: interaction.user.id, + }, + update: { + }, + }); + const targetChan = await discordClient.channels.fetch(targetData.mod_thread_id as Snowflake) as TextChannel; + if (targetChan) { + await targetChan.send({ + embeds: [embedTemplate() + .setColor(Colors.Green) + .setDescription(`${interaction.user.username} has refused their warning and was kicked.`)], + }); + } + // remove the components from the message + await interaction.update({ components: [] }); + await interaction.user.send('Thanks for admitting this, you\'ve been removed from the guild. You can rejoin if you ever decide to cooperate.'); + + await targetChan.guild.members.kick(interaction.user, 'Refused to acknowledge warning'); +} + +export async function moderate( + buttonInt: ButtonInteraction, + modalInt: ModalSubmitInteraction, +): Promise { + if (!buttonInt.guild) return { content: 'This command can only be used in a guild!' }; + const actor = buttonInt.member as GuildMember; + + const [, command, targetId]: [string, ModAction, Snowflake] = buttonInt.customId.split('~') as [string, ModAction, Snowflake]; + + const modEmbedObj = buttonInt.message.embeds[0].toJSON(); + + let targetMember = null as null | GuildMember; + let targetUser = null as null | User; + try { + targetMember = await actor.guild.members.fetch(targetId); + } catch (err) { + try { + targetUser = await discordClient.users.fetch(targetId); + } catch (error) { + // Ignore + } + } + + let targetName = targetId; + let targetObj = targetId as Snowflake | User | GuildMember; + if (targetMember) { + targetName = targetMember.displayName; + targetObj = targetMember; + } else if (targetUser) { + targetName = targetUser.username; + targetObj = targetUser; + } + + let description = ''; + if (!isNote(command) && !isReport(command)) { + description = modalInt.fields.getTextInputValue('description'); + } + let internalNote = modalInt.fields.getTextInputValue('internalNote'); + + // Check if this is a vendor ban + const vendorBan = internalNote?.toLowerCase().includes('vendor') && isFullBan(command); + + // Don't allow people to mention MEP + if (internalNote?.includes('MEP') || description?.includes('MEP')) { + return { + content: mepWarning, + }; + } + + try { + const messageField = (modEmbedObj.fields as APIEmbedField[]).find(field => field.name === 'Message'); + // If the modEmbed contains a message field, add it to the internal note + if (messageField) { + internalNote = stripIndents` + ${internalNote}`; + } + } catch (err) { + // Ignore + } + + // Process duration time for ban and timeouts + let duration = 0 as null | number; + let durationStr = ''; + if (isTimeout(command)) { + // log.debug(F, 'Parsing timeout duration'); + let durationVal = modalInt.fields.getTextInputValue('duration'); + if (durationVal === '') durationVal = '7 days'; + // log.debug(F, `durationVal: ${durationVal}`); + if (durationVal.length === 1) { + // If the input is a single number, assume it's days + duration = parseInt(durationVal, 10); + if (Number.isNaN(duration)) { + return { content: 'Timeout must be a number!' }; + } + if (duration < 0 || duration > 7) { + return { content: 'Timeout must be between 0 and 7 days!' }; + } + durationVal = `${duration} days`; + } + + duration = await parseDuration(durationVal); + if (duration && (duration < 0 || duration > 7 * 24 * 60 * 60 * 1000)) { + return { content: 'Timeout must be between 0 and 7 days!!' }; + } + + durationStr = `. It will expire ${time(new Date(Date.now() + duration), 'R')}`; + // log.debug(F, `duration: ${duration}`); + } + if (isFullBan(command)) { + // If the command is ban, then the input value exists, so pull that and try to parse it as an int + let dayInput = parseInt(modalInt.fields.getTextInputValue('days'), 10); + + // If no input was provided, default to 0 days + if (Number.isNaN(dayInput)) dayInput = 0; -type ModAction = 'INFO' | 'BAN' | 'WARNING' | 'REPORT' | 'NOTE' | 'TIMEOUT' | 'UN-CONTRIBUTOR_BAN' | 'UN-HELPER_BAN' | -'FULL_BAN' | 'TICKET_BAN' | 'DISCORD_BOT_BAN' | 'BAN_EVASION' | 'UNDERBAN' | 'HELPER_BAN' | 'CONTRIBUTOR_BAN' | 'LINK' | -'UN-FULL_BAN' | 'UN-TICKET_BAN' | 'UN-DISCORD_BOT_BAN' | 'UN-BAN_EVASION' | 'UN-UNDERBAN' | 'UN-TIMEOUT' | 'KICK'; + // If the input is a string, or outside the bounds, tell the user and return + if (dayInput && (dayInput < 0 || dayInput > 7)) { + return { content: 'Ban days must be at least 0 and at most 7!' }; + } + + // Get the millisecond value of the input + duration = await parseDuration(`${dayInput} days`); + } + + // Display all properties we're going to use + log.info(F, `[moderate] + actor: ${actor} + command: ${command} + targetId: ${targetId} + internalNote: ${internalNote} + description: ${description} + duration: ${duration} + durationStr: ${durationStr} + `); + + // Get the actor and target data from the db + const actorData = await db.users.upsert({ + where: { discord_id: actor.id }, + create: { discord_id: actor.id }, + update: {}, + }); + const targetData = await db.users.upsert({ + where: { discord_id: targetId }, + create: { discord_id: targetId }, + update: {}, + }); + const guildData = await db.discord_guilds.upsert({ + where: { id: actor.guild.id }, + create: { id: actor.guild.id }, + update: {}, + }); + + // log.debug(F, `TargetData: ${JSON.stringify(targetData, null, 2)}`); + // If this is a Warn, ban, timeout or kick, send a message to the user + // Do this first cuz you can't do this if they're not in the guild + if (sendsMessageToUser(command) + && !vendorBan + && (description !== '' && description !== null) + && (targetMember || targetUser)) { + log.debug(F, `[moderate] Sending message to ${targetName}`); + let body = stripIndents`I regret to inform you that you've been ${embedVariables[command as keyof typeof embedVariables].pastVerb}${durationStr} by Team TripSit. + + > ${description} + + **Do not message a moderator to talk about this or argue about the rules in public channels!** + `; + + const appealString = '\nYou can send an email to appeals@tripsit.me to appeal this ban! Evasion bans are permanent, and underban bans are permanent until you turn 18.'; // eslint-disable-line max-len + const evasionString = '\nEvasion bans are permanent, you can appeal the ban on your main account by sending an email, but evading will extend the ban'; // eslint-disable-line max-len + + // if (guildData.channel_helpdesk) { + // // const channel = await discordClient.channels.fetch(guildData.channel_helpdesk); + // // const discussString = `\nYou can discuss this with the mods in ${channel}.`; // eslint-disable-line max-len + // // const timeoutDiscussString = `\nYou can discuss this with the mods in ${channel} once the timeout expires.`; // eslint-disable-line max-len + // } + + if (isBan(command)) { + body = stripIndents`${body}\n\n${appealString}`; + if (isBanEvasion(command)) { + body = stripIndents`${body}\n\n${evasionString}`; + } + if (isFullBan(command)) { + const response = await last( + targetUser ?? targetMember?.user as User, + buttonInt.guild as Guild, + ); + const extraMessage = `${targetName}'s last ${response.messageCount} (out of ${response.totalMessages}) messages before being banned :\n${response.messageList}`; // eslint-disable-line max-len + body = stripIndents`${body}\n\n${extraMessage}`; + } + } + + if (isDiscussable(command) && guildData.channel_helpdesk) { + const channel = await discordClient.channels.fetch(guildData.channel_helpdesk); + const discussString = `\nYou can discuss this with the mods in ${channel}.`; // eslint-disable-line max-len + body = stripIndents`${body}\n\n${discussString}`; + } + + if (isTimeout(command) && guildData.channel_helpdesk) { + const channel = await discordClient.channels.fetch(guildData.channel_helpdesk); + const timeoutDiscussString = `\nYou can discuss this with the mods in ${channel} once the timeout expires.`; // eslint-disable-line max-len + body = stripIndents`${body}\n\n${timeoutDiscussString}`; + } + + if (isRepeatable(command)) { + body = stripIndents`${body}\n\nPlease review the [TripSit Terms](https://wiki.tripsit.me/wiki/Terms_of_Service) so this doesn't happen again!\n`; + } + + if (isKick(command)) { + body = stripIndents`${body}\n\nIf you feel you can follow the rules you can rejoin here: https://discord.gg/tripsit`; + } + + log.debug(F, `Sending message to ${targetName}`); + await messageUser( + targetUser ?? targetMember?.user as User, + buttonInt.guild as Guild, + command, + body, + isTimeout(command) || isWarning(command), + ); + } + + let actionData = { + user_id: targetData.id, + guild_id: actor.guild.id, + type: command.includes('UN-') ? command.slice(3) : command, + ban_evasion_related_user: null as string | null, + description, + internal_note: internalNote, + expires_at: null as Date | null, + repealed_by: null as string | null, + repealed_at: null as Date | null, + created_by: actorData.id, + created_at: new Date(), + } as user_actions; + + // log.debug(F, `[moderate] performing actions for ${targetName}`); + let extraMessage = ''; + if (isBan(command)) { + if (isFullBan(command) || isUnderban(command) || isBanEvasion(command)) { + targetData.removed_at = new Date(); + const deleteMessageValue = duration ?? 0; + try { + if (deleteMessageValue > 0 && targetMember) { + // log.debug(F, `I am deleting ${deleteMessageValue} days of messages!`); + const response = await last(targetMember.user, buttonInt.guild); + extraMessage = `${targetName}'s last ${response.messageCount} (out of ${response.totalMessages}) messages before being banned :\n${response.messageList}`; + } + log.debug(F, `Days to delete: ${deleteMessageValue}`); + } catch (err) { + log.error(F, `Error: ${err}`); + } + log.info(F, `target: ${targetId} | deleteMessageValue: ${deleteMessageValue} | internalNote: ${internalNote ?? noReason}`); + + try { + targetObj = await buttonInt.guild.bans.create(targetId, { deleteMessageSeconds: deleteMessageValue / 1000, reason: internalNote ?? noReason }); + } catch (err) { + log.error(F, `Error: ${err}`); + } + } else if (isTicketBan(command)) { + targetData.ticket_ban = true; + } else if (isDiscordBotBan(command)) { + targetData.discord_bot_ban = true; + } else if (isHelperBan(command)) { + targetData.helper_role_ban = true; + } else if (isContributorBan(command)) { + targetData.contributor_role_ban = true; + } + } else if (isUnBan(command)) { + if (isUnFullBan(command) || isUnUnderban(command) || isUnBanEvasion(command)) { + targetData.removed_at = null; + try { + await buttonInt.guild.bans.fetch(); + await buttonInt.guild.bans.remove(targetId, internalNote ?? noReason); + } catch (err) { + log.error(F, `Error: ${err}`); + } + } else if (isUnTicketBan(command)) { + targetData.ticket_ban = false; + } else if (isUnDiscordBotBan(command)) { + targetData.discord_bot_ban = false; + } else if (isUnHelperBan(command)) { + targetData.helper_role_ban = false; + } else if (isUnContributorBan(command)) { + targetData.contributor_role_ban = false; + } + + const record = await db.user_actions.findFirst({ + where: { + user_id: targetData.id, + repealed_at: null, + type: (command.includes('UN-') ? command.slice(3) : command) as user_action_type, + }, + orderBy: { + created_at: 'desc', + }, + }); + + if (record) { + actionData = record; + } else { + log.error(F, 'There is no record of this ban, but i will try to do it anyway'); + } + actionData.repealed_at = new Date(); + actionData.repealed_by = actorData.id; + } else if (isTimeout(command)) { + if (targetMember) { + actionData.expires_at = new Date(Date.now() + (duration as number)); + try { + await targetMember.timeout(duration, internalNote ?? noReason); + } catch (err) { + log.error(F, `Error: ${err}`); + } + } else { + return { content: 'User is not in the guild!' }; + } + } else if (isUnTimeout(command)) { + if (targetMember) { + const record = await db.user_actions.findMany({ + where: { + user_id: targetData.id, + repealed_at: null, + type: 'TIMEOUT', + }, + orderBy: { + created_at: 'desc', + }, + }); + + if (record.length > 0) { + [actionData] = record; + } + + actionData.repealed_at = new Date(); + actionData.repealed_by = actorData.id; + + try { + await targetMember.timeout(0, internalNote ?? noReason); + // log.debug(F, `I untimeouted ${target.displayName} because\n '${internalNote}'!`); + } catch (err) { + log.error(F, `Error: ${err}`); + } + } else { + return { content: 'User is not in the guild!' }; + } + } else if (isKick(command)) { + if (targetMember) { + actionData.type = 'KICK' as user_action_type; + try { + await targetMember.kick(); + } catch (err) { + log.error(F, `Error: ${err}`); + } + } else { + return { content: 'User is not in the guild!' }; + } + } + + // This needs to happen before creating the modlog embed + // await useractionsSet(actionData); + if (actionData.id) { + await db.user_actions.upsert({ + where: { id: actionData.id }, + create: actionData, + update: actionData, + }); + } else { + await db.user_actions.create({ data: actionData }); + } + + await db.users.update({ + where: { id: targetData.id }, + data: targetData, + }); + + const anonSummary = `${targetName} was ${embedVariables[command as keyof typeof embedVariables].pastVerb}${durationStr}!`; + + log.debug(F, '[moderate] Sending messages'); + const modThread = await messageModThread( + buttonInt, + actor, + targetObj, + command, + internalNote, + description, + extraMessage, + durationStr, + ); + + const embed = buttonInt.message.embeds[0].toJSON(); + const actionField = embed.fields?.find(field => field.name === 'Actions'); + + if (actionField) { + // Add the action to the list of actions + const newActionFiled = actionField?.value.concat(` + + ${buttonInt.user.toString()} muted this user: + > ${modalInt.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${modalInt.fields.getTextInputValue('description')}`); + // log.debug(F, `newActionFiled: ${newActionFiled}`); + + // Replace the action field with the new one + embed.fields?.splice(embed.fields?.findIndex(field => field.name === 'Actions'), 1, { + name: 'Actions', + value: newActionFiled, + inline: true, + }); + } else { + embed.fields?.push( + { + name: 'Actions', + value: stripIndents`${buttonInt.user.toString()} muted this user: + > ${modalInt.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${modalInt.fields.getTextInputValue('description')}`, + inline: true, + }, + ); + } + + // Return a message to the user who started this, confirming the user was acted on + // log.debug(F, `${target.displayName} has been ${embedVariables[command as keyof typeof embedVariables].verb}!`); + // log.info(F, `response: ${JSON.stringify(desc, null, 2)}`); + // Take the existing description from response and add to it:' + const desc = stripIndents` + ${anonSummary} + **Reason:** ${internalNote ?? noReason} + ${(description !== '' && description !== null && !vendorBan && targetMember) ? `\n\n**Note sent to user: ${description}**` : ''} + `; + + const response = embedTemplate() + .setAuthor(null) + .setColor(Colors.Yellow) + .setDescription(desc) + .setFooter(null); + + if (command !== 'REPORT' && modThread) response.setDescription(`${response.data.description}\nYou can access their thread here: ${modThread}`); + return { embeds: [response] }; +} + +export async function modModal( + interaction: ButtonInteraction, +): Promise { + if (!interaction.guild) return; + const [, cmd, userId] = interaction.customId.split('~'); + const command: ModAction = cmd.toUpperCase() as ModAction; + + let target: string = userId; + + try { + target = (await interaction.guild.members.fetch(userId)).displayName; + } catch (err) { + try { + target = (await discordClient.users.fetch(userId)).username; + } catch (error) { + // Ignore + } + } + + if (command === 'INFO') { + await interaction.deferReply({ ephemeral: true }); + const targetData = await db.users.upsert({ + where: { + discord_id: userId, + }, + create: { + discord_id: userId, + }, + update: {}, + }); + + // const guildData = await db.discord_guilds.upsert({ + // where: { + // id: interaction.guild.id, + // }, + // create: { + // id: interaction.guild.id, + // }, + // update: {}, + // }); + + let targetObj = userId as Snowflake | User | GuildMember; + try { + targetObj = await interaction.guild.members.fetch(userId); + } catch (err) { + try { + targetObj = await discordClient.users.fetch(userId); + } catch (error) { + // Ignore + } + } + + log.debug(F, '[modModal] generating user info embed'); + const modlogEmbed = await userInfoEmbed(interaction.member as GuildMember, targetObj, targetData, 'INFO', true); + + await interaction.editReply({ + embeds: [modlogEmbed], + }); + return; + } + + let modalInternal = ''; + let modalDescription = ''; + const embed = interaction.message.embeds[0].toJSON(); + + // Try to handle AI mod stuff + try { + const flagsField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Flags'); + if (flagsField) { + if (isNote(command)) { + modalInternal = `This user's message was flagged by the AI for ${flagsField.value}`; + } + if (isFullBan(command)) { + const messageField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Message') as APIEmbedField; + const urlField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Channel') as APIEmbedField; + modalInternal = `This user breaks TripSit's policies regarding ${flagsField.value} topics.`; + modalDescription = stripIndents` + Your recent messages have broken TripSit's policies regarding ${flagsField.value} topics. + + The offending message + > ${urlField.value} + - ${messageField.value} + `; + } + } + } catch (err) { + // log.error(F, `Error: ${err}`); + } + + try { + const messageField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Message') as APIEmbedField; + modalInternal = stripIndents`This user breaks ${interaction.guild.name}'s policies. + + The offending message + ${messageField.value}`; + modalDescription = stripIndents` + Your recent messages have broken ${interaction.guild.name}'s policies. + + The offending message + ${messageField.value}`; + } catch (err) { + // log.error(F, `Error: ${err}`); + } + + let verb = ''; + if (command === 'NOTE') verb = 'noting'; + else if (command === 'REPORT') verb = 'reporting'; + else if (command === 'WARNING') verb = 'warning'; + else if (command === 'KICK') verb = 'kicking'; + else if (command === 'TIMEOUT') verb = 'timing out'; + else if (command === 'FULL_BAN') verb = 'banning'; + else if (command === 'TICKET_BAN') verb = 'ticket banning'; + else if (command === 'DISCORD_BOT_BAN') verb = 'discord bot banning'; + else if (command === 'BAN_EVASION') verb = 'evasion banning'; + else if (command === 'UNDERBAN') verb = 'underbanning'; + else if (command === 'CONTRIBUTOR_BAN') verb = 'banning from Contributor on'; + else if (command === 'HELPER_BAN') verb = 'banning from Helper on'; + else if (command === 'UN-HELPER_BAN') verb = 'allowing Helper on '; + else if (command === 'UN-CONTRIBUTOR_BAN') verb = 'allowing Contributor on '; + else if (command === 'UN-TIMEOUT') verb = 'removing timeout on'; + else if (command === 'UN-FULL_BAN') verb = 'removing ban on'; + else if (command === 'UN-TICKET_BAN') verb = 'removing ticket ban on'; + else if (command === 'UN-DISCORD_BOT_BAN') verb = 'removing bot ban on'; + else if (command === 'UN-BAN_EVASION') verb = 'removing ban evasion on'; + else if (command === 'UN-UNDERBAN') verb = 'removing underban on'; + + // log.debug(F, `Verb: ${verb}`); + + const modal = new ModalBuilder() + .setCustomId(`modModal~${command}~${interaction.id}`) + .setTitle(`${interaction.guild.name} member ${command.toLowerCase()}`) + .addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel(`Why are you ${verb} ${target}?`) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Tell moderators why you\'re doing this') + .setValue(modalInternal) + .setMaxLength(1000) + .setRequired(true) + .setCustomId('internalNote'))); + + // All commands except INFO, NOTE and REPORT can have a public reason sent to the user + if (!isNote(command) && !isReport(command)) { + modal.addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel('What should we tell the user?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Tell the user why you\'re doing this') + .setValue(modalDescription) + .setMaxLength(1000) + .setRequired(command === 'WARNING') + .setCustomId('description'))); + } + // Only timeout and full ban can have a duration, but they're different, so separate. + if (isTimeout(command)) { + modal.addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel('Timeout for how long?') + .setStyle(TextInputStyle.Short) + .setPlaceholder('4 days 3hrs 2 mins 30 seconds (Max 7 days, Default 7 days)') + .setRequired(false) + .setCustomId('duration'))); + } + if (isFullBan(command)) { + modal.addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel('How many days of msg to remove?') + .setStyle(TextInputStyle.Short) + .setPlaceholder('4 days 3hrs 2 mins 30 seconds (Max 7 days, Default 0 days)') + .setRequired(false) + .setCustomId('days'))); + } + + // When the modal is opened, disable the button on the embed + + const buttonRow = interaction.message.components[0].toJSON() as APIActionRowComponent; + const buttonData = buttonRow.components.find(field => field.custom_id.split('~')[1] === command); + if (buttonData) { + // log.debug(F, `buttonData: ${JSON.stringify(buttonData, null, 2)}`); + + const updatedButton = { + custom_id: buttonData.custom_id, + label: buttonData.label, + emoji: buttonData.emoji, + style: buttonData.style, + type: buttonData.type, + disabled: true, + }; + + const index = buttonRow.components.findIndex(field => field.custom_id.split('~')[1] === command); + buttonRow.components.splice(index, 1, updatedButton); + + // log.debug(F, `Interaction message: ${JSON.stringify(interaction.message, null, 2)}`); + try { + await interaction.message.edit({ + components: [buttonRow], + }); + } catch (err) { + // This will happen on the initial ephemeral message and idk why + // log.error(F, `Error: ${err}`); + } + } + + await interaction.showModal(modal); + + const filter = (i: ModalSubmitInteraction) => i.customId.startsWith('modModal'); + await interaction.awaitModalSubmit({ filter, time: disableButtonTime }) + .then(async i => { + if (i.customId.split('~')[2] !== interaction.id) return; + await i.deferReply({ ephemeral: true }); + // const internalNote = i.fields.getTextInputValue('internalNote'); // eslint-disable-line + + // // Only these commands actually have the description input, so only pull it if it exists + // const description = isWarning(command) || isKick(command) || isTimeout(command) || isFullBan(command) // eslint-disable-line + // ? i.fields.getTextInputValue('description') + // : null; + + // let duration = null; + // if (isBan(command)) { + // // If the command is ban, then the input value exists, so pull that and try to parse it as an int + // let dayInput = parseInt(i.fields.getTextInputValue('days'), 10); + + // // If no input was provided, default to 0 days + // if (Number.isNaN(dayInput)) dayInput = 0; + + // // If the input is a string, or outside the bounds, tell the user and return + // if (dayInput && (dayInput < 0 || dayInput > 7)) { + // await i.editReply({ content: 'Message remove days must be at least 0 and at most 7!' }); + // return; + // } + + // // Get the millisecond value of the input + // const days = await parseDuration(`${dayInput} days`); + // // log.debug(F, `days: ${days}`); + // duration = days; + // } + + // if (isTimeout(command)) { + // // If the command is timeout get the value + // let timeoutInput = i.fields.getTextInputValue('duration'); + + // // If the value is blank, set it to 7 days, the maximum + // if (timeoutInput === '') timeoutInput = '7 days'; + + // if (timeoutInput.length === 1) { + // // If the input is a single number, assume it's days + // const numberInput = parseInt(timeoutInput, 10); + // if (Number.isNaN(numberInput)) { + // await i.editReply({ content: 'Timeout must be a number!' }); + // return; + // } + // if (numberInput < 0 || numberInput > 7) { + // await i.editReply({ content: 'Timeout must be between 0 and 7 days' }); + // return; + // } + // timeoutInput = `${timeoutInput} days`; + // } + + // // log.debug(F, `timeoutInput: ${timeoutInput}`); + + // const timeout = timeoutInput !== null + // ? await parseDuration(timeoutInput) + // : null; + + // // If timeout is not null, but is outside the bounds, tell the user and return + // if (timeout && (timeout < 0 || timeout > 7 * 24 * 60 * 60 * 1000)) { + // await i.editReply({ content: 'Timeout must be between 0 and 7 days' }); + // return; + // } + + // // log.debug(F, `timeout: ${timeout}`); + // duration = timeout; + // } + + // When the modal is submitted, re-enable the button on the embed + const buttonRow1 = interaction.message.components[0].toJSON() as APIActionRowComponent; + const buttonData1 = buttonRow1.components.find(field => field.custom_id.split('~')[1] === command); + if (buttonData1) { + // log.debug(F, `buttonData: ${JSON.stringify(buttonData1, null, 2)}`); + + const updatedButton = { + custom_id: buttonData1.custom_id, + label: buttonData1.label, + emoji: buttonData1.emoji, + style: buttonData1.style, + type: buttonData1.type, + disabled: false, + }; + + const index = buttonRow1.components.findIndex(field => field.custom_id.split('~')[1] === command); + buttonRow1.components.splice(index, 1, updatedButton); + + try { + await interaction.message.edit({ + components: [buttonRow1], + }); + } catch (err) { + // This will happen on the initial ephemeral message and idk why + // log.error(F, `Error: ${err}`); + } + } + + await i.editReply(await moderate(interaction, i)); + }) + .catch(async err => { + // log.error(F, `Error: ${JSON.stringify(err as DiscordErrorData, null, 2)}`); + // log.error(F, `Error: ${JSON.stringify((err as DiscordErrorData).code, null, 2)}`); + // log.error(F, `Error: ${JSON.stringify((err as DiscordErrorData).message, null, 2)}`); + + if ((err as DiscordErrorData).message.includes('time')) { + // When the modal is closed, re-enable the button on the embed + const buttonRow1 = interaction.message.components[0].toJSON() as APIActionRowComponent; + const buttonData1 = buttonRow1.components.find(field => field.custom_id.split('~')[1] === command); + if (buttonData1) { + // log.debug(F, `buttonData: ${JSON.stringify(buttonData1, null, 2)}`); + + const updatedButton = { + custom_id: buttonData1.custom_id, + label: buttonData1.label, + emoji: buttonData1.emoji, + style: buttonData1.style, + type: buttonData1.type, + disabled: false, + }; + + const index = buttonRow1.components.findIndex(field => field.custom_id.split('~')[1] === command); + buttonRow1.components.splice(index, 1, updatedButton); + + await interaction.message.edit({ + components: [buttonRow1], + }); + } + } + }); +} export const mod: SlashCommand = { data: new SlashCommandBuilder() - .setName('mod') + .setName('moderate') .setDescription('Moderation actions!') .addSubcommand(subcommand => subcommand - .setDescription('Info on a user') - .addStringOption(option => option - .setName('target') - .setDescription('User to get info on!') - .setRequired(true)) - .setName('info')) - .addSubcommand(subcommand => subcommand - .setDescription('Ban a user') - .addStringOption(option => option - .setName('target') - .setDescription('User to ban!') - .setRequired(true)) - .addStringOption(option => option - .setName('type') - .setDescription('Type of ban') - .setRequired(true) - .addChoices( - { name: 'Full Ban', value: 'FULL_BAN' }, - { name: 'Ticket Ban', value: 'TICKET_BAN' }, - { name: 'Discord Bot Ban', value: 'DISCORD_BOT_BAN' }, - { name: 'Ban Evasion', value: 'BAN_EVASION' }, - { name: 'Underban', value: 'UNDERBAN' }, - { name: 'Helper Ban', value: 'HELPER_BAN' }, - { name: 'Contributor Ban', value: 'CONTRIBUTOR_BAN' }, - )) - .addStringOption(option => option - .setName('toggle') - .setDescription('On or off? (Default: ON)') - .addChoices( - { name: 'On', value: 'ON' }, - { name: 'Off', value: 'OFF' }, - )) - .setName('ban')) - .addSubcommand(subcommand => subcommand - .setDescription('Warn a user') - .addStringOption(option => option - .setName('target') - .setDescription('User to warn!') - .setRequired(true)) - .setName('warning')) - .addSubcommand(subcommand => subcommand - .setDescription('Report a user') - .addStringOption(option => option - .setName('target') - .setDescription('User to report!') - .setRequired(true)) - .setName('report')) - .addSubcommand(subcommand => subcommand - .setDescription('Create a note about a user') - .addStringOption(option => option - .setName('target') - .setDescription('User to note about!') - .setRequired(true)) - .setName('note')) - .addSubcommand(subcommand => subcommand - .setDescription('Timeout a user') - .addStringOption(option => option - .setName('target') - .setDescription('User to timeout!') - .setRequired(true)) - .addStringOption(option => option - .setName('toggle') - .setDescription('On or off? (Default: ON)') - .addChoices( - { name: 'On', value: 'ON' }, - { name: 'Off', value: 'OFF' }, - )) - .setName('timeout')) - .addSubcommand(subcommand => subcommand - .setDescription('Kick a user') - .addStringOption(option => option - .setName('target') - .setDescription('User to kick!') - .setRequired(true)) - .setName('kick')) - .addSubcommand(subcommand => subcommand - .setDescription('Link user to an existing thread') + .setDescription('Link one user to another.') .addStringOption(option => option .setName('target') .setDescription('User to link!') .setRequired(true)) .addBooleanOption(option => option .setName('override') - .setDescription('Override existing threads in the DB')) + .setDescription('Override existing threads in the DB.')) .setName('link')), - async execute(interaction:ChatInputCommandInteraction) { + async execute(interaction: ChatInputCommandInteraction) { log.info(F, await commandContext(interaction)); + const command = interaction.options.getSubcommand() as ModAction; - const actor = interaction.member as GuildMember; - const targetString = interaction.options.getString('target', true); - const targets = await getDiscordMember(interaction, targetString) as GuildMember[]; + if (!interaction.guild) { + await interaction.reply({ + embeds: [embedTemplate() + .setColor(Colors.Red) + .setTitle('This command can only be used in a server!')], + ephemeral: true, + }); + return false; + } - if (targets.length > 1) { - const embed = embedTemplate() - .setColor(Colors.Red) - .setTitle('Found more than one user with with that value!') - .setDescription(stripIndents` - "${targetString}" returned ${targets.length} results! - - Be more specific: - > **Mention:** @Moonbear - > **Tag:** moonbear#1234 - > **ID:** 9876581237 - > **Nickname:** MoonBear`); + // Check if the guild is a partner (or the home guild) + const guildData = await db.discord_guilds.upsert({ + where: { + id: interaction.guild.id, + }, + create: { + id: interaction.guild.id, + }, + update: { + }, + }); + + if (!guildData.cooperative) { await interaction.reply({ - embeds: [embed], + embeds: [ + embedTemplate() + .setDescription(cooperativeExplanation) + .setColor(Colors.Red), + ], ephemeral: true, }); return false; } - if (targets.length === 0) { - const embed = embedTemplate() - .setColor(Colors.Red) - .setTitle('Found no users with that value!') - .setDescription(stripIndents` - "${interaction.options.getString('user', true)}" returned no results! + if (isLink(command)) { + const targetString = interaction.options.getString('target', true); + const targets = await getDiscordMember(interaction, targetString) as GuildMember[]; + const override = interaction.options.getBoolean('override'); + if (targets.length > 1) { + const embed = embedTemplate() + .setColor(Colors.Red) + .setTitle('Found more than one user with with that value!') + .setDescription(stripIndents` + "${targetString}" returned ${targets.length} results! Be more specific: > **Mention:** @Moonbear > **Tag:** moonbear#1234 > **ID:** 9876581237 > **Nickname:** MoonBear`); - await interaction.reply({ - embeds: [embed], - ephemeral: true, - }); - return false; - } - - // This needs to also be a User because we can ban users who are not in the guild - let target = targets[0] as GuildMember | User; - - let command = interaction.options.getSubcommand().toUpperCase() as ModAction; - if (command === 'BAN') { - command = interaction.options.getString('type', true) as ModAction; - } - - if (command === 'LINK') { - if (!interaction.channel?.isThread() - || !interaction.channel.parentId - || interaction.channel.parentId !== env.CHANNEL_MODERATORS) { await interaction.reply({ - content: 'This command can only be run inside of a mod thread!', + embeds: [embed], + ephemeral: true, + }); + return false; + } + if (targets.length === 0) { + const embed = embedTemplate() + .setColor(Colors.Red) + .setTitle(`${targetString}" returned no results!`) + .setDescription(stripIndents` + Be more specific: + > **Mention:** @Moonbear + > **Tag:** moonbear#1234 + > **ID:** 9876581237 + > **Nickname:** MoonBear`); + await interaction.reply({ + embeds: [embed], ephemeral: true, }); return false; } - const override = interaction.options.getBoolean('override'); + const target = targets[0]; let result: string | null; if (!target) { @@ -194,15 +2081,16 @@ export const mod: SlashCommand = { create: { discord_id: targetString, }, - update: {}, + update: { + }, }); if (!userData) { await interaction.reply({ content: stripIndents`Failed to link thread, I could not find this user in the guild, \ -and they do not exist in the database!`, + and they do not exist in the database!`, ephemeral: true, - }); // eslint-disable-line max-len + }); return false; } result = await linkThread(targetString, interaction.channelId, override); @@ -211,7 +2099,7 @@ and they do not exist in the database!`, } if (result === null) { - await interaction.reply({ content: 'Successfully linked thread!' }); + await interaction.editReply({ content: 'Successfully linked thread!' }); } else { const existingThread = await interaction.client.channels.fetch(result); await interaction.reply({ @@ -220,232 +2108,11 @@ and they do not exist in the database!`, ephemeral: true, }); } - - return true; } - if (!target && command !== 'FULL_BAN') { - const embed = embedTemplate() - .setColor(Colors.Red) - .setTitle('Could not find that member/user!') - .setDescription(stripIndents` - "${targetString}" returned no results! - - Try again with: - > **Mention:** @Moonbear - > **Tag:** moonbear#1234 - > **ID:** 9876581237 - > **Nickname:** MoonBear`); - await interaction.reply({ - embeds: [embed], - ephemeral: true, - }); - return false; - } - - if (!target && command === 'FULL_BAN') { - // Look up the user and use that as the target - const discordUserData = await getDiscordUser(targetString); - if (!discordUserData) { - const embed = embedTemplate() - .setColor(Colors.Red) - .setTitle('Could not find that member/user!') - .setDescription(stripIndents` - "${targetString}" returned no results! - - Try again with: - > **Mention:** @Moonbear - > **Tag:** moonbear#1234 - > **ID:** 9876581237 - > **Nickname:** MoonBear`); - await interaction.reply({ - embeds: [embed], - ephemeral: true, - }); - return false; - } - target = discordUserData; - } - - const toggleCommands = 'FULL_BAN, TICKET_BAN, DISCORD_BOT_BAN, BAN_EVASION, UNDERBAN, TIMEOUT'; - // If the command is ban or timeout, get the value of toggle. If it's null, set it to 'ON' - const toggle = toggleCommands.includes(command) - ? interaction.options.getString('toggle') ?? 'ON' - : null; - - if (toggle === 'OFF' && toggleCommands.includes(command)) { - command = `UN-${command}` as ModAction; - } - - log.debug(F, `${actor} ran ${command} on ${target}`); - - // log.debug(F, `${actor} ran ${command} on ${target}`); - - let verb = ''; - if (command === 'NOTE') verb = 'noting'; - // else if (command === 'REPORT') verb = 'reporting'; - else if (command === 'INFO') verb = 'getting info on'; - else if (command === 'WARNING') verb = 'warning'; - else if (command === 'KICK') verb = 'kicking'; - else if (command === 'TIMEOUT') verb = 'timing out'; - else if (command === 'FULL_BAN') verb = 'banning'; - else if (command === 'TICKET_BAN') verb = 'ticket banning'; - else if (command === 'DISCORD_BOT_BAN') verb = 'discord bot banning'; - else if (command === 'BAN_EVASION') verb = 'evasion banning'; - else if (command === 'UNDERBAN') verb = 'underbanning'; - else if (command === 'CONTRIBUTOR_BAN') verb = 'banning from Contributor on'; - else if (command === 'HELPER_BAN') verb = 'banning from Helper on'; - else if (command === 'UN-HELPER_BAN') verb = 'allowing Helper on '; - else if (command === 'UN-CONTRIBUTOR_BAN') verb = 'allowing Contributor on '; - else if (command === 'UN-TIMEOUT') verb = 'removing timeout on'; - else if (command === 'UN-FULL_BAN') verb = 'removing ban on'; - else if (command === 'UN-TICKET_BAN') verb = 'removing ticket ban on'; - else if (command === 'UN-DISCORD_BOT_BAN') verb = 'removing bot ban on'; - else if (command === 'UN-BAN_EVASION') verb = 'removing ban evasion on'; - else if (command === 'UN-UNDERBAN') verb = 'removing underban on'; - - // log.debug(F, `Verb: ${verb}`); - - if (command === 'INFO') { - log.debug(F, 'INFO command, deferring reply (ephemeral)'); - await interaction.deferReply({ ephemeral: true }); - await interaction.editReply(await moderate( - actor, - 'INFO', - target.id, - null, - null, - null, - )); - return true; - } - - const modal = new ModalBuilder() - .setCustomId(`modModal~${command}~${interaction.id}`) - .setTitle(`Tripbot ${command}`) - .addComponents(new ActionRowBuilder() - .addComponents(new TextInputBuilder() - .setLabel(`Why are you ${verb} this user?`) - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Tell other moderators why you\'re doing this') - .setMaxLength(1000) - .setRequired(true) - .setCustomId('internalNote'))); - - // All commands except INFO, NOTE and REPORT can have a public reason sent to the user - if (!'INFO NOTE REPORT'.includes(command)) { - modal.addComponents(new ActionRowBuilder() - .addComponents(new TextInputBuilder() - .setLabel('What should we tell the user?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Tell the user why you\'re doing this') - .setMaxLength(1000) - .setRequired(command === 'WARNING') - .setCustomId('description'))); - } - // Only timeout and full ban can have a duration, but they're different, so separate. - if (command === 'TIMEOUT') { - modal.addComponents(new ActionRowBuilder() - .addComponents(new TextInputBuilder() - .setLabel('Timeout for how long?') - .setStyle(TextInputStyle.Short) - .setPlaceholder('4 days 3hrs 2 mins 30 seconds (Max 7 days, Default 7 days)') - .setRequired(false) - .setCustomId('duration'))); - } - if (command === 'FULL_BAN') { - modal.addComponents(new ActionRowBuilder() - .addComponents(new TextInputBuilder() - .setLabel('How many days of msg to remove?') - .setStyle(TextInputStyle.Short) - .setPlaceholder('4 days 3hrs 2 mins 30 seconds (Max 7 days, Default 0 days)') - .setRequired(false) - .setCustomId('days'))); - } - - await interaction.showModal(modal); - - const filter = (i:ModalSubmitInteraction) => i.customId.startsWith('modModal'); - interaction.awaitModalSubmit({ filter, time: 0 }) - .then(async i => { - if (i.customId.split('~')[2] !== interaction.id) return; - await i.deferReply({ ephemeral: true }); - const internalNote = i.fields.getTextInputValue('internalNote'); // eslint-disable-line - - // Only these commands actually have the description input, so only pull it if it exists - const description = 'WARNING, KICK, TIMEOUT, FULL_BAN'.includes(command) // eslint-disable-line - ? i.fields.getTextInputValue('description') - : null; - - let duration = null; - if ('FULL_BAN, BAN_EVASION, UNDERBAN'.includes(command)) { - // If the command is ban, then the input value exists, so pull that and try to parse it as an int - let dayInput = parseInt(i.fields.getTextInputValue('days'), 10); - - // If no input was provided, default to 0 days - if (Number.isNaN(dayInput)) dayInput = 0; - - // If the input is a string, or outside the bounds, tell the user and return - if (dayInput && (dayInput < 0 || dayInput > 7)) { - await i.editReply({ content: 'Ban days must be at least 0 and at most 7!' }); - return; - } - - // Get the millisecond value of the input - const days = await parseDuration(`${dayInput} days`); - // log.debug(F, `days: ${days}`); - duration = days; - } - - if (command === 'TIMEOUT') { - // If the command is timeout get the value - let timeoutInput = i.fields.getTextInputValue('duration'); - - // If the value is blank, set it to 7 days, the maximum - if (timeoutInput === '') timeoutInput = '7 days'; - - if (timeoutInput.length === 1) { - // If the input is a single number, assume it's days - const numberInput = parseInt(timeoutInput, 10); - if (Number.isNaN(numberInput)) { - await i.editReply({ content: 'Timeout must be a number!' }); - return; - } - if (numberInput < 0 || numberInput > 7) { - await i.editReply({ content: 'Timeout must be between 0 and 7 days' }); - return; - } - timeoutInput = `${timeoutInput} days`; - } - - // log.debug(F, `timeoutInput: ${timeoutInput}`); - - const timeout = timeoutInput !== null - ? await parseDuration(timeoutInput) - : null; - - // If timeout is not null, but is outside the bounds, tell the user and return - if (timeout && (timeout < 0 || timeout > 7 * 24 * 60 * 60 * 1000)) { - await i.editReply({ content: 'Timeout must be between 0 and 7 days' }); - return; - } - - // log.debug(F, `timeout: ${timeout}`); - duration = timeout; - } - - await i.editReply(await moderate( - actor, - i.customId.split('~')[1] as user_action_type, - target.id, - internalNote, - description, - duration, - )); - // i.editReply({ embeds: [embedTemplate()] }); // For testing - }); + await interaction.reply(await modResponse(interaction, command, true)); - return false; + return true; }, }; diff --git a/src/discord/commands/guild/d.report.ts b/src/discord/commands/guild/d.report.ts index 1415d2521..70e3d53ea 100644 --- a/src/discord/commands/guild/d.report.ts +++ b/src/discord/commands/guild/d.report.ts @@ -1,19 +1,11 @@ -import { env } from 'process'; import { SlashCommandBuilder, ChatInputCommandInteraction, GuildMember, - Colors, } from 'discord.js'; -import { stripIndents } from 'common-tags'; -import { user_action_type } from '@prisma/client'; import { SlashCommand } from '../../@types/commandDef'; import commandContext from '../../utils/context'; -// import {embedTemplate} from '../../utils/embedTemplate'; -import { moderate } from '../../../global/commands/g.moderate'; -// import log from '../../../global/utils/log'; -import { getDiscordMember } from '../../utils/guildMemberLookup'; -import { embedTemplate } from '../../utils/embedTemplate'; +import { modResponse } from './d.moderate'; const F = f(__filename); @@ -24,85 +16,31 @@ export const dReport: SlashCommand = { .addStringOption(option => option .setDescription('User to report!') .setRequired(true) - .setName('target')) - .addStringOption(option => option - .setDescription('Reason for reporting!') - .setMaxLength(1000) - .setRequired(true) - .setName('reason')), + .setName('target')), async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.guild) return false; log.info(F, await commandContext(interaction)); await interaction.deferReply({ ephemeral: true }); - if (!interaction.guild) { - await interaction.editReply({ - embeds: [embedTemplate() - .setColor(Colors.Red) - .setTitle('This command can only be used in a server!')], - }); - return false; - } - - // Only run on tripsit - if (interaction.guild.id !== env.DISCORD_GUILD_ID) { - await interaction.editReply({ content: 'This command can only be used in the Tripsit server!' }); - return false; - } - - const targetString = interaction.options.getString('target', true); - const reason = interaction.options.getString('reason', true); - - const targets = await getDiscordMember(interaction, targetString); - - if (!targets) { - const embed = embedTemplate() - .setColor(Colors.Red) - .setTitle('Could not find that member/user!') - .setDescription(stripIndents` - "${targetString}" returned no results! - - Try again with: - > **Mention:** @Moonbear - > **Tag:** moonbear#1234 - > **ID:** 9876581237 - > **Nickname:** MoonBear`); - await interaction.editReply({ - embeds: [embed], - }); - return false; - } - - if (targets.length > 1) { - const embed = embedTemplate() - .setColor(Colors.Red) - .setTitle('Found more than one user with with that value!') - .setDescription(stripIndents` - "${targetString}" returned ${targets.length} results! - - Be more specific: - > **Mention:** @Moonbear - > **Tag:** moonbear#1234 - > **ID:** 9876581237 - > **Nickname:** MoonBear`); - await interaction.editReply({ - embeds: [embed], - }); - return false; - } - - const [target] = targets; - - const result = await moderate( - interaction.member as GuildMember, - 'REPORT' as user_action_type, - target.id, - reason, - null, - null, - ); - // log.debug(F, `Result: ${result}`); - await interaction.editReply(result); + // Get the guild + const { guild } = interaction; + const guildData = await db.discord_guilds.upsert({ + where: { + id: guild.id, + }, + create: { + id: guild.id, + }, + update: { + }, + }); + + // Get the actor + const actor = interaction.member as GuildMember; + // Determine if the actor is a mod + const actorIsMod = (!!guildData.role_moderator && actor.roles.cache.has(guildData.role_moderator)); + await interaction.editReply(await modResponse(interaction, 'REPORT', actorIsMod)); return true; }, }; diff --git a/src/discord/commands/guild/m.note.ts b/src/discord/commands/guild/m.note.ts deleted file mode 100644 index 3773149e4..000000000 --- a/src/discord/commands/guild/m.note.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - ActionRowBuilder, - ModalBuilder, - TextInputBuilder, - ContextMenuCommandBuilder, - GuildMember, - ModalSubmitInteraction, -} from 'discord.js'; -import { - ApplicationCommandType, - TextInputStyle, -} from 'discord-api-types/v10'; -import { stripIndents } from 'common-tags'; -import { user_action_type } from '@prisma/client'; -import { MessageCommand } from '../../@types/commandDef'; -import commandContext from '../../utils/context'; -// import log from '../../../global/utils/log'; -import { moderate } from '../../../global/commands/g.moderate'; - -const F = f(__filename); - -export const mNote: MessageCommand = { - data: new ContextMenuCommandBuilder() - .setName('Note') - .setType(ApplicationCommandType.Message), - async execute(interaction) { - log.info(F, await commandContext(interaction)); - await interaction.showModal(new ModalBuilder() - .setCustomId(`noteModal~${interaction.id}`) - .setTitle('Tripbot Note') - .addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('What are you noting about this person?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Tell the team why you are noting this user.') - .setMaxLength(1000) - .setRequired(true) - .setCustomId('internalNote')))); - const filter = (i:ModalSubmitInteraction) => i.customId.includes('noteModal'); - interaction.awaitModalSubmit({ filter, time: 0 }) - .then(async i => { - if (i.customId.split('~')[1] !== interaction.id) return; - await i.deferReply({ ephemeral: true }); - await i.editReply(await moderate( - interaction.member as GuildMember, - 'NOTE' as user_action_type, - interaction.targetMessage.member?.id ?? interaction.targetMessage.author.id, - stripIndents` - ${i.fields.getTextInputValue('internalNote')} - - **The offending message** - > ${interaction.targetMessage.cleanContent} - ${interaction.targetMessage.url} - `, - null, - null, - )); - }); - return true; - }, -}; - -export default mNote; diff --git a/src/discord/commands/guild/m.report.ts b/src/discord/commands/guild/m.report.ts index 66e64f856..417dba3ca 100644 --- a/src/discord/commands/guild/m.report.ts +++ b/src/discord/commands/guild/m.report.ts @@ -1,61 +1,44 @@ import { - ActionRowBuilder, - ModalBuilder, - TextInputBuilder, - ContextMenuCommandBuilder, - GuildMember, - ModalSubmitInteraction, + ContextMenuCommandBuilder, GuildMember, } from 'discord.js'; import { ApplicationCommandType, - TextInputStyle, } from 'discord-api-types/v10'; -import { stripIndents } from 'common-tags'; -import { user_action_type } from '@prisma/client'; import { MessageCommand } from '../../@types/commandDef'; -// import log from '../../../global/utils/log'; -import { moderate } from '../../../global/commands/g.moderate'; import commandContext from '../../utils/context'; +import { modResponse } from './d.moderate'; const F = f(__filename); export const mReport: MessageCommand = { data: new ContextMenuCommandBuilder() - .setName('Report') + .setName('Report Message') .setType(ApplicationCommandType.Message), async execute(interaction) { + if (!interaction.guild) return false; log.info(F, await commandContext(interaction)); - await interaction.showModal(new ModalBuilder() - .setCustomId(`reportModal~${interaction.id}`) - .setTitle('Tripbot Report') - .addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('Why are you reporting this?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Please be descriptive!') - .setMaxLength(1000) - .setRequired(true) - .setCustomId('internalNote')))); - const filter = (i:ModalSubmitInteraction) => i.customId.includes('reportModal'); - interaction.awaitModalSubmit({ filter, time: 0 }) - .then(async i => { - if (i.customId.split('~')[1] !== interaction.id) return; - await i.deferReply({ ephemeral: true }); - // log.debug(F, `Result: ${result}`); - await i.editReply(await moderate( - interaction.member as GuildMember, - 'REPORT' as user_action_type, - interaction.targetMessage.member?.id ?? interaction.targetMessage.author.id, - stripIndents` - ${i.fields.getTextInputValue('internalNote')} - - **The offending message** - > ${interaction.targetMessage.cleanContent} - ${interaction.targetMessage.url} - `, - null, - null, - )); - }); + await interaction.deferReply({ ephemeral: true }); + + // Get the guild + const { guild } = interaction; + const guildData = await db.discord_guilds.upsert({ + where: { + id: guild.id, + }, + create: { + id: guild.id, + }, + update: { + }, + }); + + // Get the actor + const actor = interaction.member as GuildMember; + + // Determine if the actor is a mod + const actorIsMod = (!!guildData.role_moderator && actor.roles.cache.has(guildData.role_moderator)); + await interaction.editReply(await modResponse(interaction, 'REPORT', actorIsMod)); + return true; }, }; diff --git a/src/discord/commands/guild/m.timeout.ts b/src/discord/commands/guild/m.timeout.ts deleted file mode 100644 index 8c2cd979e..000000000 --- a/src/discord/commands/guild/m.timeout.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - ActionRowBuilder, - ModalBuilder, - TextInputBuilder, - ContextMenuCommandBuilder, - GuildMember, - ModalSubmitInteraction, -} from 'discord.js'; -import { - ApplicationCommandType, - TextInputStyle, -} from 'discord-api-types/v10'; -import { stripIndents } from 'common-tags'; -import { user_action_type } from '@prisma/client'; -import { MessageCommand } from '../../@types/commandDef'; -import { parseDuration } from '../../../global/utils/parseDuration'; -import commandContext from '../../utils/context'; -// import log from '../../../global/utils/log'; -import { moderate } from '../../../global/commands/g.moderate'; - -const F = f(__filename); - -export const mTimeout: MessageCommand = { - data: new ContextMenuCommandBuilder() - .setName('Timeout') - .setType(ApplicationCommandType.Message), - async execute(interaction) { - log.info(F, await commandContext(interaction)); - await interaction.showModal(new ModalBuilder() - .setCustomId(`timeoutModal~${interaction.id}`) - .setTitle('Tripbot Timeout') - .addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('Why are you timeouting this person?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Tell the team why you are timeouting this user.') - .setMaxLength(1000) - .setRequired(true) - .setCustomId('internalNote')), - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('What should we tell the user?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('This will be sent to the user!') - .setMaxLength(1000) - .setRequired(false) - .setCustomId('description')), - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('Timeout for how long? (Max/default 7 days)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('4 days 3hrs 2 mins 30 seconds') - .setRequired(false) - .setCustomId('timeoutDuration')), - )); - const filter = (i:ModalSubmitInteraction) => i.customId.includes('timeoutModal'); - interaction.awaitModalSubmit({ filter, time: 0 }) - .then(async i => { - if (i.customId.split('~')[1] !== interaction.id) return; - await i.deferReply({ ephemeral: true }); - const duration = i.fields.getTextInputValue('timeoutDuration') - ? await parseDuration(i.fields.getTextInputValue('timeoutDuration')) - : 604800000; - - if (duration > 604800000) { - await i.editReply('Timeout duration cannot be longer than 7 days.'); - return; - } - - await i.editReply(await moderate( - interaction.member as GuildMember, - 'TIMEOUT' as user_action_type, - interaction.targetMessage.member?.id ?? interaction.targetMessage.author.id, - stripIndents` - > ${i.fields.getTextInputValue('internalNote')} - - **The offending message** - > ${interaction.targetMessage.cleanContent} - ${interaction.targetMessage.url} - `, - i.fields.getTextInputValue('description'), - duration, - )); - }); - return true; - }, -}; - -export default mTimeout; diff --git a/src/discord/commands/guild/m.warn.ts b/src/discord/commands/guild/m.warn.ts deleted file mode 100644 index a1447079d..000000000 --- a/src/discord/commands/guild/m.warn.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - ActionRowBuilder, - ModalBuilder, - TextInputBuilder, - ContextMenuCommandBuilder, - GuildMember, - ModalSubmitInteraction, -} from 'discord.js'; -import { - ApplicationCommandType, - TextInputStyle, -} from 'discord-api-types/v10'; -import { stripIndents } from 'common-tags'; -import { user_action_type } from '@prisma/client'; -import { MessageCommand } from '../../@types/commandDef'; -// import log from '../../../global/utils/log'; -import { moderate } from '../../../global/commands/g.moderate'; -import commandContext from '../../utils/context'; - -const F = f(__filename); - -export const mWarn: MessageCommand = { - data: new ContextMenuCommandBuilder() - .setName('Warn') - .setType(ApplicationCommandType.Message), - async execute(interaction) { - log.info(F, await commandContext(interaction)); - await interaction.showModal(new ModalBuilder() - .setCustomId(`warnModal~${interaction.id}`) - .setTitle('Tripbot Warn') - .addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('Why are you warning this person?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Tell the team why you are warning this user.') - .setMaxLength(1000) - .setRequired(true) - .setCustomId('internalNote')), - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('What should we tell the user?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('This will be sent to the user!') - .setMaxLength(1000) - .setRequired(true) - .setCustomId('description')), - )); - const filter = (i:ModalSubmitInteraction) => i.customId.includes('warnModal'); - interaction.awaitModalSubmit({ filter, time: 0 }) - .then(async i => { - if (i.customId.split('~')[1] !== interaction.id) return; - await i.deferReply({ ephemeral: true }); - await i.editReply(await moderate( - interaction.member as GuildMember, - 'WARNING' as user_action_type, - interaction.targetMessage.member?.id ?? interaction.targetMessage.author.id, - stripIndents` - ${i.fields.getTextInputValue('internalNote')} - - **The offending message** - > ${interaction.targetMessage.cleanContent} - ${interaction.targetMessage.url} - `, - i.fields.getTextInputValue('description'), - null, - )); - }); - return true; - }, -}; - -export default mWarn; diff --git a/src/discord/commands/guild/u.ban.ts b/src/discord/commands/guild/u.ban.ts deleted file mode 100644 index 6fa77b3ef..000000000 --- a/src/discord/commands/guild/u.ban.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - ActionRowBuilder, - ModalBuilder, - TextInputBuilder, - ContextMenuCommandBuilder, - GuildMember, - ModalSubmitInteraction, -} from 'discord.js'; -import { - ApplicationCommandType, - TextInputStyle, -} from 'discord-api-types/v10'; -import { user_action_type } from '@prisma/client'; -import { parseDuration } from '../../../global/utils/parseDuration'; -import { UserCommand } from '../../@types/commandDef'; -// import log from '../../../global/utils/log'; -import { moderate } from '../../../global/commands/g.moderate'; -import commandContext from '../../utils/context'; - -const F = f(__filename); - -export const uBan: UserCommand = { - data: new ContextMenuCommandBuilder() - .setName('Ban') - .setType(ApplicationCommandType.User), - async execute(interaction) { - log.info(F, await commandContext(interaction)); - - log.debug(F, `interaction.targetId: ${interaction.targetId}`); - - await interaction.showModal(new ModalBuilder() - .setCustomId(`banModal~${interaction.id}`) - .setTitle('Tripbot Ban') - .addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('Why are you banning this user?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Tell the team why you are banning this user.') - .setMaxLength(1000) - .setRequired(true) - .setCustomId('internalNote')), - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('What should we tell the user?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('This will be sent to the user!') - .setMaxLength(1000) - .setRequired(false) - .setCustomId('description')), - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('How many days of msg to remove?') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Between 0 and 7 days (Default 0)') - .setRequired(false) - .setCustomId('duration')), - )); - const filter = (i:ModalSubmitInteraction) => i.customId.includes('banModal'); - interaction.awaitModalSubmit({ filter, time: 0 }) - .then(async i => { - if (i.customId.split('~')[1] !== interaction.id) return; - await i.deferReply({ ephemeral: true }); - const duration = i.fields.getTextInputValue('duration') - ? await parseDuration(i.fields.getTextInputValue('duration')) - : 0; - - if (duration > 604800000) { - await i.editReply('Cannot remove messages older than 7 days.'); - return; - } - - await i.editReply(await moderate( - interaction.member as GuildMember, - 'FULL_BAN' as user_action_type, - interaction.targetId, - i.fields.getTextInputValue('internalNote'), - i.fields.getTextInputValue('description'), - duration, - )); - }); - return true; - }, -}; - -export default uBan; diff --git a/src/discord/commands/guild/u.kick.ts b/src/discord/commands/guild/u.kick.ts deleted file mode 100644 index 4b6278690..000000000 --- a/src/discord/commands/guild/u.kick.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - ActionRowBuilder, - ModalBuilder, - TextInputBuilder, - ContextMenuCommandBuilder, - GuildMember, - ModalSubmitInteraction, -} from 'discord.js'; -import { - ApplicationCommandType, - TextInputStyle, -} from 'discord-api-types/v10'; -import { user_action_type } from '@prisma/client'; -import { UserCommand } from '../../@types/commandDef'; -// import log from '../../../global/utils/log'; -import { moderate } from '../../../global/commands/g.moderate'; -import commandContext from '../../utils/context'; - -const F = f(__filename); - -export const uKick: UserCommand = { - data: new ContextMenuCommandBuilder() - .setName('Kick') - .setType(ApplicationCommandType.User), - async execute(interaction) { - log.info(F, await commandContext(interaction)); - await interaction.showModal(new ModalBuilder() - .setCustomId(`kickModal~${interaction.id}`) - .setTitle('Tripbot Kick') - .addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('Why are you kicking this person?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Tell the team why you are kicking this user.') - .setMaxLength(1000) - .setRequired(true) - .setCustomId('internalNote')), - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('What should we tell the user?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('This will be sent to the user!') - .setMaxLength(1000) - .setRequired(false) - .setCustomId('description')), - )); - const filter = (i:ModalSubmitInteraction) => i.customId.includes('kickModal'); - interaction.awaitModalSubmit({ filter, time: 0 }) - .then(async i => { - if (i.customId.split('~')[1] !== interaction.id) return; - await i.deferReply({ ephemeral: true }); - await i.editReply(await moderate( - interaction.member as GuildMember, - 'KICK' as user_action_type, - (interaction.targetMember as GuildMember).id, - i.fields.getTextInputValue('internalNote'), - i.fields.getTextInputValue('description'), - null, - )); - }); - return true; - }, -}; - -export default uKick; diff --git a/src/discord/commands/guild/u.report.ts b/src/discord/commands/guild/u.report.ts new file mode 100644 index 000000000..777c3e75f --- /dev/null +++ b/src/discord/commands/guild/u.report.ts @@ -0,0 +1,45 @@ +import { + ContextMenuCommandBuilder, GuildMember, +} from 'discord.js'; +import { + ApplicationCommandType, +} from 'discord-api-types/v10'; +import { UserCommand } from '../../@types/commandDef'; +import commandContext from '../../utils/context'; +import { modResponse } from './d.moderate'; + +const F = f(__filename); + +export const uReport: UserCommand = { + data: new ContextMenuCommandBuilder() + .setName('Report User') + .setType(ApplicationCommandType.User), + async execute(interaction) { + if (!interaction.guild) return false; + log.info(F, await commandContext(interaction)); + await interaction.deferReply({ ephemeral: true }); + + // Get the guild + const { guild } = interaction; + const guildData = await db.discord_guilds.upsert({ + where: { + id: guild.id, + }, + create: { + id: guild.id, + }, + update: { + }, + }); + + // Get the actor + const actor = interaction.member as GuildMember; + + // Determine if the actor is a mod + const actorIsMod = (!!guildData.role_moderator && actor.roles.cache.has(guildData.role_moderator)); + await interaction.editReply(await modResponse(interaction, 'REPORT', actorIsMod)); + return true; + }, +}; + +export default uReport; diff --git a/src/discord/commands/guild/u.underban.ts b/src/discord/commands/guild/u.underban.ts deleted file mode 100644 index ce1606f66..000000000 --- a/src/discord/commands/guild/u.underban.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - ActionRowBuilder, - ModalBuilder, - TextInputBuilder, - ContextMenuCommandBuilder, - GuildMember, - ModalSubmitInteraction, -} from 'discord.js'; -import { - ApplicationCommandType, - TextInputStyle, -} from 'discord-api-types/v10'; -import { user_action_type } from '@prisma/client'; -import { UserCommand } from '../../@types/commandDef'; -// import log from '../../../global/utils/log'; -import { moderate } from '../../../global/commands/g.moderate'; -import commandContext from '../../utils/context'; -// import {startLog} from '../../utils/startLog'; -import { embedTemplate } from '../../utils/embedTemplate'; - -const F = f(__filename); - -export const uUnderban: UserCommand = { - data: new ContextMenuCommandBuilder() - .setName('Underban') - .setType(ApplicationCommandType.User), - async execute(interaction) { - log.info(F, await commandContext(interaction)); - await interaction.showModal(new ModalBuilder() - .setCustomId(`underbanModal~${interaction.id}`) - .setTitle('Tripbot Ban') - .addComponents( - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('Why are you underbanning this user?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('Tell the team why you are underbanning this user.') - .setRequired(true) - .setCustomId('internalNote')), - new ActionRowBuilder().addComponents(new TextInputBuilder() - .setLabel('What should we tell the user?') - .setStyle(TextInputStyle.Paragraph) - .setPlaceholder('This will be sent to the user!') - .setRequired(false) - .setCustomId('description')), - )); - const filter = (i:ModalSubmitInteraction) => i.customId.includes('underbanModal'); - interaction.awaitModalSubmit({ filter, time: 0 }) - .then(async i => { - if (i.customId.split('~')[1] !== interaction.id) return; - await i.deferReply({ ephemeral: true }); - const target = interaction.targetMember as GuildMember; - if (target) { - await i.editReply(await moderate( - interaction.member as GuildMember, - 'UNDERBAN' as user_action_type, - (interaction.targetMember as GuildMember).id, - i.fields.getTextInputValue('internalNote'), - i.fields.getTextInputValue('description'), - null, - )); - } else { - await i.editReply({ - embeds: [ - embedTemplate() - .setTitle('Error') - .setDescription('This user is not in the server!')], - }); - } - }); - return true; - }, -}; - -export default uUnderban; diff --git a/src/discord/events/buttonClick.ts b/src/discord/events/buttonClick.ts index 2d2c95a14..1d9659ee5 100644 --- a/src/discord/events/buttonClick.ts +++ b/src/discord/events/buttonClick.ts @@ -25,7 +25,7 @@ import { import { helperButton } from '../commands/global/d.setup'; import { appealAccept, appealReject } from '../utils/appeal'; import { mushroomPageOne, mushroomPageTwo } from '../commands/global/d.mushroom_info'; -import { aiModButton } from '../commands/global/d.ai'; +import { acknowledgeButton, modModal, refusalButton } from '../commands/guild/d.moderate'; const F = f(__filename); @@ -50,9 +50,21 @@ export async function buttonClick(interaction:ButtonInteraction, discordClient:C } } - if (buttonID.startsWith('aiMod')) { + if (buttonID.startsWith('moderate')) { // log.debug(F, 'aiMod button clicked'); - await aiModButton(interaction); + await modModal(interaction); + return; + } + + if (buttonID.startsWith('acknowledgeButton')) { + // log.debug(F, 'aiMod button clicked'); + await acknowledgeButton(interaction); + return; + } + + if (buttonID.startsWith('refusalButton')) { + // log.debug(F, 'aiMod button clicked'); + await refusalButton(interaction); return; } diff --git a/src/discord/events/guildBanAdd.ts b/src/discord/events/guildBanAdd.ts index c30567f19..7460f91d1 100644 --- a/src/discord/events/guildBanAdd.ts +++ b/src/discord/events/guildBanAdd.ts @@ -1,68 +1,206 @@ import { + ActionRowBuilder, + ButtonBuilder, + Colors, + Guild, + GuildAuditLogsEntry, PermissionResolvable, TextChannel, + ThreadChannel, } from 'discord.js'; import { AuditLogEvent, } from 'discord-api-types/v10'; +import { stripIndents } from 'common-tags'; import { GuildBanAddEvent, } from '../@types/eventDef'; -import { checkChannelPermissions, checkGuildPermissions } from '../utils/checkPermissions'; +import { checkGuildPermissions } from '../utils/checkPermissions'; +import { + modButtonBan, modButtonInfo, modButtonNote, modButtonTimeout, modButtonWarn, tripSitTrustScore, userInfoEmbed, +} from '../commands/guild/d.moderate'; const F = f(__filename); -// https://discordjs.guide/popular-topics/audit-logs.html#who-deleted-a-message - export const guildBanAdd: GuildBanAddEvent = { name: 'guildBanAdd', async execute(ban) { - // Only run on Tripsit, we don't want to snoop on other guilds ( Ν‘~ ΝœΚ– Ν‘Β°) - if (ban.guild.id !== env.DISCORD_GUILD_ID) return; - log.info(F, `Channel ${ban.user} was added.`); - - const perms = await checkGuildPermissions(ban.guild, [ - 'ViewAuditLog' as PermissionResolvable, - ]); - - if (!perms.hasPermission) { - const guildOwner = await ban.guild.fetchOwner(); - await guildOwner.send({ content: `Please make sure I can ${perms.permission} in ${ban.guild} so I can run ${F}!` }); // eslint-disable-line - log.error(F, `Missing permission ${perms.permission} in ${ban.guild}!`); - return; - } + log.info(F, `Ban ${ban.user} was added.`); - const fetchedLogs = await ban.guild.fetchAuditLogs({ - limit: 1, - type: AuditLogEvent.MemberBanAdd, + // Get all guilds in the database + const partnerGuildsData = await db.discord_guilds.findMany({ + where: { + cooperative: true, + }, }); + // log.debug(F, `There are ${partnerGuildsData.length} coop guilds.`); + // log.debug(F, `${JSON.stringify(partnerGuildsData, null, 2)}`); + // Get a list of partnered discord guild objects + const partnerGuilds = partnerGuildsData + .map(guild => discordClient.guilds.cache.get(guild.id)) + .filter(item => item !== undefined) as Guild[]; - // Since there's only 1 audit log entry in this collection, grab the first one - const creationLog = fetchedLogs.entries.first(); - - const channel = await discordClient.channels.fetch(env.CHANNEL_AUDITLOG) as TextChannel; - const channelPerms = await checkChannelPermissions(channel, [ - 'ViewChannel' as PermissionResolvable, - 'SendMessages' as PermissionResolvable, - ]); - if (!channelPerms.hasPermission) { - const guildOwner = await channel.guild.fetchOwner(); - await guildOwner.send({ content: `Please make sure I can ${channelPerms.permission} in ${channel} so I can run ${F}!` }); // eslint-disable-line - log.error(F, `Missing permission ${channelPerms.permission} in ${channel}!`); - return; - } + // log.debug(F, `Created an array of ${partnerGuilds.length} guilds.`); + // log.debug(F, `${JSON.stringify(partnerGuilds, null, 2)}`); + // Check how many guilds the member is in + const inPartnerGuilds = await Promise.all(partnerGuilds.map(async guild => { + if (!guild) return null; + try { + await guild.members.fetch(ban.user.id); + // log.debug(F, `User is in guild: ${guild.name}`); + return guild; + } catch (err:unknown) { + return null; + } + })); + // log.info(F, `inPartnerGuilds ${inPartnerGuilds.length}`); + // log.debug(F, `${JSON.stringify(inPartnerGuilds, null, 2)}`); - // Perform a coherence check to make sure that there's *something* - if (!creationLog) { - await channel.send(`${ban.user} was banned, but no relevant audit logs were found.`); - return; - } + // Filter out null values + const mutualGuilds = inPartnerGuilds.filter(item => item !== null); + log.info(F, `I share ${mutualGuilds.length} guilds with ${ban.user.tag}`); + + // If the user is in a partnered guild, alert the guild + if (mutualGuilds.length > 0) { + let banLog = {} as GuildAuditLogsEntry | undefined; // eslint-disable-line max-len + + const guildAuditPerms = await checkGuildPermissions(ban.guild, [ + 'ViewAuditLog' as PermissionResolvable, + ]); + if (guildAuditPerms.hasPermission) { + const auditLogs = await ban.guild.fetchAuditLogs({ type: AuditLogEvent.MemberBanAdd }); + // Go through each auditLogs and find the one that banned this user + banLog = auditLogs.entries.find( + entry => entry.target?.id === ban.user.id, + ); + } + + const targetData = await db.users.upsert({ + where: { + discord_id: ban.user.id, + }, + create: { + discord_id: ban.user.id, + }, + update: { + }, + }); + + const embed = await userInfoEmbed(null, ban.user.id, targetData, 'FULL_BAN', false); + + const trustScoreData = await tripSitTrustScore( + ban.user.id, + ); + + const trustScoreColors = { + 0: Colors.Purple, + 1: Colors.Blue, + 2: Colors.Green, + 3: Colors.Yellow, + 4: Colors.Orange, + 5: Colors.Red, + }; + + log.info(F, 'attemtping to send messages'); + await Promise.all(mutualGuilds.map(async guild => { + if (!guild) return; + // await sendCooperativeMessage( + // embed, + // [`${guild.id}`], + // ); + const guildData = await db.discord_guilds.findFirst({ + where: { + id: guild.id, + cooperative: true, + }, + }); + + if (!guildData) return; + + const member = await guild.members.fetch(ban.user.id); - const response = creationLog.executor - ? `Channel ${ban.user} was banned by ${creationLog.executor.tag}.` - : `Channel ${ban.user} was banned, but the audit log was inconclusive.`; + embed + .setColor(trustScoreColors[trustScoreData.trustScore as keyof typeof trustScoreColors]) + .setDescription(stripIndents`**Report on ${member}** - await channel.send(response); + **TripSit TrustScore: ${trustScoreData.trustScore}** + + **TripSit TrustScore Reasoning** + \`\`\`${trustScoreData.tsReasoning}\`\`\` + `); + + let modThread = null as ThreadChannel | null; + let modThreadMessage = `**${member.displayName} was banned from ${ban.guild.name}`; + + if (banLog) { + if (banLog.executor) modThreadMessage += ` by ${banLog.executor?.username}`; + if (banLog.reason) modThreadMessage += ` for "${banLog.reason}"`; + } + + modThreadMessage += ` <@&${guildData.role_moderator}>**`; + const emoji = 'πŸ‘‹'; + + if (targetData.mod_thread_id || trustScoreData.trustScore < guildData.trust_score_limit) { + log.debug(F, `Mod thread id exists: ${targetData.mod_thread_id}`); + // If the mod thread already exists, then they have previous reports, so we should try to update that thread + if (targetData.mod_thread_id) { + try { + modThread = await guild.channels.fetch(targetData.mod_thread_id) as ThreadChannel | null; + log.debug(F, 'Mod thread exists'); + } catch (err) { + log.debug(F, 'Mod thread does not exist'); + } + } + + const payload = { + content: modThreadMessage, + embeds: [embed], + components: [new ActionRowBuilder().addComponents( + modButtonNote(ban.user.id), + modButtonWarn(ban.user.id), + modButtonTimeout(ban.user.id), + modButtonBan(ban.user.id), + modButtonInfo(ban.user.id), + )], + }; + // If the thread still exists, send a message and update the name + if (modThread) { + await modThread.send(payload); + await modThread.setName(`${emoji}${modThread.name.substring(1)}`); + } else if (guildData.channel_moderators) { + // IF the thread doesn't exist, likely deleted, then create a new thread + const modChan = await discordClient.channels.fetch(guildData.channel_moderators) as TextChannel; + + modThread = await modChan.threads.create({ + name: `${emoji}| ${member.displayName}`, + autoArchiveDuration: 60, + }) as ThreadChannel; + + targetData.mod_thread_id = modThread.id; + await db.users.update({ + where: { + discord_id: member.id, + }, + data: { + mod_thread_id: modThread.id, + }, + }); + + await modThread.send(payload); + } + } + })); + + if (ban.guild.id === env.DISCORD_GUILD_ID) { + const channelAuditlog = await discordClient.channels.fetch(env.CHANNEL_AUDITLOG) as TextChannel; + + const response = banLog + ? `Channel ${ban.user} was banned from ${ban.guild.name} by ${banLog.executor?.tag} for ${banLog.reason}.` + : `Channel ${ban.user} was banned, but the audit log was inconclusive.`; + + await channelAuditlog.send(response); + } + } }, }; diff --git a/src/discord/events/guildMemberAdd.ts b/src/discord/events/guildMemberAdd.ts index 677895a53..04dda17b8 100644 --- a/src/discord/events/guildMemberAdd.ts +++ b/src/discord/events/guildMemberAdd.ts @@ -1,42 +1,63 @@ import { - time, Colors, - TextChannel, - UserResolvable, Collection, + GuildMember, + ThreadChannel, + TextChannel, + ActionRowBuilder, + ButtonBuilder, + DiscordErrorData, + PermissionResolvable, + GuildBan, } from 'discord.js'; import { stripIndents } from 'common-tags'; import { GuildMemberAddEvent, } from '../@types/eventDef'; -import { embedTemplate } from '../utils/embedTemplate'; +import { + modButtonBan, modButtonInfo, modButtonNote, modButtonTimeout, modButtonWarn, tripSitTrustScore, userInfoEmbed, +} from '../commands/guild/d.moderate'; +import { checkGuildPermissions } from '../utils/checkPermissions'; const F = f(__filename); +async function getInvite(member:GuildMember) { + const newInvites = await member.guild.invites.fetch(); + const cachedInvites = global.guildInvites.get(member.guild.id); + const invite = newInvites.find(i => i.uses > cachedInvites.get(i.code)); + let inviteInfo = 'Joined via the vanity url'; + if (invite && invite.inviter) { + const inviter = await member.guild.members.fetch(invite.inviter); + inviteInfo = `Joined via ${inviter.displayName}'s invite (${invite.code}-${invite.uses})`; + } + // log.debug(F, `inviteInfo: ${inviteInfo}`); + global.guildInvites.set( + member.guild.id, + new Collection(newInvites.map(inviteEntry => [inviteEntry.code, inviteEntry.uses])), + ); + return inviteInfo; +} + export const guildMemberAdd: GuildMemberAddEvent = { name: 'guildMemberAdd', async execute(member) { - // Only run on Tripsit, we don't want to snoop on other guilds ( Ν‘~ ΝœΚ– Ν‘Β°) - if (member.guild.id !== env.DISCORD_GUILD_ID) return; - log.info(F, `${member} joined guild: ${member.guild.name} (id: ${member.guild.id})`); - - const newInvites = await member.guild.invites.fetch(); - const cachedInvites = global.guildInvites.get(member.guild.id); - const invite = newInvites.find(i => i.uses > cachedInvites.get(i.code)); - let inviteInfo = ''; - if (invite) { - const inviter = await discordClient.users.fetch(invite.inviter?.id as UserResolvable); - inviteInfo = inviter - ? `Joined via ${inviter.tag}'s invite to ${invite.channel?.name} (${invite.code}-${invite.uses})` - : 'Joined via the vanity url'; - } - // log.debug(F, `inviteInfo: ${inviteInfo}`); - global.guildInvites.set( - member.guild.id, - new Collection(newInvites.map(inviteEntry => [inviteEntry.code, inviteEntry.uses])), - ); + log.debug(F, `${member} joined guild: ${member.guild.name} (id: ${member.guild.id})`); + // Get all guilds in the database - await db.users.upsert({ + const guildData = await db.discord_guilds.findFirst({ + where: { + id: member.guild.id, + cooperative: true, + }, + }); + + // log.debug(F, `guildData: ${JSON.stringify(guildData)}`); + + if (!guildData) return; + + const inviteString = await getInvite(member); + + const targetData = await db.users.upsert({ where: { discord_id: member.id, }, @@ -49,60 +70,132 @@ export const guildMemberAdd: GuildMemberAddEvent = { }, }); - // log.debug(F, `Date.now(): ${Date.now()}`); - // log.debug(F, `member.user.createdAt: ${member.user.createdAt.toString()}`); - - const diff = Math.abs(Date.now() - Date.parse(member.user.createdAt.toString())); - // log.debug(F, `diff: ${diff}`); - const years = Math.floor(diff / (1000 * 60 * 60 * 24 * 365)); - const months = Math.floor(diff / (1000 * 60 * 60 * 24 * 30)); - const weeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7)); - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((diff % (1000 * 60)) / 1000); - let colorValue = 0; - if (years > 0) { - colorValue = Colors.White; - } else if (years === 0 && months > 0) { - colorValue = Colors.Purple; - } else if (months === 0 && weeks > 0) { - colorValue = Colors.Blue; - } else if (weeks === 0 && days > 0) { - colorValue = Colors.Green; - } else if (days === 0 && hours > 0) { - colorValue = Colors.Yellow; - } else if (hours === 0 && minutes > 0) { - colorValue = Colors.Orange; - } else if (minutes === 0 && seconds > 0) { - colorValue = Colors.Red; - } + const embed = await userInfoEmbed(member, member, targetData, 'NOTE', true); + + const trustScoreData = await tripSitTrustScore( + member.user.id, + ); + + log.debug(F, `trustScoreData: ${JSON.stringify(trustScoreData)}`); - const embed = embedTemplate() - .setAuthor(null) - .setColor(colorValue) - .setThumbnail(member.user.displayAvatarURL()) - .setFooter(null) - .setDescription(stripIndents`**${member} has joined the guild!**`) - .addFields( - { name: 'Nickname', value: `${member.nickname}`, inline: true }, - { name: 'Tag', value: `${member.user.username}#${member.user.discriminator}`, inline: true }, - { name: 'ID', value: `${member.user.id}`, inline: true }, - ) - .addFields( - { name: 'Account created', value: `${time(member.user.createdAt, 'R')}`, inline: true }, - ); - if (member.joinedAt) { - embed.addFields( - { name: 'Joined', value: `${time(member.joinedAt, 'R')}`, inline: true }, - ); + const trustScoreColors = { + 0: Colors.Purple, + 1: Colors.Blue, + 2: Colors.Green, + 3: Colors.Yellow, + 4: Colors.Orange, + 5: Colors.Red, + 6: Colors.Red, + }; + + embed + .setColor(trustScoreColors[trustScoreData.trustScore as keyof typeof trustScoreColors]) + .setDescription(stripIndents`**Report on ${member}** + + **TripSit TrustScore: ${trustScoreData.trustScore}** + + **TripSit TrustScore Reasoning** + \`\`\`${trustScoreData.tsReasoning}\`\`\` + `); + + embed.setFooter({ text: inviteString }); + + // if (trustScoreData.trustScore > 3) { + // await sendCooperativeMessage( + // embed, + // [`${member.guild.id}`], + // ); + // } + + let modThread = null as ThreadChannel | null; + let modThreadMessage = `**${member.displayName} has joined the guild!**`; + let emoji = 'πŸ‘‹'; + + if (trustScoreData.trustScore < guildData.trust_score_limit) { + modThreadMessage = `**${member.displayName} has joined the guild, their account is untrusted!** <@&${guildData.role_moderator}>`; + emoji = 'πŸ‘€'; } - if (inviteInfo) { - embed.setFooter({ text: inviteInfo }); + + const bannedTest = await Promise.all(discordClient.guilds.cache.map(async guild => { + // log.debug(F, `Checking guild: ${guild.name}`); + const guildPerms = await checkGuildPermissions(guild, [ + 'BanMembers' as PermissionResolvable, + ]); + + if (!guildPerms) { + return null; + } + + try { + return await guild.bans.fetch(member.id); + // log.debug(F, `User is banned in guild: ${guild.name}`); + // return guild.name; + } catch (err: unknown) { + if ((err as DiscordErrorData).code === 10026) { + // log.debug(F, `User is not banned in guild: ${guild.name}`); + return null; + } + // log.debug(F, `Error checking guild: ${guild.name}`); + return null; + } + })); + + // count how many 'banned' appear in the array + const bannedGuilds = bannedTest.filter(item => item) as GuildBan[]; + + if (targetData.mod_thread_id || trustScoreData.trustScore < guildData.trust_score_limit || bannedGuilds.length > 0) { + log.debug(F, `Mod thread id exists: ${targetData.mod_thread_id}`); + // If the mod thread already exists, then they have previous reports, so we should try to update that thread + if (targetData.mod_thread_id) { + try { + modThread = await member.guild.channels.fetch(targetData.mod_thread_id) as ThreadChannel | null; + log.debug(F, 'Mod thread exists'); + } catch (err) { + log.debug(F, 'Mod thread does not exist'); + } + } + + const payload = { + content: modThreadMessage, + embeds: [embed], + components: [new ActionRowBuilder().addComponents( + modButtonNote(member.id), + modButtonWarn(member.id), + modButtonTimeout(member.id), + modButtonBan(member.id), + modButtonInfo(member.id), + )], + }; + // If the thread still exists, send a message and update the name + if (modThread) { + await modThread.send(payload); + await modThread.setName(`${emoji}${modThread.name.substring(1)}`); + } else if (guildData.channel_moderators) { + // IF the thread doesn't exist, likely deleted, then create a new thread + const modChan = await discordClient.channels.fetch(guildData.channel_moderators) as TextChannel; + + modThread = await modChan.threads.create({ + name: `${emoji}| ${member.displayName}`, + autoArchiveDuration: 60, + }) as ThreadChannel; + + targetData.mod_thread_id = modThread.id; + await db.users.update({ + where: { + discord_id: member.id, + }, + data: { + mod_thread_id: modThread.id, + }, + }); + + await modThread.send(payload); + } } - const auditlog = await discordClient.channels.fetch(env.CHANNEL_AUDITLOG) as TextChannel; - if (auditlog) { - await auditlog.send({ embeds: [embed] }); + + if (guildData.channel_mod_log) { + const auditLog = await discordClient.channels.fetch(guildData.channel_mod_log) as TextChannel; + await auditLog.send({ embeds: [embed] }); } }, }; diff --git a/src/discord/events/guildMemberRemove.ts b/src/discord/events/guildMemberRemove.ts index cfc8035de..b2e08971a 100644 --- a/src/discord/events/guildMemberRemove.ts +++ b/src/discord/events/guildMemberRemove.ts @@ -1,6 +1,7 @@ import { Colors, TextChannel, + ThreadChannel, } from 'discord.js'; import { GuildMemberRemoveEvent, @@ -56,10 +57,7 @@ export const guildMemberRemove: GuildMemberRemoveEvent = { embed.setDescription(`${member} has left the guild`); } - const auditlog = await discordClient.channels.fetch(env.CHANNEL_AUDITLOG) as TextChannel; - await auditlog.send({ embeds: [embed] }); - - await db.users.upsert({ + const targetData = await db.users.upsert({ where: { discord_id: member.id, }, @@ -71,6 +69,37 @@ export const guildMemberRemove: GuildMemberRemoveEvent = { removed_at: new Date(), }, }); + + const guildData = await db.discord_guilds.upsert({ + where: { + id: member.guild.id, + }, + create: { + id: member.guild.id, + }, + update: {}, + }); + + let modThread = null as ThreadChannel | null; + if (targetData.mod_thread_id) { + // log.debug(F, `Mod thread id exists: ${targetData.mod_thread_id}`); + try { + modThread = await member.guild.channels.fetch(targetData.mod_thread_id) as ThreadChannel | null; + // log.debug(F, 'Mod thread exists'); + } catch (err) { + // log.debug(F, 'Mod thread does not exist'); + } + + if (modThread) { + await modThread.send({ embeds: [embed] }); + await modThread.setName(`🚢${modThread.name.substring(1)}`); + } + } + + if (guildData.channel_mod_log) { + const auditLog = await discordClient.channels.fetch(guildData.channel_mod_log) as TextChannel; + await auditLog.send({ embeds: [embed] }); + } }, }; diff --git a/src/discord/events/messageDelete.ts b/src/discord/events/messageDelete.ts index 9346e0808..921dadb6a 100644 --- a/src/discord/events/messageDelete.ts +++ b/src/discord/events/messageDelete.ts @@ -28,7 +28,7 @@ export const messageDelete: MessageDeleteEvent = { if (message.guild.id !== env.DISCORD_GUILD_ID) return; if (message.channel.type !== ChannelType.GuildText) return; const startTime = Date.now(); - log.info(F, `Message in ${message.channel.name} was deleted.`); + // log.info(F, `Message in ${message.channel.name} was deleted.`); // log.debug(F, `message: ${JSON.stringify(message, null, 2)}`); // Get the channel this will be posted in @@ -92,7 +92,7 @@ export const messageDelete: MessageDeleteEvent = { content = messageRecord.content; author = messageRecord.author; } else { - // log.debug(F, 'Message not found in cache'); + log.debug(F, 'Message not found in cache'); } } } diff --git a/src/discord/utils/embedTemplate.ts b/src/discord/utils/embedTemplate.ts index 5edff028d..3b59d95c8 100644 --- a/src/discord/utils/embedTemplate.ts +++ b/src/discord/utils/embedTemplate.ts @@ -1,6 +1,7 @@ import { EmbedBuilder, Colors, + APIEmbed, } from 'discord.js'; export default embedTemplate; @@ -9,7 +10,15 @@ export default embedTemplate; * Creates a template embed that can be used everywhere * @return {EmbedBuilder} */ -export function embedTemplate():EmbedBuilder { +export function embedTemplate( + data?:APIEmbed, +):EmbedBuilder { + if (data) { + return new EmbedBuilder(data) + .setAuthor({ name: 'TripSit.Me', iconURL: env.TS_ICON_URL, url: 'http://www.tripsit.me' }) + .setColor(Colors.Purple) + .setFooter({ text: env.DISCLAIMER, iconURL: env.FLAME_ICON_URL }); + } return new EmbedBuilder() .setAuthor({ name: 'TripSit.Me', iconURL: env.TS_ICON_URL, url: 'http://www.tripsit.me' }) .setColor(Colors.Purple) diff --git a/src/discord/utils/guildMemberLookup.ts b/src/discord/utils/guildMemberLookup.ts index abfc49f14..49ab5535c 100644 --- a/src/discord/utils/guildMemberLookup.ts +++ b/src/discord/utils/guildMemberLookup.ts @@ -1,13 +1,19 @@ import { + ButtonInteraction, ChatInputCommandInteraction, GuildMember, + MessageContextMenuCommandInteraction, User, + UserContextMenuCommandInteraction, } from 'discord.js'; const F = f(__filename); // eslint-disable-line @typescript-eslint/no-unused-vars export async function getDiscordMember( - interaction:ChatInputCommandInteraction, + interaction:ChatInputCommandInteraction + | UserContextMenuCommandInteraction + | MessageContextMenuCommandInteraction + | ButtonInteraction, string:string, ):Promise { const members = [] as GuildMember[]; @@ -53,32 +59,6 @@ export async function getDiscordMember( members.push(member); }); } - - // log.info(F, `members: ${members.length} #1 = ${members[0]?.displayName}`); - - // if (members.length > 1) { - // const embed = embedTemplate() - // .setColor(Colors.Red) - // .setTitle('Found more than one user with with that value!') - // .setDescription(stripIndents` - // "${string}" returned ${members.length} results! - - // Be more specific: - // > **Mention:** @Moonbear - // > **Tag:** moonbear#1234 - // > **ID:** 9876581237 - // > **Nickname:** MoonBear`); - // await interaction.reply({ - // embeds: [embed], - // ephemeral: true, - // }); - // return null; - // } - - // if (members.length === 0) { - // return null; - // } - return members; } diff --git a/src/global/commands/archive/d.moderate new.ts b/src/global/commands/archive/d.moderate new.ts new file mode 100644 index 000000000..e1d95bfef --- /dev/null +++ b/src/global/commands/archive/d.moderate new.ts @@ -0,0 +1,2447 @@ +import { + SlashCommandBuilder, + ChatInputCommandInteraction, + GuildMember, + ModalBuilder, + TextInputBuilder, + ActionRowBuilder, + ModalSubmitInteraction, + Colors, + User, + time, + ButtonBuilder, + TextChannel, + Role, + EmbedBuilder, + ThreadChannel, + MessageComponentInteraction, + Message, + Guild, + DiscordAPIError, + UserContextMenuCommandInteraction, + MessageContextMenuCommandInteraction, + PermissionResolvable, + GuildBan, + ButtonInteraction, + APIEmbedField, +} from 'discord.js'; +import { + ButtonStyle, + TextInputStyle, +} from 'discord-api-types/v10'; +import { stripIndents } from 'common-tags'; +import { user_action_type, user_actions, users } from '@prisma/client'; +import { SlashCommand } from '../../../discord/@types/commandDef'; +import { parseDuration } from '../../utils/parseDuration'; +import commandContext from '../../../discord/utils/context'; // eslint-disable-line +import { getDiscordMember, getDiscordUser } from '../../../discord/utils/guildMemberLookup'; +import { last } from '../g.last'; +import { botBannedUsers } from '../../../discord/utils/populateBotBans'; +import { embedTemplate } from '../../../discord/utils/embedTemplate'; +import { checkGuildPermissions } from '../../../discord/utils/checkPermissions'; + +const F = f(__filename); + +type ModAction = 'INFO' | 'BAN' | 'WARNING' | 'REPORT' | 'NOTE' | 'TIMEOUT' | 'UN-CONTRIBUTOR_BAN' | 'UN-HELPER_BAN' | +'FULL_BAN' | 'TICKET_BAN' | 'DISCORD_BOT_BAN' | 'BAN_EVASION' | 'UNDERBAN' | 'HELPER_BAN' | 'CONTRIBUTOR_BAN' | 'LINK' | +'UN-FULL_BAN' | 'UN-TICKET_BAN' | 'UN-DISCORD_BOT_BAN' | 'UN-BAN_EVASION' | 'UN-UNDERBAN' | 'UN-TIMEOUT' | 'KICK'; + +type BanAction = 'FULL_BAN' | 'TICKET_BAN' | 'DISCORD_BOT_BAN' | 'BAN_EVASION' | 'UNDERBAN'; +type UnBanAction = 'UN-FULL_BAN' | 'UN-TICKET_BAN' | 'UN-DISCORD_BOT_BAN' | 'UN-BAN_EVASION' | 'UN-UNDERBAN'; +const embedVariables = { + NOTE: { + embedColor: Colors.Yellow, + embedTitle: 'Note!', + pastVerb: 'noted', + presentVerb: 'noting', + }, + WARNING: { + embedColor: Colors.Yellow, + embedTitle: 'Warned!', + pastVerb: 'warned', + presentVerb: 'warning', + }, + FULL_BAN: { + embedColor: Colors.Red, + embedTitle: 'Banned!', + pastVerb: 'banned', + presentVerb: 'banning', + }, + 'UN-FULL_BAN': { + embedColor: Colors.Green, + embedTitle: 'Un-banned!', + pastVerb: 'un-banned', + presentVerb: 'un-banning', + }, + TICKET_BAN: { + embedColor: Colors.Red, + embedTitle: 'Ticket Banned!', + pastVerb: 'banned from using tickets', + presentVerb: 'banning from using tickets', + }, + 'UN-TICKET_BAN': { + embedColor: Colors.Green, + embedTitle: 'Un-Ticket Banned!', + pastVerb: 'allowed to submit tickets again', + presentVerb: 'allowing to submit tickets again', + }, + DISCORD_BOT_BAN: { + embedColor: Colors.Red, + embedTitle: 'Discord Bot Banned!', + pastVerb: 'banned from using the Discord bot', + presentVerb: 'banning from using the Discord bot', + }, + 'UN-DISCORD_BOT_BAN': { + embedColor: Colors.Green, + embedTitle: 'Un-Discord Bot Banned!', + pastVerb: 'allowed to use the Discord bot again', + presentVerb: 'allowing to use the Discord bot again', + }, + HELPER_BAN: { + embedColor: Colors.Red, + embedTitle: 'Helper Role Banned!', + pastVerb: 'banned from using the Helper role', + presentVerb: 'banning from using the Helper role', + }, + 'UN-HELPER_BAN': { + embedColor: Colors.Green, + embedTitle: 'Un-Helper Role Banned!', + pastVerb: 'allowed to use the Helper role again', + presentVerb: 'allowing to use the Helper role again', + }, + CONTRIBUTOR_BAN: { + embedColor: Colors.Red, + embedTitle: 'Contributor Role Banned!', + pastVerb: 'banned from using the Contributor role', + presentVerb: 'banning from using the Contributor role', + }, + 'UN-CONTRIBUTOR_BAN': { + embedColor: Colors.Green, + embedTitle: 'Un-Contributor Role Banned!', + pastVerb: 'allowed to use the Contributor role again', + presentVerb: 'allowing to use the Contributor role again', + }, + BAN_EVASION: { + embedColor: Colors.Red, + embedTitle: 'Ban Evasion!', + pastVerb: 'banned for evasion', + presentVerb: 'banning for evasion', + }, + 'UN-BAN_EVASION': { + embedColor: Colors.Green, + embedTitle: 'Un-Ban Evasion!', + pastVerb: 'un-banned for evasion', + presentVerb: 'un-banning for evasion', + }, + UNDERBAN: { + embedColor: Colors.Red, + embedTitle: 'Underban!', + pastVerb: 'banned for being underage', + presentVerb: 'banning for being underage', + }, + 'UN-UNDERBAN': { + embedColor: Colors.Green, + embedTitle: 'Un-Underban!', + pastVerb: 'un-banned for being underage', + presentVerb: 'un-banning for being underage', + }, + TIMEOUT: { + embedColor: Colors.Yellow, + embedTitle: 'Timeout!', + pastVerb: 'timed out', + presentVerb: 'timing out', + }, + 'UN-TIMEOUT': { + embedColor: Colors.Green, + embedTitle: 'Untimeout!', + pastVerb: 'removed from time-out', + presentVerb: 'removing from time-out', + }, + KICK: { + embedColor: Colors.Orange, + embedTitle: 'Kicked!', + pastVerb: 'kicked', + presentVerb: 'kicking', + }, + REPORT: { + embedColor: Colors.Orange, + embedTitle: 'Report!', + pastVerb: 'reported', + presentVerb: 'reporting', + }, + INFO: { + embedColor: Colors.Green, + embedTitle: 'Info!', + pastVerb: 'got info on', + presentVerb: 'getting info on', + }, +}; + +const warnButtons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('acknowledgeButton') + .setLabel('I understand, it wont happen again!') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('refusalButton') + .setLabel('Nah, I do what I want!') + .setStyle(ButtonStyle.Danger), +); + +/* TODO: +Add unban messages + +replace all .env stuff + +Motion to users with votes +*/ + +const noReason = 'No reason provided'; +const internalNotePlaceholder = 'Tell other moderators why you\'re doing this'; +const descriptionLabel = 'What should we tell the user?'; +const descriptionPlaceholder = 'Tell the user why you\'re doing this'; +const mepWarning = 'You cannot use the word "MEP" here.'; +const cooperativeExplanation = stripIndents`This is a suite of moderation tools for guilds to use, \ +this includes the ability to ban, warn, report, and more! + +Currently these tools are only available to a limited number of partner guilds, \ +use /cooperative info for more information.`; +const noUserError = 'Could not find that member/user!'; + +export async function linkThread( + discordId: string, + threadId: string, + override: boolean | null, +):Promise { + // Get the targetData from the db + const userData = await db.users.upsert({ + where: { + discord_id: discordId, + }, + create: { + discord_id: discordId, + }, + update: {}, + }); + + if (userData.mod_thread_id === null || override) { + // log.debug(F, `targetData.mod_thread_id is null, updating it`); + await db.users.update({ + where: { + id: userData.id, + }, + data: { + mod_thread_id: threadId, + }, + }); + return null; + } + // log.debug(F, `targetData.mod_thread_id is not null, not updating it`); + return userData.mod_thread_id; +} + +export async function userInfoEmbed( + target:GuildMember | User, + targetData:users, + command?: ModAction, +):Promise { + const targetActionList = { + NOTE: [] as string[], + WARNING: [] as string[], + REPORT: [] as string[], + TIMEOUT: [] as string[], + KICK: [] as string[], + FULL_BAN: [] as string[], + UNDERBAN: [] as string[], + TICKET_BAN: [] as string[], + DISCORD_BOT_BAN: [] as string[], + HELPER_BAN: [] as string[], + CONTRIBUTOR_BAN: [] as string[], + }; + // Populate targetActionList from the db + + // const targetActionListRaw = await database.actions.get(targetData.id); + const targetActionListRaw = await db.user_actions.findMany({ + where: { + user_id: targetData.id, + }, + }); + + // log.debug(F, `targetActionListRaw: ${JSON.stringify(targetActionListRaw, null, 2)}`); + + // for (const action of targetActionListRaw) { + targetActionListRaw.forEach(action => { + // log.debug(F, `action: ${JSON.stringify(action, null, 2)}`); + const actionString = `${action.type} (${time(action.created_at, 'R')}) - ${action.internal_note + ?? 'No note provided'}`; + // log.debug(F, `actionString: ${actionString}`); + targetActionList[action.type as keyof typeof targetActionList].push(actionString); + }); + + // log.debug(F, `targetActionList: ${JSON.stringify(targetActionList, null, 2)}`); + const displayName = (target as GuildMember).displayName ?? (target as User).username; + const tag = (target as GuildMember).user ? (target as GuildMember).user.tag : (target as User).tag; + const userAvatar = (target as GuildMember).user + ? (target as GuildMember).user.displayAvatarURL() + : (target as User).displayAvatarURL(); + const modlogEmbed = new EmbedBuilder() + .setFooter(null) + .setAuthor({ name: displayName }) + .setThumbnail(userAvatar) + .setColor(embedVariables[command as keyof typeof embedVariables].embedColor) + .addFields( + { name: tag, value: `${target.id}`, inline: true }, + { + name: 'Created', + value: `${time(((target as GuildMember).user + ?? (target as User)).createdAt, 'R')}`, + inline: true, + }, + { + name: 'Joined', + value: `${(target as GuildMember).joinedAt + ? time((target as GuildMember).joinedAt as Date, 'R') + : 'Unknown'}`, + inline: true, + }, + ); + if (targetActionList.NOTE.length > 0) { + modlogEmbed.addFields({ name: '# of Notes', value: `${targetActionList.NOTE.length}`, inline: true }); + } + if (targetActionList.WARNING.length > 0) { + modlogEmbed.addFields({ name: '# of Warns', value: `${targetActionList.WARNING.length}`, inline: true }); + } + if (targetActionList.REPORT.length > 0) { + modlogEmbed.addFields({ name: '# of Reports', value: `${targetActionList.REPORT.length}`, inline: true }); + } + if (targetActionList.TIMEOUT.length > 0) { + modlogEmbed.addFields({ name: '# of Timeouts', value: `${targetActionList.TIMEOUT.length}`, inline: true }); + } + if (targetActionList.KICK.length > 0) { + modlogEmbed.addFields({ name: '# of Kicks', value: `${targetActionList.KICK.length}`, inline: true }); + } + if (targetActionList.FULL_BAN.length > 0) { + modlogEmbed.addFields({ name: '# of Bans', value: `${targetActionList.FULL_BAN.length}`, inline: true }); + } + if (targetActionList.UNDERBAN.length > 0) { + modlogEmbed.addFields({ name: '# of Underbans', value: `${targetActionList.UNDERBAN.length}`, inline: true }); + } + + let infoString = stripIndents` + ${targetActionList.FULL_BAN.length > 0 ? `**Bans**\n${targetActionList.FULL_BAN.join('\n')}` : ''} + ${targetActionList.UNDERBAN.length > 0 ? `**Underbans**\n${targetActionList.UNDERBAN.join('\n')}` : ''} + ${targetActionList.KICK.length > 0 ? `**Kicks**\n${targetActionList.KICK.join('\n')}` : ''} + ${targetActionList.TIMEOUT.length > 0 ? `**Timeouts**\n${targetActionList.TIMEOUT.join('\n')}` : ''} + ${targetActionList.WARNING.length > 0 ? `**Warns**\n${targetActionList.WARNING.join('\n')}` : ''} + ${targetActionList.REPORT.length > 0 ? `**Reports**\n${targetActionList.REPORT.join('\n')}` : ''} + ${targetActionList.NOTE.length > 0 ? `**Notes**\n${targetActionList.NOTE.join('\n')}` : ''} + `; + if (infoString.length === 0) { + infoString = 'Squeaky clean!'; + } + // log.debug(F, `infoString: ${infoString}`); + modlogEmbed.setDescription(infoString); + + return modlogEmbed; +} + +export async function tripSitTrollScore( + target:User, +):Promise<{ + trollScore: number; + tsReasoning: string; + }> { + let trollScore = 0; + let tsReasoning = ''; + const errorUnknown = 'unknown-error'; + // const errorMember = 'unknown-member'; + const errorPermission = 'no-permission'; + + // Calculate how like it is that this user is a troll. + // This is based off of factors like, how old is their account, do they have a profile picture, etc. + const diff = Math.abs(Date.now() - Date.parse(target.createdAt.toString())); + const years = Math.floor(diff / (1000 * 60 * 60 * 24 * 365)); + const months = Math.floor(diff / (1000 * 60 * 60 * 24 * 30)); + const weeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7)); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + if (years > 0) { + trollScore += 0; + tsReasoning += '+0 | Account was created at least a year ago\n'; + } else if (years === 0 && months > 0) { + trollScore += 1; + tsReasoning += '+1 | Account was created months ago\n'; + } else if (months === 0 && weeks > 0) { + trollScore += 2; + tsReasoning += '+2 | Account was created weeks ago\n'; + } else if (weeks === 0 && days > 0) { + trollScore += 3; + tsReasoning += '+3 | Account was created days ago\n'; + } else if (days === 0 && hours > 0) { + trollScore += 4; + tsReasoning += '+4 | Account was created hours ago\n'; + } else if (hours === 0 && minutes > 0) { + trollScore += 5; + tsReasoning += '+5 | Account was created minutes ago\n'; + } else if (minutes === 0 && seconds > 0) { + trollScore += 6; + tsReasoning += '+6 | Account was created seconds ago\n'; + } + + if (target.avatarURL()) { + trollScore += 0; + tsReasoning += '+0 | Account has a profile picture\n'; + } else { + trollScore += 1; + tsReasoning += '+1 | Account does not have a profile picture\n'; + } + + if (target.bannerURL() !== null) { + trollScore += 0; + tsReasoning += '+0 | Account has a banner\n'; + } else { + trollScore += 1; + tsReasoning += '+1 | Account does not have a banner\n'; + } + + // let member = {} as GuildMember; + // try { + // member = await interaction.guild?.members.fetch(target.id) as GuildMember; + // } catch (err:unknown) { + // // if ((err as DiscordAPIError).code === 10007) { + // // // log.debug(F, 'User is not in guild'); + // // } else { + // // // log.error(F, `Error: ${err}`); + // // } + // } + + // if (member.premiumSince) { + // trollScore -= 1; + // tsReasoning += '-1 | Account is boosting the guild\n'; + // } else { + // trollScore += 0; + // tsReasoning += '+0 | Account is not boosting the guild\n'; + // } + + // Check how many guilds the member is in + await discordClient.guilds.fetch(); + const targetInGuilds = await Promise.all(discordClient.guilds.cache.map(async guild => { + try { + await guild.members.fetch(target.id); + // log.debug(F, `User is in guild: ${guild.name}`); + return guild; + } catch (err:unknown) { + return null; + } + })); + const mutualGuilds = targetInGuilds.filter(item => item !== null); + + if (mutualGuilds.length > 0) { + trollScore += 0; + tsReasoning += `+0 | I currently share ${mutualGuilds.length} guilds with them\n`; + } else { + trollScore += mutualGuilds.length; + tsReasoning += `+1 | Account is only in this guild, that i can tell + `; + } + + const bannedTest = await Promise.all(discordClient.guilds.cache.map(async guild => { + const guildPerms = await checkGuildPermissions(guild, [ + 'BanMembers' as PermissionResolvable, + ]); + + if (!guildPerms) { + return errorPermission; + } + + try { + return await guild.bans.fetch(target.id); + // log.debug(F, `User is banned in guild: ${guild.name}`); + // return guild.name; + } catch (err:unknown) { + if ((err as DiscordAPIError).code === 10026) { + // log.debug(F, `Ban not found for ${target.user.tag} in ${guild.name}`); + return 'not-found'; + } + // return nothing + return errorUnknown; + } + })); + + // count how many 'banned' appear in the array + const bannedGuilds = bannedTest.filter( + item => item !== errorPermission + && item !== 'not-found' + && item !== errorUnknown, + ) as GuildBan[]; + // log.debug(F, `Banned Guilds: ${bannedGuilds.join(', ')}`); + + // count how many i didn't have permission to check + const noPermissionGuilds = bannedTest.filter(item => item === errorPermission); + const checkedGuildNumber = bannedTest.length - noPermissionGuilds.length; + + if (bannedGuilds.length === 0) { + trollScore += 0; + tsReasoning += stripIndents`+0 | Not banned in ${checkedGuildNumber} other guilds that I have permission to check.`; + } else { + trollScore += (bannedGuilds.length * 5); + // eslint-disable-next-line max-len + tsReasoning += stripIndents`+${(bannedGuilds.length * 5)} | Account is banned in at least ${bannedGuilds.length} of the ${checkedGuildNumber} guilds I can check. + ${bannedGuilds.map(banData => `**${banData.guild.name}**: ${banData.reason}`).join('\n')} + `; + } + + return { + trollScore, + tsReasoning, + }; +} + +async function messageModThread( + actor: GuildMember, + target: User, + command: ModAction, + internalNote: string, + description?: string, + extraMessage?: string, +):Promise { + let modThread = {} as ThreadChannel; + + const targetData = await db.users.upsert({ + where: { + discord_id: target.id, + }, + create: { + discord_id: target.id, + }, + update: { + }, + }); + const guildData = await db.discord_guilds.upsert({ + where: { + id: actor.guild.id, + }, + create: { + id: actor.guild.id, + }, + update: { + }, + }); + + const guild = await discordClient.guilds.fetch(guildData.id); + if (targetData.mod_thread_id) { + log.debug(F, `Mod thread id exists: ${targetData.mod_thread_id}`); + try { + modThread = await guild.channels.fetch(targetData.mod_thread_id) as ThreadChannel; + log.debug(F, 'Mod thread exists'); + } catch (err) { + modThread = {} as ThreadChannel; + log.debug(F, 'Mod thread does not exist'); + } + } + + // log.debug(F, `Mod thread: ${JSON.stringify(modThread, null, 2)}`); + + let newModThread = false; + if (!modThread.id) { + // If the mod thread doesn't exist for whatever reason, maybe it got deleted, make a new one + // If the user we're banning is a vendor, don't make a new one + // Create a new thread in the mod channel + log.debug(F, 'creating mod thread'); + if (guildData.channel_moderators === null) { + throw new Error('Moderator room id is null'); + } + const modChan = await discordClient.channels.fetch(guildData.channel_moderators) as TextChannel; + modThread = await modChan.threads.create({ + name: `${target.username}`, + autoArchiveDuration: 60, + }); + // log.debug(F, 'created mod thread'); + // Save the thread id to the user + targetData.mod_thread_id = modThread.id; + await db.users.update({ + where: { + discord_id: target.id, + }, + data: { + mod_thread_id: modThread.id, + }, + }); + log.debug(F, 'saved mod thread id to user'); + newModThread = true; + } + + const modlogEmbed = await userInfoEmbed(target, targetData, command); + + const { pastVerb } = embedVariables[command as keyof typeof embedVariables]; + const summary = `${actor.displayName} ${pastVerb} ${target.username}!`; + + if (!guildData.role_moderator) { + throw new Error('Moderator role id is null'); + } + const roleModerator = await guild.roles.fetch(guildData.role_moderator) as Role; + + await modThread.send({ + content: stripIndents` + ${summary} + **Reason:** ${internalNote ?? noReason} + **Note sent to user:** ${(description !== '' && description !== null) ? description : '*No message sent to user*'} + ${command === 'NOTE' && !newModThread ? '' : roleModerator} + `, + embeds: [modlogEmbed], + }); + + if (extraMessage) { + await modThread.send({ content: extraMessage }); + } + + return modThread; +} + +async function messageUser( + target: User, + guild: Guild, + command: ModAction, + messageToUser: string, + addButtons?: boolean, +) { + log.debug(F, `Message user: ${target.username}`); + const embed = embedTemplate() + .setColor(embedVariables[command as keyof typeof embedVariables].embedColor) + .setTitle(embedVariables[command as keyof typeof embedVariables].embedTitle) + .setDescription(messageToUser); + + let message = {} as Message; + try { + if (addButtons) { + message = await target.send({ embeds: [embed], components: [warnButtons] }); + } else { + message = await target.send({ embeds: [embed] }); + } + } catch (error) { + return; + } + + if (addButtons) { + const targetData = await db.users.upsert({ + where: { + discord_id: target.id, + }, + create: { + discord_id: target.id, + }, + update: { + }, + }); + + const messageFilter = (mi: MessageComponentInteraction) => mi.user.id === target.id; + const collector = message.createMessageComponentCollector({ filter: messageFilter, time: 0 }); + + collector.on('collect', async (mi: MessageComponentInteraction) => { + if (mi.customId.startsWith('acknowledgeButton')) { + const targetChan = await discordClient.channels.fetch(targetData.mod_thread_id as string) as TextChannel; + if (targetChan) { + await targetChan.send({ + embeds: [embedTemplate() + .setColor(Colors.Green) + .setDescription(`${target.username} has acknowledged their warning.`)], + }); + } + // remove the components from the message + await mi.update({ components: [] }); + mi.user.send('Thanks for understanding! We appreciate your cooperation and will consider this in the future!'); + } else if (mi.customId.startsWith('refusalButton')) { + const targetChan = await discordClient.channels.fetch(targetData.mod_thread_id as string) as TextChannel; + await targetChan.send({ + embeds: [embedTemplate() + .setColor(Colors.Red) + .setDescription(`${target.username} has refused their timeout and was kicked.`)], + }); + // remove the components from the message + await mi.update({ components: [] }); + mi.user.send(stripIndents`Thanks for admitting this, you\'ve been removed from the guild. + You can rejoin if you ever decide to cooperate.`); + await guild.members.kick(target, 'Refused to acknowledge timeout'); + } + }); + } +} + +async function messageModlog( + target: User, + command: ModAction, + internalNote: string, + description?: string, +) { + const targetData = await db.users.upsert({ + where: { + discord_id: target.id, + }, + create: { + discord_id: target.id, + }, + update: { + }, + }); + const modlogEmbed = await userInfoEmbed(target, targetData, command); + + const anonSummary = `${target.username} was ${embedVariables[command as keyof typeof embedVariables].pastVerb}!`; + + const modChan = await discordClient.channels.fetch(env.CHANNEL_MODLOG) as TextChannel; + await modChan.send({ + content: stripIndents` + ${anonSummary} + **Reason:** ${internalNote ?? noReason} + **Note sent to user:** ${(description !== '' && description !== null) ? description : '*No message sent to user*'} + `, + embeds: [modlogEmbed], + }); +} + +export async function ban( + interaction: ChatInputCommandInteraction | UserContextMenuCommandInteraction | ButtonInteraction, + targetString: string, +) { + if (!interaction.guild) return; + let command = '' as BanAction | UnBanAction; + let modalDescription = descriptionPlaceholder; + let modalInternal = internalNotePlaceholder; + if (interaction.isChatInputCommand()) { + command = interaction.options.getString('toggle') === 'ON' + ? interaction.options.getString('type', true) as BanAction + : `UN-${interaction.options.getString('type', true) as ModAction}` as UnBanAction; + } else if (interaction.isContextMenuCommand()) { + command = 'FULL_BAN'; + } else if (interaction.isButton()) { + command = 'FULL_BAN'; + const embed = interaction.message.embeds[0].toJSON(); + const flagsField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Flags'); + const messageField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Message') as APIEmbedField; + const urlField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Channel') as APIEmbedField; + if (flagsField) { + modalInternal = `This user breaks TripSit's policies regarding ${flagsField.value} topics.`; + modalDescription = stripIndents` + Your recent messages have broken TripSit's policies regarding ${flagsField.value} topics. + + The offending message + > ${messageField.value} + ${urlField.value}`; + } + } + + let target: GuildMember | User; + const [targetMember] = await getDiscordMember(interaction, targetString) as GuildMember[]; + + const actor = interaction.member as GuildMember; + if (targetMember) { + target = targetMember; + } else { + // Look up the user and use that as the target + const discordUserData = await getDiscordUser(targetString); + if (!discordUserData) { + const embed = embedTemplate() + .setColor(Colors.Red) + .setTitle(noUserError) + .setDescription(stripIndents` + "${targetString}" returned no results! + + Try again with: + > **Mention:** @Moonbear + > **Tag:** moonbear#1234 + > **ID:** 9876581237 + > **Nickname:** MoonBear + `); + await interaction.reply({ + embeds: [embed], + ephemeral: true, + }); + return; + } + target = discordUserData; + } + log.debug(F, `${actor} ran ${command} on ${target} in ${actor.guild.name}`); + + const modal = new ModalBuilder() + .setCustomId(`modModal~${command}~${interaction.id}`) + .setTitle(`Tripbot ${command}`) + .addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel(`Why are you ${embedVariables[command as keyof typeof embedVariables].presentVerb} this user?`) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(internalNotePlaceholder) + .setValue(modalInternal) + .setRequired(true) + .setCustomId('internalNote'))); + + modal.addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel(descriptionLabel) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(descriptionPlaceholder) + .setValue(modalDescription) + .setCustomId('description'))); + + if ('FULL_BAN, BAN_EVASION, UNDERBAN'.includes(command)) { + modal.addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel('How many days of msg to remove?') + .setStyle(TextInputStyle.Short) + .setPlaceholder('4 days 3hrs 2 mins 30 seconds (Max 7 days, Default 0 days)') + .setRequired(false) + .setCustomId('days'))); + } + + await interaction.showModal(modal); + + const filter = (i:ModalSubmitInteraction) => i.customId.startsWith('modModal'); + interaction.awaitModalSubmit({ filter, time: 0 }) + .then(async i => { + if (i.customId.split('~')[2] !== interaction.id) return; + if (!i.guild) return; + await i.deferReply({ ephemeral: true }); + // const internalNote = i.fields.getTextInputValue('internalNote'); + let fullNote = i.fields.getTextInputValue('internalNote'); + const description = i.fields.getTextInputValue('description'); + + if (fullNote?.includes('MEP') || description?.includes('MEP')) { + await interaction.editReply({ + content: mepWarning, + }); + return; + } + + if (interaction.isMessageContextMenuCommand()) { + fullNote = stripIndents` + ${i.fields.getTextInputValue('internalNote')}`; + } + + if (interaction.isButton()) { + const embed = interaction.message.embeds[0].toJSON(); + + const messageField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Message') as APIEmbedField; + const urlField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Channel') as APIEmbedField; + + fullNote = stripIndents` + ${i.fields.getTextInputValue('internalNote')} + + **The offending message** + > ${messageField.value} + ${urlField.value} + `; + } + + const vendorBan = fullNote?.toLowerCase().includes('vendor') + && command === 'FULL_BAN'; + + // If the command is ban, then the input value exists, so pull that and try to parse it as an int + let dayInput = parseInt(i.fields.getTextInputValue('days'), 10); + + // If no input was provided, default to 0 days + if (Number.isNaN(dayInput)) dayInput = 0; + + // If the input is a string, or outside the bounds, tell the user and return + if (dayInput && (dayInput < 0 || dayInput > 7)) { + await i.editReply({ content: 'Ban days must be at least 0 and at most 7!' }); + return; + } + + // Get the millisecond value of the input + const duration = await parseDuration(`${dayInput} days`); + + if (!vendorBan && (description !== '' && description !== null)) { + // if target is the type GuildMember, use the user property + + const appealString = '\n\nYou can send an email to appeals@tripsit.me to appeal this ban! Evasion bans are permanent, and underban bans are permanent until you turn 18.'; // eslint-disable-line max-len + const evasionString = '\n\nEvasion bans are permanent, you can appeal the ban on your main account by sending an email, but evading will extend the ban'; // eslint-disable-line max-len + const channel = await discordClient.channels.fetch(env.CHANNEL_HELPDESK); + const discussString = `\n\nYou can discuss this with the mods in ${channel}. Do not argue the rules in public channels!`; // eslint-disable-line max-len + const { pastVerb } = embedVariables[command as keyof typeof embedVariables]; + await messageUser( + (target as GuildMember).user ? (target as GuildMember).user : target as User, + i.guild, + command, + stripIndents` + Hey ${target}, I'm sorry to inform that you've been ${pastVerb} by Team TripSit: + + ${description} + + **Do not message a moderator to talk about this!**${'FULL_BAN, BAN_EVASION, UNDERBAN'.includes(command) ? appealString : ''}${'BAN_EVASION'.includes(command) ? evasionString : ''}${'TICKET_BAN, DISCORD_BOT_BAN'.includes(command) ? discussString : ''}`, // eslint-disable-line max-len + ); + } + + const actorData = await db.users.upsert({ + where: { + discord_id: actor.id, + }, + create: { + discord_id: actor.id, + }, + update: { + }, + }); + const targetData = await db.users.upsert({ + where: { + discord_id: target.id, + }, + create: { + discord_id: target.id, + }, + update: { + }, + }); + + let actionData = { + id: undefined as string | undefined, + user_id: targetData.id, + type: 'TIMEOUT' as user_action_type, + ban_evasion_related_user: null as string | null, + description, + internal_note: fullNote, + expires_at: null as Date | null, + repealed_by: null as string | null, + repealed_at: null as Date | null, + created_by: actorData.id, + created_at: new Date(), + } as user_actions; + let extraMessage = ''; + if (command === 'FULL_BAN') { + actionData.type = 'FULL_BAN' as user_action_type; + targetData.removed_at = new Date(); + await db.users.update({ + where: { + discord_id: target.id, + }, + data: { + removed_at: new Date(), + }, + }); + try { + const deleteMessageValue = duration ?? 0; + if (deleteMessageValue > 0) { + // log.debug(F, `I am deleting ${deleteMessageValue} days of messages!`); + const response = await last( + (target as GuildMember).user ? (target as GuildMember).user : target as User, + interaction.guild as Guild, + ); + const username = (target as GuildMember).user ? (target as GuildMember).user.username : (target as User).username; // eslint-disable-line max-len + extraMessage = `${username}'s last ${response.messageCount} (out of ${response.totalMessages}) messages before being banned :\n${response.messageList}`; // eslint-disable-line max-len + } + const targetGuild = await discordClient.guilds.fetch(env.DISCORD_GUILD_ID); + // log.debug(F, `Days to delete: ${deleteMessageValue}`); + log.info(F, `target: ${target.id} | deleteMessageValue: ${deleteMessageValue} | internalNote: ${fullNote ?? noReason}`); // eslint-disable-line max-len + targetGuild.bans.create( + (target as GuildMember).user ? (target as GuildMember).user : target as User, + { deleteMessageSeconds: deleteMessageValue / 1000, reason: fullNote ?? noReason }, + ); + } catch (err) { + log.error(F, `Error: ${err}`); + } + } else if (command === 'UN-FULL_BAN') { + actionData.type = 'FULL_BAN' as user_action_type; + + targetData.removed_at = null; + await db.users.update({ + where: { + discord_id: target.id, + }, + data: { + removed_at: null, + }, + }); + + const record = await db.user_actions.findMany({ + where: { + user_id: targetData.id, + type: 'FULL_BAN', + }, + }); + + if (record.length > 0) { + [actionData] = record; + } + actionData.repealed_at = new Date(); + actionData.repealed_by = actorData.id; + + try { + const targetGuild = await discordClient.guilds.fetch(env.DISCORD_GUILD_ID); + await targetGuild.bans.fetch(); + await targetGuild.bans.remove( + (target as GuildMember).user ? (target as GuildMember).user : target as User, + fullNote ?? noReason, + ); + } catch (err) { + log.error(F, `Error: ${err}`); + } + } else if (command === 'UNDERBAN') { + actionData.type = 'UNDERBAN' as user_action_type; + targetData.removed_at = new Date(); + await db.users.update({ + where: { + discord_id: target.id, + }, + data: { + removed_at: new Date(), + }, + }); + try { + const targetGuild = await discordClient.guilds.fetch(env.DISCORD_GUILD_ID); + targetGuild.bans.create( + (target as GuildMember).user ? (target as GuildMember).user : target as User, + { reason: fullNote ?? noReason }, + ); + } catch (err) { + log.error(F, `Error: ${err}`); + } + } else if (command === 'UN-UNDERBAN') { + actionData.type = 'UNDERBAN' as user_action_type; + targetData.removed_at = null; + await db.users.update({ + where: { + discord_id: target.id, + }, + data: { + removed_at: null, + }, + }); + + const record = await db.user_actions.findMany({ + where: { + user_id: targetData.id, + type: 'UNDERBAN', + }, + }); + + if (record.length > 0) { + [actionData] = record; + } + actionData.repealed_at = new Date(); + actionData.repealed_by = actorData.id; + + try { + const targetGuild = await discordClient.guilds.fetch(env.DISCORD_GUILD_ID); + await targetGuild.bans.fetch(); + await targetGuild.bans.remove( + (target as GuildMember).user ? (target as GuildMember).user : target as User, + fullNote ?? noReason, + ); + } catch (err) { + log.error(F, `Error: ${err}`); + } + } else if (command === 'TICKET_BAN') { + actionData.type = 'TICKET_BAN' as user_action_type; + targetData.ticket_ban = true; + await db.users.update({ + where: { + discord_id: target.id, + }, + data: { + ticket_ban: true, + }, + }); + } else if (command === 'UN-TICKET_BAN') { + actionData.type = 'TICKET_BAN' as user_action_type; + targetData.ticket_ban = false; + + await db.users.update({ + where: { + discord_id: target.id, + }, + data: { + ticket_ban: false, + }, + }); + + const record = await db.user_actions.findMany({ + where: { + user_id: targetData.id, + type: 'TICKET_BAN', + }, + }); + if (record.length > 0) { + [actionData] = record; + } + actionData.repealed_at = new Date(); + actionData.repealed_by = actorData.id; + } else if (command === 'DISCORD_BOT_BAN') { + actionData.type = 'DISCORD_BOT_BAN' as user_action_type; + targetData.discord_bot_ban = true; + await db.users.update({ + where: { + discord_id: target.id, + }, + data: { + discord_bot_ban: true, + }, + }); + + botBannedUsers.push(target.id); + } else if (command === 'UN-DISCORD_BOT_BAN') { + actionData.type = 'DISCORD_BOT_BAN' as user_action_type; + targetData.discord_bot_ban = false; + await db.users.update({ + where: { + discord_id: target.id, + }, + data: { + discord_bot_ban: false, + }, + }); + + const record = await db.user_actions.findMany({ + where: { + user_id: targetData.id, + type: 'DISCORD_BOT_BAN', + }, + }); + if (record.length > 0) { + [actionData] = record; + } + actionData.repealed_at = new Date(); + actionData.repealed_by = actorData.id; + + // Remove the user from the botBannedUsers list + const index = botBannedUsers.indexOf(target.id); + if (index > -1) { + botBannedUsers.splice(index, 1); + } + } else if (command === 'BAN_EVASION') { + actionData.type = 'BAN_EVASION' as user_action_type; + targetData.removed_at = new Date(); + await db.users.update({ + where: { + discord_id: target.id, + }, + data: { + removed_at: new Date(), + }, + }); + } else if (command === 'UN-BAN_EVASION') { + actionData.type = 'BAN_EVASION' as user_action_type; + targetData.removed_at = null; + await db.users.update({ + where: { + discord_id: target.id, + }, + data: { + removed_at: null, + }, + }); + + const record = await db.user_actions.findMany({ + where: { + user_id: targetData.id, + type: 'BAN_EVASION', + }, + }); + if (record.length > 0) { + [actionData] = record; + } + actionData.repealed_at = new Date(); + actionData.repealed_by = actorData.id; + } + + await db.user_actions.create({ + data: actionData, + }); + + let modThread = {} as ThreadChannel; + if (!vendorBan) { + modThread = await messageModThread( + actor, + (target as GuildMember).user ? (target as GuildMember).user : target as User, + command, + fullNote, + description, + extraMessage, + ); + } + + await messageModlog( + (target as GuildMember).user ? (target as GuildMember).user : target as User, + command, + fullNote, + description, + ); + + const username = (target as GuildMember).user ? (target as GuildMember).user.username : (target as User).username; + + if (interaction.isButton()) { + const embed = interaction.message.embeds[0].toJSON(); + const actionField = embed.fields?.find(field => field.name === 'Actions'); + + if (actionField) { + // Add the action to the list of actions + const newActionFiled = actionField?.value.concat(` + + ${interaction.user.toString()} muted this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${i.fields.getTextInputValue('description')}`); + // log.debug(F, `newActionFiled: ${newActionFiled}`); + + // Replace the action field with the new one + embed.fields?.splice(embed.fields?.findIndex(field => field.name === 'Actions'), 1, { + name: 'Actions', + value: newActionFiled, + inline: true, + }); + } else { + embed.fields?.push( + { + name: 'Actions', + value: stripIndents`${interaction.user.toString()} muted this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${i.fields.getTextInputValue('description')}`, + inline: true, + }, + ); + } + + embed.color = Colors.Green; + const buttonRows = interaction.message.components.map(row => ActionRowBuilder.from(row.toJSON())); + + await interaction.message.edit({ + embeds: [embed], + components: buttonRows as ActionRowBuilder[], + }); + } + + await i.editReply({ + embeds: [ + embedTemplate() + .setAuthor(null) + .setColor(Colors.Yellow) + .setDescription(stripIndents` + ${username} was ${embedVariables[command as keyof typeof embedVariables].pastVerb} + **Reason:** ${fullNote ?? noReason} + ${(description !== '' && description !== null) ? `\n\n**Note sent to user: ${description}**` : ''} + ${`You can access their thread here: ${modThread}`} + `) + .setFooter(null), + ], + }); + }); +} + +export async function info( + interaction: ChatInputCommandInteraction | UserContextMenuCommandInteraction, + targetString: string, +) { + await interaction.deferReply({ ephemeral: true }); + const command = 'INFO'; + // log.debug(F, `Member: ${JSON.stringify(target)}`); + // log.debug(F, `User: ${JSON.stringify(target.user)}`); + + let target: GuildMember | User; + const [targetMember] = await getDiscordMember(interaction, targetString) as GuildMember[]; + if (targetMember) { + target = targetMember; + } else { + // Look up the user and use that as the target + const discordUserData = await getDiscordUser(targetString); + if (!discordUserData) { + const embed = embedTemplate() + .setColor(Colors.Red) + .setTitle(noUserError) + .setDescription(stripIndents` + "${targetString}" returned no results! + + Try again with: + > **Mention:** @Moonbear + > **Tag:** moonbear#1234 + > **ID:** 9876581237 + > **Nickname:** MoonBear + `); + await interaction.reply({ + embeds: [embed], + ephemeral: true, + }); + return; + } + target = discordUserData; + } + const actor = interaction.member as GuildMember; + log.debug(F, `${actor} ran ${command} on ${target} in ${actor.guild.name}`); + + const targetData = await db.users.upsert({ + where: { + discord_id: target.id, + }, + create: { + discord_id: target.id, + }, + update: { + }, + }); + const modlogEmbed = await userInfoEmbed(target, targetData, command); + const trollScoreData = await tripSitTrollScore((target as GuildMember).user ? (target as GuildMember).user : target as User); + modlogEmbed.setDescription(`**TripSit TrollScore: ${trollScoreData.trollScore}**\n\`\`\`${trollScoreData.tsReasoning}\`\`\`${modlogEmbed.data.description}`); + await interaction.editReply({ embeds: [modlogEmbed] }); +} + +export async function kick( + interaction: ChatInputCommandInteraction | UserContextMenuCommandInteraction, + target: GuildMember, +) { + const command = 'KICK'; + const actor = interaction.member as GuildMember; + log.debug(F, `${actor} ran ${command} on ${target} in ${actor.guild.name}`); + const modal = new ModalBuilder() + .setCustomId(`modModal~${command}~${interaction.id}`) + .setTitle(`Tripbot ${command}`) + .addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel(`Why are you ${embedVariables[command as keyof typeof embedVariables].presentVerb} this user?`) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(internalNotePlaceholder) + .setRequired(true) + .setCustomId('internalNote'))); + + modal.addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel(descriptionLabel) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(descriptionPlaceholder) + .setCustomId('description'))); + + await interaction.showModal(modal); + + const filter = (i:ModalSubmitInteraction) => i.customId.startsWith('modModal'); + interaction.awaitModalSubmit({ filter, time: 0 }) + .then(async i => { + if (i.customId.split('~')[2] !== interaction.id) return; + await i.deferReply({ ephemeral: true }); + if (!i.guild) return; + const internalNote = i.fields.getTextInputValue('internalNote'); + const description = i.fields.getTextInputValue('description'); + + if (internalNote?.includes('MEP') || description?.includes('MEP')) { + await interaction.editReply({ + content: mepWarning, + }); + return; + } + + const { pastVerb } = embedVariables[command as keyof typeof embedVariables]; + if ((description !== '' && description !== null)) { + await messageUser( + target.user, + i.guild, + command, + ` + Hey ${target}, I'm sorry to inform that you've been ${pastVerb} by Team TripSit: + + ${description} + + **Do not message a moderator to talk about this!** + + Please review the rules so this doesn't happen again!\nhttps:// wiki.tripsit.me/wiki/Terms_of_Service + + If you feel you can follow the rules you can rejoin here: https://discord.gg/tripsit + `, + ); + } + + try { + await target.kick(); + } catch (err) { + log.error(F, `Error: ${err}`); + } + + const actorData = await db.users.upsert({ + where: { + discord_id: actor.id, + }, + create: { + discord_id: actor.id, + }, + update: { + }, + }); + const targetData = await db.users.upsert({ + where: { + discord_id: target.id, + }, + create: { + discord_id: target.id, + }, + update: { + }, + }); + + await db.user_actions.create({ + data: { + id: undefined as string | undefined, + user_id: targetData.id, + guild_id: i.guild.id, + type: 'KICK' as user_action_type, + ban_evasion_related_user: null as string | null, + description, + internal_note: internalNote, + expires_at: null as Date | null, + repealed_by: null as string | null, + repealed_at: null as Date | null, + created_by: actorData.id, + created_at: new Date(), + }, + }); + + const modThread = await messageModThread( + actor, + target.user, + command, + internalNote, + description, + ); + + await messageModlog( + target.user, + command, + internalNote, + description, + ); + + i.editReply({ + embeds: [ + embedTemplate() + .setAuthor(null) + .setColor(Colors.Yellow) + .setDescription(stripIndents` + ${target.displayName} was ${embedVariables[command as keyof typeof embedVariables].pastVerb} + **Reason:** ${internalNote ?? noReason} + ${(description !== '' && description !== null) ? `\n\n**Note sent to user: ${description}**` : ''} + ${`You can access their thread here: ${modThread}`} + `) + .setFooter(null), + ], + }); + }); + + return false; +} + +export async function link( + interaction: ChatInputCommandInteraction, + target: GuildMember, +) { + const guildData = await db.discord_guilds.upsert({ + where: { + id: (interaction.guild as Guild).id, + }, + create: { + id: (interaction.guild as Guild).id, + }, + update: { + }, + }); + if (!interaction.channel?.isThread() + || !interaction.channel.parentId + || interaction.channel.parentId !== guildData.channel_moderators) { + await interaction.reply({ + content: `This command can only be run inside a thread under <#${guildData.channel_moderators}>!`, + ephemeral: true, + }); + return false; + } + + const override = interaction.options.getBoolean('override'); + + let result: string | null; + const targetString = interaction.options.getString('target', true); + if (!target) { + const userData = await db.users.upsert({ + where: { + discord_id: targetString, + }, + create: { + discord_id: targetString, + }, + update: { + }, + }); + + if (!userData) { + await interaction.reply({ + content: stripIndents`Failed to link thread, I could not find this user in the guild, \ +and they do not exist in the database!`, + ephemeral: true, + }); + return false; + } + result = await linkThread(targetString, interaction.channelId, override); + } else { + result = await linkThread(target.id, interaction.channelId, override); + } + + if (result === null) { + await interaction.editReply({ content: 'Successfully linked thread!' }); + } else { + const existingThread = await interaction.client.channels.fetch(result); + await interaction.reply({ + content: stripIndents`Failed to link thread, this user has an existing thread: ${existingThread} + Use the override parameter if you're sure!`, + ephemeral: true, + }); + } + + return true; +} + +export async function note( + interaction: ChatInputCommandInteraction | MessageContextMenuCommandInteraction | ButtonInteraction, + target: GuildMember | User, +) { + const command = 'NOTE'; + const actor = interaction.member as GuildMember; + + let modalValue = ''; + log.debug(F, `${actor} ran ${command} on ${target} in ${actor.guild.name}`); + + if (interaction.isButton()) { + const embed = interaction.message.embeds[0].toJSON(); + const flagsField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Flags'); + if (flagsField) { + modalValue = `This user's message was flagged by the AI for ${flagsField.value}`; + } + } + + const modal = new ModalBuilder() + .setCustomId(`modModal~${command}~${interaction.id}`) + .setTitle(`Tripbot ${command}`) + .addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel(`Why are you ${embedVariables[command as keyof typeof embedVariables].presentVerb} this user?`) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(internalNotePlaceholder) + .setValue(modalValue) + .setRequired(true) + .setCustomId('internalNote'))); + + await interaction.showModal(modal); + + const filter = (i:ModalSubmitInteraction) => i.customId.startsWith('modModal'); + interaction.awaitModalSubmit({ filter, time: 0 }) + .then(async i => { + if (i.customId.split('~')[2] !== interaction.id) return; + await i.deferReply({ ephemeral: true }); + const internalNote = i.fields.getTextInputValue('internalNote'); + if (internalNote?.includes('MEP')) { + await interaction.editReply({ + content: mepWarning, + }); + return; + } + + let fullNote = i.fields.getTextInputValue('internalNote'); + + if (interaction.isMessageContextMenuCommand()) { + fullNote = stripIndents` + ${i.fields.getTextInputValue('internalNote')} + + **The offending message** + > ${interaction.targetMessage.cleanContent} + ${interaction.targetMessage.url} + `; + } + + if (interaction.isButton()) { + const embed = interaction.message.embeds[0].toJSON(); + + const messageField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Message') as APIEmbedField; + const urlField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Channel') as APIEmbedField; + + fullNote = stripIndents` + ${i.fields.getTextInputValue('internalNote')} + + **The offending message** + > ${messageField.value} + ${urlField.value} + `; + } + + const actorData = await db.users.upsert({ + where: { + discord_id: actor.id, + }, + create: { + discord_id: actor.id, + }, + update: { + }, + }); + + const targetData = await db.users.upsert({ + where: { + discord_id: target.id, + }, + create: { + discord_id: target.id, + }, + update: { + }, + }); + + await db.user_actions.create({ + data: { + user_id: targetData.id, + guild_id: actor.guild.id, + type: 'NOTE' as user_action_type, + ban_evasion_related_user: null as string | null, + description: null, + internal_note: fullNote, + expires_at: null as Date | null, + repealed_by: null as string | null, + repealed_at: null as Date | null, + created_by: actorData.id, + created_at: new Date(), + }, + }); + + const modThread = await messageModThread( + actor, + (target as GuildMember).user ? (target as GuildMember).user : target as User, + command, + fullNote, + ); + + await messageModlog( + (target as GuildMember).user ? (target as GuildMember).user : target as User, + command, + fullNote, + ); + + if (interaction.isButton()) { + const embed = interaction.message.embeds[0].toJSON(); + const actionField = embed.fields?.find(field => field.name === 'Actions'); + + if (actionField) { + // Add the action to the list of actions + const newActionFiled = actionField?.value.concat(` + + ${interaction.user.toString()} noted this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > **No message sent to user on notes** + `); + // log.debug(F, `newActionFiled: ${newActionFiled}`); + + // Replace the action field with the new one + embed.fields?.splice(embed.fields?.findIndex(field => field.name === 'Actions'), 1, { + name: 'Actions', + value: newActionFiled, + inline: true, + }); + } else { + embed.fields?.push( + { + name: 'Actions', + value: stripIndents`${interaction.user.toString()} noted this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${i.fields.getTextInputValue('description')}`, + inline: true, + }, + ); + } + + embed.color = Colors.Green; + const buttonRows = interaction.message.components.map(row => ActionRowBuilder.from(row.toJSON())); + + await interaction.message.edit({ + embeds: [embed], + components: buttonRows as ActionRowBuilder[], + }); + } + + await i.editReply({ + embeds: [ + embedTemplate() + .setAuthor(null) + .setColor(Colors.Yellow) + .setDescription(stripIndents` + ${(target as GuildMember).user ? (target as GuildMember).user.username : (target as User).username} was ${embedVariables[command as keyof typeof embedVariables].pastVerb} + **Reason:** ${fullNote ?? noReason} + ${`You can access their thread here: ${modThread}`} + `) + .setFooter(null), + ], + }); + }); +} + +export async function report( + interaction: ChatInputCommandInteraction | MessageContextMenuCommandInteraction, + target: GuildMember, +) { + const command = 'REPORT'; + const actor = interaction.member as GuildMember; + log.debug(F, `${actor} ran ${command} on ${target} in ${actor.guild.name}`); + const modal = new ModalBuilder() + .setCustomId(`modModal~${command}~${interaction.id}`) + .setTitle(`Tripbot ${command}`) + .addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel(`Why are you ${embedVariables[command as keyof typeof embedVariables].presentVerb} this user?`) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(internalNotePlaceholder) + .setRequired(true) + .setCustomId('internalNote'))); + + await interaction.showModal(modal); + + const filter = (i:ModalSubmitInteraction) => i.customId.startsWith('modModal'); + interaction.awaitModalSubmit({ filter, time: 0 }) + .then(async i => { + if (i.customId.split('~')[2] !== interaction.id) return; + await i.deferReply({ ephemeral: true }); + const internalNote = i.fields.getTextInputValue('internalNote'); + if (internalNote?.includes('MEP')) { + await interaction.editReply({ + content: mepWarning, + }); + return; + } + + let fullNote = i.fields.getTextInputValue('internalNote'); + + if (interaction.isMessageContextMenuCommand()) { + fullNote = stripIndents` + ${i.fields.getTextInputValue('internalNote')} + + **The offending message** + > ${interaction.targetMessage.cleanContent} + ${interaction.targetMessage.url} + `; + } + + // const actorData = await database.users.get((interaction.member as GuildMember).id, null, null); + // const targetData = await database.users.get(target.id, null, null); + const actorData = await db.users.upsert({ + where: { + discord_id: actor.id, + }, + create: { + discord_id: actor.id, + }, + update: { + }, + }); + const targetData = await db.users.upsert({ + where: { + discord_id: target.id, + }, + create: { + discord_id: target.id, + }, + update: { + }, + }); + + await db.user_actions.create({ + data: { + user_id: targetData.id, + guild_id: actor.guild.id, + type: 'REPORT' as user_action_type, + ban_evasion_related_user: null as string | null, + description: null, + internal_note: fullNote, + expires_at: null as Date | null, + repealed_by: null as string | null, + repealed_at: null as Date | null, + created_by: actorData.id, + created_at: new Date(), + }, + }); + + await messageModThread( + actor, + target.user, + command, + fullNote, + ); + + await messageModlog( + target.user, + command, + fullNote, + ); + + i.editReply({ + embeds: [ + embedTemplate() + .setAuthor(null) + .setColor(Colors.Yellow) + .setDescription(stripIndents` + ${target.displayName} was ${embedVariables[command as keyof typeof embedVariables].pastVerb} + **Reason:** ${fullNote ?? noReason} + `) + .setFooter(null), + ], + }); + }); + + return false; +} + +export async function timeout( + interaction: ChatInputCommandInteraction | MessageContextMenuCommandInteraction | ButtonInteraction, + target: GuildMember, +) { + let command = '' as 'TIMEOUT' | 'UN-TIMEOUT'; + let modalDescription = descriptionPlaceholder; + let modalInternal = internalNotePlaceholder; + if (interaction.isChatInputCommand()) { + command = interaction.options.getString('toggle', true) === 'ON' + ? 'TIMEOUT' + : 'UN-TIMEOUT'; + } else if (interaction.isMessageContextMenuCommand()) { + command = 'TIMEOUT'; + } else if (interaction.isButton()) { + command = 'TIMEOUT'; + const embed = interaction.message.embeds[0].toJSON(); + const flagsField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Flags'); + const messageField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Message') as APIEmbedField; + const urlField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Channel') as APIEmbedField; + if (flagsField) { + modalInternal = `This user breaks TripSit's policies regarding ${flagsField.value} topics.`; + modalDescription = stripIndents` + Your recent messages have broken TripSit's policies regarding ${flagsField.value} topics. + + The offending message + > ${messageField.value} + ${urlField.value}`; + } + } + + const actor = interaction.member as GuildMember; + log.debug(F, `${actor} ran ${command} on ${target} in ${actor.guild.name}`); + const modal = new ModalBuilder() + .setCustomId(`modModal~${command}~${interaction.id}`) + .setTitle(`Tripbot ${command}`) + .addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel(`Why are you ${embedVariables[command].presentVerb} this user?`) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(internalNotePlaceholder) + .setValue(modalInternal) + .setRequired(true) + .setCustomId('internalNote'))); + + modal.addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel(descriptionLabel) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(descriptionPlaceholder) + .setValue(modalDescription) + .setCustomId('description'))); + + modal.addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel('Timeout for how long?') + .setStyle(TextInputStyle.Short) + .setPlaceholder('4 days 3hrs 2 mins 30 seconds (Max 7 days, Default 7 days)') + .setRequired(false) + .setCustomId('duration'))); + + await interaction.showModal(modal); + + const filter = (i:ModalSubmitInteraction) => i.customId.startsWith('modModal'); + interaction.awaitModalSubmit({ filter, time: 0 }) + .then(async i => { + if (i.customId.split('~')[2] !== interaction.id) return; + if (!i.guild) return; + await i.deferReply({ ephemeral: true }); + const internalNote = i.fields.getTextInputValue('internalNote'); + const description = i.fields.getTextInputValue('description'); + + if (internalNote?.includes('MEP') || description?.includes('MEP')) { + await interaction.editReply({ + content: mepWarning, + }); + return; + } + + let fullNote = i.fields.getTextInputValue('internalNote'); + + if (interaction.isMessageContextMenuCommand()) { + fullNote = stripIndents` + ${i.fields.getTextInputValue('internalNote')} + + **The offending message** + > ${interaction.targetMessage.cleanContent} + ${interaction.targetMessage.url} + `; + } + + if (interaction.isButton()) { + const embed = interaction.message.embeds[0].toJSON(); + + const messageField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Message') as APIEmbedField; + const urlField = (embed.fields as APIEmbedField[]).find(field => field.name === 'Channel') as APIEmbedField; + + fullNote = stripIndents` + ${i.fields.getTextInputValue('internalNote')} + + **The offending message** + > ${messageField.value} + ${urlField.value} + `; + } + + let duration = i.fields.getTextInputValue('duration'); + if (duration === '') duration = '7 days'; + if (duration.length === 1) { + // If the input is a single number, assume it's days + const numberInput = parseInt(duration, 10); + if (Number.isNaN(numberInput)) { + await i.editReply({ content: 'Timeout must be a number!' }); + return; + } + if (numberInput < 0 || numberInput > 7) { + await i.editReply({ content: 'Timeout must be between 0 and 7 days!' }); + return; + } + duration = `${duration} days`; + } + const timeoutDuration = await parseDuration(duration); + if (timeoutDuration && (timeoutDuration < 0 || timeoutDuration > 7 * 24 * 60 * 60 * 1000)) { + await i.editReply({ content: 'Timeout must be between 0 and 7 days!!' }); + return; + } + + const { pastVerb } = embedVariables[command as keyof typeof embedVariables]; + + if ((description !== '' && description !== null)) { + const channel = await discordClient.channels.fetch(env.CHANNEL_HELPDESK); + await messageUser( + target.user, + i.guild, + command, + ` + Hey ${target}, I'm sorry to inform that you've been ${pastVerb} by Team TripSit: + + ${description} + + **Do not message a moderator to talk about this!** + + Please review the rules so this doesn't happen again!\nhttps:// wiki.tripsit.me/wiki/Terms_of_Service + + **Do not argue the rules in public channels!** + + You can discuss this with the mods in ${channel} when this expires. + `, + true, + ); + } + + if (command === 'TIMEOUT') { + try { + await target.timeout(timeoutDuration, fullNote ?? noReason); + } catch (err) { + log.error(F, `Error: ${err}`); + } + } else { + try { + await target.timeout(0, fullNote ?? noReason); + } catch (err) { + log.error(F, `Error: ${err}`); + } + } + + // const actorData = await database.users.get(actor.id, null, null); + // const targetData = await database.users.get(target.id, null, null); + + const actorData = await db.users.upsert({ + where: { + discord_id: actor.id, + }, + create: { + discord_id: actor.id, + }, + update: { + }, + }); + + const targetData = await db.users.upsert({ + where: { + discord_id: target.id, + }, + create: { + discord_id: target.id, + }, + update: { + }, + }); + + let actionData = { + id: undefined as string | undefined, + user_id: targetData.id, + type: 'TIMEOUT' as user_action_type, + ban_evasion_related_user: null as string | null, + description, + internal_note: fullNote, + expires_at: null as Date | null, + repealed_by: null as string | null, + repealed_at: null as Date | null, + created_by: actorData.id, + created_at: new Date(), + } as user_actions; + + if (command === 'TIMEOUT') { + actionData.type = 'TIMEOUT' as user_action_type; + actionData.expires_at = new Date(Date.now() + timeoutDuration); + } else { + // const record = await database.actions.get(targetData.id, 'TIMEOUT'); + const record = await db.user_actions.findMany({ + where: { + user_id: targetData.id, + type: 'TIMEOUT', + }, + }); + if (record.length > 0) { + [actionData] = record; + } + actionData.repealed_at = new Date(); + actionData.repealed_by = actorData.id; + } + + await db.user_actions.create({ + data: actionData, + }); + + const modThread = await messageModThread( + actor, + target.user, + command, + fullNote, + description, + ); + + await messageModlog( + target.user, + command, + fullNote, + description, + ); + + if (interaction.isButton()) { + const embed = interaction.message.embeds[0].toJSON(); + const actionField = embed.fields?.find(field => field.name === 'Actions'); + + if (actionField) { + // Add the action to the list of actions + const newActionFiled = actionField?.value.concat(` + + ${interaction.user.toString()} muted this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${i.fields.getTextInputValue('description')}`); + // log.debug(F, `newActionFiled: ${newActionFiled}`); + + // Replace the action field with the new one + embed.fields?.splice(embed.fields?.findIndex(field => field.name === 'Actions'), 1, { + name: 'Actions', + value: newActionFiled, + inline: true, + }); + } else { + embed.fields?.push( + { + name: 'Actions', + value: stripIndents`${interaction.user.toString()} muted this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${i.fields.getTextInputValue('description')}`, + inline: true, + }, + ); + } + + embed.color = Colors.Green; + const buttonRows = interaction.message.components.map(row => ActionRowBuilder.from(row.toJSON())); + + await interaction.message.edit({ + embeds: [embed], + components: buttonRows as ActionRowBuilder[], + }); + } + + await i.editReply({ + embeds: [ + embedTemplate() + .setAuthor(null) + .setColor(Colors.Yellow) + .setDescription(stripIndents` + ${target.displayName} was ${embedVariables[command as keyof typeof embedVariables].pastVerb} + **Reason:** ${fullNote ?? noReason} + ${(description !== '' && description !== null) ? `\n\n**Note sent to user: ${description}**` : ''} + ${`You can access their thread here: ${modThread}`} + `) + .setFooter(null), + ], + }); + }); +} + +export async function warn( + interaction: ChatInputCommandInteraction | MessageContextMenuCommandInteraction, + target: GuildMember, +) { + const command = 'WARNING'; + const actor = interaction.member as GuildMember; + log.debug(F, `${actor} ran ${command} on ${target} in ${actor.guild.name}`); + const modal = new ModalBuilder() + .setCustomId(`modModal~${command}~${interaction.id}`) + .setTitle(`Tripbot ${command}`) + .addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel(`Why are you ${embedVariables[command as keyof typeof embedVariables].presentVerb} this user?`) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(internalNotePlaceholder) + .setRequired(true) + .setCustomId('internalNote'))); + modal.addComponents(new ActionRowBuilder() + .addComponents(new TextInputBuilder() + .setLabel(descriptionLabel) + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder(descriptionPlaceholder) + .setRequired(true) + .setCustomId('description'))); + + await interaction.showModal(modal); + + const filter = (i:ModalSubmitInteraction) => i.customId.startsWith('modModal'); + interaction.awaitModalSubmit({ filter, time: 0 }) + .then(async i => { + if (i.customId.split('~')[2] !== interaction.id) return; + if (!i.guild) return; + await i.deferReply({ ephemeral: true }); + const internalNote = i.fields.getTextInputValue('internalNote'); + const description = i.fields.getTextInputValue('description'); + + if (internalNote?.includes('MEP')) { + await interaction.editReply({ + content: mepWarning, + }); + return; + } + + let fullNote = i.fields.getTextInputValue('internalNote'); + + if (interaction.isMessageContextMenuCommand()) { + fullNote = stripIndents` + ${i.fields.getTextInputValue('internalNote')} + + **The offending message** + > ${interaction.targetMessage.cleanContent} + ${interaction.targetMessage.url} + `; + } + + const channelHelpDesk = await discordClient.channels.fetch(env.CHANNEL_HELPDESK); + const { pastVerb } = embedVariables[command as keyof typeof embedVariables]; + await messageUser( + target.user, + i.guild, + command, + ` + Hey ${target}, I'm sorry to inform that you've been ${pastVerb} by ${i.guild.name}: + + ${description} + + **Do not message a moderator to talk about this!** + + Please review ${i.guild.rulesChannel} so this doesn't happen again! + + You can discuss this with the mods in ${channelHelpDesk}. Do not argue the rules in public channels! + `, + true, + ); + + const actorData = await db.users.upsert({ + where: { + discord_id: actor.id, + }, + create: { + discord_id: actor.id, + }, + update: { + }, + }); + + const targetData = await db.users.upsert({ + where: { + discord_id: target.id, + }, + create: { + discord_id: target.id, + }, + update: { + }, + }); + + await db.user_actions.create({ + data: { + user_id: targetData.id, + guild_id: actor.guild.id, + type: 'WARNING' as user_action_type, + ban_evasion_related_user: null as string | null, + description, + internal_note: fullNote, + expires_at: null as Date | null, + repealed_by: null as string | null, + repealed_at: null as Date | null, + created_by: actorData.id, + created_at: new Date(), + }, + }); + + const modThread = await messageModThread( + actor, + target.user, + command, + fullNote, + description, + ); + + await messageModlog( + target.user, + command, + fullNote, + description, + ); + + i.editReply({ + embeds: [ + embedTemplate() + .setAuthor(null) + .setColor(Colors.Yellow) + .setDescription(stripIndents` + ${target.displayName} was ${embedVariables[command as keyof typeof embedVariables].pastVerb} + **Reason:** ${fullNote ?? noReason} + ${(description !== '' && description !== null) ? `\n\n**Note sent to user: ${description}**` : ''} + ${`You can access their thread here: ${modThread}`} + `) + .setFooter(null), + ], + }); + }); +} + +export const mod: SlashCommand = { + data: new SlashCommandBuilder() + .setName('mod') + .setDescription('Moderation actions!') + .addSubcommand(subcommand => subcommand + .setDescription('Info on a user') + .addStringOption(option => option + .setName('target') + .setDescription('User to get info on!') + .setRequired(true)) + .setName('info')) + .addSubcommand(subcommand => subcommand + .setDescription('Ban a user') + .addStringOption(option => option + .setName('target') + .setDescription('User to ban!') + .setRequired(true)) + .addStringOption(option => option + .setName('type') + .setDescription('Type of ban') + .setRequired(true) + .addChoices( + { name: 'Full Ban', value: 'FULL_BAN' }, + { name: 'Ticket Ban', value: 'TICKET_BAN' }, + { name: 'Discord Bot Ban', value: 'DISCORD_BOT_BAN' }, + { name: 'Ban Evasion', value: 'BAN_EVASION' }, + { name: 'Underban', value: 'UNDERBAN' }, + { name: 'Helper Ban', value: 'HELPER_BAN' }, + { name: 'Contributor Ban', value: 'CONTRIBUTOR_BAN' }, + )) + .addStringOption(option => option + .setName('toggle') + .setDescription('On or off? (Default: ON)') + .addChoices( + { name: 'On', value: 'ON' }, + { name: 'Off', value: 'OFF' }, + )) + .setName('ban')) + .addSubcommand(subcommand => subcommand + .setDescription('Warn a user') + .addStringOption(option => option + .setName('target') + .setDescription('User to warn!') + .setRequired(true)) + .setName('warning')) + .addSubcommand(subcommand => subcommand + .setDescription('Report a user') + .addStringOption(option => option + .setName('target') + .setDescription('User to report!') + .setRequired(true)) + .setName('report')) + .addSubcommand(subcommand => subcommand + .setDescription('Create a note about a user') + .addStringOption(option => option + .setName('target') + .setDescription('User to note about!') + .setRequired(true)) + .setName('note')) + .addSubcommand(subcommand => subcommand + .setDescription('Timeout a user') + .addStringOption(option => option + .setName('target') + .setDescription('User to timeout!') + .setRequired(true)) + .addStringOption(option => option + .setName('toggle') + .setDescription('On or off? (Default: ON)') + .addChoices( + { name: 'On', value: 'ON' }, + { name: 'Off', value: 'OFF' }, + )) + .setName('timeout')) + .addSubcommand(subcommand => subcommand + .setDescription('Kick a user') + .addStringOption(option => option + .setName('target') + .setDescription('User to kick!') + .setRequired(true)) + .setName('kick')) + .addSubcommand(subcommand => subcommand + .setDescription('Link user to an existing thread') + .addStringOption(option => option + .setName('target') + .setDescription('User to link!') + .setRequired(true)) + .addBooleanOption(option => option + .setName('override') + .setDescription('Override existing threads in the DB')) + .setName('link')), + async execute(interaction:ChatInputCommandInteraction) { + log.info(F, await commandContext(interaction)); + + if (!interaction.guild) { + await interaction.reply({ + embeds: [embedTemplate() + .setColor(Colors.Red) + .setTitle('This command can only be used in a server!')], + ephemeral: true, + }); + return false; + } + + const command = interaction.options.getSubcommand().toUpperCase() as ModAction; + + // Check if the guild is a partner (or the home guild) + const guildData = await db.discord_guilds.upsert({ + where: { + id: interaction.guild.id, + }, + create: { + id: interaction.guild.id, + }, + update: { + }, + }); + if (interaction.guild.id !== env.DISCORD_GUILD_ID + && !guildData.partner + && !guildData.supporter) { + await interaction.reply({ + embeds: [ + embedTemplate() + .setDescription(cooperativeExplanation) + .setColor(Colors.Red), + ], + ephemeral: true, + }); + return false; + } + + const actor = interaction.member as GuildMember; + const targetString = interaction.options.getString('target', true); + const targets = await getDiscordMember(interaction, targetString) as GuildMember[]; + + if (targets.length > 1) { + const embed = embedTemplate() + .setColor(Colors.Red) + .setTitle('Found more than one user with with that value!') + .setDescription(stripIndents` + "${targetString}" returned ${targets.length} results! + + Be more specific: + > **Mention:** @Moonbear + > **Tag:** moonbear#1234 + > **ID:** 9876581237 + > **Nickname:** MoonBear`); + await interaction.reply({ + embeds: [embed], + ephemeral: true, + }); + return false; + } + + const target = targets[0]; + if (!target && command !== 'FULL_BAN') { + const embed = embedTemplate() + .setColor(Colors.Red) + .setTitle(noUserError) + .setDescription(stripIndents` + "${targetString}" returned no results! + + Try again with: + > **Mention:** @Moonbear + > **Tag:** moonbear#1234 + > **ID:** 9876581237 + > **Nickname:** MoonBear`); + await interaction.reply({ + embeds: [embed], + ephemeral: true, + }); + return false; + } + + switch (command) { + case 'BAN': + await ban(interaction, targetString); + break; + case 'INFO': + await info(interaction, targetString); + return true; + case 'KICK': + await kick(interaction, targets[0]); + break; + case 'LINK': + await link(interaction, targets[0]); + break; + case 'NOTE': + await note(interaction, targets[0]); + break; + case 'REPORT': + await report(interaction, targets[0]); + break; + case 'TIMEOUT': + await timeout(interaction, targets[0]); + break; + case 'WARNING': + await warn(interaction, targets[0]); + break; + default: + break; + } + + log.debug(F, `${actor} ran ${command} on ${target}`); + return true; + }, +}; + +export default mod; diff --git a/src/global/commands/g.last.ts b/src/global/commands/g.last.ts index 548c78286..2ccc971af 100644 --- a/src/global/commands/g.last.ts +++ b/src/global/commands/g.last.ts @@ -1,6 +1,6 @@ -/* eslint-disable max-len */ import { - GuildMember, + Guild, + User, time, } from 'discord.js'; import { @@ -17,7 +17,8 @@ export default last; * @param {GuildMember} target */ export async function last( - target:GuildMember, + target:User, + guild: Guild, ):Promise<{ lastMessage: string; messageList: string; @@ -27,7 +28,6 @@ export async function last( // log.debug(F, `started!`); // This function will find all messages sent by the user in all channels // and return an array of messages - const { guild } = target; let totalMessages = 0; const messageInfo = [] as { channel: string; @@ -83,7 +83,7 @@ export async function last( // Get the most recent message const lastMessage = messageInfo[messageInfo.length - 1]; const lastMessageText = stripIndents` - **${target.displayName}'s** last message was: + **${target.username}'s** last message was: ${time(lastMessage.timestamp, 'd')} ${lastMessage.channel}: ${lastMessage.content}`; // Reverse the order: Display most recent messages first diff --git a/src/global/commands/g.moderate.ts b/src/global/commands/g.moderate.ts deleted file mode 100644 index e6e93a6c2..000000000 --- a/src/global/commands/g.moderate.ts +++ /dev/null @@ -1,1088 +0,0 @@ -/* eslint-disable max-len */ -import { - time, - Colors, - ActionRowBuilder, - ButtonBuilder, - GuildMember, - TextChannel, - Role, - InteractionReplyOptions, - EmbedBuilder, - ThreadChannel, - MessageComponentInteraction, - User, -} from 'discord.js'; -import { - ButtonStyle, -} from 'discord-api-types/v10'; -import { - user_action_type, user_actions, users, -} from '@prisma/client'; - -import { stripIndents } from 'common-tags'; -import ms from 'ms'; -import { embedTemplate } from '../../discord/utils/embedTemplate'; -import { last } from './g.last'; -import { botBannedUsers } from '../../discord/utils/populateBotBans'; - -export default moderate; - -const F = f(__filename); - -// const teamRoles = [ -// env.ROLE_DIRECTOR, -// env.ROLE_SUCCESSOR, -// env.ROLE_SYSADMIN, -// env.ROLE_LEADDEV, -// env.ROLE_DISCORDADMIN, -// env.ROLE_MODERATOR, -// env.ROLE_TRIPSITTER, -// env.ROLE_TEAMTRIPSIT, -// env.ROLE_TRIPBOT2, -// env.ROLE_TRIPBOT, -// env.ROLE_BOT, -// env.ROLE_DEVELOPER, -// ]; - -const embedVariables = { - NOTE: { - embedColor: Colors.Yellow, - embedTitle: 'Note!', - verb: 'noted', - }, - WARNING: { - embedColor: Colors.Yellow, - embedTitle: 'Warned!', - verb: 'warned', - }, - FULL_BAN: { - embedColor: Colors.Red, - embedTitle: 'Banned!', - verb: 'banned', - }, - 'UN-FULL_BAN': { - embedColor: Colors.Green, - embedTitle: 'Un-banned!', - verb: 'un-banned', - }, - TICKET_BAN: { - embedColor: Colors.Red, - embedTitle: 'Ticket Banned!', - verb: 'banned from using tickets', - }, - 'UN-TICKET_BAN': { - embedColor: Colors.Green, - embedTitle: 'Un-Ticket Banned!', - verb: 'allowed to submit tickets again', - }, - DISCORD_BOT_BAN: { - embedColor: Colors.Red, - embedTitle: 'Discord Bot Banned!', - verb: 'banned from using the Discord bot', - }, - 'UN-DISCORD_BOT_BAN': { - embedColor: Colors.Green, - embedTitle: 'Un-Discord Bot Banned!', - verb: 'allowed to use the Discord bot again', - }, - HELPER_BAN: { - embedColor: Colors.Red, - embedTitle: 'Helper Role Banned!', - verb: 'banned from using the Helper role', - }, - 'UN-HELPER_BAN': { - embedColor: Colors.Green, - embedTitle: 'Un-Helper Role Banned!', - verb: 'allowed to use the Helper role again', - }, - CONTRIBUTOR_BAN: { - embedColor: Colors.Red, - embedTitle: 'Contributor Role Banned!', - verb: 'banned from using the Contributor role', - }, - 'UN-CONTRIBUTOR_BAN': { - embedColor: Colors.Green, - embedTitle: 'Un-Contributor Role Banned!', - verb: 'allowed to use the Contributor role again', - }, - BAN_EVASION: { - embedColor: Colors.Red, - embedTitle: 'Ban Evasion!', - verb: 'banned for evasion', - }, - 'UN-BAN_EVASION': { - embedColor: Colors.Green, - embedTitle: 'Un-Ban Evasion!', - verb: 'un-banned for evasion', - }, - UNDERBAN: { - embedColor: Colors.Red, - embedTitle: 'Underban!', - verb: 'banned for being underage', - }, - 'UN-UNDERBAN': { - embedColor: Colors.Green, - embedTitle: 'Un-Underban!', - verb: 'un-banned for being underage', - }, - TIMEOUT: { - embedColor: Colors.Yellow, - embedTitle: 'Timeout!', - verb: 'timed out', - }, - 'UN-TIMEOUT': { - embedColor: Colors.Green, - embedTitle: 'Untimeout!', - verb: 'removed from time-out', - }, - KICK: { - embedColor: Colors.Orange, - embedTitle: 'Kicked!', - verb: 'kicked', - }, - REPORT: { - embedColor: Colors.Orange, - embedTitle: 'Report!', - verb: 'reported', - }, - INFO: { - embedColor: Colors.Green, - embedTitle: 'Info!', - verb: 'got info on', - }, - FLAGGED: { - embedColor: Colors.Red, - embedTitle: 'Flagged!', - verb: 'flagged', - }, -}; - -const warnButtons = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('acknowledgeButton') - .setLabel('I understand, it wont happen again!') - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId('refusalButton') - .setLabel('Nah, I do what I want!') - .setStyle(ButtonStyle.Danger), -); - -export async function userInfoEmbed(target:GuildMember | User, targetData:users, command: string):Promise { - const targetActionList = { - NOTE: [] as string[], - WARNING: [] as string[], - REPORT: [] as string[], - TIMEOUT: [] as string[], - KICK: [] as string[], - FULL_BAN: [] as string[], - UNDERBAN: [] as string[], - TICKET_BAN: [] as string[], - DISCORD_BOT_BAN: [] as string[], - HELPER_BAN: [] as string[], - CONTRIBUTOR_BAN: [] as string[], - }; - // Populate targetActionList from the db - - const targetActionListRaw = await db.user_actions.findMany({ - where: { - user_id: targetData.id, - }, - orderBy: { - created_at: 'desc', - }, - }); - - // log.debug(F, `targetActionListRaw: ${JSON.stringify(targetActionListRaw, null, 2)}`); - - // for (const action of targetActionListRaw) { - targetActionListRaw.forEach(action => { - // log.debug(F, `action: ${JSON.stringify(action, null, 2)}`); - const actionString = `${action.type} (${time(action.created_at, 'R')}) - ${action.internal_note - ?? 'No note provided'}`; - // log.debug(F, `actionString: ${actionString}`); - targetActionList[action.type as keyof typeof targetActionList].push(actionString); - }); - - // log.debug(F, `targetActionList: ${JSON.stringify(targetActionList, null, 2)}`); - const displayName = (target as GuildMember).displayName ?? (target as User).username; - const tag = (target as GuildMember).user ? (target as GuildMember).user.tag : (target as User).tag; - const iconUrl = (target as GuildMember).user ? (target as GuildMember).user.displayAvatarURL() : (target as User).displayAvatarURL(); - const modlogEmbed = embedTemplate() - // eslint-disable-next-line - .setFooter(null) - .setAuthor({ name: `${displayName} (${tag})`, iconURL: iconUrl }) - .setColor(embedVariables[command as keyof typeof embedVariables].embedColor) - .addFields( - { name: 'Created', value: `${time(((target as GuildMember).user ?? (target as User)).createdAt, 'R')}`, inline: true }, - { name: 'Joined', value: `${(target as GuildMember).joinedAt ? time((target as GuildMember).joinedAt as Date, 'R') : 'Unknown'}`, inline: true }, - { name: 'ID', value: `${target.id}`, inline: true }, - ); - if (targetActionList.NOTE.length > 0) { - modlogEmbed.addFields({ name: '# of Notes', value: `${targetActionList.NOTE.length}`, inline: true }); - } - if (targetActionList.WARNING.length > 0) { - modlogEmbed.addFields({ name: '# of Warns', value: `${targetActionList.WARNING.length}`, inline: true }); - } - if (targetActionList.REPORT.length > 0) { - modlogEmbed.addFields({ name: '# of Reports', value: `${targetActionList.REPORT.length}`, inline: true }); - } - if (targetActionList.TIMEOUT.length > 0) { - modlogEmbed.addFields({ name: '# of Timeouts', value: `${targetActionList.TIMEOUT.length}`, inline: true }); - } - if (targetActionList.KICK.length > 0) { - modlogEmbed.addFields({ name: '# of Kicks', value: `${targetActionList.KICK.length}`, inline: true }); - } - if (targetActionList.FULL_BAN.length > 0) { - modlogEmbed.addFields({ name: '# of Bans', value: `${targetActionList.FULL_BAN.length}`, inline: true }); - } - if (targetActionList.UNDERBAN.length > 0) { - modlogEmbed.addFields({ name: '# of Underbans', value: `${targetActionList.UNDERBAN.length}`, inline: true }); - } - - if (command === 'INFO') { - let infoString = stripIndents` - ${targetActionList.FULL_BAN.length > 0 ? `**Bans**\n${targetActionList.FULL_BAN.join('\n')}` : ''} - ${targetActionList.UNDERBAN.length > 0 ? `**Underbans**\n${targetActionList.UNDERBAN.join('\n')}` : ''} - ${targetActionList.KICK.length > 0 ? `**Kicks**\n${targetActionList.KICK.join('\n')}` : ''} - ${targetActionList.TIMEOUT.length > 0 ? `**Timeouts**\n${targetActionList.TIMEOUT.join('\n')}` : ''} - ${targetActionList.WARNING.length > 0 ? `**Warns**\n${targetActionList.WARNING.join('\n')}` : ''} - ${targetActionList.REPORT.length > 0 ? `**Reports**\n${targetActionList.REPORT.join('\n')}` : ''} - ${targetActionList.NOTE.length > 0 ? `**Notes**\n${targetActionList.NOTE.join('\n')}` : ''} - `; - if (infoString.length === 0) { - infoString = 'Squeaky clean!'; - } - // log.debug(F, `infoString: ${infoString}`); - modlogEmbed.setDescription(infoString); - } - - return modlogEmbed; -} - -export async function linkThread( - discordId: string, - threadId: string, - override: boolean | null, -):Promise { - // Get the targetData from the db - const userData = await db.users.upsert({ - where: { - discord_id: discordId, - }, - create: { - discord_id: discordId, - }, - update: {}, - }); - - if (userData.mod_thread_id === null || override) { - // log.debug(F, `targetData.mod_thread_id is null, updating it`); - await db.users.update({ - where: { - id: userData.id, - }, - data: { - mod_thread_id: threadId, - }, - }); - return null; - } - // log.debug(F, `targetData.mod_thread_id is not null, not updating it`); - return userData.mod_thread_id; -} - -export async function moderate( - actor: GuildMember, - command: user_action_type | 'INFO' | 'UN-FULL_BAN' | 'UN-TICKET_BAN' | 'UN-DISCORD_BOT_BAN' | 'UN-UNDERBAN' | 'UN-BAN_EVASION' | 'UN-TIMEOUT' | 'UN-HELPER_BAN' | 'UN-CONTRIBUTOR_BAN', - targetId: string, - internalNote: string | null, - description: string | null, - duration: number | null, -):Promise { - log.info(F, ` - actor: ${actor} - command: ${command} - targetId: ${targetId} - internalNote: ${internalNote} - description: ${description} - duration: ${duration}`); - - const actorData = await db.users.upsert({ - where: { - discord_id: actor.id, - }, - create: { - discord_id: actor.id, - }, - update: {}, - }); - - const targetData = await db.users.upsert({ - where: { - discord_id: targetId, - }, - create: { - discord_id: targetId, - }, - update: {}, - }); - - let discordMember = {} as GuildMember; - let targetIsMember = false; - try { - discordMember = await actor.guild.members.fetch(targetId); - targetIsMember = true; - } catch (err) { - // Ignore - } - - const discordUser = await discordClient.users.fetch(targetId); - // let targetIsUser = false; - try { - discordMember = await actor.guild.members.fetch(targetId); - // targetIsUser = true; - } catch (err) { - // Ignore - } - - const vendorBan = internalNote?.toLowerCase().includes('vendor') - && command === 'FULL_BAN'; - - if (internalNote?.includes('MEP') || description?.includes('MEP')) { - return { - content: 'You cannot use the word "MEP" here.', - ephemeral: true, - }; - } - - // log.debug(F, `TargetData: ${JSON.stringify(targetData, null, 2)}`); - - // If this is a Warn, ban, timeout or kick, send a message to the user - // Do this first cuz you can't do this if they're not in the guild - if ((description !== '' && description !== null) && 'WARNING, FULL_BAN, TICKET_BAN, DISCORD_BOT_BAN, BAN_EVASION, UNDERBAN, TIMEOUT, KICK'.includes(command)) { - const embed = embedTemplate() - .setColor(embedVariables[command as keyof typeof embedVariables].embedColor) - .setTitle(embedVariables[command as keyof typeof embedVariables].embedTitle); - - let body = stripIndents` - Hey ${discordUser}, I'm sorry to inform that you've been ${embedVariables[command as keyof typeof embedVariables].verb}${duration && command === 'TIMEOUT' ? ` for ${ms(duration, { long: true })}` : ''} by Team TripSit: - - ${description} - - **Do not message a moderator to talk about this!** - `; - - if ('FULL_BAN, BAN_EVASION, UNDERBAN'.includes(command)) { - body = stripIndents`${body}\n\nYou can appeal this decision via the bot dashboard at https://${env.BOT_DOMAIN}! - Evasion bans are permanent, and underban bans are until you turn 18 (based on whatever you said in chat`; - } - - if ('WARNING, TICKET_BAN, DISCORD_BOT_BAN, TIMEOUT, KICK'.includes(command)) { - const channel = await discordClient.channels.fetch(env.CHANNEL_HELPDESK); - body = stripIndents`${body}\n\nYou can discuss this with the mods in ${channel}. Do not argue the rules in public channels!`; - } - - if ('TIMEOUT'.includes(command)) { - const channel = await discordClient.channels.fetch(env.CHANNEL_HELPDESK); - body = stripIndents`${body}\n\nYou can discuss this with the mods in ${channel} when this expires. Do not argue the rules in public channels!`; - } - - if ('WARNING, TIMEOUT, KICK'.includes(command)) { - body = stripIndents`${body}\n\nPlease review the rules so this doesn't happen again!\nhttps:// wiki.tripsit.me/wiki/Terms_of_Service`; - } - - if ('KICK'.includes(command)) { - body = stripIndents`${body}\n\nIf you feel you can follow the rules you can rejoin here: https://discord.gg/tripsit`; - } - - embed.setDescription(body); - - if ('WARNING, TIMEOUT'.includes(command)) { - try { - const message = await discordMember.user.send({ embeds: [embed], components: [warnButtons] }); - const filter = (i: MessageComponentInteraction) => i.user.id === discordMember.user.id; - const collector = message.createMessageComponentCollector({ filter, time: 0 }); - - collector.on('collect', async (i: MessageComponentInteraction) => { - if (i.customId === 'acknowledgeButton') { - const targetChan = await discordClient.channels.fetch(targetData.mod_thread_id as string) as TextChannel; - if (targetChan) { - await targetChan.send({ - embeds: [embedTemplate() - .setColor(Colors.Green) - .setDescription(`${discordMember.user.username} has acknowledged their warning.`)], - }); - } - // remove the components from the message - await i.update({ components: [] }); - i.user.send('Thanks for understanding! We appreciate your cooperation and will consider this in the future!'); - } else if (i.customId === 'refusalButton') { - const targetChan = await discordClient.channels.fetch(targetData.mod_thread_id as string) as TextChannel; - await targetChan.send({ - embeds: [embedTemplate() - .setColor(Colors.Red) - .setDescription(`${discordUser.username} has refused their warning and was kicked.`)], - }); - // remove the components from the message - await i.update({ components: [] }); - i.user.send('Thanks for admitting this, you\'ve been removed from the guild. You can rejoin if you ever decide to cooperate.'); - const guild = await discordClient.guilds.fetch(env.DISCORD_GUILD_ID); - await guild.members.kick(discordUser, 'Refused to acknowledge warning'); - } - }); - } catch (error) { - // Ignore - } - } else { - try { - if (!vendorBan && targetIsMember) { - await discordMember.user.send({ embeds: [embed] }); - } - } catch (error) { - // Ignore - } - } - } - - const noReason = 'No reason provided'; - let extraMessage = ''; - - let actionData = { - user_id: targetData.id, - type: {} as user_action_type, - ban_evasion_related_user: null as string | null, - description, - internal_note: internalNote, - expires_at: null as Date | null, - repealed_by: null as string | null, - repealed_at: null as Date | null, - created_by: actorData.id, - created_at: new Date(), - } as user_actions; - - // Perform actions - if (command === 'TIMEOUT') { - actionData.type = 'TIMEOUT' as user_action_type; - actionData.expires_at = new Date(Date.now() + (duration as number)); - try { - await discordMember.timeout(duration, internalNote ?? noReason); - } catch (err) { - log.error(F, `Error: ${err}`); - } - } else if (command === 'UN-TIMEOUT') { - actionData.type = 'TIMEOUT' as user_action_type; - // Get the current timeout record from the DB - - const record = await db.user_actions.findMany({ - where: { - user_id: targetData.id, - repealed_at: null, - type: 'TIMEOUT', - }, - orderBy: { - created_at: 'desc', - }, - }); - - if (record.length > 0) { - [actionData] = record; - } - - actionData.repealed_at = new Date(); - actionData.repealed_by = actorData.id; - - try { - await discordMember.timeout(0, internalNote ?? noReason); - // log.debug(F, `I untimeouted ${target.displayName} because\n '${internalNote}'!`); - } catch (err) { - log.error(F, `Error: ${err}`); - } - } else if (command === 'FULL_BAN') { - actionData.type = 'FULL_BAN' as user_action_type; - targetData.removed_at = new Date(); - // await usersUpdate(targetData); - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - removed_at: new Date(), - }, - }); - - try { - const deleteMessageValue = duration ?? 0; - if (deleteMessageValue > 0 && targetIsMember) { - // log.debug(F, `I am deleting ${deleteMessageValue} days of messages!`); - const response = await last(discordMember); - extraMessage = `${discordMember.displayName}'s last ${response.messageCount} (out of ${response.totalMessages}) messages before being banned :\n${response.messageList}`; - } - const targetGuild = await discordClient.guilds.fetch(env.DISCORD_GUILD_ID); - // log.debug(F, `Days to delete: ${deleteMessageValue}`); - log.info(F, `target: ${discordUser.id} | deleteMessageValue: ${deleteMessageValue} | internalNote: ${internalNote ?? noReason}`); - targetGuild.bans.create(discordUser, { deleteMessageSeconds: deleteMessageValue / 1000, reason: internalNote ?? noReason }); - } catch (err) { - log.error(F, `Error: ${err}`); - } - } else if (command === 'UN-FULL_BAN') { - actionData.type = 'FULL_BAN' as user_action_type; - - targetData.removed_at = null; - - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - removed_at: null, - }, - }); - - const record = await db.user_actions.findMany({ - where: { - user_id: targetData.id, - repealed_at: null, - type: 'FULL_BAN', - }, - orderBy: { - created_at: 'desc', - }, - }); - - if (record.length > 0) { - [actionData] = record; - } - actionData.repealed_at = new Date(); - actionData.repealed_by = actorData.id; - - try { - const targetGuild = await discordClient.guilds.fetch(env.DISCORD_GUILD_ID); - await targetGuild.bans.fetch(); - await targetGuild.bans.remove(discordUser, internalNote ?? noReason); - } catch (err) { - log.error(F, `Error: ${err}`); - } - } else if (command === 'UNDERBAN') { - actionData.type = 'UNDERBAN' as user_action_type; - targetData.removed_at = new Date(); - - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - removed_at: new Date(), - }, - }); - - try { - const targetGuild = await discordClient.guilds.fetch(env.DISCORD_GUILD_ID); - targetGuild.bans.create(discordUser, { reason: internalNote ?? noReason }); - } catch (err) { - log.error(F, `Error: ${err}`); - } - } else if (command === 'UN-UNDERBAN') { - actionData.type = 'UNDERBAN' as user_action_type; - targetData.removed_at = null; - - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - removed_at: null, - }, - }); - - const record = await db.user_actions.findMany({ - where: { - user_id: targetData.id, - repealed_at: null, - type: 'UNDERBAN', - }, - orderBy: { - created_at: 'desc', - }, - }); - - if (record.length > 0) { - [actionData] = record; - } - actionData.repealed_at = new Date(); - actionData.repealed_by = actorData.id; - - try { - const targetGuild = await discordClient.guilds.fetch(env.DISCORD_GUILD_ID); - await targetGuild.bans.fetch(); - await targetGuild.bans.remove(discordUser, internalNote ?? noReason); - } catch (err) { - log.error(F, `Error: ${err}`); - } - } else if (command === 'TICKET_BAN') { - actionData.type = 'TICKET_BAN' as user_action_type; - - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - ticket_ban: true, - }, - }); - } else if (command === 'UN-TICKET_BAN') { - actionData.type = 'TICKET_BAN' as user_action_type; - targetData.ticket_ban = false; - - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - ticket_ban: false, - }, - }); - - const record = await db.user_actions.findMany({ - where: { - user_id: targetData.id, - repealed_at: null, - type: 'TICKET_BAN', - }, - orderBy: { - created_at: 'desc', - }, - }); - - if (record.length > 0) { - [actionData] = record; - } - actionData.repealed_at = new Date(); - actionData.repealed_by = actorData.id; - } else if (command === 'DISCORD_BOT_BAN') { - actionData.type = 'DISCORD_BOT_BAN' as user_action_type; - targetData.discord_bot_ban = true; - - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - discord_bot_ban: true, - }, - }); - - botBannedUsers.push(targetId); - } else if (command === 'UN-DISCORD_BOT_BAN') { - actionData.type = 'DISCORD_BOT_BAN' as user_action_type; - - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - discord_bot_ban: false, - }, - }); - - const record = await db.user_actions.findMany({ - where: { - user_id: targetData.id, - repealed_at: null, - type: 'DISCORD_BOT_BAN', - }, - orderBy: { - created_at: 'desc', - }, - }); - - if (record.length > 0) { - [actionData] = record; - } - actionData.repealed_at = new Date(); - actionData.repealed_by = actorData.id; - - // Remove the user from the botBannedUsers list - const index = botBannedUsers.indexOf(targetId); - if (index > -1) { - botBannedUsers.splice(index, 1); - } - } else if (command === 'BAN_EVASION') { - actionData.type = 'BAN_EVASION' as user_action_type; - - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - removed_at: new Date(), - }, - }); - } else if (command === 'UN-BAN_EVASION') { - actionData.type = 'BAN_EVASION' as user_action_type; - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - removed_at: null, - }, - }); - - const record = await db.user_actions.findMany({ - where: { - user_id: targetData.id, - repealed_at: null, - type: 'BAN_EVASION', - }, - orderBy: { - created_at: 'desc', - }, - }); - - if (record.length > 0) { - [actionData] = record; - } - actionData.repealed_at = new Date(); - actionData.repealed_by = actorData.id; - } else if (command === 'NOTE') { - actionData.type = 'NOTE' as user_action_type; - } else if (command === 'REPORT') { - actionData.type = 'REPORT' as user_action_type; - } else if (command === 'KICK') { - actionData.type = 'KICK' as user_action_type; - try { - await discordMember.kick(); - } catch (err) { - log.error(F, `Error: ${err}`); - } - } else if (command === 'WARNING') { - actionData.type = 'WARNING' as user_action_type; - } else if (command === 'HELPER_BAN') { - actionData.type = 'HELPER_BAN' as user_action_type; - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - helper_role_ban: true, - }, - }); - botBannedUsers.push(targetId); - } else if (command === 'UN-HELPER_BAN') { - actionData.type = 'HELPER_BAN' as user_action_type; - - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - helper_role_ban: false, - }, - }); - - const record = await db.user_actions.findMany({ - where: { - user_id: targetData.id, - repealed_at: null, - type: 'HELPER_BAN', - }, - orderBy: { - created_at: 'desc', - }, - }); - - if (record.length > 0) { - [actionData] = record; - } - actionData.repealed_at = new Date(); - actionData.repealed_by = actorData.id; - } else if (command === 'CONTRIBUTOR_BAN') { - actionData.type = 'CONTRIBUTOR_BAN' as user_action_type; - targetData.contributor_role_ban = true; - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - contributor_role_ban: true, - }, - }); - botBannedUsers.push(targetId); - } else if (command === 'UN-CONTRIBUTOR_BAN') { - actionData.type = 'CONTRIBUTOR_BAN' as user_action_type; - targetData.contributor_role_ban = false; - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - contributor_role_ban: false, - }, - }); - - const record = await db.user_actions.findMany({ - where: { - user_id: targetData.id, - repealed_at: null, - type: 'CONTRIBUTOR_BAN', - }, - orderBy: { - created_at: 'desc', - }, - }); - - if (record.length > 0) { - [actionData] = record; - } - actionData.repealed_at = new Date(); - actionData.repealed_by = actorData.id; - } - - if (command !== 'INFO') { - // This needs to happen before creating the modlog embed - // await useractionsSet(actionData); - if (actionData.id) { - await db.user_actions.upsert({ - where: { - id: actionData.id, - }, - create: actionData, - update: actionData, - }); - } else { - await db.user_actions.create({ - data: actionData, - }); - } - } - - const modlogEmbed = await userInfoEmbed(discordUser, targetData, command); - - // If this is the info command then return with info - if (command === 'INFO') { - // log.debug(F, `Member: ${JSON.stringify(target)}`); - // log.debug(F, `User: ${JSON.stringify(target.user)}`); - let trollScore = 0; - let tsReasoning = ''; - - // Calculate how like it is that this user is a troll. - // This is based off of factors like, how old is their account, do they have a profile picture, how many other guilds are they in, etc. - const diff = Math.abs(Date.now() - Date.parse(discordMember.user.createdAt.toString())); - const years = Math.floor(diff / (1000 * 60 * 60 * 24 * 365)); - const months = Math.floor(diff / (1000 * 60 * 60 * 24 * 30)); - const weeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7)); - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((diff % (1000 * 60)) / 1000); - if (years > 0) { - trollScore += 0; - tsReasoning += '+0 | Account was created at least a year ago\n'; - } else if (years === 0 && months > 0) { - trollScore += 1; - tsReasoning += '+1 | Account was created months ago\n'; - } else if (months === 0 && weeks > 0) { - trollScore += 2; - tsReasoning += '+2 | Account was created weeks ago\n'; - } else if (weeks === 0 && days > 0) { - trollScore += 3; - tsReasoning += '+3 | Account was created days ago\n'; - } else if (days === 0 && hours > 0) { - trollScore += 4; - tsReasoning += '+4 | Account was created hours ago\n'; - } else if (hours === 0 && minutes > 0) { - trollScore += 5; - tsReasoning += '+5 | Account was created minutes ago\n'; - } else if (minutes === 0 && seconds > 0) { - trollScore += 6; - tsReasoning += '+6 | Account was created seconds ago\n'; - } - - if (discordMember.user.avatarURL()) { - trollScore += 0; - tsReasoning += '+0 | Account has a profile picture\n'; - } else { - trollScore += 1; - tsReasoning += '+1 | Account does not have a profile picture\n'; - } - - if (discordMember.user.bannerURL()) { - trollScore += 0; - tsReasoning += '+0 | Account has a banner\n'; - } else { - trollScore += 1; - tsReasoning += '+1 | Account does not have a banner\n'; - } - - if (discordMember.premiumSince) { - trollScore -= 1; - tsReasoning += '-1 | Account is boosting the guild\n'; - } else { - trollScore += 0; - tsReasoning += '+0 | Account is not boosting the guild\n'; - } - - const errorUnknown = 'unknown-error'; - const errorMember = 'unknown-member'; - const errorPermission = 'no-permission'; - - await discordClient.guilds.fetch(); - const memberTest = await Promise.all(discordClient.guilds.cache.map(async guild => { - try { - await guild.members.fetch(targetId); - // log.debug(F, `User is in guild: ${guild.name}`); - return guild.name; - } catch (err:any) { // eslint-disable-line @typescript-eslint/no-explicit-any - // log.debug(F, `Error: ${err} in ${guild.name}`); - if (err.code === 10007) { - return errorMember; - } - return errorUnknown; - } - })); - - // count how many 'banned' appear in the array - const mutualGuilds = memberTest.filter(item => item !== errorUnknown && item !== errorMember); - // log.debug(F, `mutualGuilds: ${mutualGuilds.join(', ')}`); - - if (mutualGuilds.length > 0) { - trollScore += 0; - tsReasoning += `+0 | I currently share ${mutualGuilds.length} guilds with them\n`; - } else { - trollScore += mutualGuilds.length; - tsReasoning += `+1 | Account is only in this guild, that i can tell - `; - } - - const bannedTest = await Promise.all(discordClient.guilds.cache.map(async guild => { - try { - await guild.bans.fetch(targetId); - // log.debug(F, `User is banned in guild: ${guild.name}`); - return guild.name; - } catch (err:any) { // eslint-disable-line @typescript-eslint/no-explicit-any - // log.debug(F, `Error: ${err} in ${guild.name}`); - if (err.code === 50013) { - // log.debug(F, `I do not have permission to check if ${target.user.tag} is banned in ${guild.name}`); - return errorPermission; - } - if (err.code === 10026) { - // log.debug(F, `Ban not found for ${target.user.tag} in ${guild.name}`); - return 'not-found'; - } - // return nothing - return errorUnknown; - } - })); - - // count how many 'banned' appear in the array - const bannedGuilds = bannedTest.filter(item => item !== errorPermission && item !== 'not-found' && item !== errorUnknown); - // log.debug(F, `Banned Guilds: ${bannedGuilds.join(', ')}`); - - // count how many i didn't have permission to check - const noPermissionGuilds = bannedTest.filter(item => item === errorPermission); - - if (bannedGuilds.length === 0) { - trollScore += 0; - tsReasoning += stripIndents`+0 | Not banned in any other guilds that I can tell - I could not check ${noPermissionGuilds.length} guilds due to permission issues\n`; - } else { - trollScore += bannedGuilds.length; - tsReasoning += stripIndents`+${bannedGuilds.length} | Account is banned in ${bannedGuilds.length} other guilds that I can see - I could not check ${noPermissionGuilds.length} guilds due to permission issues\n`; - } - - modlogEmbed.setDescription(`**TripSit TrollScore: ${trollScore}**\n\`\`\`${tsReasoning}\`\`\` - ${modlogEmbed.data.description}`); - return { embeds: [modlogEmbed] }; - } - - const tripsitGuild = await discordClient.guilds.fetch(env.DISCORD_GUILD_ID); - const roleModerator = await tripsitGuild.roles.fetch(env.ROLE_MODERATOR) as Role; - // const modPing = `Hey ${roleModerator}`; - const timeoutDuration = duration ? ` for ${ms(duration, { long: true })}` : ''; - const summary = `${actor.displayName} ${embedVariables[command as keyof typeof embedVariables].verb} ${discordMember.displayName ?? discordUser.username}${command === 'TIMEOUT' ? timeoutDuration : ''}!`; - const anonSummary = `${discordMember.displayName ?? discordUser.username} was ${embedVariables[command as keyof typeof embedVariables].verb}${command === 'TIMEOUT' ? timeoutDuration : ''}!`; - - let modThread = {} as ThreadChannel; - if (targetData.mod_thread_id) { - log.debug(F, `Mod thread id exists: ${targetData.mod_thread_id}`); - try { - modThread = await tripsitGuild.channels.fetch(targetData.mod_thread_id) as ThreadChannel; - log.debug(F, 'Mod thread exists'); - } catch (err) { - modThread = {} as ThreadChannel; - log.debug(F, 'Mod thread does not exist'); - } - } - - // log.debug(F, `Mod thread: ${JSON.stringify(modThread, null, 2)}`); - - let newModThread = false; - if (!modThread.id && !vendorBan) { - // If the mod thread doesn't exist for whatever reason, maybe it got deleted, make a new one - // If the user we're banning is a vendor, don't make a new one - // Create a new thread in the mod channel - log.debug(F, 'creating mod thread'); - const modChan = await discordClient.channels.fetch(env.CHANNEL_MODERATORS) as TextChannel; - modThread = await modChan.threads.create({ - name: `${discordMember.displayName ?? discordUser.username}`, - autoArchiveDuration: 60, - }); - // log.debug(F, 'created mod thread'); - // Save the thread id to the user - targetData.mod_thread_id = modThread.id; - // await usersUpdate(targetData); - await db.users.update({ - where: { - id: targetData.id, - }, - data: { - mod_thread_id: modThread.id, - }, - }); - log.debug(F, 'saved mod thread id to user'); - newModThread = true; - } - - if (!vendorBan) { - await modThread.send({ - content: stripIndents` - ${summary} - **Reason:** ${internalNote ?? noReason} - **Note sent to user:** ${(description !== '' && description !== null && targetIsMember) ? description : '*No message sent to user*'} - ${command === 'NOTE' && !newModThread ? '' : roleModerator} - `, - embeds: [modlogEmbed], - }); - // log.debug(F, `sent a message to the moderators room`); - if (extraMessage) { - await modThread.send({ content: extraMessage }); - } - } - - const desc = stripIndents` - ${anonSummary} - **Reason:** ${internalNote ?? noReason} - ${(description !== '' && description !== null && !vendorBan && targetIsMember) ? `\n\n**Note sent to user: ${description}**` : ''} - `; - - const response = embedTemplate() - .setAuthor(null) - .setColor(Colors.Yellow) - .setDescription(desc) - .setFooter(null); - - const modlog = await discordClient.channels.fetch(env.CHANNEL_MODLOG) as TextChannel; - modlog.send({ embeds: [response] }); - // log.debug(F, `sent a message to the modlog room`); - - // Return a message to the user who started this, confirming the user was acted on - // log.debug(F, `${target.displayName} has been ${embedVariables[command as keyof typeof embedVariables].verb}!`); - - // log.info(F, `response: ${JSON.stringify(desc, null, 2)}`); - // Take the existing description from response and add to it: - if (command !== 'REPORT') response.setDescription(`${response.data.description}\nYou can access their thread here: ${modThread}`); - return { embeds: [response] }; -} diff --git a/src/global/utils/env.config.ts b/src/global/utils/env.config.ts index cd416fd76..68a1b1efa 100644 --- a/src/global/utils/env.config.ts +++ b/src/global/utils/env.config.ts @@ -140,6 +140,7 @@ export const env = { CATEGORY_COLLABORATION: isProd ? '991688510325133362' : '1052634092425982023', CHANNEL_COLLABVC: isProd ? '' : '1052634128757043220', CHANNEL_GROUPCOLLAB: isProd ? '' : '1052634196352442408', + CHANNEL_COOP_MOD: isProd ? '' : '1098046223409221633', CATEGORY_TEAMTRIPSIT: isProd ? '1002624862151512124' : '1052634096397983764', CHANNEL_INTRODUCTIONS: isProd ? '' : '1052634216464126083', diff --git a/src/prisma/seed.ts b/src/prisma/seed.ts index a67179c85..23af1c07b 100644 --- a/src/prisma/seed.ts +++ b/src/prisma/seed.ts @@ -310,24 +310,40 @@ async function seedIdoseEntry(userId:string): Promise { }); } +async function seedDiscordGuilds(): Promise { + await prisma.discord_guilds.create({ + data: { + id: '960606557622657026', + partner: true, + supporter: true, + cooperative: true, + }, + }); +} + async function seed() { await Promise.all([ - await prisma.rpg_inventory.deleteMany({}), - await prisma.personas.deleteMany({}), - await prisma.ai_images.deleteMany({}), - await prisma.user_actions.deleteMany({}), - await prisma.user_drug_doses.deleteMany({}), - await prisma.user_experience.deleteMany({}), - await prisma.user_reminders.deleteMany({}), - await prisma.user_tickets.deleteMany({}), - await prisma.appeals.deleteMany({}), - await prisma.user_actions.deleteMany({}), - await prisma.ai_usage.deleteMany({}), - await prisma.drug_names.deleteMany({}), - await prisma.drug_variants.deleteMany({}), - await prisma.drug_variant_roas.deleteMany({}), + // These need to happen in any order before removing... + prisma.reaction_roles.deleteMany({}), // discord_guilds + prisma.counting.deleteMany({}), // discord_guilds + prisma.ai_moderation.deleteMany({}), // discord_guilds + prisma.rpg_inventory.deleteMany({}), // discord_guilds + prisma.personas.deleteMany({}), // discord_guilds + prisma.ai_images.deleteMany({}), // users + prisma.user_actions.deleteMany({}), // users + prisma.user_drug_doses.deleteMany({}), // users + prisma.user_experience.deleteMany({}), // users + prisma.user_reminders.deleteMany({}), // users + prisma.user_tickets.deleteMany({}), // users + prisma.appeals.deleteMany({}), // users + prisma.user_actions.deleteMany({}), // users + prisma.ai_usage.deleteMany({}), // users + prisma.drug_names.deleteMany({}), // drugs + prisma.drug_variants.deleteMany({}), // drugs + prisma.drug_variant_roas.deleteMany({}), // drugs ]); + await prisma.discord_guilds.deleteMany({}); await prisma.drugs.deleteMany({}); await prisma.users.deleteMany({}); // Needs to happen last @@ -336,6 +352,8 @@ async function seed() { const userList = await seedUsers(); await seedExperience(userList); + await seedDiscordGuilds(); + // Create drugs, drug names, drug variants, and drug variant ROAs const drugRecords = await seedDrugs(userList[0].id); await seedDrugNames(drugRecords); diff --git a/src/prisma/tripbot/migrations/20240111205448_better_moderation/migration.sql b/src/prisma/tripbot/migrations/20240111205448_better_moderation/migration.sql new file mode 100644 index 000000000..55c578ad8 --- /dev/null +++ b/src/prisma/tripbot/migrations/20240111205448_better_moderation/migration.sql @@ -0,0 +1,15 @@ +-- AlterTable +ALTER TABLE "discord_guilds" ADD COLUMN "channel_helpdesk" TEXT, +ADD COLUMN "channel_mod_log" TEXT, +ADD COLUMN "channel_moderators" TEXT, +ADD COLUMN "cooperative" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "role_moderator" TEXT, +ADD COLUMN "trust_score_limit" INTEGER NOT NULL DEFAULT 5, +ALTER COLUMN "partner" SET DEFAULT false, +ALTER COLUMN "supporter" SET DEFAULT false; + +-- AlterTable +ALTER TABLE "user_actions" ADD COLUMN "guild_id" TEXT NOT NULL DEFAULT '179641883222474752'; + +-- AddForeignKey +ALTER TABLE "user_actions" ADD CONSTRAINT "appeals_guildid_foreign" FOREIGN KEY ("guild_id") REFERENCES "discord_guilds"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/src/prisma/tripbot/schema.prisma b/src/prisma/tripbot/schema.prisma index 4e47b48d1..e398d6f13 100644 --- a/src/prisma/tripbot/schema.prisma +++ b/src/prisma/tripbot/schema.prisma @@ -178,20 +178,27 @@ model discord_guilds { channel_tripsit String? channel_tripsitmeta String? channel_applications String? + channel_moderators String? + channel_mod_log String? + channel_helpdesk String? role_needshelp String? role_tripsitter String? role_helper String? role_techhelp String? + role_moderator String? removed_at DateTime? @db.Timestamptz(6) joined_at DateTime @default(now()) @db.Timestamptz(6) created_at DateTime @default(now()) @db.Timestamptz(6) - partner Boolean @default(true) - supporter Boolean @default(true) + partner Boolean @default(false) + supporter Boolean @default(false) + cooperative Boolean @default(false) premium_role_ids String? + trust_score_limit Int @default(5) appeals appeals[] counting counting[] reaction_roles reaction_roles[] ai_moderation ai_moderation? + user_actions user_actions[] } model drug_articles { @@ -298,6 +305,7 @@ model rpg_inventory { model user_actions { id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid user_id String @db.Uuid + guild_id String @default("179641883222474752") type user_action_type ban_evasion_related_user String? @db.Uuid description String? @@ -311,6 +319,8 @@ model user_actions { users_user_actions_created_byTousers users @relation("user_actions_created_byTousers", fields: [created_by], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "useractions_createdby_foreign") users_user_actions_repealed_byTousers users? @relation("user_actions_repealed_byTousers", fields: [repealed_by], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "useractions_repealedby_foreign") users_user_actions_user_idTousers users @relation("user_actions_user_idTousers", fields: [user_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "useractions_userid_foreign") + + discord_guilds discord_guilds @relation(fields: [guild_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "appeals_guildid_foreign") } model user_drug_doses {