├── .eslintrc
├── .github
└── workflows
│ ├── build-and-release.yml
│ ├── build-and-test.yml
│ └── build.yml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── assets
├── solaire-banner.png
└── solaire-drawing.png
├── example-bot
├── index.js
├── package-lock.json
└── package.json
├── jest.config.js
├── package-lock.json
├── package.json
├── src
├── __tests__
│ ├── command-collection.test.ts
│ ├── command-processing.test.ts
│ ├── command-runner.test.ts
│ ├── command-validate.test.ts
│ ├── command.test.ts
│ ├── solaire_commands_guards.test.ts
│ └── solaire_event-emitting.test.ts
├── command-collection.ts
├── command-invocation-error.ts
├── command-processing.ts
├── command-runner.ts
├── command-validate.ts
├── command.ts
├── discord-message-utils.ts
├── index.ts
└── solaire.ts
├── test
├── async.ts
├── discord-mocks.ts
└── solaire-tester.ts
└── tsconfig.json
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "@typescript-eslint/parser",
4 | "parserOptions": {
5 | "ecmaVersion": 12,
6 | "sourceType": "module"
7 | },
8 | "plugins": [
9 | "@typescript-eslint",
10 | "prettier"
11 | ],
12 | "extends": [
13 | "eslint:recommended",
14 | "plugin:@typescript-eslint/eslint-recommended",
15 | "plugin:@typescript-eslint/recommended",
16 | "prettier"
17 | ],
18 | "rules": {
19 | "prettier/prettier": 2
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.github/workflows/build-and-release.yml:
--------------------------------------------------------------------------------
1 | name: Build And Release
2 | on: [push]
3 | jobs:
4 | build:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v2
8 | # Setup .npmrc file to publish to npm
9 | - uses: actions/setup-node@v2
10 | with:
11 | node-version: '12.x'
12 | registry-url: 'https://registry.npmjs.org'
13 | - run: npm install
14 | - run: npm run build
15 | - run: npm run test
16 | - run: npm publish
17 | env:
18 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
19 |
--------------------------------------------------------------------------------
/.github/workflows/build-and-test.yml:
--------------------------------------------------------------------------------
1 | name: Build And Test
2 | on: [pull_request]
3 | jobs:
4 | build:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v2
8 | # Setup .npmrc file to publish to npm
9 | - uses: actions/setup-node@v2
10 | with:
11 | node-version: '12.x'
12 | registry-url: 'https://registry.npmjs.org'
13 | - run: npm install
14 | - run: npm run build
15 | - run: npm run test
16 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build And Release
2 | on:
3 | push:
4 | branches:
5 | - master
6 | jobs:
7 | build:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v2
11 | # Setup .npmrc file to publish to npm
12 | - uses: actions/setup-node@v2
13 | with:
14 | node-version: '12.x'
15 | registry-url: 'https://registry.npmjs.org'
16 | - run: npm install
17 | - run: npm run build
18 | - run: npm run test
19 | - run: npm publish
20 | env:
21 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "trailingComma": "none",
4 | "singleQuote": true,
5 | "printWidth": 80
6 | }
7 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Weston Selleck
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Solaire
4 |
5 | [](https://badge.fury.io/js/solaire-discord)
6 |
7 | A lightweight framework with a simple interface for creating Discord bots in Node
8 |
9 | ```js
10 | import Discord from 'discord.js';
11 | import { Solaire } from "solaire-discord";
12 |
13 | const client = new Discord.Client({
14 | intents: [ Discord.Intents.FLAGS.GUILDS, Discord.Intents.FLAGS.GUILD_MESSAGES ]
15 | });
16 |
17 | const bot = Solaire.create({
18 | discordClient: client,
19 | token: process.env.TOKEN,
20 | commandPrelude: "!",
21 | commands: {
22 | // In a Discord channel...
23 | // > !ban @someUser being mean
24 | "ban <...reason>": {
25 | execute({ args, message }) {
26 | // args.user: Discord.js::GuildMember(someUser)
27 | // args.offense: ["being", "mean"]
28 | message.channel.send(`Banning ${args.user.displayName} for ${args.reason.join(' ')}!`;
29 | },
30 | },
31 | },
32 | });
33 |
34 | bot.start();
35 |
36 | ```
37 |
38 | ### Discord.js
39 | Solaire interacts heavily with [Discord.js](https://github.com/discordjs/discord.js), and many of the objects exposed from the Solaire API will be directly from Discord.js.
40 |
41 | **Solaire requires that you provide a Discord.js client version >=13.0.0**
42 |
43 | ### 📣 Simplicity & Limitations 📣
44 | Solaire is very much targetted at developers working on smaller or simpler Discord bots that don't require some of the more advanced features of existing popular Discord bot frameworks, and just want something that will get their bot up and running quickly. More advanced features may be added in the future, but the guiding principle of the framework will always be simplicity in its API.
45 |
46 | If you don't find Solaire's feature-set to be advanced enough for your use case, there are other great Discord/Node frameworks to take a look at
47 | - [sapphire](https://github.com/sapphiredev/framework)
48 | - [discord-akairo](https://github.com/discord-akairo/discord-akairo)
49 | - [chookscord](https://github.com/chookscord/framework)
50 |
51 | #### Slash Commands
52 | Solaire does **not** utilize the new Discord [slash commands feature](https://blog.discord.com/slash-commands-are-here-8db0a385d9e6), instead listening for plain new message events and parsing those to figure out which command to execute. There are no plans to add support for slash commands to Solaire, and I instead recommend [chookscord](https://github.com/chookscord/framework), which features the simplest API I've seen for creating slash commands.
53 |
54 | ---
55 |
56 | **[Example Bot](./example-bot)**
57 |
58 | [Install](#install) ·
59 | [Example Config](#example-config) ·
60 | [Defining Commands](#defining-commands) ·
61 | [Command Configuration](#command-configuration) ·
62 | [Events](#events)
63 |
64 | ---
65 |
66 | ## Install
67 | `npm install solaire-discord`
68 |
69 | ## Config
70 |
71 | | Property | Required | Type | Desc |
72 | |-------------------|----------|----------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
73 | | `discordClient` | Yes | `Discord.js::Client` | A Discord.js Client object. This client must have the `GUILD_MESSAGES` intent enabled for Solaire to work properly. |
74 | | `token` | Yes | `string` | Your bot's Discord token (see https://discord.com/developers/docs/intro) |
75 | | `commandPrelude` | No | `string` | The string that must precede a command's name in a Discord message for the command to be invoked. Common values are `!`, `?`, `;;`, but any string would technically work. |
76 | | `commandCooldown` | No | `number` | The amount of time in milliseconds that a command is un-invokable after being used. This cooldown is _per-command_. |
77 | | `commands` | Yes | `Record` | See [Defining commands](#defining-commands) and [Command configuration](#command-configuration) |
78 |
79 | ## Defining commands
80 | In Solaire, bot commands are defined using a definition string that resembles how you would actually use the command in Discord. For example, a command that is used like `!ban @someAnnoyingUser being mean`, would be defined using the string `ban [...reason]`.
81 |
82 | This string, along with associated configuration for the command, is passed in via your Solaire config's `commands` property.
83 |
84 | ```js
85 | const bot = Solaire.create({
86 | ...
87 | commands: {
88 | 'ban <...reason>': {
89 | ...
90 | }
91 | }
92 | })
93 | ```
94 |
95 | ### Command Name & Aliases
96 | A command's name is defined as the first word in your command definition string
97 | ```
98 | ban
99 | ^------ "ban" is the command's name
100 | ```
101 |
102 | You can define aliases for a command by appending the command's name with `|`, e.g.
103 | ```
104 | ban|b|banMember
105 | ^---- "ban is the command's name, but "b" and "banMember" can also be used to invoke the command
106 | ```
107 |
108 |
109 | ### Command Arguments
110 | After your command's name, you can define any number of arguments that can be passed into your command.
111 |
112 | **Required Arguments**
113 |
114 | Required arguments are denoted in the definition string by being wrapped in `<>`, e.g.
115 | ```
116 | ban
117 | ^---- "user" is a required argument for the "ban" command
118 | ```
119 |
120 | **Optional Arguments**
121 |
122 | Optional arguments are denoted by being wrapped in `[]`, e.g.
123 | ```
124 | ban [reason]
125 | ^---- "reason" is an optional argument
126 | ```
127 |
128 | When an optional argument is defined, the remaining arguments in the command must also be optional.
129 |
130 | ```
131 | ban [reason>
132 | ^----- INVALID - since "reason" is optional, all arguments after it must also be optional
133 | ```
134 |
135 | **Rest Arguments**
136 |
137 | A "rest" argument is an arg whose value is defined as all remaining words in a message. They are denoted by the arg's name being preceded with `...`. e.g.
138 |
139 | ```
140 | ban [reason]
141 | > !ban @someAnnoyingUser being mean
142 | ^----- "reason" arg has value "being"
143 |
144 | ban [...reason]
145 | > !ban @someAnnoyingUser being mean
146 | ^----- "reason" arg has value "being mean"
147 | ```
148 |
149 | A rest argument must be the last argument of a command. When accessing the argument in your execute, guard, etc. functions, the value of the argument will be an array.
150 |
151 | #### Argument Types
152 |
153 | An argument's value can be constrained by defining an explicit `type` for that argument, denoted in the command definition string by appending the argument's name with `:`, e.g.
154 |
155 | ```
156 | ban
157 | ^---- "user" arg value must be parseable into a "GuildMember" type
158 | ```
159 |
160 | Defining an argument type has a few benefits
161 |
162 | - It validates that the passed in value is valid
163 | - It automatically parses the argument and fits it to its type, transforming the value to a more convenient data type for use when processing and executing the command
164 | - It provides documentation for how our command is supposed to be used
165 |
166 | The available argument types are:
167 |
168 | | Argument Type | Validation | Resolved JS Type |
169 | |---------------|--------------------------------------------------------------------------|---------------------------|
170 | | Int | Validates using `parseInt` | `Number` |
171 | | Float | Validates using `parseFloat` | `Number` |
172 | | GuildMember | Validates that ID passed in resolves to a member of the message's server | `Discord.js::GuildMember` |
173 | | Date | Validates using `new Date()` | `Date`
174 |
175 |
176 |
177 | ## Command Configuration
178 | ### Command Execute Function
179 | When your command is invoked, the command's `execute` function gets called.
180 |
181 | ```js
182 | const bot = Solaire.create({
183 | ...
184 | commandPrelude: '!',
185 | commands: {
186 | 'ban [...reason]': {
187 | async execute({ args, message }) {
188 | // message: Discord.js::Message
189 | // args.user: Discord.js::GuildMember
190 | // args.reason: string[]
191 |
192 | const fullReason = args.reason.join(' ');
193 |
194 | message.channel.send(`Banning ${args.user.displayName} for ${fullReason}`;
195 |
196 | user.ban({ reason: fullReason })
197 | }
198 | }
199 | }
200 | })
201 | ```
202 |
203 | ```
204 | > !ban @someAnnoyingUser mean
205 | < Banning Some Annoying User for mean
206 | ```
207 |
208 | The payload that gets passed into the `execute` function contains the following properties
209 |
210 | | Property | Type | Desc |
211 | |-----------|-----------------------|----------------------------------------|
212 | | `args` | `Record` | The arguments passed into the command |
213 | | `message` | `Discord.js::Message` | The message that triggered the command |
214 |
215 |
216 | ### Command Authorization
217 |
218 | You can restrict which users can invoke a command by defining a `guard` function for a command.
219 |
220 | ```js
221 | const bot = Solaire.create({
222 | ...
223 | commandPrelude: '!',
224 | commands: {
225 | 'ban [...reason]': {
226 | async execute({ args, message }) {...},
227 | async guard({ error, ok, message, args}) {
228 | if(!message.member.roles.cache.some(r => r.name.toLowerCase() === 'admin'){
229 | error('Member must be an admin');
230 | } else {
231 | ok();
232 | }
233 | }
234 | }
235 | }
236 | })
237 | ```
238 | The payload provided to the `guard` function is the same as the one given to the `execute` function, with the addition of two new callback properties `ok` and `error`. If a `guard` function is provided, the command will be exected **only if** `guard` calls the `ok` function, **and** the `error` function is **not** called. If neither is called, the command will default closed and not execute.
239 |
240 | ### Command Prelude
241 | It is heavily suggested that you assign a `commandPrelude` to your bot, which is the string that is required at the start of any command invocation. Otherwise, Solaire has to process every single message for the possibility that it's invoking a command. It's also just nan extremely common practice for chat bots.
242 |
243 | ```js
244 | const bot = Solaire.create({
245 | ...
246 | // To invoke a command in chat, the message has to start with '!'
247 | // e.g. ❌ ban @someUser being mean WON'T work
248 | // ✅ !ban @someUser being mean WILL work
249 | commandPrelude: '!',
250 | commands: {
251 | 'ban ': {
252 | ...
253 | }
254 | }
255 | })
256 | ```
257 |
258 | ```
259 | > !ban @someAnnoyingUser mean
260 | ```
261 |
262 |
263 |
264 |
265 | ```
266 | > !ban @someAnnoyingUser being mean
267 | < Banning Some Annoying User for being mean
268 | ```
269 |
270 | ## Events
271 |
272 | The Solaire class extends `EventEmitter`, and emits events that you can listen to.
273 |
274 | ### `commandInvokedEnd`
275 |
276 | This event gets emitted after a bot command is invoked and Solaire has finished processing the invocation. The object that is passed to the listener has the following properties
277 |
278 | | Property | Type | Desc |
279 | |-----------|--------------------------|---------------------------------------------------|
280 | | `success` | `boolean` | Whether or not the command executed successfully |
281 | | `command` | `Command` | The Command that was invoked |
282 | | `message` | `Discord.js::Message` | The message that invoked the command |
283 | | `error` | `CommandInvocationError` | See [`command-invocation-error.ts`](./src/command-invocation-error.ts) for all possible values |
284 |
--------------------------------------------------------------------------------
/assets/solaire-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wwselleck/solaire-discord/b0159065f4c9038aaaa88ee66be326343a7fc01f/assets/solaire-banner.png
--------------------------------------------------------------------------------
/assets/solaire-drawing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wwselleck/solaire-discord/b0159065f4c9038aaaa88ee66be326343a7fc01f/assets/solaire-drawing.png
--------------------------------------------------------------------------------
/example-bot/index.js:
--------------------------------------------------------------------------------
1 | const Discord = require('discord.js');
2 | const { Solaire } = require('solaire-discord');
3 |
4 | class Farm {
5 | animals = [];
6 | petHistory = [];
7 |
8 | addAnimal(kind, name) {
9 | let newAnimalName = name;
10 | if (!newAnimalName) {
11 | const currentOfKind = this.animals.find(
12 | (otherAnimal) => otherAnimal.kind === kind
13 | );
14 | newAnimalName = `${kind} ${currentOfKind + 1}`;
15 | }
16 | this.animals.push({ kind, name: newAnimalName });
17 | }
18 |
19 | all() {
20 | return this.animals;
21 | }
22 |
23 | pet(idUser, name) {
24 | const animalWithNameExists = Boolean(
25 | this.animals.find((animal) => animal.name === name)
26 | );
27 |
28 | if (!animalWithNameExists) {
29 | throw new Error(`Cannot pet animal ${name}, they're not in the farm`);
30 | }
31 |
32 | this.petHistory.push({ date: new Date(), idUser, name });
33 | }
34 |
35 | getPetHistory() {
36 | return this.petHistory;
37 | }
38 | }
39 |
40 | const farm = new Farm();
41 |
42 | /**
43 | * Example usage:
44 | *
45 | * > !add Cow Alfred
46 | * > !farm
47 | * Alfred the Cow
48 | *
49 | */
50 | const bot = Solaire.create({
51 | discordClient: new Discord.Client({
52 | intents: [
53 | Discord.Intents.FLAGS.GUILDS,
54 | Discord.Intents.FLAGS.GUILD_MESSAGES
55 | ]
56 | }),
57 | token: process.env.TOKEN || '',
58 | commandPrelude: '!',
59 | commandCooldown: 2000,
60 | commands: {
61 | 'add-animal|add [animalName]': {
62 | execute({ args }) {
63 | const { animalKind, animalName } = args;
64 | farm.addAnimal(animalKind, animalName);
65 | }
66 | },
67 | farm: {
68 | execute({ message }) {
69 | const animals = farm.all();
70 | let response = '';
71 |
72 | if (animals.length > 0) {
73 | animals.forEach((animal) => {
74 | response += `${animal.name} the ${animal.kind}\n`;
75 | });
76 | } else {
77 | response = 'There are no animals in the farm :(';
78 | }
79 |
80 | message.channel.send(response);
81 | }
82 | },
83 | 'pet [times:Int]': {
84 | execute({ message, args }) {
85 | const { name, times = 1 } = args;
86 | for (let i = 0; i < times; i++) {
87 | try {
88 | farm.pet(message.author.id, name);
89 | } catch (e) {
90 | message.channel.send(e.message);
91 | }
92 | }
93 | }
94 | },
95 | 'petHistory|pets [user:GuildMember]': {
96 | async execute({ message, args }) {
97 | const petHistory = farm.getPetHistory().reverse();
98 |
99 | const { user } = args;
100 |
101 | let petsToOutput = user
102 | ? petHistory.filter((pet) => pet.idUser === user.user.id).slice(0, 10)
103 | : petHistory.slice(0, 10);
104 |
105 | let result = '';
106 | for (const pet of petsToOutput) {
107 | const userWhoPet = await message.guild.members.fetch(pet.idUser);
108 | result += `${pet.date.toString()} ${userWhoPet.displayName} pet ${
109 | pet.name
110 | }\n`;
111 | }
112 | return message.channel.send(result);
113 | }
114 | },
115 | 'closeFarm|close': {
116 | async execute({ message }) {
117 | message.channel.send('The farm is now closed!');
118 | },
119 | async guard({ message, error, ok }) {
120 | if (
121 | !message.member.roles.cache.some(
122 | (r) => r.name.toLowerCase() === 'farmer'
123 | )
124 | ) {
125 | error('');
126 | }
127 | ok();
128 | }
129 | }
130 | }
131 | });
132 |
133 | farm.addAnimal('cow', 'benny');
134 |
135 | bot.on('commandInvokedEnd', (evt) => {
136 | console.log(evt);
137 | });
138 |
139 | bot.start();
140 |
--------------------------------------------------------------------------------
/example-bot/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "example-bot",
3 | "version": "1.0.0",
4 | "description": "",
5 | "scripts": {
6 | "start": "node index.js"
7 | },
8 | "author": "",
9 | "license": "ISC",
10 | "dependencies": {
11 | "discord.js": "^13.5.0",
12 | "solaire-discord": "file:.."
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
2 | module.exports = {
3 | preset: 'ts-jest',
4 | testEnvironment: 'node',
5 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "solaire-discord",
3 | "version": "1.0.0",
4 | "description": "",
5 | "author": "wwselleck@gmail.com",
6 | "main": "dist/src/index.js",
7 | "types": "dist/src/index.d.ts",
8 | "license": "ISC",
9 | "scripts": {
10 | "build": "tsc",
11 | "test": "jest ./src/**/*.test.ts",
12 | "lint": "which eslint && eslint --fix src"
13 | },
14 | "repository": {
15 | "type": "git",
16 | "url": "git+https://github.com/wwselleck/solaire-discord.git"
17 | },
18 | "files": [
19 | "dist"
20 | ],
21 | "bugs": {
22 | "url": "https://github.com/wwselleck/solaire-discord/issues"
23 | },
24 | "homepage": "https://github.com/wwselleck/solaire-discord#readme",
25 | "peerDependencies": {
26 | "discord.js": "^13.0.0"
27 | },
28 | "devDependencies": {
29 | "@types/jest": "^27.0.3",
30 | "@types/node": "^14.14.28",
31 | "@types/ws": "^7.4.0",
32 | "@typescript-eslint/eslint-plugin": "^5.4.0",
33 | "@typescript-eslint/parser": "^5.4.0",
34 | "discord.js": "^13.5.0",
35 | "eslint": "^8.3.0",
36 | "eslint-config-prettier": "^8.3.0",
37 | "eslint-plugin-prettier": "^4.0.0",
38 | "jest": "^27.3.1",
39 | "prettier": "^2.4.1",
40 | "ts-jest": "^27.0.7",
41 | "ts-node": "^9.1.1",
42 | "typescript": "^4.5.4"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/__tests__/command-collection.test.ts:
--------------------------------------------------------------------------------
1 | import { CommandCollection } from '../command-collection';
2 |
3 | const MockCommands = () => ({
4 | standard: {
5 | name: 'standard',
6 | execute: jest.fn()
7 | },
8 | hasAlias: {
9 | name: 'hasAlias',
10 | aliases: ['hasAl'],
11 | execute: jest.fn()
12 | }
13 | });
14 |
15 | describe('CommandCollection', () => {
16 | let mockCommands: ReturnType;
17 |
18 | beforeEach(() => {
19 | mockCommands = MockCommands();
20 | });
21 |
22 | describe('getCommand', () => {
23 | it('gets a command by its name', () => {
24 | const commands = new CommandCollection([mockCommands.standard]);
25 | const command = commands.getCommand('standard');
26 | expect(command).toEqual(mockCommands.standard);
27 | });
28 |
29 | it('gets a command by alias', () => {
30 | const commands = new CommandCollection([mockCommands.hasAlias]);
31 | const command = commands.getCommand('hasAl');
32 | expect(command).toEqual(mockCommands.hasAlias);
33 | });
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/__tests__/command-processing.test.ts:
--------------------------------------------------------------------------------
1 | import { parseCommandMessage, buildExecuteArgs } from '../command-processing';
2 | import { Command } from '../command';
3 | import { MockMessage } from '../../test/discord-mocks';
4 |
5 | const CommandWithArgs = (args: Command['args']): Command => {
6 | return {
7 | name: 'test',
8 | args,
9 | execute() {}
10 | };
11 | };
12 |
13 | describe('parseCommandMessage', () => {
14 | it('should correctly parse a valid command message with a prelude', () => {
15 | const res = parseCommandMessage('!ping hello there', '!');
16 | expect(res.success).toEqual(true);
17 | expect((res as any).result).toEqual({
18 | name: 'ping',
19 | args: ['hello', 'there']
20 | });
21 | });
22 |
23 | it('should correctly parse a valid command message without a prelude', () => {
24 | const res = parseCommandMessage('!ping hello there');
25 | expect(res.success).toEqual(true);
26 | expect((res as any).result).toEqual({
27 | name: '!ping',
28 | args: ['hello', 'there']
29 | });
30 | });
31 |
32 | it('should correctly parse a valid command message without args', () => {
33 | const res = parseCommandMessage('!ping', '!');
34 | expect(res.success).toEqual(true);
35 | expect((res as any).result).toEqual({
36 | name: 'ping',
37 | args: []
38 | });
39 | });
40 |
41 | it('should correctly parse a valid command message without args and trailing whitespace', () => {
42 | const res = parseCommandMessage('!ping ', '!');
43 | expect(res.success).toEqual(true);
44 | expect((res as any).result).toEqual({
45 | name: 'ping',
46 | args: []
47 | });
48 | });
49 |
50 | it('should fail if the message is not a command', () => {
51 | const res = parseCommandMessage('ping hello there', '!');
52 | expect(res.success).toEqual(false);
53 | });
54 |
55 | it('should consider leading whitespace when determining prelude match', () => {
56 | let res = parseCommandMessage(' !ping hello there', '!');
57 | expect(res.success).toEqual(false);
58 |
59 | res = parseCommandMessage(' !ping hello there', ' !');
60 | expect(res.success).toEqual(true);
61 | });
62 | });
63 |
64 | describe('buildExecuteArgs', () => {
65 | it('should correctly build valid args', () => {
66 | const messageArgs = ['a', 'b'];
67 | const commandArgs = [
68 | {
69 | name: 'X',
70 | required: false
71 | },
72 | {
73 | name: 'Y',
74 | required: false
75 | }
76 | ];
77 |
78 | const res = buildExecuteArgs(
79 | MockMessage(),
80 | messageArgs,
81 | CommandWithArgs(commandArgs)
82 | );
83 |
84 | expect(res).toEqual({
85 | X: 'a',
86 | Y: 'b'
87 | });
88 | });
89 |
90 | it('should correctly build args with rest arg', () => {
91 | const messageArgs = ['weston', 'this', 'is', 'a', 'test'];
92 | const commandArgs = [
93 | {
94 | name: 'user',
95 | required: true
96 | },
97 | {
98 | name: 'text',
99 | required: true,
100 | rest: true
101 | }
102 | ];
103 |
104 | const res = buildExecuteArgs(
105 | MockMessage(),
106 | messageArgs,
107 | CommandWithArgs(commandArgs)
108 | );
109 |
110 | expect(res).toEqual({
111 | user: 'weston',
112 | text: 'this is a test'
113 | });
114 | });
115 |
116 | it('should build args if optional arg is missing', () => {
117 | const messageArgs = ['a'];
118 | const commandArgs = [
119 | {
120 | name: 'X',
121 | required: true
122 | },
123 | {
124 | name: 'Y',
125 | required: false
126 | }
127 | ];
128 |
129 | const res = buildExecuteArgs(
130 | MockMessage(),
131 | messageArgs,
132 | CommandWithArgs(commandArgs)
133 | );
134 |
135 | expect(res).toEqual({
136 | X: 'a'
137 | });
138 | });
139 |
140 | it('should build args if some optional args are missing', () => {
141 | const messageArgs = ['a', 'b'];
142 | const commandArgs = [
143 | {
144 | name: 'X',
145 | required: true
146 | },
147 | {
148 | name: 'Y',
149 | required: false
150 | },
151 | {
152 | name: 'Z',
153 | required: false
154 | },
155 | {
156 | name: 'N',
157 | required: false
158 | }
159 | ];
160 |
161 | const res = buildExecuteArgs(
162 | MockMessage(),
163 | messageArgs,
164 | CommandWithArgs(commandArgs)
165 | );
166 |
167 | expect(res).toEqual({
168 | X: 'a',
169 | Y: 'b'
170 | });
171 | });
172 |
173 | it('should fail if a required arg is missing', () => {
174 | const messageArgs = ['a'];
175 | const commandArgs = [
176 | {
177 | name: 'X',
178 | required: true
179 | },
180 | {
181 | name: 'Y',
182 | required: true
183 | }
184 | ];
185 |
186 | try {
187 | buildExecuteArgs(
188 | MockMessage(),
189 | messageArgs,
190 | CommandWithArgs(commandArgs)
191 | );
192 | fail();
193 | } catch (e) {}
194 | });
195 |
196 | describe('type resolving', () => {
197 | it('should resolve an arg with no type as a string', () => {
198 | const messageArgs = ['abc'];
199 | const commandArgs = [
200 | {
201 | name: 'X'
202 | }
203 | ];
204 |
205 | const res = buildExecuteArgs(
206 | MockMessage(),
207 | messageArgs,
208 | CommandWithArgs(commandArgs)
209 | );
210 | expect(res).toEqual({
211 | X: 'abc'
212 | });
213 | });
214 |
215 | it("should resolve an arg with a 'Int' type as an int", () => {
216 | const messageArgs = ['2'];
217 | const commandArgs = [
218 | {
219 | name: 'X',
220 | type: 'Int'
221 | }
222 | ];
223 |
224 | const res = buildExecuteArgs(
225 | MockMessage(),
226 | messageArgs,
227 | CommandWithArgs(commandArgs)
228 | );
229 | expect(res).toEqual({
230 | X: 2
231 | });
232 | });
233 |
234 | it("should resolve an arg with a 'GuildMember' type as a GuildMember", () => {
235 | const messageArgs = ['<@!abc123>'];
236 | const commandArgs = [
237 | {
238 | name: 'user',
239 | type: 'GuildMember'
240 | }
241 | ];
242 |
243 | const res = buildExecuteArgs(
244 | MockMessage(),
245 | messageArgs,
246 | CommandWithArgs(commandArgs)
247 | );
248 | expect(res).toEqual({
249 | user: {
250 | id: 'abc123'
251 | }
252 | });
253 | });
254 |
255 | it("should resolve an arg with a 'Date' type as a Date", () => {
256 | const messageArgs = ['12/2/1980'];
257 | const commandArgs = [
258 | {
259 | name: 'date',
260 | type: 'Date'
261 | }
262 | ];
263 |
264 | const res = buildExecuteArgs(
265 | MockMessage(),
266 | messageArgs,
267 | CommandWithArgs(commandArgs)
268 | );
269 | expect(res.date.getTime()).toEqual(new Date('12/2/1980').getTime());
270 | });
271 |
272 | it("should return an error if an arg of type 'Int' is passed a non-int value", () => {
273 | const messageArgs = ['abdf'];
274 | const commandArgs = [
275 | {
276 | name: 'X',
277 | type: 'Int'
278 | }
279 | ];
280 |
281 | expect(() =>
282 | buildExecuteArgs(
283 | MockMessage(),
284 | messageArgs,
285 | CommandWithArgs(commandArgs)
286 | )
287 | ).toThrow(
288 | expect.objectContaining({
289 | type: 'invalid-arg-value'
290 | })
291 | );
292 | });
293 | });
294 | });
295 |
--------------------------------------------------------------------------------
/src/__tests__/command-runner.test.ts:
--------------------------------------------------------------------------------
1 | import Discord from 'discord.js';
2 | import { CommandRunner } from '../command-runner';
3 | import { CommandCollection } from '../command-collection';
4 |
5 | jest.useFakeTimers('modern');
6 |
7 | const MockMessage = (content: string) => {
8 | return {
9 | content,
10 | reply: jest.fn()
11 | } as unknown as Discord.Message;
12 | };
13 |
14 | const MockCommands = () => ({
15 | standard: {
16 | name: 'standard',
17 | execute: jest.fn()
18 | },
19 | standard2: {
20 | name: 'standard2',
21 | execute: jest.fn()
22 | },
23 | hasAlias: {
24 | name: 'hasAlias',
25 | aliases: ['hasAl'],
26 | execute: jest.fn()
27 | },
28 | oneRequiredArg: {
29 | name: 'oneRequiredArg',
30 | args: [
31 | {
32 | name: 'arg1',
33 | required: true
34 | }
35 | ],
36 | execute: jest.fn()
37 | },
38 | twoRequiredArgs: {
39 | name: 'twoRequiredArgs',
40 | args: [
41 | {
42 | name: 'arg1',
43 | required: true
44 | },
45 | {
46 | name: 'arg2',
47 | required: true
48 | }
49 | ],
50 | execute: jest.fn()
51 | },
52 | noOneCanRun: {
53 | name: 'noOneCanRun',
54 | execute: jest.fn(),
55 | guard: jest.fn(({ error }) => {
56 | error('nope');
57 | })
58 | },
59 | everyoneCanRun: {
60 | name: 'everyoneCanRun',
61 | execute: jest.fn(),
62 | guard: jest.fn()
63 | }
64 | });
65 |
66 | describe('CommandRunner', () => {
67 | let mockCommands: ReturnType;
68 |
69 | beforeEach(() => {
70 | mockCommands = MockCommands();
71 | });
72 |
73 | describe('processMessage', () => {
74 | it('executes correct command', () => {
75 | const runner = new CommandRunner(
76 | new CommandCollection([mockCommands.standard])
77 | );
78 | const msg = MockMessage('standard');
79 | runner.processMessage(msg);
80 | expect(mockCommands.standard.execute).toHaveBeenCalled();
81 | });
82 |
83 | it('doesnt execute commands that were not called in the message', () => {
84 | const runner = new CommandRunner(
85 | new CommandCollection([mockCommands.standard, mockCommands.standard2])
86 | );
87 | const msg = MockMessage('standard');
88 | runner.processMessage(msg);
89 | expect(mockCommands.standard.execute).toHaveBeenCalled();
90 | expect(mockCommands.standard2.execute).not.toHaveBeenCalled();
91 | });
92 |
93 | describe('command prelude', () => {
94 | it('does not execute a command if the message did not include the prelude', () => {
95 | const runner = new CommandRunner(
96 | new CommandCollection([mockCommands.standard]),
97 | {
98 | prelude: '!'
99 | }
100 | );
101 | const msg = MockMessage('standard');
102 | runner.processMessage(msg);
103 | expect(mockCommands.standard.execute).not.toHaveBeenCalled();
104 | });
105 |
106 | it('does executes a command if the message did include the prelude', () => {
107 | const runner = new CommandRunner(
108 | new CommandCollection([mockCommands.standard]),
109 | {
110 | prelude: '!'
111 | }
112 | );
113 | const msg = MockMessage('!standard');
114 | runner.processMessage(msg);
115 | expect(mockCommands.standard.execute).toHaveBeenCalled();
116 | });
117 | });
118 |
119 | describe('command aliases', () => {
120 | it('executes a command if one of its aliases was used', () => {
121 | const runner = new CommandRunner(
122 | new CommandCollection([mockCommands.hasAlias])
123 | );
124 | const msg = MockMessage('hasAl');
125 | runner.processMessage(msg);
126 | expect(mockCommands.hasAlias.execute).toHaveBeenCalled();
127 | });
128 | });
129 |
130 | describe('arguments', () => {
131 | it('passes a required argument to the handler', () => {
132 | const runner = new CommandRunner(
133 | new CommandCollection([mockCommands.oneRequiredArg])
134 | );
135 | const msg = MockMessage('oneRequiredArg testArg');
136 | runner.processMessage(msg);
137 | expect(mockCommands.oneRequiredArg.execute).toHaveBeenCalledWith(
138 | expect.objectContaining({
139 | args: {
140 | arg1: 'testArg'
141 | }
142 | })
143 | );
144 | });
145 | });
146 |
147 | describe('cooldown', () => {
148 | it('blocks command execution if a general cooldown is set and not enough time has surpassed', async () => {
149 | const startTime = 1349852318000;
150 | jest.setSystemTime(startTime);
151 |
152 | const runner = new CommandRunner(
153 | new CommandCollection([mockCommands.standard]),
154 | {
155 | cooldown: 5000
156 | }
157 | );
158 |
159 | await runner.processMessage(MockMessage('standard'));
160 | expect(mockCommands.standard.execute).toHaveBeenCalledTimes(1);
161 | await runner.processMessage(MockMessage('standard'));
162 | expect(mockCommands.standard.execute).toHaveBeenCalledTimes(1);
163 | });
164 |
165 | it('allows command execution if general cooldown is set and enough time has surpassed', async () => {
166 | const cooldown = 5000;
167 |
168 | const startTime = 1349852318000;
169 | jest.setSystemTime(startTime);
170 |
171 | const runner = new CommandRunner(
172 | new CommandCollection([mockCommands.standard]),
173 | {
174 | cooldown
175 | }
176 | );
177 |
178 | await runner.processMessage(MockMessage('standard'));
179 | expect(mockCommands.standard.execute).toHaveBeenCalledTimes(1);
180 |
181 | jest.setSystemTime(startTime + cooldown - 1);
182 | await runner.processMessage(MockMessage('standard'));
183 | expect(mockCommands.standard.execute).toHaveBeenCalledTimes(1);
184 |
185 | jest.setSystemTime(startTime + cooldown);
186 | await runner.processMessage(MockMessage('standard'));
187 | expect(mockCommands.standard.execute).toHaveBeenCalledTimes(2);
188 | });
189 | });
190 |
191 | describe('guard', () => {
192 | it('calls guard if provided', async () => {
193 | const runner = new CommandRunner(
194 | new CommandCollection([mockCommands.noOneCanRun])
195 | );
196 | await expect(
197 | runner.processMessage(MockMessage('noOneCanRun'))
198 | ).resolves.toEqual(expect.anything());
199 | expect(mockCommands.noOneCanRun.guard).toHaveBeenCalled();
200 | });
201 |
202 | it('throws if guard throws', () => {
203 | const runner = new CommandRunner(
204 | new CommandCollection([mockCommands.noOneCanRun])
205 | );
206 | expect(
207 | runner.processMessage(MockMessage('noOneCanRun'))
208 | ).resolves.toEqual(expect.anything());
209 | });
210 |
211 | it('does not throw if guard exists but does not throw', () => {
212 | const runner = new CommandRunner(
213 | new CommandCollection([mockCommands.everyoneCanRun])
214 | );
215 | runner.processMessage(MockMessage('everyoneCanRun'));
216 | });
217 | });
218 | });
219 | });
220 |
--------------------------------------------------------------------------------
/src/__tests__/command-validate.test.ts:
--------------------------------------------------------------------------------
1 | import { Command } from '../command';
2 | import {
3 | validateCommand,
4 | ArgPositionError,
5 | DuplicateArgError
6 | } from '../command-validate';
7 |
8 | const testArg = {
9 | name: 'testArg'
10 | };
11 | const testRestArg = {
12 | name: 'testRestArg',
13 | rest: true
14 | };
15 | const testOptionalArg = {
16 | name: 'testOptionalArg',
17 | required: false
18 | };
19 | const testRequiredArg = {
20 | name: 'testRequiredArg',
21 | required: true
22 | };
23 |
24 | const createTestCommand = (props?: Partial) => {
25 | return {
26 | name: 'test',
27 | aliases: ['t'],
28 | args: [
29 | {
30 | name: 'arg1'
31 | }
32 | ],
33 | execute: jest.fn(),
34 | ...props
35 | };
36 | };
37 |
38 | describe('validateCommand', () => {
39 | it('should throw an error if a rest arg is present but not the final arg', () => {
40 | const command = createTestCommand({
41 | args: [testRestArg, testArg]
42 | });
43 | expect(() => validateCommand(command)).toThrow(ArgPositionError);
44 | });
45 |
46 | it('should throw an error if multiple args have the same name', () => {
47 | const command = createTestCommand({
48 | args: [testArg, testArg]
49 | });
50 | expect(() => validateCommand(command)).toThrow(DuplicateArgError);
51 | });
52 |
53 | it('should throw an error if a required arg comes after an optional arg', () => {
54 | const command = createTestCommand({
55 | args: [testOptionalArg, testRequiredArg]
56 | });
57 | expect(() => validateCommand(command)).toThrow(ArgPositionError);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/__tests__/command.test.ts:
--------------------------------------------------------------------------------
1 | import { parseCommandString } from '../command';
2 |
3 | describe('parseCommandString', () => {
4 | it('parses a command with one name', () => {
5 | const str = 'play';
6 | expect(parseCommandString(str).name).toEqual('play');
7 | });
8 |
9 | it('correctly adds secondary names as aliases', () => {
10 | const str = 'play|p|pl';
11 | expect(parseCommandString(str).aliases).toEqual(['p', 'pl']);
12 | });
13 |
14 | it('parses required args correctly', () => {
15 | const str = 'play|p|pl ';
16 | const res = parseCommandString(str);
17 | expect(res.args).toEqual([
18 | {
19 | name: 'url',
20 | required: true
21 | }
22 | ]);
23 | });
24 |
25 | it('parses optional args correctly', () => {
26 | const str = 'play|p|pl [url]';
27 | const res = parseCommandString(str);
28 | expect(res.args).toEqual([
29 | {
30 | name: 'url',
31 | required: false
32 | }
33 | ]);
34 | });
35 |
36 | it('errors when an arg is not surrounded by valid chars', () => {
37 | const str = 'cmd *url*';
38 | expect(() => parseCommandString(str)).toThrow();
39 | });
40 |
41 | it('parses multiple args correctly', () => {
42 | const str = 'play|p|pl [shuffle]';
43 | const res = parseCommandString(str);
44 | expect(res.args).toEqual([
45 | {
46 | name: 'url',
47 | required: true
48 | },
49 | {
50 | name: 'shuffle',
51 | required: false
52 | }
53 | ]);
54 | });
55 |
56 | it('parses a rest arg correctly', () => {
57 | const str = 'log <...text>';
58 | const res = parseCommandString(str);
59 | expect(res.args).toEqual([
60 | {
61 | name: 'user',
62 | required: true
63 | },
64 | {
65 | name: 'text',
66 | required: true,
67 | rest: true
68 | }
69 | ]);
70 | });
71 |
72 | it('parses an arg type correctly', () => {
73 | const str = 'log ';
74 | const res = parseCommandString(str);
75 | expect(res.args).toEqual([
76 | {
77 | name: 'user',
78 | required: true,
79 | type: 'Member'
80 | }
81 | ]);
82 | });
83 |
84 | it('errors if a rest arg is given a type', () => {
85 | const str = 'log <...text:Member>';
86 | expect(() => parseCommandString(str)).toThrow();
87 | });
88 | });
89 |
--------------------------------------------------------------------------------
/src/__tests__/solaire_commands_guards.test.ts:
--------------------------------------------------------------------------------
1 | import { SolaireTester } from '../../test/solaire-tester';
2 |
3 | describe('Solaire Command Guards', () => {
4 | it('does not call execute when guard check fails', async () => {
5 | const executeFn = jest.fn();
6 | const tester = SolaireTester({
7 | commands: {
8 | test: {
9 | execute: executeFn,
10 | guard({ ok, error }) {
11 | error('not allowed');
12 | }
13 | }
14 | }
15 | });
16 |
17 | await tester.sendMessage('test');
18 |
19 | expect(executeFn).not.toHaveBeenCalled();
20 | });
21 |
22 | it('does not call execute when guard error and ok fns are both called', async () => {
23 | const executeFn = jest.fn();
24 | const tester = SolaireTester({
25 | commands: {
26 | test: {
27 | execute: executeFn,
28 | guard({ ok, error }) {
29 | error('not allowed');
30 | ok();
31 | }
32 | }
33 | }
34 | });
35 |
36 | await tester.sendMessage('test');
37 |
38 | expect(executeFn).not.toHaveBeenCalled();
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/src/__tests__/solaire_event-emitting.test.ts:
--------------------------------------------------------------------------------
1 | import { SolaireTester } from '../../test/solaire-tester';
2 |
3 | jest.useFakeTimers('modern');
4 |
5 | describe('Solaire - Event Emitting', () => {
6 | describe('command invocation', () => {
7 | describe('commandInvokedEnd', () => {
8 | it('emitted when a command is invoked with a missing required arg', async () => {
9 | const { solaire, sendMessage } = SolaireTester({
10 | commands: {
11 | 'test ': {
12 | execute: jest.fn()
13 | }
14 | }
15 | });
16 |
17 | const eventListener = jest.fn();
18 | solaire.on('commandInvokedEnd', eventListener);
19 | await sendMessage('test');
20 |
21 | expect(eventListener).toHaveBeenCalledWith({
22 | success: false,
23 | commandInvoked: true,
24 | command: expect.objectContaining({
25 | name: 'test'
26 | }),
27 | message: expect.any(Object),
28 | error: {
29 | type: 'missing-required-arg',
30 | commandArg: expect.objectContaining({
31 | name: 'requiredArg'
32 | })
33 | }
34 | });
35 | });
36 |
37 | it('emitted when a command is invoked with an invalid arg value', async () => {
38 | const { solaire, sendMessage } = SolaireTester({
39 | commands: {
40 | 'test ': {
41 | execute: jest.fn()
42 | }
43 | }
44 | });
45 |
46 | const eventListener = jest.fn();
47 | solaire.on('commandInvokedEnd', eventListener);
48 | await sendMessage('test oopsnotanint');
49 |
50 | expect(eventListener).toHaveBeenCalledWith({
51 | success: false,
52 | commandInvoked: true,
53 | command: expect.objectContaining({
54 | name: 'test'
55 | }),
56 | message: expect.any(Object),
57 | error: {
58 | type: 'invalid-arg-value',
59 | commandArg: expect.objectContaining({
60 | name: 'requiredArg'
61 | }),
62 | providedValue: 'oopsnotanint'
63 | }
64 | });
65 | });
66 |
67 | it('emitted when a command is invoked by a user who does not pass the guard test', async () => {
68 | const { solaire, sendMessage } = SolaireTester({
69 | commands: {
70 | test: {
71 | execute: jest.fn(),
72 | guard: ({ error }) => error()
73 | }
74 | }
75 | });
76 |
77 | const eventListener = jest.fn();
78 | solaire.on('commandInvokedEnd', eventListener);
79 | await sendMessage('test oopsnotanint');
80 |
81 | expect(eventListener).toHaveBeenCalledWith({
82 | success: false,
83 | commandInvoked: true,
84 | command: expect.objectContaining({
85 | name: 'test'
86 | }),
87 | message: expect.any(Object),
88 | error: {
89 | type: 'blocked-by-guard'
90 | }
91 | });
92 | });
93 |
94 | it('emitted when a command is on cooldown', async () => {
95 | const { solaire, sendMessage } = SolaireTester({
96 | commandCooldown: 1000,
97 | commands: {
98 | test: {
99 | execute: jest.fn()
100 | }
101 | }
102 | });
103 |
104 | const eventListener = jest.fn();
105 | solaire.on('commandInvokedEnd', eventListener);
106 | await sendMessage('test');
107 | await sendMessage('test');
108 |
109 | expect(eventListener).toHaveBeenCalledWith({
110 | success: false,
111 | commandInvoked: true,
112 | command: expect.objectContaining({
113 | name: 'test'
114 | }),
115 | message: expect.any(Object),
116 | error: {
117 | type: 'cooldown-in-effect'
118 | }
119 | });
120 | });
121 |
122 | it('emitted when an command execute fn throws an error', async () => {
123 | const { solaire, sendMessage } = SolaireTester({
124 | commands: {
125 | test: {
126 | execute: () => {
127 | throw new Error('oopsy');
128 | }
129 | }
130 | }
131 | });
132 |
133 | const eventListener = jest.fn();
134 | solaire.on('commandInvokedEnd', eventListener);
135 | await sendMessage('test');
136 |
137 | expect(eventListener).toHaveBeenCalledWith({
138 | success: false,
139 | commandInvoked: true,
140 | command: expect.objectContaining({
141 | name: 'test'
142 | }),
143 | message: expect.any(Object),
144 | error: {
145 | type: 'unhandled-command-execution-error',
146 | error: expect.objectContaining({
147 | message: 'oopsy'
148 | })
149 | }
150 | });
151 | });
152 | });
153 | });
154 | });
155 |
--------------------------------------------------------------------------------
/src/command-collection.ts:
--------------------------------------------------------------------------------
1 | import { Command } from './command';
2 | import { validateCommand } from './command-validate';
3 |
4 | export class CommandCollection {
5 | constructor(private commands: Command[]) {
6 | commands.forEach((command) => validateCommand(command));
7 | }
8 |
9 | addCommand(command: Command) {
10 | this.commands.push(command);
11 | }
12 |
13 | getCommand(keyword: string) {
14 | for (const command of this.commands) {
15 | const keywordMatchesCommand = [
16 | command.name,
17 | ...(command.aliases ?? [])
18 | ].includes(keyword);
19 | if (keywordMatchesCommand) {
20 | return command;
21 | }
22 | }
23 | return null;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/command-invocation-error.ts:
--------------------------------------------------------------------------------
1 | import { Command, CommandArg } from './command';
2 |
3 | export const CooldownInEffect = () => {
4 | return {
5 | type: 'cooldown-in-effect'
6 | } as const;
7 | };
8 |
9 | export const BlockedByGuard = (error: any) => {
10 | return {
11 | type: 'blocked-by-guard',
12 | error
13 | } as const;
14 | };
15 |
16 | export const MissingRequiredArg = (commandArg: CommandArg) => {
17 | return {
18 | type: 'missing-required-arg',
19 | commandArg
20 | } as const;
21 | };
22 |
23 | export const InvalidArgValue = (
24 | commandArg: CommandArg,
25 | providedValue: string
26 | ) => {
27 | return {
28 | type: 'invalid-arg-value',
29 | commandArg,
30 | providedValue
31 | } as const;
32 | };
33 |
34 | export const UnhandledCommandExecutionError = (error: any) => {
35 | return {
36 | type: 'unhandled-command-execution-error',
37 | error
38 | } as const;
39 | };
40 |
41 | export type CommandInvocationError =
42 | | ReturnType
43 | | ReturnType
44 | | ReturnType
45 | | ReturnType
46 | | ReturnType;
47 |
--------------------------------------------------------------------------------
/src/command-processing.ts:
--------------------------------------------------------------------------------
1 | import Discord from 'discord.js';
2 | import { Command } from './command';
3 | import {
4 | InvalidArgValue,
5 | MissingRequiredArg
6 | } from './command-invocation-error';
7 | import { getIdFromMention } from './discord-message-utils';
8 |
9 | function escapeRegex(str: string) {
10 | return str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
11 | }
12 |
13 | /**
14 | * Take a content string and return the command tokens
15 | *
16 | * Example:
17 | *
18 | * Prelude: !
19 | * Message: !trivia c
20 | * Return: [trivia, c]
21 | *
22 | * Prelude:
23 | * Message: !trivia c
24 | * Return: [!trivia, c]
25 | *
26 | */
27 | const extractCommandTokens = (prelude = '', message: string) => {
28 | if (!message.startsWith(prelude)) {
29 | return [];
30 | }
31 |
32 | // Remove the prelude
33 | const re = new RegExp(`^(${escapeRegex(prelude)})`);
34 | const messageWithoutPrelude = message.replace(re, '');
35 |
36 | return messageWithoutPrelude.match(/\S+/g) ?? [];
37 | };
38 |
39 | export const resolveArgValueOfType = (
40 | message: Discord.Message,
41 | argStr: string,
42 | argType?: string
43 | ) => {
44 | if (!argType) {
45 | return argStr;
46 | }
47 | if (argType === 'Int') {
48 | const res = parseInt(argStr);
49 | if (Number.isNaN(res)) {
50 | throw new Error('Could not parse int');
51 | }
52 | return res;
53 | }
54 | if (argType === 'Float') {
55 | const res = parseFloat(argStr);
56 | if (Number.isNaN(res)) {
57 | throw new Error('Could not parse float');
58 | }
59 | return res;
60 | }
61 | if (argType === 'GuildMember') {
62 | const idMember = getIdFromMention(argStr);
63 | if (!idMember) {
64 | throw new Error('Could not parse guild member arg');
65 | }
66 | return message.guild!.members.cache.get(idMember);
67 | }
68 | if (argType === 'Date') {
69 | const resolvedDate = new Date(argStr);
70 | return resolvedDate;
71 | }
72 | throw new Error(`Invalid arg type ${argType}`);
73 | };
74 |
75 | interface ParsedCommandMessage {
76 | name: string;
77 | args: string[];
78 | }
79 |
80 | export const parseCommandMessage = (
81 | message: string,
82 | prelude?: string
83 | ): { success: false } | { success: true; result: ParsedCommandMessage } => {
84 | const tokens = extractCommandTokens(prelude, message);
85 | if (tokens.length === 0) {
86 | return {
87 | success: false
88 | };
89 | }
90 |
91 | const [nameToken, ...argsTokens] = tokens;
92 |
93 | return {
94 | success: true,
95 | result: {
96 | name: nameToken,
97 | args: argsTokens
98 | }
99 | };
100 | };
101 |
102 | export const buildExecuteArgs = (
103 | message: Discord.Message,
104 | messageArgs: ParsedCommandMessage['args'],
105 | command: Command
106 | ): Record => {
107 | if (!command.args) {
108 | return {};
109 | }
110 | const args = {} as Record;
111 |
112 | let commandArgIndex = 0;
113 | let messageArgIndex = 0;
114 | while (
115 | commandArgIndex < command.args.length &&
116 | messageArgIndex < messageArgs.length
117 | ) {
118 | const messageArg = messageArgs[messageArgIndex];
119 | const commandArg = command.args[commandArgIndex];
120 |
121 | if (commandArg.rest) {
122 | args[commandArg.name] = messageArgs.slice(messageArgIndex).join(' ');
123 | messageArgIndex = messageArgs.length;
124 | commandArgIndex++;
125 | } else {
126 | try {
127 | args[commandArg.name] = resolveArgValueOfType(
128 | message,
129 | messageArg,
130 | commandArg.type
131 | );
132 | } catch (e) {
133 | throw InvalidArgValue(commandArg, messageArg);
134 | }
135 | messageArgIndex++;
136 | commandArgIndex++;
137 | }
138 | }
139 | while (commandArgIndex < command.args.length) {
140 | const commandArg = command.args[commandArgIndex];
141 | if (commandArg.required) {
142 | throw MissingRequiredArg(commandArg);
143 | }
144 | commandArgIndex++;
145 | }
146 |
147 | return args;
148 | };
149 |
--------------------------------------------------------------------------------
/src/command-runner.ts:
--------------------------------------------------------------------------------
1 | import Discord from 'discord.js';
2 | import { Command } from './command';
3 | import { CommandCollection } from './command-collection';
4 | import { parseCommandMessage, buildExecuteArgs } from './command-processing';
5 | import {
6 | CommandInvocationError,
7 | CooldownInEffect,
8 | BlockedByGuard,
9 | UnhandledCommandExecutionError
10 | } from './command-invocation-error';
11 |
12 | interface BaseMessageHandleResult {
13 | message: Discord.Message;
14 | }
15 |
16 | interface NoCommandInvoked extends BaseMessageHandleResult {
17 | success: true;
18 | commandInvoked: false;
19 | preludeIncluded: boolean;
20 | }
21 |
22 | interface CommandInvokedSuccess extends BaseMessageHandleResult {
23 | success: true;
24 | commandInvoked: true;
25 | command: Command;
26 | }
27 |
28 | interface CommandInvokedFailure extends BaseMessageHandleResult {
29 | success: false;
30 | commandInvoked: true;
31 | command: Command;
32 | error: CommandInvocationError;
33 | }
34 |
35 | type MessageHandleResult =
36 | | NoCommandInvoked
37 | | CommandInvokedSuccess
38 | | CommandInvokedFailure;
39 |
40 | interface CommandRunLog {
41 | command: Command;
42 | date: Date;
43 | }
44 |
45 | class CommandRunHistory {
46 | private logs: CommandRunLog[];
47 | constructor() {
48 | this.logs = [];
49 | }
50 |
51 | addRun(log: CommandRunLog) {
52 | this.logs.unshift(log);
53 | }
54 |
55 | latestRunOfCommand(command: Command) {
56 | for (const log of this.logs) {
57 | if (log.command.name === command.name) {
58 | return log;
59 | }
60 | }
61 | return null;
62 | }
63 | }
64 |
65 | export class CommandRunner {
66 | private history: CommandRunHistory;
67 | constructor(
68 | private commands: CommandCollection,
69 | private options?: { prelude?: string; cooldown?: number }
70 | ) {
71 | this.history = new CommandRunHistory();
72 | }
73 |
74 | async processMessage(message: Discord.Message): Promise {
75 | const parsedCommandMessage = parseCommandMessage(
76 | message.content,
77 | this.options?.prelude
78 | );
79 |
80 | // Could not parse message into a command call,
81 | // no need to handle
82 | if (!parsedCommandMessage.success) {
83 | return {
84 | success: true,
85 | preludeIncluded: false,
86 | commandInvoked: false,
87 | message
88 | };
89 | }
90 |
91 | const calledCommand = this.commands.getCommand(
92 | parsedCommandMessage.result.name
93 | );
94 |
95 | if (!calledCommand) {
96 | return {
97 | success: true,
98 | preludeIncluded: true,
99 | commandInvoked: false,
100 | message
101 | };
102 | }
103 |
104 | if (this.options?.cooldown && this.options.cooldown > 0) {
105 | const mostRecentRun = this.history.latestRunOfCommand(calledCommand);
106 | if (mostRecentRun) {
107 | const diffMs = Date.now() - mostRecentRun?.date.getTime();
108 | if (diffMs < this.options.cooldown) {
109 | return {
110 | success: false,
111 | message,
112 | commandInvoked: true,
113 | command: calledCommand,
114 | error: CooldownInEffect()
115 | };
116 | }
117 | }
118 | }
119 |
120 | let executeArgs;
121 | try {
122 | executeArgs = buildExecuteArgs(
123 | message,
124 | parsedCommandMessage.result.args,
125 | calledCommand
126 | );
127 | } catch (e) {
128 | return {
129 | success: false,
130 | message,
131 | commandInvoked: true,
132 | command: calledCommand,
133 | error: e as CommandInvocationError
134 | };
135 | }
136 |
137 | if (!executeArgs) {
138 | throw new Error('executeArgs was falsey somehow');
139 | }
140 |
141 | const payload = { args: executeArgs, message };
142 |
143 | if (calledCommand.guard) {
144 | let ok = false;
145 | let error = null;
146 | const guardPayload = {
147 | ...payload,
148 | ok: () => (ok = true),
149 | error: (err?: any) => (error = err)
150 | };
151 |
152 | await calledCommand.guard(guardPayload);
153 |
154 | // Eventually need to re-structure to report error object to
155 | // some error listener
156 | if (error || !ok) {
157 | if (!error) {
158 | console.warn(
159 | `guard() function for command ${calledCommand.name} did not call ok() or error(), defaulting to no access`
160 | );
161 | }
162 |
163 | return {
164 | success: false,
165 | command: calledCommand,
166 | commandInvoked: true,
167 | message,
168 | error: BlockedByGuard(error)
169 | };
170 | }
171 | }
172 |
173 | try {
174 | await calledCommand.execute(payload);
175 | } catch (e) {
176 | return {
177 | success: false,
178 | command: calledCommand,
179 | commandInvoked: true,
180 | message,
181 | error: UnhandledCommandExecutionError(e)
182 | };
183 | }
184 | this.history.addRun({
185 | command: calledCommand,
186 | date: new Date()
187 | });
188 | return {
189 | success: true,
190 | command: calledCommand,
191 | commandInvoked: true,
192 | message
193 | };
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/src/command-validate.ts:
--------------------------------------------------------------------------------
1 | import { Command } from './command';
2 |
3 | export class ArgPositionError extends Error {
4 | constructor(msg: string) {
5 | super(msg);
6 | this.name = 'ArgPositionError';
7 | Object.setPrototypeOf(this, ArgPositionError.prototype);
8 | }
9 | }
10 |
11 | export class DuplicateArgError extends Error {
12 | constructor(argName: string) {
13 | super(`Duplicate arg name ${argName}`);
14 | this.name = 'DuplicateArgError';
15 | Object.setPrototypeOf(this, DuplicateArgError.prototype);
16 | }
17 | }
18 |
19 | export function validateCommand(command: Command) {
20 | const argError = validateCommandArgs(command.args);
21 | if (argError) {
22 | throw argError;
23 | }
24 | }
25 |
26 | function validateCommandArgs(commandArgs: Command['args']) {
27 | if (!commandArgs) {
28 | return null;
29 | }
30 |
31 | let optionalArgFound = false;
32 | let restArgFound = false;
33 | const commandArgNames = new Set();
34 |
35 | for (const commandArg of commandArgs) {
36 | if (restArgFound) {
37 | return new ArgPositionError(
38 | `Invalid arg ${commandArg.name} positioned after rest arg`
39 | );
40 | }
41 |
42 | if (optionalArgFound && commandArg.required) {
43 | return new ArgPositionError(
44 | `Invalid required arg ${commandArg.name} positioned after optional arg`
45 | );
46 | }
47 |
48 | if (commandArg.rest) {
49 | restArgFound = true;
50 | }
51 | if (!commandArg.required) {
52 | optionalArgFound = true;
53 | }
54 | if (commandArgNames.has(commandArg.name)) {
55 | throw new DuplicateArgError(commandArg.name);
56 | }
57 | commandArgNames.add(commandArg.name);
58 | }
59 | return null;
60 | }
61 |
--------------------------------------------------------------------------------
/src/command.ts:
--------------------------------------------------------------------------------
1 | import Discord from 'discord.js';
2 |
3 | export interface CommandArg {
4 | name: string;
5 | required?: boolean;
6 | rest?: boolean;
7 | type?: string;
8 | }
9 |
10 | export type CommandExecuteArgs = Record;
11 |
12 | export interface CommandExecutePayload {
13 | message: Discord.Message;
14 | args: CommandExecuteArgs;
15 | }
16 |
17 | export interface GuardPayload extends CommandExecutePayload {
18 | ok(): void;
19 | error(err?: any): void;
20 | }
21 |
22 | export interface Command {
23 | name: string;
24 | aliases?: string[];
25 | args?: CommandArg[];
26 | guard?(payload: GuardPayload): Promise | void;
27 | execute(payload: CommandExecutePayload): Promise | void;
28 | }
29 |
30 | export function parseCommandString(cmdString: string) {
31 | const [nameToken, ...argTokens] = cmdString.split(' ');
32 | if (!nameToken) {
33 | throw new Error('Invalid command string: Missing name');
34 | }
35 | const { name, aliases } = parseCommandStringName(nameToken);
36 | const args = argTokens.map((token) => parseArgTokenToCommandArg(token));
37 |
38 | return {
39 | name,
40 | aliases,
41 | args
42 | };
43 | }
44 |
45 | function parseCommandStringName(cmdStringName: string) {
46 | const [name, ...aliases] = cmdStringName.split('|');
47 | return { name, aliases };
48 | }
49 |
50 | interface ParsedArgToken {
51 | name: string;
52 | surroundingChars: [string, string];
53 | type?: string;
54 | rest?: boolean;
55 | }
56 |
57 | function parseArgToken(argToken: string): ParsedArgToken {
58 | const innerText = argToken.slice(1, -1);
59 |
60 | const { name, rest, type } = parseArgTokenInnerText(innerText);
61 | const surroundingChars: [string, string] = [
62 | argToken[0],
63 | argToken[argToken.length - 1]
64 | ];
65 |
66 | return {
67 | name,
68 | rest,
69 | surroundingChars,
70 | type
71 | };
72 | }
73 |
74 | interface ParsedArgTokenInnerText {
75 | name: string;
76 | rest?: boolean;
77 | type?: string;
78 | }
79 |
80 | function parseArgTokenInnerText(innerText: string): ParsedArgTokenInnerText {
81 | if (innerText.startsWith('...')) {
82 | const name = innerText.slice(3);
83 | if (name.includes(':')) {
84 | throw new Error(`Rest arg (${name}) can not specify type`);
85 | }
86 | return {
87 | name: innerText.slice(3),
88 | rest: true
89 | };
90 | }
91 |
92 | const [name, type] = innerText.split(':');
93 |
94 | return {
95 | name,
96 | type: type
97 | };
98 | }
99 |
100 | function parseArgTokenToCommandArg(argToken: string): CommandArg {
101 | const {
102 | name,
103 | rest,
104 | surroundingChars,
105 | type: argType
106 | } = parseArgToken(argToken);
107 |
108 | const isRequired = ([startChar, endChar]: [string, string]) => {
109 | if (startChar === '<' && endChar === '>') {
110 | return true;
111 | } else if (startChar === '[' && endChar === ']') {
112 | return false;
113 | }
114 | throw new Error(
115 | `Argument ${name} has invalid surrounding characters ${surroundingChars}`
116 | );
117 | };
118 |
119 | return {
120 | name,
121 | ...(rest && { rest }),
122 | ...(argType && { type: argType }),
123 | required: isRequired(surroundingChars)
124 | };
125 | }
126 |
--------------------------------------------------------------------------------
/src/discord-message-utils.ts:
--------------------------------------------------------------------------------
1 | // See: https://discordjs.guide/miscellaneous/parsing-mention-arguments.html#implementation
2 | export function getIdFromMention(mention: string) {
3 | if (!mention) return;
4 |
5 | if (mention.startsWith('<@') && mention.endsWith('>')) {
6 | let mentionWithoutSurrounding = mention.slice(2, -1);
7 |
8 | if (mentionWithoutSurrounding.startsWith('!')) {
9 | mentionWithoutSurrounding = mentionWithoutSurrounding.slice(1);
10 | }
11 |
12 | return mentionWithoutSurrounding;
13 | }
14 | }
15 |
16 | export function buildUserMentionFromId(id: string) {
17 | return `<@${id}>`;
18 | }
19 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { Solaire } from './solaire';
2 | export * from './discord-message-utils';
3 |
--------------------------------------------------------------------------------
/src/solaire.ts:
--------------------------------------------------------------------------------
1 | import Discord from 'discord.js';
2 | import EventEmitter from 'events';
3 | import { Command, CommandArg, parseCommandString } from './command';
4 | import { CommandCollection } from './command-collection';
5 | import { CommandRunner } from './command-runner';
6 |
7 | type SolaireCommands = Record>;
8 |
9 | interface SolaireConfig {
10 | discordClient: Discord.Client;
11 |
12 | /**
13 | * Discord bot user token
14 | */
15 | token: string;
16 |
17 | /**
18 | * The text that a command invocation must start with.
19 | *
20 | * Example:
21 | * For a command named `echo` and a commandPrelude of `'!'`, the message
22 | *
23 | * echo Hello world!
24 | *
25 | * will not execute the `echo` command, because it did not start with the `'!'`
26 | * prelude.
27 | *
28 | * !echo Hello world!
29 | *
30 | * This message would invoke the `echo` command.
31 | */
32 | commandPrelude?: string;
33 |
34 | /**
35 | * The default cooldown for all commands, in milliseconds.
36 | */
37 | commandCooldown?: number;
38 |
39 | /**
40 | * The commands to initialize the bot with
41 | */
42 | commands?: SolaireCommands;
43 | }
44 |
45 | export class Solaire extends EventEmitter {
46 | private commands: CommandCollection;
47 | private runner: CommandRunner;
48 |
49 | constructor(private config: SolaireConfig) {
50 | super();
51 | const commands =
52 | Object.entries(config.commands ?? {})?.map(([cmd, cmdConfig]) => {
53 | const { name, aliases, args } = parseCommandString(cmd);
54 | return {
55 | ...cmdConfig,
56 | name,
57 | aliases,
58 | args
59 | };
60 | }) ?? [];
61 |
62 | this.commands = new CommandCollection(commands);
63 |
64 | this.runner = new CommandRunner(this.commands, {
65 | prelude: config.commandPrelude,
66 | cooldown: config.commandCooldown
67 | });
68 | }
69 |
70 | static create(config: SolaireConfig) {
71 | return new Solaire(config);
72 | }
73 |
74 | start() {
75 | this.config.discordClient.login(this.config.token);
76 | this.config.discordClient.on('message', (message) =>
77 | this._onMessage(message)
78 | );
79 | }
80 |
81 | ejectDiscordClient() {
82 | return this.config.discordClient;
83 | }
84 |
85 | async _onMessage(message: Discord.Message) {
86 | const result = await this.runner.processMessage(message);
87 | if (result.commandInvoked) {
88 | this.emit('commandInvokedEnd', result);
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/test/async.ts:
--------------------------------------------------------------------------------
1 | export const flushPromises = () => {
2 | const p = new Promise((resolve) => {
3 | setTimeout(resolve, 0);
4 | });
5 | jest.runAllTimers();
6 | return p;
7 | };
8 |
--------------------------------------------------------------------------------
/test/discord-mocks.ts:
--------------------------------------------------------------------------------
1 | import Discord from 'discord.js';
2 | import EventEmitter from 'events';
3 |
4 | export const MockGuildMember = () => {
5 | return { id: 'abc123' };
6 | };
7 |
8 | export const MockMessage = (content = '') => {
9 | return {
10 | content,
11 | guild: {
12 | members: {
13 | cache: {
14 | get: (id: string) => {
15 | if (id === 'abc123') {
16 | return MockGuildMember();
17 | }
18 | return null;
19 | }
20 | }
21 | }
22 | }
23 | } as unknown as Discord.Message;
24 | };
25 |
26 | export class MockDiscordClient extends EventEmitter {
27 | login() {}
28 | }
29 |
--------------------------------------------------------------------------------
/test/solaire-tester.ts:
--------------------------------------------------------------------------------
1 | import Discord from 'discord.js';
2 | import { Solaire } from '../src/solaire';
3 | import { MockMessage, MockDiscordClient } from './discord-mocks';
4 | import { flushPromises } from './async';
5 |
6 | export const SolaireTester = (
7 | solaireConfig: Omit<
8 | ConstructorParameters[0],
9 | 'token' | 'discordClient'
10 | >
11 | ) => {
12 | const mockDiscordClient = new MockDiscordClient() as Discord.Client;
13 | const solaire = new Solaire({
14 | discordClient: mockDiscordClient,
15 | token: 'abc',
16 | ...solaireConfig
17 | });
18 | solaire.start();
19 | return {
20 | solaire,
21 | sendMessage: async (message: string) => {
22 | mockDiscordClient.emit('message', MockMessage(message));
23 | await flushPromises();
24 | }
25 | };
26 | };
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["esnext"],
5 | "declaration": true,
6 | "module": "commonjs",
7 | "strict": true,
8 | "esModuleInterop": true,
9 | "outDir": "dist",
10 | "forceConsistentCasingInFileNames": true,
11 | "types": ["jest", "node"]
12 | },
13 | "include": ["src/**/*"]
14 | }
15 |
--------------------------------------------------------------------------------