parent
998ff503e3
commit
b69510ffab
63 changed files with 7360 additions and 2 deletions
2
.eslintignore
Normal file
2
.eslintignore
Normal file
|
@ -0,0 +1,2 @@
|
|||
/node_modules/
|
||||
/dist/
|
17
.eslintrc
Normal file
17
.eslintrc
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 6
|
||||
},
|
||||
"rules": {
|
||||
"semi": ["error", "never"],
|
||||
"quotes": ["error", "double"],
|
||||
"@typescript-eslint/ban-ts-comment": "off"
|
||||
}
|
||||
}
|
BIN
.github/img/example.png
vendored
Normal file
BIN
.github/img/example.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.7 MiB |
BIN
.github/img/logo-white.png
vendored
Normal file
BIN
.github/img/logo-white.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
BIN
.github/img/logo.png
vendored
Normal file
BIN
.github/img/logo.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.3 KiB |
146
.gitignore
vendored
Normal file
146
.gitignore
vendored
Normal file
|
@ -0,0 +1,146 @@
|
|||
_/
|
||||
.env
|
||||
/build/
|
||||
/src/secret/
|
||||
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node,visualstudiocode
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node,visualstudiocode
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
.env.production
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
.ionide
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode
|
4
.prettierrc
Normal file
4
.prettierrc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"semi": false,
|
||||
"useTabs": true
|
||||
}
|
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
|
||||
}
|
18
.vscode/settings.json
vendored
Normal file
18
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnPaste": true,
|
||||
"cSpell.words": [
|
||||
"adminsdk",
|
||||
"clapify",
|
||||
"colorette",
|
||||
"cooldown",
|
||||
"firestore",
|
||||
"Neko",
|
||||
"nekos",
|
||||
"OwOify"
|
||||
],
|
||||
"[ignore]": {
|
||||
"editor.defaultFormatter": "foxundermoon.shell-format"
|
||||
}
|
||||
}
|
130
CONTRIBUTING.md
Normal file
130
CONTRIBUTING.md
Normal file
|
@ -0,0 +1,130 @@
|
|||
# Contribution guide
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Contribution guide](#contribution-guide)
|
||||
- [Table of contents](#table-of-contents)
|
||||
- [Purpose](#purpose)
|
||||
- [Assumptions](#assumptions)
|
||||
- [Getting started](#getting-started)
|
||||
- [Development environment](#development-environment)
|
||||
- [Required knowledge](#required-knowledge)
|
||||
- [Bot](#bot)
|
||||
- [Web Interface](#web-interface)
|
||||
- [Documentation](#documentation)
|
||||
- [Commit message](#commit-message)
|
||||
- [Creating / updating a command](#creating--updating-a-command)
|
||||
- [Help message](#help-message)
|
||||
- [Structure](#structure)
|
||||
|
||||
## Purpose
|
||||
|
||||
There are several goals this guide aims to achieve:
|
||||
|
||||
- Help new contributors get started
|
||||
- Prevent information fragmentation as people work on different parts of the software
|
||||
- Streamline the development workflow
|
||||
- Make things work in the project owner's absence
|
||||
- Minimize intervention and back-and-forth communication (e.g. The contributor didn't format their code properly)
|
||||
|
||||
## Assumptions
|
||||
|
||||
All contributors are assumed to be familiar with the following:
|
||||
|
||||
- git (and by extension github and developer collaboration)
|
||||
- node.js and its ecosystem (npm packages, yarn, etc.)
|
||||
- javascript and typescript
|
||||
- code formatting
|
||||
|
||||
## Getting started
|
||||
|
||||
### Development environment
|
||||
|
||||
Contributors are free to use whatever IDE they want but the usage of [vscode](https://code.visualstudio.com) is highly recommended.
|
||||
|
||||
Format markdown file(s) with [prettier](https://prettier.io) formatter
|
||||
|
||||
### Required knowledge
|
||||
|
||||
### Bot
|
||||
|
||||
- [discord.js](https://discord.js.org)
|
||||
- [sapphire framework](https://www.sapphirejs.dev)
|
||||
|
||||
### Web Interface
|
||||
|
||||
- [express.js](https://expressjs.com)
|
||||
- [passport.js](https://www.passportjs.org)
|
||||
- [firebase functions](https://firebase.google.com/docs/functions)
|
||||
|
||||
### Documentation
|
||||
|
||||
- [Docusaurus](https://docusaurus.io)
|
||||
- [Markdown](https://www.markdownguide.org/basic-syntax)
|
||||
|
||||
## Commit message
|
||||
|
||||
The commit message should be a clear and concise description of what the commit does.
|
||||
The first line should be no more than 50 characters and the rest no more than 72.
|
||||
|
||||
## Creating / updating a command
|
||||
|
||||
Use the `CustomCommand` defined in [src/custom/CustomCommand.ts](./src/custom/CustomCommand.ts) instead of importing from `@sapphire/framework`.
|
||||
|
||||
### Help message
|
||||
|
||||
- consistent usage information
|
||||
|
||||
### Structure
|
||||
|
||||
- one export per command
|
||||
- functions should be `static` whenever possible
|
||||
- Keep the `messageRun` function clean (ideally less than 50 lines).
|
||||
- Separate regular imports from type imports.
|
||||
- Separate the embedded message building process to a separate function.
|
||||
|
||||
This also makes testing easier
|
||||
|
||||
- jsdoc comments should...
|
||||
|
||||
- not include argument and return types.
|
||||
|
||||
They should be included in the function instead.
|
||||
|
||||
- not include a dash (`-`) in the `@returns` tag.
|
||||
|
||||
Do this:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* This function does something.
|
||||
*
|
||||
* - Here's some extra information
|
||||
*
|
||||
* @param argument - This is an argument.
|
||||
* @returns some string.
|
||||
*/
|
||||
function someFunction(argument: string): string {
|
||||
const someString = "some string"
|
||||
|
||||
return someString
|
||||
}
|
||||
```
|
||||
|
||||
Not this:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* This function does something.
|
||||
*
|
||||
* - Here's some extra information
|
||||
*
|
||||
* @param {string} argument - This is an argument.
|
||||
* @returns {string} - some string.
|
||||
*/
|
||||
function someFunction(argument) {
|
||||
const someString = "some string"
|
||||
|
||||
return someString
|
||||
}
|
||||
```
|
19
LICENSE
Normal file
19
LICENSE
Normal file
|
@ -0,0 +1,19 @@
|
|||
Copyright (c) 2019 developomp
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
OR OTHER DEALINGS IN THE SOFTWARE.
|
106
README.md
106
README.md
|
@ -1,2 +1,104 @@
|
|||
**ATTENTION!**<br />
|
||||
The llama bot has found a new home: https://github.com/llama-bot/llama-bot
|
||||
# Llama bot
|
||||
|
||||

|
||||
|
||||
The Llama bot is a [discord](https://discord.com) bot made for the
|
||||
[LP community discord server](https://discord.gg/2fsar34APa).<br />
|
||||
|
||||
## Setting up
|
||||
|
||||
### Pre-requirements
|
||||
|
||||
- Node.js 16.6.0+
|
||||
- [yarn](https://yarnpkg.com)
|
||||
- A Discord account
|
||||
- A Google Firebase account
|
||||
- ~~A sacrifice to be given to the llama gods~~ (no longer needed)
|
||||
|
||||
### Discord
|
||||
|
||||
1. Create a new application from the [Discord Developer Portal](https://discord.com/developers/applications). Select one if you already have it.
|
||||
2. Go to the `Bot` tab and convert your application to a discord bot. Be cautious since this operation is **NOT REVERSIBLE**.
|
||||
3. Copy the bot token. This will be used during the [Server](#server) setup.
|
||||
|
||||
### Firebase
|
||||
|
||||
1. Head over to https://console.firebase.google.com and create a firebase project.
|
||||
2. Enable firestore database.
|
||||
3. [Generate and download](https://console.firebase.google.com/project/_/settings/serviceaccounts/adminsdk) the service account key.
|
||||
This will be used during the [Server](#server) setup.
|
||||
|
||||
### Server
|
||||
|
||||
- Assumes UNIX-like environment (Linux, BSD, Mac, etc.)
|
||||
|
||||
1. Clone this repository and open it.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/llama-bot/llama-bot.git
|
||||
```
|
||||
|
||||
```bash
|
||||
cd llama-bot
|
||||
```
|
||||
|
||||
2. Install dependencies.
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
3. Create `.env` file in the project root and put the discord bot token generated during the [Discord](#discord) setup.
|
||||
|
||||
```dosini
|
||||
TOKEN=PUT_YOUR_DISCORD_BOT_TOKEN_HERE
|
||||
TESTING=true # set it to false on production
|
||||
PREFIX_PROD=PUT_PRODUCTION_DEFAULT_PREFIX_HERE
|
||||
PREFIX_DEV=PUT_DEVELOPMENT_DEFAULT_PREFIX_HERE
|
||||
OWNER_IDS=ID1,ID2,ID3,...
|
||||
```
|
||||
|
||||
4. Create `secret` directory in the `src` directory,
|
||||
rename the firebase admin key generated during the [Firebase](#firebase) setup to `firebase-adminsdk.json`,
|
||||
and put it in the `secret` directory.
|
||||
|
||||
5. Build the bot.
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
```
|
||||
|
||||
6. Install pm2 globally.
|
||||
|
||||
```bash
|
||||
yarn global add pm2
|
||||
```
|
||||
|
||||
7. Start the bot.
|
||||
|
||||
```bash
|
||||
pm2 start build/index.js --watch --name "Llama Bot"
|
||||
```
|
||||
|
||||
| Option | Explanation |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| `--watch` | Auto restart bot if bot files have been changed |
|
||||
| `--name "Llama Bot"` | Set the name of the process so it can be easily recognized |
|
||||
|
||||
8. Make the process automatically start on boot.
|
||||
|
||||
```bash
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
## More info
|
||||
|
||||
- [discord developers documentation](https://discord.com/developers/docs)
|
||||
- discord API's javascript implementation [documentation](https://discord.js.org/#/docs), [guide](https://discordjs.guide), and bot [framework documentation](https://sapphiredev.github.io/framework)
|
||||
- [firebase admin sdk documentation](https://firebase.google.com/docs)
|
||||
- [pm2 documentation](https://pm2.keymetrics.io/docs/usage/quick-start)
|
||||
|
||||
## Special thanks
|
||||
|
||||
- `Dabidoo#9888 (265697563280146433)` for making the [colored logo](./.github/img/logo.png)
|
||||
- `Sɪʟᴋ Sᴘɪᴅᴇʀ#8364 (419184817368858644)` for making the [white logo](./.github/img/logo-white.png)
|
||||
|
|
5
jest.config.js
Normal file
5
jest.config.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
}
|
36
package.json
Normal file
36
package.json
Normal file
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"main": "dist/index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn build && node dist/index.js",
|
||||
"test": "jest",
|
||||
"lint": "eslint .",
|
||||
"clean": "del dist",
|
||||
"build": "yarn clean && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sapphire/decorators": "^4.2.6",
|
||||
"@sapphire/framework": "^2.4.1",
|
||||
"discord.js": "^13.6.0",
|
||||
"firebase-admin": "^10.0.2",
|
||||
"nekos.life": "^2.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^27.4.1",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/string-similarity": "^4.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.13.0",
|
||||
"@typescript-eslint/parser": "^5.13.0",
|
||||
"colorette": "^2.0.16",
|
||||
"del-cli": "^4.0.1",
|
||||
"dotenv": "^16.0.0",
|
||||
"eslint": "^8.10.0",
|
||||
"jest": "^27.5.1",
|
||||
"pretty-error": "^4.0.0",
|
||||
"string-similarity": "^4.0.4",
|
||||
"ts-jest": "^27.1.3",
|
||||
"tslint-config-prettier": "^1.18.0",
|
||||
"typescript": "^4.6.2",
|
||||
"utility-types": "^3.10.0"
|
||||
}
|
||||
}
|
29
src/DB/defaults.ts
Normal file
29
src/DB/defaults.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import type { Settings } from "../types/bot"
|
||||
|
||||
export const defaultSettings: Settings = {
|
||||
clear_emojis: ["🧹"],
|
||||
quotes: [
|
||||
"Paul: Caaaaarrrrrlllll!!!!",
|
||||
"Carl: And I... I... stabbed him 37 times in the chest!",
|
||||
"Carl: Well, I kill people and I eat hands! That's—that's two things!",
|
||||
"Carl: That is what forgiveness sounds like, screaming and then silence.",
|
||||
"Carl: Probably because I'm a dangerous sociopath with a long history of violence.",
|
||||
"Carl: I may have created a crack in space time... through which to collect millions of baby hands.",
|
||||
"Carl: Whities gotta pay... and the payment is baby hands.",
|
||||
"Paul: CAAAAAAAAARL! WHAT DID YOU DO?",
|
||||
"Carl: My stomach was making the rumblies - that only hands could satisfy.",
|
||||
"Paul: What is wrong with you, Carl?",
|
||||
"Paul: And then you started making out with the ice sculptures.",
|
||||
"Carl: I will not apologize for art.",
|
||||
"Carl: Well, I'm building a meat dragon and not just any meat will do.",
|
||||
"Carl: What's that? It's hard to hear you over the sound of melting city!",
|
||||
"Carl: Who's laughing? Clearly not all the people who've just exploded.",
|
||||
"Paul: All you do is kill people, Carl!\n" +
|
||||
"Carl:That's like saying all Mozart did was write songs.",
|
||||
"Carl: It's not a meat grinder, it's an orphan stomper.",
|
||||
"Carl: Let me explain: Efficiency, industry, never before has this many dead bodies been so manageable.",
|
||||
"Carl: I'm the Henry Ford of human meat!",
|
||||
`Paul: It's horrifying, Carl.
|
||||
Carl: Thank you.`,
|
||||
],
|
||||
}
|
89
src/DB/index.ts
Normal file
89
src/DB/index.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* @file Firestore Database interface for the bot.
|
||||
*
|
||||
* More information about firestore can be found here: https://firebase.google.com/docs/firestore
|
||||
* Though there are no plans to move away from firebase,
|
||||
* all firebase interactions are contained in this file so it's easier to move to other platform.
|
||||
*
|
||||
* Database structure:
|
||||
* bot/ // Discord Bot related data
|
||||
* settings/ // Discord bot settings
|
||||
* servers/ // Server-specific data
|
||||
* SERVER_ID/ // discord server ID (snowflake)
|
||||
* settings/ // server-specific settings
|
||||
* name: string // server name
|
||||
* vars/ // server-specific variables
|
||||
* users/ // user data
|
||||
* USER_ID/ // discord user ID (snowflake)
|
||||
* avatar: string // discord avatar ID
|
||||
* discriminator: string // a 4 digit numerical discord user discriminator
|
||||
* id: string // discord user ID (snowflake)
|
||||
* username: string // discord user name
|
||||
*/
|
||||
|
||||
// todo: create and populate if it doesn't exist already
|
||||
|
||||
import admin from "firebase-admin"
|
||||
import serviceAccountKey from "../secret/firebase-adminsdk.json"
|
||||
|
||||
import type { Snowflake } from "discord-api-types"
|
||||
import type { Settings, Servers, ServerData } from "../types/bot"
|
||||
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccountKey as admin.ServiceAccount),
|
||||
})
|
||||
|
||||
const firestoreDB = admin.firestore()
|
||||
|
||||
const botCollection = firestoreDB.collection("bot")
|
||||
const serversCollection = firestoreDB.collection("servers")
|
||||
|
||||
const settingsDoc = botCollection.doc("settings")
|
||||
|
||||
/**
|
||||
* Global bot settings. Fetched by calling the {@link fetchSettings} function.
|
||||
*/
|
||||
export let settings: Settings = {}
|
||||
|
||||
/**
|
||||
* Server-specific data.
|
||||
* Fetched by calling the {@link fetchServerData} function.
|
||||
*/
|
||||
export const servers: Servers = {}
|
||||
|
||||
/**
|
||||
* Fetch settings from the database.
|
||||
*/
|
||||
export async function fetchSettings(): Promise<void> {
|
||||
await settingsDoc.get().then((doc) => {
|
||||
settings = doc.data() as Settings
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch server-specific data from the database.
|
||||
*/
|
||||
export async function fetchServerData(
|
||||
serverSnowflake: Snowflake
|
||||
): Promise<void> {
|
||||
await serversCollection
|
||||
.doc(serverSnowflake)
|
||||
.get()
|
||||
.then((doc) => {
|
||||
servers[serverSnowflake] = doc.data() as unknown as ServerData
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
// initialize
|
||||
//
|
||||
|
||||
// todo: make sure settings is fetched before anyone runs a command
|
||||
fetchSettings()
|
||||
|
||||
export default {
|
||||
settings,
|
||||
servers,
|
||||
fetchSettings,
|
||||
fetchServerData,
|
||||
}
|
240
src/commands/core/help.ts
Normal file
240
src/commands/core/help.ts
Normal file
|
@ -0,0 +1,240 @@
|
|||
// todo: also prevent command from having same name as command category
|
||||
|
||||
import { MessageEmbed } from "discord.js"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
import stringSimilarity from "string-similarity"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
import type {
|
||||
Args,
|
||||
Command,
|
||||
CommandOptions,
|
||||
CommandStore,
|
||||
} from "@sapphire/framework"
|
||||
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
enum QueryType {
|
||||
empty = "empty",
|
||||
command = "command",
|
||||
category = "category",
|
||||
unknown = "unknown",
|
||||
}
|
||||
|
||||
type CategorizeQueryReturn =
|
||||
| { queryType: QueryType.empty }
|
||||
| { queryType: QueryType.command; command: Command }
|
||||
| { queryType: QueryType.category }
|
||||
| { queryType: QueryType.unknown }
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["h"],
|
||||
description:
|
||||
"Shows list of helpful information about a command or a command category.",
|
||||
})
|
||||
export default class HelpCommand extends CustomCommand {
|
||||
usage = `> {$} ["command"|"category"]
|
||||
|
||||
ex:
|
||||
List categories:
|
||||
> {$}
|
||||
|
||||
List commands in the \`util\` category:
|
||||
> {$} util
|
||||
|
||||
Shows information about the \`ping\` command:
|
||||
> {$} ping`
|
||||
|
||||
//
|
||||
commands: CommandStore = this.container.client.stores.get("commands")
|
||||
// lower case names of categories
|
||||
lowerCaseCategoryNames: string[] = []
|
||||
// all command names and aliases
|
||||
allCommands: string[] = []
|
||||
|
||||
async messageRun(message: Message, args: Args): Promise<void> {
|
||||
if (this.lowerCaseCategoryNames.length <= 0) {
|
||||
// can not put this in class constructor because then `this.commands.categories` will be equal to `[]`
|
||||
// can not put this in the "ready" listener either because of race conditions
|
||||
this.lowerCaseCategoryNames = this.commands.categories.map((elem) =>
|
||||
elem.toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
const query: string = await args.pick("string").catch(() => "")
|
||||
const queryCategory = this.categorizeQuery(query)
|
||||
|
||||
switch (queryCategory.queryType) {
|
||||
case QueryType.empty:
|
||||
this.sendDefaultHelpMessage(message)
|
||||
break
|
||||
|
||||
case QueryType.command:
|
||||
this.sendCommandHelpMessage(message, queryCategory.command)
|
||||
break
|
||||
|
||||
case QueryType.category:
|
||||
this.sendCategoryHelpMessage(message, query)
|
||||
break
|
||||
|
||||
default:
|
||||
this.sendCommandNotFoundMessage(message, query)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
categorizeQuery(input: string): CategorizeQueryReturn {
|
||||
const query = input.toLowerCase()
|
||||
|
||||
if (!query) return { queryType: QueryType.empty }
|
||||
|
||||
if (this.lowerCaseCategoryNames.includes(query))
|
||||
return { queryType: QueryType.category }
|
||||
|
||||
const command = this.commands.find(
|
||||
(command: Command, key: string) =>
|
||||
key.toLowerCase() === query ||
|
||||
command.aliases.some((elem) => elem.toLowerCase() === query)
|
||||
)
|
||||
if (command) return { queryType: QueryType.command, command }
|
||||
|
||||
return { queryType: QueryType.unknown }
|
||||
}
|
||||
|
||||
sendDefaultHelpMessage(message: Message): void {
|
||||
const helpEmbed = new MessageEmbed({
|
||||
title: "Help",
|
||||
description: `Use the \`${process.env.PREFIX}help <category>\` command to get more information about a category.
|
||||
This command is not case sensitive.
|
||||
|
||||
You can read more about the bot in the [documentation](https://docs.llama.developomp.com/docs/usage/overview).
|
||||
|
||||
**Categories:**`,
|
||||
})
|
||||
|
||||
//
|
||||
// add categories
|
||||
//
|
||||
|
||||
this.commands.categories.map((categoryName) => {
|
||||
const commandsInCategory = this.commands.filter((command) =>
|
||||
command.fullCategory.includes(categoryName)
|
||||
)
|
||||
|
||||
helpEmbed.addField(
|
||||
categoryName,
|
||||
`${commandsInCategory.size} commands`,
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
//
|
||||
// reply
|
||||
//
|
||||
|
||||
message.channel.send({
|
||||
embeds: [helpEmbed],
|
||||
})
|
||||
}
|
||||
|
||||
sendCommandHelpMessage(message: Message, command: Command): void {
|
||||
const aliases = command.aliases
|
||||
? `\`${command.aliases.join("`, `")}\``
|
||||
: "None"
|
||||
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: command.name,
|
||||
description: `Aliases: ${aliases}`,
|
||||
fields: [
|
||||
{
|
||||
name: "Description",
|
||||
value: command.description || "WIP",
|
||||
},
|
||||
{
|
||||
name: "Usage",
|
||||
value:
|
||||
// replace `{$}` with <prefix><command>
|
||||
command.usage?.replace(
|
||||
/{\$}/g,
|
||||
`${process.env.PREFIX}${command.name}`
|
||||
) || "WIP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
sendCategoryHelpMessage(message: Message, query: string): void {
|
||||
//
|
||||
// find category
|
||||
//
|
||||
|
||||
let selectedCategoryName = ""
|
||||
|
||||
const lowerCaseCategoryName = query.toLowerCase()
|
||||
this.commands.categories.some((categoryName) => {
|
||||
if (categoryName.toLowerCase() === lowerCaseCategoryName) {
|
||||
selectedCategoryName = categoryName
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
//
|
||||
// Find commands in category
|
||||
//
|
||||
|
||||
const commandsInCategory = this.commands.filter((command) =>
|
||||
command.fullCategory.includes(selectedCategoryName)
|
||||
)
|
||||
|
||||
//
|
||||
// Reply
|
||||
//
|
||||
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: `${selectedCategoryName} category`,
|
||||
description: `Use the \`${process.env.PREFIX}help <command>\` command to get more information about a command.
|
||||
This command is not case sensitive.`,
|
||||
fields: [
|
||||
{
|
||||
name: "commands",
|
||||
value: commandsInCategory
|
||||
.map((command) => `- \`${command.name}\`\n`)
|
||||
.join(""),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
sendCommandNotFoundMessage(message: Message, query: string): void {
|
||||
if (this.allCommands.length <= 0) {
|
||||
this.allCommands = [...this.commands.keys()].concat(
|
||||
this.commands.aliases
|
||||
.map((elem) => elem.aliases)
|
||||
.reduce((prev, curr) => prev.concat(curr)) as string[]
|
||||
)
|
||||
}
|
||||
|
||||
const mostLikelyGuess =
|
||||
this.allCommands[
|
||||
stringSimilarity.findBestMatch(query, this.allCommands).bestMatchIndex
|
||||
]
|
||||
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Command not found",
|
||||
description: `Command \`${query}\` was not found. Did you mean \`${mostLikelyGuess}\`?
|
||||
You can also use the \`${process.env.PREFIX}help\` command to list all available commands.`,
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
29
src/commands/core/testing.ts
Normal file
29
src/commands/core/testing.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Cannot name file to `test.ts` because then it'll treat it like a testing file.
|
||||
*/
|
||||
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
import type { Args, CommandOptions } from "@sapphire/framework"
|
||||
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["t"],
|
||||
description: "Tests bot features. Only available to bot owners.",
|
||||
preconditions: ["OwnersOnly"],
|
||||
})
|
||||
export default class TestCommand extends CustomCommand {
|
||||
usage = `> {$} [feature]
|
||||
|
||||
If feature is not passed, then the command will result in a error.
|
||||
This is an intended behavior to make testing easier.
|
||||
`
|
||||
|
||||
async messageRun(message: Message, args: Args) {
|
||||
await args.pick("string").catch(() => {
|
||||
throw new Error("") // the intended behavior
|
||||
})
|
||||
}
|
||||
}
|
64
src/commands/fun/8ball.ts
Normal file
64
src/commands/fun/8ball.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { MessageEmbed } from "discord.js"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
import type { Args, CommandOptions } from "@sapphire/framework"
|
||||
|
||||
import { globalObject } from "../.."
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["8", "ball"],
|
||||
description:
|
||||
"Gives you the best advice you can get. We are not responsible for your action though.",
|
||||
})
|
||||
export default class EightBallCommand extends CustomCommand {
|
||||
usage = `> {$} [your question]
|
||||
|
||||
e.g.
|
||||
> {$}
|
||||
|
||||
> {$} Should I buy more doge coin?
|
||||
`
|
||||
|
||||
async messageRun(message: Message, args: Args) {
|
||||
message.channel.sendTyping()
|
||||
|
||||
//
|
||||
// Parse user input
|
||||
//
|
||||
|
||||
let text = ""
|
||||
|
||||
const allStr = await args.repeat("string").catch(() => "")
|
||||
|
||||
if (typeof allStr === "string") {
|
||||
text = allStr
|
||||
}
|
||||
if (Array.isArray(allStr)) {
|
||||
text = allStr.join(" ")
|
||||
}
|
||||
|
||||
//
|
||||
// Get response from nekos.life
|
||||
//
|
||||
|
||||
const { response, url } = await globalObject.nekosClient.sfw["eightBall"]({
|
||||
text,
|
||||
})
|
||||
|
||||
//
|
||||
// Reply
|
||||
//
|
||||
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: response,
|
||||
image: { url },
|
||||
footer: { text: "powered by nekos.life" },
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
59
src/commands/fun/clapify.ts
Normal file
59
src/commands/fun/clapify.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { Formatters, MessageEmbed } from "discord.js"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
import type { Args, CommandOptions } from "@sapphire/framework"
|
||||
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["c", "clap"],
|
||||
description:
|
||||
"Does the annoying Karen clap.👏Does👏not👏work👏with👏external👏emojis.",
|
||||
})
|
||||
export default class ClapifyCommand extends CustomCommand {
|
||||
usage = `> {$} [message to clapify]
|
||||
|
||||
e.g.
|
||||
> {$} I said bring me the manager.
|
||||
`
|
||||
|
||||
async messageRun(message: Message, args: Args): Promise<void> {
|
||||
const inputs = await args.repeat("string").catch(() => [])
|
||||
|
||||
//
|
||||
// Handle empty argument
|
||||
//
|
||||
|
||||
if (!inputs) {
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Error!",
|
||||
description: "What should I clapify?",
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//
|
||||
// Reply
|
||||
//
|
||||
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
description: `**${Formatters.userMention(message.author.id)} says:**
|
||||
|
||||
${ClapifyCommand.clapify(inputs)}`,
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
static clapify(inputs: string[]): string {
|
||||
return inputs.join("👏")
|
||||
}
|
||||
}
|
30
src/commands/fun/fact.ts
Normal file
30
src/commands/fun/fact.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { MessageEmbed } from "discord.js"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
import type { CommandOptions } from "@sapphire/framework"
|
||||
|
||||
import { globalObject } from "../.."
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["f", "facts"],
|
||||
description: "Shows useless facts.",
|
||||
})
|
||||
export default class FactCommand extends CustomCommand {
|
||||
usage = "> {$}"
|
||||
|
||||
async messageRun(message: Message) {
|
||||
message.channel.sendTyping()
|
||||
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Here's a fact for you",
|
||||
description: (await globalObject.nekosClient.sfw.fact()).fact,
|
||||
footer: { text: "powered by nekos.life" },
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
205
src/commands/fun/image.ts
Normal file
205
src/commands/fun/image.ts
Normal file
|
@ -0,0 +1,205 @@
|
|||
import { CommandOptions } from "@sapphire/framework"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
import { Formatters, MessageEmbed } from "discord.js"
|
||||
|
||||
import type NekoClient from "nekos.life"
|
||||
import type { FunctionKeys, $PropertyType } from "utility-types"
|
||||
import type { Message } from "discord.js"
|
||||
import type { Args } from "@sapphire/framework"
|
||||
|
||||
import { isChannelInMessageNSFW, caseInsensitiveIndexOf } from "../../util"
|
||||
import { globalObject } from "../.."
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
type nsfwOptionsType = FunctionKeys<$PropertyType<NekoClient, "nsfw">>
|
||||
type sfwOptionsType = Exclude<
|
||||
FunctionKeys<$PropertyType<NekoClient, "sfw">>,
|
||||
"why" | "catText" | "OwOify" | "eightBall" | "fact" | "spoiler"
|
||||
>
|
||||
|
||||
const nsfwOptions: nsfwOptionsType[] = Object.getOwnPropertyNames(
|
||||
globalObject.nekosClient.nsfw
|
||||
) as nsfwOptionsType[]
|
||||
|
||||
const sfwOptions: sfwOptionsType[] = Object.getOwnPropertyNames(
|
||||
globalObject.nekosClient.sfw
|
||||
).filter(
|
||||
(elem) =>
|
||||
// the return values for these options do not have the url attribute
|
||||
!["why", "catText", "OwOify", "eightBall", "fact", "spoiler"].includes(elem)
|
||||
) as sfwOptionsType[]
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["i", "img", "images"],
|
||||
description: "Shows some good images",
|
||||
})
|
||||
export default class ImageCommand extends CustomCommand {
|
||||
usage = `> {$} [<"nsfw"|"sfw"|"list"|"help"> <image type>]
|
||||
|
||||
e.g.
|
||||
List all available image types:
|
||||
> {$} help
|
||||
|
||||
Show an image of a puppy:
|
||||
> {$} woof
|
||||
`
|
||||
|
||||
async messageRun(message: Message, args: Args): Promise<void> {
|
||||
const option1 = (await args.pick("string").catch(() => "")).toLowerCase()
|
||||
const option2 = await args.pick("string").catch(() => "")
|
||||
|
||||
//
|
||||
// Show help message
|
||||
//
|
||||
|
||||
if (!option1 || !option2 || option1 === "list" || option1 === "help") {
|
||||
this.list(message)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//
|
||||
// Handle invalid input
|
||||
//
|
||||
|
||||
if (option1 != "sfw" && option1 != "nsfw") {
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Error!",
|
||||
description:
|
||||
"Option should be either `list`, `help`, `nsfw` or `sfw`",
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
// Logic starts here
|
||||
//
|
||||
|
||||
message.channel.sendTyping()
|
||||
|
||||
//
|
||||
// Get option index
|
||||
//
|
||||
|
||||
let index = -1
|
||||
|
||||
if (option1 === "sfw") {
|
||||
index = caseInsensitiveIndexOf(nsfwOptions, option2)
|
||||
}
|
||||
|
||||
if (option1 === "nsfw") {
|
||||
index = caseInsensitiveIndexOf(nsfwOptions, option2)
|
||||
}
|
||||
|
||||
// check if option is valid
|
||||
if (index < 0) {
|
||||
this.option2NotFound(message, option2)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//
|
||||
// check if the channel allows NSFW content
|
||||
//
|
||||
|
||||
// todo: handle server-wide NSFW settings
|
||||
if (option1 === "nsfw" && !isChannelInMessageNSFW(message)) {
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Error!",
|
||||
description: "You cannot run this command outside NSFW channels.",
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
//
|
||||
// Get image url
|
||||
//
|
||||
|
||||
let url = ""
|
||||
if (option1 === "sfw") {
|
||||
url = (await globalObject.nekosClient.sfw[sfwOptions[index]]()).url
|
||||
}
|
||||
if (option1 === "nsfw") {
|
||||
url = (await globalObject.nekosClient.nsfw[nsfwOptions[index]]()).url
|
||||
}
|
||||
|
||||
//
|
||||
// Send image
|
||||
//
|
||||
|
||||
this.sendImage(message, url)
|
||||
}
|
||||
|
||||
sendImage(message: Message, url: string): void {
|
||||
if (!message.member) {
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Error",
|
||||
description: "Failed to identify command caller",
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Image",
|
||||
description: `requested by: ${Formatters.userMention(
|
||||
message.member.id
|
||||
)}\n**[Click if you don't see the image](${url})**`,
|
||||
image: { url },
|
||||
footer: { text: "powered by nekos.life" },
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
option2NotFound(message: Message, option: string): void {
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Error!",
|
||||
description: `Option \`${option}\` is not a valid option.`,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
this.list(message)
|
||||
}
|
||||
|
||||
list(message: Message): void {
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Image Options",
|
||||
fields: [
|
||||
{
|
||||
name: "Usage",
|
||||
value: this.usage,
|
||||
},
|
||||
{
|
||||
name: "NSFW",
|
||||
value: `\`${nsfwOptions.join("`, `")}\``,
|
||||
},
|
||||
{
|
||||
name: "SFW",
|
||||
value: `\`${sfwOptions.join("`, `")}\``,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
34
src/commands/fun/llama.ts
Normal file
34
src/commands/fun/llama.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { CommandOptions } from "@sapphire/framework"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
import { MessageEmbed } from "discord.js"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
|
||||
import { settings, fetchSettings } from "../../DB"
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["l", "llamaQuote", "llamaQuotes", "lq"],
|
||||
description: "Shows a random llama quote.",
|
||||
})
|
||||
export default class LlamaCommand extends CustomCommand {
|
||||
usage = "> {$}"
|
||||
|
||||
async messageRun(message: Message) {
|
||||
if (!settings.quotes) {
|
||||
await fetchSettings()
|
||||
}
|
||||
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Llama quote that'll make your day",
|
||||
description:
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
settings.quotes[Number(message.id) % settings.quotes.length],
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
42
src/commands/fun/owo.ts
Normal file
42
src/commands/fun/owo.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { Formatters, MessageEmbed } from "discord.js"
|
||||
import { CommandOptions } from "@sapphire/framework"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
import type { Args } from "@sapphire/framework"
|
||||
|
||||
import { globalObject } from "../.."
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["owoify"],
|
||||
description: "OwOifies youw message OwO",
|
||||
})
|
||||
export default class CatCommand extends CustomCommand {
|
||||
usage = "> {$} [message to owoify]"
|
||||
|
||||
async messageRun(message: Message, args: Args) {
|
||||
message.channel.sendTyping()
|
||||
|
||||
// combine all arguments to a single string
|
||||
const input = [...(await args.repeat("string").catch(() => ""))].join(" ")
|
||||
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "OwO",
|
||||
description: `**${Formatters.userMention(message.author.id)} says:**
|
||||
|
||||
${
|
||||
(
|
||||
await globalObject.nekosClient.sfw.OwOify({
|
||||
text: input,
|
||||
})
|
||||
).owo
|
||||
}`,
|
||||
footer: { text: "powered by nekos.life" },
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
114
src/commands/fun/pp.ts
Normal file
114
src/commands/fun/pp.ts
Normal file
|
@ -0,0 +1,114 @@
|
|||
import { Formatters } from "discord.js"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
import type { Args, CommandOptions } from "@sapphire/framework"
|
||||
|
||||
import { sendEmbeddedMessage } from "../../util"
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
interface PPUser {
|
||||
id: string
|
||||
length: number
|
||||
}
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["penis"],
|
||||
description: `Measure user's pp length and arrange them in descending order.
|
||||
|
||||
Shortest length (0):
|
||||
\`8D\`
|
||||
Longest length (30):
|
||||
\`8==============================D\`
|
||||
|
||||
This is 101% accurate.`,
|
||||
})
|
||||
export default class PPCommand extends CustomCommand {
|
||||
usage = `> {$} [user]*
|
||||
|
||||
e.g.
|
||||
Measure yourself:
|
||||
> {$}
|
||||
|
||||
Measure someone else's pp:
|
||||
> {$} @someone
|
||||
|
||||
Measure multiple people's pp:
|
||||
> {$} @someone @sometwo ...
|
||||
`
|
||||
|
||||
async messageRun(message: Message, args: Args) {
|
||||
let membersRaw: string[] = await args.repeat("string").catch(() => [])
|
||||
|
||||
// handle 0 argument case
|
||||
if (membersRaw.length <= 0) {
|
||||
if (!message.member) {
|
||||
sendEmbeddedMessage(message.channel, {
|
||||
title: "Error",
|
||||
description: "Failed to get user",
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
membersRaw = [message.author.id]
|
||||
}
|
||||
|
||||
const users = PPCommand.calculatePPLengths(membersRaw)
|
||||
sendEmbeddedMessage(message.channel, {
|
||||
title: "pp",
|
||||
description: PPCommand.buildPPList(users),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates pp lengths for a list of people.
|
||||
*
|
||||
* @param membersRaw - A list of discord snowflakes to convert to pp length.
|
||||
* @returns A sorted array of users from lowest to highest.
|
||||
*/
|
||||
static calculatePPLengths(membersRaw: string[]): PPUser[] {
|
||||
const users: PPUser[] = []
|
||||
|
||||
for (const memberRaw of membersRaw) {
|
||||
const numbersInString = memberRaw.match(/\d+/)
|
||||
if (!numbersInString) continue
|
||||
const memberIDStr = numbersInString[0]
|
||||
if (!memberIDStr) continue
|
||||
const memberID = parseInt(memberIDStr)
|
||||
if (!memberID) continue
|
||||
|
||||
try {
|
||||
users.push({
|
||||
id: memberIDStr,
|
||||
length: memberID % 31 /* Calculation happens here */,
|
||||
})
|
||||
} catch (e) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// sort users ascending by pp length
|
||||
users.sort((prev, curr) => curr.length - prev.length)
|
||||
|
||||
return users
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the final text that will be shown to the user.
|
||||
*
|
||||
* @param users - A list of user and their pp size.
|
||||
*/
|
||||
static buildPPList(users: PPUser[]): string {
|
||||
let ppList = ""
|
||||
|
||||
for (const user of users) {
|
||||
const userMention = Formatters.userMention(user.id)
|
||||
|
||||
ppList += `${userMention}:\n`
|
||||
ppList += `8${"=".repeat(user.length)}D **(${user.length})**\n`
|
||||
}
|
||||
|
||||
return ppList
|
||||
}
|
||||
}
|
15
src/commands/fun/tests/ClapifyCommand.test.ts
Normal file
15
src/commands/fun/tests/ClapifyCommand.test.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import ClapifyCommand from "../clapify"
|
||||
|
||||
test("Correctly adds clap between the words", () => {
|
||||
expect(
|
||||
ClapifyCommand.clapify([
|
||||
"<@123456789012345678>",
|
||||
"<#123456789012345678>",
|
||||
"<@&123456789012345678>",
|
||||
"word",
|
||||
"1234",
|
||||
])
|
||||
).toStrictEqual(
|
||||
"<@123456789012345678>👏<#123456789012345678>👏<@&123456789012345678>👏word👏1234"
|
||||
)
|
||||
})
|
27
src/commands/fun/tests/PPCommand.test.ts
Normal file
27
src/commands/fun/tests/PPCommand.test.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import PPCommand from "../pp"
|
||||
|
||||
test("Correctly builds pp list", () => {
|
||||
const ppList = PPCommand.calculatePPLengths([
|
||||
"<@123456789012345678>",
|
||||
"<@111111111111111111>",
|
||||
"<@333333333333333333>",
|
||||
"<@444444444444444444>",
|
||||
])
|
||||
|
||||
expect(ppList).toStrictEqual([
|
||||
{ id: "123456789012345678", length: 24 },
|
||||
{ id: "444444444444444444", length: 13 },
|
||||
{ id: "111111111111111111", length: 11 },
|
||||
{ id: "333333333333333333", length: 2 },
|
||||
])
|
||||
|
||||
expect(PPCommand.buildPPList(ppList)).toStrictEqual(`<@123456789012345678>:
|
||||
8========================D **(24)**
|
||||
<@444444444444444444>:
|
||||
8=============D **(13)**
|
||||
<@111111111111111111>:
|
||||
8===========D **(11)**
|
||||
<@333333333333333333>:
|
||||
8==D **(2)**
|
||||
`)
|
||||
})
|
76
src/commands/util/about.ts
Normal file
76
src/commands/util/about.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { SnowflakeUtil, MessageEmbed } from "discord.js"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
import type { CommandOptions } from "@sapphire/framework"
|
||||
|
||||
import { countDays, formatDate, formatTimeDiff } from "../../util"
|
||||
import { globalObject } from "../.."
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["a", "u", "uptime"],
|
||||
description: "Shows basic information about the bot.",
|
||||
})
|
||||
export default class AboutCommand extends CustomCommand {
|
||||
usage = "> {$}"
|
||||
|
||||
async messageRun(message: Message) {
|
||||
const now = message.editedTimestamp || message.createdTimestamp
|
||||
const startTime = globalObject.startTime
|
||||
|
||||
if (!startTime || !this.container.client.id)
|
||||
return this.failedToGetStartTime(message)
|
||||
|
||||
const formattedUptime = formatTimeDiff(startTime, now)
|
||||
const botCreatedTime = SnowflakeUtil.deconstruct(this.container.client.id)
|
||||
const formattedBotCreationDate = formatDate(botCreatedTime.date)
|
||||
const botAgeInDays = countDays(botCreatedTime.date.getTime(), now)
|
||||
|
||||
const serversCount = this.container.client.guilds.cache.size || 0
|
||||
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "About",
|
||||
fields: [
|
||||
{
|
||||
name: "Creation date in UTC",
|
||||
value: `${formattedBotCreationDate} (${botAgeInDays} days ago)`,
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: "Uptime",
|
||||
value: formattedUptime,
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: "Servers",
|
||||
value: `The bot is in ${serversCount} server${
|
||||
serversCount > 1 ? "s" : ""
|
||||
}.`,
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: "Source Code",
|
||||
value: "https://github.com/llama-bot",
|
||||
inline: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
failedToGetStartTime(message: Message): void {
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Failed to run command",
|
||||
description:
|
||||
"The bot failed to get one or more information about the bot.",
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
26
src/commands/util/invite.ts
Normal file
26
src/commands/util/invite.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { CommandOptions } from "@sapphire/framework"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
import { MessageEmbed } from "discord.js"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
description: "Shows information about inviting the bot.",
|
||||
})
|
||||
export default class InviteCommand extends CustomCommand {
|
||||
usage = "> {$}"
|
||||
|
||||
async messageRun(message: Message) {
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Sorry",
|
||||
description: `Sorry, but only the owner can invite the bot.
|
||||
Check the [documentation](https://docs.llama.developomp.com/docs/overview#can-i-use-this-bot-in-my-discord-server) for more information.`,
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
61
src/commands/util/ping.ts
Normal file
61
src/commands/util/ping.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { MessageEmbed } from "discord.js"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
import type { CommandOptions } from "@sapphire/framework"
|
||||
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["p", "pong", "latency"],
|
||||
description:
|
||||
"Measures communication delay (latency) in 1/1000 of a second, also known as millisecond (ms).",
|
||||
})
|
||||
export default class PingCommand extends CustomCommand {
|
||||
usage = "> {$}"
|
||||
|
||||
async messageRun(message: Message) {
|
||||
const response = await message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
description: `Called by ${message.author}`,
|
||||
fields: [
|
||||
{
|
||||
name: "API Latency",
|
||||
value: "...",
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: "Bot latency",
|
||||
value: "...",
|
||||
inline: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
response.edit({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
description: `Called by ${message.author}`,
|
||||
fields: [
|
||||
{
|
||||
name: "API latency",
|
||||
value: `${
|
||||
(response.editedTimestamp || response.createdTimestamp) -
|
||||
(message.editedTimestamp || message.createdTimestamp)
|
||||
}ms`,
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: "Bot latency",
|
||||
value: `${Math.round(this.container.client.ws.ping)}ms`,
|
||||
inline: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
230
src/commands/util/serverInfo.ts
Normal file
230
src/commands/util/serverInfo.ts
Normal file
|
@ -0,0 +1,230 @@
|
|||
import { Formatters, MessageEmbed, SnowflakeUtil } from "discord.js"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
|
||||
import type { Guild, Message } from "discord.js"
|
||||
import type { CommandOptions } from "@sapphire/framework"
|
||||
|
||||
import {
|
||||
formatDate,
|
||||
formatTimeDiff,
|
||||
highlightIndex,
|
||||
formatNumber,
|
||||
} from "../../util"
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
interface Data {
|
||||
// general info
|
||||
guildName: string
|
||||
guildIconUrl: string
|
||||
guildOwner: string
|
||||
membersCount: number
|
||||
maxMembers: number | null
|
||||
communityStatus: string
|
||||
partneredStatus: string
|
||||
verifiedStatus: string
|
||||
ScanningStatus: string
|
||||
verificationStatus: string
|
||||
nitroBoosts: number
|
||||
boostLevel: number
|
||||
|
||||
// creation date
|
||||
guildCreationDate: string
|
||||
guildAge: string
|
||||
|
||||
// channels
|
||||
totalChannelCount: number
|
||||
textChannelCount: number
|
||||
voiceChannelCount: number
|
||||
announcementChannelCount: number
|
||||
stageChannelCount: number
|
||||
storeChannelCount: number
|
||||
}
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["si"],
|
||||
description: "Show information about the server",
|
||||
})
|
||||
export default class ServerInfoCommand extends CustomCommand {
|
||||
usage = "> {$}"
|
||||
|
||||
// value of VerificationLevels from "discord.js/typings/enums"
|
||||
// DO NOT CHANGE!!
|
||||
verificationLevels = ["NONE", "LOW", "MEDIUM", "HIGH", "VERY_HIGH"]
|
||||
|
||||
// value of ExplicitContentFilterLevels from "discord.js/typings/enums"
|
||||
// DO NOT CHANGE!!
|
||||
explicitContentFilterLevels = [
|
||||
"DISABLED",
|
||||
"MEMBERS_WITHOUT_ROLES",
|
||||
"ALL_MEMBERS",
|
||||
]
|
||||
|
||||
// value of PremiumTiers from "discord.js/typings/enums"
|
||||
// DO NOT CHANGE!!
|
||||
premiumTiers = ["NONE", "TIER_1", "TIER_2", "TIER_3"]
|
||||
|
||||
async messageRun(message: Message) {
|
||||
message.channel.sendTyping()
|
||||
|
||||
if (!message.guild) {
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Error!",
|
||||
description: "This command only works in servers",
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.reply(message, message.guild)
|
||||
}
|
||||
|
||||
async getData(message: Message, guild: Guild): Promise<Data> {
|
||||
//
|
||||
// general info
|
||||
//
|
||||
|
||||
const guildName = guild.name
|
||||
const guildIconUrl = guild.iconURL() || ""
|
||||
const guildOwner = Formatters.userMention(guild.ownerId)
|
||||
|
||||
const membersCount = guild.memberCount
|
||||
const maxMembers = guild.maximumMembers
|
||||
|
||||
const communityStatus = highlightIndex(
|
||||
["Yes", "No"],
|
||||
guild.features.includes("COMMUNITY") ? 0 : 1
|
||||
)
|
||||
const partneredStatus = highlightIndex(
|
||||
["Yes", "No"],
|
||||
guild.features.includes("PARTNERED") ? 0 : 1
|
||||
)
|
||||
const verifiedStatus = highlightIndex(
|
||||
["Yes", "No"],
|
||||
guild.features.includes("VERIFIED") ? 0 : 1
|
||||
)
|
||||
|
||||
const ScanningStatus = highlightIndex(
|
||||
this.explicitContentFilterLevels,
|
||||
this.explicitContentFilterLevels.indexOf(guild.explicitContentFilter)
|
||||
)
|
||||
const verificationStatus = highlightIndex(
|
||||
this.verificationLevels,
|
||||
this.verificationLevels.indexOf(guild.verificationLevel)
|
||||
)
|
||||
|
||||
const nitroBoosts = guild.premiumSubscriptionCount || 0
|
||||
const boostLevel = this.premiumTiers.indexOf(guild.premiumTier)
|
||||
|
||||
//
|
||||
// creation date
|
||||
//
|
||||
|
||||
const serverCreatedTime = SnowflakeUtil.deconstruct(guild.id)
|
||||
const guildCreationDate = formatDate(serverCreatedTime.date)
|
||||
const guildAge = formatTimeDiff(
|
||||
serverCreatedTime.date.getTime(),
|
||||
message.editedTimestamp || message.createdTimestamp
|
||||
)
|
||||
|
||||
//
|
||||
// channels
|
||||
//
|
||||
|
||||
const channels = await guild.channels.fetch()
|
||||
|
||||
const totalChannelCount = channels.size
|
||||
let textChannelCount = 0
|
||||
let voiceChannelCount = 0
|
||||
let announcementChannelCount = 0
|
||||
let stageChannelCount = 0
|
||||
let storeChannelCount = 0
|
||||
|
||||
for (const [, channel] of channels) {
|
||||
if (channel.type === "GUILD_TEXT") textChannelCount += 1
|
||||
if (channel.type === "GUILD_VOICE") voiceChannelCount += 1
|
||||
if (channel.type === "GUILD_NEWS") announcementChannelCount += 1
|
||||
if (channel.type === "GUILD_STAGE_VOICE") stageChannelCount += 1
|
||||
if (channel.type === "GUILD_STORE") storeChannelCount += 1
|
||||
}
|
||||
|
||||
return {
|
||||
// general info
|
||||
guildName,
|
||||
guildIconUrl,
|
||||
guildOwner,
|
||||
membersCount,
|
||||
maxMembers,
|
||||
communityStatus,
|
||||
partneredStatus,
|
||||
verifiedStatus,
|
||||
ScanningStatus,
|
||||
verificationStatus,
|
||||
nitroBoosts,
|
||||
boostLevel,
|
||||
|
||||
// creation date
|
||||
guildCreationDate,
|
||||
guildAge,
|
||||
|
||||
// channels
|
||||
totalChannelCount,
|
||||
textChannelCount,
|
||||
voiceChannelCount,
|
||||
announcementChannelCount,
|
||||
stageChannelCount,
|
||||
storeChannelCount,
|
||||
}
|
||||
}
|
||||
|
||||
async reply(message: Message, guild: Guild): Promise<void> {
|
||||
const data = await this.getData(message, guild)
|
||||
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: data.guildName,
|
||||
thumbnail: { url: data.guildIconUrl },
|
||||
|
||||
description: `Owner: ${data.guildOwner}
|
||||
|
||||
Members: Capacity: ${formatNumber(data.membersCount)} / ${formatNumber(
|
||||
data.maxMembers
|
||||
)}
|
||||
|
||||
Community: ${data.communityStatus}
|
||||
Partnered: ${data.partneredStatus}
|
||||
Verified: ${data.verifiedStatus}
|
||||
|
||||
Scanning: ${data.ScanningStatus}
|
||||
Verification: ${data.verificationStatus}
|
||||
|
||||
Nitro boosts: ${data.nitroBoosts} (Lv ${data.boostLevel})`,
|
||||
|
||||
fields: [
|
||||
{
|
||||
name: "Creation date",
|
||||
value: `Creation date in UTC (24h time): ${data.guildCreationDate}
|
||||
|
||||
${data.guildAge} ago`,
|
||||
},
|
||||
|
||||
{
|
||||
name: "Channels",
|
||||
value: `Channels: ${data.totalChannelCount}
|
||||
Text: ${data.textChannelCount}
|
||||
Voice: ${data.voiceChannelCount}
|
||||
Announcement: ${data.announcementChannelCount}
|
||||
Stage: ${data.stageChannelCount}
|
||||
Store: ${data.storeChannelCount}`,
|
||||
},
|
||||
],
|
||||
footer: { text: `Server ID: ${guild.id}` },
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
68
src/commands/util/snowflake.ts
Normal file
68
src/commands/util/snowflake.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { SnowflakeUtil, MessageEmbed } from "discord.js"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
import type { Args, CommandOptions } from "@sapphire/framework"
|
||||
|
||||
import { countDays, formatDate, formatTimeDiff } from "../../util"
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["s"],
|
||||
description: "Calculates when a discord ID (snowflake) was created.",
|
||||
})
|
||||
export default class SnowflakeCommand extends CustomCommand {
|
||||
usage = "> {$} <discord snowflake>"
|
||||
|
||||
async messageRun(message: Message, args: Args) {
|
||||
let input: string
|
||||
|
||||
try {
|
||||
input = await args.pick("string")
|
||||
} catch {
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Error",
|
||||
description: "You did not pass any snowflake :(",
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const now = message.editedTimestamp || message.createdTimestamp
|
||||
const creationDate = SnowflakeUtil.deconstruct(input).date
|
||||
const dateDelta = countDays(creationDate.getTime(), now)
|
||||
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: input,
|
||||
fields: [
|
||||
{
|
||||
name: "Creation Date (UTC)",
|
||||
value: `${formatDate(creationDate)} (${dateDelta} days ago)`,
|
||||
},
|
||||
{
|
||||
name: "Age",
|
||||
value: formatTimeDiff(creationDate.getTime(), now),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
} catch {
|
||||
message.channel.send({
|
||||
embeds: [
|
||||
new MessageEmbed({
|
||||
title: "Error",
|
||||
description: `Failed to parse snowflake \`${input}\``,
|
||||
}),
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
99
src/commands/util/userInfo.ts
Normal file
99
src/commands/util/userInfo.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
// todo: nitro boost
|
||||
|
||||
import { Formatters, MessageEmbed } from "discord.js"
|
||||
import { CommandOptions } from "@sapphire/framework"
|
||||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
|
||||
import type { GuildMember, Message } from "discord.js"
|
||||
import type { Args } from "@sapphire/framework"
|
||||
|
||||
import { formatDate, formatTimeDiff } from "../../util"
|
||||
import CustomCommand from "../../custom/CustomCommand"
|
||||
|
||||
@ApplyOptions<CommandOptions>({
|
||||
aliases: ["ui"],
|
||||
description: "Shows information about a user.",
|
||||
})
|
||||
export default class UserInfoCommand extends CustomCommand {
|
||||
usage = `> {$} [user]
|
||||
|
||||
e.g.
|
||||
Get your user info
|
||||
> {$}
|
||||
|
||||
Get someone else's user info by mentioning
|
||||
> {$} @someone
|
||||
|
||||
Get someone else's user info with user id
|
||||
> {$} 123456789012345678
|
||||
`
|
||||
|
||||
async messageRun(message: Message, args: Args) {
|
||||
let user = await args.pick("user").catch(() => undefined)
|
||||
|
||||
if (!user) user = message.author
|
||||
|
||||
const avatarURL = user.avatarURL() || undefined
|
||||
|
||||
const resultEmbed = new MessageEmbed({
|
||||
author: { name: user.tag, icon_url: avatarURL },
|
||||
thumbnail: { url: avatarURL },
|
||||
description: Formatters.userMention(user.id),
|
||||
footer: { text: `USER ID: ${user.id}` },
|
||||
})
|
||||
|
||||
resultEmbed.addField(
|
||||
"Discord join date",
|
||||
this.formattedJoinDate(user.createdAt)
|
||||
)
|
||||
|
||||
if (message.guild) {
|
||||
const member = await message.guild.members.fetch(user.id)
|
||||
|
||||
resultEmbed.addField(
|
||||
"Server join date",
|
||||
this.formattedJoinDate(member.joinedAt)
|
||||
)
|
||||
|
||||
const roleMentions = this.getMemberRoles(member, message.guild.id)
|
||||
resultEmbed.addField(
|
||||
`Roles (${roleMentions.length})`,
|
||||
roleMentions.join(" ")
|
||||
)
|
||||
}
|
||||
|
||||
message.channel.send({
|
||||
embeds: [resultEmbed],
|
||||
})
|
||||
}
|
||||
|
||||
formattedJoinDate(joinedAt: Date | null): string {
|
||||
let result = "Unknown"
|
||||
|
||||
if (joinedAt) {
|
||||
const xAgo = formatTimeDiff(joinedAt.getTime(), Date.now())
|
||||
|
||||
result = `${formatDate(joinedAt)}\n${xAgo} ago`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
getMemberRoles(member: GuildMember, guildID: string): string[] {
|
||||
return [...member.roles.cache.entries()] // returns a list if [role_id, Role]
|
||||
.map((elem) => elem[0]) // only get role IDs
|
||||
.filter((value) => value != guildID) // remove @everyone role
|
||||
.map((id) => Formatters.roleMention(id)) // converts role ID to mention string
|
||||
}
|
||||
|
||||
// converts "SOMETHING_LIKE_THIS" to "Something Like This"
|
||||
// from https://stackoverflow.com/a/32589289/12979111
|
||||
convertCase(input: string): string {
|
||||
return input
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.substring(1))
|
||||
.join(" ")
|
||||
}
|
||||
}
|
14
src/custom/CustomCommand.ts
Normal file
14
src/custom/CustomCommand.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Command } from "@sapphire/framework"
|
||||
|
||||
import type { PieceContext } from "@sapphire/framework"
|
||||
|
||||
export default abstract class CustomCommand extends Command {
|
||||
public constructor(context: PieceContext, options?: Command.Options) {
|
||||
super(context, {
|
||||
...options,
|
||||
|
||||
// default preconditions
|
||||
preconditions: ["NoDM", ...(options?.preconditions || [])],
|
||||
})
|
||||
}
|
||||
}
|
78
src/index.ts
Normal file
78
src/index.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { SapphireClient } from "@sapphire/framework"
|
||||
import { start as startPrettyError } from "pretty-error"
|
||||
import nekosClient from "nekos.life"
|
||||
import dotenv from "dotenv"
|
||||
import "./DB"
|
||||
|
||||
function initializeEnv() {
|
||||
dotenv.config()
|
||||
// do not start the bot if token is not found
|
||||
if (!process.env.TOKEN) throw Error("Token not found!")
|
||||
|
||||
// set to default values if not defined already
|
||||
process.env.TESTING ??= "false"
|
||||
process.env.PREFIX_PROD ??= "-"
|
||||
process.env.PREFIX_DEV ??= "b-"
|
||||
|
||||
process.env.PREFIX =
|
||||
process.env.TESTING == "true"
|
||||
? process.env.PREFIX_DEV
|
||||
: process.env.PREFIX_PROD
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
startPrettyError()
|
||||
initializeEnv()
|
||||
}
|
||||
|
||||
initialize()
|
||||
|
||||
export const globalObject = {
|
||||
startTime: 0,
|
||||
nekosClient: new nekosClient(),
|
||||
}
|
||||
|
||||
const client = new SapphireClient({
|
||||
caseInsensitiveCommands: true,
|
||||
caseInsensitivePrefixes: true,
|
||||
defaultPrefix: process.env.PREFIX,
|
||||
partials: [
|
||||
// necessary for DM events to work
|
||||
// https://discordjs.guide/popular-topics/partials.html#enabling-partials
|
||||
|
||||
"CHANNEL",
|
||||
|
||||
// necessary for reaction detection to work
|
||||
|
||||
"MESSAGE",
|
||||
"REACTION",
|
||||
"USER",
|
||||
],
|
||||
intents: [
|
||||
"DIRECT_MESSAGE_REACTIONS",
|
||||
"DIRECT_MESSAGE_TYPING",
|
||||
"DIRECT_MESSAGES",
|
||||
|
||||
"GUILD_MESSAGE_REACTIONS",
|
||||
"GUILD_MESSAGE_TYPING",
|
||||
"GUILD_MESSAGES",
|
||||
],
|
||||
defaultCooldown: {
|
||||
delay: 1_000,
|
||||
filteredUsers: process.env.OWNER_IDS.split(","),
|
||||
limit: 5,
|
||||
},
|
||||
})
|
||||
|
||||
//
|
||||
// start the bot
|
||||
//
|
||||
|
||||
try {
|
||||
client.login(process.env.TOKEN)
|
||||
} catch (err) {
|
||||
console.log("The bot crashed :(")
|
||||
console.error(err)
|
||||
|
||||
client.destroy()
|
||||
}
|
37
src/listeners/commands/commandError.ts
Normal file
37
src/listeners/commands/commandError.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { Listener } from "@sapphire/framework"
|
||||
|
||||
import type { CommandErrorPayload } from "@sapphire/framework"
|
||||
|
||||
/**
|
||||
* Gets executed when the bot encounters an error.
|
||||
*/
|
||||
export class CommandError extends Listener {
|
||||
run(_error: unknown, payload: CommandErrorPayload) {
|
||||
this.logToConsole(payload)
|
||||
this.logToChannels(payload)
|
||||
this.logToFirebase(payload)
|
||||
}
|
||||
|
||||
async logToConsole(payload: CommandErrorPayload): Promise<void> {
|
||||
const message = payload.message
|
||||
const author = message.author
|
||||
|
||||
console.error(`
|
||||
===============[ ERROR ]===============
|
||||
Author: ${author.id} (${author.tag})
|
||||
URL: ${message.url}
|
||||
Content: ${message.content}
|
||||
=======================================
|
||||
`)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async logToChannels(payload: CommandErrorPayload): Promise<void> {
|
||||
//
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async logToFirebase(payload: CommandErrorPayload): Promise<void> {
|
||||
//
|
||||
}
|
||||
}
|
33
src/listeners/messages/messageCreate.ts
Normal file
33
src/listeners/messages/messageCreate.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Listener } from "@sapphire/framework"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
|
||||
import { sendEmbeddedMessage } from "../../util"
|
||||
|
||||
/**
|
||||
* Gets executed when a message is sent
|
||||
*/
|
||||
export class MessageCreate extends Listener {
|
||||
autoDeleteSeconds = 10
|
||||
|
||||
run(message: Message) {
|
||||
if (message.channel.type === "DM") this.handleDMs(message)
|
||||
}
|
||||
|
||||
async handleDMs(message: Message): Promise<void> {
|
||||
// ignore messages from other bots (or even itself)
|
||||
if (message.author.bot) return
|
||||
|
||||
const responseMessage = await sendEmbeddedMessage(message.channel, {
|
||||
title: "❗ DM commands are not supported.",
|
||||
description: `Add a broom (🧹) emoji to a messages sent by me to delete them.
|
||||
This message will deleted in ${this.autoDeleteSeconds} seconds.`,
|
||||
})
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, this.autoDeleteSeconds * 1000)
|
||||
)
|
||||
|
||||
responseMessage.delete()
|
||||
}
|
||||
}
|
31
src/listeners/messages/messageReactionAdd.ts
Normal file
31
src/listeners/messages/messageReactionAdd.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { Listener } from "@sapphire/framework"
|
||||
|
||||
import type { MessageReaction, PartialMessageReaction } from "discord.js"
|
||||
|
||||
import { settings, fetchSettings } from "../../DB"
|
||||
|
||||
/**
|
||||
* Gets executed when a reaction is added to a message
|
||||
*/
|
||||
export class MessageReactionAdd extends Listener {
|
||||
run(reaction: MessageReaction | PartialMessageReaction) {
|
||||
if (reaction.message.channel.type === "DM") this.handleDMs(reaction)
|
||||
}
|
||||
|
||||
async handleDMs(
|
||||
reaction: MessageReaction | PartialMessageReaction
|
||||
): Promise<void> {
|
||||
const message = await reaction.message.fetch()
|
||||
|
||||
if (
|
||||
!reaction.emoji.name ||
|
||||
!this.container.client.id ||
|
||||
this.container.client.id !== message.author.id
|
||||
)
|
||||
return
|
||||
|
||||
if (!settings.clear_emojis) await fetchSettings()
|
||||
|
||||
if (settings.clear_emojis?.includes(reaction.emoji.name)) message.delete()
|
||||
}
|
||||
}
|
34
src/listeners/ready.ts
Normal file
34
src/listeners/ready.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { ApplyOptions } from "@sapphire/decorators"
|
||||
import { Listener } from "@sapphire/framework"
|
||||
import { gray, yellow } from "colorette"
|
||||
|
||||
import type { ListenerOptions } from "@sapphire/framework"
|
||||
|
||||
import { globalObject } from ".."
|
||||
|
||||
const y = yellow
|
||||
|
||||
@ApplyOptions<ListenerOptions>({
|
||||
once: true,
|
||||
})
|
||||
export class Ready extends Listener {
|
||||
run() {
|
||||
globalObject.startTime = Date.now()
|
||||
|
||||
this.printReady()
|
||||
}
|
||||
|
||||
printReady(): void {
|
||||
const botTag = this.container.client.user?.tag || "unknown bot tag"
|
||||
const botID = this.container.client.user?.id || "unknown bot ID"
|
||||
const botMode =
|
||||
process.env.TESTING === "true" ? "DEVELOPMENT" : "PRODUCTION"
|
||||
|
||||
console.log(
|
||||
gray(`
|
||||
${y(botTag)} (ID: ${y(botID)}) is Ready!
|
||||
Mode: ${y(botMode)}
|
||||
`)
|
||||
)
|
||||
}
|
||||
}
|
18
src/preconditions/AdminsOnly.ts
Normal file
18
src/preconditions/AdminsOnly.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Precondition } from "@sapphire/framework"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
|
||||
/**
|
||||
* Only allow commands sent in non-DM channels
|
||||
*/
|
||||
export default class AdminsOnlyPrecondition extends Precondition {
|
||||
async run(message: Message) {
|
||||
if (!message.guild) return this.error()
|
||||
|
||||
const guild = await message.guild.fetch()
|
||||
const member = await guild.members.fetch(message.author.id)
|
||||
|
||||
if (member.permissions.has("ADMINISTRATOR")) return this.ok()
|
||||
return this.error()
|
||||
}
|
||||
}
|
14
src/preconditions/NoDM.ts
Normal file
14
src/preconditions/NoDM.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { Precondition } from "@sapphire/framework"
|
||||
|
||||
import type { Message } from "discord.js"
|
||||
|
||||
/**
|
||||
* Only allow commands sent in non-DM channels
|
||||
*/
|
||||
export default class NoDMPrecondition extends Precondition {
|
||||
run(message: Message) {
|
||||
if (message.channel.type === "DM") return this.error()
|
||||
|
||||
return this.ok()
|
||||
}
|
||||
}
|
31
src/preconditions/OwnersOnly.ts
Normal file
31
src/preconditions/OwnersOnly.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { Precondition } from "@sapphire/framework"
|
||||
|
||||
import type { Snowflake } from "discord-api-types"
|
||||
import type { Message } from "discord.js"
|
||||
|
||||
import { sendEmbeddedMessage } from "../util"
|
||||
|
||||
export default class OwnersOnlyPrecondition extends Precondition {
|
||||
// IDs of users who can run owners only commands
|
||||
owners: Snowflake[] = []
|
||||
|
||||
run(message: Message) {
|
||||
if (this.owners.length <= 0) {
|
||||
// convert comma separated string to array and remove empty values
|
||||
// trailing comma and double comma can result in empty values
|
||||
this.owners = process.env.OWNER_IDS.split(",").filter((elem) => elem)
|
||||
}
|
||||
|
||||
if (this.owners.includes(message.author.id)) {
|
||||
return this.ok()
|
||||
}
|
||||
|
||||
sendEmbeddedMessage(message.channel, {
|
||||
title: "Permission Error!",
|
||||
description: `Only the bot owners can use this command!
|
||||
[message](${message.url})`,
|
||||
})
|
||||
|
||||
return this.error()
|
||||
}
|
||||
}
|
27
src/types/bot.d.ts
vendored
Normal file
27
src/types/bot.d.ts
vendored
Normal file
|
@ -0,0 +1,27 @@
|
|||
export type Snowflake = string
|
||||
// structure: "<snowflake>/<snowflake>"
|
||||
export type MessageSelector = string
|
||||
|
||||
export interface Settings {
|
||||
// emoji names
|
||||
clear_emojis?: string[]
|
||||
// quotes to be used in the `llama` command
|
||||
quotes?: string[]
|
||||
}
|
||||
|
||||
export interface ServerData {
|
||||
settings: {
|
||||
// todo: enable/disable commands by default
|
||||
// todo: enabled/disabled commands/categories
|
||||
}
|
||||
|
||||
vars: {
|
||||
channels: { [key: string]: Snowflake }
|
||||
messages: { [key: string]: MessageSelector }
|
||||
roles: { [key: string]: Snowflake }
|
||||
}
|
||||
}
|
||||
|
||||
export interface Servers {
|
||||
[key: Snowflake]: ServerData
|
||||
}
|
15
src/types/process.d.ts
vendored
Normal file
15
src/types/process.d.ts
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
[key: string]: string | undefined
|
||||
|
||||
// .env values
|
||||
TOKEN: string
|
||||
TESTING: string
|
||||
PREFIX_PROD: string
|
||||
PREFIX_DEV: string
|
||||
OWNER_IDS: string // ID1,ID2,ID3,...
|
||||
|
||||
// default prefix currently being used
|
||||
PREFIX: string
|
||||
}
|
||||
}
|
13
src/types/sapphire.d.ts
vendored
Normal file
13
src/types/sapphire.d.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
import "@sapphire/framework"
|
||||
|
||||
declare module "@sapphire/framework" {
|
||||
abstract class Command {
|
||||
usage?: string
|
||||
}
|
||||
|
||||
interface Preconditions {
|
||||
AdminsOnly: never
|
||||
OwnersOnly: never
|
||||
NoDM: never
|
||||
}
|
||||
}
|
11
src/util/caseInsensitiveIndexOf.ts
Normal file
11
src/util/caseInsensitiveIndexOf.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Finds the index of {@link query} in {@link input}.
|
||||
*
|
||||
* - Returns -1 if {@link query} was not found in {@link input}.
|
||||
*
|
||||
* @param input - An array of string
|
||||
* @param query - String to find
|
||||
*/
|
||||
export default function (input: string[], query: string): number {
|
||||
return input.findIndex((elem) => query.toLowerCase() === elem.toLowerCase())
|
||||
}
|
12
src/util/countDays.ts
Normal file
12
src/util/countDays.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Counts the number of days in between {@link startDate} and {@link endDate}.
|
||||
*
|
||||
* - Returns NaN if either of the arguments are NaN.
|
||||
* - Result could be a negative number if the {@link startDate} is greater than {@link endDate}.
|
||||
*
|
||||
* @param startDate - Starting date in milliseconds
|
||||
* @param endDate - Ending date in milliseconds
|
||||
*/
|
||||
export default function (startDate: number, endDate: number): number {
|
||||
return Math.ceil((endDate - startDate) / (1000 * 60 * 60 * 24))
|
||||
}
|
15
src/util/extractUrlsFromString.ts
Normal file
15
src/util/extractUrlsFromString.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* From [URI.js](https://github.com/medialize/URI.js).
|
||||
* More information can be found in [this stackOverflow answer](https://stackoverflow.com/a/11209098).
|
||||
*/
|
||||
const pattern =
|
||||
/\b((?:[a-z][\w-]+:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()[\]{};:'".,<>?«»“”‘’]))/gi
|
||||
|
||||
/**
|
||||
* Extract URLs from string
|
||||
*
|
||||
* @param input - Raw string to check
|
||||
*/
|
||||
export default function (input: string): string[] {
|
||||
return input.match(pattern) || []
|
||||
}
|
16
src/util/formatDate.ts
Normal file
16
src/util/formatDate.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Formats {@link date} to `YYYY-MM-DD hh:mm:ss`.
|
||||
*
|
||||
* @param date - Raw date object
|
||||
*/
|
||||
export default function (date: Date): string {
|
||||
const YYYY = date.getFullYear()
|
||||
const MM = date.getMonth() + 1 // starts from 0 for some reason
|
||||
const DD = date.getDate()
|
||||
|
||||
const hh = date.getHours()
|
||||
const mm = date.getMinutes()
|
||||
const ss = date.getSeconds()
|
||||
|
||||
return `${YYYY}-${MM}-${DD} ${hh}:${mm}:${ss}`
|
||||
}
|
12
src/util/formatNumber.ts
Normal file
12
src/util/formatNumber.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Add commas to a long, positive number. Does not add comma to negative numbers.
|
||||
*
|
||||
* @param num - raw number
|
||||
*/
|
||||
export default function (num: number | undefined | null): string {
|
||||
if (num === undefined || num === null) return "None"
|
||||
|
||||
if (num <= 999) return String(num)
|
||||
|
||||
return String(num).replace(/(.)(?=(\d{3})+$)/g, "$1,")
|
||||
}
|
43
src/util/formatTimeDiff.ts
Normal file
43
src/util/formatTimeDiff.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
const SECONDS_IN_A_YEAR = 60 * 60 * 24 * 365
|
||||
const SECONDS_IN_A_DAY = 60 * 60 * 24
|
||||
const SECONDS_IN_A_HOUR = 60 * 60
|
||||
const SECONDS_IN_A_MINUTE = 60
|
||||
|
||||
/**
|
||||
* Formats difference in time in a readable format.
|
||||
*
|
||||
* @param startTime - Start date in millisecond
|
||||
* @param endTime - End date in millisecond
|
||||
*/
|
||||
export default function (startTime: number, endTime: number): string {
|
||||
let result = ""
|
||||
|
||||
function addToResult(num: number, unit: string) {
|
||||
if (num) result += ` ${num} ${unit}` + (num > 1 ? "s" : "")
|
||||
}
|
||||
|
||||
let diffSec = (endTime - startTime) / 1000
|
||||
|
||||
// prevent empty response
|
||||
if (diffSec === 0) return "0 second"
|
||||
|
||||
const years = Math.floor(diffSec / SECONDS_IN_A_YEAR)
|
||||
diffSec -= years * SECONDS_IN_A_YEAR
|
||||
addToResult(years, "year")
|
||||
|
||||
const days = Math.floor(diffSec / SECONDS_IN_A_DAY)
|
||||
diffSec -= days * SECONDS_IN_A_DAY
|
||||
addToResult(days, "day")
|
||||
|
||||
const hours = Math.floor(diffSec / SECONDS_IN_A_HOUR) % 24
|
||||
diffSec -= hours * SECONDS_IN_A_HOUR
|
||||
addToResult(hours, "hour")
|
||||
|
||||
const minutes = Math.floor(diffSec / SECONDS_IN_A_MINUTE) % 60
|
||||
diffSec -= minutes * SECONDS_IN_A_MINUTE
|
||||
addToResult(minutes, "minute")
|
||||
|
||||
addToResult(diffSec, "second")
|
||||
|
||||
return result.trim()
|
||||
}
|
20
src/util/highlightIndex.ts
Normal file
20
src/util/highlightIndex.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Highlight a string in a list of entries using markdown syntax.
|
||||
*
|
||||
* - If {@link index} is -1 or is greater than the last index of {@link entries}, it will leave all entries un-highlighted.
|
||||
* - If {@link entries} does not contain any element, the resulting string will be empty.
|
||||
*
|
||||
* @param index - Index of string to highlight
|
||||
* @param entries - An array of strings that can be highlighted
|
||||
* @param separator - What to put between the entries (defaults to " / ")
|
||||
*/
|
||||
export default function (
|
||||
entries: string[],
|
||||
index: number,
|
||||
separator = " / "
|
||||
): string {
|
||||
entries = entries.map((elem, i) => {
|
||||
return i === index ? `**${elem}**` : elem
|
||||
})
|
||||
return entries.join(separator)
|
||||
}
|
9
src/util/index.ts
Normal file
9
src/util/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
export { default as caseInsensitiveIndexOf } from "./caseInsensitiveIndexOf"
|
||||
export { default as countDays } from "./countDays"
|
||||
export { default as formatDate } from "./formatDate"
|
||||
export { default as formatNumber } from "./formatNumber"
|
||||
export { default as highlightIndex } from "./highlightIndex"
|
||||
export { default as isChannelInMessageNSFW } from "./isChannelInMessageNSFW"
|
||||
export { default as formatTimeDiff } from "./formatTimeDiff"
|
||||
export { default as sendEmbeddedMessage } from "./sendEmbeddedMessage"
|
||||
export { default as extractUrlsFromString } from "./extractUrlsFromString"
|
10
src/util/isChannelInMessageNSFW.ts
Normal file
10
src/util/isChannelInMessageNSFW.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import type { Message } from "discord.js"
|
||||
|
||||
/**
|
||||
* Checks if channel where the message sent is a NSFW channel.
|
||||
*
|
||||
* @param message - Message to get the channel
|
||||
*/
|
||||
export default function (message: Message): boolean {
|
||||
return Reflect.get(message.channel, "nsfw") === true
|
||||
}
|
23
src/util/sendEmbeddedMessage.ts
Normal file
23
src/util/sendEmbeddedMessage.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { MessageEmbed } from "discord.js"
|
||||
|
||||
import type {
|
||||
BaseGuildTextChannel,
|
||||
Message,
|
||||
MessageEmbedOptions,
|
||||
TextBasedChannel,
|
||||
} from "discord.js"
|
||||
|
||||
/**
|
||||
* Sends message with one embed.
|
||||
*
|
||||
* @param channel - Text channel to send the embedded message
|
||||
* @param data - Message content to send
|
||||
*/
|
||||
export default function (
|
||||
channel: BaseGuildTextChannel | TextBasedChannel,
|
||||
data: MessageEmbed | MessageEmbedOptions
|
||||
): Promise<Message<boolean>> {
|
||||
return channel.send({
|
||||
embeds: [new MessageEmbed(data)],
|
||||
})
|
||||
}
|
17
src/util/tests/caseInsensitiveIndexOf.test.ts
Normal file
17
src/util/tests/caseInsensitiveIndexOf.test.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import caseInsensitiveIndexOf from "../caseInsensitiveIndexOf"
|
||||
|
||||
test("Correctly identifies index", () => {
|
||||
const array = ["A", "B", "C", "D"]
|
||||
|
||||
array.map((entry, index) => {
|
||||
expect(caseInsensitiveIndexOf(array, entry.toLowerCase())).toStrictEqual(
|
||||
index
|
||||
)
|
||||
})
|
||||
|
||||
expect(
|
||||
caseInsensitiveIndexOf(array, "this does not exist in the array")
|
||||
).toStrictEqual(-1)
|
||||
|
||||
expect(caseInsensitiveIndexOf([], "testing empty array")).toStrictEqual(-1)
|
||||
})
|
24
src/util/tests/countDays.test.ts
Normal file
24
src/util/tests/countDays.test.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import countDays from "../countDays"
|
||||
|
||||
test("Accurately counts days between dates", () => {
|
||||
expect(
|
||||
countDays(
|
||||
new Date("2022/02/22").getTime(),
|
||||
new Date("2022/02/23").getTime()
|
||||
)
|
||||
).toStrictEqual(1)
|
||||
|
||||
expect(
|
||||
countDays(
|
||||
new Date("2022/02/01").getTime(),
|
||||
new Date("2022/03/01").getTime()
|
||||
)
|
||||
).toStrictEqual(28)
|
||||
|
||||
expect(
|
||||
countDays(
|
||||
new Date("this string").getTime(), // NaN
|
||||
new Date("is not a valid date").getTime() // NaN
|
||||
)
|
||||
).toStrictEqual(NaN)
|
||||
})
|
11
src/util/tests/formatDate.test.ts
Normal file
11
src/util/tests/formatDate.test.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import formatDate from "../formatDate"
|
||||
|
||||
test("Properly formats date", () => {
|
||||
expect(formatDate(new Date("Feb 22, 2022 22:22:22"))).toStrictEqual(
|
||||
"2022-2-22 22:22:22"
|
||||
)
|
||||
|
||||
expect(formatDate(new Date("2022/02/22 22:22:22"))).toStrictEqual(
|
||||
"2022-2-22 22:22:22"
|
||||
)
|
||||
})
|
10
src/util/tests/formatNumber.test.ts
Normal file
10
src/util/tests/formatNumber.test.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import formatNumber from "../formatNumber"
|
||||
|
||||
test("Correctly formats numbers", () => {
|
||||
expect(formatNumber(1_000)).toStrictEqual("1,000")
|
||||
expect(formatNumber(999_999_999)).toStrictEqual("999,999,999")
|
||||
expect(formatNumber(0)).toStrictEqual("0")
|
||||
expect(formatNumber(-0)).toStrictEqual("0")
|
||||
expect(formatNumber(-1_000)).toStrictEqual("-1000")
|
||||
expect(formatNumber(-999_999_999)).toStrictEqual("-999999999")
|
||||
})
|
24
src/util/tests/formatTimeDiff.test.ts
Normal file
24
src/util/tests/formatTimeDiff.test.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import formatTimeDiff from "../formatTimeDiff"
|
||||
|
||||
test("Correctly formats time", () => {
|
||||
expect(
|
||||
formatTimeDiff(
|
||||
new Date("2022/02/22").getTime(),
|
||||
new Date("2023/02/23 01:01:01").getTime()
|
||||
)
|
||||
).toStrictEqual("1 year 1 day 1 hour 1 minute 1 second")
|
||||
|
||||
expect(
|
||||
formatTimeDiff(
|
||||
new Date("2022/02/22").getTime(),
|
||||
new Date("2024/02/24 02:02:02").getTime()
|
||||
)
|
||||
).toStrictEqual("2 years 2 days 2 hours 2 minutes 2 seconds")
|
||||
|
||||
expect(
|
||||
formatTimeDiff(
|
||||
new Date("2022/02/22").getTime(),
|
||||
new Date("2022/02/22").getTime()
|
||||
)
|
||||
).toStrictEqual("0 second")
|
||||
})
|
13
src/util/tests/highlightIndex.test.ts
Normal file
13
src/util/tests/highlightIndex.test.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import highlightIndex from "../highlightIndex"
|
||||
|
||||
test("Correctly highlights entry", () => {
|
||||
const array = ["A", "B", "C"]
|
||||
|
||||
expect(highlightIndex(array, -1)).toStrictEqual("A / B / C")
|
||||
expect(highlightIndex(array, 0)).toStrictEqual("**A** / B / C")
|
||||
expect(highlightIndex(array, 1)).toStrictEqual("A / **B** / C")
|
||||
expect(highlightIndex(array, 2)).toStrictEqual("A / B / **C**")
|
||||
expect(highlightIndex(array, 3)).toStrictEqual("A / B / C")
|
||||
|
||||
expect(highlightIndex([], 0)).toStrictEqual("")
|
||||
})
|
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
|
||||
"moduleResolution": "Node",
|
||||
"target": "ES6",
|
||||
"module": "CommonJS",
|
||||
"lib": ["ES6"],
|
||||
|
||||
"removeComments": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitAny": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["_/", "**/*test.ts"]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue