feat(examples): Migrate example bots to discordeno v19 (#3647)

* Migrate beginner and minimal bot to discordeno v19

* Add .swcrc and fix minimal yarn.lock

* update .gitignore files

* Update nodejs template

Discordeno.js (DD v13) -> DD v19 "raw"

Currently the permission checking is not working correctly

* Fix permission issue

* Rename the templates

* remove unused indents

* Rename starter to beginner

So now it is minimal (main branch) -> beginner

* Really small refactor & eslint fixes (bigbot template)

This is to make my life less miserable at a later time

* mark rabbitMQ plugins as binary files

git seems to be treating them as text

* Add v19 bigbot rest

* Add gateway code

and rabbitmq_message_deduplication v0.6.2 plugin

* fix yarn messy semevr version for @types/amqplib

* clear channel con amqp connection close

* Add bot code for bigbot v19

missing prisma setup, collector setup & language setup

* Add localization

The "command versioning" system works the same say as before, but instead of a command version the code updates the commands every time they change based on the SHA1 of the commands

* Use file relative paths instead of cwd relative paths

* Fix todos

* revert autocomplete tests

* Revert "Add localization"

This reverts commit 2b1da8d2cd.

* move env assertion to config.ts

* Add shard ping to /ping

* fix small issue

* Update readme files

* use Date.now() for the bigbot REST ping

* Remove bigbot v16 code

* Add docker (compose) setup to bigbot template

* remove healthchecks from rest & gateway

* Update dependencies of examples

* Apply readme(s) suggestions from code review

Hopefully i haven't missed any related to markdown files

Co-authored-by: LTS20050703 <lts20050703@gmail.com>

* Apply code suggestions from code review

Co-authored-by: LTS20050703 <lts20050703@gmail.com>

---------

Co-authored-by: LTS20050703 <lts20050703@gmail.com>
This commit is contained in:
Fleny
2024-07-07 17:40:43 +02:00
committed by GitHub
parent aecc55d150
commit 769d50ff93
168 changed files with 11847 additions and 3855 deletions

View File

@@ -1,13 +0,0 @@
FROM mcr.microsoft.com/vscode/devcontainers/base:0-buster
ENV DENO_INSTALL=/deno
RUN mkdir -p /deno \
&& curl -fsSL https://deno.land/x/install/install.sh | sh \
&& chown -R vscode /deno
ENV PATH=${DENO_INSTALL}/bin:${PATH} \
DENO_DIR=${DENO_INSTALL}/.cache/deno
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>

View File

@@ -1,51 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.162.0/containers/deno
{
"name": "Deno",
"dockerFile": "Dockerfile",
// Set *default* container specific settings.json values on container create.
"settings": {
"terminal.integrated.shell.linux": "/bin/bash",
"deno.enable": true,
"editor.defaultFormatter": "denoland.vscode-deno",
"editor.minimap.enabled": false,
"editor.wordWrap": "on",
"editor.codeActionsOnSave": {
"source.organizeImports": true,
"source.fixAll": true
},
"editor.fontSize": 16,
"workbench.colorTheme": "Material Theme Darker",
"workbench.iconTheme": "eq-material-theme-icons-darker",
"breadcrumbs.enabled": true,
"editor.renderWhitespace": "all",
"editor.suggestSelection": "first",
"editor.formatOnSave": true,
"files.autoSave": "afterDelay",
"editor.fontFamily": "Fira Code, Menlo, Monaco, 'Courier New', monospace",
"typescript.updateImportsOnFileMove.enabled": "always",
"javascript.updateImportsOnFileMove.enabled": "always",
"deno.inlayHints.enumMemberValues.enabled": true,
"deno.inlayHints.functionLikeReturnTypes.enabled": true,
"deno.inlayHints.parameterNames.enabled": "all",
"deno.inlayHints.parameterNames.suppressWhenArgumentMatchesName": false,
"deno.inlayHints.parameterTypes.enabled": true,
"deno.inlayHints.propertyDeclarationTypes.enabled": true,
"deno.inlayHints.variableTypes.enabled": true,
"deno.inlayHints.variableTypes.suppressWhenTypeMatchesName": false
},
// Add the Ids of extensions you want installed when the container is created.
"extensions": [
"denoland.vscode-deno",
"pkief.material-icon-theme",
"coenraads.bracket-pair-colorizer",
"equinusocio.vsc-material-theme",
"tabnine.tabnine-vscode"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Uncomment to use the Docker CLI from inside the container. See https://aka.ms/vscode-remote/samples/docker-from-docker.
// "mounts": [ "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" ],
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode"
}

9
examples/.gitignore vendored
View File

@@ -1,9 +0,0 @@
.env
fileloader.ts
config.json
# Folder made by Kwik db to watch guild command versions
db/
node_modules
package-lock.json

View File

@@ -1,24 +0,0 @@
{
"deno.enable": true,
"deno.lint": true,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": true,
"source.fixAll": true
},
"editor.defaultFormatter": "denoland.vscode-deno",
"deno.suggest.imports.hosts": {
"https://deno.land": true
},
"discord.enabled": true,
"cSpell.words": ["denoland", "Discordeno", "Kwik", "Loglevels", "msgpack", "Slowmode", "upsert"],
"deno.unstable": true,
"deno.inlayHints.enumMemberValues.enabled": true,
"deno.inlayHints.functionLikeReturnTypes.enabled": true,
"deno.inlayHints.parameterNames.enabled": "all",
"deno.inlayHints.parameterNames.suppressWhenArgumentMatchesName": false,
"deno.inlayHints.parameterTypes.enabled": true,
"deno.inlayHints.propertyDeclarationTypes.enabled": true,
"deno.inlayHints.variableTypes.enabled": true,
"deno.inlayHints.variableTypes.suppressWhenTypeMatchesName": false
}

View File

@@ -1,10 +1,27 @@
# Discordeno Bot Template
# Discordeno Bot Templates
In this directory you will find some example bots written using Discordeno.
In each template directory you will find more information on how to setup and run the template.
## Minimal & Beginner
A very minimal bot with only a /ping command to show how to set-up a discordeno bot for interactions
The beginner template is a bit more complete and has a caching system already setup using the `dd-cache-proxy` library
## Advanced
A more complex bot compared to beginner. It also has a /ping command, but also a /warn command showing how to deal with permissions and sending DMs
This template has a caching system already setup using the `dd-cache-proxy` library
## BigBot
![Log Image](https://i.imgur.com/09skKfz.png)
This repo is meant as a template which you can use to create a Discord bot very easily using the
[Discordeno library](https://github.com/discordeno/discordeno).
The BigBot template is intended for more complex systems that need scaling.
[Website/Guide](https://discordeno.js.org/)
The template consists of 3 folders with some common files. The template is configured with a REST proxy, the Gateway in a separate process and the Bot code in another.
[Discord Server](https://discord.com/invite/5vBgXk3UcZ)
While the template does not include any caching by default, you can either install `dd-cache-proxy` and setup it or roll your own solution

View File

@@ -0,0 +1 @@
TOKEN=

32
examples/advanced/.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# build
dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

24
examples/advanced/.swcrc Normal file
View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"target": "es2022",
"keepClassNames": true,
"loose": true
},
"module": {
"type": "es6",
"strict": false,
"strictMode": true,
"lazy": false,
"noInterop": false
}
}

View File

@@ -0,0 +1,20 @@
# Advanced Bot Template
This template contains more advanced code.
This template includes caching (using `dd-proxy-cache`), user permission handling, and slash commands options support
This template also includes a /ping command to show the bot latency and a /warn command to show how to send a DM to a user and how to check for permissions
## Setup
- Download the source
- Install the dependencies using `yarn`
- Copy the .env.example file and rename it to .env
- Fill out the .env file
## Run Bot
- run `yarn` to install the dependencies
- run `yarn build` to build the source
- run `node dist/register-commands.js` to register the slash commands
- run `yarn start` to run the bot

View File

@@ -0,0 +1,26 @@
{
"name": "dd-advanced-bot",
"version": "1.0.0",
"description": "A bit more advanced bot that has some permission handling and slash command options.",
"main": "dist/index.js",
"type": "module",
"license": "ISC",
"private": true,
"packageManager": "yarn@4.0.2",
"scripts": {
"start": "node dist/index.js",
"build": "swc src --strip-leading-paths --delete-dir-on-start --out-dir dist",
"setup-dd": ""
},
"dependencies": {
"@discordeno/bot": "19.0.0-next.92bf166",
"dd-cache-proxy": "^2.1.1",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@swc/cli": "^0.3.12",
"@swc/core": "^1.6.3",
"@types/node": "^20.14.6",
"typescript": "^5.5.2"
}
}

View File

@@ -0,0 +1,49 @@
import { createBot, Intents, LogDepth, type logger as discordenoLogger } from '@discordeno/bot'
import { createProxyCache } from 'dd-cache-proxy'
import { configs } from './config.js'
export const bot = createProxyCache(
createBot({
token: configs.token,
intents: Intents.Guilds,
}),
{
desiredProps: {
guilds: ['id', 'name', 'roles'],
roles: ['id', 'guildId', 'permissions'],
},
cacheInMemory: {
guilds: true,
roles: true,
default: false,
},
},
)
// By default, bot.logger will use an instance of the logger from @discordeno/bot, this logger supports depth and we need to change it, so we need to say to TS that we know what we are doing with as
;(bot.logger as typeof discordenoLogger).setDepth(LogDepth.Full)
// Setup desired proprieties
bot.transformers.desiredProperties.interaction.id = true
bot.transformers.desiredProperties.interaction.type = true
bot.transformers.desiredProperties.interaction.data = true
bot.transformers.desiredProperties.interaction.token = true
bot.transformers.desiredProperties.interaction.guildId = true
bot.transformers.desiredProperties.interaction.member = true
bot.transformers.desiredProperties.guild.id = true
bot.transformers.desiredProperties.guild.name = true
bot.transformers.desiredProperties.guild.roles = true
bot.transformers.desiredProperties.role.id = true
bot.transformers.desiredProperties.role.guildId = true
bot.transformers.desiredProperties.role.permissions = true
bot.transformers.desiredProperties.member.id = true
bot.transformers.desiredProperties.member.roles = true
bot.transformers.desiredProperties.channel.id = true
bot.transformers.desiredProperties.user.id = true
bot.transformers.desiredProperties.user.username = true
bot.transformers.desiredProperties.user.discriminator = true

View File

@@ -0,0 +1,20 @@
import { Collection, type ApplicationCommandOption, type ApplicationCommandTypes, type Interaction } from '@discordeno/bot'
export const commands = new Collection<string, Command>()
export function createCommand(command: Command): void {
commands.set(command.name, command)
}
export interface Command {
/** The name of this command. */
name: string
/** What does this command do? */
description: string
/** The type of command this is. */
type: ApplicationCommandTypes
/** The options for this command */
options?: ApplicationCommandOption[]
/** This will be executed when the command is run. */
execute: (interaction: Interaction, options: Record<string, unknown>) => unknown
}

View File

@@ -0,0 +1,15 @@
import { ApplicationCommandTypes, createEmbeds, snowflakeToTimestamp } from '@discordeno/bot'
import { createCommand } from '../commands.js'
createCommand({
name: 'ping',
description: 'See if the bot latency is okay',
type: ApplicationCommandTypes.ChatInput,
async execute(interaction) {
const ping = Date.now() - snowflakeToTimestamp(interaction.id)
const embeds = createEmbeds().setTitle(`The bot ping is ${ping}ms`)
await interaction.respond({ embeds })
},
})

View File

@@ -0,0 +1,80 @@
import { ApplicationCommandOptionTypes, ApplicationCommandTypes, createEmbeds, Permissions, type Member, type User } from '@discordeno/bot'
import { bot } from '../bot.js'
import { createCommand } from '../commands.js'
import { calculateMemberPermissions } from '../utils/permissions.js'
createCommand({
name: 'warn',
description: 'Warn a user from the server',
type: ApplicationCommandTypes.ChatInput,
options: [
{
name: 'user',
description: 'The user you want to warn',
type: ApplicationCommandOptionTypes.User,
required: true,
},
{
name: 'reason',
description: 'The reason for the warn',
type: ApplicationCommandOptionTypes.String,
maxLength: 300,
},
],
async execute(interaction, options) {
if (!interaction.guildId || !interaction.member) {
await interaction.respond('This command can only be ran in guilds')
return
}
// Type based on the options declared above
const { user, reason } = options as { user: UserResolved; reason?: string }
const guild = await bot.cache.guilds.get(interaction.guildId)
if (!guild) {
await interaction.respond('An error has occurred')
return
}
await interaction.defer()
const perms = new Permissions(await calculateMemberPermissions(guild, interaction.member))
const adminPerm = perms.has('ADMINISTRATOR')
const kickMembersPerm = adminPerm || perms.has('KICK_MEMBERS')
if (!kickMembersPerm) {
await interaction.respond("You don't have the necessary permissions to warn a members (this command requires `Kick members`)")
return
}
const embeds = createEmbeds()
.setTitle('Warned User:')
.setDescription(`User: <@${user.user.id}>\nReason: ${reason}`)
.setColor(0x00ff00)
.setTimestamp(Date.now())
const warnEmbeds = createEmbeds()
.setTitle('Warning:')
.setDescription(`You have been warned in **${guild.name}** for \`${reason}\``)
.setTimestamp(Date.now())
try {
const dmChannel = await bot.helpers.getDmChannel(user.user.id)
await bot.helpers.sendMessage(dmChannel.id, { embeds: warnEmbeds })
} catch (error) {
bot.logger.error(`There was an error in the warn command:`, error)
await interaction.respond(`Could not warn user <@${user.user.id}> | They likely do not have their DMs open.`)
return
}
await interaction.respond({ embeds })
},
})
interface UserResolved {
user: User
member?: Member
}

View File

@@ -0,0 +1,11 @@
const token = process.env.TOKEN
if (!token) throw new Error('Missing TOKEN environment variable')
export const configs: Config = {
token,
}
export interface Config {
token: string
}

View File

@@ -0,0 +1,22 @@
import { InteractionTypes, commandOptionsParser } from '@discordeno/bot'
import { bot } from '../bot.js'
import { commands } from '../commands.js'
bot.events.interactionCreate = async (interaction) => {
if (!interaction.data || interaction.type !== InteractionTypes.ApplicationCommand) return
const command = commands.get(interaction.data.name)
if (!command) {
bot.logger.error(`Command ${interaction.data.name} not found`)
return
}
const options = commandOptionsParser(interaction)
try {
await command.execute(interaction, options)
} catch (error) {
bot.logger.error(`There was an error running the ${command.name} command.`, error)
}
}

View File

@@ -0,0 +1,8 @@
import { bot } from '../bot.js'
bot.events.ready = ({ user, shardId }) => {
if (shardId === bot.gateway.lastShardId) {
// All shards are ready
bot.logger.info(`Successfully connected to the gateway as ${user.username}#${user.discriminator}`)
}
}

View File

@@ -0,0 +1,14 @@
import 'dotenv/config'
import { bot } from './bot.js'
import importDirectory from './utils/loader.js'
bot.logger.info('Starting bot...')
bot.logger.info('Loading commands...')
await importDirectory('./dist/commands')
bot.logger.info('Loading events...')
await importDirectory('./dist/events')
await bot.start()

View File

@@ -0,0 +1,7 @@
import 'dotenv/config'
import { bot } from './bot.js'
import { updateApplicationCommands } from './utils/updateCommands.js'
bot.logger.info('Updating commands...')
await updateApplicationCommands()

View File

@@ -0,0 +1,15 @@
import { logger } from '@discordeno/bot'
import { readdir } from 'node:fs/promises'
export default async function importDirectory(folder: string): Promise<void> {
const files = await readdir(folder, { recursive: true })
for (const filename of files) {
if (!filename.endsWith('.js')) continue
// Using `file://` and `process.cwd()` to avoid weird issues with relative paths and/or Windows
await import(`file://${process.cwd()}/${folder}/${filename}`).catch((x) =>
logger.fatal(`Cannot import file (${folder}/${filename}) for reason:`, x),
)
}
}

View File

@@ -0,0 +1,20 @@
import { BitwisePermissionFlags, type Guild, type Member } from '@discordeno/bot'
import assert from 'node:assert'
export async function calculateMemberPermissions(guild: Guild, member: Member): Promise<bigint> {
if (member.id === guild.ownerId) return 8n
let permissions = guild.roles.get(guild.id)?.permissions.bitfield
const rolePerms = member.roles.map((x) => guild.roles.get(x)?.permissions.bitfield).filter((x): x is bigint => x !== undefined)
// Small hack to avoid calling assert with 0n
if (permissions === undefined) assert(permissions)
for (const rolePerm of rolePerms) {
permissions |= rolePerm
}
if ((permissions & BigInt(BitwisePermissionFlags.ADMINISTRATOR)) === BigInt(BitwisePermissionFlags.ADMINISTRATOR)) return 8n
return permissions
}

View File

@@ -0,0 +1,6 @@
import { bot } from '../bot.js'
import { commands } from '../commands.js'
export async function updateApplicationCommands(): Promise<void> {
await bot.helpers.upsertGlobalApplicationCommands(commands.array())
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"moduleResolution": "node",
"skipDefaultLibCheck": true,
"skipLibCheck": true,
"strict": true,
"incremental": true
}
}

2124
examples/advanced/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1 @@
BOT_TOKEN=''
DEV_GUILD_ID=''

32
examples/beginner/.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# build
dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

24
examples/beginner/.swcrc Normal file
View File

@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"target": "es2022",
"keepClassNames": true,
"loose": true
},
"module": {
"type": "es6",
"strict": false,
"strictMode": true,
"lazy": false,
"noInterop": false
}
}

View File

@@ -1,18 +1,20 @@
# Beginner Bot Template
This template is designed for the beginner developer to start coding discord bots.
This template is designed for beginners to start coding discord bots.
Make sure to install the latest version when you use it.
This template includes caching (using `dd-cache-proxy`) and support for slash subcommands.
This template also includes a /ping command to show the bot latency
## Setup
- [Click here](https://github.com/discordeno/template/generate) to make your own copy.
- Delete all the template folders except the beginner folder.
- Move all files from this folder to the root of the project.
- You may encounter an issue with README.md file but force move the files to the root of the project.
- Rename the .env.example file to .env OR create a new .env file and copy the example file code to this new file.
- Download the source
- Install the dependencies using `yarn`
- Copy the .env.example file and rename it to .env
- Fill out the .env file
## Run Bot
- deno run -A mod.ts
- run `yarn` to install the dependencies
- run `yarn build` to build the source
- run `node dist/register-commands.js` to register the slash commands
- run `yarn start` to run the bot

View File

@@ -1,35 +0,0 @@
import { configs } from './configs.ts.js'
import type { BotWithCache, BotWithHelpersPlugin } from './deps.ts.js'
import {
Collection,
createBot,
enableCachePlugin,
enableCacheSweepers,
enableHelpersPlugin,
enablePermissionsPlugin,
GatewayIntents,
} from './deps.ts.js'
import type { Command } from './src/types/commands.ts.js'
// MAKE THE BASIC BOT OBJECT
const bot = createBot({
token: configs.token,
botId: configs.botId,
intents: GatewayIntents.Guilds,
events: {},
})
// ENABLE ALL THE PLUGINS THAT WILL HELP MAKE IT EASIER TO CODE YOUR BOT
enableHelpersPlugin(bot)
enableCachePlugin(bot)
enableCacheSweepers(bot as BotWithCache)
enablePermissionsPlugin(bot as BotWithCache)
export interface BotClient extends BotWithCache<BotWithHelpersPlugin> {
commands: Collection<string, Command>
}
// THIS IS THE BOT YOU WANT TO USE EVERYWHERE IN YOUR CODE! IT HAS EVERYTHING BUILT INTO IT!
export const Bot = bot as BotClient
// PREPARE COMMANDS HOLDER
Bot.commands = new Collection()

View File

@@ -1,19 +0,0 @@
import { dotEnvConfig } from './deps.ts.js'
// Get the .env file that the user should have created, and get the token
const env = dotEnvConfig({ export: true, path: './.env' })
const token = env.BOT_TOKEN || ''
export interface Config {
token: string
botId: bigint
}
export const configs = {
/** Get token from ENV variable */
token,
/** Get the BotId from the token */
botId: BigInt(atob(token.split('.')[0])),
/** The server id where you develop your bot and want dev commands created. */
devGuildId: BigInt(env.DEV_GUILD_ID!),
}

View File

@@ -1,9 +0,0 @@
export * from 'https://deno.land/x/discordeno@17.0.0/mod.ts'
export * from 'https://deno.land/x/discordeno@17.0.0/plugins/mod.ts'
// Terminal Colors!
export * from 'https://deno.land/std@0.117.0/fmt/colors.ts'
// Get data from .env files
export { config as dotEnvConfig } from 'https://deno.land/x/dotenv@v3.1.0/mod.ts'
// Database, thx Tri!
export { decode as KwikDecode, encode as KwikEncode, Kwik } from 'https://deno.land/x/kwik@v1.3.1/mod.ts'

View File

@@ -1,25 +0,0 @@
import { startBot } from './deps.ts.js'
import log from './src/utils/logger.ts.js'
import { fileLoader, importDirectory } from './src/utils/loader.ts.js'
import { updateApplicationCommands } from './src/utils/updateCommands.ts.js'
// setup db
import './src/database/mod.ts.js'
import { Bot } from './bot.ts.js'
log.info('Starting bot...')
// Forces deno to read all the files which will fill the commands/inhibitors cache etc.
await Promise.all(
[
'./src/commands',
'./src/events',
// "./src/tasks",
].map((path) => importDirectory(Deno.realPathSync(path))),
)
await fileLoader()
// UPDATES YOUR COMMANDS TO LATEST COMMANDS
await updateApplicationCommands()
// STARTS THE CONNECTION TO DISCORD
await startBot(Bot)

View File

@@ -0,0 +1,27 @@
{
"name": "dd-beginner-bot",
"version": "1.0.0",
"description": "An example bot for beginner developers to start coding discord bots.",
"main": "dist/index.js",
"type": "module",
"license": "ISC",
"private": true,
"packageManager": "yarn@4.0.2",
"scripts": {
"start": "node dist/index.js",
"build": "swc src --strip-leading-paths --delete-dir-on-start --out-dir dist",
"setup-dd": ""
},
"dependencies": {
"@discordeno/bot": "19.0.0-next.92bf166",
"chalk": "^5.3.0",
"dd-cache-proxy": "^2.1.1",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@swc/cli": "^0.3.12",
"@swc/core": "^1.6.3",
"@types/node": "^20.14.6",
"typescript": "^5.5.2"
}
}

View File

@@ -0,0 +1,32 @@
import { Intents, createBot } from '@discordeno/bot'
import { createProxyCache } from 'dd-cache-proxy'
import { configs } from './config.js'
export const bot = createProxyCache(
createBot({
token: configs.token,
intents: Intents.Guilds,
}),
{
desiredProps: {
guilds: ['id', 'name'],
},
cacheInMemory: {
guilds: true,
default: false,
},
},
)
// Setup desired proprieties
bot.transformers.desiredProperties.interaction.id = true
bot.transformers.desiredProperties.interaction.type = true
bot.transformers.desiredProperties.interaction.data = true
bot.transformers.desiredProperties.interaction.user = true
bot.transformers.desiredProperties.interaction.token = true
bot.transformers.desiredProperties.interaction.guildId = true
bot.transformers.desiredProperties.guild.id = true
bot.transformers.desiredProperties.guild.name = true
bot.transformers.desiredProperties.user.username = true

View File

@@ -0,0 +1,26 @@
import { Collection, type ApplicationCommandOption, type ApplicationCommandTypes, type Interaction } from '@discordeno/bot'
export const commands = new Collection<string, Command>()
export function createCommand(command: Command): void {
commands.set(command.name, command)
}
export interface Command {
name: string
description: string
usage?: string[]
options?: ApplicationCommandOption[]
type: ApplicationCommandTypes
/** Defaults to `Guild` */
scope?: 'Global' | 'Guild'
execute: (interaction: Interaction) => unknown
subcommands?: Array<SubCommandGroup | SubCommand>
}
export type SubCommand = Omit<Command, 'subcommands'>
export interface SubCommandGroup {
name: string
subCommands: SubCommand[]
}

View File

@@ -1,6 +0,0 @@
import { Bot } from '../../bot.ts.js'
import type { Command } from '../types/commands.ts.js'
export function createCommand(command: Command) {
Bot.commands.set(command.name, command)
}

View File

@@ -1,18 +1,15 @@
import { ApplicationCommandTypes, InteractionResponseTypes } from '../../deps.ts.js'
import { snowflakeToTimestamp } from '../utils/helpers.ts.js'
import { createCommand } from './mod.ts.js'
import { ApplicationCommandTypes, snowflakeToTimestamp } from '@discordeno/bot'
import { createCommand } from '../commands.js'
import { humanizeMilliseconds } from '../utils/helpers.js'
createCommand({
name: 'ping',
description: 'Ping the Bot!',
type: ApplicationCommandTypes.ChatInput,
execute: async (Bot, interaction) => {
scope: 'Global',
async execute(interaction) {
const ping = Date.now() - snowflakeToTimestamp(interaction.id)
await Bot.helpers.sendInteractionResponse(interaction.id, interaction.token, {
type: InteractionResponseTypes.ChannelMessageWithSource,
data: {
content: `🏓 Pong! ${ping}ms`,
},
})
await interaction.respond(`🏓 Pong! Ping ${ping}ms (${humanizeMilliseconds(ping)})`)
},
})

View File

@@ -0,0 +1,12 @@
const token = process.env.BOT_TOKEN
if (!token) throw new Error('Missing BOT_TOKEN environment variable')
export const configs: Config = {
/** Get token from ENV variable */
token,
}
export interface Config {
token: string
}

View File

@@ -1,32 +0,0 @@
import { Kwik, KwikDecode, KwikEncode } from '../../deps.ts.js'
import { logger } from '../utils/logger.ts.js'
const log = logger({ name: 'DB Manager' })
log.info('Initializing Database')
const kwik = new Kwik()
// Add BigInt Support
kwik.msgpackExtensionCodec.register({
type: 0,
encode: (object: unknown): Uint8Array | null => {
if (typeof object === 'bigint') {
if (object <= Number.MAX_SAFE_INTEGER && object >= Number.MIN_SAFE_INTEGER) {
return KwikEncode(parseInt(object.toString(), 10), {})
} else {
return KwikEncode(object.toString(), {})
}
} else {
return null
}
},
decode: (data: Uint8Array) => {
return BigInt(KwikDecode(data, {}) as string)
},
})
// Initialize the Database
await kwik.init()
log.info('Database Initialized!')

View File

@@ -0,0 +1,4 @@
import { bot } from '../bot.js'
import { updateGuildCommands } from '../utils/helpers.js'
bot.events.guildCreate = async (guild) => await updateGuildCommands(bot, guild)

View File

@@ -1,14 +1,94 @@
import { Bot } from '../../bot.ts.js'
import { InteractionTypes } from '../../deps.ts.js'
import log from '../utils/logger.ts.js'
import { ApplicationCommandOptionTypes, hasProperty, type Guild } from '@discordeno/bot'
import chalk from 'chalk'
import { bot } from '../bot.js'
import { commands } from '../commands.js'
import { getGuildFromId, isSubCommand, isSubCommandGroup } from '../utils/helpers.js'
import { createLogger } from '../utils/logger.js'
Bot.events.interactionCreate = (_, interaction) => {
if (!interaction.data) return
const logger = createLogger({ name: 'Event: InteractionCreate' })
switch (interaction.type) {
case InteractionTypes.ApplicationCommand:
log.info(`[Application Command] ${interaction.data.name} command executed.`)
Bot.commands.get(interaction.data.name!)?.execute(Bot, interaction)
break
bot.events.interactionCreate = async (interaction) => {
if (!interaction.data || !interaction.id) return
let guildName = 'Direct Message'
let guild = {} as Guild
// Set guild, if there was an error getting the guild, then just say it was a DM. (What else are we going to do?)
if (interaction.guildId) {
const guildOrVoid = await getGuildFromId(interaction.guildId).catch((err) => {
logger.error(err)
})
if (guildOrVoid) {
guild = guildOrVoid
guildName = guild.name
}
}
logger.info(
`[Command: ${chalk.bgYellow.black(interaction.data.name)} - ${chalk.bgBlack.white(`Trigger`)}] by @${interaction.user.username} in ${guildName}${guildName !== 'Direct Message' ? ` (${guild.id})` : ``}`,
)
let command = commands.get(interaction.data.name)
if (!command) {
logger.warn(
`[Command: ${chalk.bgYellow.black(interaction.data.name)} - ${chalk.bgBlack.yellow(`Not Found`)}] by @${interaction.user.username} in ${guildName}${guildName !== 'Direct Message' ? ` (${guild.id})` : ``}`,
)
return
}
if (interaction.data.options?.[0]) {
const optionType = interaction.data.options[0].type
if (optionType === ApplicationCommandOptionTypes.SubCommandGroup) {
// Check if command has subcommand and handle types
if (!command.subcommands) return
// Try to find the subcommand group
const subCommandGroup = command.subcommands?.find((command) => command.name === interaction.data?.options?.[0].name)
if (!subCommandGroup) return
if (isSubCommand(subCommandGroup)) return
// Get name of the command which we are looking for
const targetCmdName = interaction.data.options?.[0].options?.[0].name ?? interaction.data.options?.[0].options?.[0].name
if (!targetCmdName) return
// Try to find the command
command = subCommandGroup.subCommands.find((c) => c.name === targetCmdName)
}
if (optionType === ApplicationCommandOptionTypes.SubCommand) {
// Check if command has subcommand and handle types
if (!command?.subcommands) return
// Try to find the command
const found = command.subcommands.find((command) => command.name === interaction.data?.options?.[0].name)
if (!found) return
if (isSubCommandGroup(found)) return
command = found
}
}
try {
if (!command) throw new Error('Not command could be found')
await command.execute(interaction)
logger.info(
`[Command: ${chalk.bgYellow.black(interaction.data.name)} - ${chalk.bgBlack.green(`Success`)}] by @${interaction.user.username} in ${guildName}${guildName !== 'Direct Message' ? ` (${guild.id})` : ``}`,
)
} catch (err) {
logger.error(
`[Command: ${chalk.bgYellow.black(interaction.data.name)} - ${chalk.bgBlack.red(`Error`)}] by @${interaction.user.username} in ${guildName}${guildName !== 'Direct Message' ? ` (${guild.id})` : ``}`,
)
if (typeof err !== 'object' || !err || !hasProperty(err, 'message') || err.message === 'Not command could be found') return
logger.error(err)
}
}

View File

@@ -1,16 +1,22 @@
import { Bot } from '../../bot.ts.js'
import log from '../utils/logger.ts.js'
import { ActivityTypes } from '@discordeno/bot'
import { bot } from '../bot.js'
import { createLogger } from '../utils/logger.js'
Bot.events.ready = (_, payload) => {
log.info(`[READY] Shard ID ${payload.shardId} of ${Bot.gateway.lastShardId + 1} shards is ready!`)
const logger = createLogger({ name: 'Event: Ready' })
if (payload.shardId === Bot.gateway.lastShardId) {
botFullyReady()
}
}
bot.events.ready = async ({ shardId }) => {
logger.info('Bot Ready')
// This function lets you run custom code when all your bot's shards are online.
function botFullyReady() {
// DO STUFF YOU WANT HERE ONCE BOT IS FULLY ONLINE.
log.info('[READY] Bot is fully online.')
await bot.gateway.editShardStatus(shardId, {
status: 'online',
activities: [
{
name: 'Discordeno is the Best Lib',
type: ActivityTypes.Game,
timestamps: {
start: Date.now(),
},
},
],
})
}

View File

@@ -0,0 +1,15 @@
import 'dotenv/config'
import { bot } from './bot.js'
import importDirectory from './utils/loader.js'
import logger from './utils/logger.js'
logger.info('Starting bot...')
logger.info('Loading commands...')
await importDirectory('./dist/commands')
logger.info('Loading events...')
await importDirectory('./dist/events')
await bot.start()

View File

@@ -0,0 +1,7 @@
import 'dotenv/config'
import { bot } from './bot.js'
import { updateCommands } from './utils/helpers.js'
bot.logger.info('Updating commands...')
await updateCommands()

View File

@@ -1,3 +0,0 @@
// This file will export all of the types in this directory.
export * from './commands.ts.js'

View File

@@ -1,3 +1,101 @@
export function snowflakeToTimestamp(id: bigint) {
return Number(id / 4194304n + 1420070400000n)
import { hasProperty, type Bot, type CreateApplicationCommand, type Guild } from '@discordeno/bot'
import { bot } from '../bot.js'
import { commands, type SubCommand, type SubCommandGroup } from '../commands.js'
import { createLogger } from './logger.js'
const logger = createLogger({ name: 'Helpers' })
/** This function will update all commands, or the defined scope */
export async function updateCommands(scope?: 'Guild' | 'Global'): Promise<void> {
const globalCommands: Array<MakeRequired<CreateApplicationCommand, 'name'>> = []
const perGuildCommands: Array<MakeRequired<CreateApplicationCommand, 'name'>> = []
for (const command of commands.values()) {
if (command.scope === 'Guild') {
perGuildCommands.push({
name: command.name,
description: command.description,
type: command.type,
options: command.options ? command.options : undefined,
})
} else {
globalCommands.push({
name: command.name,
description: command.description,
type: command.type,
options: command.options ? command.options : undefined,
})
}
}
if (globalCommands.length && (scope === 'Global' || scope === undefined)) {
logger.info('Updating Global Commands, changes should apply in short...')
await bot.helpers.upsertGlobalApplicationCommands(globalCommands).catch(logger.error)
}
if (perGuildCommands.length && (scope === 'Guild' || scope === undefined)) {
await Promise.all(
bot.cache.guilds.memory.map(async (guild: Guild) => {
await bot.helpers.upsertGuildApplicationCommands(guild.id, perGuildCommands)
}),
)
}
}
/** Update commands for a guild */
export async function updateGuildCommands(bot: Bot, guild: Guild): Promise<void> {
const perGuildCommands: Array<MakeRequired<CreateApplicationCommand, 'name'>> = []
for (const command of commands.values()) {
if (command.scope === 'Guild') {
perGuildCommands.push({
name: command.name,
description: command.description,
type: command.type,
options: command.options ? command.options : undefined,
})
}
}
if (perGuildCommands.length) {
await bot.helpers.upsertGuildApplicationCommands(guild.id, perGuildCommands)
}
}
export async function getGuildFromId(guildId: bigint): Promise<Guild> {
const cached = await bot.cache.guilds.get(guildId)
if (cached) return cached
return await bot.helpers.getGuild(guildId)
}
export function humanizeMilliseconds(milliseconds: number): string {
// Gets ms into seconds
const time = milliseconds / 1000
if (time < 1) return '< 1s'
const days = Math.floor(time / 86400)
const hours = Math.floor((time % 86400) / 3600)
const minutes = Math.floor(((time % 86400) % 3600) / 60)
const seconds = Math.floor(((time % 86400) % 3600) % 60)
const dayString = days ? `${days}d ` : ''
const hourString = hours ? `${hours}h ` : ''
const minuteString = minutes ? `${minutes}m ` : ''
const secondString = seconds ? `${seconds}s ` : ''
return `${dayString}${hourString}${minuteString}${secondString}`
}
export function isSubCommand(data: SubCommand | SubCommandGroup): data is SubCommand {
return !hasProperty(data, 'subCommands')
}
export function isSubCommandGroup(data: SubCommand | SubCommandGroup): data is SubCommandGroup {
return hasProperty(data, 'subCommands')
}
type MakeRequired<TObj, TKey extends keyof TObj> = TObj & {
[Key in TKey]-?: TObj[Key]
}

View File

@@ -1,40 +1,15 @@
import log from './logger.ts.js'
import { readdir } from 'node:fs/promises'
import logger from './logger.js'
// Very important to make sure files are reloaded properly
let uniqueFilePathCounter = 0
let paths: string[] = []
export default async function importDirectory(folder: string): Promise<void> {
const files = await readdir(folder, { recursive: true })
/** This function allows reading all files in a folder. Useful for loading/reloading commands, monitors etc */
export async function importDirectory(path: string) {
path = path.replaceAll('\\', '/')
const files = Deno.readDirSync(Deno.realPathSync(path))
const folder = path.substring(path.indexOf('/src/') + 5)
for (const filename of files) {
if (!filename.endsWith('.js')) continue
if (!folder.includes('/')) log.info(`Loading ${folder}...`)
for (const file of files) {
if (!file.name) continue
const currentPath = `${path}/${file.name}`
if (file.isFile) {
if (!currentPath.endsWith('.ts')) continue
paths.push(
`import "${Deno.mainModule.substring(0, Deno.mainModule.lastIndexOf('/'))}/${currentPath.substring(
currentPath.indexOf('src/'),
)}#${uniqueFilePathCounter}";`,
// Using `file://` and `process.cwd()` to avoid weird issues with relative paths and/or Windows
await import(`file://${process.cwd()}/${folder}/${filename}`).catch((x) =>
logger.fatal(`Cannot import file (${folder}/${filename}) for reason:`, x),
)
continue
}
await importDirectory(currentPath)
}
uniqueFilePathCounter++
}
/** Imports all everything in fileloader.ts */
export async function fileLoader() {
await Deno.writeTextFile('fileloader.ts', paths.join('\n').replaceAll('\\', '/'))
await import(`${Deno.mainModule.substring(0, Deno.mainModule.lastIndexOf('/'))}/fileloader.ts#${uniqueFilePathCounter}`)
paths = []
}

View File

@@ -1,5 +1,5 @@
// deno-lint-ignore-file no-explicit-any
import { bold, cyan, gray, italic, red, yellow } from '../../deps.ts.js'
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import chalk from 'chalk'
export enum LogLevels {
Debug,
@@ -19,21 +19,15 @@ const prefixes = new Map<LogLevels, string>([
const noColor: (str: string) => string = (msg) => msg
const colorFunctions = new Map<LogLevels, (str: string) => string>([
[LogLevels.Debug, gray],
[LogLevels.Info, cyan],
[LogLevels.Warn, yellow],
[LogLevels.Error, (str: string) => red(str)],
[LogLevels.Fatal, (str: string) => red(bold(italic(str)))],
[LogLevels.Debug, chalk.gray],
[LogLevels.Info, chalk.cyan],
[LogLevels.Warn, chalk.yellow],
[LogLevels.Error, (str: string) => chalk.red(str)],
[LogLevels.Fatal, (str: string) => chalk.red.bold.italic(str)],
])
export function logger({
logLevel = LogLevels.Info,
name,
}: {
logLevel?: LogLevels
name?: string
} = {}) {
function log(level: LogLevels, ...args: any[]) {
export function createLogger({ logLevel = LogLevels.Info, name }: { logLevel?: LogLevels; name?: string } = {}): Logger {
function log(level: LogLevels, ...args: any[]): void {
if (level < logLevel) return
let color = colorFunctions.get(level)
@@ -42,7 +36,7 @@ export function logger({
const date = new Date()
const log = [
`[${date.toLocaleDateString()} ${date.toLocaleTimeString()}]`,
color(prefixes.get(level) || 'DEBUG'),
color(prefixes.get(level) ?? 'DEBUG'),
name ? `${name} >` : '>',
...args,
]
@@ -63,27 +57,27 @@ export function logger({
}
}
function setLevel(level: LogLevels) {
function setLevel(level: LogLevels): void {
logLevel = level
}
function debug(...args: any[]) {
function debug(...args: any[]): void {
log(LogLevels.Debug, ...args)
}
function info(...args: any[]) {
function info(...args: any[]): void {
log(LogLevels.Info, ...args)
}
function warn(...args: any[]) {
function warn(...args: any[]): void {
log(LogLevels.Warn, ...args)
}
function error(...args: any[]) {
function error(...args: any[]): void {
log(LogLevels.Error, ...args)
}
function fatal(...args: any[]) {
function fatal(...args: any[]): void {
log(LogLevels.Fatal, ...args)
}
@@ -98,5 +92,15 @@ export function logger({
}
}
export const log = logger({ name: 'Main' })
export default log
export const logger = createLogger({ name: 'Main' })
export default logger
export interface Logger {
log: (level: LogLevels, ...args: any[]) => void
debug: (...args: any[]) => void
info: (...args: any[]) => void
warn: (...args: any[]) => void
error: (...args: any[]) => void
fatal: (...args: any[]) => void
setLevel: (level: LogLevels) => void
}

View File

@@ -1,19 +0,0 @@
import { Bot } from '../../bot.ts.js'
import { configs } from '../../configs.ts.js'
export async function updateApplicationCommands() {
await Bot.helpers.upsertGlobalApplicationCommands(
Bot.commands
// ONLY GLOBAL COMMANDS
.filter((command) => !command.devOnly)
.array(),
)
await Bot.helpers.upsertGuildApplicationCommands(
configs.devGuildId,
Bot.commands
// ONLY GLOBAL COMMANDS
.filter((command) => !!command.devOnly)
.array(),
)
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"moduleResolution": "node",
"skipDefaultLibCheck": true,
"skipLibCheck": true,
"strict": true,
"incremental": true
}
}

2133
examples/beginner/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
# Include any files or directories that you don't want to be copied to your
# container here (e.g., local build artifacts, temporary files, etc.).
#
# For more help, visit the .dockerignore file reference guide at
# https://docs.docker.com/go/build-context-dockerignore/
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Git files
.git
.gitignore
# Docker files
.dockerignore
docker-compose*
compose.y*ml
Dockerfile*
# Local build files
node_modules
dist
.yarn
LICENSE
README.md

View File

@@ -1,104 +1,142 @@
# For Prisma use. Remember to remove the [ and ]
DATABASE_URL=postgres:#[username]:[password]@[host]:[port]/[db]
# For other configs, update src/configs.ts
#
# General Configurations
# Whether or not this process is a local development version. Change to false for the main production bot. */
# SETUP-DD-TEMP: Change this to false in your server for production bot. Keep true in local testing.
#
# Whether or not this process is a local development version
# In production this value should be set to false
# TEMPLATE-SETUP: When deploying, set this value to false
DEVELOPMENT=true
# The server id where you develop/test the bot. */
# SETUP-DD-TEMP: Change the id to a server where you develop the bot privately.
# The server id where you develop/test the bot
# TEMPLATE-SETUP: Add the id to a server where you develop the bot
DEV_SERVER_ID=
# The discord bot token, without the BOT prefix. */
# SETUP-DD-TEMP: Add the bot token here.
# The discord bot token
# NOTE: It should not be prefixed with Bot
# TEMPLATE-SETUP: Add the bot token here.
DISCORD_TOKEN=
# Bot Configurations
# The secret passcode that the bot code (event handler) is listening for. This is used to prevent someone else from trying to send malicious messages to your bot. */
# SETUP-DD-TEMP: Add a secret passcode here.
EVENT_HANDLER_AUTHORIZATION=SuperSecretPassword
#
# Bot Configuration
#
# The host where the event handler will run. Must follow https:#nodejs.org/api/net.html#serverlistenoptions-callback. */
# SETUP-DD-TEMP: Set the event handler's host here.
# NOTE: With "bot code" we refer to the code that will handle the events
# The secret passcode that the bot code is checking for
# This is used to prevent someone else from trying to send malicious events to your bot
# TEMPLATE-SETUP: Add a secret passcode here. It can be whatever you want
EVENT_HANDLER_AUTHORIZATION=
# The host where the event handler will run
# Will be used together with EVENT_HANDLER_PORT to compose the HTTP url to send the events to
# TEMPLATE-SETUP: Set the event handler's host here
EVENT_HANDLER_HOST=localhost
# The port where the event handler is listening for events. */
# SETUP-DD-TEMP: Set the desired port where events will be sent to be processed.
# The port where the event handler will listening for events
# TEMPLATE-SETUP: Set the port where events will be sent
EVENT_HANDLER_PORT=8081
# The full webhook url where the bot can send errors to alert you that the bot is missing translations. */
# SETUP-DD-TEMP: Set a full discord webhook url here.
MISSING_TRANSLATION_WEBHOOK=
# The full webhook url where the bot can send errors to alert you that the bot is throwing errors. */
# SETUP-DD-TEMP: Set a full discord webhook url here.
# The full webhook url where the bot can send errors to alert you that the bot is throwing errors.
# TEMPLATE-SETUP: Add the full discord webhook url
BUGS_ERRORS_REPORT_WEBHOOK=
#
# Rest Proxy Configurations
# The authorization code that the REST proxy will check for to make sure the requests are coming from you. */
# SETUP-DD-TEMP: Add a secret passcode here.
REST_AUTHORIZATION=password123
#
# The host where the REST proxy will run. Must follow https:#nodejs.org/api/net.html#serverlistenoptions-callback. */
# SETUP-DD-TEMP: Set the REST proxy's host here.
# The passcode that the REST proxy is checking for
# This is used to prevent someone else from trying to send malicious API requests from your bot
# TEMPLATE-SETUP: Add a secret passcode here. It can be whatever you want
REST_AUTHORIZATION=
# The host where the REST proxy will run
# Will be used together with REST_PORT to compose the HTTP url to send the API requests to
# TEMPLATE-SETUP: Set the REST proxy's host here
REST_HOST=localhost
# The port that will run the REST proxy. */
# SETUP-DD-TEMP: Choose the port number here that will be used for the REST proxy.
# The port where the REST proxy will listen for API requests
# TEMPLATE-SETUP: Set the port where API requests will be sent
REST_PORT=8000
#
# Gateway Proxy Configurations
#
# The amount of shards to start. Useful with multiple servers where each server is handling a portion of your bot. */
# SETUP-DD-TEMP: To start all bots, leave it as undefined. Specify he number of shards this process should handle.
# The amount of shards to start
# Useful with multiple servers where each server is handling a portion of your bot
# OPTIONAL: You can leave this value unspecified if you want this server to manage all shards
# TEMPLATE-SETUP: If you have separate servers, add the number of shards this process should handle
TOTAL_SHARDS=
# The amount of shards to start per worker. */
# SETUP-DD-TEMP: Choose how many shards to start per worker. If you are not sure just stick to 16.
# The amount of shards to start per worker.
# NOTE: If you are not sure just stick to 16
# TEMPLATE-SETUP: Set how many shards to start per worker
SHARDS_PER_WORKER=16
# The total amount of workers to start. Generally this should be equal to the number of cores your server has. */
# SETUP-DD-TEMP: Choose how many workers to start up. If you are not sure, check how many cores your server has.
# The total amount of workers to start.
# NOTE: Generally this should be equal to the number of cores your server has
# TEMPLATE-SETUP: Choose how many workers to start up
TOTAL_WORKERS=4
# The secret passcode that the gateway is listening for. This is used to prevent someone else from trying to send malicious messages to your bot. */
# SETUP-DD-TEMP: Add a secret passcode here.
# The passcode that the gateway is checking for
# This is used to prevent someone else from trying to send malicious messages to your bot
# TEMPLATE-SETUP: Set a secret passcode here. It can be whatever you want
GATEWAY_AUTHORIZATION=
# The host where the gateway will run. Must follow https:#nodejs.org/api/net.html#serverlistenoptions-callback. */
# SETUP-DD-TEMP: Set the gateways's host here.
# The host where the gateway will run
# Will be used together with GATEWAY_PORT to compose the HTTP url to send the gateway messages to
# TEMPLATE-SETUP: Set the gateway's host here
GATEWAY_HOST=localhost
# The port where the gateway will run. This is where the bot will send its messages to the gateway. */
# SETUP-DD-TEMP: Set the gateways's port here.
# The port where the gateway will listen for gateway messages
# TEMPLATE-SETUP: Set the port where gateway messages will be sent
GATEWAY_PORT=8080
# Messsage queue / RabbitMQ configuration
# enable using messages queue to send messages from gateway to bot
#
# Message queue (RabbitMQ configuration)
#
# Whatever to queue messages from the gateway to bot
# NOTE: If this is set to true, all other configuration in this section are requried
# NOTE: if this is set to false, gateway messages will be sent directly to the bot code, and will fail if the bot code is not running
MESSAGEQUEUE_ENABLE=false
# The url of the message queue
MESSAGEQUEUE_URL=rabbitmq:5672
# The url of the RabbitMQ instance
MESSAGEQUEUE_URL=localhost:5672
# username and password for the message queue
MESSAGEQUEUE_USERNAME=guest
MESSAGEQUEUE_PASSWORD=guest
# Username for the authentication against the RabbitMQ instance
MESSAGEQUEUE_USERNAME=
# Database Configurations
# Password for the authentication against the RabbitMQ instance
MESSAGEQUEUE_PASSWORD=
# These INFLUX configs are only if you wish to enable analytics. */
# SETUP-DD-TEMP: This is optional. If you want to build analytics, add influxdb here.
#
# Analytics (InfluxDB configuration)
#
# NOTE: This entire section is optional
# TEMPLATE-SETUP: If you want to enable analytics, add the the following values
# The InfluxDB organization
INFLUX_ORG=
# The InfluxDB bucket
INFLUX_BUCKET=
# The InfluxDB secret API token
# NOTE: this may need to be in quotes ("...") if it contains the = sign
INFLUX_TOKEN=
INFLUX_URL=http://influxdb:8086
# The InfluxDB Instance url
INFLUX_URL=http://localhost:8086
#
# Docker InfluxDB
#
DOCKER_INFLUXDB_INIT_MODE=setup
DOCKER_INFLUXDB_INIT_USERNAME=skillz
DOCKER_INFLUXDB_INIT_PASSWORD=ILoveskillz
DOCKER_INFLUXDB_INIT_USERNAME=discordeno
DOCKER_INFLUXDB_INIT_PASSWORD=discordeno
DOCKER_INFLUXDB_INIT_ORG=discordeno
DOCKER_INFLUXDB_INIT_BUCKET=discordeno
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=skillzPrefersID
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=discordeno

View File

@@ -1,2 +1,32 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# build
dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

View File

@@ -20,6 +20,5 @@
"strictMode": true,
"lazy": false,
"noInterop": false
},
"sourceMaps": true
}
}

View File

@@ -0,0 +1,4 @@
# This file is needed or else when yarn runs in `docker build` will attempt to use PnP
compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules

View File

@@ -1,37 +1,75 @@
FROM node:16.18.0-alpine3.16 AS deps
WORKDIR /app
COPY package.json package-lock.json .swcrc ./
RUN npm install
# syntax=docker/dockerfile:1
FROM node:16.18.0-alpine3.16 as rest
COPY --from=deps /app /app
WORKDIR /app
COPY src/analytics.ts src/configs.ts src/
COPY src/rest src/rest
RUN npm run build
ARG NODE_VERSION=20
################################################################################
FROM node:${NODE_VERSION}-alpine as base
# Set working directory for all build stages.
WORKDIR /usr/src/app
# Enable corepack so it can install the needed yarn version
RUN corepack enable
COPY package.json .
COPY yarn.lock .
# We need to copy the yarnrc or else yarn will attempt to use PnP
COPY .yarnrc.yml .
RUN yarn install
# Copy the rest of the source files into the image.
COPY . .
# Run the build script.
RUN yarn build
# Use production node environment by default.
ENV NODE_ENV production
# Run the application as a non-root user.
USER node
################################################################################
FROM base as rest
# Expose the port that the application listens on.
EXPOSE 8000
CMD ["npm","run","startr"]
FROM node:16.18.0-alpine3.16 as gateway
COPY --from=deps /app /app
WORKDIR /app
COPY src/configs.ts src/
COPY src/gateway src/gateway
RUN npm run build
# Run the application.
CMD node dist/rest/index.js
################################################################################
FROM base as gateway
# Expose the port that the application listens on.
EXPOSE 8080
CMD ["npm","run","startg"]
FROM node:16.18.0-alpine3.16 as bot
COPY --from=deps /app /app
WORKDIR /app
COPY prisma prisma
COPY node_modules/.prisma/client node_modules/.prisma/client
COPY src/analytics.ts src/prisma.ts src/configs.ts src/
COPY src/bot src/bot
RUN npm run build
EXPOSE 8080
CMD ["npm","run","startb"]
# Run the application.
CMD node dist/gateway/index.js
FROM rabbitmq:3.11.2-management-alpine as rabbitmq
COPY src/rabbitmq/plugins plugins
################################################################################
FROM base as bot
# Expose the port that the application listens on.
EXPOSE 8081
# Run the application.
CMD node dist/bot/index.js
################################################################################
FROM rabbitmq:3.12-management-alpine as rabbitmq
# Copy the rabbitmq plugins
COPY rabbitmq/plugins/** plugins
HEALTHCHECK CMD [ "rabbitmq-diagnostics", "-q", "status" ] \
--interval=30s --timeout=30s --start-period=30s --retries=5
# Enable the required plugins
RUN rabbitmq-plugins enable rabbitmq_message_deduplication

View File

@@ -1,66 +1,105 @@
# Discordeno Big Bot Template
# Big Bot Template
Support: <https://discord.gg/ddeno>
> [!TIP]
> If you have any issue you can join the discord server for support: https://discord.gg/ddeno
This template is designed for bots that aim or are already in millions of Discord servers. It is written with Node.js as
currently Deno & Bun are not ready to run something at such a scale. The general idea of this template can be modified
for any other runtime if this improves in the future.
Make sure to install the latest version when you use it.
This template is designed for bots that aim to be in millions of Discord services or already are.
## Setup
1. Run a find all for `// SETUP-DD-TEMP:` and follow all instructions and delete the comments as you finish them.
- Download the source
- Copy the .env.example file and rename it to .env
- Fill out the .env file
- Find all the `TEMPLATE-SETUP:` comments and follow the instructions and delete the comments as you finish them.
## Startup
There are two ways to start your bot, using docker and node. Using docker will be the simplest and easiest way to start
your bot. The default configuation will be set for Docker.
You can run the template using either Docker or Node.
Using docker will be the simplest and easiest way to start your bot.
### Using Docker
The docker compose file include the discordeno bot and influxdb, this would create an enviroment more close to the
production enviroment.
The docker compose file includes the discordeno bot and influxdb. This will create an environment close to the production environment.
First, rename the .env.example file to .env, and set the discord token and your dev guild id, change the `REST_URL` to
`rest` and `EVENT_HANDLER_URL` to `bot`, set `MESSAGEQUEUE_ENABLE` to true to use message queue, copy the value of
`DOCKER_INFLUXDB_INIT_xxxx` to `INFLUX_xxxx`
First, copy the `.env.example` file, rename it to `.env`, and fill in the values. Pre-set values can be left to their default value, except for the following:
Then, run ... to build/rebuild the bot
> [!IMPORTANT]
> The following values must be set to enable the docker container to communicate between different parts of your bot, InfluxDB, and RabbitMQ
>
> - `EVENT_HANDLER_HOST` should be set to `bot`
> - `REST_HOST` should be set to `rest`
> - `GATEWAY_HOST` should be set to `gateway`
> - Setup the message queue:
> - `MESSAGEQUEUE_ENABLE` should be set to `true`
> - `MESSAGEQUEUE_URL` should be set to `rabbitmq:5672`
> - `MESSAGEQUEUE_USERNAME` should be set to `guest`
> - `MESSAGEQUEUE_PASSWORD` should be set to `guest`
> - Set the value for influxDB:
> - Copy `DOCKER_INFLUXDB_INIT_ORG` to `INFLUX_ORG`
> - Copy `DOCKER_INFLUXDB_INIT_BUCKET` to `INFLUX_BUCKET`
> - Copy `DOCKER_INFLUXDB_INIT_ADMIN_TOKEN` to `INFLUX_TOKEN`
> - Set `INFLUX_URL` to `http://influxdb:8086`
- `docker-compose build`
After setting the aforementioned values, run `docker compose build` to build/rebuild the bot
And, run ... to start
Finally, run `docker compose up -d` to start
- `docker-compose up -d`
> [!NOTE]
> Docker will start the REST proxy, Gateway and Bot, however you won't see any command in Discord.
> You will need to manually run the `bot/register-commands.js` file.
>
> You can do this locally, but you will need to change some environment variables like the `REST_HOST` to point to something accessible from your machine
Your bot should be running now, you can check the rest/bot process fetch analytics (methods, status...) in influxdb's
webgui - <http://localhost:8086> with the username and password in the .env file, message queue's information (number of
events...) at <http://localhost:15672> with user: guest and pass: guest.
Your bot should be running now.
You can check the REST process fetch analytics (methods, status...) in influxdb's WebUI at http://localhost:8086 with the username and password in the .env file (`DOCKER_INFLUXDB_INIT_USERNAME` and `DOCKER_INFLUXDB_INIT_PASSWORD`, respectively). You can also check the message queue's information (number of events, ...) in the RabbitMQ WebUI at http://localhost:15672 with the username `guest` and password `guest`.
### Using Node
you will need to start a few processes. The instructions below will use `node` but you can use something like `pm2` to
help keep your processes alive.
> [!NOTE]
> This template has been tested with the following versions:
>
> - NodeJS: v18.20.3, v20.14.0 and v22.2.0
> - Any NodeJS version between v18.20.3 and v22.2.0 should work, anything below v18 will not run correctly, anything above v22 should work
> - RabbitMQ: v3.12.14 with:
> - Erlang: v26.2.5
> - [RabbitMQ Message Deduplication Plugin](https://github.com/noxdafox/rabbitmq-message-deduplication): v0.6.2
> - InfluxDB: v2.7.6
First, rename the .env.example file to .env, and set the discord token and your dev guild id, change the `REST_URL` and
`EVENT_HANDLER_URL` to `localhost`
You will need to start a few processes.
Then compile everything with `npm run build`.
The preset value of `EVENT_HANDLER_HOST`, `REST_HOST`, and `GATEWAY_HOST` all use localhost. If you are using different servers you will need to change those values
After that, you can start your bot one by one with the following order.
#### Setup process
- Start REST
- `npm run startr`
- Start Bot
- `npm run startb`
- Start Gateway
- `npm run startg`
- Install the dependencies with yarn
- Build the code with `yarn build`
Other things you can add:
You can start different parts of your bot in the following order.
- InfluxDB for logging fetch analytics, by change value of `INFLUX_xxxx` to your influxdb config, leave it empty will
disable it.
- RabbitMQ for using message queues instead of fetch calls, by change value of `MESSAGEQUEUE_ENABLE` to true, and
`MESSAGEQUEUE_xxx` of your rabbitmq config <br/> Note: the RabbitMQ must installed the
[RabbitMQ Message Deduplication Plugin](https://github.com/noxdafox/rabbitmq-message-deduplication)
- Start the REST Proxy: `yarn start:rest`
- Deploy the commands: `node dist/bot/register-commands.js`
- Start the Bot: `yarn start:bot`
- Start Gateway: `yarn start:gateway`
#### InfluxDB
To enable InfluxDB you will need to set the `INFLUX_ORG`, `INFLUX_BUCKET`, `INFLUX_TOKEN`, and `INFLUX_URL` variables in the `.env`. file
For `INFLUX_URL`, the preset value uses localhost. If your InfluxDB is not running on the same machine, change the URL accordingly (do not include any protocol prefix, just `HOST:PORT`)
If you do not set one of the values mentioned above, InfluxDB will be disabled.
#### RabbitMQ
To enable RabbitMQ you will need to set `MESSAGEQUEUE_ENABLE` to `true` and set `MESSAGEQUEUE_URL`, `MESSAGEQUEUE_USERNAME`, and `MESSAGEQUEUE_PASSWORD` variables in the `.env` file.
`MESSAGEQUEUE_USERNAME` and `MESSAGEQUEUE_PASSWORD` will both default to `guest` in a RabbitMQ instance unless changed.
> [!IMPORTANT]
> The [RabbitMQ Message Deduplication Plugin](https://github.com/noxdafox/rabbitmq-message-deduplication) must be installed
>
> The plugin files (`.ez`) are in the `rabbitmq/plugins/message-deduplication` folder. You can copy those into your `plugins` folder for the RabbitMQ installation folder or download them from the original repo (make sure to download the correct version).
>
> To enable the plugin you will need to run: `rabbitmq-plugins enable rabbitmq_message_deduplication`

View File

@@ -1,25 +1,29 @@
version: '3.8'
services:
influxdb:
image: 'influxdb:2.4.0-alpine'
image: influxdb:2.7.6-alpine
ports:
- 127.0.0.1:8086:8086
env_file:
- .env
healthcheck:
test: "curl -f http://localhost:8086/ping"
interval: 5s
timeout: 10s
retries: 5
rabbitmq:
build:
context: .
target: rabbitmq
ports:
- 127.0.0.1:15672:15672
rest:
build:
context: .
target: rest
deploy:
replicas: 2
depends_on:
- influxdb
influxdb:
condition: service_healthy
env_file:
- .env
gateway:
@@ -27,9 +31,10 @@ services:
context: .
target: gateway
depends_on:
- rest
deploy:
replicas: 2
rest:
condition: service_started
rabbitmq:
condition: service_healthy
env_file:
- .env
bot:
@@ -37,8 +42,9 @@ services:
context: .
target: bot
depends_on:
- rest
deploy:
replicas: 2
rest:
condition: service_started
rabbitmq:
condition: service_healthy
env_file:
- .env

View File

@@ -1,6 +0,0 @@
{
"watch": "./src/**/*.ts",
"ext ": "env,ts",
"signal": "SIGKILL",
"exec": "npm run build && node"
}

View File

@@ -1,51 +1,36 @@
{
"name": "dd-big-bot",
"name": "dd-bigbot",
"version": "1.0.0",
"main": "index.js",
"description": "A scalable bot for big bot developers.",
"main": "dist/index.js",
"type": "module",
"license": "ISC",
"private": true,
"packageManager": "yarn@4.0.2",
"scripts": {
"devbg": "npx prisma generate && tsc --watch",
"fmt": "eslint --fix \"src/**/*.ts*\"",
"devg": "nodemon --ignore ./src/bot/**/* --ignore ./src/rest/**/* --ignore ./dist/**/* -e ts dist/gateway/index.js",
"devr": "nodemon --ignore ./src/bot/**/* --ignore ./src/gateway/**/* --ignore ./dist/**/* -e ts dist/rest/index.js",
"devb": "nodemon --ignore ./src/rest/**/* --ignore ./src/gateway/**/* --ignore ./dist/**/* -e ts dist/bot/index.js",
"type": "tsc --noEmit",
"build": "swc --delete-dir-on-start src --out-dir dist",
"startr": "node dist/rest/index.js",
"startg": "node dist/gateway/index.js",
"startb": "node dist/bot/index.js"
"start:bot": "node dist/bot/index.js",
"start:rest": "node dist/rest/index.js",
"start:gateway": "node dist/gateway/index.js",
"build": "swc src --strip-leading-paths --delete-dir-on-start --out-dir dist",
"build:watch": "swc src --strip-leading-paths --delete-dir-on-start --watch --out-dir dist",
"dev:bot": "node --watch --watch-preserve-output dist/bot/index.js",
"dev:rest": "node --watch --watch-preserve-output dist/rest/index.js",
"dev:gateway": "node --watch --watch-preserve-output dist/gateway/index.js",
"setup-dd": ""
},
"dependencies": {
"@influxdata/influxdb-client": "^1.29.0",
"@prisma/client": "^3.15.2",
"amqplib": "^0.10.3",
"colorette": "^2.0.19",
"discordeno": "^16.0.1",
"dotenv": "^16.0.3",
"express": "^4.18.1",
"fastify": "^4.10.2",
"nanoid": "^4.0.0",
"node-fetch": "^3.2.10",
"web-worker": "^1.2.0"
"@discordeno/bot": "19.0.0-next.92bf166",
"@fastify/multipart": "^8.3.0",
"@influxdata/influxdb-client": "^1.33.2",
"amqplib": "^0.10.4",
"chalk": "^5.3.0",
"fastify": "^4.28.0"
},
"devDependencies": {
"@swc/cli": "^0.1.57",
"@swc/core": "^1.3.9",
"@types/amqplib": "^0.8.2",
"@types/express": "^4.17.13",
"@types/node": "^17.0.23",
"@types/ws": "^8.5.3",
"nodemon": "^2.0.15",
"prettier": "2.6.2",
"prisma": "^4.2.1",
"typescript": "^4.6.3"
},
"prettier": {
"trailingComma": "all",
"useTabs": true,
"tabWidth": 2,
"singleQuote": true,
"semi": true,
"printWidth": 120
"@swc/cli": "^0.3.12",
"@swc/core": "^1.6.3",
"@types/amqplib": "^0.10.5",
"@types/node": "^20.14.6",
"typescript": "^5.5.2"
}
}

View File

@@ -1,25 +0,0 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Guilds {
/// The server id
id BigInt @unique @id
/// The language the server uses.
language String @default("english")
}
model Commands {
/// The server id
id BigInt @unique @id
/// The version number this server is using for it's commands.
version Int
}

View File

@@ -0,0 +1,18 @@
# RabbitMQ
This template has been tested using RabbitMQ v3.12.14 (Erlang v26.2.5)
## Plugins
The template needs the [rabbitmq_message_deduplication](https://github.com/noxdafox/rabbitmq-message-deduplication) plugin.
This template has been tested with version v0.6.2 of said plugin. The `.ez` files are already in the `message-deduplication` folder, but you can re-download them from the original repository if needed.
To enable the plugin you will need to run the following command:
```sh
rabbitmq-plugins enable rabbitmq_message_deduplication
```
> [!NOTE]
> You may need to prefix the command with `sudo` based on your RabbitMQ setup/current shell permissions

View File

@@ -0,0 +1,2 @@
# RabbitMQ plugins are stored in a zip format (.ez files), just for security we tell git to always threat them as binary files
* binary

View File

@@ -1,55 +0,0 @@
import { InfluxDB, Point } from '@influxdata/influxdb-client';
import type { RestManager } from 'discordeno/rest';
const INFLUX_ORG = process.env.INFLUX_ORG as string;
const INFLUX_BUCKET = process.env.INFLUX_BUCKET as string;
const INFLUX_TOKEN = process.env.INFLUX_TOKEN as string;
const INFLUX_URL = process.env.INFLUX_URL as string;
export const influxDB = INFLUX_URL && INFLUX_TOKEN ? new InfluxDB({ url: INFLUX_URL, token: INFLUX_TOKEN }) : undefined;
export const Influx = influxDB?.getWriteApi(INFLUX_ORG, INFLUX_BUCKET);
export const setupAnalyticsHooks = (rest: RestManager) => {
// If influxdb data is provided, enable analytics in this proxy.
if (Influx) {
rest.fetching = function (options) {
Influx?.writePoint(
new Point('restEvents')
// MARK THE TIME WHEN EVENT ARRIVED
.timestamp(new Date())
// SET THE GUILD ID
.stringField('type', 'REQUEST_FETCHING')
.tag('method', options.method)
.tag('url', options.url)
.tag('bucket', options.bucketId ?? 'NA'),
);
};
rest.fetched = function (options, response) {
Influx?.writePoint(
new Point('restEvents')
// MARK THE TIME WHEN EVENT ARRIVED
.timestamp(new Date())
// SET THE GUILD ID
.stringField('type', 'REQUEST_FETCHED')
.tag('method', options.method)
.tag('url', options.url)
.tag('bucket', options.bucketId ?? 'NA')
.intField('status', response.status)
.tag('statusText', response.statusText),
);
};
setInterval(() => {
console.log(`[Influx - REST] Saving events...`);
Influx?.flush()
.then(() => {
console.log(`[Influx - REST] Saved events!`);
})
.catch((error) => {
console.log(`[Influx - REST] Error saving events!`, error);
});
// Every 30seconds
}, 30000);
}
};

View File

@@ -1,8 +0,0 @@
# Bot Code
This folder will contain the code for our bot. When events are received from the gateway, they will be handled here.
## Further Steps
- Express framework to create the listener however, you can replace it with anything you like. Express is quite a
bloated framework. Feel free to optimize to a better framework.

View File

@@ -1,63 +1,101 @@
import type { Bot } from 'discordeno';
import { Collection, createBot, createRestManager } from 'discordeno';
import enableHelpersPlugin from 'discordeno/helpers-plugin';
import { createLogger } from 'discordeno/logger';
import { setupAnalyticsHooks } from '../analytics.js';
import { INTENTS, REST_URL } from '../configs.js';
import { setupEventHandlers } from './events/mod.js';
import type { MessageCollector } from './utils/collectors.js';
import { customizeInternals } from './utils/internals/mod.js';
import { Collection, LogDepth, createBot, type Bot, type logger } from '@discordeno/bot'
import { DISCORD_TOKEN, GATEWAY_AUTHORIZATION, GATEWAY_INTENTS, GATEWAY_URL, REST_AUTHORIZATION, REST_URL } from '../config.js'
import type { ManagerGetShardInfoFromGuildId, ShardInfo, WorkerPresencesUpdate, WorkerShardPayload } from '../gateway/worker/types.js'
import type { Command } from './commands.js'
const DISCORD_TOKEN = process.env.DISCORD_TOKEN as string;
const REST_AUTHORIZATION = process.env.REST_AUTHORIZATION as string;
export const bot = enableHelpersPlugin(
customizeBot(
export const bot = createCustomBot(
createBot({
token: DISCORD_TOKEN,
intents: INTENTS,
intents: GATEWAY_INTENTS,
rest: {
token: DISCORD_TOKEN,
proxy: {
baseUrl: REST_URL,
authorization: REST_AUTHORIZATION,
},
},
}),
),
);
)
/** Add custom props to your `bot` here */
// SETUP-DD-TEMP: If you want to add any custom props to `bot` you can do so here. Please make sure to also add them in the type below. As an example, i have added a `logger` property. You can add any useful methods or props you wish to have easily available.
function customizeBot<B extends Bot = Bot>(bot: B): BotWithCustomProps {
const customized = bot as unknown as BotWithCustomProps;
customized.logger = createLogger({ name: '[Bot]' });
customized.collectors = {
messages: new Collection(),
};
customized.commandVersions = new Collection();
overrideGatewayImplementations(bot)
return customized;
// TEMPLATE-SETUP: Add/Remove the desired proprieties that you don't need
const props = bot.transformers.desiredProperties
props.interaction.id = true
props.interaction.data = true
props.interaction.type = true
props.interaction.user = true
props.interaction.token = true
props.interaction.guildId = true
props.user.id = true
props.user.username = true
// TEMPLATE-SETUP: If you want/need to add any custom proprieties on the Bot type, you can do it in this function and the `CustomBot` type below. Make sure to do it in both or else you will get an error by TypeScript
function createCustomBot<TBot extends Bot = Bot>(rawBot: TBot): CustomBot<TBot> {
const bot = rawBot as CustomBot<TBot>
// We need to set the log depth for the default discordeno logger or else only the first param will be logged
;(bot.logger as typeof logger).setDepth(LogDepth.Full)
bot.commands = new Collection()
return bot
}
// SETUP-DD-TEMP: If you want to add any custom props to `bot` you can do so here. Please make sure to also add them in the function above. Run a find all and change this to your Bot's name. For example, if your bot's name is Gamer change BotWithCustomProps to Gamer. This way whenever you need to provide the type for the Bot with your custom props it is your bots name.
// Note: ALWAYS edit the function above first before adding the type here.
export type BotWithCustomProps<B extends Bot = Bot> = B & {
/** A easy to use logger to make clean log messages. */
logger: ReturnType<typeof createLogger>;
/** Collectors that can be used to get input from users. */
collectors: {
/** Holds the pending messages collectors that users can respond to. */
messages: Collection<bigint, MessageCollector>;
};
/** The command versions for each guild id. */
commandVersions: Collection<bigint, number>;
};
export type CustomBot<TBot extends Bot = Bot> = TBot & {
commands: Collection<string, Command>
}
// Example of how to customize internal discordeno stuff easily.
customizeInternals(bot);
// Override the default gateway functions to allow the methods on the gateway object to proxy the requests to the gateway proxy
function overrideGatewayImplementations(bot: CustomBot): void {
bot.gateway.sendPayload = async (shardId, payload) => {
await fetch(GATEWAY_URL, {
method: 'POST',
body: JSON.stringify({
type: 'ShardPayload',
shardId,
payload,
} satisfies WorkerShardPayload),
headers: {
'Content-Type': 'application/json',
Authorization: GATEWAY_AUTHORIZATION,
},
})
}
// Setup event handlers.
setupEventHandlers();
bot.gateway.editBotStatus = async (payload) => {
await fetch(GATEWAY_URL, {
method: 'POST',
body: JSON.stringify({
type: 'EditShardsPresence',
payload,
} satisfies WorkerPresencesUpdate),
headers: {
'Content-Type': 'application/json',
Authorization: GATEWAY_AUTHORIZATION,
},
})
}
}
bot.rest = createRestManager({
token: DISCORD_TOKEN,
secretKey: REST_AUTHORIZATION,
customUrl: REST_URL,
});
export async function getShardInfoFromGuild(guildId?: bigint): Promise<Omit<ShardInfo, 'nonce'>> {
const req = await fetch(GATEWAY_URL, {
method: 'POST',
body: JSON.stringify({
type: 'ShardInfoFromGuild',
guildId: guildId?.toString(),
} as ManagerGetShardInfoFromGuildId),
headers: {
'Content-Type': 'application/json',
Authorization: GATEWAY_AUTHORIZATION,
},
})
// Add send fetching analytics hook to rest
setupAnalyticsHooks(bot.rest);
const res = await req.json()
if (req.ok) return res
throw new Error(`There was an issue getting the shard info: ${res.error}`)
}

View File

@@ -0,0 +1,85 @@
import type {
ApplicationCommandOptionTypes,
Attachment,
CamelizedDiscordApplicationCommandOption,
ChannelTypes,
CreateApplicationCommand,
Interaction,
Member,
Role,
User,
} from '@discordeno/bot'
import { bot } from './bot.js'
export default function createCommand<const TOptions extends CommandOptions>(command: Command<TOptions>): void {
bot.commands.set(command.name, command as Command)
}
export type Command<TOptions extends CommandOptions = CommandOptions> = CreateApplicationCommand & {
/** @inheritdoc */
options?: TOptions
/**
* Should this command be only deployed on the Dev guild?
*
* @default false
*/
devOnly?: boolean
/** Function to run when the interaction is executed */
run: (interaction: Interaction, options: GetCommandOptions<TOptions>) => unknown
/** Function to run when an autocomplete interaction is fired */
autoComplete?: (interaction: Interaction, options: GetCommandOptions<TOptions>) => unknown
}
export type GetCommandOptions<T extends CommandOptions> = T extends CommandOptions
? { [Prop in keyof BuildOptions<T> as Prop]: BuildOptions<T>[Prop] }
: never
export type CommandOption = CamelizedDiscordApplicationCommandOption
export type CommandOptions = CommandOption[]
// Option parsing
interface UserResolved {
user: User
member: Member | undefined
}
interface ChannelResolved {
id: bigint
name: string
type: ChannelTypes
permissions: bigint
}
type ResolvedValues = number | boolean | UserResolved | Role | ChannelResolved | Attachment
/**
* From here SubCommandGroup and SubCommand are missing, this is wanted.
*
* The entries are sorted based on the enum value
*/
interface TypeToResolvedMap {
[ApplicationCommandOptionTypes.String]: string
[ApplicationCommandOptionTypes.Integer]: number
[ApplicationCommandOptionTypes.Boolean]: boolean
[ApplicationCommandOptionTypes.User]: UserResolved
[ApplicationCommandOptionTypes.Channel]: ChannelResolved
[ApplicationCommandOptionTypes.Role]: Role
[ApplicationCommandOptionTypes.Mentionable]: Role | UserResolved
[ApplicationCommandOptionTypes.Number]: number
[ApplicationCommandOptionTypes.Attachment]: Attachment
}
type ConvertTypeToResolved<T extends ApplicationCommandOptionTypes> = T extends keyof TypeToResolvedMap ? TypeToResolvedMap[T] : ResolvedValues
type SubCommandApplicationCommand = ApplicationCommandOptionTypes.SubCommand | ApplicationCommandOptionTypes.SubCommandGroup
type GetOptionName<T> = T extends { name: string } ? T['name'] : never
type GetOptionValue<T> = T extends { type: ApplicationCommandOptionTypes; required?: boolean }
? T extends { type: SubCommandApplicationCommand; options?: CommandOptions }
? BuildOptions<T['options']>
: ConvertTypeToResolved<T['type']> | (T['required'] extends true ? never : undefined)
: never
type BuildOptions<T extends CommandOptions | undefined> = {
[Prop in keyof Omit<T, keyof unknown[]> as GetOptionName<T[Prop]>]: GetOptionValue<T[Prop]>
}

View File

@@ -1,33 +0,0 @@
import { ApplicationCommandOptionTypes } from 'discordeno';
import { prisma } from '../../prisma.js';
import languages from '../languages/languages.js';
import { serverLanguages, translate } from '../languages/translate.js';
import { createCommand } from '../utils/slash/createCommand.js';
export default createCommand({
name: 'LANGUAGE_NAME',
description: 'LANGUAGE_DESCRIPTION',
options: [
{
name: 'LANGUAGE_KEY_NAME',
description: 'LANGUAGE_KEY_DESCRIPTION',
type: ApplicationCommandOptionTypes.String,
choices: Object.keys(languages).map((key) => ({ name: key, value: key })),
required: true,
},
],
execute: async function (_, interaction, args) {
if (!interaction.guildId) return;
// Set the new language in cache
serverLanguages.set(interaction.guildId, args.name);
// Let the user know its been updated.
await interaction.reply(translate(interaction.guildId!, 'LANGUAGE_UPDATED', args.name));
// Update the db
return await prisma.guilds.upsert({
where: { id: interaction.guildId },
create: { language: args.name, id: interaction.guildId },
update: { language: args.name },
});
},
});

View File

@@ -1,9 +0,0 @@
import language from './language.js';
import ping from './ping.js';
export const COMMANDS = {
language,
ping,
};
export default COMMANDS;

View File

@@ -1,17 +1,16 @@
import { translate } from '../languages/translate.js';
import { createCommand } from '../utils/slash/createCommand.js';
import { snowflakeToTimestamp } from '@discordeno/bot'
import { getShardInfoFromGuild } from '../bot.js'
import createCommand from '../commands.js'
export default createCommand({
name: 'PING_NAME',
description: 'PING_DESCRIPTION',
execute: async function (_, interaction) {
return await interaction.reply(
translate(interaction.guildId!, 'PING_RESPONSE_WITH_TIME', Date.now() - snowflakeToTimestamp(interaction.id)),
);
createCommand({
name: 'ping',
description: '🏓 Check whether the bot is online and responsive.',
async run(interaction) {
const ping = Date.now() - snowflakeToTimestamp(interaction.id)
const shardInfo = await getShardInfoFromGuild(interaction.guildId)
const shardPing = shardInfo.rtt === -1 ? '*Not yet available*' : `${shardInfo.rtt}ms`
await interaction.respond(`🏓 Pong! Gateway Latency: ${shardPing}, Roundtrip Latency: ${ping}ms. I am online and responsive! 🕙`)
},
});
// TODO: This should be deleted once this is available in the helpers plugin.
export function snowflakeToTimestamp(id: bigint) {
return Number(id / 4194304n + 1420070400000n);
}
})

View File

@@ -1,14 +0,0 @@
import type { Interaction } from 'discordeno';
import type { BotWithCustomProps } from '../../bot.js';
export async function executeButtonClick(bot: BotWithCustomProps, interaction: Interaction) {
if (!interaction.data) return;
bot.logger.info(
`[Button] The ${interaction.data.customId} button was clicked in Guild: ${interaction.guildId} by ${interaction.user.id}.`,
);
await Promise.allSettled([
// SETUP-DD-TEMP: Insert any functions you wish to run when a user clicks a button.
]).catch(console.log);
}

View File

@@ -1,304 +0,0 @@
import { bgBlack, bgGreen, bgMagenta, bgYellow, black, green, red, white } from 'colorette';
import type {
ApplicationCommandOption,
Bot,
Channel,
ChannelTypes,
Interaction,
InteractionDataOption,
Member,
Role,
User,
} from 'discordeno';
import { ApplicationCommandOptionTypes, InteractionResponseTypes } from 'discordeno';
import type { BotWithCustomProps } from '../../bot.js';
import { bot } from '../../bot.js';
import COMMANDS from '../../commands/mod.js';
import type { translationKeys } from '../../languages/translate.js';
import { getLanguage, loadLanguage, serverLanguages, translate } from '../../languages/translate.js';
import type { InteractionWithCustomProps } from '../../typings/discordeno.js';
import type { Command, ConvertArgumentDefinitionsToArgs } from '../../utils/slash/createCommand.js';
function logCommand(
info: Interaction,
type: 'Failure' | 'Success' | 'Trigger' | 'Slowmode' | 'Missing' | 'Inhibit',
commandName: string,
) {
const command = `[COMMAND: ${bgYellow(black(commandName || 'Unknown'))} - ${bgBlack(
['Failure', 'Slowmode', 'Missing'].includes(type) ? red(type) : type === 'Success' ? green(type) : white(type),
)}]`;
const user = bgGreen(
black(`${info.user.username}#${info.user.discriminator.toString().padStart(4, '0')}(${info.id})`),
);
const guild = bgMagenta(black(`${info.guildId ? `Guild ID: (${info.guildId})` : 'DM'}`));
bot.logger.info(`${command} by ${user} in ${guild} with MessageID: ${info.id}`);
}
export async function executeSlashCommand(bot: BotWithCustomProps, interaction: InteractionWithCustomProps) {
const data = interaction.data;
const name = data?.name as keyof typeof COMMANDS;
const command: Command<any> | undefined = COMMANDS[name];
// Command could not be found
if (!command?.execute) {
return await interaction
.reply(translate(interaction.guildId!, 'EXECUTE_COMMAND_NOT_FOUND'))
.catch(bot.logger.error);
}
// HAVE TO CONVERT OUTSIDE OF TRY SO IT CAN BE USED IN CATCH TOO
try {
logCommand(interaction, 'Trigger', name);
// Check subcommand permissions and options
if (!(await commandAllowed(interaction, command))) return;
// Load the language for this guild
if (interaction.guildId && !serverLanguages.has(interaction.guildId)) {
// Todo: make command.execute reply change to editReply after running this
// await interaction.reply({
// type: InteractionResponseTypes.DeferredChannelMessageWithSource,
// });
await loadLanguage(interaction.guildId);
} // Load the language for this guild
else if (command.acknowledge) {
// Acknowledge the command
await interaction.reply({
type: InteractionResponseTypes.DeferredChannelMessageWithSource,
});
}
// FIRST GET THE TRANSLATIONS FOR ALL OPTIONS
const translatedOptionNames =
interaction.guildId && command.options ? translateOptionNames(bot, interaction.guildId, command.options) : {};
// PARSE THE OPTIONS TO A NICE OBJECT AND TRANSLATE THE KEYS TO ENGLISH
const parsedArguments = optionParser(interaction, translatedOptionNames);
await command.execute(bot, interaction, parsedArguments as ConvertArgumentDefinitionsToArgs<any>);
logCommand(interaction, 'Success', name);
} catch (error) {
console.error(error);
logCommand(interaction, 'Failure', name);
try {
console.log('try');
// try to reply the interaction, becuase we don't know if it replied or deffered
return await interaction.reply(translate(interaction.id, 'EXECUTE_COMMAND_ERROR'));
} catch {
console.log('catch');
// edit the reply or deffered reply of interaction
return await interaction.editReply(translate(interaction.id, 'EXECUTE_COMMAND_ERROR')).catch(bot.logger.error);
}
}
}
/** Runs the inhibitors to see if a command is allowed to run. */
export async function commandAllowed(interaction: InteractionWithCustomProps, command: Command<any>) {
// CHECK WHETHER THE USER/GUILD IS VIP
if (command.vipOnly) {
// SETUP-DD-TEMP: Check if this server/user is a vip.
const isVIP = true;
if (!isVIP) {
await interaction.reply(translate(interaction.id, 'NEED_VIP')).catch(bot.logger.error);
return false;
}
}
return true;
}
// Mapped by `language-commandName`
const translatedOptionNamesCache = new Map<string, Record<string, string>>();
/** Translates all options of the command to an object: translatedOptionName: optionName */
export function translateOptionNames(
bot: Bot,
guildId: bigint,
options: ApplicationCommandOption[],
commandName?: string,
): Record<string, string> {
const language = getLanguage(guildId);
// RETURN THE ALREADY TRANSLATED OPTIONS WHICH ARE IN CACHE
if (commandName && translatedOptionNamesCache.has(`${language}-${commandName}`)) {
return translatedOptionNamesCache.get(`${language}-${commandName}`)!;
}
// TRANSLATE ALL OPTIONS
let translated: Record<string, string> = {};
for (const option of options) {
translated[translate(guildId, option.name as translationKeys).toLowerCase()] = translate(
'english',
option.name as translationKeys,
);
if (option.options) {
translated = {
...translated,
...translateOptionNames(bot, guildId, option.options),
};
}
}
// SAVE THE TRANSLATED OPTIONS IN CACHE FOR FASTER ACCESS
if (commandName) {
translatedOptionNamesCache.set(`${language}-${commandName}`, translated);
}
return translated;
}
function convertOptionValue(
interaction: Interaction,
option: InteractionDataOption,
translateOptions?: Record<string, string>,
): [
string,
(
| { user: User; member: Member }
| Role
| {
id: bigint;
name: string;
type: ChannelTypes;
permissions: bigint;
}
| boolean
| string
| number
),
] {
// THE OPTION IS A CHANNEL
if (option.type === ApplicationCommandOptionTypes.Channel) {
const channel = interaction.data?.resolved?.channels?.get(BigInt(option.value as string));
// SAVE THE ARGUMENT WITH THE CORRECT NAME
return [translateOptions?.[option.name] ?? option.name, channel];
}
// THE OPTION IS A ROLE
if (option.type === ApplicationCommandOptionTypes.Role) {
const role = interaction.data?.resolved?.roles?.get(BigInt(option.value as string));
// SAVE THE ARGUMENT WITH THE CORRECT NAME
return [translateOptions?.[option.name] ?? option.name, role];
}
// THE OPTION IS A USER
if (option.type === ApplicationCommandOptionTypes.User) {
const user = interaction.data?.resolved?.users?.get(BigInt(option.value as string));
const member = interaction.data?.resolved?.members?.get(BigInt(option.value as string));
// SAVE THE ARGUMENT WITH THE CORRECT NAME
return [
translateOptions?.[option.name] ?? option.name,
{
member,
user,
},
];
}
// THE OPTION IS A MENTIONABLE
if (option.type === ApplicationCommandOptionTypes.Mentionable) {
const role = interaction.data?.resolved?.roles?.get(BigInt(option.value as string));
const user = interaction.data?.resolved?.users?.get(BigInt(option.value as string));
const member = interaction.data?.resolved?.members?.get(BigInt(option.value as string));
const final = user && member ? { user, member } : role;
// SAVE THE ARGUMENT WITH THE CORRECT NAME
return [translateOptions?.[option.name] ?? option.name, final];
}
// THE REST OF OPTIONS DON'T NEED ANY CONVERTION
// SAVE THE ARGUMENT WITH THE CORRECT NAME
// @ts-expect-error
return [translateOptions?.[option.name] ?? option.name, option.value];
}
/** Parse the options to a nice object.
* NOTE: this does not work with subcommands
*/
export function optionParser(
interaction: Interaction,
translateOptions?: Record<string, string>,
):
| InteractionCommandArgs
| { [key: string]: InteractionCommandArgs }
| { [key: string]: { [key: string]: InteractionCommandArgs } } {
// OPTIONS CAN BE UNDEFINED SO WE JUST RETURN AN EMPTY OBJECT
if (!interaction.data?.options) return {};
// A SUBCOMMAND WAS USED
if (interaction.data.options[0]?.type === ApplicationCommandOptionTypes.SubCommand) {
const convertedOptions: Record<
string,
| { user: User; member: Member }
| Role
| {
id: bigint;
name: string;
type: ChannelTypes;
permissions: bigint;
}
| boolean
| string
| number
> = {};
// CONVERT ALL THE OPTIONS
for (const option of interaction.data.options[0].options ?? []) {
const [name, value] = convertOptionValue(interaction, option, translateOptions);
convertedOptions[name] = value;
}
// @ts-expect-error
return {
[translateOptions?.[interaction.data.options[0].name] ?? interaction.data.options[0].name]: convertedOptions,
};
}
// A SUBCOMMAND GROUP WAS USED
if (interaction.data.options[0]?.type === ApplicationCommandOptionTypes.SubCommandGroup) {
const convertedOptions: Record<string, Member | Role | Channel | boolean | string | number> = {};
// CONVERT ALL THE OPTIONS
for (const option of interaction.data.options[0]?.options![0]?.options ?? []) {
const [name, value] = convertOptionValue(interaction, option, translateOptions);
// @ts-expect-error
convertedOptions[name] = value;
}
// @ts-expect-error
return {
[translateOptions?.[interaction.data.options[0].name] ?? interaction.data.options[0].name]: {
[translateOptions?.[interaction.data.options[0]!.options![0]!.name] ??
interaction.data.options[0]!.options![0]!.name]: convertedOptions,
},
};
}
// A NORMAL COMMAND WAS USED
const convertedOptions: Record<
string,
Member | Role | Record<string, Pick<Channel, 'id' | 'name' | 'type' | 'permissions'>> | boolean | string | number
> = {};
for (const option of interaction.data.options ?? []) {
const [name, value] = convertOptionValue(interaction, option, translateOptions);
// @ts-expect-error
convertedOptions[name] = value;
}
return convertedOptions;
}
/** The interaction arguments.
* Important the members `deaf` and `mute` properties will always be false.
*/
export type InteractionCommandArgs = Record<
string,
Member | Role | Record<string, Pick<Channel, 'id' | 'name' | 'type' | 'permissions'>> | boolean | string | number
>;

View File

@@ -0,0 +1,53 @@
import { InteractionTypes, LogLevels, commandOptionsParser, type Interaction, type logger } from '@discordeno/bot'
import chalk from 'chalk'
import { bot } from '../../bot.js'
bot.events.interactionCreate = async (interaction) => {
const isAutocomplete = interaction.type === InteractionTypes.ApplicationCommandAutocomplete
const isCommandOrAutocomplete = interaction.type === InteractionTypes.ApplicationCommand || isAutocomplete
if (!interaction.data || !isCommandOrAutocomplete) return
const command = bot.commands.get(interaction.data.name)
if (!command) {
logCommand(interaction, 'Missing', interaction.data.name)
await interaction.respond('❌ Something went wrong. I was not able to find this command.')
return
}
logCommand(interaction, 'Trigger', interaction.data.name)
const options = commandOptionsParser(interaction)
try {
if (isAutocomplete) {
await command.autoComplete?.(interaction, options)
} else {
await command.run(interaction, options)
}
logCommand(interaction, 'Success', interaction.data.name)
} catch (error) {
logCommand(interaction, 'Failure', interaction.data.name, LogLevels.Error, error)
await interaction.respond('❌ Something went wrong. The command execution has thrown an error.')
}
}
function logCommand(
interaction: Interaction,
type: 'Failure' | 'Success' | 'Trigger' | 'Missing',
commandName: string,
logLevel: LogLevels = LogLevels.Info,
...restArgs: unknown[]
): void {
const typeColor = ['Failure', 'Missing'].includes(type) ? chalk.red(type) : type === 'Success' ? chalk.green(type) : chalk.white(type)
const autocomplete = interaction.type === InteractionTypes.ApplicationCommandAutocomplete ? ' (AutoComplete) ' : ''
const command = `Command${autocomplete}: ${chalk.bgYellow.black(commandName || 'Unknown')} - ${chalk.bgBlack(typeColor)}`
const user = chalk.bgGreen.black(`@${interaction.user.username} (${interaction.user.id})`)
const guild = chalk.bgMagenta.black(interaction.guildId ? `guildId: ${interaction.guildId}` : 'DM')
;(bot.logger as typeof logger).log(logLevel, `${command} - By ${user} in ${guild}`, ...restArgs)
}

View File

@@ -1,23 +0,0 @@
import { InteractionTypes, MessageComponentTypes } from 'discordeno';
import { bot } from '../../bot.js';
import type { InteractionWithCustomProps } from '../../typings/discordeno.js';
import { executeButtonClick } from './button.js';
import { executeSlashCommand } from './command.js';
import { executeModalSubmit } from './modal.js';
export function setInteractionCreateEvent() {
bot.events.interactionCreate = async function (_, interaction) {
if (interaction.type === InteractionTypes.ApplicationCommand) {
await executeSlashCommand(bot, interaction as InteractionWithCustomProps);
} else if (interaction.type === InteractionTypes.MessageComponent) {
if (!interaction.data) return;
// THE INTERACTION CAME FROM A BUTTON
if (interaction.data.componentType === MessageComponentTypes.Button) {
await executeButtonClick(bot, interaction);
}
} else if (interaction.type === InteractionTypes.ModalSubmit) {
await executeModalSubmit(bot, interaction);
}
};
}

View File

@@ -1,16 +0,0 @@
import type { Interaction } from 'discordeno';
import type { BotWithCustomProps } from '../../bot.js';
export async function executeModalSubmit(bot: BotWithCustomProps, interaction: Interaction) {
if (!interaction.data) return;
bot.logger.info(
`[Modal] The ${interaction.data?.customId || 'UNKNWON'} modal was submitted in Guild: ${interaction.guildId} by ${
interaction.user.id
}.`,
);
await Promise.allSettled([
// SETUP-DD-TEMP: Insert any functions you wish to run when a user clicks a button.
]).catch(console.log);
}

View File

@@ -1,12 +0,0 @@
import { bot } from '../../bot.js';
import { processMessageCollectors } from '../../utils/collectors.js';
export function setMessageCreateEvent() {
bot.events.messageCreate = async function (_, message) {
processMessageCollectors(message);
await Promise.allSettled([
// SETUP-DD-TEMP: Add any functions you want to run on every message here. For example, automoderation filters.
]).catch(console.log);
};
}

View File

@@ -1,9 +0,0 @@
import { setInteractionCreateEvent } from './interactions/mod.js';
import { setMessageCreateEvent } from './messages/create.js';
import { setRawEvent } from './raw.js';
export function setupEventHandlers() {
setInteractionCreateEvent();
setRawEvent();
setMessageCreateEvent();
}

View File

@@ -0,0 +1,21 @@
import { createEmbeds } from '@discordeno/bot'
import { inspect } from 'node:util'
import { BUGS_ERRORS_REPORT_WEBHOOK } from '../../config.js'
import { bot } from '../bot.js'
import { webhookURLToIDAndToken } from '../utils/webhook.js'
process.on('unhandledRejection', async (error) => {
bot.logger.error('An unhandled rejection occurred', error)
if (!BUGS_ERRORS_REPORT_WEBHOOK || !error) return
const { id, token } = webhookURLToIDAndToken(BUGS_ERRORS_REPORT_WEBHOOK)
if (!id || !token) return
const inspectedError = inspect(error)
const embeds = createEmbeds().setDescription(`\`\`\`${inspectedError}\`\`\``).setFooter('Unhandled rejection occurred').setTimestamp(Date.now())
await bot.helpers.executeWebhook(id, token, { embeds })
})

View File

@@ -1,38 +0,0 @@
import type { DiscordUnavailableGuild } from 'discordeno';
import { prisma } from '../../prisma.js';
import { bot } from '../bot.js';
import { updateGuildCommands, usesLatestCommandVersion } from '../utils/slash/updateCommands.js';
/** To prevent updating every guild when a shard goes ready we have to ignore them using this */
// export const initialyLoadingGuildIds = new Set<bigint>()
export function setRawEvent() {
bot.events.raw = async function (_, data) {
if (data.t === 'GUILD_DELETE') {
const id = (data.d as DiscordUnavailableGuild).id;
return await prisma.commands.delete({ where: { id: bot.transformers.snowflake(id) } });
}
const id = bot.transformers.snowflake(
(data.t && ['GUILD_UPDATE', 'GUILD_CREATE'].includes(data.t)
? // deno-lint-ignore no-explicit-any
data.d?.id
: // deno-lint-ignore no-explicit-any
data.d?.guild_id) ?? '',
);
// The GUILD_CREATE event came from a shard loaded event so ignore it
if (['READY', 'GUILD_LOADED_DD', null].includes(data.t)) return;
// console.log({ id, v: await usesLatestCommandVersion(id) })
if (!id || (await usesLatestCommandVersion(id))) return;
// dev guild
if (id === 547046977578336286n) return;
// NEW GUILD AVAILABLE
bot.logger.info(`[Slash Setup] Installing Slash commands on Guild ${id} event type: ${data.t}`);
await updateGuildCommands(bot, id).catch(bot.logger.error);
};
}

View File

@@ -0,0 +1,17 @@
import fastify, { type FastifyInstance } from 'fastify'
import { EVENT_HANDLER_AUTHORIZATION } from '../config.js'
export function buildFastifyApp(): FastifyInstance {
const app = fastify()
// Authorization check
app.addHook('onRequest', async (req, res) => {
if (req.headers.authorization !== EVENT_HANDLER_AUTHORIZATION) {
res.status(401).send({
message: 'Credentials not valid.',
})
}
})
return app
}

View File

@@ -1,185 +1,123 @@
import dotenv from 'dotenv';
import type { DiscordGatewayPayload, GatewayDispatchEventNames } from '@discordeno/bot'
import { connect as connectAmqp } from 'amqplib'
import { join as joinPath } from 'node:path'
import {
EVENT_HANDLER_HOST,
EVENT_HANDLER_PORT,
MESSAGEQUEUE_ENABLE,
MESSAGEQUEUE_PASSWORD,
MESSAGEQUEUE_URL,
MESSAGEQUEUE_USERNAME,
} from '../config.js'
import { getDirnameFromFileUrl } from '../util.js'
import { bot } from './bot.js'
import { buildFastifyApp } from './fastify.js'
import importDirectory from './utils/loader.js'
import type { DiscordGatewayPayload } from 'discordeno';
// ReferenceError: publishMessage is not defined
// import Embeds from "discordeno/embeds";
import amqplib from 'amqplib';
import express from 'express';
import { BOT_ID, EVENT_HANDLER_URL } from '../configs.js';
import { bot } from './bot.js';
import { updateDevCommands } from './utils/slash/updateCommands.js';
import { webhookURLToIDAndToken } from './utils/webhook.js';
dotenv.config();
// The importDirectory function uses 'readdir' that requires either a relative path compared to the process CWD or an absolute one, so to get one relative we need to use import.meta.url
const currentDirectory = getDirnameFromFileUrl(import.meta.url)
const BUGS_ERRORS_REPORT_WEBHOOK = process.env.BUGS_ERRORS_REPORT_WEBHOOK;
const EVENT_HANDLER_AUTHORIZATION = process.env.EVENT_HANDLER_AUTHORIZATION as string;
const EVENT_HANDLER_PORT = process.env.EVENT_HANDLER_PORT as string;
await importDirectory(joinPath(currentDirectory, './commands'))
await importDirectory(joinPath(currentDirectory, './events'))
process
.on('unhandledRejection', (error) => {
if (!BUGS_ERRORS_REPORT_WEBHOOK) return;
const { id, token } = webhookURLToIDAndToken(BUGS_ERRORS_REPORT_WEBHOOK);
if (!id || !token) return;
// DO NOT SEND ERRORS FROM NON PRODUCTION
if (BOT_ID !== 270010330782892032n) {
return console.error(error);
if (MESSAGEQUEUE_ENABLE) {
await connectToRabbitMQ()
}
// An unhandled error occurred on the bot in production
console.error(error ?? `An unhandled rejection error occurred but error was null or undefined`);
const app = buildFastifyApp()
if (!error) return;
// ReferenceError: publishMessage is not defined
/*
const embeds = new Embeds()
.setDescription(["```js", error, "```"].join(`\n`))
.setTimestamp()
.setFooter("Unhandled Rejection Error Occurred");
// SEND ERROR TO THE LOG CHANNEL ON THE DEV SERVER
return bot.helpers.sendWebhookMessage(bot.transformers.snowflake(id), token, { embeds }).catch(console.error);
*/
app.get('/timecheck', async (_req, res) => {
res.status(200).send({ message: Date.now() })
})
.on('uncaughtException', async (error) => {
if (!BUGS_ERRORS_REPORT_WEBHOOK) return;
const { id, token } = webhookURLToIDAndToken(BUGS_ERRORS_REPORT_WEBHOOK);
if (!id || !token) return;
// DO NOT SEND ERRORS FROM NON PRODUCTION
if (BOT_ID !== 270010330782892032n) {
return console.error(error);
}
// An unhandled error occurred on the bot in production
console.error(error ?? `An unhandled exception occurred but error was null or undefined`);
if (!error) process.exit(1);
/*
const embeds = new Embeds()
.setDescription(["```js", error.stack, "```"].join(`\n`))
.setTimestamp()
.setFooter("Unhandled Exception Error Occurred");
// SEND ERROR TO THE LOG CHANNEL ON THE DEV SERVER
await bot.helpers.sendWebhookMessage(bot.transformers.snowflake(id), token, { embeds }).catch(console.error);
*/
process.exit(1);
});
if (process.env.DEVELOPMENT === 'true') {
bot.logger.info(`[DEV MODE] Updating slash commands for dev server.`);
updateDevCommands(bot);
}
// Handle events from the gateway
const handleEvent = async (message: DiscordGatewayPayload, shardId: number) => {
// EMITS RAW EVENT
bot.events.raw(bot, message, shardId);
if (message.t && message.t !== 'RESUMED') {
// When a guild or something isnt in cache this will fetch it before doing anything else
if (!['READY', 'GUILD_LOADED_DD'].includes(message.t)) {
await bot.events.dispatchRequirements(bot, message, shardId);
}
bot.handlers[message.t]?.(bot, message, shardId);
}
};
const app = express();
app.use(
express.urlencoded({
extended: true,
}),
);
app.use(express.json());
app.all('/', async (req, res) => {
try {
if (!EVENT_HANDLER_AUTHORIZATION || EVENT_HANDLER_AUTHORIZATION !== req.headers.authorization) {
return res.status(401).json({ error: 'Invalid authorization key.' });
}
const json = req.body as {
message: DiscordGatewayPayload;
shardId: number;
};
await handleEvent(json.message, json.shardId);
res.status(200).json({ success: true });
} catch (error: any) {
bot.logger.error(error);
res.status(error.code).json(error);
}
});
app.listen(EVENT_HANDLER_PORT, () => {
console.log(`Bot is listening at ${EVENT_HANDLER_URL};`);
});
const connectRabbitmq = async () => {
let connection: amqplib.Connection | undefined;
app.post('/', async (req, res) => {
const body = req.body as GatewayEvent
try {
connection = await amqplib.connect(
`amqp://${process.env.MESSAGEQUEUE_USERNAME}:${process.env.MESSAGEQUEUE_PASSWORD}@${process.env.MESSAGEQUEUE_URL}`,
);
handleGatewayEvent(body.payload, body.shardId)
res.status(200).send()
} catch (error) {
console.error(error);
setTimeout(connectRabbitmq, 1000);
bot.logger.error('There was an error handling the incoming gateway command', error)
res.status(500).send()
}
})
await app.listen({
host: EVENT_HANDLER_HOST,
port: EVENT_HANDLER_PORT,
})
bot.logger.info(`Bot event handler is listening on port ${EVENT_HANDLER_PORT}`)
async function handleGatewayEvent(payload: DiscordGatewayPayload, shardId: number): Promise<void> {
bot.events.raw?.(payload, shardId)
// If we don't have the event type we don't process it further
if (!payload.t) return
// Run the dispatch check
await bot.events.dispatchRequirements?.(payload, shardId)
bot.handlers[payload.t as GatewayDispatchEventNames]?.(bot, payload, shardId)
}
if (!connection) return;
connection.on('error', (err) => {
console.error(err);
setTimeout(connectRabbitmq, 1000);
});
async function connectToRabbitMQ(): Promise<void> {
const connection = await connectAmqp(`amqp://${MESSAGEQUEUE_USERNAME}:${MESSAGEQUEUE_PASSWORD}@${MESSAGEQUEUE_URL}`).catch((error) => {
bot.logger.error('Failed to connect to RabbitMQ, retrying in 1s.', error)
setTimeout(connectToRabbitMQ, 1000)
})
if (!connection) return
connection.on('close', () => {
setTimeout(connectRabbitmq, 1000);
});
setTimeout(connectToRabbitMQ, 1000)
})
connection.on('error', (error) => {
bot.logger.error('There was an error in the connection with RabbitMQ, reconnecting in 1s.', error)
setTimeout(connectToRabbitMQ, 1000)
})
try {
const channel = await connection.createChannel();
const channel = await connection.createChannel().catch((error) => {
bot.logger.error('There was an error creating the RabbitMQ channel', error)
})
await channel.assertExchange('gatewayMessage', 'x-message-deduplication', {
if (!channel) return
const exchange = await channel
.assertExchange('gatewayMessage', 'x-message-deduplication', {
durable: true,
arguments: {
'x-cache-size': 1000,
'x-cache-ttl': 500,
'x-cache-size': 1000, // maximum number of entries
'x-cache-ttl': 500, // 500ms
},
});
})
.catch((error) => {
bot.logger.error('There was an error asserting the exchange', error)
})
await channel.assertQueue('gatewayMessageQueue');
await channel.bindQueue('gatewayMessageQueue', 'gatewayMessage', '');
await channel.consume(
'gatewayMessageQueue',
async (msg) => {
if (!msg) return;
const json = JSON.parse(msg.content.toString()) as {
message: DiscordGatewayPayload;
shardId: number;
};
if (!exchange) return
await handleEvent(json.message, json.shardId);
await channel.assertQueue('gatewayMessageQueue').catch(bot.logger.error)
await channel.bindQueue('gatewayMessageQueue', 'gatewayMessage', '').catch(bot.logger.error)
await channel
.consume('gatewayMessageQueue', async (message) => {
if (!message) return
await channel.ack(msg);
},
{
noAck: false,
},
);
try {
const messageBody = JSON.parse(message.content.toString()) as GatewayEvent
await handleGatewayEvent(messageBody.payload, messageBody.shardId)
channel.ack(message)
} catch (error) {
console.error(error);
bot.logger.error('There was an error handling events received from RabbitMQ', error)
}
})
.catch(bot.logger.error)
}
};
if (process.env.MESSAGEQUEUE_ENABLE === 'true') {
connectRabbitmq();
interface GatewayEvent {
payload: DiscordGatewayPayload
shardId: number
}

View File

@@ -1,23 +0,0 @@
const english = {
// Permissions
NEED_VIP: '❌ Only VIP users or servers can use this feature.',
// Execute Command
EXECUTE_COMMAND_NOT_FOUND: '❌ Something went wrong. I was not able to find this command.',
EXECUTE_COMMAND_ERROR: '❌ Something went wrong. The command execution has thrown an error.',
// Language Command
LANGUAGE_NAME: 'language',
LANGUAGE_DESCRIPTION: '⚙️ Change the bots language.',
LANGUAGE_KEY_NAME: 'name',
LANGUAGE_KEY_DESCRIPTION: 'What language would you like to set?',
LANGUAGE_UPDATED: (language: string) => `The language has been updated to ${language}`,
// Ping Command
PING_NAME: 'ping',
PING_DESCRIPTION: '🏓 Check whether the bot is online and responsive.',
PING_RESPONSE: '🏓 Pong! I am online and responsive! :clock10:',
PING_RESPONSE_WITH_TIME: (time: number) => `🏓 Pong! ${time / 1000} seconds! I am online and responsive! :clock10:`,
} as const;
export default english;

View File

@@ -1,20 +0,0 @@
import english from './english.js';
// import french from './french'
// import german from './german'
// import portuguese from './portuguese'
// import russian from './russian'
// import spanish from './spanish'
const languages: Record<LanguageNames, Language> & Record<string, Language> = {
english,
// french,
// german,
// portuguese,
// russian,
// spanish,
};
export default languages;
export type Language = Record<string, string | string[] | ((...args: any[]) => string)>;
export type LanguageNames = 'english';

View File

@@ -1,84 +0,0 @@
import Embeds from 'discordeno/embeds';
import { bot } from '../bot.js';
import { webhookURLToIDAndToken } from '../utils/webhook.js';
import type english from './english.js';
import languages from './languages.js';
const MISSING_TRANSLATION_WEBHOOK = process.env.MISSING_TRANSLATION_WEBHOOK;
/** This should hold the language names per guild id. <guildId, language> */
export const serverLanguages = new Map<bigint, keyof typeof languages>();
export function translate<K extends translationKeys>(
guildIdOrLanguage: bigint | keyof typeof languages,
key: K,
...params: getArgs<K>
): string {
const language = getLanguage(guildIdOrLanguage);
let value: string | ((...any: any[]) => string) | string[] | undefined = languages[language]?.[key];
// Was not able to be translated
if (!value) {
// Check if this key is available in english
if (language !== 'english') {
value = languages.english[key];
}
// Still not found in english so default to using the KEY_ITSELF
if (!value) value = key;
// Send a log webhook so the devs know sth is missing
missingTranslation(language, key);
}
if (Array.isArray(value)) return value.join('\n');
if (typeof value === 'function') return value(...(params || []));
return value;
}
/** Get the language this guild has set, will always return "english" if it is not in cache */
export function getLanguage(guildIdOrLanguage: bigint | keyof typeof languages) {
return typeof guildIdOrLanguage === 'string'
? guildIdOrLanguage
: serverLanguages.get(guildIdOrLanguage) ?? 'english';
}
export async function loadLanguage(guildId: bigint) {
// TODO: add this settings
// const settings = await database.findOne('guilds', guildId)
const settings = { language: 'undefined' };
if (settings?.language && languages[settings.language]) {
serverLanguages.set(guildId, settings.language);
} else serverLanguages.set(guildId, 'english');
}
/** Send a webhook for a missing translation key */
export async function missingTranslation(language: keyof typeof languages, key: string) {
if (!MISSING_TRANSLATION_WEBHOOK) return;
const { id, token } = webhookURLToIDAndToken(MISSING_TRANSLATION_WEBHOOK);
if (!id || !token) return;
const embeds = new Embeds()
.setTitle('Missing Translation')
.setColor('RANDOM')
.addField('Language', language, true)
.addField('Key', key, true);
await bot.helpers
.sendWebhookMessage(bot.transformers.snowflake(id), token, {
// SETUP-DD-TEMP: If you wish to make it @ mention you, please edit the next line.
// content: `<@${owner id here}>`,
embeds,
wait: false,
})
.catch(bot.logger.error);
}
// type translationKeys = keyof typeof english | string
export type translationKeys = keyof typeof english;
type getArgs<K extends translationKeys> = (typeof english)[K] extends (...any: any[]) => unknown
? Parameters<(typeof english)[K]>
: [];

View File

@@ -0,0 +1,5 @@
import 'dotenv/config'
import { updateCommands } from './utils/updateCommands.js'
await updateCommands()

View File

@@ -1,12 +0,0 @@
// This file allows you to tell typescript about any additions you have made to the internal discordeno objects.
import type { Interaction, InteractionCallbackData, InteractionResponse, Message } from 'discordeno';
export interface InteractionWithCustomProps extends Interaction {
// Normally, to send a response you would have to do something like bot.helpers.sendInteractionResponse(interaction.id, interaction.token, { type: InteractionResponseTypes.ChannelMessageWithSource, data: { content: "text here" } })
// But with this reply method we added, it is as simple as interaction.reply("text here").
// Feel free to delete these comments once you have understood the concept.
/** Send a reply to an interaction. */
reply: (response: InteractionResponse | string) => Promise<Message | undefined>;
/** Edit a deferred reply of an interaction. */
editReply: (response: InteractionCallbackData | string) => Promise<Message | undefined>;
}

View File

@@ -1,151 +0,0 @@
import type { Interaction, Member, Message } from 'discordeno';
import { bot } from '../bot.js';
export async function needMessage(
memberId: bigint,
channelId: bigint,
options: MessageCollectorOptions & { amount?: 1 },
): Promise<Message>;
export async function needMessage(
memberId: bigint,
channelId: bigint,
options: MessageCollectorOptions & { amount?: number },
): Promise<Message[]>;
export async function needMessage(memberId: bigint, channelId: bigint): Promise<Message>;
export async function needMessage(memberId: bigint, channelId: bigint, options?: MessageCollectorOptions) {
const messages = await collectMessages({
key: memberId,
channelId,
createdAt: Date.now(),
filter: options?.filter || ((msg) => memberId === msg.authorId),
amount: options?.amount || 1,
duration: options?.duration || 1000 * 60 * 5,
});
return (options?.amount || 1) > 1 ? messages : messages[0];
}
export async function collectMessages(options: CollectMessagesOptions): Promise<Message[]> {
return await new Promise((resolve, reject) => {
bot.collectors.messages
.get(options.key)
?.reject('A new collector began before the user responded to the previous one.');
bot.collectors.messages.set(options.key, {
...options,
messages: [],
resolve,
reject,
});
});
}
export function processMessageCollectors(message: Message) {
// IGNORE DMS
if (!message.guildId) return;
const collector = bot.collectors.messages.get(message.authorId);
// This user has no collectors pending or the message is in a different channel
if (!collector || message.channelId !== collector.channelId) return;
// This message is a response to a collector. Now running the filter function.
if (!collector.filter(message)) return;
// If the necessary amount has been collected
if (collector.amount === 1 || collector.amount === collector.messages.length + 1) {
// Remove the collector
bot.collectors.messages.delete(message.authorId);
// Resolve the collector
return collector.resolve([...collector.messages, message]);
}
// More messages still need to be collected
collector.messages.push(message);
}
export interface BaseCollectorOptions {
/** The amount of messages to collect before resolving. Defaults to 1 */
amount?: number;
/** The amount of milliseconds this should collect for before expiring. Defaults to 5 minutes. */
duration?: number;
}
export interface MessageCollectorOptions extends BaseCollectorOptions {
/** Function that will filter messages to determine whether to collect this message. Defaults to making sure the message is sent by the same member. */
filter?: (message: Message) => boolean;
/** The amount of messages to collect before resolving. Defaults to 1 */
amount?: number;
/** The amount of milliseconds this should collect for before expiring. Defaults to 5 minutes. */
duration?: number;
}
export interface ReactionCollectorOptions extends BaseCollectorOptions {
/** Function that will filter messages to determine whether to collect this message. Defaults to making sure the message is sent by the same member. */
filter?: (userId: bigint, reaction: string, message: Message | { id: string }) => boolean;
}
export interface BaseCollectorCreateOptions {
/** The unique key that will be used to get responses for this. Ideally, meant to be for member id. */
key: bigint;
/** The amount of messages to collect before resolving. */
amount: number;
/** The timestamp when this collector was created */
createdAt: number;
/** The duration in milliseconds how long this collector should last. */
duration: number;
}
export interface CollectMessagesOptions extends BaseCollectorCreateOptions {
/** The channel Id where this is listening to */
channelId: bigint;
/** Function that will filter messages to determine whether to collect this message */
filter: (message: Message) => boolean;
}
export interface CollectReactionsOptions extends BaseCollectorCreateOptions {
/** The message Id where this is listening to */
messageId: bigint;
/** Function that will filter messages to determine whether to collect this message */
filter: (userId: bigint, reaction: string, message: Message | { id: string }) => boolean;
}
export interface MessageCollector extends CollectMessagesOptions {
resolve: (value: Message[] | PromiseLike<Message[]>) => void;
// deno-lint-ignore no-explicit-any
reject: (reason?: any) => void;
/** Where the messages are stored if the amount to collect is more than 1. */
messages: Message[];
}
export interface ReactionCollector extends CollectReactionsOptions {
resolve: (value: string[] | PromiseLike<string[]>) => void;
// deno-lint-ignore no-explicit-any
reject: (reason?: any) => void;
/** Where the reactions are stored if the amount to collect is more than 1. */
reactions: string[];
}
export interface CollectButtonOptions extends BaseCollectorCreateOptions {
/** The message Id where this is listening to */
messageId: bigint;
/** Function that will filter messages to determine whether to collect this message */
filter: (message: Message, member?: Member) => boolean;
}
export interface ButtonCollector extends CollectButtonOptions {
resolve: (value: ButtonCollectorReturn[] | PromiseLike<ButtonCollectorReturn[]>) => void;
// deno-lint-ignore no-explicit-any
reject: (reason?: any) => void;
/** Where the buttons are stored if the amount to collect is more than 1. */
buttons: ButtonCollectorReturn[];
}
export interface ButtonCollectorOptions extends BaseCollectorOptions {
/** Function that will filter messages to determine whether to collect this message. Defaults to making sure the message is sent by the same member. */
filter?: (message: Message, member?: Member) => boolean;
}
export interface ButtonCollectorReturn {
customId: string;
interaction: Omit<Interaction, 'member'>;
member?: Member;
}

View File

@@ -1,6 +0,0 @@
import type { BotWithCustomProps } from '../../bot.js';
import { customizeTransformers } from './transformers/mod.js';
export function customizeInternals(bot: BotWithCustomProps) {
customizeTransformers(bot);
}

View File

@@ -1,29 +0,0 @@
// SETUP-DD-TEMP: This file is an example, of how to customize an object properties that you do not want.
// Only keep the properties your bot uses. If your bot does not use emojis in cache, you can save all that memory.
// This file is currently disabled, but you can enable it should you choose when you go the customizer file.
// Feel free to delete this comment or file as you wish.
import type { Guild } from 'discordeno';
import { Collection } from 'discordeno';
import type { BotWithCustomProps } from '../../../bot.js';
export function customizeGuildTransformer(bot: BotWithCustomProps) {
bot.transformers.guild = function (bot, payload) {
const guildId = bot.transformers.snowflake(payload.guild.id);
return {
name: payload.guild.name,
joinedAt: payload.guild.joined_at ? Date.parse(payload.guild.joined_at) : undefined,
memberCount: payload.guild.member_count ?? 0,
shardId: payload.shardId,
icon: payload.guild.icon ? bot.utils.iconHashToBigInt(payload.guild.icon) : undefined,
roles: new Collection(
payload.guild.roles?.map((role) => {
const result = bot.transformers.role(bot, { role, guildId });
return [result.id, result];
}),
),
id: guildId,
ownerId: bot.transformers.snowflake(payload.guild.owner_id),
} as unknown as Guild;
};
}

View File

@@ -1,40 +0,0 @@
// SETUP-DD-TEMP: This file serves as an example, of how to customize internal discordeno objects. Feel free to use, add more or remove as desired.
import type { InteractionCallbackData, InteractionResponse } from 'discordeno';
import { InteractionResponseTypes } from 'discordeno';
import type { BotWithCustomProps } from '../../../bot.js';
export function customizeInteractionTransformer(bot: BotWithCustomProps) {
// Store the internal transformer function
const oldInteraction = bot.transformers.interaction;
// Overwrite the internal function.
bot.transformers.interaction = function (_, payload) {
// Run the old function to get the internal value.
const interaction = oldInteraction(bot, payload);
// Add anything to this object. In this case we add a Interaction.reply() method.
Object.defineProperty(interaction, 'reply', {
value: function (response: InteractionResponse | string) {
if (typeof response === 'string') {
response = { type: InteractionResponseTypes.ChannelMessageWithSource, data: { content: response } };
}
return bot.helpers.sendInteractionResponse(interaction.id, interaction.token, response);
},
});
Object.defineProperty(interaction, 'editReply', {
value: function (response: InteractionCallbackData | string) {
if (typeof response === 'string') {
response = { content: response };
}
return bot.helpers.editOriginalInteractionResponse(interaction.token, response);
},
});
// Add as many properties or methods you would like here.
// NOTE: Whenever you add anything here, in order to get nice autocomplete you should also add it to the src/types/discordeno.ts file.
// Return the new customized object.
return interaction;
};
}

View File

@@ -1,10 +0,0 @@
import type { BotWithCustomProps } from '../../../bot.js';
// SETUP-DD-TEMP: Enable this comment if you want to enable this customizer.
// import { customizeGuildTransformer } from "./guild.js";
import { customizeInteractionTransformer } from './interaction.js';
export function customizeTransformers(bot: BotWithCustomProps) {
customizeInteractionTransformer(bot);
// SETUP-DD-TEMP: Enable this comment if you want to enable this customizer.
// customizeGuildTransformer(bot);
}

View File

@@ -0,0 +1,13 @@
import { readdir } from 'node:fs/promises'
import { bot } from '../bot.js'
export default async function importDirectory(folder: string): Promise<void> {
const files = await readdir(folder, { recursive: true })
for (const filename of files) {
if (!filename.endsWith('.js')) continue
// Using `file://` to avoid weird issues with paths on Windows
await import(`file://${folder}/${filename}`).catch((x) => bot.logger.fatal(`Cannot import file (${folder}/${filename}) for reason:`, x))
}
}

View File

@@ -1,307 +0,0 @@
import type {
ApplicationCommandOptionTypes,
ApplicationCommandTypes,
Bot,
Channel,
Interaction,
Member,
PermissionStrings,
Role,
User,
} from 'discordeno';
import type english from '../../languages/english.js';
import type { translationKeys } from '../../languages/translate.js';
import type { InteractionWithCustomProps } from '../../typings/discordeno.js';
import type { PermissionLevelHandlers } from './permLevels.js';
export function createCommand<T extends readonly ArgumentDefinition[]>(command: Command<T>) {
return command;
}
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
type Identity<T> = { [P in keyof T]: T[P] };
// TODO: make required by default true
// Define each of the types here
interface BaseDefinition {
description: translationKeys;
}
// Subcommand
type SubcommandArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.SubCommand;
// options: Omit<ArgumentDefinition, 'SubcommandArgumentDefinition' | 'SubcommandGroupArgumentDefinition'>[]
options?: readonly ArgumentDefinition[];
};
// SubcommandGroup
type SubcommandGroupArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.SubCommandGroup;
options: readonly SubcommandArgumentDefinition[];
};
// String
type StringArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.String;
choices?: ReadonlyArray<{ name: string; value: string }>;
required?: true;
};
type StringOptionalArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.String;
choices?: ReadonlyArray<{ name: string; value: string }>;
required?: false;
};
// Integer
type IntegerArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.Integer;
choices?: ReadonlyArray<{ name: string; value: number }>;
required: true;
};
type IntegerOptionalArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.Integer;
choices?: ReadonlyArray<{ name: string; value: number }>;
required?: false;
};
// BOOLEAN
type BooleanArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.Boolean;
required: true;
};
type BooleanOptionalArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.Boolean;
required?: false;
};
// USER
type UserArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.User;
required: true;
};
type UserOptionalArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.User;
required?: false;
};
// CHANNEL
type ChannelArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.Channel;
required: true;
};
type ChannelOptionalArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.Channel;
required?: false;
};
// ROLE
type RoleArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.Role;
required: true;
};
type RoleOptionalArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.Role;
required?: false;
};
// MENTIONABLE
type MentionableArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.Mentionable;
required: true;
};
type MentionableOptionalArgumentDefinition<N extends translationKeys = translationKeys> = BaseDefinition & {
name: N;
type: ApplicationCommandOptionTypes.Mentionable;
required?: false;
};
// Add each of known ArgumentDefinitions to this union.
export type ArgumentDefinition =
| StringArgumentDefinition
| StringOptionalArgumentDefinition
| IntegerArgumentDefinition
| IntegerOptionalArgumentDefinition
| BooleanArgumentDefinition
| BooleanOptionalArgumentDefinition
| UserArgumentDefinition
| UserOptionalArgumentDefinition
| ChannelArgumentDefinition
| ChannelOptionalArgumentDefinition
| RoleArgumentDefinition
| RoleOptionalArgumentDefinition
| MentionableArgumentDefinition
| MentionableOptionalArgumentDefinition
| SubcommandArgumentDefinition
| SubcommandGroupArgumentDefinition;
type getName<K extends translationKeys> = (typeof english)[K] extends string ? (typeof english)[K] : never;
// OPTIONALS MUST BE FIRST!!!
export type ConvertArgumentDefinitionsToArgs<T extends readonly ArgumentDefinition[]> = Identity<
UnionToIntersection<
{
[P in keyof T]: T[P] extends StringOptionalArgumentDefinition<infer N> // STRING
? {
// @ts-expect-error TODO: fix this some day
[_ in getName<N>]?: T[P]['choices'] extends ReadonlyArray<{ name: string; value: string }> // @ts-expect-error
? T[P]['choices'][number]['value']
: string;
}
: T[P] extends StringArgumentDefinition<infer N>
? {
// @ts-expect-error TODO: fix this some day
[_ in getName<N>]: T[P]['choices'] extends ReadonlyArray<{ name: string; value: string }> // @ts-expect-error
? T[P]['choices'][number]['value']
: string;
}
: // INTEGER
T[P] extends IntegerOptionalArgumentDefinition<infer N>
? {
[_ in getName<N>]?: T[P]['choices'] extends ReadonlyArray<{ name: string; value: number }> // @ts-expect-error
? T[P]['choices'][number]['value']
: number;
}
: T[P] extends IntegerArgumentDefinition<infer N>
? {
[_ in getName<N>]: T[P]['choices'] extends ReadonlyArray<{ name: string; value: number }> // @ts-expect-error
? T[P]['choices'][number]['value']
: number;
}
: // BOOLEAN
T[P] extends BooleanOptionalArgumentDefinition<infer N>
? { [_ in getName<N>]?: boolean }
: T[P] extends BooleanArgumentDefinition<infer N>
? { [_ in getName<N>]: boolean }
: // USER
T[P] extends UserOptionalArgumentDefinition<infer N>
? {
[_ in getName<N>]?: {
user: User;
member: Member;
};
}
: T[P] extends UserArgumentDefinition<infer N>
? {
[_ in getName<N>]: {
user: User;
member: Member;
};
}
: // CHANNEL
T[P] extends ChannelOptionalArgumentDefinition<infer N>
? { [_ in getName<N>]?: Channel }
: T[P] extends ChannelArgumentDefinition<infer N>
? { [_ in getName<N>]: Channel }
: // ROLE
T[P] extends RoleOptionalArgumentDefinition<infer N>
? { [_ in getName<N>]?: Role }
: T[P] extends RoleArgumentDefinition<infer N>
? { [_ in getName<N>]: Role }
: // MENTIONABLE
T[P] extends MentionableOptionalArgumentDefinition<infer N>
? {
[_ in getName<N>]?:
| Role
| {
user: User;
member: Member;
};
}
: T[P] extends MentionableArgumentDefinition<infer N>
? {
[_ in getName<N>]:
| Role
| {
user: User;
member: Member;
};
}
: // SUBCOMMAND
T[P] extends SubcommandArgumentDefinition<infer N>
? {
[_ in getName<N>]?: T[P]['options'] extends readonly ArgumentDefinition[] // @ts-expect-error somehow this check does not work
? ConvertArgumentDefinitionsToArgs<T[P]['options']>
: {};
}
: // SUBCOMMANDGROUP
T[P] extends SubcommandGroupArgumentDefinition<infer N>
? {
[_ in getName<N>]?: ConvertArgumentDefinitionsToArgs<T[P]['options']>;
}
: never;
}[number]
>
>;
export interface Command<T extends readonly ArgumentDefinition[]> {
/** The name of the command, used for both slash and message commands. */
name: translationKeys;
/** The type of command. */
type?: ApplicationCommandTypes;
/** The description of the command */
description: translationKeys;
// TODO: consider type being a string like "number" | "user" for better ux
/** The options for the command, used for both slash and message commands. */
// options?: ApplicationCommandOption[];
options?: T;
execute: (bot: Bot, data: InteractionWithCustomProps, args: ConvertArgumentDefinitionsToArgs<T>) => unknown;
subcommands?: Record<string, Omit<Command<any>, 'subcommands'> & { group?: string }>;
/** Whether the command should have a cooldown */
cooldown?: {
/** How long the user needs to wait after the first execution until he can use the command again */
seconds: number;
/** How often the user is allowed to use the command until he is in cooldown */
allowedUses?: number;
};
nsfw?: boolean;
/** By default false */
global?: boolean;
/** Dm only by default false */
dmOnly?: boolean;
/** VIP only by default false */
vipOnly?: boolean;
advanced?: boolean;
/** Whether or not this slash command should be enabled right now. Defaults to true. */
enabled?: boolean;
/** Whether or not this command is still in development and should be setup in the dev server for testing. */
dev?: boolean;
/** Whether or not this command will take longer than 3s and need to acknowledge to discord. */
acknowledge?: boolean;
permissionLevels?:
| Array<keyof typeof PermissionLevelHandlers>
| ((data: Interaction, command: Command<T>) => boolean | Promise<boolean>);
botServerPermissions?: PermissionStrings[];
botChannelPermissions?: PermissionStrings[];
userServerPermissions?: PermissionStrings[];
userChannelPermissions?: PermissionStrings[];
}
export enum PermissionLevels {
Member,
Moderator,
Admin,
ServerOwner,
BotSupporter,
BotDev,
BotOwner,
}

View File

@@ -1,31 +0,0 @@
import COMMANDS from '../../commands/mod.js';
export async function validateSlashLimits() {
const MAX_ALLOWED_CHARACTERS = 4000;
const commands = await fetch('https://cmd-counter-play.deno.dev/', {
body: JSON.stringify(COMMANDS),
headers: {
'content-type': 'application/json',
},
})
.then(async (res) => await res.json())
.catch(() => undefined);
if (!commands) return;
const invalidCommandNames: string[] = [];
if (commands[0]?.characters > MAX_ALLOWED_CHARACTERS) {
for (const command of commands) {
if (command.characters <= MAX_ALLOWED_CHARACTERS) continue;
invalidCommandNames.push(command.name);
console.log(
`[Invalid Command] The ${command.name} is not a valid command. It's total characters are (${command.characters}) which is more than the max allowed ${MAX_ALLOWED_CHARACTERS}.`,
);
}
}
if (invalidCommandNames.length) throw new Error(`[Startup] Invalid commands: ${invalidCommandNames.join(', ')}`);
}

View File

@@ -1,48 +0,0 @@
import type { Interaction } from 'discordeno';
import { validatePermissions } from 'discordeno/permissions-plugin';
import type { Command } from './createCommand.js';
export default async function hasPermissionLevel(command: Command<any>, payload: Interaction) {
// This command doesnt require a perm level so allow the command.
if (!command.permissionLevels) return true;
// If a custom function was provided
if (typeof command.permissionLevels === 'function') {
return await command.permissionLevels(payload, command);
}
// If an array of perm levels was provided
for (const permlevel of command.permissionLevels) {
// If this user has one of the allowed perm level, the loop is canceled and command is allowed.
if (await PermissionLevelHandlers[permlevel](payload, command)) return true;
}
// None of the perm levels were met. So cancel the command
return false;
}
export const PermissionLevelHandlers: Record<
keyof typeof PermissionLevels,
(payload: Interaction, command: Command<any>) => boolean | Promise<boolean>
> = {
MEMBER: () => true,
MODERATOR: (payload) =>
Boolean(payload.member?.permissions) && validatePermissions(payload.member!.permissions!, ['MANAGE_GUILD']),
ADMIN: (payload) =>
Boolean(payload.member?.permissions) && validatePermissions(payload.member!.permissions!, ['ADMINISTRATOR']),
// TODO(cache): fix this
SERVER_OWNER: () => false,
BOT_SUPPORT: () => false,
BOT_DEVS: () => false,
BOT_OWNERS: (payload) => [130136895395987456n, 615542460151496705n].includes(payload.user.id),
};
export enum PermissionLevels {
MEMBER,
MODERATOR,
ADMIN,
SERVER_OWNER,
BOT_SUPPORT,
BOT_DEVS,
BOT_OWNERS,
}

View File

@@ -1,149 +0,0 @@
import type { ApplicationCommandOption, Bot } from 'discordeno';
import { ApplicationCommandTypes } from 'discordeno';
import { prisma } from '../../../prisma.js';
import { bot } from '../../bot.js';
import COMMANDS from '../../commands/mod.js';
import { serverLanguages, translate } from '../../languages/translate.js';
import type { ArgumentDefinition } from './createCommand.js';
const DEV_SERVER_ID = process.env.DEV_SERVER_ID as string;
export async function updateDevCommands(bot: Bot) {
const cmds = Object.entries(COMMANDS)
// ONLY DEV COMMANDS
.filter(([_name, command]) => command?.dev);
if (!cmds.length) return;
// DEV RELATED COMMANDS, USE upsertGlobalApplicationCommands TO UPDATE GLOBALLY
await bot.helpers.upsertGuildApplicationCommands(
bot.transformers.snowflake(DEV_SERVER_ID),
cmds.map(([name, command]) => {
const translatedName = translate(DEV_SERVER_ID, command.name);
const translatedDescription = command.description ? translate(DEV_SERVER_ID, command.description) : '';
if (command.type && command.type !== ApplicationCommandTypes.ChatInput) {
return {
name: (translatedName || name).toLowerCase(),
type: command.type,
};
}
return {
name: (translatedName || name).toLowerCase(),
description: translatedDescription || command.description,
options: command.options
? createOptions(bot.transformers.snowflake(DEV_SERVER_ID), command.options, command.name)
: undefined,
};
}),
);
}
// SETUP-DD-TEMP: You can make this able to be updated dynicamally by moving this value to something in the database and having a command to update it on the fly or as part of CI.
export const CURRENT_SLASH_COMMAND_VERSION = 1;
/** Whether the guild has the latest slash command version */
export async function usesLatestCommandVersion(guildId: bigint): Promise<boolean> {
return (await getCurrentCommandVersion(guildId)) === CURRENT_SLASH_COMMAND_VERSION;
}
/** Get the current slash command version for this guild */
export async function getCurrentCommandVersion(guildId: bigint): Promise<number> {
if (bot.commandVersions.has(guildId)) return bot.commandVersions.get(guildId)!;
const commandVersion = await prisma.commands.findUnique({ where: { id: guildId } });
if (commandVersion) bot.commandVersions.set(guildId, commandVersion.version);
return commandVersion?.version ?? 0;
}
export async function updateCommandVersion(guildId: bigint): Promise<number> {
// UPDATE THE VERSION SAVED IN THE DB
await prisma.commands.upsert({
where: { id: guildId },
create: { id: guildId, version: CURRENT_SLASH_COMMAND_VERSION },
update: { version: CURRENT_SLASH_COMMAND_VERSION },
});
bot.commandVersions.set(guildId, CURRENT_SLASH_COMMAND_VERSION);
return CURRENT_SLASH_COMMAND_VERSION;
}
export async function updateGuildCommands(bot: Bot, guildId: bigint) {
if (guildId === 547046977578336286n) return await updateDevCommands(bot);
await updateCommandVersion(guildId);
// GUILD RELATED COMMANDS
await bot.helpers.upsertGuildApplicationCommands(
guildId,
Object.entries(COMMANDS)
// ONLY GUILD COMMANDS
.filter(([_name, command]) => !command.global && !command.dev)
.map(([name, command]) => {
// USER OPTED TO USE BASIC VERSION ONLY
if (command.advanced === false) {
return {
name,
description: translate('english', command.description),
options: command.options ? createOptions('english', command.options, command.name) : undefined,
};
}
// ADVANCED VERSION WILL ALLOW TRANSLATION
const translatedName = translate(guildId, command.name);
const translatedDescription = translate(guildId, command.description);
return {
name: translatedName.toLowerCase(),
description: translatedDescription,
options: command.options ? createOptions(guildId, command.options, command.name) : undefined,
};
}),
);
}
// USED TO CACHE CONVERTED COMMANDS AFTER START TO PREVENT UNNECESSARY LOOPS
const convertedCache = new Map<string, ApplicationCommandOption[]>();
/** Creates the commands options including subcommands. Also translates them. */
function createOptions(
guildId: bigint | 'english',
options: readonly ArgumentDefinition[],
commandName?: string,
): ApplicationCommandOption[] | undefined {
const language = guildId === 'english' ? 'english' : serverLanguages.get(guildId) ?? 'english';
if (commandName && convertedCache.has(`${language}-${commandName}`)) {
return convertedCache.get(`${language}-${commandName}`)!;
}
const newOptions: ApplicationCommandOption[] = [];
for (const option of options || []) {
const optionName = translate(guildId, option.name);
const optionDescription = translate(guildId, option.description);
// TODO: remove this ts ignore
// @ts-expect-error
const choices = option.choices?.map((choice) => ({
...choice,
name: translate(guildId, choice.name),
}));
newOptions.push({
...option,
name: optionName.toLowerCase(),
description: optionDescription || 'No description available.',
choices,
// @ts-expect-error fix this
options: option.options
? // @ts-expect-error fix this
createOptions(bot, guildId, option.options)
: undefined,
} as ApplicationCommandOption);
}
if (commandName) convertedCache.set(`${language}-${commandName}`, newOptions);
return newOptions;
}

View File

@@ -0,0 +1,19 @@
import assert from 'node:assert'
import { DEVELOPMENT, DEV_SERVER_ID } from '../../config.js'
import { bot } from '../bot.js'
export async function updateCommands(): Promise<void> {
bot.logger.info('Updating commands')
const userCommands = bot.commands.filter((x) => !x.devOnly).array()
await bot.helpers.upsertGlobalApplicationCommands(userCommands)
if (DEVELOPMENT) {
assert(DEV_SERVER_ID, 'The DEV_SERVER_ID environment is missing')
bot.logger.info('Updating developer commands')
const devCommands = bot.commands.filter((x) => x.devOnly ?? false).array()
await bot.helpers.upsertGuildApplicationCommands(DEV_SERVER_ID, devCommands)
}
}

Some files were not shown because too many files have changed in this diff Show More