From f126b91ef723851d654baf2f9156d2b9ff689178 Mon Sep 17 00:00:00 2001 From: Igor Sikachyna Date: Wed, 8 May 2024 15:47:30 -0400 Subject: [PATCH] BLOCK-2247 - UOS threshold roles (#32) * BLOCK-2247 - Added ability to register UOS threshold roles * BLOCK-2247 - Added commands to print and delete roles, add and remove roles based on UOS ownership, remove roles that have no requirements attached * BLOCK-2247 - Remove unused code from adduosthreshold, fix mixed conditions for a role * BLOCK-2247 - Allow printing all roles, add common way to check if a role is empty, allow deleting role based on numerical ID, remove all empty user roles on refresh * BLOCK-2247 - Properly check for message exceeding the size limit * BLOCK-2247 - Update package-lock.json * BLOCK-2247 - Use common interface to check for empty role --- package-lock.json | 4 +- package.json | 2 +- src/interfaces/database.ts | 14 + src/interfaces/fungibleTokenBalance.ts | 3 + src/interfaces/index.ts | 1 + src/services/database/factory.ts | 153 -------- src/services/database/index.ts | 2 +- src/services/database/role.ts | 351 ++++++++++++++++++ src/services/discord/commands/addfactory.ts | 6 +- .../discord/commands/adduosthreshold.ts | 66 ++++ src/services/discord/commands/deleterole.ts | 79 ++++ src/services/discord/commands/index.ts | 4 + src/services/discord/commands/printrole.ts | 105 ++++++ .../discord/commands/removefactory.ts | 2 +- .../discord/commands/removeuosthreshold.ts | 56 +++ src/services/discord/commands/unlink.ts | 2 +- src/services/discord/index.ts | 25 +- src/services/users/index.ts | 91 ++++- 18 files changed, 785 insertions(+), 181 deletions(-) create mode 100644 src/interfaces/fungibleTokenBalance.ts delete mode 100644 src/services/database/factory.ts create mode 100644 src/services/database/role.ts create mode 100644 src/services/discord/commands/adduosthreshold.ts create mode 100644 src/services/discord/commands/deleterole.ts create mode 100644 src/services/discord/commands/printrole.ts create mode 100644 src/services/discord/commands/removeuosthreshold.ts diff --git a/package-lock.json b/package-lock.json index 53b2198..ad2c225 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ultra-discord-uniq-roles-bot", - "version": "1.1.7", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ultra-discord-uniq-roles-bot", - "version": "1.1.7", + "version": "1.2.0", "license": "ISC", "dependencies": { "@ultraos/ultra-signer-lib": "^1.6.2", diff --git a/package.json b/package.json index 2543eae..536515f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ultra-discord-uniq-roles-bot", - "version": "1.1.7", + "version": "1.2.0", "description": "Ultra Uniq Bot for Discord Servers", "scripts": { "build": "tsc --build --clean && tsc ", diff --git a/src/interfaces/database.ts b/src/interfaces/database.ts index 3cb347c..0b5180b 100644 --- a/src/interfaces/database.ts +++ b/src/interfaces/database.ts @@ -74,6 +74,20 @@ export interface dRole extends Document { * @memberof dRole */ role: string; + + /** + * Minimum amount of UOS associated with this role. + * + * @type {number} + * @memberof dRole + */ + uosThreshold: number; +} + +export function isRoleEmpty(role: dRole): boolean { + if (role.factories && role.factories.length > 0) return false; + if (role.uosThreshold && role.uosThreshold > 0) return false; + return true; } /** diff --git a/src/interfaces/fungibleTokenBalance.ts b/src/interfaces/fungibleTokenBalance.ts new file mode 100644 index 0000000..ae3b0e1 --- /dev/null +++ b/src/interfaces/fungibleTokenBalance.ts @@ -0,0 +1,3 @@ +export default interface FungibleTokenBalance { + balance: string; +} diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index 743fc59..6a915ca 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -2,4 +2,5 @@ export { default as Config } from './config'; export { default as MessageRequest } from './messageRequest'; export { default as Response } from './response'; export { default as Token } from './token'; +export { default as FungibleTokenBalance } from './fungibleTokenBalance'; export * as db from './database'; diff --git a/src/services/database/factory.ts b/src/services/database/factory.ts deleted file mode 100644 index 98670aa..0000000 --- a/src/services/database/factory.ts +++ /dev/null @@ -1,153 +0,0 @@ -import * as shared from './shared'; -import * as I from '../../interfaces'; -import { Role, dRole } from 'interfaces/database'; - -const COLLECTION_NAME = 'roles'; - -/** - * Add a factory to a role. - * If the role doesn't exists, a new document is created. - * - * @export - * @param {number} factory - * @param {string} discordRole - * @return {Promise>} - */ -export async function addFactory(factory: number, discordRole: string): Promise> { - const factoryDocument = await getFactory(factory); - if (factoryDocument.status === true) { - return { status: true, data: 'factory already has an entry' }; - } - - const db = await shared.getDatabase(); - if (typeof db === 'undefined') { - return { status: false, data: 'database could not be found' }; - } - - const collection = db.collection(COLLECTION_NAME); - const result = await collection - .findOneAndUpdate( - { role: discordRole }, // query filter - { $set: { role: discordRole }, $push: { factories: factory } }, // data to update - { upsert: true } - ) - .catch((err) => { - console.error(err); - return undefined; - }); - - if (!result || !result.ok) { - return { status: false, data: 'could not add factory' }; - } - - return { status: true, data: 'added factory' }; -} - -/** - * Removes a token factory from a role. Returns the role object that was updated - * - * @export - * @param {number} factory - * @return {Promise>} - */ -export async function removeFactory(factory: number): Promise> { - const factoryDocument = await getFactory(factory); - if (factoryDocument.status === false) { - return { status: false, data: 'factory does not exist in the database' }; - } - - const db = await shared.getDatabase(); - if (typeof db === 'undefined') { - return { status: false, data: 'database could not be found' }; - } - - if (typeof factoryDocument.data === 'string') { - return { status: false, data: 'factory does not exist in the database' }; - } - - // Delete factoryId from the role object - const collection = db.collection(COLLECTION_NAME); - await collection.updateOne({ _id: factoryDocument.data._id }, { $pull: { factories: factory } }); - - return { status: true, data: factoryDocument.data }; -} - -/** - * Checks if a factory id exists in the database. - * Returns the associated role object, if found. - * - * @export - * @param {number} factory - * @return {(Promise>)} - */ -export async function getFactory(factory: number): Promise> { - const db = await shared.getDatabase(); - if (typeof db === 'undefined') { - return { status: false, data: 'database could not be found' }; - } - - const collection = db.collection(COLLECTION_NAME); - const factoryDocument = await collection.findOne({ factories: factory }).catch((err) => { - return null; - }); - - if (factoryDocument === null || typeof factoryDocument === 'undefined') { - return { status: false, data: 'factory was not found' }; - } - - return { status: true, data: factoryDocument }; -} - -/** - * Returns associated factories based on role id. - * - * @export - * @param {string | number} role - * @return {(Promise>)} - */ -export async function getFactoriesByRole(role: number | string): Promise> { - const db = await shared.getDatabase(); - if (typeof db === 'undefined') { - return { status: false, data: 'database could not be found' }; - } - - const collection = db.collection(COLLECTION_NAME); - const factoryDocument = await collection.findOne({ role }).catch((err) => { - return null; - }); - - if (factoryDocument === null || typeof factoryDocument === 'undefined') { - return { status: false, data: 'factory was not found' }; - } - - return { status: true, data: factoryDocument }; -} - -/** - * Deletes a role from the db. Returns the role object that was deleted - * - * @export - * @param {string | number} role - * @return {(Promise>)} - */ -export async function deleteRole(role: number | string): Promise> { - const roleDocument = await getFactoriesByRole(role); - if (roleDocument.status === false) { - return { status: false, data: 'role does not exist in the database' }; - } - - const db = await shared.getDatabase(); - if (typeof db === 'undefined') { - return { status: false, data: 'database could not be found' }; - } - - if (typeof roleDocument.data === 'string') { - return { status: false, data: 'role does not exist in the database' }; - } - - // Delete role from database - const collection = db.collection(COLLECTION_NAME); - await collection.deleteOne({ _id: roleDocument.data._id }); - - return { status: true, data: roleDocument.data }; -} diff --git a/src/services/database/index.ts b/src/services/database/index.ts index 6005820..65a16db 100644 --- a/src/services/database/index.ts +++ b/src/services/database/index.ts @@ -1,3 +1,3 @@ -export * as factory from './factory'; +export * as role from './role'; export * as user from './user'; export * as shared from './shared'; diff --git a/src/services/database/role.ts b/src/services/database/role.ts new file mode 100644 index 0000000..fc8cacd --- /dev/null +++ b/src/services/database/role.ts @@ -0,0 +1,351 @@ +import * as shared from './shared'; +import * as I from '../../interfaces'; +import { Role, dRole } from 'interfaces/database'; + +const COLLECTION_NAME = 'roles'; + +/** + * Add a factory to a role. + * If the role doesn't exists, a new document is created. + * + * @export + * @param {number} factory + * @param {string} discordRole + * @return {Promise>} + */ +export async function addFactory(factory: number, discordRole: string): Promise> { + const factoryDocument = await getFactory(factory); + if (factoryDocument.status === true) { + return { status: true, data: 'factory already has an entry' }; + } + + const db = await shared.getDatabase(); + if (typeof db === 'undefined') { + return { status: false, data: 'database could not be found' }; + } + + const roleDocument = await getDocumentByRole(discordRole); + if (typeof roleDocument.data !== 'string') { + if (roleDocument.data.uosThreshold && roleDocument.data.uosThreshold > 0) { + return { status: false, data: 'cannot use factory and UOS threshold requirement in the same role' }; + } + } + + const collection = db.collection(COLLECTION_NAME); + const result = await collection + .findOneAndUpdate( + { role: discordRole }, // query filter + { $set: { role: discordRole }, $push: { factories: factory } }, // data to update + { upsert: true } + ) + .catch((err) => { + console.error(err); + return undefined; + }); + + if (!result || !result.ok) { + return { status: false, data: 'could not add factory' }; + } + + return { status: true, data: 'added factory' }; +} + +/** + * Removes a token factory from a role. Returns the role object that was updated + * + * @export + * @param {number} factory + * @return {Promise>} + */ +export async function removeFactory(factory: number): Promise> { + const factoryDocument = await getFactory(factory); + if (factoryDocument.status === false) { + return { status: false, data: 'factory does not exist in the database' }; + } + + const db = await shared.getDatabase(); + if (typeof db === 'undefined') { + return { status: false, data: 'database could not be found' }; + } + + if (typeof factoryDocument.data === 'string') { + return { status: false, data: 'factory does not exist in the database' }; + } + + // Delete factoryId from the role object + const collection = db.collection(COLLECTION_NAME); + await collection.updateOne({ _id: factoryDocument.data._id }, { $pull: { factories: factory } }); + + return { status: true, data: factoryDocument.data }; +} + +/** + * Checks if a factory id exists in the database. + * Returns the associated role object, if found. + * + * @export + * @param {number} factory + * @return {(Promise>)} + */ +export async function getFactory(factory: number): Promise> { + const db = await shared.getDatabase(); + if (typeof db === 'undefined') { + return { status: false, data: 'database could not be found' }; + } + + const collection = db.collection(COLLECTION_NAME); + const factoryDocument = await collection.findOne({ factories: factory }).catch((err) => { + return null; + }); + + if (factoryDocument === null || typeof factoryDocument === 'undefined') { + return { status: false, data: 'factory was not found' }; + } + + return { status: true, data: factoryDocument }; +} + +/** + * Returns associated roles that contain factory requirement. + * + * @export + * @return {(Promise>)} + */ +export async function getFactoryDocuments(): Promise> { + const db = await shared.getDatabase(); + if (typeof db === 'undefined') { + return { status: false, data: 'database could not be found' }; + } + + const collection = db.collection(COLLECTION_NAME); + const roleDocuments = await collection.find({ factory: { $ne : null } }); + let response: dRole[] = []; + while (await roleDocuments.hasNext().catch((err) => { + return null; + })) { + let document = await roleDocuments.next().catch((err) => { + return null; + }); + // Search filter already checked that factories is not null + if (document){ + if (document.factories.length > 0) response.push(document); + } + else break; + } + + return { status: true, data: response }; +} + +/** + * Returns associated role document based on role id. + * + * @export + * @param {string | number} role + * @return {(Promise>)} + */ +export async function getDocumentByRole(role: number | string): Promise> { + const db = await shared.getDatabase(); + if (typeof db === 'undefined') { + return { status: false, data: 'database could not be found' }; + } + + const collection = db.collection(COLLECTION_NAME); + const roleDocument = await collection.findOne({ role }).catch((err) => { + return null; + }); + + if (roleDocument === null || typeof roleDocument === 'undefined') { + return { status: false, data: 'role document was not found' }; + } + + return { status: true, data: roleDocument }; +} + +/** + * Deletes a role from the db. Returns the role object that was deleted + * + * @export + * @param {string | number} role + * @return {(Promise>)} + */ +export async function deleteRole(role: number | string): Promise> { + const roleDocument = await getDocumentByRole(role); + if (roleDocument.status === false) { + return { status: false, data: 'role does not exist in the database' }; + } + + const db = await shared.getDatabase(); + if (typeof db === 'undefined') { + return { status: false, data: 'database could not be found' }; + } + + if (typeof roleDocument.data === 'string') { + return { status: false, data: 'role does not exist in the database' }; + } + + // Delete role from database + const collection = db.collection(COLLECTION_NAME); + await collection.deleteOne({ _id: roleDocument.data._id }); + + return { status: true, data: roleDocument.data }; +} + +/** + * Checks if UOS threshold exists in the database. + * Returns the associated role object, if found. + * + * @export + * @param {number} factory + * @return {(Promise>)} + */ +export async function getUosThreshold(uosThreshold: number): Promise> { + const db = await shared.getDatabase(); + if (typeof db === 'undefined') { + return { status: false, data: 'database could not be found' }; + } + + const collection = db.collection(COLLECTION_NAME); + const uosThresholdDocument = await collection.findOne({ uosThreshold: uosThreshold }).catch((err) => { + return null; + }); + + if (uosThresholdDocument === null || typeof uosThresholdDocument === 'undefined') { + return { status: false, data: 'UOS threshold was not found' }; + } + + return { status: true, data: uosThresholdDocument }; +} + +/** + * Add a UOS threshold to a role. + * If the role doesn't exists, a new document is created. + * + * @export + * @param {number} uosThreshold + * @param {string} discordRole + * @return {Promise>} + */ +export async function addUosThreshold(uosThreshold: number, discordRole: string): Promise> { + const uosThresholdDocument = await getUosThreshold(uosThreshold); + if (uosThresholdDocument.status === true) { + return { status: true, data: 'UOS threshold already has an entry' }; + } + + const db = await shared.getDatabase(); + if (typeof db === 'undefined') { + return { status: false, data: 'database could not be found' }; + } + + const roleDocument = await getDocumentByRole(discordRole); + if (typeof roleDocument.data !== 'string') { + if (roleDocument.data.factories && roleDocument.data.factories.length > 0) { + return { status: false, data: 'cannot use factory and UOS threshold requirement in the same role' }; + } + } + + const collection = db.collection(COLLECTION_NAME); + const result = await collection + .findOneAndUpdate( + { role: discordRole }, // query filter + { $set: { role: discordRole, uosThreshold: uosThreshold }}, // data to update + { upsert: true } + ) + .catch((err) => { + console.error(err); + return undefined; + }); + + if (!result || !result.ok) { + return { status: false, data: 'could not UOS threshold' }; + } + + return { status: true, data: 'added UOS threshold' }; +} + +/** + * Removes a UOS threshold from a role. Returns the role object that was updated + * + * @export + * @param {number} uosThreshold + * @return {Promise>} + */ +export async function removeUosThreshold(uosThreshold: number): Promise> { + const uosThresholdDocument = await getUosThreshold(uosThreshold); + if (uosThresholdDocument.status === false) { + return { status: false, data: 'UOS threshold does not exist in the database' }; + } + + const db = await shared.getDatabase(); + if (typeof db === 'undefined') { + return { status: false, data: 'database could not be found' }; + } + + if (typeof uosThresholdDocument.data === 'string') { + return { status: false, data: 'UOS threshold does not exist in the database' }; + } + + // Remove UOS threshold from the role object + const collection = db.collection(COLLECTION_NAME); + await collection.updateOne({ _id: uosThresholdDocument.data._id }, { $set: { uosThreshold: undefined } }); + + return { status: true, data: uosThresholdDocument.data }; +} + +/** + * Returns associated roles that contain UOS threshold requirement. + * + * @export + * @return {(Promise>)} + */ +export async function getUosThresholdDocuments(): Promise> { + const db = await shared.getDatabase(); + if (typeof db === 'undefined') { + return { status: false, data: 'database could not be found' }; + } + + const collection = db.collection(COLLECTION_NAME); + const roleDocuments = await collection.find({ uosThreshold: { $ne : null } }); + let response: dRole[] = []; + while (await roleDocuments.hasNext().catch((err) => { + return null; + })) { + let document = await roleDocuments.next().catch((err) => { + return null; + }); + // Search filter already checked that uosThreshold is not null + if (document){ + if (document.uosThreshold > 0) response.push(document); + } + else break; + } + + return { status: true, data: response }; +} + +/** + * Returns all roles that are currently managed by the Discord bot. + * + * @export + * @return {(Promise>)} + */ +export async function getAllDocuments(): Promise> { + const db = await shared.getDatabase(); + if (typeof db === 'undefined') { + return { status: false, data: 'database could not be found' }; + } + + const collection = db.collection(COLLECTION_NAME); + const roleDocuments = await collection.find({}); + let response: dRole[] = []; + while (await roleDocuments.hasNext().catch((err) => { + return null; + })) { + let document = await roleDocuments.next().catch((err) => { + return null; + }); + if (document) response.push(document); + else break; + } + + return { status: true, data: response }; +} \ No newline at end of file diff --git a/src/services/discord/commands/addfactory.ts b/src/services/discord/commands/addfactory.ts index 39a5510..520eb7d 100644 --- a/src/services/discord/commands/addfactory.ts +++ b/src/services/discord/commands/addfactory.ts @@ -74,7 +74,7 @@ async function handleInteraction(interaction: ChatInputCommandInteraction) { try { // Check if factory already exists in database. - const factoryInDb = await Services.database.factory.getFactory(factoryId); + const factoryInDb = await Services.database.role.getFactory(factoryId); if (factoryInDb.status) { return interaction.editReply({ content: `⚠️ Error: Factory ID: ${factoryId} is already assigned to a role.`, @@ -90,7 +90,7 @@ async function handleInteraction(interaction: ChatInputCommandInteraction) { } // Added factory to database - const resp = await Services.database.factory.addFactory(factoryId, role.id); + const resp = await Services.database.role.addFactory(factoryId, role.id); if (!resp.status) { return interaction.editReply({ content: `⚠️ Error: ${resp.data}`, @@ -98,7 +98,7 @@ async function handleInteraction(interaction: ChatInputCommandInteraction) { } return interaction.editReply({ - content: `✅ Factory: ${factoryId} added with role: ${role.name} successfully.`, + content: `✅ Factory: ${factoryId} added with role: ${role.name} (${role.id}) successfully.`, }); } catch (error) { return interaction.editReply({ diff --git a/src/services/discord/commands/adduosthreshold.ts b/src/services/discord/commands/adduosthreshold.ts new file mode 100644 index 0000000..6c54d15 --- /dev/null +++ b/src/services/discord/commands/adduosthreshold.ts @@ -0,0 +1,66 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits, BaseInteraction } from 'discord.js'; +import * as Services from '../..'; + +const commandName = 'adduosthreshold'; +const commandDescription = `Allows an admin to bind current user's UOS balance to a role`; +const command = new SlashCommandBuilder() + .setName(commandName) + .setDescription(commandDescription) + .addIntegerOption((option) => + option.setName('uos_threshold').setDescription('minimum amount of UOS required for this role').setRequired(true) + ) + .addRoleOption((option) => option.setName('role').setDescription('role id').setRequired(true)) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator); + +async function handleInteraction(interaction: ChatInputCommandInteraction) { + if (!interaction.isRepliable()) { + return; + } + + if (!interaction.member) { + return interaction.reply({ + content: 'Could not find user in ultra server.', + ephemeral: true, // Makes responses 'only you can see this' + }); + } + + // Let the user know we're working on this request + // If we don't use deferReply, we might run out of the 3 seconds reply time limit + // We can hit the limit because of on-chain lookups or network latency in db operations + await interaction.deferReply({ + ephemeral: true, // Makes responses 'only you can see this' + }); + + // Using non-null assertion operator (!) because if we get here, then these two values do exist + // Because we're using .setRequired(true) when setting up the command options + const uosThreshold = interaction.options.getInteger('uos_threshold')!; + const role = interaction.options.getRole('role')!; + + try { + // Check if threshold exists in database. + const factoryInDb = await Services.database.role.getUosThreshold(uosThreshold); + if (factoryInDb.status) { + return interaction.editReply({ + content: `⚠️ Error: UOS threshold: ${uosThreshold} is already assigned to a role.`, + }); + } + + // Add threshold to database + const resp = await Services.database.role.addUosThreshold(uosThreshold, role.id); + if (!resp.status) { + return interaction.editReply({ + content: `⚠️ Error: ${resp.data}`, + }); + } + + return interaction.editReply({ + content: `✅ UOS threshold: ${uosThreshold} added with role: ${role.name} (${role.id}) successfully.`, + }); + } catch (error) { + return interaction.editReply({ + content: `❌ Something went wrong. Error: ${error}`, + }); + } +} + +Services.discord.register(command, handleInteraction); diff --git a/src/services/discord/commands/deleterole.ts b/src/services/discord/commands/deleterole.ts new file mode 100644 index 0000000..763b7be --- /dev/null +++ b/src/services/discord/commands/deleterole.ts @@ -0,0 +1,79 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import * as Services from '../..'; +import { getRole } from '..'; + +const commandName = 'deleterole'; +const commandDescription = "Allows an admin to delete a role record from the database so it is no longer controlled by the bot"; +const command = new SlashCommandBuilder() + .setName(commandName) + .setDescription(commandDescription) + .addRoleOption((option) => option.setName('role').setDescription('Role id').setRequired(false)) + .addStringOption((option) => + option.setName('role_id').setDescription('Numeric ID of the role').setRequired(false) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator); + +async function handleInteraction(interaction: ChatInputCommandInteraction) { + if (!interaction.isRepliable()) { + return; + } + + if (!interaction.member) { + return interaction.reply({ + content: 'Could not find user in ultra server.', + ephemeral: true, // Makes responses 'only you can see this' + }); + } + + // Let the user know we're working on this request + // If we don't use deferReply, we might run out of the 3 seconds reply time limit + // We can hit the limit because of on-chain lookups or network latency in db operations + await interaction.deferReply({ + ephemeral: true, // Makes responses 'only you can see this' + }); + + const role = interaction.options.getRole('role'); + const roleNumericId = interaction.options.getString('role_id'); + + try { + if ((role && roleNumericId) || (!role && !roleNumericId)) { + return interaction.editReply({ + content: `⚠️ Error: specify either one of "role" or "role_id"`, + }); + } + + // Remove role from db + if (role) { + const resp = await Services.database.role.deleteRole(role.id); + if (!resp.status) { + return interaction.editReply({ + content: `⚠️ Error: ${resp.data}`, + }); + } + + return interaction.editReply({ + content: `✅ Role link: ${role.name} (${role.id}) removed successfully`, + }); + } + if (roleNumericId) { + const resp = await Services.database.role.deleteRole(roleNumericId); + if (!resp.status) { + return interaction.editReply({ + content: `⚠️ Error: ${resp.data}`, + }); + } + + let roleData = await getRole(roleNumericId); + + return interaction.editReply({ + content: `✅ Role link: ${roleData?.name} (${roleNumericId}) removed successfully`, + }); + } + } catch (error) { + return interaction.editReply({ + content: `❌ Something went wrong. Error: ${error}`, + }); + } +} + +Services.discord.register(command, handleInteraction); diff --git a/src/services/discord/commands/index.ts b/src/services/discord/commands/index.ts index d25c875..4fd3e11 100644 --- a/src/services/discord/commands/index.ts +++ b/src/services/discord/commands/index.ts @@ -3,3 +3,7 @@ import './link'; import './unlink'; import './addfactory'; import './removefactory'; +import './adduosthreshold'; +import './removeuosthreshold'; +import './deleterole'; +import './printrole'; \ No newline at end of file diff --git a/src/services/discord/commands/printrole.ts b/src/services/discord/commands/printrole.ts new file mode 100644 index 0000000..7e3aad5 --- /dev/null +++ b/src/services/discord/commands/printrole.ts @@ -0,0 +1,105 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import * as Services from '../..'; +import { isRoleEmpty } from '../../../interfaces/database'; +import { getGuild, getRole } from '..'; + +const commandName = 'printrole'; +const commandDescription = "Allows an admin to print requirements of a specific role"; +const command = new SlashCommandBuilder() + .setName(commandName) + .setDescription(commandDescription) + .addRoleOption((option) => option.setName('role').setDescription('role id').setRequired(false)) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator); + +async function handleInteraction(interaction: ChatInputCommandInteraction) { + if (!interaction.isRepliable()) { + return; + } + + if (!interaction.member) { + return interaction.reply({ + content: 'Could not find user in ultra server.', + ephemeral: true, // Makes responses 'only you can see this' + }); + } + + // Let the user know we're working on this request + // If we don't use deferReply, we might run out of the 3 seconds reply time limit + // We can hit the limit because of on-chain lookups or network latency in db operations + await interaction.deferReply({ + ephemeral: true, // Makes responses 'only you can see this' + }); + + const role = interaction.options.getRole('role'); + + try { + const resp = role ? + await Services.database.role.getDocumentByRole(role.id) : + await Services.database.role.getAllDocuments(); + + if (!resp.status) { + return interaction.editReply({ + content: `⚠️ Error: ${resp.data}`, + }); + } + + let isFirstReply: boolean = true; + let resultString = ''; + if (typeof resp.data === 'string') { + resultString = resp.data; + } else { + let roles = Array.isArray(resp.data) ? resp.data : [resp.data]; + for (let role of roles) { + let roleData = await getRole(role.role); + let appendString = ''; + appendString += `Role: ${roleData?.name} (${role.role})\n`; + if (isRoleEmpty(role)) { + appendString += `Empty\n`; + } else { + if (role.factories && role.factories.length) { + appendString += `Factories: ${JSON.stringify(role.factories)}\n`; + } + if (role.uosThreshold && role.uosThreshold > 0) { + appendString += `UOS threshold: ${role.uosThreshold}\n`; + } + } + appendString += `\n`; + + // Avoid hitting the message limit of Discord of 2000 characters, just in case + // Check for a bit less than 2000 just to have a small buffer + if (resultString.length + appendString.length > 1900) { + if (isFirstReply) { + isFirstReply = false; + await interaction.editReply({ + content: `✅\n${resultString}`, + }); + } else { + await interaction.followUp({ + content: `✅\n${resultString}`, + ephemeral: true, // Makes responses 'only you can see this' + }); + } + resultString = ''; + } + resultString += appendString; + } + } + + if (isFirstReply) { + return interaction.editReply({ + content: `✅\n${resultString}`, + }); + } else { + return interaction.followUp({ + content: `✅\n${resultString}`, + ephemeral: true, // Makes responses 'only you can see this' + }); + } + } catch (error) { + return interaction.editReply({ + content: `❌ Something went wrong. Error: ${error}`, + }); + } +} + +Services.discord.register(command, handleInteraction); diff --git a/src/services/discord/commands/removefactory.ts b/src/services/discord/commands/removefactory.ts index 17beef6..4ee34fd 100644 --- a/src/services/discord/commands/removefactory.ts +++ b/src/services/discord/commands/removefactory.ts @@ -36,7 +36,7 @@ async function handleInteraction(interaction: ChatInputCommandInteraction) { try { // Remove factoryId from db - const resp = await Services.database.factory.removeFactory(factoryId); + const resp = await Services.database.role.removeFactory(factoryId); if (!resp.status) { return interaction.editReply({ content: `⚠️ Error: ${resp.data}`, diff --git a/src/services/discord/commands/removeuosthreshold.ts b/src/services/discord/commands/removeuosthreshold.ts new file mode 100644 index 0000000..a487116 --- /dev/null +++ b/src/services/discord/commands/removeuosthreshold.ts @@ -0,0 +1,56 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import * as Services from '../..'; + +const commandName = 'rmvuosthreshold'; +const commandDescription = "Allows an admin to remove a UOS threshold from it's associated role"; +const command = new SlashCommandBuilder() + .setName(commandName) + .setDescription(commandDescription) + .addIntegerOption((option) => + option.setName('uos_threshold').setDescription('UOS threshold to remove').setRequired(true) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator); + +async function handleInteraction(interaction: ChatInputCommandInteraction) { + if (!interaction.isRepliable()) { + return; + } + + if (!interaction.member) { + return interaction.reply({ + content: 'Could not find user in ultra server.', + ephemeral: true, // Makes responses 'only you can see this' + }); + } + + // Let the user know we're working on this request + // If we don't use deferReply, we might run out of the 3 seconds reply time limit + // We can hit the limit because of on-chain lookups or network latency in db operations + await interaction.deferReply({ + ephemeral: true, // Makes responses 'only you can see this' + }); + + // Using non-null assertion operator (!) because if we get here, then these two values do exist + // Because we're using .setRequired(true) when setting up the command options + const uosThreshold = interaction.options.getInteger('uos_threshold')!; + + try { + // Remove uosThreshold from db + const resp = await Services.database.role.removeUosThreshold(uosThreshold); + if (!resp.status) { + return interaction.editReply({ + content: `⚠️ Error: ${resp.data}`, + }); + } + + return interaction.editReply({ + content: `✅ UOS threshold: ${uosThreshold} removed successfully`, + }); + } catch (error) { + return interaction.editReply({ + content: `❌ Something went wrong. Error: ${error}`, + }); + } +} + +Services.discord.register(command, handleInteraction); diff --git a/src/services/discord/commands/unlink.ts b/src/services/discord/commands/unlink.ts index 72149a4..c7f34fc 100644 --- a/src/services/discord/commands/unlink.ts +++ b/src/services/discord/commands/unlink.ts @@ -18,7 +18,7 @@ async function removeFactoryRoles(discordId: string): Promise<{ status: boolean; // Loop through each role, and check if it's a factory role and remove it for (let role of userData.roles) { - const response = await Services.database.factory.getFactoriesByRole(role); + const response = await Services.database.role.getDocumentByRole(role); // If record not found, then this role is not a factory role - don't remove // We only keep factory roles in the db. diff --git a/src/services/discord/index.ts b/src/services/discord/index.ts index 0830f76..be3e757 100644 --- a/src/services/discord/index.ts +++ b/src/services/discord/index.ts @@ -1,7 +1,7 @@ import * as I from '../../interfaces'; import * as Utility from '../../utility'; import { updateAllCommands } from './update'; -import { deleteRole } from '../database/factory'; +import { deleteRole } from '../database/role'; import { ChatInputCommandInteraction, Client, @@ -207,3 +207,26 @@ export async function init(token: string): Promise { client.login(token); }); } + +/** + * Get a role in the discord server where the bot is located. + * + * @export + * @param {string} id + * @return {(Role | undefined)} + */ +export async function getRole(id: string | number): Promise { + const guild = getGuild(); + if (typeof guild === 'undefined') { + return undefined; + } + + try { + if (typeof id === 'number') id = id.toString(); + const role = await guild.roles.fetch(id); + if (!role) return undefined; + return role; + } catch (err) { + return undefined; + } +} \ No newline at end of file diff --git a/src/services/users/index.ts b/src/services/users/index.ts index a24ed4b..62c45ae 100644 --- a/src/services/users/index.ts +++ b/src/services/users/index.ts @@ -1,4 +1,4 @@ -import { factory, shared, user } from '../database'; +import { role, shared, user } from '../database'; import * as util from '../../utility'; import * as Services from '..'; import * as I from '../../interfaces'; @@ -8,9 +8,16 @@ let interval: NodeJS.Timer; const tokenTables = ['token.a', 'token.b']; +function defaultFailedToAssignRolesWarning(action: string) { + util.log.warn(`Cannot Assign Roles. [Case: ${action}]`); + util.log.warn(`- Does bot role have manage roles?`); + util.log.warn(`- Is bot role above all roles that it manages?`); +} + export async function refreshUser(discord: string, blockchainId: string) { - // Get all user tokens + // Get all user tokens and UOS balance let tokens: Array = []; + let uosBalance: number | undefined = undefined; for (let table of tokenTables) { const rows = await Services.blockchain.getAllTableData('eosio.nft.ft', blockchainId, table); @@ -21,6 +28,18 @@ export async function refreshUser(discord: string, blockchainId: string) { tokens = tokens.concat(rows); } + { + const rows = await Services.blockchain.getAllTableData('eosio.token', blockchainId, 'accounts'); + if (!Array.isArray(rows)) { + util.log.warn('Failed to get UOS balance'); + return; + } + + let uosBalanceObject = rows.find((r) => r.balance.split(' ')[1] === 'UOS'); + if (uosBalanceObject) { + uosBalance = parseFloat(uosBalanceObject.balance.split(' ')[0]); + } + } // Remove duplicates const tokenIds = [ @@ -41,20 +60,33 @@ export async function refreshUser(discord: string, blockchainId: string) { let amountAdded = 0; let amountRemoved = 0; - // Loop through each role, and check if it's a factory role - for (let role of userData?.roles) { - const response = await factory.getFactoriesByRole(role); + // Loop through each role, and check if it's a factory role and/or UOS threshold role + for (let userRole of userData?.roles) { + const response = await role.getDocumentByRole(userRole); // If record not found, then this role is not a factory role - don't remove if (!response || !response.status || typeof response.data === 'string') { continue; } - // If the role exits in the database, and user own at least one of the tokens + // Check if the role is effectively empty + // If so - remove it from the user + if (I.db.isRoleEmpty(response.data)) { + await userData.member.roles.remove(userRole, 'Role No Longer Managed').catch((err) => defaultFailedToAssignRolesWarning('User has a role that has no conditions')); + amountRemoved += 1; + continue; + } + + // If the role exits in the database, and user owns at least one of the tokens // associated with the role, keep the role const factoryIds = response.data.factories; let tokenIndex = -1; + // Skip if there is no factory array object + if (!factoryIds) { + continue; + } + // Try to find if the tokenIds are present in factoryIds for this role. // If present, it means user is eligible for this role. for (let i = 0; i < factoryIds.length; i++) { @@ -73,11 +105,7 @@ export async function refreshUser(discord: string, blockchainId: string) { } // If the factory exists, and the user does not have the token; remove the role. - await userData.member.roles.remove(role, 'No Longer Owns Token').catch((err) => { - util.log.warn('Cannot Assign Roles. [Case: User no longer owns token]'); - util.log.warn(`- Does bot role have manage roles?`); - util.log.warn(`- Is bot role above all roles that it manages?`); - }); + await userData.member.roles.remove(userRole, 'No Longer Owns Token').catch((err) => defaultFailedToAssignRolesWarning('User no longer owns token')); amountRemoved += 1; } @@ -85,7 +113,7 @@ export async function refreshUser(discord: string, blockchainId: string) { // Re-loop the tokens; and determine if a role exists for it // If it does exist; append the role. for (let token of tokenIds) { - const response = await factory.getFactory(token); + const response = await role.getFactory(token); if (!response.status) { continue; } @@ -94,22 +122,48 @@ export async function refreshUser(discord: string, blockchainId: string) { continue; } - // If user already have that role, skip + // If user already has that role, skip if (userData.member.roles.cache.has(response.data.role)) { continue; } // If user doesn't have the role, and is eligible for it, // assign the role to user - await userData.member.roles.add(response.data.role).catch((err) => { - util.log.warn('Cannot Assign Roles. [Case: Adding role to user]'); - util.log.warn(`- Does bot role have manage roles?`); - util.log.warn(`- Is bot role above all roles that it manages?`); - }); + await userData.member.roles.add(response.data.role).catch((err) => defaultFailedToAssignRolesWarning('Adding factory role to user')); amountAdded += 1; } + // Update UOS roles only if we were able to get UOS balance + if (uosBalance) { + let uosThresholdDocuments = await role.getUosThresholdDocuments(); + if (uosThresholdDocuments && uosThresholdDocuments.status && typeof uosThresholdDocuments.data !== 'string') { + // Sort in descending order + let roles = uosThresholdDocuments.data.sort((a, b) => b.uosThreshold - a.uosThreshold); + let identifiedRole = null; + for (let i = 0; i < roles.length; i++) { + if (uosBalance >= roles[i].uosThreshold) { + // Only the highest role should be added + if (identifiedRole === null) { + identifiedRole = i; + + // If user already has that role, skip + if (!userData.member.roles.cache.has(roles[i].role)) { + await userData.member.roles.add(roles[i].role).catch((err) => defaultFailedToAssignRolesWarning('Adding UOS threshold role to user')); + amountAdded += 1; + } + } + } + + // If already has a role with higher UOS threshold - remove the lower roles + if (i !== identifiedRole && userData.member.roles.cache.has(roles[i].role)) { + await userData.member.roles.remove(roles[i].role, 'No Longer Within the UOS Threshold').catch((err) => defaultFailedToAssignRolesWarning('User is no longer within UOS threshold')); + amountRemoved += 1; + } + } + } + } + util.log.info( `${userData.member.user.username}#${userData.member.user.discriminator} | Roles +${amountAdded} & -${amountRemoved} | Token Count: ${tokenCount}` ); @@ -146,6 +200,7 @@ async function updateUsers() { while ((document = (await cursor.next()) as I.db.dDiscordUser)) { if (document) userInfo.push(document); } + for (let i = 0; i < userInfo.length; i++) { promises.push(refreshUser(userInfo[i].discord, userInfo[i].blockchain)); await new Promise((r) => setTimeout(r, config.SINGLE_USER_REFRESH_INTERVAL_MS));