Skip to content

Commit

Permalink
feat: add spigot account linking, add requests
Browse files Browse the repository at this point in the history
  • Loading branch information
yusshu committed Jan 22, 2024
1 parent cf75740 commit 5d42138
Show file tree
Hide file tree
Showing 10 changed files with 352 additions and 30 deletions.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ config.js
/.yarn/

# output files
/build/
/build/

# data
/data/
15 changes: 13 additions & 2 deletions src/bot/command.loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<string, Command>());

Expand Down Expand Up @@ -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);
Expand Down
59 changes: 32 additions & 27 deletions src/bot/command/command.builder.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,33 @@
import { SlashCommandBuilder } from '@discordjs/builders';
import { CommandInteraction, PermissionResolvable } from 'discord.js';

export type CommandExecutor = (interaction: CommandInteraction) => Promise<void>;

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<void>;

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
};
}

}
52 changes: 52 additions & 0 deletions src/bot/command/request.command.ts
Original file line number Diff line number Diff line change
@@ -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<string>('.', '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();
28 changes: 28 additions & 0 deletions src/bot/command/spigot-unlink.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import CommandBuilder from './command.builder';
import { FileSingletonDatastore } from '../../data/FileSingletonDatastore';

const discordToSpigotDatastore = new FileSingletonDatastore<string>('.', 'data', 'discord_to_spigot.json');
const spigotToDiscordDatastore = new FileSingletonDatastore<string>('.', '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();
90 changes: 90 additions & 0 deletions src/bot/command/spigot.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import CommandBuilder from './command.builder';
import { findAuthor } from '../../util/spigot';
import { FileSingletonDatastore } from '../../data/FileSingletonDatastore';

const discordToSpigotDatastore = new FileSingletonDatastore<string>('.', 'data', 'discord_to_spigot.json');
const spigotToDiscordDatastore = new FileSingletonDatastore<string>('.', '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();
33 changes: 33 additions & 0 deletions src/data/Datastore.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
/**
* 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<T | null>;

/**
* Removes the value for the given key.
*
* @param key The key to remove the value for
*/
remove(key: string): Promise<void>;

/**
* 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<void>;
}

export default Datastore;
58 changes: 58 additions & 0 deletions src/data/FileSingletonDatastore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import fs from 'fs';
import path from 'path';
import Datastore from './Datastore';

export class FileSingletonDatastore<T> implements Datastore<T> {
private readonly file: string;

constructor(...pathArgs: string[]) {
this.file = path.join(...pathArgs);
}

async get(key: string): Promise<T | null> {
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<void> {
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<void> {
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));
}
}
}
Loading

0 comments on commit 5d42138

Please sign in to comment.