diff --git a/.gitignore b/.gitignore index 866162d..8f9ca4f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ config.js /.yarn/ # output files -/build/ \ No newline at end of file +/build/ + +# data +/data/ \ No newline at end of file diff --git a/src/bot/command.loader.ts b/src/bot/command.loader.ts index 660dc95..9aacc96 100644 --- a/src/bot/command.loader.ts +++ b/src/bot/command.loader.ts @@ -8,6 +8,9 @@ import config from '../../config'; import AnnounceCommand from './command/announce.command'; import PingCommand from './command/ping.command'; import RepoCommand from './command/repo.command'; +import SpigotCommand from './command/spigot.command'; +import SpigotUnlinkCommand from './command/spigot-unlink.command'; +import RequestCommand from './command/request.command'; const COOLDOWN_EXPIRY_TIME = 5 * 1000; @@ -16,7 +19,10 @@ export default async function loadCommands(client: Client) { const commands = [ AnnounceCommand, PingCommand, - RepoCommand + RepoCommand, + SpigotCommand, + SpigotUnlinkCommand, + RequestCommand ]; const commandMap = commands.reduce((map, command) => map.set(command.data.name, command), new Map()); @@ -81,13 +87,18 @@ export default async function loadCommands(client: Client) { await command.executor(commandInteraction); } catch (thrown) { if (thrown.title !== undefined) { + const ephemeral = thrown.ephemeral; + if (ephemeral) { + delete thrown.ephemeral; + } await commandInteraction.reply({ embeds: [ { color: config.color, ...thrown } as MessageEmbed - ] + ], + ephemeral }); } else { console.error(thrown); diff --git a/src/bot/command/command.builder.ts b/src/bot/command/command.builder.ts index 0d04861..d3e0395 100644 --- a/src/bot/command/command.builder.ts +++ b/src/bot/command/command.builder.ts @@ -1,28 +1,33 @@ -import { SlashCommandBuilder } from '@discordjs/builders'; -import { CommandInteraction, PermissionResolvable } from 'discord.js'; - -export type CommandExecutor = (interaction: CommandInteraction) => Promise; - -export interface Command { - data: any, - executor: CommandExecutor; -} - -export default class CommandBuilder extends SlashCommandBuilder { - - private executor: CommandExecutor; - private permissions: PermissionResolvable[]; - - setExecutor(executor: CommandExecutor): CommandBuilder { - this.executor = executor; - return this; - } - - build(): Command { - return { - data: this.toJSON(), - executor: this.executor - }; - } - +import { SlashCommandBuilder } from '@discordjs/builders'; +import { CommandInteraction, PermissionResolvable } from 'discord.js'; + +export type CommandExecutor = (interaction: CommandInteraction) => Promise; + +export interface Command { + data: any, + executor: CommandExecutor; +} + +export default class CommandBuilder extends SlashCommandBuilder { + + private executor: CommandExecutor; + private permissions: PermissionResolvable[]; + + setExecutor(executor: CommandExecutor): CommandBuilder { + this.executor = executor; + return this; + } + + let(func: (_this: CommandBuilder) => any): this { + func(this); + return this; + } + + build(): Command { + return { + data: this.toJSON(), + executor: this.executor + }; + } + } \ No newline at end of file diff --git a/src/bot/command/request.command.ts b/src/bot/command/request.command.ts new file mode 100644 index 0000000..8e2d7d6 --- /dev/null +++ b/src/bot/command/request.command.ts @@ -0,0 +1,52 @@ +import config from '../../../config.js'; +import CommandBuilder from './command.builder'; +import { FileSingletonDatastore } from '../../data/FileSingletonDatastore'; +import {submit} from "../../task/tasks"; + +const discordToSpigotDatastore = new FileSingletonDatastore('.', 'data', 'discord_to_spigot.json'); + +export default new CommandBuilder() + .setName('request') + .setDescription('Make a request to the developers!') + .let(b => b.addSubcommand(builder => builder + .setName('productroles') + .setDescription('Request a verification of your purchases to get your bought products\' roles.'))) + .setExecutor(async interaction => { + const subcommand = interaction.options.getSubcommand(true); + switch (subcommand) { + case 'productroles': { + const spigotId = await discordToSpigotDatastore.get(interaction.user.id); + if (spigotId === null) { + throw { + title: 'You don\'t have a SpigotMC Forums account linked!', + description: 'To link your SpigotMC Forums account, use the `/spigot` command.', + color: config.color, + ephemeral: true + }; + } + await submit(interaction.client, { + description: 'Verify my products and give me my roles.', + submittedBy: { + id: interaction.user.id, + name: interaction.user.username, + spigotId: spigotId + } + }); + throw { + title: 'Request sent', + description: 'Your request has been sent to the developers, please wait patiently for an action.', + color: config.color, + ephemeral: true + }; + } + default: { + throw { + title: 'Unknown request type', + description: 'You tried to submit an unknown request type, please re-check the available sub-commands.', + color: config.color, + ephemeral: true + }; + } + } + }) + .build(); \ No newline at end of file diff --git a/src/bot/command/spigot-unlink.command.ts b/src/bot/command/spigot-unlink.command.ts new file mode 100644 index 0000000..389dc15 --- /dev/null +++ b/src/bot/command/spigot-unlink.command.ts @@ -0,0 +1,28 @@ +import CommandBuilder from './command.builder'; +import { FileSingletonDatastore } from '../../data/FileSingletonDatastore'; + +const discordToSpigotDatastore = new FileSingletonDatastore('.', 'data', 'discord_to_spigot.json'); +const spigotToDiscordDatastore = new FileSingletonDatastore('.', 'data', 'spigot_to_discord.json'); + +export default new CommandBuilder() + .setName('spigot-unlink') + .setDescription('Unlink your SpigotMC Forums account from this Discord account') + .setExecutor(async interaction => { + const { user } = interaction; + + const spigotId = await discordToSpigotDatastore.get(user.id); + if (spigotId === null) { + throw { + title: 'You don\'t have a SpigotMC Forums account linked!', + description: 'To link your SpigotMC Forums account, use the `/spigot` command.' + }; + } + + await discordToSpigotDatastore.remove(user.id); + await spigotToDiscordDatastore.remove(spigotId); + throw { + title: 'Unlinked SpigotMC Forums account!', + description: 'You have successfully unlinked your SpigotMC Forums account.' + }; + }) + .build(); \ No newline at end of file diff --git a/src/bot/command/spigot.command.ts b/src/bot/command/spigot.command.ts new file mode 100644 index 0000000..f7e95c4 --- /dev/null +++ b/src/bot/command/spigot.command.ts @@ -0,0 +1,90 @@ +import CommandBuilder from './command.builder'; +import { findAuthor } from '../../util/spigot'; +import { FileSingletonDatastore } from '../../data/FileSingletonDatastore'; + +const discordToSpigotDatastore = new FileSingletonDatastore('.', 'data', 'discord_to_spigot.json'); +const spigotToDiscordDatastore = new FileSingletonDatastore('.', 'data', 'spigot_to_discord.json'); + +export default new CommandBuilder() + .setName('spigot') + .setDescription('Verify your SpigotMC Forums account') + .addStringOption( + option => option + .setName('username') + .setDescription('Your exact SpigotMC Forums username') + .setRequired(true) + ) + .setExecutor(async interaction => { + const { user } = interaction; + + { + // check if this Discord user has a SpigotMC Forums account linked already + const spigotId = await discordToSpigotDatastore.get(user.id); + if (spigotId !== null) { + throw { + title: 'You already have a SpigotMC Forums account linked!', + description: 'If you want to link a different account, unlink your current one first using the `/spigot-unlink` command.', + ephemeral: true + }; + } + } + + const username = interaction.options.getString('username'); + const author = await findAuthor(username); + if ((author as any).code === 404) { + throw { title: 'Invalid SpigotMC Forums username', description: 'Can\'t find a SpigotMC author with that username.', ephemeral: true }; + } + + { + // check if this SpigotMC Forums account is linked to a Discord user already + const discordId = await spigotToDiscordDatastore.get(author.id); + if (discordId !== null) { + throw { + title: 'This SpigotMC Forums account is already linked!', + description: 'This SpigotMC Forums account is already verified for another Discord user.', + ephemeral: true + }; + } + } + + const senderUsername = interaction.user.username; + const expectedUsername = author?.identities?.discord; + + if (expectedUsername === undefined) { + throw { + title: 'Can\'t verify SpigotMC Forums account: No Discord Identity', + description: 'You have not linked your Discord account to your SpigotMC account. (from SpigotMC Forums).\n\n' + + 'Go to [your Contact Details page](https://www.spigotmc.org/account/contact-details) and set your ' + + 'Discord identity to `' + senderUsername + '`.', + footer: { + text: 'Please note that it can take some minutes until it fully updates.' + }, + ephemeral: true + }; + } + + if (senderUsername === expectedUsername) { + await discordToSpigotDatastore.save(interaction.user.id, author.id); + await spigotToDiscordDatastore.save(author.id, interaction.user.id); + throw { + title: `Verified SpigotMC Forums account: ${author.username}`, + description: 'You have successfully verified your SpigotMC Forums account. If you' + + ' have bought a premium resource, you can now use the `/request` command to request your products\' roles.', + author: { + name: author?.username, + iconURL: author?.avatar + }, + ephemeral: true + }; + } else { + throw { + title: 'Can\'t verify SpigotMC Forums account: Different Discord Identity', + description: `The Discord identity set on SpigotMC Forums for ${author.username} is \`${expectedUsername}\`, but you are \`${senderUsername}\`.`, + footer: { + text: 'Please note that updates can take some minutes until they can be recognized by the bot.' + }, + ephemeral: true + }; + } + }) + .build(); \ No newline at end of file diff --git a/src/data/Datastore.ts b/src/data/Datastore.ts new file mode 100644 index 0000000..fa5fca2 --- /dev/null +++ b/src/data/Datastore.ts @@ -0,0 +1,33 @@ +/** + * Represents a datastore, which is a repository for a specific + * type of data. This can be a database, a file, or an in-memory + * cache. + * + * @template T The type of the data + */ +interface Datastore { + /** + * Gets the value for the given key. + * + * @param key The key to get the value for + * @returns A promise that resolves to the value for the given key + */ + get(key: string): Promise; + + /** + * Removes the value for the given key. + * + * @param key The key to remove the value for + */ + remove(key: string): Promise; + + /** + * Saves the given value for the given key. + * + * @param key The key to save the value for + * @param value The value to save + */ + save(key: string, value: T): Promise; +} + +export default Datastore; \ No newline at end of file diff --git a/src/data/FileSingletonDatastore.ts b/src/data/FileSingletonDatastore.ts new file mode 100644 index 0000000..ee2b245 --- /dev/null +++ b/src/data/FileSingletonDatastore.ts @@ -0,0 +1,58 @@ +import fs from 'fs'; +import path from 'path'; +import Datastore from './Datastore'; + +export class FileSingletonDatastore implements Datastore { + private readonly file: string; + + constructor(...pathArgs: string[]) { + this.file = path.join(...pathArgs); + } + + async get(key: string): Promise { + if (!fs.existsSync(this.file)) { + return null; + } + const content = fs.readFileSync(this.file, { encoding: 'utf-8' }); + const json = JSON.parse(content); + if (typeof json === 'object') { + return json[key] || null; + } else { + return null; + } + } + + async save(key: string, value: T): Promise { + let json; + if (!fs.existsSync(this.file)) { + json = {}; + } else { + const content = fs.readFileSync(this.file, { encoding: 'utf-8' }); + json = JSON.parse(content); + if (typeof json !== 'object') { + json = {}; + } + } + json[key] = value; + + // create directory if not exists + const dir = path.dirname(this.file); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(this.file, JSON.stringify(json)); + } + + async remove(key: string): Promise { + if (!fs.existsSync(this.file)) { + return; + } + const content = fs.readFileSync(this.file, { encoding: 'utf-8' }); + const json = JSON.parse(content); + if (typeof json === 'object') { + delete json[key]; + fs.writeFileSync(this.file, JSON.stringify(json)); + } + } +} \ No newline at end of file diff --git a/src/task/tasks.ts b/src/task/tasks.ts new file mode 100644 index 0000000..cfa2fe9 --- /dev/null +++ b/src/task/tasks.ts @@ -0,0 +1,16 @@ +import {Client, TextChannel} from "discord.js"; + +interface Task { + submittedBy: { + id: string; + name: string; + spigotId: string; + }; + description: string; +} + + +export async function submit(client: Client, task: Task): Promise { + const channel = await client.channels.fetch('1198877326595862578') as TextChannel; + await channel.send(`'${task.description}' submitted by <@${task.submittedBy.id}> (${task.submittedBy.name}, Spigot ID: ${task.submittedBy.spigotId})`); +} \ No newline at end of file diff --git a/src/util/spigot.ts b/src/util/spigot.ts new file mode 100644 index 0000000..1f6015f --- /dev/null +++ b/src/util/spigot.ts @@ -0,0 +1,26 @@ +import fetch from 'node-fetch'; + +const API_URL = 'https://api.spigotmc.org/simple/0.2/index.php'; + +type SpigotAPIAction = 'listResources' | 'getResource' | 'getResourcesByAuthor' | 'listResourceCategories' | 'getResourceUpdate' | 'getResourceUpdates' | 'getAuthor' | 'findAuthor'; + +async function get(action: SpigotAPIAction, params?: Record) { + let url = `${API_URL}?action=${action}`; + if (params !== undefined) { + url += '&' + Object.keys(params).map(key => `${key}=${encodeURIComponent(params[key])}`).join('&'); + } + const response = await fetch(url); + return await response.json(); +} + +export async function findAuthor(name: string): Promise<{ + id: string, + username: string, + resource_count: string, + identities: { + discord: string | null, + }, + avatar: string | null +}> { + return get('findAuthor', { name }); +} \ No newline at end of file