diff --git a/src/commands/records.ts b/src/commands/records.ts new file mode 100644 index 0000000..0c87a8a --- /dev/null +++ b/src/commands/records.ts @@ -0,0 +1,48 @@ +import { ApplicationIntegrationType, BitFieldResolvable, ChatInputCommandInteraction, Client, GuildBasedChannel, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils'; +import { getRecordMessageFromDiscordId } from '../utils/record-utils'; + +export default { + name: 'records', + data: new SlashCommandBuilder() + .setName('records') + .setDescription("Get a user's on-site records.") + .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) + .setContexts(InteractionContextType.Guild) + .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) + .addUserOption(option => + option + .setName('user') + .setDescription('The discord user to find the e621 user of.') + .setRequired(false) + ) + .addStringOption(option => + option + .setName('id') + .setDescription('The discord user id to find the e621 user of.') + .setRequired(false) + ), + handler: async function (client: Client, interaction: ChatInputCommandInteraction) { + const isStaffChannel = await channelIsInStaffCategory(interaction.channel as GuildBasedChannel); + + if (isStaffChannel) await interaction.deferReply(); + else await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + + if (!interaction.guild) return interaction.editReply('This command must be used in a server'); + + const user = interaction.options.getUser('user'); + const id = interaction.options.getString('id'); + + if (!user && !id) { + return interaction.reply({ content: 'No user or id given.', flags: [MessageFlags.Ephemeral] }); + } + + const idToUse = (user?.id ?? id) as string; + + const recordMessage = await getRecordMessageFromDiscordId(idToUse, 1, interaction.guild); + + if (!recordMessage) return interaction.editReply('No records found on any linked accounts.'); + + interaction.editReply(recordMessage); + } +}; \ No newline at end of file diff --git a/src/commands/whois.context-menu.ts b/src/context-menus/whois.ts similarity index 92% rename from src/commands/whois.context-menu.ts rename to src/context-menus/whois.ts index 89b389f..10cdc2b 100644 --- a/src/commands/whois.context-menu.ts +++ b/src/context-menus/whois.ts @@ -2,9 +2,9 @@ import { ApplicationIntegrationType, Client, InteractionContextType, PermissionF import { channelIsInStaffCategory, handleWhoIsInteraction } from '../utils'; export default { - name: 'whois-context', + name: 'Whois', data: new ContextMenuCommandBuilder() - .setName('whois-context') + .setName('Whois') .setIntegrationTypes(ApplicationIntegrationType.GuildInstall) .setContexts(InteractionContextType.Guild) .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers) diff --git a/src/index.ts b/src/index.ts index 0f7815e..1c5c5fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,11 +31,13 @@ const client = new DiscordClient({ }); const commands: Handler[] = []; +const contextMenus: Handler[] = []; const buttons: Handler[] = []; const modals: Handler[] = []; const menus: Handler[] = []; loadHandlersFrom('commands', commands); +loadHandlersFrom('context-menus', contextMenus); loadHandlersFrom('buttons', buttons); loadHandlersFrom('modals', modals); loadHandlersFrom('menus', menus); @@ -59,14 +61,22 @@ client.on('interactionCreate', async (interaction) => { return; } - if (interaction.isChatInputCommand() || interaction.isContextMenuCommand()) { - // Handle chat and context menu commands. + if (interaction.isChatInputCommand()) { + // Handle chat for (const command of commands) { if (interaction.commandName == command.name) { command.handler(client, interaction); return; } } + } else if (interaction.isContextMenuCommand()) { + // Handle context menu commands. + for (const command of contextMenus) { + if (interaction.commandName == command.name) { + command.handler(client, interaction); + return; + } + } } else if (interaction.isAutocomplete()) { // Handle autocomplete requests. for (const command of commands) { diff --git a/src/types/e621-types.ts b/src/types/e621-types.ts index a76f25e..d1b8ac4 100644 --- a/src/types/e621-types.ts +++ b/src/types/e621-types.ts @@ -138,4 +138,18 @@ export type Ban = { export type BanUpdate = { action: 'create' | 'update' | 'delete' ban: Ban -}; \ No newline at end of file +}; + +export type RecordCategory = 'positive' | 'negative' | 'neutral' + +export type Record = { + id: number + user_id: number + creator_id: number + created_at: string + body: string + category: RecordCategory + updated_at: string + updater_id: number + is_deleted: boolean +} \ No newline at end of file diff --git a/src/types/helper-types.ts b/src/types/helper-types.ts index 2fabdb5..30612cd 100644 --- a/src/types/helper-types.ts +++ b/src/types/helper-types.ts @@ -1,4 +1,6 @@ -import { APIRole, PermissionsBitField, TextChannel } from 'discord.js'; +import { ActionRowBuilder, APIRole, ButtonBuilder, EmbedBuilder, PermissionsBitField, TextChannel } from 'discord.js'; + +export type MessageContent = { content?: string, embeds?: EmbedBuilder[], components: ActionRowBuilder[] }; export type RoleChangeLog = { key: '$add' | '$remove', diff --git a/src/utils/alt-utils.ts b/src/utils/alt-utils.ts index 274b243..eee9df0 100644 --- a/src/utils/alt-utils.ts +++ b/src/utils/alt-utils.ts @@ -3,6 +3,14 @@ import { Database } from '../shared/Database'; import { userIsBanned } from './e621-utils'; import { config } from '../config'; +type AltData = { + type: 'e621' | 'discord' + thisId: number | string + banned: boolean + alts: AltData[] +}; + + export async function getE621Alts(discordId: string, guild: Guild, depth = 1, ignore: number[] = []): Promise { const e621UserIds = await Database.getE621Ids(discordId); @@ -42,4 +50,57 @@ export async function getDiscordAlts(e621Id: number, guild: Guild, depth = 1, ig } return content; +} + +export async function comprehensiveAltLookupFromDiscord(discordId: string, guild: Guild): Promise { + return getE621AltData(discordId, guild); +} + +export async function comprehensiveAltLookupFromE621(e621Id: number, guild: Guild): Promise { + return getDiscordAltData(e621Id, guild); +} + +async function getE621AltData(discordId: string, guild: Guild, depth = 1, ignore: number[] = []): Promise { + const e621UserIds = await Database.getE621Ids(discordId); + + const toIgnore = ignore.concat(e621UserIds); + + let banned = false; + + // It's either this or fetch all the bans and sift through them for every discord alt. + try { + banned = !!(await guild.bans.fetch(discordId)); + } catch (e) { } + + const data: AltData = { type: 'discord', thisId: discordId, banned, alts: [] }; + + for (const e621Id of e621UserIds) { + if (ignore.includes(e621Id)) continue; + + data.alts.push(await getDiscordAltData(e621Id, guild, depth + 1, toIgnore)); + } + + return data; +} + +async function getDiscordAltData(e621Id: number, guild: Guild, depth = 1, ignore: number[] = []): Promise { + const discordIds = await Database.getDiscordIds(e621Id); + + const data: AltData = { type: 'e621', thisId: e621Id, banned: await userIsBanned(e621Id), alts: [] }; + + for (const discordId of discordIds) { + data.alts.push(await getE621AltData(discordId, guild, depth + 1, ignore)); + } + + return data; +} + +export function e621IdsFromAltData(altData: AltData, data: number[] = []) { + if (altData.type == 'e621' && !data.includes(altData.thisId as number)) data.push(altData.thisId as number); + + for (const alt of altData.alts) { + e621IdsFromAltData(alt, data); + } + + return data; } \ No newline at end of file diff --git a/src/utils/channel-utils.ts b/src/utils/channel-utils.ts index 2fad384..9f01bf3 100644 --- a/src/utils/channel-utils.ts +++ b/src/utils/channel-utils.ts @@ -2,6 +2,8 @@ import { Channel, GuildBasedChannel, GuildChannel, GuildTextBasedChannel, TextBa import { Database } from '../shared/Database'; export async function channelIsInStaffCategory(channel: GuildBasedChannel) { + if (!channel.guildId) return false; + const staffCategories = await Database.getGuildStaffCategories(channel.guildId); return staffCategories.includes(channel.parentId!); diff --git a/src/utils/e621-utils.ts b/src/utils/e621-utils.ts index 88fa480..678390d 100644 --- a/src/utils/e621-utils.ts +++ b/src/utils/e621-utils.ts @@ -1,16 +1,24 @@ +import { Guild } from 'discord.js'; import { config } from '../config'; -import { E621Post, E621User } from '../types'; +import { E621Post, E621User, Record } from '../types'; +import { Database } from '../shared/Database'; const BLACKLISTED_TAGS: string[] = []; const BLACKLISTED_NONSAFE_TAGS: string[] = ['young']; const USER_AGENT = 'E621DiscordBot'; -const E621_NAME_URL = `${config.E621_BASE_URL}/users/{idOrName}.json`; -const E621_POST_URL = `${config.E621_BASE_URL}/posts/{id}.json`; -const E621_MD5_POST_URL = `${config.E621_BASE_URL}/posts.json?md5={md5}`; -export async function getE621User(idOrName: string | number): Promise { - const res = await fetch(E621_NAME_URL.replace('{idOrName}', idOrName.toString()), { +async function request(path: string, query?: { [name: string]: string }): Promise { + const url = new URL(config.E621_BASE_URL!); + url.pathname = path + '.json'; + + if (query) { + for (const [name, value] of Object.entries(query)) { + url.searchParams.set(name, value); + } + } + + const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } @@ -18,31 +26,19 @@ export async function getE621User(idOrName: string | number): Promise { + return await request(`/users/${idOrName}`) as E621User; } export async function getE621Post(id: string | number): Promise { - const res = await fetch(E621_POST_URL.replace('{id}', id.toString()), { - headers: { - 'User-Agent': USER_AGENT - } - }); - - if (!res.ok) return null; - - return (await res.json() as { post: E621Post }).post as E621Post; + return (await request(`/posts/${id}`)).post as E621Post; } export async function getE621PostByMd5(md5: string): Promise { - const res = await fetch(E621_MD5_POST_URL.replace('{md5}', md5), { - headers: { - 'User-Agent': USER_AGENT - } - }); - - if (!res.ok) return null; - - return (await res.json() as { post: E621Post }).post as E621Post; + return (await request('/posts', { md5 })).post as E621Post; } export function hasBlacklistedTags(post: E621Post): boolean { @@ -62,4 +58,12 @@ export function getPostUrl(post: E621Post): string { export async function userIsBanned(idOrName: string | number): Promise { const user = await getE621User(idOrName); return user?.is_banned ?? false; +} + +export async function getUserRecords(id: number): Promise { + const records = await request('/user_feedbacks', { 'search[user_id]': id.toString() }); + + if (records.user_feedbacks) return []; + + return records as Record[]; } \ No newline at end of file diff --git a/src/utils/note-utils.ts b/src/utils/note-utils.ts index aad12f3..a49445f 100644 --- a/src/utils/note-utils.ts +++ b/src/utils/note-utils.ts @@ -1,8 +1,6 @@ import { ActionRowBuilder, APIEmbedField, ButtonBuilder, ButtonStyle, EmbedBuilder, GuildMember, MessageActionRowComponentBuilder, time } from 'discord.js'; import { Database } from '../shared/Database'; -import { Note } from '../types'; - -type MessageContent = { content: string, components: ActionRowBuilder[] }; +import { MessageContent, Note } from '../types'; const NOTES_PER_PAGE = 5; diff --git a/src/utils/record-utils.ts b/src/utils/record-utils.ts new file mode 100644 index 0000000..35807c5 --- /dev/null +++ b/src/utils/record-utils.ts @@ -0,0 +1,104 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Embed, EmbedBuilder, Guild } from 'discord.js'; +import { Database } from '../shared/Database'; +import { E621User, MessageContent, Record, RecordCategory } from '../types'; +import { getE621User, getUserRecords } from './e621-utils'; +import { config } from '../config'; +import { e621IdsFromAltData, comprehensiveAltLookupFromDiscord } from './alt-utils'; + +type RecordWithUserData = Record & { user: E621User, creator: E621User, updater: E621User } +type AllRecords = RecordWithUserData[]; + +const RECORDS_PER_PAGE = 5; + +export async function getAllRecordsFromDiscordId(id: string, guild: Guild): Promise { + const altData = await comprehensiveAltLookupFromDiscord(id, guild); + const userCache: Map = new Map(); + + const allRecords: AllRecords = []; + + const e621UserIds = e621IdsFromAltData(altData); + + for (const id of e621UserIds) { + const user = userCache.get(id) ?? await getE621User(id); + if (!user) continue; + userCache.set(id, user); + + const records = await getUserRecords(id); + for (const record of records) { + const creator = userCache.get(record.creator_id) ?? await getE621User(record.creator_id); + if (!creator) continue; + userCache.set(record.creator_id, creator); + + const updater = userCache.get(record.updater_id) ?? await getE621User(record.updater_id); + if (!updater) continue; + userCache.set(record.updater_id, updater); + + allRecords.push({ + user, + creator, + updater, + ...record + }); + } + } + + return allRecords; +} + +export async function getRecordMessageFromDiscordId(id: string, page: number, guild: Guild): Promise { + const records = await getAllRecordsFromDiscordId(id, guild); + + if (records.length == 0) return null; + + page = page - 1; + + const maxPage = Math.floor(records.length / RECORDS_PER_PAGE); + + const embeds: EmbedBuilder[] = []; + + for (let i = page * RECORDS_PER_PAGE; i < page * RECORDS_PER_PAGE + RECORDS_PER_PAGE; i++) { + if (i >= records.length) break; + + embeds.push(getRecordEmbed(records[i])); + } + + const prevPage = new ButtonBuilder() + .setLabel('Previous Page') + .setCustomId(`records-previous_${id}_${page + 1}`) + .setDisabled(page == 0) + .setStyle(ButtonStyle.Primary); + + const nextPage = new ButtonBuilder() + .setLabel('Next Page') + .setCustomId(`records-next_${id}_${page + 1}`) + .setDisabled(page >= maxPage) + .setStyle(ButtonStyle.Primary); + + const row = new ActionRowBuilder() + .addComponents(prevPage, nextPage); + + return { content: `Records found for <@${id}>`, embeds, components: [row] }; +} + +function getRecordColor(category: RecordCategory) { + switch (category) { + case 'positive': return 0x00ff00; + case 'negative': return 0xff0000; + case 'neutral': return 0xaaaaaa; + } +} + +function getRecordEmbed(record: RecordWithUserData): EmbedBuilder { + const isUpdated = record.updated_at != record.created_at; + const creator = isUpdated ? record.updater : record.creator; + return new EmbedBuilder() + .setColor(getRecordColor(record.category)) + .setTitle(`Record from ${record.creator.name} for ${record.user.name}`) + .setDescription(record.body.trim()) + .setURL(`${config.E621_BASE_URL}/user_feedbacks/${record.id}`) + .setAuthor({ + name: `${isUpdated ? 'Last updated by' : 'Created by'}: ${creator.name}`, + url: `${config.E621_BASE_URL}/users/${creator.id}` + }) + .setTimestamp(new Date(record.updated_at)); +} \ No newline at end of file diff --git a/src/utils/refresh-commands.ts b/src/utils/refresh-commands.ts index 8298c79..967ea68 100644 --- a/src/utils/refresh-commands.ts +++ b/src/utils/refresh-commands.ts @@ -13,27 +13,40 @@ export async function refreshCommands(client: Client) { try { const commands: RESTPostAPIApplicationCommandsJSONBody[] = []; const guildCommands: { [id: string]: RESTPostAPIApplicationCommandsJSONBody[] } = {}; - const commandFiles = fs.readdirSync(`${ROOT_DIR}/commands`).filter(file => file.endsWith('.js') || file.endsWith('.ts')); + const commandFiles = { + commands: fs.readdirSync(`${ROOT_DIR}/commands`).filter(file => file.endsWith('.js') || file.endsWith('.ts')), + 'context-menus': fs.readdirSync(`${ROOT_DIR}/context-menus`).filter(file => file.endsWith('.js') || file.endsWith('.ts')), + }; - for (const file of commandFiles) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const command: Command = require(`${ROOT_DIR}/commands/${file}`).default; - let data: RESTPostAPIApplicationCommandsJSONBody; - if (typeof (command.data) == 'function') { - data = (await command.data(client)).toJSON(); - } else { - data = command.data.toJSON(); - } + for (const [folderName, files] of Object.entries(commandFiles)) { + for (const file of files) { + const p = `${ROOT_DIR}/${folderName}/${file}`; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const command: Command = require(p).default; - if (!command.guilds) { - commands.push(data); - } else { - for (const id of command.guilds) { - if (!guildCommands[id]) guildCommands[id] = []; - guildCommands[id].push(data); + if (!command) { + console.warn(`File at ${p} has no export. Skipping registering.`); + continue; + } + + let data: RESTPostAPIApplicationCommandsJSONBody; + if (typeof (command.data) == 'function') { + data = (await command.data(client)).toJSON(); + } else { + data = command.data.toJSON(); + } + + if (!command.guilds) { + commands.push(data); + } else { + for (const id of command.guilds) { + if (!guildCommands[id]) guildCommands[id] = []; + guildCommands[id].push(data); + } } } } + console.log('Started refreshing application (/) commands.'); console.log('Global commands: ' + commands.length);