diff --git a/package.json b/package.json index 48d329d..a60cf6a 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "", "main": "index.js", "scripts": { + "deploy": "node src/deploy.js", "start": "node src/index.js" }, "author": "", diff --git a/src/amiami/index.js b/src/amiami/index.js index 8bed75c..9b4be34 100644 --- a/src/amiami/index.js +++ b/src/amiami/index.js @@ -122,6 +122,7 @@ class AmiAmiFallbackPreview { * @see {@link AmiAmiFallbackPreview} */ const AmiAmiPreview = { + name: "AmiAmi", /** * @param {string} content * @returns {string[]} matches diff --git a/src/client.js b/src/client.js new file mode 100644 index 0000000..019fdce --- /dev/null +++ b/src/client.js @@ -0,0 +1,21 @@ +const { Client, IntentsBitField: {Flags: IntentsFlags} } = require("discord.js"); +const { AmiAmiPreview } = require("./amiami"); +const { YouTubePreview } = require("./youtube"); +const sqlite = require("sqlite3"); + +const client = new Client({ + intents: [IntentsFlags.Guilds, IntentsFlags.GuildMessages, IntentsFlags.MessageContent], +}); + +client.db = new sqlite.Database(process.env.DB_PATH ?? "./data.db"); + +client.previews = [ + AmiAmiPreview, + YouTubePreview, +]; + +function getClient() { + return client; +} + +module.exports = { getClient }; \ No newline at end of file diff --git a/src/deploy.js b/src/deploy.js new file mode 100644 index 0000000..aacae2b --- /dev/null +++ b/src/deploy.js @@ -0,0 +1,31 @@ +require("dotenv").config(); +const { getSettingsCommand } = require("./settings"); +const { getClient } = require("./client"); + + +const client = getClient(); + + +client.on("ready", async () => { + console.log(`Deploying commands for ${client.user.tag}.`); + + const debugGuild = process.env.DEBUG_GUILD; + + if (debugGuild) { + console.log(`Deploying commands to Guild ${debugGuild}`); + } else { + console.log("Deploying commands globally."); + } + + await client.application.commands.set([ + getSettingsCommand(client), + ], debugGuild); + + await client.destroy(); + + console.log("Done."); + + process.exit(0); +}); + +client.login(process.env.DISCORD_TOKEN); \ No newline at end of file diff --git a/src/index.js b/src/index.js index e081d33..d1dec50 100644 --- a/src/index.js +++ b/src/index.js @@ -1,22 +1,20 @@ require("dotenv").config(); -const { Client, IntentsBitField: {Flags: IntentsFlags} } = require("discord.js"); -const { AmiAmiPreview } = require("./amiami"); -const { YouTubePreview } = require("./youtube"); - -const client = new Client({ - intents: [IntentsFlags.Guilds, IntentsFlags.GuildMessages, IntentsFlags.MessageContent], -}); +const { getClient } = require("./client"); +const { Settings, settingsHandler } = require("./settings"); - -client.previews = [ - AmiAmiPreview, - YouTubePreview, -]; +const client = getClient(); client.on("messageCreate", async message => { if (message.author.bot) return; + if (!message.inGuild()) return; + + const settings = await Settings.forGuild(client.db, message.guildId); - for (const group of client.previews) { + const disabled = settings.disabled; + + const enabledPreviews = client.previews.filter(preview => !disabled.has(preview.name)); + + for (const group of enabledPreviews) { const matches = group.match(message.content); matches: for (const match of matches) { for (const generator of group.generators) { @@ -35,13 +33,28 @@ client.on("messageCreate", async message => { } }) +client.on("interactionCreate", async interaction => { + if (!interaction.isChatInputCommand()) return; + if (interaction.commandName === "settings") { + await settingsHandler(interaction); + } +}); + + + async function main() { for (const matcher of client.previews) { + console.log(`Initializing ${matcher.generators.length} generators for "${matcher.name}".`); + await matcher.init?.(); + for (const generator of matcher.generators) { await generator.init?.(); } } + console.log(`Initializing settings.`); + await Settings.init(client.db); + await client.login(process.env.DISCORD_TOKEN); console.log(`Logged into Discord as ${client.user.tag}.`); } diff --git a/src/settings.js b/src/settings.js new file mode 100644 index 0000000..6ebdb18 --- /dev/null +++ b/src/settings.js @@ -0,0 +1,250 @@ +const { Colors, PermissionFlagsBits, SlashCommandBuilder, EmbedBuilder } = require("discord.js"); + +/** + * Per-guild settings. + * + * Construct with `Settings.forGuild(db, guildId)`. + */ +class Settings { + /** + * @type {Map} + */ + static instances = new Map(); + + /** + * Whether the settings have changed since the last save. + */ + #changed = false; + + /** + * @type {{guild: string, disabled: string | null, preferredStyle: string | null}} + */ + #row; + /** + * @type {sqlite.Database} + */ + #db; + + constructor(db, row) { + this.#db = db; + this.#row = row; + } + + async save() { + if (!this.#changed) return; + + return new Promise((res, rej) => { + this.#db.run("UPDATE settings SET disabled = (?), preferredStyle = (?) WHERE guild = (?)", [this.#row.disabled, this.#row.preferredStyle, this.#row.guild], (err) => { + if (err) rej(err); + this.#changed = false; + + res(); + }); + }); + } + + /** + * @type {string} + */ + get guildId() { + return this.#row.guild; + } + + /** + * @type {Set} + */ + get disabled() { + const raw = this.#row.disabled; + + return raw ? new Set(raw.split(",")) : new Set(); + } + + /** + * Disable a preview provider. + */ + disable(previewName) { + const disabled = this.disabled; + + if (disabled.has(previewName)) this.#changed = true; + + disabled.add(previewName); + + this.#row.disabled = Array.from(disabled).join(","); + + return this; + } + + /** + * Enable a preview provider. + */ + enable(previewName) { + const disabled = this.disabled; + + if (disabled.has(previewName)) this.#changed = true; + + disabled.delete(previewName); + + this.#row[2] = Array.from(disabled).join(","); + + return this; + } + + get preferredStyle() { + return this.#row.preferredStyle ?? "full"; + } + + set preferredStyle(value) { + if (value != "full" && value != "compact") throw new TypeError("Prefered style must be 'full' or 'compact'"); + + this.#changed = true; + this.#row.preferredStyle = value; + } + + /** + * @returns {Promise} + */ + static async forGuild(db, id) { + if (Settings.instances.has(id)) return Settings.instances.get(id); + + let row = await new Promise((res, rej) => db.get("SELECT * FROM settings WHERE guild = (?)", [id], (err, row) => { + if (err) rej(err); + res(row); + })); + + if (!row) { + await new Promise((res, rej) => db.run("INSERT INTO settings VALUES (?, ?, ?)", [id, null, null], (err) => { + if (err) rej(err); + res(); + })); + + row = await new Promise((res, rej) => db.get("SELECT * FROM settings WHERE guild = (?)", [id], (err, row) => { + if (err) rej(err); + res(row); + })); + } + + const settings = new Settings(db, row); + Settings.instances.set(id, settings); + return settings; + } + + + static async init(db) { + return new Promise((res, rej) => db.run("CREATE TABLE IF NOT EXISTS settings (guild TEXT UNIQUE NOT NULL, disabled TEXT, preferredStyle TEXT)", (err) => { + if (err) rej(err); + res(); + })); + } +} + +function getSettingsCommand(client) { + /** + * @type {string[]} + */ + const previewNames = client.previews.map(preview => preview.name); + + return new SlashCommandBuilder() + .setName("settings") + .setDescription("View or modify your settings.") + .setDMPermission(false) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(view => view + .setName("view") + .setDescription("View your settings.") + ) + .addSubcommand(disable => disable + .setName("disable") + .setDescription("Disable a preview provider.") + .addStringOption(option => option + .setName("provider") + .setDescription("The provider to disable.") + .setChoices(...previewNames.map(name => ({ name, value: name }))) + .setRequired(true) + ) + ) + .addSubcommand(enable => enable + .setName("enable") + .setDescription("Enable a preview provider.") + .addStringOption(option => option + .setName("provider") + .setDescription("The provider to enable.") + .setChoices(...previewNames.map(name => ({ name, value: name }))) + .setRequired(true) + ) + ) + .addSubcommand(style => style + .setName("style") + .setDescription("Set the preferred preview style.") + .addStringOption(option => option + .setName("style") + .setDescription("The preferred preview style.") + .setChoices({ name: "Full", value: "full" }, { name: "Compact", value: "compact" }) + .setRequired(true) + ) + ); +} + +async function settingsHandler(interaction) { + if (!interaction.inGuild()) throw new Error("Must be in a server to use this command."); + + const subcommand = interaction.options.getSubcommand(); + + const settings = await Settings.forGuild(client.db, interaction.guildId); + switch (subcommand) { + case "view": { + const disabled = settings.disabled; + const enabled = interaction.client.previews.filter(preview => !disabled.has(preview.name)).map(preview => preview.name); + const style = settings.preferredStyle; + + const embed = new EmbedBuilder() + .setAuthor({ + name: interaction.guild.name, + iconURL: interaction.guild.iconURL(), + }) + .setTitle("Settings") + .addFields( + { name: "Disabled", value: disabled.size ? Array.from(disabled).join(", ") : "None", inline: true }, + { name: "Enabled", value: enabled.length ? Array.from(enabled).join(", ") : "None", inline: true }, + { name: "Preferred style", value: style, inline: false }, + ) + .setColor(Colors.Aqua); + + return interaction.reply({ embeds: [embed] }); + } + + case "disable": { + const provider = interaction.options.getString("provider"); + settings.disable(provider); + await settings.save(); + return interaction.reply({ embeds: [ + new EmbedBuilder() + .setDescription(`Disabled ${provider}.`) + .setColor(Colors.Green) + ]}); + } + + case "enable": { + const provider = interaction.options.getString("provider"); + settings.enable(provider); + await settings.save(); + return interaction.reply({ embeds: [ + new EmbedBuilder() + .setDescription(`Enabled ${provider}.`) + .setColor(Colors.Green) + ]}); + } + + case "style": { + const style = interaction.options.getString("style"); + settings.preferredStyle = style; + await settings.save(); + return interaction.reply({ embeds: [ + new EmbedBuilder() + .setDescription(`Set preferred style to ${style}.`) + .setColor(Colors.Green) + ]}); + } + } +} + +module.exports = { Settings, getSettingsCommand, settingsHandler }; \ No newline at end of file diff --git a/src/youtube/index.js b/src/youtube/index.js index dfccedc..d01aa0c 100644 --- a/src/youtube/index.js +++ b/src/youtube/index.js @@ -100,6 +100,7 @@ function postToEmbed(post, channel) { } const YouTubePreview = { + name: "YouTube (Community Posts)", match(content) { return [...content.matchAll(postIdRegex)].map(match => typeof match == "string" ? match : match[0]); },