diff --git a/.env.example b/.env.example index c9625ab77..d4cc95003 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,12 @@ # EXPERIMENTAL. DO NOT USE THIS! # This will change in upcoming versions. -TESSERACT_OWNER_ID= TESSERACT_BOT_ID= TESSERACT_BOT_TOKEN= +TESSERACT_OWNER_ID= TESSERACT_MONGO_URI= +TESSERACT_UNSAFE_MODE=FALSE + BASTION_MUSIC_ACTIVITY=TRUE -BASTION_SAFE_MODE=TRUE +BASTION_RELAY_DMS=FALSE BASTION_API_PORT=8377 diff --git a/package.json b/package.json index a579a6776..a474d5a8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bastion", - "version": "10.1.0", + "version": "10.2.0", "description": "Get an enhanced Discord experience!", "homepage": "https://bastion.traction.one", "main": "./dist/index.js", @@ -25,7 +25,7 @@ "typescript": "^4.9.3" }, "dependencies": { - "@bastion/tesseract": "^3.0.1", + "@bastion/tesseract": "^3.1.0", "@discordjs/rest": "^1.3.0", "@iamtraction/google-translate": "^2.0.1", "@types/gamedig": "^4.0.0", diff --git a/scripts/bash/methods.sh b/scripts/bash/methods.sh index e5ff9982f..720a9144d 100644 --- a/scripts/bash/methods.sh +++ b/scripts/bash/methods.sh @@ -121,7 +121,7 @@ function method::update () { echo "Updating dependencies..." - rm -fr node_modules package-lock.json screenlog.0 + rm -fr dist node_modules package-lock.json screenlog.0 npm install --no-package-lock if ! [[ "$?" -eq 0 ]] then @@ -140,6 +140,14 @@ function method::update () { exit 1 fi + npm run commands + if ! [[ "$?" -eq 0 ]] + then + print::error "Found some errors while publishing Bastion commands." + print::message "Contact Bastion Support for help." + exit 1 + fi + print::message "Ready to boot up and start running." fi } diff --git a/scripts/powershell/Update.ps1 b/scripts/powershell/Update.ps1 index 64fa2769c..40ae39f37 100644 --- a/scripts/powershell/Update.ps1 +++ b/scripts/powershell/Update.ps1 @@ -48,7 +48,7 @@ Pass-Step Write-Host "[Bastion]: Updating dependencies..." Write-Host -Remove-Item -Path ".\node_modules", ".\package-lock.json" -Force -Recurse -ErrorAction SilentlyContinue +Remove-Item -Path ".\dist", ".\node_modules", ".\package-lock.json" -Force -Recurse -ErrorAction SilentlyContinue npm install --no-package-lock If (-Not ($?)) { Write-Host "[Bastion]: Unable to update Bastion, error while updating dependencies." @@ -73,6 +73,14 @@ If (-Not ($?)) { Exit-Bastion-Updater } +npm run commands +If (-Not ($?)) { + Write-Host "[Bastion]: Found some errors while publishing Bastion commands." + Write-Host "[Bastion]: Contact Bastion Support for further help." + + Exit-Bastion-Updater +} + Pass-Step diff --git a/settings.example.yaml b/settings.example.yaml index 87b16f900..1898ad7f0 100644 --- a/settings.example.yaml +++ b/settings.example.yaml @@ -1,3 +1,15 @@ +# Bot ID +# Add the Client ID of bot which should be running Bastion. +# https://discord.com/developers/applications +# `TESSERACT_BOT_ID` environment variable overwrites this value. +id: "" + +# Bot Token +# Add the token of the bot which should be running Bastion. +# https://discord.com/developers/applications +# `TESSERACT_BOT_TOKEN` environment variable overwrites this value. +token: "" + # Bot Owners # User IDs of users who should be considered as the bot owners. # `TESSERACT_OWNER_ID` environment variable adds an additional owner to the list. @@ -5,11 +17,6 @@ owners: - "YOUR_USER_ID" - "ANOTHER_USER_ID" -# Add the token of the bot which should be running Bastion. -# https://discord.com/developers/applications -# `TESSERACT_BOT_TOKEN` environment variable overwrites this value. -token: "" - # MongoDB connection URI # `TESSERACT_MONGO_URI` environment variable overwrites this value. mongoURI: "mongodb://localhost:27017/bastion" diff --git a/src/commands/config/verification.ts b/src/commands/config/verification.ts index 4f077ccc7..d68c2cb6a 100644 --- a/src/commands/config/verification.ts +++ b/src/commands/config/verification.ts @@ -2,10 +2,11 @@ * @author TRACTION (iamtraction) * @copyright 2022 */ -import { ApplicationCommandOptionType, ChatInputCommandInteraction, PermissionFlagsBits } from "discord.js"; +import { ApplicationCommandOptionType, ButtonStyle, ChatInputCommandInteraction, ComponentType, PermissionFlagsBits } from "discord.js"; import { Command } from "@bastion/tesseract"; import GuildModel from "../../models/Guild"; +import MessageComponents from "../../utils/components"; class VerificationCommand extends Command { constructor() { @@ -18,6 +19,11 @@ class VerificationCommand extends Command { name: "role", description: "The role that should be assigned to verified users.", }, + { + type: ApplicationCommandOptionType.String, + name: "text", + description: "Type a text message that will be shown to the users trying to verify.", + }, ], userPermissions: [ PermissionFlagsBits.ManageGuild ], }); @@ -26,13 +32,37 @@ class VerificationCommand extends Command { public async exec(interaction: ChatInputCommandInteraction<"cached">): Promise { await interaction.deferReply(); const role = interaction.options.getRole("role"); + const text = interaction.options.getString("text"); + + const guildDocument = await GuildModel.findById(interaction.guildId); + + if (text) { + if (guildDocument.verifiedRole) { + return await interaction.editReply({ + content: text, + components: [ + { + type: ComponentType.ActionRow, + components: [ + { + type: ComponentType.Button, + label: "I am human", + style: ButtonStyle.Primary, + customId: MessageComponents.VerificationButton, + }, + ], + }, + ], + }); + } + + return await interaction.editReply("A role for verified users hasn't been set."); + } if (role?.id === interaction.guildId) { return await interaction.editReply("**@everyone** isn't a valid role for this."); } - const guildDocument = await GuildModel.findById(interaction.guildId); - // update verified role guildDocument.verifiedRole = role?.id || undefined; diff --git a/src/commands/iam.ts b/src/commands/iam.ts index d905a222a..0089e0379 100644 --- a/src/commands/iam.ts +++ b/src/commands/iam.ts @@ -65,7 +65,7 @@ class IamCommand extends Command { type: ComponentType.ActionRow, components: [ { - type: ComponentType.SelectMenu, + type: ComponentType.StringSelect, customId: MessageComponents.SelfRolesSelect, placeholder: "Select Roles", minValues: 0, diff --git a/src/commands/profile.ts b/src/commands/profile.ts index 3e06a34fe..4ca316ac2 100644 --- a/src/commands/profile.ts +++ b/src/commands/profile.ts @@ -38,7 +38,7 @@ class ProfileCommand extends Command { if (!member) return interaction.editReply(`${ user } is not a member of the server anymore.`); // get user's profile data - const memberProfile = await MemberModel.findOne({ user: member.id, guild: interaction.guild.id }); + const memberProfile = await MemberModel.findOne({ user: member.id, guild: interaction.guildId }); // check whether user profile exists if (!memberProfile) return interaction.editReply({ @@ -125,6 +125,11 @@ class ProfileCommand extends Command { value: (memberProfile.balance || 0).toLocaleString(), inline: true, }, + { + name: "Infractions", + value: `${ (memberProfile.infractions?.length || 0).toLocaleString() } warnings`, + inline: true, + }, { name: `Progress — ${ totalRequiredXP.currentLevel } / ${ totalRequiredXP.nextLevel } — ${ Math.round(currentProgress) }%`, value: `\`${ progress(currentProgress, 35) }\``, diff --git a/src/commands/say.ts b/src/commands/say.ts index f73cea886..04eac5d23 100644 --- a/src/commands/say.ts +++ b/src/commands/say.ts @@ -5,6 +5,8 @@ import { ApplicationCommandOptionType, ChatInputCommandInteraction } from "discord.js"; import { Command } from "@bastion/tesseract"; +import { generate as generateEmbed } from "../utils/embeds"; + class SayCommand extends Command { constructor() { super({ @@ -21,8 +23,15 @@ class SayCommand extends Command { }); } - public async exec(interaction: ChatInputCommandInteraction<"cached">): Promise { - await interaction.reply(interaction.options.getString("message")); + public async exec(interaction: ChatInputCommandInteraction<"cached">): Promise { + const message = generateEmbed(interaction.options.getString("message")); + + if (typeof message === "string") { + return await interaction.reply(message); + } + return await interaction.reply({ + embeds: [ message ], + }); } } diff --git a/src/commands/user/infractions.ts b/src/commands/user/infractions.ts new file mode 100644 index 000000000..f69af6e1f --- /dev/null +++ b/src/commands/user/infractions.ts @@ -0,0 +1,104 @@ +/*! + * @author TRACTION (iamtraction) + * @copyright 2022 + */ +import { ApplicationCommandOptionType, ChatInputCommandInteraction, PermissionFlagsBits } from "discord.js"; +import { Command, Logger } from "@bastion/tesseract"; + +import GuildModel from "../../models/Guild"; +import MemberModel from "../../models/Member"; +import { COLORS } from "../../utils/constants"; + +class UserInfractionsCommand extends Command { + constructor() { + super({ + name: "infractions", + description: "Configure infraction actions and displays infractions of the specified user.", + options: [ + { + type: ApplicationCommandOptionType.Integer, + name: "timeout", + description: "Number of violations after which the user is timed out.", + min_value: 1, + }, + { + type: ApplicationCommandOptionType.Integer, + name: "kick", + description: "Number of violations after which the user is kicked.", + min_value: 1, + }, + { + type: ApplicationCommandOptionType.Integer, + name: "ban", + description: "Number of violations after which the user is banned.", + min_value: 1, + }, + { + type: ApplicationCommandOptionType.User, + name: "user", + description: "The user whose infractions you want to display.", + }, + ], + userPermissions: [ PermissionFlagsBits.ModerateMembers ], + }); + } + + public async exec(interaction: ChatInputCommandInteraction<"cached">): Promise { + await interaction.deferReply(); + const timeoutThreshold = interaction.options.getInteger("timeout"); + const kickThreshold = interaction.options.getInteger("kick"); + const banThreshold = interaction.options.getInteger("ban"); + const user = interaction.options.getUser("user"); + + if (user) { + // get member + const member = user ? await interaction.guild.members.fetch(user).catch(Logger.ignore) : undefined; + const memberDocument = await MemberModel.findOne({ user: user.id, guild: interaction.guildId }); + + if (memberDocument?.infractions?.length) { + return await interaction.editReply({ + embeds: [ + { + color: COLORS.PRIMARY, + author: { + name: user.tag + (member && member.nickname ? " / " + member.nickname : ""), + }, + title: "Infractions", + fields: memberDocument.infractions.map((infraction, i) => ({ + name: `#${ i + 1 }`, + value: infraction, + })), + footer: { + text: user.id, + }, + }, + ], + }); + } + + return await interaction.editReply({ + content: `${ user } has no active infractions.`, + allowedMentions: { + users: [], + }, + }); + } + + // get guild document + const guildDocument = await GuildModel.findById(interaction.guildId); + + if (interaction.channel.permissionsFor(interaction.member)?.has(PermissionFlagsBits.ManageGuild)) { + guildDocument.infractionsTimeoutThreshold = timeoutThreshold; + guildDocument.infractionsKickThreshold = kickThreshold; + guildDocument.infractionsBanThreshold = banThreshold; + + await guildDocument.save(); + + return await interaction.editReply(`**Timeout**, **Kick** and **Ban** thresholds have been set to **${ timeoutThreshold || 0 }**, **${ kickThreshold || 0 }** and **${ banThreshold || 0 }** warnings, respectively.`); + } + + return await interaction.editReply(`**Timeout**, **Kick** and **Ban** thresholds are set to **${ guildDocument.infractionsTimeoutThreshold || 0 }**, **${ guildDocument.infractionsKickThreshold || 0 }** and **${ guildDocument.infractionsBanThreshold || 0 }** warnings, respectively.`); + } +} + +export = UserInfractionsCommand; diff --git a/src/components/VerificationButton.ts b/src/components/VerificationButton.ts new file mode 100644 index 000000000..071f7a7ab --- /dev/null +++ b/src/components/VerificationButton.ts @@ -0,0 +1,41 @@ +/*! + * @author TRACTION (iamtraction) + * @copyright 2022 + */ +import { ButtonInteraction, ComponentType, TextInputStyle } from "discord.js"; +import { MessageComponent } from "@bastion/tesseract"; + +import MessageComponents from "../utils/components"; + +class VerificationButton extends MessageComponent { + constructor() { + super({ + id: MessageComponents.VerificationButton, + scope: "guild", + }); + } + + public async exec(interaction: ButtonInteraction<"cached">): Promise { + return interaction.showModal({ + custom_id: MessageComponents.VerificationModal, + title: "Are you a human?", + components: [ + { + type: ComponentType.ActionRow, + components: [ + { + custom_id: MessageComponents.VerificationTextInput, + type: ComponentType.TextInput, + label: "Type \"i am human\" to verify yourself.", + placeholder: "i am human", + required: true, + style: TextInputStyle.Short, + }, + ], + }, + ], + }); + } +} + +export = VerificationButton; diff --git a/src/components/VerificationModal.ts b/src/components/VerificationModal.ts new file mode 100644 index 000000000..27cce1c30 --- /dev/null +++ b/src/components/VerificationModal.ts @@ -0,0 +1,34 @@ +/*! + * @author TRACTION (iamtraction) + * @copyright 2022 + */ +import { ModalSubmitInteraction } from "discord.js"; +import { Logger, MessageComponent } from "@bastion/tesseract"; + +import GuildModel from "../models/Guild"; +import MessageComponents from "../utils/components"; + +class VerificationModal extends MessageComponent { + constructor() { + super({ + id: MessageComponents.VerificationModal, + scope: "guild", + }); + } + + public async exec(interaction: ModalSubmitInteraction<"cached">): Promise { + const inputValue = interaction.fields.getTextInputValue(MessageComponents.VerificationTextInput)?.toLowerCase(); + + if ([ "i'm human", "i am human" ].includes(inputValue)) { + await interaction.deferUpdate(); + + const guildDocument = await GuildModel.findById(interaction.guildId); + + if (guildDocument.verifiedRole) { + await interaction.member.roles.add(guildDocument.verifiedRole, "Verified").catch(Logger.ignore); + } + } + } +} + +export = VerificationModal; diff --git a/src/listeners/guildMemberAdd.ts b/src/listeners/guildMemberAdd.ts index 5ac3ca6e8..b51ff0c48 100644 --- a/src/listeners/guildMemberAdd.ts +++ b/src/listeners/guildMemberAdd.ts @@ -2,12 +2,13 @@ * @author TRACTION (iamtraction) * @copyright 2022 */ -import { GuildMember, time } from "discord.js"; +import { APIEmbed, GuildMember, time } from "discord.js"; import { Listener, Logger } from "@bastion/tesseract"; import GuildModel from "../models/Guild"; import RoleModel from "../models/Role"; import { COLORS } from "../utils/constants"; +import { generate as generateEmbed } from "../utils/embeds"; import { logGuildEvent } from "../utils/guilds"; import * as variables from "../utils/variables"; import * as yaml from "../utils/yaml"; @@ -42,12 +43,16 @@ class GuildMemberAddListener extends Listener<"guildMemberAdd"> { // check whether the channel is valid if (!greetingChannel?.isTextBased()) return; + const greetingMessage = generateEmbed(variables.replace(guildDocument.greetingMessage || this.greetings[Math.floor(Math.random() * this.greetings.length)], member), true) as APIEmbed; + greetingChannel.send({ embeds: [ { + ...greetingMessage, color: COLORS.SECONDARY, - title: "Welcome!", - description: variables.replace(guildDocument.greetingMessage || this.greetings[Math.floor(Math.random() * this.greetings.length)], member), + footer: { + text: "Greetings!" + }, }, ], }).then(m => { diff --git a/src/listeners/guildMemberRemove.ts b/src/listeners/guildMemberRemove.ts index 96dee2151..c96c817f1 100644 --- a/src/listeners/guildMemberRemove.ts +++ b/src/listeners/guildMemberRemove.ts @@ -2,11 +2,12 @@ * @author TRACTION (iamtraction) * @copyright 2022 */ -import { GuildMember, PartialGuildMember, time } from "discord.js"; +import { APIEmbed, GuildMember, PartialGuildMember, time } from "discord.js"; import { Listener, Logger } from "@bastion/tesseract"; import GuildModel from "../models/Guild"; import { COLORS } from "../utils/constants"; +import { generate as generateEmbed } from "../utils/embeds"; import { logGuildEvent } from "../utils/guilds"; import * as variables from "../utils/variables"; import * as yaml from "../utils/yaml"; @@ -29,12 +30,16 @@ class GuildMemberRemoveListener extends Listener<"guildMemberRemove"> { // check whether the channel is valid if (!farewellChannel?.isTextBased()) return; + const farewellMessage = generateEmbed(variables.replace(guildDocument.farewellMessage || this.farewells[Math.floor(Math.random() * this.farewells.length)], member), true) as APIEmbed; + farewellChannel.send({ embeds: [ { + ...farewellMessage, color: COLORS.SECONDARY, - title: "Farewell!", - description: variables.replace(guildDocument.farewellMessage || this.farewells[Math.floor(Math.random() * this.farewells.length)], member), + footer: { + text: "Farewell!" + }, }, ], }).then(m => { diff --git a/src/listeners/messageCreate.ts b/src/listeners/messageCreate.ts index b11c3463b..effb9051c 100644 --- a/src/listeners/messageCreate.ts +++ b/src/listeners/messageCreate.ts @@ -12,12 +12,13 @@ import GuildModel, { Guild as GuildDocument } from "../models/Guild"; import MemberModel from "../models/Member"; import RoleModel from "../models/Role"; import TriggerModel from "../models/Trigger"; +import { COLORS } from "../utils/constants"; +import { generate as generateEmbed } from "../utils/embeds"; import * as gamification from "../utils/gamification"; import * as members from "../utils/members"; import * as numbers from "../utils/numbers"; -import { bastion } from "../types"; -import { COLORS } from "../utils/constants"; import * as variables from "../utils/variables"; +import { bastion } from "../types"; class MessageCreateListener extends Listener<"messageCreate"> { public activeUsers: Map; @@ -112,7 +113,7 @@ class MessageCreateListener extends Listener<"messageCreate"> { }, 13e3).unref(); }; - handleTriggers = async (message: Message): Promise => { + handleTriggers = async (message: Message): Promise => { const triggers = await TriggerModel.find({ guild: message.guild.id }); // responses @@ -134,7 +135,14 @@ class MessageCreateListener extends Listener<"messageCreate"> { // response message if (responseMessages.length) { - message.reply(variables.replace(responseMessages[Math.floor(Math.random() * responseMessages.length)], message)) + const responseMessage = generateEmbed(variables.replace(responseMessages[Math.floor(Math.random() * responseMessages.length)], message)); + if (typeof responseMessage === "string") { + return message.reply(responseMessage) + .catch(Logger.error); + } + return message.reply({ + embeds: [ responseMessage ], + }) .catch(Logger.error); } diff --git a/src/utils/components.ts b/src/utils/components.ts index f83f5a374..15740e817 100644 --- a/src/utils/components.ts +++ b/src/utils/components.ts @@ -20,6 +20,10 @@ enum MessageComponents { VoiceSessionLockButton = "VoiceSessionLockButton", VoiceSessionUnlockButton = "VoiceSessionUnlockButton", VoiceSessionEndButton = "VoiceSessionEndButton", + + VerificationButton = "VerificationButton", + VerificationModal = "VerificationModal", + VerificationTextInput = "VerificationTextInput", } export default MessageComponents; diff --git a/src/utils/embeds.ts b/src/utils/embeds.ts new file mode 100644 index 000000000..0cea0a153 --- /dev/null +++ b/src/utils/embeds.ts @@ -0,0 +1,90 @@ +/*! + * @author TRACTION (iamtraction) + * @copyright 2022 + */ +import { APIEmbed } from "discord.js"; + +import { COLORS } from "./constants"; + +export const generate = (embed: string | APIEmbed, force?: boolean): string | APIEmbed => { + // check whether this is a valid embed string + if (typeof embed === "string") { + try { + const newEmbed = JSON.parse(embed); + if (isValid(newEmbed)) { + embed = newEmbed; + } + } catch { + // this error can be ignored + } + } + + if (typeof embed === "string") { + if (force) { + return { + color: COLORS.PRIMARY, + description: embed, + }; + } + return embed; + } + + return { + ...embed, + color: embed.color || COLORS.PRIMARY, + }; +}; + +export const isValid = (embed: APIEmbed): boolean => { + // check whether it's an embed object + if (embed?.constructor !== ({}).constructor) return false; + + // check whether the author is valid + if ("author" in embed) { + if (typeof embed.author?.name !== "string") return false; + if (typeof embed.author?.url !== "string") return false; + if (typeof embed.author?.icon_url !== "string") return false; + } + + // check whether the color is valid + if ("color" in embed && typeof embed.color !== "number") return false; + + // check whether the description is valid + if ("description" in embed && typeof embed.description !== "string") return false; + + // check whether the fields are valid + if ("fields" in embed) { + if (!(embed.fields instanceof Array)) return; + for (const field of embed.fields) { + if (field?.constructor !== ({}).constructor) return false; + if (!("name" in field)) return false; + if (!("value" in field)) return false; + if (typeof field.inline !== "boolean") return false; + } + } + + // check whether the footer is valid + if ("footer" in embed) { + if (embed.footer.constructor !== ({}).constructor) return false; + if (typeof embed.footer?.icon_url !== "string") return false; + if (typeof embed.footer?.text !== "string") return false; + } + + // check whether the image is valid + if ("image" in embed && typeof embed.image?.url !== "string") return false; + + // check whether the thumbnail is valid + if ("thumbnail" in embed && typeof embed.thumbnail?.url !== "string") return false; + + // check whether the timestamp is valid + if ("timestamp" in embed && typeof embed.timestamp !== "string") return false; + + // check whether the title is valid + if ("title" in embed && typeof embed.title !== "string") return false; + + // check whether the url is valid + if ("url" in embed && typeof embed.url !== "string") return false; + + // well, it's a valid embed! + return true; +}; diff --git a/src/utils/variables.ts b/src/utils/variables.ts index 6162121e9..bc5f05790 100644 --- a/src/utils/variables.ts +++ b/src/utils/variables.ts @@ -22,9 +22,9 @@ export const replace = (string: string, context: PartialGuildMember | GuildMembe "{server.members.size}": context?.guild.memberCount, "{server.users.size}": context?.guild.members.cache.filter(context => context?.user.bot === false).size, "{server.bots.size}": context?.guild.members.cache.filter(context => context?.user.bot === true).size, - "{author}": "<@" + context?.id + ">", - "{author.id}": context?.id, - "{author.tag}": context instanceof Message ? context?.author.id : context?.user.tag, + "{author}": "<@" + (context instanceof Message ? context?.author.id : context?.id) + ">", + "{author.id}": context instanceof Message ? context?.author.id : context?.id, + "{author.tag}": context instanceof Message ? context?.author.tag : context?.user.tag, "{author.name}": context instanceof Message ? context?.author.username : context?.user.username, "{author.nick}": context instanceof Message ? context?.member.displayName : context?.displayName, "{author.avatar}": context instanceof Message ? context?.author.displayAvatarURL() : context?.user.displayAvatarURL(),