diff --git a/commands/bal.ts b/commands/bal.ts index b8b0111..e01bbbd 100644 --- a/commands/bal.ts +++ b/commands/bal.ts @@ -5,20 +5,18 @@ import type { CommandData } from "./index"; import type { User } from "../db"; import config from "../config.json"; import { get_user } from "../db"; -import { BotError } from "./error"; +import { BotError } from "./common/error"; async function run(interaction: ChatInputCommandInteraction, user: User) { const options = interaction.options; const target = (await options.get("target"))?.user; - let embeds = []; let bal_embed = new EmbedBuilder(); let bal_target = target ?? interaction.user; let bal_user = target ? await get_user(target.id) : user; if (!bal_user) throw new BotError("Target is not registered"); //must be target bal_embed.setTitle(`${bal_target.tag}'s Balance`); bal_embed.setDescription(`${bal_user.balance} ${ bal_user.balance === 1 ? config.currency : config.currency_plural }`); - embeds.push(bal_embed); - return await interaction.editReply({ embeds }); + return await interaction.editReply({ embeds: [ bal_embed ] }); } const data: CommandData = { diff --git a/commands/change_bal.ts b/commands/change_bal.ts index c6ba039..956835b 100644 --- a/commands/change_bal.ts +++ b/commands/change_bal.ts @@ -3,9 +3,10 @@ import type { ChatInputCommandInteraction } from "discord.js"; import type { CommandData } from "./index"; import type { User } from "../db"; import { get_user, add_balance, sub_balance } from "../db"; -import { BotError } from "./error"; +import { BotError } from "./common/error"; -async function run(interaction: ChatInputCommandInteraction, _user: User) { +async function run(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); const options = interaction.options; const target_id: string = (await options.get("target")).user.id; const amount: number = (await options.get("amount")).value as number; @@ -24,7 +25,7 @@ async function run(interaction: ChatInputCommandInteraction, _user: User) { const data: CommandData = { name: "change_bal", description: "Change a user's balance", - registered_only: true, + registered_only: false, ephemeral: false, admin_only: true, run, diff --git a/commands/change_item_balance.ts b/commands/change_item_balance.ts new file mode 100644 index 0000000..45fec45 --- /dev/null +++ b/commands/change_item_balance.ts @@ -0,0 +1,37 @@ +import type { ChatInputCommandInteraction } from "discord.js"; + +import type { CommandData } from "./index"; +import type { Items, StoreItem, User } from "../db"; +import { get_item, get_user, add_item_to_user, sub_item_to_user } from "../db"; +import { BotError } from "./common/error"; +import { item_name_autocomplete } from "./common/autocompletes"; + +async function run(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + const options = interaction.options; + const name: string = (await options.get("name")).value as string; + const quantity: number = (await options.get("quantity")).value as number; + const target = (await options.get("target")).user; + if (!(await get_user(target.id))) throw new BotError("Target is not registered"); + if (!(await get_item(name))) throw new BotError("No such item exists"); + if (quantity < 0) { + if (!(await sub_item_to_user(target.id, name, -quantity))) throw new BotError("Cannot remove more items from that user's inventory than they actually have"); + return await interaction.editReply(`Removed ${-quantity} of item \`${name}\` to <@${target.id}>`); + } else { + await add_item_to_user(target.id, name, quantity); + return await interaction.editReply(`Added ${quantity} of item \`${name}\` to <@${target.id}>`); + } +} + +const data: CommandData = { + name: "change_item_balance", + description: "Add/remove items from a user (admin only)", + registered_only: false, + ephemeral: false, + admin_only: true, + run, + autocomplete: item_name_autocomplete, //autocompletes for the "name" option +}; + +export default data; + diff --git a/commands/common/autocompletes.ts b/commands/common/autocompletes.ts new file mode 100644 index 0000000..d94ce08 --- /dev/null +++ b/commands/common/autocompletes.ts @@ -0,0 +1,12 @@ +import type { AutocompleteInteraction } from "discord.js"; + +import { get_all_items } from "../../db"; + +export async function item_name_autocomplete(interaction: AutocompleteInteraction) { + return await interaction.respond((await get_all_items()).filter( + (item) => item.name.startsWith(interaction.options.getFocused(true).value) + ).map( + (item) => ({ name: item.name, value: item.name }) + )); +} + diff --git a/commands/error.ts b/commands/common/error.ts similarity index 100% rename from commands/error.ts rename to commands/common/error.ts diff --git a/commands/create_item.ts b/commands/create_item.ts new file mode 100644 index 0000000..74df95b --- /dev/null +++ b/commands/create_item.ts @@ -0,0 +1,46 @@ +//also: edit_item, delete_item, store, buy, use_item, (admin: /take_item, /add_item) + +import type { ChatInputCommandInteraction } from "discord.js"; + +import type { CommandData } from "./index"; +import type { Items, StoreItem, User } from "../db"; +import { create_item, get_item } from "../db"; +import { BotError } from "./common/error"; + +async function run(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + const options = interaction.options; + const name: string = (await options.get("name")).value as string; + const price: number = (await options.get("price")).value as number; + const description: string = (await options.get("description")).value as string; + const usable: boolean = ((await options.get("usable"))?.value ?? true) as boolean; + if (name.includes("`")) throw new BotError("Item name cannot include the ` character"); //don't want to escape shit + //to add multiple roles, people will have to use /edit_item, I guess? augh + const required_role = (await options.get("required_role"))?.role; + if (price < 0) throw new BotError("Price cannot be negative"); + //name and description char limits (based on discord embed field name/value limits) + if (name.length > 200) throw new BotError("Item name cannot be more than 256 characters"); //true limit is 256 (still might error if currency name is more than like 50 characters, or price is absurdly huge) + if (description.length > 900) throw new BotError("Item description cannot be more than 1024 characters"); //true limit is 1024 but we want some margin for other info + if (await get_item(name)) throw new BotError("Item with that name already exists. Use a different name, or /edit_item or /delete_item"); + const store_item: StoreItem = { + name, + price, + description, + roles_required: required_role ? [required_role.id] : [], + usable, + }; + await create_item(store_item); + return await interaction.editReply("Item created"); +} + +const data: CommandData = { + name: "create_item", + description: "Create item", + registered_only: false, + ephemeral: false, + admin_only: true, + run, +}; + +export default data; + diff --git a/commands/index.ts b/commands/index.ts index 158a72b..271d431 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -1,9 +1,9 @@ -import type { ChatInputCommandInteraction, GuildMember } from "discord.js"; +import type { AutocompleteInteraction, ChatInputCommandInteraction, GuildMember } from "discord.js"; import { EmbedBuilder } from "discord.js"; import type { User } from "../db"; import { get_user } from "../db"; -import { BotError } from "./error"; +import { BotError } from "./common/error"; import config from "../config.json"; import say from "./say"; @@ -12,6 +12,10 @@ import register_user from "./register_user"; import bal from "./bal"; import change_bal from "./change_bal"; import transfer from "./transfer"; +import items from "./items"; +import create_item from "./create_item"; +import store from "./store"; +import change_item_balance from "./change_item_balance"; export interface CommandData { name: string; @@ -20,17 +24,16 @@ export interface CommandData { ephemeral: boolean; admin_only: boolean; run: (interaction: ChatInputCommandInteraction, user?: User) => Promise; + autocomplete?: (interaction: AutocompleteInteraction) => Promise; }; -const commands: CommandData[] = [say, roll, register_user, bal, change_bal, transfer]; +const commands: CommandData[] = [say, roll, register_user, bal, change_bal, transfer, items, create_item, store, change_item_balance]; function is_admin(interaction: ChatInputCommandInteraction): boolean { return (interaction.member as GuildMember).roles.cache.some((r) => r.id === config.admin_role); } -export default async function run(interaction: ChatInputCommandInteraction) { - const name = interaction.commandName; - +async function run(interaction: ChatInputCommandInteraction, found: CommandData, name: string) { //help command is "auto-generated" if (name === "help") { //max of 25 fields per embed, so if too many commands, this section needs a rewrite @@ -58,7 +61,6 @@ export default async function run(interaction: ChatInputCommandInteraction) { return await interaction.reply({ embeds, ephemeral: true }); } - const found = commands.find((c) => c.name === name); try { //admin stuff should be ideally handled by register.ts, but this is a fallback if (found.admin_only && !is_admin(interaction)) throw new BotError("Admin permission needed to run that command"); @@ -87,3 +89,14 @@ export default async function run(interaction: ChatInputCommandInteraction) { } } +export default async function handle_interaction(interaction: AutocompleteInteraction | ChatInputCommandInteraction) { + const name = interaction.commandName; + const found = commands.find((c) => c.name === name); + + if (interaction.isChatInputCommand()) { + return await run(interaction, found, name); + } else if (interaction.isAutocomplete()) { + return await found.autocomplete(interaction); + } +} + diff --git a/commands/items.ts b/commands/items.ts new file mode 100644 index 0000000..7b4a0ea --- /dev/null +++ b/commands/items.ts @@ -0,0 +1,89 @@ +import type { ChatInputCommandInteraction } from "discord.js"; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; + +import type { CommandData } from "./index"; +import type { Items, User } from "../db"; +import { get_user } from "../db"; +import { BotError } from "./common/error"; + +async function run(interaction: ChatInputCommandInteraction, user: User) { + function gen_items_embed(items_target, items: Items, page: number, pages: number) { + let items_embed = new EmbedBuilder(); + items_embed.setTitle(`${items_target.tag} Items (Page ${page}/${pages})`); + if (Object.keys(items).length === 0) { + items_embed.setDescription("No items"); + } else { + items_embed.addFields( + Object.keys(items) + .slice((page - 1) * 10, page * 10) + .map( + (item_name) => + ({ + name: item_name, + value: String(items[item_name]), + }) + ) + ); + } + return items_embed; + } + function gen_action_row(page: number, pages: number) { + let action_row = new ActionRowBuilder(); + let action_prev = new ButtonBuilder() + .setCustomId(String(page - 1)) + .setLabel("Prev") + .setEmoji("⬅️") + .setStyle(ButtonStyle.Primary) + .setDisabled(page - 1 === 0); + let action_next = new ButtonBuilder() + .setCustomId(String(page + 1)) + .setLabel("Next") + .setEmoji("➡️") + .setStyle(ButtonStyle.Primary) + .setDisabled(page + 1 > pages); + action_row.addComponents(action_prev, action_next); + return action_row; + } + const options = interaction.options; + const target = (await options.get("target"))?.user; + let items_target = target ?? interaction.user; + let items_user = target ? await get_user(target.id) : user; + if (!items_user) throw new BotError("Target is not registered"); //must be target + //filter out items which the user owns 0 of, but is in their items record thing + const items: Items = Object.keys(items_user.items).filter((item) => items_user.items[item] > 0).reduce((accum, item) => { + accum[item] = items_user.items[item]; + return accum; + }, {}); + //list items in items embed, if too many, make sure pagination buttons work + const pages: number = Math.ceil(Object.keys(items).length / 10) || 1; //min of 1 + let page = 1; + const dresp = await interaction.editReply({ + embeds: [ gen_items_embed(items_target, items, page, pages) ], + components: [ gen_action_row(page, pages) ], + }); + while (true) { + try { + let dresp_bin = await dresp.awaitMessageComponent({ filter: (bin) => bin.user.id === interaction.user.id, time: 60000 }); //bin = button interaction + page = Number(dresp_bin.customId); + await dresp_bin.update({ + embeds: [ gen_items_embed(items_target, items, page, pages) ], + components: [ gen_action_row(page, pages) ], + }); + } catch (_) { + //errors when people stop pressing the button + return; + } + } +} + +const data: CommandData = { + name: "items", + description: "See you or someone else's items", + registered_only: true, + ephemeral: false, + admin_only: false, + run, +}; + +export default data; + diff --git a/commands/register_user.ts b/commands/register_user.ts index 6e82b1f..3357d1a 100644 --- a/commands/register_user.ts +++ b/commands/register_user.ts @@ -2,7 +2,7 @@ import type { ChatInputCommandInteraction } from "discord.js"; import type { CommandData } from "./index"; import { get_user, add_new_user } from "../db"; -import { BotError } from "./error"; +import { BotError } from "./common/error"; async function run(interaction: ChatInputCommandInteraction) { await interaction.deferReply(); diff --git a/commands/roll.ts b/commands/roll.ts index acdd0da..d657fbc 100644 --- a/commands/roll.ts +++ b/commands/roll.ts @@ -2,7 +2,7 @@ import type { ChatInputCommandInteraction } from "discord.js"; import { randomInt } from "crypto"; import type { CommandData } from "./index"; -import { BotError } from "./error"; +import { BotError } from "./common/error"; const MAX_DICE: number = 100; const MAX_FACES: number = 9999; diff --git a/commands/say.ts b/commands/say.ts index 60d1dcd..96689fd 100644 --- a/commands/say.ts +++ b/commands/say.ts @@ -1,7 +1,7 @@ import type { ChatInputCommandInteraction } from "discord.js"; import type { CommandData } from "./index"; -import { BotError } from "./error"; +import { BotError } from "./common/error"; import { is_text_channel } from "../guards"; async function run(interaction: ChatInputCommandInteraction) { diff --git a/commands/store.ts b/commands/store.ts new file mode 100644 index 0000000..c431c13 --- /dev/null +++ b/commands/store.ts @@ -0,0 +1,85 @@ +import type { ChatInputCommandInteraction } from "discord.js"; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; + +import type { CommandData } from "./index"; +import type { StoreItem } from "../db"; +import config from "../config.json"; +import { get_all_items } from "../db"; +import { BotError } from "./common/error"; + +async function run(interaction: ChatInputCommandInteraction) { + function gen_store_embed(store_items: StoreItem[], page: number, pages: number) { + let store_embed = new EmbedBuilder(); + store_embed.setTitle(`Store (Page ${page}/${pages})`); + // + if (store_items.length === 0) { + store_embed.setDescription("No items"); + } else { + // + store_embed.addFields( + store_items + .slice((page - 1) * 10, page * 10) + .map( + (store_item: StoreItem) => + ({ + name: `${store_item.name} (${store_item.price} ${ store_item.price === 1 ? config.currency : config.currency_plural })`, + value: `${store_item.description}\nUsable: ${store_item.usable}${ store_item.roles_required.length === 0 ? "" : `\nRoles required: ${store_item.roles_required.map((role_id) => `<@&${role_id}>`).join("")}` }`, + }) + ) + ); + } + return store_embed; + } + function gen_action_row(page: number, pages: number) { + let action_row = new ActionRowBuilder(); + let action_prev = new ButtonBuilder() + .setCustomId(String(page - 1)) + .setLabel("Prev") + .setEmoji("⬅️") + .setStyle(ButtonStyle.Primary) + .setDisabled(page - 1 === 0); + let action_next = new ButtonBuilder() + .setCustomId(String(page + 1)) + .setLabel("Next") + .setEmoji("➡️") + .setStyle(ButtonStyle.Primary) + .setDisabled(page + 1 > pages); + action_row.addComponents(action_prev, action_next); + return action_row; + } + await interaction.deferReply({ ephemeral: true }); + const store_items: StoreItem[] = await get_all_items(); + //list items in items embed, if too many, make sure pagination buttons work + const pages: number = Math.ceil(store_items.length / 10) || 1; //min of 1 + let page = 1; + const dresp = await interaction.editReply({ + embeds: [ gen_store_embed(store_items, page, pages) ], + components: [ gen_action_row(page, pages) ], + }); + while (true) { + try { + let dresp_bin = await dresp.awaitMessageComponent({ filter: (bin) => bin.user.id === interaction.user.id, time: 60000 }); //bin = button interaction + page = Number(dresp_bin.customId); + await dresp_bin.update({ + embeds: [ gen_store_embed(store_items, page, pages) ], + components: [ gen_action_row(page, pages) ], + }); + } catch (_) { + //errors when people stop pressing the button + return; + } + } +} + +const data: CommandData = { + name: "store", + description: "See info about all items", + registered_only: false, + ephemeral: true, + admin_only: false, + run, +}; + +export default data; + + diff --git a/commands/transfer.ts b/commands/transfer.ts index a6a57b5..f296e16 100644 --- a/commands/transfer.ts +++ b/commands/transfer.ts @@ -4,7 +4,7 @@ import type { CommandData } from "./index"; import type { User } from "../db"; import config from "../config.json"; import { get_user, add_balance, sub_balance } from "../db"; -import { BotError } from "./error"; +import { BotError } from "./common/error"; async function run(interaction: ChatInputCommandInteraction, user: User) { const options = interaction.options; @@ -31,4 +31,3 @@ const data: CommandData = { export default data; - diff --git a/db.ts b/db.ts index 1f6fe97..864ae0f 100644 --- a/db.ts +++ b/db.ts @@ -18,14 +18,21 @@ client.connect().then(() => { //db structure types +export type Items = Record; + export interface StoreItem { + name: string; + price: number; + description: string; + roles_required: string[]; + usable: boolean; // }; export interface User { user: `${number}`; //discord user id balance: number; - items: Record; + items: Items; // }; @@ -85,7 +92,7 @@ export async function add_item_to_user(user: string, item: string, amount: numbe } export async function sub_item_to_user(user: string, item: string, amount: number): Promise { - return await users.updateOne({ + return did_update(await users.updateOne({ user, [`items.${item}`]: { $gte: amount, @@ -94,8 +101,46 @@ export async function sub_item_to_user(user: string, item: string, amount: numbe $inc: { [`items.${item}`]: -amount, }, + })); +} + +//to use when admin deletes an item +async function del_item_from_all_users(item: string) { + return await users.updateMany({ + [`items.${item}`]: { + $gte: 1, + }, + }, { + $set: { + [`items.${item}`]: 0, + }, }); } +//store collection db functions +//so, "Submarine", "submarine", and "suBMARine" are different items. deal with it + +export async function get_all_items(): Promise { + return await (await store.find()).toArray(); +} + +export async function get_item(item: string): Promise { + return await store.findOne({ name: item }); +} + +export async function create_item(store_item: StoreItem) { + return await store.insertOne(store_item); +} + +//assume name cannot be edited +export async function edit_item(store_item: StoreItem) { + return await store.updateOne({ name: store_item.name }, store_item); +} + +export async function delete_item(item: string) { + await del_item_from_all_users(item); + return await store.deleteOne({ name: item }); +} + // diff --git a/index.ts b/index.ts index 4ba7c6c..a9d0a54 100644 --- a/index.ts +++ b/index.ts @@ -4,7 +4,7 @@ import { config } from "dotenv"; config(); import {} from "./db"; -import run from "./commands"; +import handle_interaction from "./commands"; const client = new Client({ intents: [GatewayIntentBits.Guilds] }); @@ -14,9 +14,8 @@ client.on("ready", async () => { }); client.on("interactionCreate", async (interaction: BaseInteraction) => { - // - if (interaction.isChatInputCommand()) { - return await run(interaction); + if (interaction.isChatInputCommand() || interaction.isAutocomplete()) { + return await handle_interaction(interaction); } }); diff --git a/register.ts b/register.ts index a88d912..1cb79b9 100644 --- a/register.ts +++ b/register.ts @@ -95,7 +95,7 @@ const commands = [ { type: 4, name: "amount", - description: "Amount to add/subtract", + description: "Amount to add/subtract (negative allowed)", required: true, }, { @@ -124,6 +124,85 @@ const commands = [ }, ], }, + { + name: "items", + description: "See you or someone else's items", + options: [ + { + type: 6, + name: "target", + description: "The user to check the items of", + required: false, + }, + ], + }, + { + name: "create_item", + description: "Create item (admin only)", + options: [ + { + type: 3, + name: "name", + description: "Name of the item", + required: true, + }, + { + type: 4, + name: "price", + description: "Price of the item", + required: true, + }, + { + type: 3, + name: "description", + description: "Description of the item", + required: true, + }, + { + type: 5, + name: "usable", + description: "Whether it can be /use'd (default: true)", + required: false, + }, + { + type: 8, + name: "required_role", + description: "Roles that are required to buy this item. /edit_item to add multiple", + required: false, + }, + // + ], + }, + { + name: "store", + description: "See info about all items", + options: [], + }, + { + name: "change_item_balance", + description: "Add/remove items from a user", + options: [ + { + type: 3, + name: "name", + description: "Name of the item", + required: true, + autocomplete: true, + }, + { + type: 4, + name: "quantity", + description: "Amount to add/subtract (negative allowed)", + required: true, + }, + { + type: 6, + name: "target", + description: "The user to check the items of", + required: true, + }, + ], + }, ]; (new REST().setToken(process.env.DISCORD_TOKEN)).put(Routes.applicationCommands(process.env.CLIENT_ID), { body: commands }).then(() => console.log("Finished reloading slash commands"));