1
0
Fork 0

repo migration

- from https://github.com/llama-bot/llama-bot
This commit is contained in:
Kim, Jimin 2022-12-22 00:07:48 +09:00
parent 998ff503e3
commit b69510ffab
63 changed files with 7360 additions and 2 deletions

2
.eslintignore Normal file
View file

@ -0,0 +1,2 @@
/node_modules/
/dist/

17
.eslintrc Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

146
.gitignore vendored Normal file
View 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
View file

@ -0,0 +1,4 @@
{
"semi": false,
"useTabs": true
}

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}

18
.vscode/settings.json vendored Normal file
View 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
View 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
View 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
View file

@ -1,2 +1,104 @@
**ATTENTION!**<br />
The llama bot has found a new home: https://github.com/llama-bot/llama-bot
# Llama bot
![example image of bot usage](./.github/img/example.png)
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
View file

@ -0,0 +1,5 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
}

36
package.json Normal file
View 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
View 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
View 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
View 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.`,
}),
],
})
}
}

View 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
View 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" },
}),
],
})
}
}

View 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
View 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
View 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
View 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
View 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
View 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
}
}

View 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"
)
})

View 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)**
`)
})

View 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.",
}),
],
})
}
}

View 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
View 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,
},
],
}),
],
})
}
}

View 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}` },
}),
],
})
}
}

View 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}\``,
}),
],
})
}
}
}

View 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(" ")
}
}

View 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
View 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()
}

View 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> {
//
}
}

View 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()
}
}

View 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
View 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)}
`)
)
}
}

View 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
View 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()
}
}

View 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
View 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
View 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
View 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
}
}

View 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
View 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))
}

View 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
View 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
View 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,")
}

View 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()
}

View 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
View 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"

View 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
}

View 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)],
})
}

View 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)
})

View 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)
})

View 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"
)
})

View 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")
})

View 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")
})

View 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
View 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"]
}

4701
yarn.lock Normal file

File diff suppressed because it is too large Load diff