Merge branch 'warnings'

This commit is contained in:
Tarrgon
2025-05-29 09:42:30 -04:00
13 changed files with 280 additions and 20 deletions
+15
View File
@@ -0,0 +1,15 @@
import { ButtonInteraction, Client } from 'discord.js';
import { getWarningMessage } from '../utils/warning-utils';
export default {
name: 'warning-next',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate();
const message = await getWarningMessage(userId, parseInt(page) + 1);
if (!message) return;
interaction.editReply(message);
}
};
+15
View File
@@ -0,0 +1,15 @@
import { ButtonInteraction, Client} from 'discord.js';
import { getWarningMessage } from '../utils/warning-utils';
export default {
name: 'warning-previous',
handler: async function (client: Client, interaction: ButtonInteraction, userId: string, page: string) {
await interaction.deferUpdate();
const message = await getWarningMessage(userId, parseInt(page) - 1);
if (!message) return;
interaction.editReply(message);
}
};
+1 -1
View File
@@ -147,7 +147,7 @@ async function dumpPhrases(interaction: ChatInputCommandInteraction) {
}
async function addPhrase(interaction: ChatInputCommandInteraction, phrase: string, group: SubcommandGroup) {
await Database.addTicketPhrase(group == 'admin' ? 'admin' : interaction.user.id, phrase);
await Database.putTicketPhrase(group == 'admin' ? 'admin' : interaction.user.id, phrase);
interaction.reply(`Phrases matching "${phrase}" will now alert ${group == 'admin' ? 'admins' : 'you'}.`);
}
+6 -4
View File
@@ -65,12 +65,14 @@ export default {
.setRequired(false)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
let response = '';
const settings = await Database.getGuildSettings(interaction.guildId!);
if (!settings) {
await Database.addGuild(interaction.guildId!);
await Database.putGuild(interaction.guildId!);
}
const generalChannel = interaction.options.getChannel('general-channel');
@@ -132,7 +134,7 @@ export default {
const addCategory = interaction.options.getChannel('add-staff-category');
if (addCategory) {
if (addCategory.type == ChannelType.GuildCategory) {
await Database.addGuildStaffCategory(interaction.guildId!, addCategory.id);
await Database.putGuildStaffCategory(interaction.guildId!, addCategory.id);
response += `Added ${addCategory.toString()} as a staff category.\n`;
} else {
@@ -153,8 +155,8 @@ export default {
}
}
if (response.length == 0) return interaction.reply({ content: 'No settings provided.', flags: [MessageFlags.Ephemeral] });
if (response.length == 0) return interaction.editReply({ content: 'No settings provided.' });
interaction.reply({ content: response, flags: [MessageFlags.Ephemeral] });
interaction.editReply({ content: response });
}
};
+116
View File
@@ -0,0 +1,116 @@
import { ApplicationCommandOptionType, ApplicationIntegrationType, AutocompleteInteraction, ChatInputCommandInteraction, Client, GuildBasedChannel, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';
import { Database } from '../shared/Database';
import { channelIsInStaffCategory, getE621User } from '../utils';
import { config } from '../config';
import { getWarningMessage } from '../utils/warning-utils';
export default {
name: 'warnings',
data: new SlashCommandBuilder()
.setName('warnings')
.setDescription('Add, view, or remove user warnings.')
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall)
.setContexts(InteractionContextType.Guild)
.setDefaultMemberPermissions(PermissionFlagsBits.BanMembers)
.addSubcommand(subcommand =>
subcommand
.setName('add')
.setDescription('Add warnings to a user.')
.addUserOption(option =>
option
.setName('user')
.setDescription('The user to add a warning to.')
.setRequired(true)
)
.addStringOption(option =>
option
.setName('reason')
.setDescription('The reason for the warning.')
.setRequired(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('remove')
.setDescription('Remove warnings from a user.')
.addUserOption(option =>
option
.setName('user')
.setDescription('The user to remove a warning from.')
.setRequired(true)
)
.addIntegerOption(option =>
option
.setName('warning')
.setDescription('The warning to remove.')
.setRequired(true)
.setAutocomplete(true)
)
)
.addSubcommand(subcommand =>
subcommand
.setName('list')
.setDescription("List a user's warnings")
.addUserOption(option =>
option
.setName('user')
.setDescription('The user to list the warnings of.')
.setRequired(true)
)
),
handler: async function (client: Client, interaction: ChatInputCommandInteraction) {
const subcommand = interaction.options.getSubcommand(true);
const user = interaction.options.getUser('user', true);
const isStaffChannel = await channelIsInStaffCategory(interaction.channel as GuildBasedChannel);
if (isStaffChannel) await interaction.deferReply();
else await interaction.deferReply({ flags: [MessageFlags.Ephemeral] });
if (subcommand == 'add') {
const reason = interaction.options.getString('reason', true);
await Database.putWarning(user.id, reason, interaction.user.id);
if (isStaffChannel) interaction.editReply('Added warning.');
else interaction.editReply({ content: 'Added warning.' });
} else if (subcommand == 'remove') {
const warningId = interaction.options.getInteger('warning', true);
if (await Database.removeWarning(warningId)) {
if (isStaffChannel) interaction.editReply('Removed warning.');
else interaction.editReply({ content: 'Removed warning.' });
} else {
if (isStaffChannel) interaction.editReply('Warning not found.');
else interaction.editReply({ content: 'Warning not found.' });
}
} else if (subcommand == 'list') {
const warningMessage = await getWarningMessage(user.id, 1);
if (!warningMessage) return interaction.editReply(`No warnings found for <@${user.id}>`);
interaction.editReply(warningMessage);
}
},
autoComplete: async function (client: Client, interaction: AutocompleteInteraction) {
const user = interaction.options.get('user');
if (!user || user.type != ApplicationCommandOptionType.User) return interaction.respond([]);
const userId = user.value as string;
const value = interaction.options.getFocused().toLowerCase();
const warnings = await Database.getWarnings(userId);
const toRespond = warnings.filter(w => !value ? true : w.reason.toLowerCase().includes(value));
if (toRespond.length > 25) toRespond.length = 25;
interaction.respond(toRespond.map((w) => {
return {
name: w.reason.substring(0, 50),
value: w.id
};
}));
}
};
+3 -3
View File
@@ -5,6 +5,7 @@ import { getE621Post, getE621PostByMd5, getPostUrl, hasBlacklistedTags } from '.
import { Database } from '../shared/Database';
import { logDeletion, logEdit } from '../utils/message-logger';
import { isEdited } from '../utils/message-utils';
import { channelIsInStaffCategory } from '../utils';
export type Message<InGuild extends boolean = boolean> = OmitPartialGroupDMChannel<DiscordMessage<InGuild>>;
export type Partial = OmitPartialGroupDMChannel<PartialMessage>;
@@ -142,12 +143,11 @@ async function wikiPageHandler(message: Message, matchedGroups: RegExpExecArray[
}
async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promise<boolean> {
const staffCategories = await Database.getGuildStaffCategories(message.guildId!);
const blacklistedIds: number[] = [];
const channel = await message.channel.fetch() as GuildTextBasedChannel;
const isStaffChannel = await channelIsInStaffCategory(channel);
for (const post of posts) {
if (hasBlacklistedTags(post)) {
@@ -159,7 +159,7 @@ async function blacklistIfNecessary(message: Message, posts: E621Post[]): Promis
await message.delete();
if (channel.parentId && staffCategories.includes(channel.parentId)) {
if (channel.parentId && isStaffChannel) {
await message.channel.send({
content: `_sucks message into the void._ ${message.author.toString()} nono, don't post links to ${blacklistedIds.length == 1 ? `post ${blacklistedIds[0]}` : `posts \`${blacklistedIds.join('`, `')}\``}. See rule #5.b for more details.`,
allowedMentions: {
+6 -5
View File
@@ -1,5 +1,5 @@
import 'source-map-support/register';
import { Client as DiscordClient, GatewayIntentBits, Guild, Partials } from 'discord.js';
import { Client as DiscordClient, GatewayIntentBits, Guild, MessageFlags, Partials } from 'discord.js';
import { config } from './config';
import { Handler } from './types';
import { initIfNecessary, loadHandlersFrom, openRedisClient, refreshCommands } from './utils';
@@ -52,7 +52,7 @@ client.on('interactionCreate', async (interaction) => {
)
interaction.reply({
content: 'Bot is still starting up. Please wait a few seconds.',
ephemeral: true,
flags: [MessageFlags.Ephemeral],
});
return;
@@ -84,7 +84,7 @@ client.on('interactionCreate', async (interaction) => {
for (const button of buttons) {
if (id == button.name) {
button.handler(client, interaction, interaction.customId.split('_')[1]);
button.handler(client, interaction, ...interaction.customId.split('_').slice(1));
return;
}
}
@@ -94,7 +94,7 @@ client.on('interactionCreate', async (interaction) => {
for (const modal of modals) {
if (id == modal.name) {
modal.handler(client, interaction, interaction.customId.split('_')[1]);
modal.handler(client, interaction, ...interaction.customId.split('_').slice(1));
return;
}
}
@@ -104,7 +104,7 @@ client.on('interactionCreate', async (interaction) => {
for (const menu of menus) {
if (id == menu.name) {
menu.handler(client, interaction, interaction.customId.split('_')[1]);
menu.handler(client, interaction, ...interaction.customId.split('_').slice(1));
return;
}
}
@@ -151,5 +151,6 @@ client.on('threadCreate', handleThreadCreate);
client.on('voiceStateUpdate', handleVoiceStateUpdate);
client.on('error', console.error);
process.on('uncaughtException', console.error);
client.login(config.DISCORD_TOKEN);
+48 -6
View File
@@ -3,7 +3,7 @@ import { open, Database as SqliteDatabase } from 'sqlite';
import { config } from '../config';
import DiscordOAuth2 from 'discord-oauth2';
import { serializeMessage, wait } from '../utils';
import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase } from '../types';
import { GuildSettings, LoggedMessage, TicketMessage, TicketPhrase, Warning } from '../types';
import { Message } from '../events';
const DB_SCHEMA = `
@@ -52,6 +52,14 @@ const DB_SCHEMA = `
user_id TEXT NOT NULL,
phrase TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS warnings (
id INTEGER PRIMARY KEY,
user_id TEXT,
reason TEXT,
mod_id TEXT,
timestamp datetime NOT NULL DEFAULT (datetime('now', 'localtime'))
);
`;
export class Database {
@@ -75,6 +83,8 @@ export class Database {
console.log('SQLite database ensured');
}
// -- START WHOIS --
static async getE621Ids(discordId: string): Promise<number[]> {
const ids = await Database.db.all<{ user_id: number }[]>('SELECT DISTINCT user_id FROM discord_names WHERE discord_id = ?', discordId);
@@ -87,7 +97,7 @@ export class Database {
return ids.map(r => r.discord_id);
}
static async getCombinedIds(id: string) {
static async getCombinedIds(id: string): Promise<{ userId: string, discordId: string }[]> {
const ids = await Database.db.all<{ discord_id: string, user_id: number }[]>(`
WITH RECURSIVE rec AS (
SELECT DISTINCT d1.user_id, d1.discord_id, 1 AS depth FROM discord_names d1 WHERE d1.user_id = ? or d1.discord_id = ?
@@ -98,18 +108,22 @@ export class Database {
WHERE depth <= 5 AND rec.depth = depth
) SELECT DISTINCT user_id, discord_id FROM rec`, id, id);
return ids.map(r => ({ user_id: r.user_id.toString(), discord_id: r.discord_id }));
return ids.map(r => ({ userId: r.user_id.toString(), discordId: r.discord_id }));
}
static async putUser(id: number, user: DiscordOAuth2.User) {
await Database.db.run('INSERT INTO discord_names(user_id, discord_id, discord_username) VALUES (?, ?, ?)', id, user.id, user.username);
}
// -- END WHOIS --
// -- START SETTINGS --
static async getGuildSettings(guildId: string): Promise<GuildSettings | undefined> {
return await Database.db.get<GuildSettings>('SELECT * FROM settings WHERE guild_id = ?', guildId);
}
static async addGuild(guildId: string) {
static async putGuild(guildId: string) {
await Database.db.run('INSERT INTO settings(guild_id) VALUES (?)', guildId);
}
@@ -149,7 +163,7 @@ export class Database {
return settings.staff_categories.split(',');
}
static async addGuildStaffCategory(guildId: string, categoryId: string) {
static async putGuildStaffCategory(guildId: string, categoryId: string) {
const categories = await Database.getGuildStaffCategories(guildId);
categories.push(categoryId);
@@ -174,6 +188,10 @@ export class Database {
return true;
}
// -- END SETTINGS --
// START MESSAGE LOGS --
static async putMessage(message: Message): Promise<boolean> {
try {
const serializedMessage = serializeMessage(message);
@@ -206,6 +224,10 @@ export class Database {
}
}
// -- END MESSAGE LOGS --
// -- START TICKETS --
static async putTicket(ticketId: number, messageId: string) {
await Database.db.run('INSERT INTO tickets(id, message_id) VALUES (?, ?)', ticketId, messageId);
}
@@ -215,7 +237,7 @@ export class Database {
return ticket?.message_id;
}
static async addTicketPhrase(userId: string, phrase: string) {
static async putTicketPhrase(userId: string, phrase: string) {
await Database.db.run('INSERT INTO ticket_phrases(user_id, phrase) VALUES (?, ?)', userId, phrase);
}
@@ -234,4 +256,24 @@ export class Database {
cb(ticketPhrase);
});
}
// -- END TICKETS --
// -- START WARNINGS --
static async putWarning(userId: string, reason: string, modId: string) {
await Database.db.run('INSERT INTO warnings(user_id, reason, mod_id) VALUES (?, ?, ?)', userId, reason, modId);
}
static async removeWarning(id: number): Promise<boolean> {
const res = await Database.db.run('DELETE from warnings WHERE id = ?', id);
return (res.changes ?? 0) > 0;
}
static async getWarnings(userId: string): Promise<Warning[]> {
return await Database.db.all<Warning[]>('SELECT * from warnings WHERE user_id = ?', userId);
}
// -- END WARNINGS --
}
+8
View File
@@ -30,4 +30,12 @@ export type TicketPhrase = {
id: number
user_id: string
phrase: string
}
export type Warning = {
id: number
user_id: string
reason: string
mod_id: string
timestamp: string
}
+1 -1
View File
@@ -1,5 +1,5 @@
export * from './command';
export * from './database-types';
export * from './e621-types';
export * from './handler';
export * from './helper-types';
export * from './database-types';
+8
View File
@@ -0,0 +1,8 @@
import { Channel, GuildBasedChannel, GuildChannel, GuildTextBasedChannel, TextBasedChannel, VoiceBasedChannel } from 'discord.js';
import { Database } from '../shared/Database';
export async function channelIsInStaffCategory(channel: GuildBasedChannel) {
const staffCategories = await Database.getGuildStaffCategories(channel.guildId);
return staffCategories.includes(channel.parentId!);
}
+2
View File
@@ -1,5 +1,6 @@
export * from './array-utils';
export * from './audit-log-utils';
export * from './channel-utils';
export * from './commands';
export * from './e621-ticket-listener';
export * from './e621-utils';
@@ -11,3 +12,4 @@ export * from './string-utils';
export * from './ticket-utils';
export * from './wait';
export * from './whois';
export * from './warning-utils';
+51
View File
@@ -0,0 +1,51 @@
import { ActionRowBuilder, APIEmbedField, ButtonBuilder, ButtonStyle, EmbedBuilder, GuildMember, MessageActionRowComponentBuilder, time } from 'discord.js';
import { Database } from '../shared/Database';
import { Warning } from '../types';
type MessageContent = { content: string, components: ActionRowBuilder<ButtonBuilder>[] };
const WARNINGS_PER_PAGE = 5;
function getWarningText(warning: Warning): string {
const timestamp = time(new Date(warning.timestamp));
return `### Warning from <@${warning.mod_id}> (${timestamp}):\n${warning.reason}`;
}
export async function getWarningMessage(userId: string, page: number): Promise<MessageContent | null> {
const warnings = await Database.getWarnings(userId);
page = page - 1;
const maxPage = Math.floor(warnings.length / WARNINGS_PER_PAGE);
if (warnings.length == 0) return null;
const warningTexts: string[] = [];
for (let i = page * WARNINGS_PER_PAGE; i < page * WARNINGS_PER_PAGE + WARNINGS_PER_PAGE; i++) {
if (i >= warnings.length) break;
warningTexts.push(getWarningText(warnings[i]));
}
const prevPage = new ButtonBuilder()
.setLabel('Previous Page')
.setCustomId(`warning-previous_${userId}_${page + 1}`)
.setDisabled(page == 0)
.setStyle(ButtonStyle.Primary);
const nextPage = new ButtonBuilder()
.setLabel('Next Page')
.setCustomId(`warning-next_${userId}_${page + 1}`)
.setDisabled(page >= maxPage)
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(prevPage, nextPage);
return {
content: `<@${userId}>'s Warnings\n` + warningTexts.join('\n\n') + `\n\n-# Page ${page + 1}/${maxPage + 1}`,
components: [row]
};
}