diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c6c8b36 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..94b59d7 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +docs +lib +.husky diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..21a02b9 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,44 @@ +module.exports = { + env: { + commonjs: true, + es6: true, + node: true + }, + extends: ['eslint:recommended', 'prettier', 'plugin:prettier/recommended'], + globals: {}, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 6, + sourceType: 'module' + }, + plugins: ['@typescript-eslint'], + rules: { + 'prettier/prettier': 'warn', + 'no-cond-assign': [2, 'except-parens'], + 'no-unused-vars': 0, + '@typescript-eslint/no-unused-vars': 1, + 'no-empty': [ + 'error', + { + allowEmptyCatch: true + } + ], + 'prefer-const': [ + 'warn', + { + destructuring: 'all' + } + ], + 'spaced-comment': 'warn' + }, + overrides: [ + { + files: ['test/**/*.ts'], + globals: { + describe: true, + it: true, + beforeEach: true + } + } + ] +}; diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..01f534c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +patreon: Snazzah +ko_fi: Snazzah +github: Snazzah diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e574638 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "npm" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..54eee03 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,40 @@ +name: ESLint +on: + push: + branches: + - "*" + - "!docs" + paths: + - "src/**" + - ".eslintignore" + - ".eslintrc.*" + - ".prettierrc" + - ".github/workflows/lint.yml" + workflow_dispatch: + +jobs: + lint: + name: Lint source code + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Install Node v12 + uses: actions/setup-node@v1 + with: + node-version: 12.x + + - name: Install dependencies + run: npm install + + - name: Run ESLint + run: npm run lint:fix + + - name: Commit changes + uses: EndBug/add-and-commit@v4 + with: + add: src + message: "chore(lint): auto-lint source code" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..100a653 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Release +on: + release: + types: [published] + +jobs: + tag: + name: Add/update 'latest' tag + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Run latest-tag + uses: EndBug/latest-tag@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +# docs: +# name: Update docs +# runs-on: ubuntu-18.04 +# needs: tag +# +# steps: +# - name: Checkout repository +# uses: actions/checkout@v2 +# +# - name: Setup Node 12.x +# uses: actions/setup-node@v1 +# with: +# node-version: 12.x +# +# - name: Install dependencies +# run: npm install +# +# - name: Build documentation +# run: npm run docs +# +# - name: Deploy documentation +# uses: dbots-pkg/action-docs@v1 +# env: +# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-npm: + name: Publish on NPM + runs-on: ubuntu-latest +# needs: docs + + steps: + - uses: actions/checkout@v2 + + - name: Set up Node.js for NPM + uses: actions/setup-node@v1 + with: + registry-url: 'https://registry.npmjs.org' + + - run: npm install + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + publish-gpr: + name: Publish on GPR + runs-on: ubuntu-latest +# needs: docs + + steps: + - uses: actions/checkout@v2 + + - name: Set up Node.js for GPR + uses: actions/setup-node@v1 + with: + registry-url: 'https://npm.pkg.github.com/' + scope: '@dexare' + + - run: npm install + - run: npm run gpr + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d8a82a --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Packages +node_modules/ +yarn.lock +package-lock.json + +# Log files +logs/ +*.log + +# Miscellaneous +.tmp/ +.vscode/ +docs/docs.json +webpack/ +testing/ +lib/ +.husky/_/ diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..6e1eeed --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx lint-staged +npm run build diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 0000000..9bd0645 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,25 @@ +pull_request_rules: + - name: Automatic merge on approval + conditions: + - approved-reviews-by=Snazzah + actions: + merge: + method: merge + - name: Merge non-breaking dependencies automatically + conditions: + - author~=^dependabot(|-preview)\[bot\]$ + - title~=from (?P\d+).\d+.\d+ to (?P=major).\d+.\d+ + actions: + merge: + method: merge + - name: Notify breaking dependencies automatically + conditions: + - author~=^dependabot(|-preview)\[bot\]$ + - title~=from (?P\d+).\d+.\d+ to (?!(?P=major).\d+.\d+) + actions: + comment: + message: | + This PR features a major change and requires further approval. + request_reviews: + users: + - Snazzah diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..9692524 --- /dev/null +++ b/.npmignore @@ -0,0 +1,32 @@ +# Packages +node_modules/ +yarn.lock +package-lock.json + +# Log files +logs/ +*.log + +# Authentication +deploy/ + +# Miscellaneous +.tmp/ +.vscode/ +scripts/ +static/ +docs/ + +# NPM ignore +.eslintignore +.eslintrc.js +.gitattributes +.gitignore +.editorconfig +.prettierrc +.github/ +.dependabot/ +test/ +testing/ +tsconfig.json +.husky/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e995a18 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "useTabs": false, + "endOfLine": "lf", + "trailingComma": "none", + "printWidth": 110 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ea072ea --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +## [1.0.0] - 2021-02-13 +- Initial release. + +[Unreleased]: https://github.com/Dexare/Dexare/compare/v0.1.0...HEAD +[1.0.0]: https://github.com/Dexare/Dexare/releases/tag/v0.1.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e1173c4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Snazzah + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ecaeb4 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +
+ +# Voltare + +[![NPM version](https://img.shields.io/npm/v/voltare?maxAge=3600?&color=2ed573)](https://www.npmjs.com/package/voltare) [![NPM downloads](https://img.shields.io/npm/dt/voltare?maxAge=3600&color=2ed573)](https://www.npmjs.com/package/voltare) [![ESLint status](https://github.com/Dexare/Dexare/Voltare/ESLint/badge.svg)](https://github.com/Dexare/Voltare/actions?query=workflow%3A%22ESLint%22) + +
+ +Voltare is a version of the [Dexare](https://github.com/Dexare/Dexare) framework for [Revolt](https://revolt.chat). Easily make modules that depend on others or overwrite their functions. + +> Note: This is still in development. As [better-revolt-js](https://github.com/TheMaestro0/better-revolt.js) is still new, this framework may have some bugs. + +Documentation is unavailable at the moment, but some of the core features of Voltare is shown in the [Documentation for Dexare](https://github.com/Dexare/Dexare/wiki). diff --git a/package.json b/package.json new file mode 100644 index 0000000..65e826d --- /dev/null +++ b/package.json @@ -0,0 +1,66 @@ +{ + "name": "voltare", + "version": "0.1.0", + "description": "Modular and extendable Revolt bot framework", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "author": "Snazzah", + "license": "MIT", + "repository": "https://github.com/Dexare/Voltare", + "bugs": { + "url": "https://github.com/Dexare/Voltare/issues" + }, + "keywords": [ + "api", + "bot", + "revolt chat", + "revolt", + "framework", + "typescript", + "voltare" + ], + "funding": { + "url": "https://github.com/sponsors/Snazzah" + }, + "scripts": { + "build": "npx rimraf lib && npx tsc", + "build:prepare": "npx shx test -d ./lib || npm run build", + "changelog": "npx ts-node scripts/changelog", + "lint": "npx eslint --ext .ts ./src", + "lint:fix": "npx eslint --ext .ts ./src --fix", + "gpr": "npx ts-node scripts/gpr", + "prepare": "npx husky install && npm run build:prepare", + "prepublishOnly": "(npx shx test -d ./lib || (echo \"lib folder does not exist\" && exit 1)) && npm run lint:fix" + }, + "lint-staged": { + "*.ts": "eslint --fix" + }, + "dependencies": { + "@discordjs/collection": "^0.2.1", + "better-revolt-js": "^0.1.0-beta", + "common-tags": "^1.8.0", + "eventemitter3": "^4.0.7", + "lodash.uniq": "^4.5.0" + }, + "devDependencies": { + "@types/common-tags": "^1.8.0", + "@types/lodash.uniq": "^4.5.6", + "@types/node": "^14.14.22", + "@types/node-fetch": "^2.5.12", + "@types/ws": "^7.4.7", + "@typescript-eslint/eslint-plugin": "^4.14.1", + "@typescript-eslint/parser": "^4.14.1", + "eslint": "^7.32.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-prettier": "^3.4.1", + "husky": "^7.0.2", + "lint-staged": "^11.1.2", + "prettier": "^2.3.2", + "ts-node": "^10.2.1", + "typescript": "^4.1.3" + }, + "engines": { + "node": ">=16.6.0", + "npm": ">=7.0.0" + } +} diff --git a/scripts/changelog.ts b/scripts/changelog.ts new file mode 100644 index 0000000..e3ae30b --- /dev/null +++ b/scripts/changelog.ts @@ -0,0 +1,46 @@ +import fs from 'fs'; +import path from 'path'; + +const currentVersion = require('../package.json').version; +if (!currentVersion) throw new Error("Can't detect library version."); + +const changelogPath = path.resolve(__dirname, '../CHANGELOG.md'); +const changelog = fs.readFileSync(changelogPath, { encoding: 'utf-8' }); +if (changelog.includes(`## [${currentVersion}]`)) + throw new Error('Current version has already been documented.'); +let futureChangelog = ''; + +// Add version section +let arr = changelog.split('## [Unreleased]'); +arr[1] = + ` +## [${currentVersion}] - ${new Date().toISOString().slice(0, 10)} +### Removed: +- **[BREAKING]** description +### Changed: +- +### Added: +- +### Fixed: +- ` + arr[1]; +futureChangelog = arr.join('## [Unreleased]'); + +// Update footer +arr = futureChangelog + .split('\n') + .map((line) => + line.startsWith('[Unreleased]') + ? `[Unreleased]: https://github.com/Dexare/Voltare/compare/v${currentVersion}...HEAD` + : line + ); + +// eslint-disable-next-line no-useless-escape +const lastVersion = ([...arr].reverse()[1].match(/\[([^\][]*)]/) || [])[0].replace(/[[\]']+/g, ''); +if (!lastVersion) throw new Error("Can't find last version in changelog."); + +const lastLine = `[${currentVersion}]: https://github.com/Dexare/Voltare/compare/v${lastVersion}...v${currentVersion}`; +if (arr[arr.length - 1] === '') arr[arr.length - 1] = lastLine; +else arr.push(lastLine); +futureChangelog = arr.join('\n'); + +fs.writeFileSync(changelogPath, futureChangelog); diff --git a/scripts/gpr.ts b/scripts/gpr.ts new file mode 100644 index 0000000..837eb01 --- /dev/null +++ b/scripts/gpr.ts @@ -0,0 +1,8 @@ +import fs from 'fs'; +import { join } from 'path'; + +import pkg from '../package.json'; + +pkg.name = `@dexare/${pkg.name}`; + +fs.writeFileSync(join(__dirname, '../package.json'), JSON.stringify(pkg)); diff --git a/src/client/events.ts b/src/client/events.ts new file mode 100644 index 0000000..4626c78 --- /dev/null +++ b/src/client/events.ts @@ -0,0 +1,242 @@ +import Collection from '@discordjs/collection'; +import uniq from 'lodash.uniq'; +import LoggerHandler from '../util/logger'; +import { Arguments } from '../util/typedEmitter'; +import VoltareClient, { VoltareEvents } from './index'; + +/** @hidden */ +export type EventHandlers = { + [event in keyof VoltareEvents]: ( + event: ClientEvent, + ...args: Arguments + ) => Promise | void; +} & { + [event: string]: (event: ClientEvent, ...args: any[]) => Promise | void; +}; + +/** @hidden */ +export type EventGroup = { + [event in keyof EventHandlers]?: { + group: string; + before: string[]; + after: string[]; + listener: EventHandlers[event]; + }; +} & { + [event: string]: { + group: string; + before: string[]; + after: string[]; + listener: (event: ClientEvent, ...args: any[]) => Promise | void; + }; +}; + +/** An object that temporarily stores the data of an event. */ +export class ClientEvent { + /** The groups that have been (or will be) skipped. */ + readonly skipped: string[] = []; + /** The name of this event. */ + readonly name: keyof VoltareEvents; + /** The data for this event. Can be altered at any time */ + readonly data = new Map(); + + constructor(name: keyof VoltareEvents) { + this.name = name; + } + + /** + * Skip a group's listener for this event, if it has not already been fired. + * @param group The group/extension/module name + */ + skip(group: string) { + if (!this.skipped.includes(group)) this.skipped.push(group); + } + + /** + * Whether a data key exists within the data. + * @param key The key to check + */ + has(key: string) { + return this.data.has(key); + } + + /** + * Gets a key within the event's data. + * @param key The key to get + */ + get(key: string) { + return this.data.get(key); + } + + /** + * Sets a key within the event's data. + * @param key The key to set + * @param value The data + */ + set(key: string, data: any) { + return this.data.set(key, data); + } +} + +/** The event registry that handles the event system. */ +export default class EventRegistry> { + /** The event groups in the registry. */ + readonly eventGroups = new Collection(); + /** the client responsible for this registry. */ + readonly client: T; + + private readonly loadOrders = new Map(); + private readonly hookedEvents: (keyof VoltareEvents)[] = []; + private readonly logger: LoggerHandler; + + constructor(client: T) { + this.client = client; + this.logger = new LoggerHandler(this.client, 'voltare/events'); + } + + /** + * Registers an event. + * @param groupName The group to register with + * @param event The event to register + * @param listener The event listener + * @param options The options for the event + */ + register( + groupName: string, + event: E, + listener: EventHandlers[E], + options?: { before?: string[]; after?: string[] } + ) { + this.logger.log(`Registering event '${event}' for group '${groupName}'`); + const eventGroup = this.eventGroups.has(groupName) ? this.eventGroups.get(groupName)! : {}; + eventGroup[event] = { + group: groupName, + before: (options && options.before) || [], + after: (options && options.after) || [], + listener + }; + this.eventGroups.set(groupName, eventGroup); + this.hookEvent(event); + this.refreshLoadOrder(event); + } + + /** + * Unregisters an event from a group. + * @param groupName The group to unregister from + * @param event The event to unregister + */ + unregister(groupName: string, event: keyof VoltareEvents) { + this.logger.log(`Unregistering event '${event}' from group '${groupName}'`); + if (!this.eventGroups.has(groupName)) return; + const eventGroup = this.eventGroups.get(groupName)!; + delete eventGroup[event]; + this.eventGroups.set(groupName, eventGroup); + this.refreshLoadOrder(event); + } + + /** + * Unregisters a group, removing all of their listeners. + * @param groupName The group to unregister + */ + unregisterGroup(groupName: string) { + this.logger.log(`Unregistering event group '${groupName}'`); + const refresh = this.eventGroups.has(groupName); + const result = this.eventGroups.delete(groupName); + if (refresh) this.refreshAllLoadOrders(); + return result; + } + + /** + * Emits an event. + * @param event The event to emit + * @param args The arcuments to emit with + */ + emit(event: E, ...args: Arguments) { + if (!this.loadOrders.has(event)) this.refreshLoadOrder(event); + const loadOrder = this.loadOrders.get(event)!; + const clientEvent = new ClientEvent(event); + + // Do async emitting w/o returning promises + (async () => { + for (const groupName of loadOrder) { + if (clientEvent.skipped.includes(groupName)) continue; + try { + await this.eventGroups.get(groupName)![event]!.listener(clientEvent, ...args); + } catch (e) {} + } + })(); + } + + /** + * Emits an event asynchronously. + * @param event The event to emit + * @param args The arcuments to emit with + */ + async emitAsync(event: E, ...args: Arguments) { + if (!this.loadOrders.has(event)) this.refreshLoadOrder(event); + const loadOrder = this.loadOrders.get(event)!; + const clientEvent = new ClientEvent(event); + + for (const groupName of loadOrder) { + if (clientEvent.skipped.includes(groupName)) continue; + try { + await this.eventGroups.get(groupName)![event]!.listener(clientEvent, ...args); + } catch (e) {} + } + } + + private hookEvent(event: keyof VoltareEvents) { + if (this.hookedEvents.includes(event)) return; + this.hookedEvents.push(event); + this.client.on(event, (...args: any) => this.emit(event, ...args)); + } + + private refreshLoadOrder(event: keyof VoltareEvents) { + this.loadOrders.set(event, this.createLoadOrder(event)); + } + + private refreshAllLoadOrders() { + const events = uniq( + this.eventGroups.reduce( + (prev, group) => (Object.keys(group) as (keyof VoltareEvents)[]).concat(prev), + [] as (keyof VoltareEvents)[] + ) + ); + events.forEach((event) => this.refreshLoadOrder(event)); + } + + private createLoadOrder(event: E) { + const handlers = Array.from(this.eventGroups.values()) + .filter((group) => event in group) + .map((group) => group[event]); + + const loadOrder: string[] = []; + + function insert(handler: EventGroup[E]) { + if (handler.before && handler.before.length) + handler.before.forEach((groupName) => { + const dep = handlers.find((handler) => handler.group === groupName); + if (dep) insert(dep); + }); + if (!loadOrder.includes(handler.group)) loadOrder.push(handler.group); + if (handler.after && handler.after.length) + handler.after.forEach((groupName) => { + const dep = handlers.find((handler) => handler.group === groupName); + if (dep) insert(dep); + }); + } + + // handle "afters" first + handlers.filter((group) => group.after.length).forEach((handler) => insert(handler)); + + // handle "befores" second + handlers.filter((group) => group.before.length).forEach((handler) => insert(handler)); + + // handle others last + handlers + .filter((group) => !group.before.length && !group.after.length) + .forEach((handler) => insert(handler)); + + return loadOrder; + } +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..e4e3987 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,272 @@ +import Collection from '@discordjs/collection'; +import * as Revolt from 'better-revolt-js'; +import { LoginDetails } from 'better-revolt-js/dist/client/Client'; +import { ClientOptions } from 'better-revolt-js/dist/client/BaseClient'; +import EventEmitter from 'eventemitter3'; +import { RevoltEventNames } from '../constants'; +import VoltareModule from '../module'; +import CommandsModule from '../modules/commands'; +import CollectorModule from '../modules/collector'; +import { RevoltEvents, LoggerExtra } from '../types'; +import LoggerHandler from '../util/logger'; +import TypedEmitter from '../util/typedEmitter'; +import EventRegistry from './events'; +import PermissionRegistry from './permissions'; +import DataManager from '../dataManager'; +import MemoryDataManager from '../dataManagers/memory'; + +type DeepPartial = { [P in keyof T]?: DeepPartial }; + +export interface BaseConfig { + login: LoginDetails; + revoltOptions?: DeepPartial; + elevated?: string | Array; +} + +/** + * The events typings for the {@link VoltareClient}. + * @private + */ +export interface VoltareClientEvents extends RevoltEvents { + logger(level: string, group: string, args: any[], extra?: LoggerExtra): void; + beforeConnect(): void; + afterConnect(): void; + beforeDisconnect(): void; + afterDisconnect(): void; +} + +/** @hidden */ +export type VoltareEvents = VoltareClientEvents & { + [event: string]: (...args: any[]) => void; +}; + +export default class VoltareClient< + T extends BaseConfig = BaseConfig +> extends (EventEmitter as any as new () => TypedEmitter) { + config: T; + readonly bot: Revolt.Client; + readonly permissions: PermissionRegistry; + readonly events = new EventRegistry(this); + readonly logger = new LoggerHandler(this, 'voltare/client'); + readonly modules = new Collection>(); + readonly commands = new CommandsModule(this); + readonly collector = new CollectorModule(this); + data: DataManager = new MemoryDataManager(this); + private readonly _hookedEvents: string[] = []; + private _revoltEventsLogged = false; + + constructor(config: T, bot?: Revolt.Client) { + // eslint-disable-next-line constructor-super + super(); + + this.config = config; + if (bot) this.bot = bot; + else this.bot = new Revolt.Client(this.config.revoltOptions); + this.permissions = new PermissionRegistry(this); + this.modules.set('commands', this.commands); + this.commands._load(); + this.modules.set('collector', this.collector); + this.collector._load(); + } + + /** + * Load modules into the client. + * @param moduleObjects The modules to load. + * @returns The client for chaining purposes + */ + loadModules(...moduleObjects: any[]) { + const modules = moduleObjects.map(this._resolveModule.bind(this)); + const loadOrder = this._getLoadOrder(modules); + + for (const modName of loadOrder) { + const mod = modules.find((mod) => mod.options.name === modName)!; + if (this.modules.has(mod.options.name)) + throw new Error(`A module in the client already has been named "${mod.options.name}".`); + this._log('debug', `Loading module "${modName}"`); + this.modules.set(modName, mod); + mod._load(); + } + + return this; + } + + /** + * Load modules into the client asynchronously. + * @param moduleObjects The modules to load. + * @returns The client for chaining purposes + */ + async loadModulesAsync(...moduleObjects: any[]) { + const modules = moduleObjects.map(this._resolveModule.bind(this)); + const loadOrder = this._getLoadOrder(modules); + + for (const modName of loadOrder) { + const mod = modules.find((mod) => mod.options.name === modName)!; + if (this.modules.has(mod.options.name)) + throw new Error(`A module in the client already has been named "${mod.options.name}".`); + this._log('debug', `Loading module "${modName}"`); + this.modules.set(modName, mod); + await mod._load(); + } + } + + /** + * Loads a module asynchronously into the client. + * @param moduleObject The module to load + */ + async loadModule(moduleObject: any) { + const mod = this._resolveModule(moduleObject); + if (this.modules.has(mod.options.name)) + throw new Error(`A module in the client already has been named "${mod.options.name}".`); + this._log('debug', `Loading module "${mod.options.name}"`); + this.modules.set(mod.options.name, mod); + await mod._load(); + } + + /** + * Unloads a module. + * @param moduleName The module to unload + */ + async unloadModule(moduleName: string) { + if (!this.modules.has(moduleName)) return; + const mod = this.modules.get(moduleName)!; + this._log('debug', `Unloading module "${moduleName}"`); + await mod.unload(); + this.modules.delete(moduleName); + } + + /** + * Loads a data manager asynchronously into the client. + * @param moduleObject The manager to load + * @param startOnLoad Whether to start the manager after loading + */ + async loadDataManager(mgrObject: any, startOnLoad = false) { + if (typeof mgrObject === 'function') mgrObject = new mgrObject(this); + else if (typeof mgrObject.default === 'function') mgrObject = new mgrObject.default(this); + + if (!(mgrObject instanceof DataManager)) + throw new Error(`Invalid data manager object to load: ${mgrObject}`); + + await this.data.stop(); + this.data = mgrObject; + if (startOnLoad) await this.data.start(); + } + + /** + * Log events to console. + * @param logLevel The level to log at. + * @param excludeModules The modules to exclude + */ + logToConsole(logLevel: 'debug' | 'info' | 'warn' | 'error' = 'info', excludeModules: string[] = []) { + const levels = ['debug', 'info', 'warn', 'error']; + const index = levels.indexOf(logLevel); + this.on('logger', (level, moduleName, args) => { + let importance = levels.indexOf(level); + if (importance === -1) importance = 0; + if (importance < index) return; + + if (excludeModules.includes(moduleName)) return; + + let logFunc = console.debug; + if (level === 'info') logFunc = console.info; + else if (level === 'error') logFunc = console.error; + else if (level === 'warn') logFunc = console.warn; + logFunc(level.toUpperCase(), `[${moduleName}]`, ...args); + }); + + return this; + } + + /** Logs informational Revolt events to Voltare's logger event. */ + logErisEvents() { + if (this._revoltEventsLogged) return this; + this._revoltEventsLogged = true; + + this.on('raw', (data: any) => { + if (data.type === 'Authenticated') this.emit('logger', 'debug', 'revolt', ['Authenticated']); + }); + this.on('ready', () => this.emit('logger', 'info', 'revolt', ['Bot is ready.'])); + this.on('debug', (message) => this.emit('logger', 'debug', 'revolt', [message])); + this.on('error', (error) => this.emit('logger', 'error', 'revolt', [error])); + + return this; + } + + /** + * Register an event. + * @param event The event to register + * @param listener The event listener + */ + on(event: E, listener: VoltareEvents[E]) { + if ( + typeof event === 'string' && + !this._hookedEvents.includes(event) && + RevoltEventNames.includes(event) + ) { + // @ts-ignore + this.bot.on(event, (...args: any[]) => this.emit(event, ...args)); + this._hookedEvents.push(event); + } + + return super.on(event, listener); + } + + /** + * Creates a promise that resolves on the next event + * @param event The event to wait for + */ + waitTill(event: keyof VoltareEvents) { + return new Promise((resolve) => this.once(event, resolve)); + } + + /** Connects and logs in to Revolt. */ + async connect() { + await this.events.emitAsync('beforeConnect'); + await this.data.start(); + await this.bot.login(this.config.login); + await this.events.emitAsync('afterConnect'); + } + + /** Disconnects the bot. */ + async disconnect() { + await this.events.emitAsync('beforeDisconnect'); + await this.data.stop(); + await this.bot.logout(); + await this.events.emitAsync('afterDisconnect'); + } + + /** @hidden */ + private _resolveModule(moduleObject: any) { + if (typeof moduleObject === 'function') moduleObject = new moduleObject(this); + else if (typeof moduleObject.default === 'function') moduleObject = new moduleObject.default(this); + + if (!(moduleObject instanceof VoltareModule)) + throw new Error(`Invalid module object to load: ${moduleObject}`); + return moduleObject; + } + + /** @hidden */ + private _getLoadOrder(modules: VoltareModule[]) { + const loadOrder: string[] = []; + + const insert = (mod: VoltareModule) => { + if (mod.options.requires && mod.options.requires.length) + mod.options.requires.forEach((modName) => { + const dep = modules.find((mod) => mod.options.name === modName) || this.modules.get(modName); + if (!dep) + throw new Error( + `Module '${mod.options.name}' requires dependency '${modName}' which does not exist!` + ); + if (!this.modules.has(modName)) insert(dep); + }); + if (!loadOrder.includes(mod.options.name)) loadOrder.push(mod.options.name); + }; + + modules.forEach((mod) => insert(mod)); + + return loadOrder; + } + + private _log(level: string, ...args: any[]) { + this.emit('logger', level, 'voltare', args); + } +} diff --git a/src/client/permissions.ts b/src/client/permissions.ts new file mode 100644 index 0000000..fd09b00 --- /dev/null +++ b/src/client/permissions.ts @@ -0,0 +1,143 @@ +import Collection from '@discordjs/collection'; +import * as Revolt from 'better-revolt-js'; +import type { ChannelPermissionsResolvable } from 'better-revolt-js'; +import { PermissionObject } from '../types'; +import LoggerHandler from '../util/logger'; +import { ClientEvent } from './events'; +import VoltareClient from './index'; + +// TODO rewrite this + +/** The function for a permission. */ +export type PermissionFunction> = ( + object: PermissionObject, + client: T, + event?: ClientEvent +) => boolean; + +export const CorePermissions = [ + ...Object.keys(Revolt.ChannelPermissions.FLAGS).map( + (permission) => 'revolt.channel.' + permission.toLowerCase() + ), + ...Object.keys(Revolt.UserPermissions.FLAGS).map((permission) => 'revolt.user.' + permission.toLowerCase()), + ...Object.keys(Revolt.ServerPermissions.FLAGS).map( + (permission) => 'revolt.server.' + permission.toLowerCase() + ), + 'voltare.elevated', + 'voltare.inserver' +]; + +/** The registry for permissions in Voltare. */ +export default class PermissionRegistry> { + readonly permissions = new Collection>(); + private readonly logger: LoggerHandler; + private readonly client: T; + + constructor(client: T) { + this.client = client; + this.logger = new LoggerHandler(this.client, 'voltare/permissions'); + + // TODO revolt permissions, not entirely available in better-revolt-js + // for (const permission in Revolt.ChannelPermissions.FLAGS) { + // this.permissions.set('revolt.channel.' + permission.toLowerCase(), (object) => { + // if (object.message) + // if (object.message.serverId && object.member) {} + // else if (object.member) {} + // return Revolt.DEFAULT_PERMISSION_DM.has(permission as ChannelPermissionsResolvable); + // }); + // } + + this.permissions.set('voltare.elevated', (object, client) => { + if (!client.config.elevated) return false; + + if (Array.isArray(client.config.elevated)) return client.config.elevated.includes(object.user.id); + return client.config.elevated === object.user.id; + }); + + this.permissions.set('voltare.inserver', (object) => { + if (object.member) return true; + if (object.message) return !!object.message.serverId; + return false; + }); + } + + /** + * Registers a new permission. + * @param key The permission key to register + * @param permission The permission function to use + */ + register(key: string, permission: PermissionFunction): void { + key = key.toLowerCase(); + if (CorePermissions.includes(key)) throw new Error(`Cannot register to core permissions. (${key})`); + this.logger.log(`Registering permission '${key}'`); + + this.permissions.set(key, permission); + } + + /** + * Unregisters a permission. + * @param key The permission to unregister + */ + unregister(key: string): boolean { + key = key.toLowerCase(); + if (CorePermissions.includes(key)) throw new Error(`Cannot unregister core permissions. (${key})`); + this.logger.log(`Unregistering permission '${key}'`); + + return this.permissions.delete(key); + } + + /** + * Check a permission. + * @param object The object to check with + * @param permission The permission to check + * @param event The client event to associate the function + */ + has(object: PermissionObject, permission: string, event?: ClientEvent) { + permission = permission.toLowerCase(); + if (!permission || !this.permissions.has(permission)) return false; + return this.permissions.get(permission)!(object, this.client, event); + } + + /** + * Maps permissions into an object with true/false values and permission keys. + * @param object The object to check with + * @param permissions The permissions to map + * @param prevMap The previous map, if any + * @param event The client event to associate + */ + map( + object: PermissionObject, + permissions: string[], + prevMap: { [permission: string]: boolean } = {}, + event?: ClientEvent + ) { + for (const permission of permissions) { + if (permission in prevMap) continue; + prevMap[permission] = this.has(object, permission, event); + } + + return prevMap; + } + + /** + * Convert something into a permission object. + * @param object The object to convert + */ + toObject(object: Revolt.Message | Revolt.User | Revolt.ServerMember): PermissionObject { + const result: any = {}; + + let user: Revolt.User; + if (object instanceof Revolt.Message) user = object.author!; + else if (object instanceof Revolt.ServerMember) user = object.user; + else user = object; + + result.user = user; + if (object instanceof Revolt.ServerMember) result.member = object; + if (object instanceof Revolt.Message) { + result.message = object; + if (object.member) result.member = object.member; + } + + return result; + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..ecaf724 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,39 @@ +import { Events } from 'better-revolt-js'; + +export const RevoltEventNames: string[] = [ + Events.MESSAGE, + Events.MESSAGE_DELETE, + Events.MESSAGE_UPDATE, + + Events.SERVER_CREATE, + Events.SERVER_DELETE, + Events.SERVER_UPDATE, + + Events.READY, + Events.DEBUG, + Events.ERROR, + Events.RAW, + + Events.USER_UPDATE, + + Events.CHANNEL_CREATE, + Events.CHANNEL_DELETE, + Events.CHANNEL_UPDATE, + + Events.SERVER_MEMBER_JOIN, + Events.SERVER_MEMBER_LEAVE, + Events.SERVER_MEMBER_UPDATE, + + Events.ROLE_CREATE, + Events.ROLE_DELETE, + // Events.ROLE_UPDATE, + Events.TYPING_START, + Events.TYPING_STOP, + Events.GROUP_JOIN, + Events.GROUP_LEAVE +]; + +export const PermissionNames: { [perm: string]: string } = { + 'voltare.inserver': 'Ran in a Server', + 'voltare.elevated': 'Bot developer' +}; diff --git a/src/dataManager.ts b/src/dataManager.ts new file mode 100644 index 0000000..2037cc1 --- /dev/null +++ b/src/dataManager.ts @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import VoltareClient from './client'; +import { ClientEvent } from './client/events'; +import { ThrottlingOptions } from './modules/commands/command'; +import LoggerHandler from './util/logger'; + +/** Options for the {@link DataManager}. */ +export interface DataManagerOptions { + /** The name of the manager. */ + name: string; + /** The description of the manager. */ + description?: string; +} + +/** The throttle result of {@link DataManager#throttleCommand} */ +export interface ThrottleResult { + okay: boolean; + reset?: number; + usesLeft?: number; +} + +/** @private */ +export interface ThrottleObject { + reset: number; + uses: number; +} + +/** A data manager for Voltare. */ +export default class DataManager { + /** The options for this manager. */ + readonly options: DataManagerOptions; + /** The logger for the manager. */ + readonly logger: LoggerHandler>; + /** The Voltare client for this manager. */ + readonly client: VoltareClient; + /** + * The file path of the manager. + * Set this to `__filename` in the constructor. + */ + filePath?: string; + + constructor(client: VoltareClient, options: DataManagerOptions) { + this.options = options; + this.client = client; + this.logger = new LoggerHandler>(this.client, this.options.name); + } + + /** Fired when the manager is signaled to start. */ + async start() {} + + /** Fired when the manager is signaled to stop. */ + async stop() {} + + /** + * Gets the throttle result from an ID and scope. + * @param scope The scope of the throttles + * @param id The ID of the throttle + * @returns The Throttle result, if any + */ + async getThrottle(scope: string, id: string): Promise { + return; + } + + /** + * Sets the throttle result to an ID and scope. + * @param scope The scope of the throttles + * @param id The ID of the throttle + * @param object The throttle to set + */ + async setThrottle(scope: string, id: string, object: ThrottleObject): Promise { + return; + } + + /** + * Removes a throttle object. + * @param scope The scope of the throttles + * @param id The ID of the throttle + */ + async removeThrottle(scope: string, id: string): Promise { + return; + } + + /** + * Throttles something. + * @param scope The group to put the throttle in + * @param opts The throttling options to use + * @param id The identifier of the throttle + * @param event The event to use + */ + async throttle( + scope: string, + opts: ThrottlingOptions, + id: string, + event?: ClientEvent + ): Promise { + let throttle = await this.getThrottle(scope, id); + if (!throttle || throttle.reset < Date.now()) { + throttle = { + reset: Date.now() + opts.duration * 1000, + uses: opts.usages + }; + } + + const okay = throttle.uses > 0; + if (okay) { + throttle.uses--; + await this.setThrottle(scope, id, throttle); + } + + return { + okay, + reset: throttle.reset, + usesLeft: throttle.uses + }; + } +} diff --git a/src/dataManagers/memory.ts b/src/dataManagers/memory.ts new file mode 100644 index 0000000..0a96ce9 --- /dev/null +++ b/src/dataManagers/memory.ts @@ -0,0 +1,42 @@ +import VoltareClient from '../client'; +import DataManager, { ThrottleObject } from '../dataManager'; + +/** Data manager in Voltare using memory. */ +export default class MemoryDataManager extends DataManager { + static SEPARATOR = '|'; + /** Current throttle objects for commands, mapped by scope and ID. */ + private _throttles = new Map(); + + constructor(client: VoltareClient) { + super(client, { + name: 'memory-data', + description: 'Voltare data manager using memory.' + }); + + this.filePath = __filename; + } + + async getThrottle(scope: string, id: string) { + return this._throttles.get([scope, id].join(MemoryDataManager.SEPARATOR)); + } + + async setThrottle(scope: string, id: string, object: ThrottleObject) { + this._throttles.set([scope, id].join(MemoryDataManager.SEPARATOR), object); + return; + } + + async removeThrottle(scope: string, id: string) { + this._throttles.delete([scope, id].join(MemoryDataManager.SEPARATOR)); + return; + } + + /** + * Flushes any expired throttles. + */ + flushThrottles() { + for (const key in this._throttles) { + const throttle = this._throttles.get(key); + if (throttle && throttle.reset < Date.now()) this._throttles.delete(key); + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..92f86aa --- /dev/null +++ b/src/index.ts @@ -0,0 +1,49 @@ +import VoltareClient from './client'; +import EventRegistry from './client/events'; +import PermissionRegistry from './client/permissions'; +import CollectorModule from './modules/collector'; +import Collector from './modules/collector/collector'; +import MessageCollector from './modules/collector/message'; +import CommandsModule from './modules/commands'; +import VoltareCommand from './modules/commands/command'; +import CommandContext from './modules/commands/context'; +import ArgumentInterpreter from './modules/commands/interpreter'; +import VoltareModule from './module'; +import LoggerHandler from './util/logger'; +import DataManager from './dataManager'; +import MemoryDataManager from './dataManagers/memory'; +import * as Util from './util'; + +export { + VoltareClient, + EventRegistry, + PermissionRegistry, + CollectorModule, + Collector, + MessageCollector, + CommandsModule, + VoltareCommand, + CommandContext, + ArgumentInterpreter, + VoltareModule, + LoggerHandler, + DataManager, + MemoryDataManager, + Util +}; + +export { VoltareEvents, BaseConfig } from './client'; +export { EventHandlers, EventGroup, ClientEvent } from './client/events'; +export { PermissionFunction, CorePermissions } from './client/permissions'; +export { CollectorOptions, CollectorFilter, ResetTimerOptions } from './modules/collector/collector'; +export { AwaitMessagesOptions } from './modules/collector'; +export { MessageCollectorOptions, MessageCollectorFilter } from './modules/collector/message'; +export { DefaultCommand } from './modules/commands'; +export { CommandOptions, ThrottlingOptions } from './modules/commands/command'; +export { StringIterator } from './modules/commands/interpreter'; +export { ThrottleObject, ThrottleResult } from './dataManager'; +export { RevoltEventNames, PermissionNames } from './constants'; +export { ModuleOptions } from './module'; +export { RevoltEvents, LoggerExtra, PermissionObject } from './types'; + +export const VERSION: string = require('../package.json').version; diff --git a/src/module.ts b/src/module.ts new file mode 100644 index 0000000..13d5d11 --- /dev/null +++ b/src/module.ts @@ -0,0 +1,84 @@ +import VoltareClient, { VoltareEvents } from './client'; +import { EventHandlers } from './client/events'; +import LoggerHandler from './util/logger'; + +/** Options for the {@link VoltareModule}. */ +export interface ModuleOptions { + /** The name of the module. */ + name: string; + /** The requirements/dependencies of the module. */ + requires?: string[]; + /** The description of the module. */ + description?: string; +} + +/** A module for Voltare. */ +export default class VoltareModule> { + /** The options for this module. */ + readonly options: ModuleOptions; + /** @hidden */ + readonly registerQueue: [ + { event: keyof VoltareEvents; before?: string[]; after?: string[] }, + EventHandlers[keyof VoltareEvents] + ][] = []; + /** The logger for the module. */ + readonly logger: LoggerHandler; + /** The Voltare client for this module. */ + readonly client: T; + /** Whether the module has been loaded. */ + loaded = false; + /** + * The file path of the module. + * Set this to `__filename` in the constructor. + */ + filePath?: string; + + constructor(client: T, options: ModuleOptions) { + this.options = options; + this.client = client; + this.logger = new LoggerHandler(this.client, this.options.name); + } + + /** @hidden */ + async _load() { + this.loaded = true; + this.registerQueue.forEach(([{ event, before, after }, handler]) => + this.registerEvent(event, handler, { before, after }) + ); + this.registerQueue.length = 0; + await this.load(); + } + + /** Fired when this module is loaded. */ + load() {} + + /** Fired when this module is being unloaded. */ + unload() {} + + /** + * Registers an event for this module. + * @param event The event to register + * @param handler The event handler + * @param options The options for the handler + */ + registerEvent( + event: E, + handler: EventHandlers[E], + options?: { before?: string[]; after?: string[] } + ) { + return this.client.events.register(this.options.name, event, handler, options); + } + + /** + * Unregisters an event from this module. + * @param event The event to unregister + */ + unregisterEvent(event: keyof VoltareEvents) { + return this.client.events.unregister(this.options.name, event); + } + + /** Unregisters all events from this module. */ + unregisterAllEvents() { + return this.client.events.unregisterGroup(this.options.name); + } +} diff --git a/src/modules/collector/collector.ts b/src/modules/collector/collector.ts new file mode 100644 index 0000000..18498d0 --- /dev/null +++ b/src/modules/collector/collector.ts @@ -0,0 +1,254 @@ +import Collection from '@discordjs/collection'; +import EventEmitter from 'eventemitter3'; +import CollectorModule from '.'; +import VoltareClient, { VoltareEvents } from '../../client'; +import { EventHandlers } from '../../client/events'; +import TypedEmitter from '../../util/typedEmitter'; + +/** @hidden */ +export type CollectorEvents = { + collect: (...args: any[]) => void; + dispose: (...args: any[]) => void; + end: (collected: Collection, reason: string) => void; +}; + +/** @hidden */ +export type CollectorFilter = (...args: any[]) => boolean | Promise; + +/** The options for a {@link Collector}. */ +export interface CollectorOptions { + /** How long to run the collector for in milliseconds */ + time?: number; + /** How long to stop the collector after inactivity in milliseconds */ + idle?: number; + /** Whether to dispose data when it's deleted */ + dispose?: boolean; +} + +/** The options for {@link Collector#resetTimer}. */ +export interface ResetTimerOptions { + /** How long to run the collector for in milliseconds */ + time?: number; + /** How long to stop the collector after inactivity in milliseconds */ + idle?: number; +} + +/** Class for defining a collector. */ +export default class Collector extends (EventEmitter as any as new () => TypedEmitter) { + readonly module: CollectorModule>; + readonly client: VoltareClient; + + /** The filter applied to this collector */ + readonly filter: CollectorFilter; + /** The options of this collector */ + readonly options: CollectorOptions; + /** The items collected by this collector */ + readonly collected = new Collection(); + /** Whether this collector has finished collecting */ + ended = false; + + // eslint-disable-next-line no-undef + private _timeout: NodeJS.Timeout | null = null; + // eslint-disable-next-line no-undef + private _idletimeout: NodeJS.Timeout | null = null; + + readonly id: string; + + constructor( + collectorModule: CollectorModule>, + filter: CollectorFilter, + options: CollectorOptions = {} + ) { + // eslint-disable-next-line constructor-super + super(); + this.module = collectorModule; + this.client = collectorModule.client; + this.filter = filter; + this.options = options; + this.id = (Date.now() + Math.round(Math.random() * 1000)).toString(36); + + if (typeof filter !== 'function') throw new TypeError('INVALID_TYPE'); + + this.handleCollect = this.handleCollect.bind(this); + this.handleDispose = this.handleDispose.bind(this); + + if (options.time) this._timeout = setTimeout(() => this.stop('time'), options.time); + if (options.idle) this._idletimeout = setTimeout(() => this.stop('idle'), options.idle); + + this.module.activeCollectors.set(this.id, this); + } + + registerEvent( + event: E, + handler: EventHandlers[E], + options?: { before?: string[]; after?: string[] } + ) { + return this.client.events.register('collector:' + this.id, event, handler, options); + } + + /** + * Call this to handle an event as a collectable element. Accepts any event data as parameters. + * @param args The arguments emitted by the listener + */ + async handleCollect(...args: any[]) { + const collect = this.collect(...args); + + if (collect && (await this.filter(...args, this.collected))) { + this.collected.set(collect.key, collect.value); + this.emit('collect', ...args); + + if (this._idletimeout) { + clearTimeout(this._idletimeout); + this._idletimeout = setTimeout(() => this.stop('idle'), this.options.idle!); + } + } + this.checkEnd(); + } + + /** + * Call this to remove an element from the collection. Accepts any event data as parameters. + * @param args The arguments emitted by the listener + */ + handleDispose(...args: any[]) { + if (!this.options.dispose) return; + + const dispose = this.dispose(...args); + // deepscan-disable-next-line CONSTANT_CONDITION + if (!dispose || !this.filter(...args) || !this.collected.has(dispose)) return; + this.collected.delete(dispose); + + this.emit('dispose', ...args); + this.checkEnd(); + } + + /** + * Returns a promise that resolves with the next collected element; + * rejects with collected elements if the collector finishes without receiving a next element + */ + get next() { + return new Promise((resolve, reject) => { + if (this.ended) { + reject(this.collected); + return; + } + + const cleanup = () => { + this.removeListener('collect', onCollect); + this.removeListener('end', onEnd); + }; + + const onCollect = (item: any) => { + cleanup(); + resolve(item); + }; + + const onEnd = () => { + cleanup(); + reject(this.collected); + }; + + this.on('collect', onCollect); + this.on('end', onEnd); + }); + } + + /** + * Stops this collector and emits the `end` event. + * @param reason the reason this collector is ending + */ + stop(reason = 'user') { + if (this.ended) return; + + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + if (this._idletimeout) { + clearTimeout(this._idletimeout); + this._idletimeout = null; + } + this.ended = true; + + this.client.events.unregisterGroup('collector:' + this.id); + this.module.activeCollectors.delete(this.id); + this.emit('end', this.collected, reason); + } + + /** + * Resets the collectors timeout and idle timer. + * @param {Object} [options] Options + * @param {number} [options.time] How long to run the collector for in milliseconds + * @param {number} [options.idle] How long to stop the collector after inactivity in milliseconds + */ + resetTimer(options: ResetTimerOptions = {}) { + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = setTimeout(() => this.stop('time'), (options && options.time) || this.options.time!); + } + if (this._idletimeout) { + clearTimeout(this._idletimeout); + this._idletimeout = setTimeout( + () => this.stop('idle'), + (options && options.idle) || this.options.idle! + ); + } + } + + /** Checks whether the collector should end, and if so, ends it. */ + checkEnd() { + const reason = this.endReason(); + if (reason) this.stop(reason); + } + + /** + * Allows collectors to be consumed with for-await-of loops + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of} + */ + async *[Symbol.asyncIterator]() { + const queue: any[] = []; + const onCollect = (item: any) => queue.push(item); + this.on('collect', onCollect); + + try { + while (queue.length || !this.ended) { + if (queue.length) { + yield queue.shift(); + } else { + await new Promise((resolve) => { + const tick = () => { + this.removeListener('collect', tick); + this.removeListener('end', tick); + return resolve(); + }; + this.on('collect', tick); + this.on('end', tick); + }); + } + } + } finally { + this.removeListener('collect', onCollect); + } + } + + /* eslint-disable no-empty-function, @typescript-eslint/no-unused-vars */ + /** + * Handles incoming events from the `handleCollect` function. Returns null if the event should not + * be collected, or returns an object describing the data that should be stored. + * @see Collector#handleCollect + * @param args Any args the event listener emits + * @returns Data to insert into collection, if any + */ + collect(...args: any[]): { key: any; value: any } | void | null {} + + /** + * Handles incoming events from the `handleDispose`. Returns null if the event should not + * be disposed, or returns the key that should be removed. + * @see Collector#handleDispose + * @param args Any args the event listener emits + * @returns Key to remove from the collection, if any + */ + dispose(...args: any[]): any {} + + /** The reason this collector has ended or will end with. */ + endReason(): string | void | null {} +} diff --git a/src/modules/collector/index.ts b/src/modules/collector/index.ts new file mode 100644 index 0000000..605db9b --- /dev/null +++ b/src/modules/collector/index.ts @@ -0,0 +1,58 @@ +import Collection from '@discordjs/collection'; +import * as Revolt from 'better-revolt-js'; +import VoltareClient from '../../client'; +import VoltareModule from '../../module'; +import Collector from './collector'; +import MessageCollector, { MessageCollectorFilter, MessageCollectorOptions } from './message'; + +/** The options for {@link CollectorModule#awaitMessages}. */ +export interface AwaitMessagesOptions extends MessageCollectorOptions { + /** Stop/end reasons that cause the promise to reject */ + errors?: string[]; +} + +/** The Voltare module for collecting objects. */ +export default class CollectorModule> extends VoltareModule { + readonly activeCollectors = new Collection(); + + constructor(client: T) { + super(client, { + name: 'collector', + description: "Voltare's collection handler, for asynchronous object collection." + }); + + this.filePath = __filename; + } + + /** + * Creates a message collector. + * @param channel The channel to create the collector for + * @param filter The filter to use against new messages + * @param options The options for the collector. + */ + createMessageCollector( + channel: Revolt.TextChannel | Revolt.DMChannel | Revolt.GroupChannel, + filter: MessageCollectorFilter, + options: MessageCollectorOptions = {} + ): MessageCollector { + return new MessageCollector(this, channel, filter, options); + } + + /** Awaits messages in a channel. */ + awaitMessages( + channel: Revolt.TextChannel | Revolt.DMChannel | Revolt.GroupChannel, + filter: MessageCollectorFilter, + options: AwaitMessagesOptions = {} + ) { + return new Promise((resolve, reject) => { + const collector = this.createMessageCollector(channel, filter, options); + collector.once('end', (collection, reason) => { + if (options.errors && options.errors.includes(reason)) { + reject(collection); + } else { + resolve(collection); + } + }); + }); + } +} diff --git a/src/modules/collector/message.ts b/src/modules/collector/message.ts new file mode 100644 index 0000000..d7b474b --- /dev/null +++ b/src/modules/collector/message.ts @@ -0,0 +1,75 @@ +import * as Revolt from 'better-revolt-js'; +import CollectorModule from '.'; +import { VoltareClient } from '../..'; +import { ClientEvent } from '../../client/events'; +import Collector, { CollectorOptions } from './collector'; + +export type MessageCollectorFilter = (message: Revolt.Message) => boolean; + +export interface MessageCollectorOptions extends CollectorOptions { + /** The maximum amount of messages to collect */ + max?: number; + /** The maximum amount of messages to process */ + maxProcessed?: number; + /** The event groups to skip over while collecting */ + skip?: string[]; +} + +/** + * Collects messages on a channel. + * Will automatically stop if the channel (`'channelDelete'`) or guild (`'guildDelete'`) are deleted. + */ +export default class MessageCollector extends Collector { + readonly channel: Revolt.TextChannel | Revolt.DMChannel | Revolt.GroupChannel; + readonly options!: MessageCollectorOptions; + received = 0; + constructor( + collectorModule: CollectorModule>, + channel: Revolt.TextChannel | Revolt.DMChannel | Revolt.GroupChannel, + filter: MessageCollectorFilter, + options: MessageCollectorOptions = {} + ) { + super(collectorModule, filter, options); + this.channel = channel; + + this.registerEvent(Revolt.Events.MESSAGE, this.handleCollect, { + before: this.options.skip || [] + }); + this.registerEvent(Revolt.Events.MESSAGE_DELETE, this.handleDispose); + this.registerEvent(Revolt.Events.CHANNEL_DELETE, (_, channel) => { + if (channel.id === this.channel.id) this.stop('channelDelete'); + }); + this.registerEvent(Revolt.Events.SERVER_DELETE, (_, server) => { + if ('serverId' in this.channel && server.id === this.channel.serverId) this.stop('serverDelete'); + }); + } + + /** + * Handles a message for possible collection. + * @param message The message that could be collected + */ + collect(event: ClientEvent, message: Revolt.Message) { + if (message.channel.id !== this.channel.id) return null; + if (this.options.skip) this.options.skip.forEach((group) => event.skip(group)); + this.received++; + return { + key: message.id, + value: message + }; + } + + /** + * Handles a message for possible disposal. + * @param message The message that could be disposed of + */ + dispose(_: never, message: Revolt.Message) { + return message.channel.id === this.channel.id ? message.id : null; + } + + /** Checks after un/collection to see if the collector is done. */ + endReason() { + if (this.options.max && this.collected.size >= this.options.max) return 'limit'; + if (this.options.maxProcessed && this.received === this.options.maxProcessed) return 'processedLimit'; + return null; + } +} diff --git a/src/modules/commands/command.ts b/src/modules/commands/command.ts new file mode 100644 index 0000000..e3ee695 --- /dev/null +++ b/src/modules/commands/command.ts @@ -0,0 +1,254 @@ +import * as Revolt from 'better-revolt-js'; +import { oneLine } from 'common-tags'; +import CommandsModule from '.'; +import VoltareClient from '../../client'; +import { PermissionNames } from '../../constants'; +import CommandContext from './context'; +import { ClientEvent } from '../../client/events'; + +/** The options for a {@link VoltareCommand}. */ +export interface CommandOptions { + /** The name of the command. */ + name: string; + /** The command's aliases. */ + aliases?: string[]; + /** The command's category. */ + category?: string; + /** The description of the command. */ + description?: string; + /** The required permission(s) for a user to use this command. */ + userPermissions?: string[]; + // TODO clientPermissions + // /** The required client permission(s) for this command. */ + // clientPermissions?: (keyof Eris.Constants['Permissions'])[]; + /** The throttling options for the command. */ + throttling?: ThrottlingOptions; + /** Metadata for the command. Useful for any other identifiers for the command. */ + metadata?: any; +} + +/** The throttling options for a {@link VoltareCommand}. */ +export interface ThrottlingOptions { + /** Maximum number of usages of the command allowed in the time frame. */ + usages: number; + /** Amount of time to count the usages of the command within (in seconds). */ + duration: number; + /** The Voltare permissions that can bypass throttling. */ + bypass?: string[]; +} + +export default class VoltareCommand { + /** The command's name. */ + readonly name: string; + /** The command's aliases. */ + readonly aliases: string[]; + /** The command's category. */ + readonly category: string; + /** The command's description. */ + readonly description?: string; + /** The permissions required to use this command. */ + readonly userPermissions?: string[]; + // /** The permissions the client is required to have for this command. */ + // readonly clientPermissions?: (keyof Eris.Constants['Permissions'])[]; + /** The throttling options for this command. */ + readonly throttling?: ThrottlingOptions; + /** Metadata for the command. */ + readonly metadata?: any; + /** + * The file path of the command. + * Used for refreshing the require cache. + * Set this to `__filename` in the constructor to enable cache clearing. + */ + filePath?: string; + + /** The commands module. */ + readonly cmdsModule: CommandsModule>; + /** The client from the commands module. */ + readonly client: VoltareClient; + + /** Whether the command is enabled globally */ + private _globalEnabled = true; + + /** + * @param creator The instantiating creator. + * @param opts The options for the command. + */ + constructor(client: VoltareClient, opts: CommandOptions) { + if (this.constructor.name === 'VoltareCommand') + throw new Error('The base VoltareCommand cannot be instantiated.'); + this.cmdsModule = client.commands; + this.client = client; + + this.name = opts.name; + this.aliases = opts.aliases || []; + this.category = opts.category || 'Uncategorized'; + this.description = opts.description; + this.userPermissions = opts.userPermissions; + // this.clientPermissions = opts.clientPermissions; + this.throttling = opts.throttling; + this.metadata = opts.metadata; + } + + /** + * Checks whether the context member has permission to use the command. + * @param ctx The triggering context + * @return {boolean|string} Whether the member has permission, or an error message to respond with if they don't + */ + hasPermission(ctx: CommandContext, event?: ClientEvent): boolean | string { + if (this.userPermissions) { + const permObject = this.client.permissions.toObject(ctx.message); + let permissionMap = + event && event.has('voltare/permissionMap') ? event.get('voltare/permissionMap') : {}; + permissionMap = this.client.permissions.map(permObject, this.userPermissions, permissionMap, event); + if (event) event.set('voltare/permissionMap', permissionMap); + const missing = this.userPermissions.filter((perm: string) => !permissionMap[perm]); + + if (missing.length > 0) { + if (missing.includes('voltare.elevated')) + return `The \`${this.name}\` command can only be used by the bot developers or elevated users.`; + else if (missing.includes('voltare.inserver')) + return `The \`${this.name}\` command can only be ran in servers.`; + else if (missing.length === 1) { + return `The \`${this.name}\` command requires you to have the "${ + PermissionNames[missing[0]] || missing[0] + }" permission.`; + } + return oneLine` + The \`${this.name}\` command requires you to have the following permissions: + ${missing.map((perm) => PermissionNames[perm] || perm).join(', ')} + `; + } + } + + return true; + } + + /** + * Called when the command is prevented from running. + * @param ctx Command context the command is running from + * @param reason Reason that the command was blocked + * (built-in reasons are `permission`, `throttling`) + * @param data Additional data associated with the block. + * - permission: `response` ({@link string}) to send + * - throttling: `throttle` ({@link Object}), `remaining` ({@link number}) time in seconds + */ + onBlock(ctx: CommandContext, reason: string, data?: any) { + switch (reason) { + case 'permission': { + if (data.response) return ctx.reply(data.response); + return ctx.reply(`You do not have permission to use the \`${this.name}\` command.`); + } + case 'clientPermissions': { + if (data.missing.length === 1) { + return ctx.reply( + `I need the "${ + PermissionNames['discord.' + data.missing[0].toLowerCase()] + }" permission for the \`${this.name}\` command to work.` + ); + } + return ctx.reply(oneLine` + I need the following permissions for the \`${this.name}\` command to work: + ${data.missing.map((perm: string) => PermissionNames['discord.' + perm.toLowerCase()]).join(', ')} + `); + } + case 'throttling': { + return ctx.reply( + data.remaining + ? `You may not use the \`${this.name}\` command again for another ${data.remaining.toFixed( + 1 + )} seconds.` + : `You are currently ratelimited from using the \`${this.name}\` command. Try again later.` + ); + } + default: + return null; + } + } + + /** + * Called when the command produces an error while running. + * @param err Error that was thrown + * @param ctx Command context the command is running from + */ + onError(err: Error, ctx: CommandContext) { + return ctx.reply(`An error occurred while running the \`${this.name}\` command.`); + } + + /** + * Checks if the command is usable for a message + * @param message The message + */ + isUsable(ctx: CommandContext) { + if (!ctx) return this._globalEnabled; + const hasPermission = this.hasPermission(ctx); + return typeof hasPermission !== 'string' && hasPermission; + } + + /** + * Used to throttle a user from the command. + * @param object The permission object to throttle + * @param event The event to use + */ + async throttle(object: Revolt.Message | Revolt.User | Revolt.ServerMember, event?: ClientEvent) { + if (!this.throttling) return; + const permObject = this.client.permissions.toObject(object); + + if (this.throttling.bypass && this.throttling.bypass.length) { + let permissionMap = + event && event.has('voltare/permissionMap') ? event.get('voltare/permissionMap') : {}; + permissionMap = this.client.permissions.map(permObject, this.throttling.bypass, permissionMap, event); + if (event) event.set('voltare/permissionMap', permissionMap); + const missing = this.throttling.bypass.filter((perm: string) => !permissionMap[perm]); + if (!missing.length) return; + } + + return await this.client.data.throttle( + 'command_' + this.name, + this.throttling, + permObject.user.id, + event + ); + } + + /** + * Runs the command. + * @param ctx The context of the message + */ + async run(ctx: CommandContext): Promise { // eslint-disable-line @typescript-eslint/no-unused-vars, prettier/prettier + throw new Error(`${this.constructor.name} doesn't have a run() method.`); + } + + /** + * Preloads the command. + * This function is called upon loading the command, NOT after logging in. + */ + async preload(): Promise { + return true; + } + + /** Reloads the command. */ + reload() { + if (!this.filePath) throw new Error('Cannot reload a command without a file path defined!'); + const newCommand = require(this.filePath); + this.cmdsModule.reregister(newCommand, this); + } + + /** Unloads the command. */ + unload() { + if (this.filePath && require.cache[this.filePath]) delete require.cache[this.filePath]; + this.cmdsModule.unregister(this); + } + + /** + * Finalizes the return output. + * @param response The response from the command run + * @param ctx The context of the message + */ + finalize(response: any, ctx: CommandContext) { + if ( + typeof response === 'string' || + (response && response.constructor && response.constructor.name === 'Object') + ) + return ctx.send(response); + } +} diff --git a/src/modules/commands/context.ts b/src/modules/commands/context.ts new file mode 100644 index 0000000..5ab4933 --- /dev/null +++ b/src/modules/commands/context.ts @@ -0,0 +1,94 @@ +import * as Revolt from 'better-revolt-js'; +import CommandsModule from '.'; +import VoltareClient from '../../client'; +import { ClientEvent } from '../../client/events'; + +export default class CommandContext { + /** The commands module. */ + readonly cmdsModule: CommandsModule>; + /** The event that created this context. */ + readonly event: ClientEvent; + /** The client from this context. */ + readonly client: VoltareClient; + /** The message this context is reffering to. */ + readonly message: Revolt.Message; + /** The channel that the message is in. */ + readonly channel: Revolt.TextChannel | Revolt.DMChannel | Revolt.GroupChannel; + /** The author of the message. */ + readonly author: Revolt.User; + /** The prefix used for this context. */ + readonly prefix: string; + /** The arguments used in this context. */ + readonly args: string[]; + /** The server the message is in. */ + readonly server?: Revolt.Server; + /** The member that created the message. */ + member?: Revolt.ServerMember; + + /** + * @param creator The instantiating creator. + * @param data The interaction data for the context. + * @param respond The response function for the interaction. + * @param webserverMode Whether the interaction was from a webserver. + */ + constructor( + cmdsModule: CommandsModule, + event: ClientEvent, + args: string[], + prefix: string, + message: Revolt.Message + ) { + this.cmdsModule = cmdsModule; + this.client = cmdsModule.client; + this.event = event; + this.message = message; + this.channel = message.channel; + this.author = message.author!; + if (message.member) this.member = message.member; + if ('server' in message && message.server) this.server = message.server; + this.args = args; + this.prefix = prefix; + } + + /* + TODO attachments, send through .send() and .reply() + + https://autumn.revolt.chat/attachments + Form Data to `file` + {"id":"IdRF3_PV0-AD7kCyuQ-Vd98ZhGrvjiUYQM0WcIrP-d"} + + In message: + { attachments: ['IdRF3_PV0-AD7kCyuQ-Vd98ZhGrvjiUYQM0WcIrP-d'] } + */ + + /** Shorthand for `message.channel.send`. */ + send(content: Revolt.MessageOptions | string) { + return this.message.channel.send(content); + } + + /** + * Replies to the message in context. + */ + reply(content: Revolt.MessageOptions | string) { + if (typeof content === 'string') content = { content }; + if ('replies' in content && content.replies) content.replies.push({ id: this.message.id, mention: true }); + else content.replies = [{ id: this.message.id, mention: true }]; + return this.message.channel.send(content); + } + + /** + * Fetches the member for this message and assigns it. + */ + async fetchMember() { + if (this.member) return this.member; + if (!this.server) return null; + let member = this.server.members.cache.get(this.author.id); + if (member) { + this.member = member; + return member; + } + member = await this.server.members.fetch(this.author.id); + this.member = member; + return member; + } +} diff --git a/src/modules/commands/default/eval.ts b/src/modules/commands/default/eval.ts new file mode 100644 index 0000000..0fafb3e --- /dev/null +++ b/src/modules/commands/default/eval.ts @@ -0,0 +1,110 @@ +import VoltareClient from '../../../client'; +import VoltareCommand from '../command'; +import CommandContext from '../context'; +import util from 'util'; +import { escapeRegex } from '../../../util'; + +const nl = '!!NL!!'; +const nlPattern = new RegExp(nl, 'g'); + +export default class EvalCommand extends VoltareCommand { + private _sensitivePattern?: RegExp; + private hrStart?: [number, number]; + private lastResult?: any; + + constructor(client: VoltareClient) { + super(client, { + name: 'eval', + description: 'Evaluates code.', + category: 'Developer', + userPermissions: ['voltare.elevated'], + metadata: { + examples: ['eval 1+1', 'eval someAsyncFunction.then(callback)'], + usage: '', + details: + 'Only the bot owner(s) may use this command. Can use `message`, `client`, `lastResult`, `event` and `callback` in evaluation.' + } + }); + + Object.defineProperty(this, '_sensitivePattern', { + value: null, + configurable: true + }); + + this.filePath = __filename; + } + + async run(ctx: CommandContext) { + let evalString: string = ctx.event + .get('commands/strippedContent') + .slice(ctx.event.get('commands/commandName').length + 1) + .trim(); + + if (evalString.startsWith('```') && evalString.endsWith('```')) + evalString = evalString.replace(/(^.*?\s)|(\n.*$)/g, ''); + + if (!evalString) return 'This command requires some code.'; + + /* eslint-disable @typescript-eslint/no-unused-vars, no-unused-vars */ + const message = ctx.message; + const client = ctx.client; + const event = ctx.event; + const lastResult = this.lastResult; + const callback = (val: any) => { + if (val instanceof Error) ctx.reply(`Callback error: \`${val}\``); + else { + const result = this.makeResultMessages(val, process.hrtime(this.hrStart)); + ctx.reply(result); + } + }; + /* eslint-enable @typescript-eslint/no-unused-vars, no-unused-vars */ + + let hrDiff; + try { + const hrStart = process.hrtime(); + this.lastResult = eval(evalString); + hrDiff = process.hrtime(hrStart); + } catch (err) { + return `Error while evaluating: \`${err}\``; + } + + this.hrStart = process.hrtime(); + return this.makeResultMessages(this.lastResult, hrDiff, evalString); + } + + makeResultMessages(result: any, hrDiff: [number, number], input?: string) { + const inspected = util + .inspect(result, { depth: 0 }) + .replace(nlPattern, '\n') + .replace(this.sensitivePattern, '--snip--'); + if (input) { + return ( + `*Executed in ${hrDiff[0] > 0 ? `${hrDiff[0]}s ` : ''}${hrDiff[1] / 1000000}ms.*\n\`\`\`js\n` + + inspected.slice(0, 1900) + + `\`\`\`` + ); + } else { + return ( + `*Callback executed after ${hrDiff[0] > 0 ? `${hrDiff[0]}s ` : ''}${ + hrDiff[1] / 1000000 + }ms.*\n\`\`\`js\n` + + inspected.slice(0, 1900) + + `\`\`\`` + ); + } + } + + get sensitivePattern() { + if (!this._sensitivePattern) { + // @ts-ignore + const token = this.client.bot._token; + let pattern = ''; + if (token) pattern += escapeRegex(token); + Object.defineProperty(this, '_sensitivePattern', { + value: new RegExp(pattern, 'gi'), + configurable: false + }); + } + return this._sensitivePattern!; + } +} diff --git a/src/modules/commands/default/exec.ts b/src/modules/commands/default/exec.ts new file mode 100644 index 0000000..9310a24 --- /dev/null +++ b/src/modules/commands/default/exec.ts @@ -0,0 +1,46 @@ +import { exec } from 'child_process'; +import VoltareClient from '../../../client'; +import VoltareCommand from '../command'; +import CommandContext from '../context'; + +export default class ExecCommand extends VoltareCommand { + constructor(client: VoltareClient) { + super(client, { + name: 'exec', + description: 'Executes terminal commands with child_process.exec.', + category: 'Developer', + userPermissions: ['voltare.elevated'], + metadata: { + examples: ['exec echo hi'], + usage: '' + } + }); + + this.filePath = __filename; + } + + async run(ctx: CommandContext) { + let execString: string = ctx.event + .get('commands/strippedContent') + .trim() + .slice(ctx.event.get('commands/commandName') + 1); + + if (execString.startsWith('```') && execString.endsWith('```')) + execString = execString.replace(/(^.*?\s)|(\n.*$)/g, ''); + + if (!execString) return 'This command requires something to execute.'; + + // await this.client.startTyping(ctx.channel.id); + const hrStart = process.hrtime(); + exec(execString, (err, stdout, stderr) => { + // this.client.stopTyping(ctx.channel.id); + if (err) return ctx.send(`Error while executing: \`\`\`${err}\`\`\``); + const hrDiff = process.hrtime(hrStart); + ctx.send( + `*Executed in ${hrDiff[0] > 0 ? `${hrDiff[0]}s ` : ''}${hrDiff[1] / 1000000}ms.*\n` + + (stderr ? `\`\`\`js${stderr}\`\`\`\n` : '') + + `\`\`\`${stdout}\`\`\`` + ); + }); + } +} diff --git a/src/modules/commands/default/help.ts b/src/modules/commands/default/help.ts new file mode 100644 index 0000000..5b8a4b4 --- /dev/null +++ b/src/modules/commands/default/help.ts @@ -0,0 +1,113 @@ +import { oneLine, stripIndents } from 'common-tags'; +import VoltareClient from '../../../client'; +import { keyValueForEach, splitMessage, truncate } from '../../../util'; +import VoltareCommand from '../command'; +import CommandContext from '../context'; + +export default class HelpCommand extends VoltareCommand { + constructor(client: VoltareClient) { + super(client, { + name: 'help', + description: 'Displays a list of available commands, or detailed information for a specified command.', + category: 'General', + metadata: { + examples: ['help', 'help ping'], + usage: '[command]', + details: oneLine` + The command may be part of a command name or a whole command name. + If it isn't specified, all available commands will be listed. + ` + } + }); + + this.filePath = __filename; + } + + async run(ctx: CommandContext) { + const prefix = ctx.prefix + (ctx.event.get('commands/spacedPrefix') ? ' ' : ''); + + if (ctx.args.length) { + const commands = ctx.cmdsModule.find(ctx.args[0], ctx); + if (!commands.length) return `I couldn't find any commands with \`${ctx.args[0]}\`!`; + else { + const command = commands[0]; + let text = stripIndents` + __**${prefix}${command.name}**:__ ${command.description || ''} + **Category:** ${command.category} + `; + + // Aliases + if (command.aliases.length !== 0) { + text += `\n**Aliases:** ${command.aliases.join(', ')}`; + } + + // Details + if (command.metadata?.details) { + text += `\n**Details:** ${command.metadata.details}`; + } + + // Usage + if (command.metadata?.usage) { + text += `\n**Usage:** ${command.metadata.usage}`; + } + + // Examples + if (command.metadata?.examples && command.metadata?.examples.length !== 0) { + text += `\n**Examples:**\n${command.metadata.examples.join('\n')}`; + } + + return text; + } + } + + // Display general help command + const blocks: string[] = []; + + // Populate categories + const categories: { [cat: string]: string[] } = {}; + this.client.commands.commands.forEach((command) => { + if (typeof command.hasPermission(ctx, ctx.event) === 'string') return; + const commandName = command.name; + const category = command.category || 'Uncategorized'; + if (categories[category]) categories[category].push(commandName); + else categories[category] = [commandName]; + }); + + // List categories into fields + keyValueForEach(categories, (cat, cmdNames) => { + let cmds: string[] = []; + let valueLength = 0; + let fieldsPushed = 0; + cmdNames.forEach((name: string) => { + const length = name.length + 4; + if (valueLength + length > 1024) { + fieldsPushed++; + blocks.push(stripIndents` + __**${truncate(cat, 200)} (${fieldsPushed})**__ + ${cmds.join(', ')} + `); + valueLength = 0; + cmds = []; + } + + cmds.push(`\`${name}\``); + valueLength += length; + }); + + blocks.push(stripIndents` + __**${fieldsPushed ? `${truncate(cat, 200)} (${fieldsPushed + 1})` : truncate(cat, 256)}**__ + ${cmds.join(', ')} + `); + }); + + const messages = splitMessage(blocks.join('\n\n')); + if (messages.length === 1) return messages[0]; + try { + const dm = await ctx.author.createDM(); + for (const content of messages) await dm.send(content); + if (ctx.server) return 'Sent you a DM with information.'; + } catch (e) { + return 'Unable to send you the help DM. You probably have DMs disabled.'; + } + } +} diff --git a/src/modules/commands/default/kill.ts b/src/modules/commands/default/kill.ts new file mode 100644 index 0000000..1cac0ed --- /dev/null +++ b/src/modules/commands/default/kill.ts @@ -0,0 +1,25 @@ +import VoltareClient from '../../../client'; +import VoltareCommand from '../command'; +import CommandContext from '../context'; + +export default class KillCommand extends VoltareCommand { + constructor(client: VoltareClient) { + super(client, { + name: 'kill', + description: 'Disconnects the bot and kills the process.', + category: 'Developer', + userPermissions: ['voltare.elevated'], + metadata: { + examples: ['kill'] + } + }); + + this.filePath = __filename; + } + + async run(ctx: CommandContext) { + await ctx.reply('Killing the bot...'); + await this.client.disconnect(); + process.exit(0); + } +} diff --git a/src/modules/commands/default/load.ts b/src/modules/commands/default/load.ts new file mode 100644 index 0000000..60dedf6 --- /dev/null +++ b/src/modules/commands/default/load.ts @@ -0,0 +1,52 @@ +import path from 'path'; +import VoltareClient from '../../../client'; +import VoltareCommand from '../command'; +import CommandContext from '../context'; + +export default class LoadCommand extends VoltareCommand { + constructor(client: VoltareClient) { + super(client, { + name: 'load', + description: 'Loads modules.', + category: 'Developer', + userPermissions: ['voltare.elevated'], + metadata: { + examples: ['load ./path/to/module', 'load ~@dexare/logger'], + usage: ' [path] ...', + details: 'You can prefix a path name with `~` to load from a package.' + } + }); + + this.filePath = __filename; + } + + async run(ctx: CommandContext) { + if (!ctx.args.length) return 'Please define module(s) you want to load.'; + + const mods: any[] = []; + + for (const arg of ctx.args) { + try { + let requirePath: string; + if (arg.startsWith('~')) { + requirePath = arg.slice(1); + } else { + requirePath = path.join(process.cwd(), arg); + } + delete require.cache[require.resolve(requirePath)]; + const mod = require(requirePath); + mods.push(mod); + } catch (e) { + if ((e as any).code === 'MODULE_NOT_FOUND') return `A module could not be found in \`${arg}\`.`; + return `Error loading module from \`${arg}\`: \`${(e as any).toString()}\``; + } + } + + try { + await this.client.loadModulesAsync(...mods); + return `Loaded ${ctx.args.length.toLocaleString()} module(s).`; + } catch (e) { + return `Error loading modules: \`${(e as any).toString()}\``; + } + } +} diff --git a/src/modules/commands/default/ping.ts b/src/modules/commands/default/ping.ts new file mode 100644 index 0000000..cf9f64d --- /dev/null +++ b/src/modules/commands/default/ping.ts @@ -0,0 +1,27 @@ +import { oneLine } from 'common-tags'; +import VoltareClient from '../../../client'; +import VoltareCommand from '../command'; +import CommandContext from '../context'; + +export default class PingCommand extends VoltareCommand { + constructor(client: VoltareClient) { + super(client, { + name: 'ping', + description: "Checks the bot's ping and latency.", + category: 'General', + metadata: { + examples: ['ping'] + } + }); + + this.filePath = __filename; + } + + async run(ctx: CommandContext) { + const timeBeforeMessage = Date.now(); + const pingMsg = await ctx.reply('Pinging...'); + await pingMsg.edit(oneLine` + Pong! The message took ${(Date.now() - timeBeforeMessage).toLocaleString()}ms to be created. + `); + } +} diff --git a/src/modules/commands/default/reload.ts b/src/modules/commands/default/reload.ts new file mode 100644 index 0000000..a7fcfbe --- /dev/null +++ b/src/modules/commands/default/reload.ts @@ -0,0 +1,43 @@ +import fs from 'fs'; +import VoltareClient from '../../../client'; +import VoltareCommand from '../command'; +import CommandContext from '../context'; + +export default class ReloadCommand extends VoltareCommand { + constructor(client: VoltareClient) { + super(client, { + name: 'reload', + description: 'Reloads modules.', + category: 'Developer', + userPermissions: ['voltare.elevated'], + metadata: { + examples: ['reload moduleName'], + usage: ' [moduleName] ...' + } + }); + + this.filePath = __filename; + } + + fileExists(path: string) { + const stat = fs.lstatSync(path); + return stat.isFile(); + } + + async run(ctx: CommandContext) { + if (!ctx.args.length) return 'Please define module(s) you want to reload.'; + + for (const arg of ctx.args) { + if (!this.client.modules.has(arg)) return `The module \`${arg}\` does not exist.`; + const mod = this.client.modules.get(arg)!; + if (!mod.filePath) return `The module \`${arg}\` does not have a file path defined.`; + if (!this.fileExists(mod.filePath)) return `The file for module \`${arg}\` no longer exists.`; + await this.client.unloadModule(arg); + delete require.cache[require.resolve(mod.filePath)]; + const newMod = require(mod.filePath); + this.client.loadModules(newMod); + } + + return `Reloaded ${ctx.args.length.toLocaleString()} module(s).`; + } +} diff --git a/src/modules/commands/default/unload.ts b/src/modules/commands/default/unload.ts new file mode 100644 index 0000000..88f1a09 --- /dev/null +++ b/src/modules/commands/default/unload.ts @@ -0,0 +1,31 @@ +import VoltareClient from '../../../client'; +import VoltareCommand from '../command'; +import CommandContext from '../context'; + +export default class UnloadCommand extends VoltareCommand { + constructor(client: VoltareClient) { + super(client, { + name: 'unload', + description: 'Unloads modules.', + category: 'Developer', + userPermissions: ['voltare.elevated'], + metadata: { + examples: ['unload moduleName'], + usage: ' [moduleName] ...' + } + }); + + this.filePath = __filename; + } + + async run(ctx: CommandContext) { + if (!ctx.args.length) return 'Please define module(s) you want to unload.'; + + for (const arg of ctx.args) { + if (!this.client.modules.has(arg)) return `The module \`${arg}\` does not exist.`; + await this.client.unloadModule(arg); + } + + return `Unloaded ${ctx.args.length.toLocaleString()} module(s).`; + } +} diff --git a/src/modules/commands/index.ts b/src/modules/commands/index.ts new file mode 100644 index 0000000..ea37498 --- /dev/null +++ b/src/modules/commands/index.ts @@ -0,0 +1,295 @@ +import Collection from '@discordjs/collection'; +import * as Revolt from 'better-revolt-js'; +import { join } from 'path'; +import VoltareClient from '../../client'; +import { ClientEvent } from '../../client/events'; +import VoltareModule from '../../module'; +import { iterateFolder } from '../../util'; +import VoltareCommand from './command'; +import CommandContext from './context'; +import ArgumentInterpreter from './interpreter'; + +import EvalCommand from './default/eval'; +import HelpCommand from './default/help'; +import PingCommand from './default/ping'; +import ExecCommand from './default/exec'; +import KillCommand from './default/kill'; +import LoadCommand from './default/load'; +import UnloadCommand from './default/unload'; +import ReloadCommand from './default/reload'; + +/** The default command names available. */ +export type DefaultCommand = 'eval' | 'help' | 'ping' | 'exec' | 'kill' | 'load' | 'unload' | 'reload'; + +/** The commands module in Voltare. */ +export default class CommandsModule> extends VoltareModule { + /** The commands loaded into the module. */ + readonly commands = new Collection(); + + constructor(client: T) { + super(client, { + name: 'commands', + description: "Voltare's command handler." + }); + + this.filePath = __filename; + } + + /** @hidden */ + load() { + this.registerEvent(Revolt.Events.MESSAGE, this.onMessage.bind(this)); + } + + /** @hidden */ + unload() { + this.unregisterAllEvents(); + } + + /** + * Registers a command. + * @param command The command to register + */ + register(command: any) { + if (typeof command === 'function') command = new command(this.client); + else if (typeof command.default === 'function') command = new command.default(this.client); + + if (!(command instanceof VoltareCommand)) + throw new Error(`Invalid command object to register: ${command}`); + + // Make sure there aren't any conflicts + if (this.commands.some((cmd) => cmd.name === command.name || cmd.aliases.includes(command.name))) { + throw new Error(`A command with the name/alias "${command.name}" is already registered.`); + } + for (const alias of command.aliases) { + if (this.commands.some((cmd) => cmd.name === alias || cmd.aliases.includes(alias))) { + throw new Error(`A command with the name/alias "${alias}" is already registered.`); + } + } + + command.preload(); + this.commands.set(command.name, command); + this.logger.log(`Registered command ${command.name}.`); + return command; + } + + /** + * Registers commands from a folder. + * @param path The path to register from. + */ + registerFromFolder(path: string) { + return iterateFolder(path, async (file) => this.register(require(join(process.cwd(), file)))); + } + + /** + * Re-registers a command. + * @param command The new command + * @param oldCommand The old command + */ + reregister(command: any, oldCommand: VoltareCommand) { + if (typeof command === 'function') command = new command(this.client); + else if (typeof command.default === 'function') command = new command.default(this.client); + + if (!(command instanceof VoltareCommand)) + throw new Error(`Invalid command object to register: ${command}`); + + if (command.name !== oldCommand.name) throw new Error('Command name cannot change.'); + + command.preload(); + this.commands.set(command.name, command); + this.logger.log(`Reregistered command ${command.name}.`); + } + + /** + * Unregisters a command. + * @param command The command to unregister + */ + unregister(command: VoltareCommand) { + this.commands.delete(command.name); + this.logger.log(`Unregistered command ${command.name}.`); + } + + /** + * Find commands with a query. + * @param searchString The string to search with + * @param ctx The context to check with + */ + find(searchString: string, ctx?: CommandContext) { + if (!searchString) { + return ctx + ? Array.from(this.commands.filter((cmd) => cmd.isUsable(ctx)).values()) + : Array.from(this.commands.values()); + } + const matchedCommands = Array.from( + this.commands + .filter( + (cmd) => + cmd.name === searchString || (cmd.aliases && cmd.aliases.some((ali) => ali === searchString)) + ) + .values() + ); + + return matchedCommands; + } + + /** + * Registers default commands. (eval, help, ping) + * @param commands The commands to register, if not defined, all commands are used. + */ + registerDefaults(commands?: DefaultCommand[]) { + if (!commands) commands = ['eval', 'help', 'ping', 'exec', 'kill', 'load', 'unload', 'reload']; + + if (commands.includes('eval')) this.register(EvalCommand); + if (commands.includes('help')) this.register(HelpCommand); + if (commands.includes('ping')) this.register(PingCommand); + if (commands.includes('exec')) this.register(ExecCommand); + if (commands.includes('kill')) this.register(KillCommand); + if (commands.includes('load')) this.register(LoadCommand); + if (commands.includes('unload')) this.register(UnloadCommand); + if (commands.includes('reload')) this.register(ReloadCommand); + } + + /** @hidden */ + private _escapeRegExp(string: string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + /** @hidden */ + private _buildPrefixes(event: ClientEvent) { + const prefixes: string[] = []; + let useMentionPrefix = false; + let caseSensitive = false; + + if (event.has('prefix')) { + const eventPrefixes = event.get('prefix'); + if (Array.isArray(eventPrefixes)) prefixes.push(...eventPrefixes); + else prefixes.push(eventPrefixes); + } + + if (this.client.config.prefix && !event.has('skipConfigPrefix')) { + const configPrefixes = this.client.config.prefix; + if (Array.isArray(configPrefixes)) prefixes.push(...configPrefixes); + else prefixes.push(configPrefixes); + } + + if ((this.client.config.mentionPrefix && !event.has('skipConfigPrefix')) || event.has('mentionPrefix')) + useMentionPrefix = true; + + if (this.client.config.caseSensitivePrefix || event.has('caseSensitivePrefix')) caseSensitive = true; + + if (!prefixes.length && !useMentionPrefix) return; + + const escapedPrefixes = prefixes.map(this._escapeRegExp); + + if (useMentionPrefix) escapedPrefixes.push(`<@${this.client.bot.user!.id}>`); + + return new RegExp(`^(?${escapedPrefixes.join('|')})(? )?`, caseSensitive ? '' : 'i'); + } + + /** @hidden */ + private _logCommand(level: string, command: VoltareCommand, ...args: any[]) { + return this.client.emit('logger', level, this.options.name, args, { + command + }); + } + + /** @hidden */ + private async onMessage(event: ClientEvent, message: Revolt.Message) { + if (message.system || message.type !== 'TEXT') return; + + // Resolve user + if (!message.author) + try { + await this.client.bot.users.fetch(message.authorId); + } catch (e) { + return; + } + + // Resolve member + if (message.serverId && !message.member) + try { + await message.server!.members.fetch(message.authorId); + } catch (e) { + return; + } + + // TODO Bot exclusion + if (message.authorId === this.client.bot.user!.id) return; + + const prefixRegex = this._buildPrefixes(event); + if (!prefixRegex) return; + + const match = prefixRegex.exec(message.content); + if (!message.content || !match) return; + + const prefixUsed = match.groups!.prefix; + const strippedContent = message.content.substr(match[0].length); + const argInterpretor = new ArgumentInterpreter(strippedContent); + const args = argInterpretor.parseAsStrings(); + const commandName = args.splice(0, 1)[0]; + const ctx = new CommandContext(this, event, args, prefixUsed, message); + const command = this.find(commandName, ctx)[0]; + + event.set('commands/invoked', !commandName || !command); + if (!commandName || !command) return; + + event.set('commands/prefixMatch', match); + event.set('commands/spacedPrefix', !!match.groups?.space); + event.set('commands/strippedContent', strippedContent); + event.set('commands/commandName', commandName); + event.set('commands/command', command); + event.set('commands/ctx', ctx); + + // TODO implement permission checks + // // Ensure the user has permission to use the command + // const hasPermission = command.hasPermission(ctx); + // if (!hasPermission || typeof hasPermission === 'string') { + // const data = { + // response: typeof hasPermission === 'string' ? hasPermission : undefined + // }; + // await command.onBlock(ctx, 'permission', data); + // return; + // } + + // // Ensure the client user has the required permissions + // if ('permissionsOf' in message.channel && command.clientPermissions) { + // const perms = message.channel.permissionsOf(this.client.bot.user!.id).json; + // const missing = command.clientPermissions.filter( + // (perm: keyof Eris.Constants['Permissions']) => !perms[perm] + // ); + // if (missing.length > 0) { + // const data = { missing }; + // await command.onBlock(ctx, 'clientPermissions', data); + // return; + // } + // } + + // Throttle the command + if (command.throttling) { + const throttle = await command.throttle(ctx.message); + if (throttle && !throttle.okay) { + const remaining = throttle.reset ? (throttle.reset - Date.now()) / 1000 : null; + const data = { throttle, remaining }; + command.onBlock(ctx, 'throttling', data); + return; + } + } + + // Run the command + try { + this._logCommand( + 'debug', + command, + `Running command '${command.name}' (${ctx.author.username}, ${ctx.author.id})` + ); + const promise = command.run(ctx); + const retVal = await promise; + await command.finalize(retVal, ctx); + } catch (err) { + try { + await command.onError(err as Error, ctx); + } catch (secondErr) { + this._logCommand('error', command, command.name, secondErr); + } + } + } +} diff --git a/src/modules/commands/interpreter.ts b/src/modules/commands/interpreter.ts new file mode 100644 index 0000000..2939e79 --- /dev/null +++ b/src/modules/commands/interpreter.ts @@ -0,0 +1,165 @@ +const QUOTES: { [opening: string]: string } = { + '"': '"', + '‘': '’', + '‚': '‛', + '“': '”', + '„': '‟', + '⹂': '⹂', + '「': '」', + '『': '』', + '〝': '〞', + '﹁': '﹂', + '﹃': '﹄', + '"': '"', + '「': '」', + '«': '»', + '‹': '›', + '《': '》', + '〈': '〉' +}; + +/** + * A class that iterates a string's index + * @see ArgumentInterpreter + */ +export class StringIterator { + string: string; + index = 0; + previous = 0; + end: number; + + /** + * @param string The string to iterate through + */ + constructor(string: string) { + this.string = string; + this.end = string.length; + } + + /** Get the character on an index and moves the index forward. */ + get(): string | undefined { + const nextChar = this.string[this.index]; + if (!nextChar) return nextChar; + else { + this.previous += this.index; + this.index += 1; + return nextChar; + } + } + + /** Reverts to the previous index. */ + undo() { + this.index = this.previous; + } + + /** The previous character that was used. */ + get prevChar() { + return this.string[this.previous]; + } + + /** Whether or not the index is out of range. */ + get inEOF() { + return this.index >= this.end; + } +} + +/** Parses arguments from a message. */ +export default class ArgumentInterpreter { + static QUOTES = QUOTES; + static ALL_QUOTES = Object.keys(QUOTES) + .map((i) => QUOTES[i]) + .concat(Object.keys(QUOTES)); + + string: string; + allowWhitespace: boolean; + + /** + * @param string The string that will be parsed for arguments + * @param options The options for the interpreter + * @param options.allowWhitespace Whether to allow whitespace characters in the arguments + */ + constructor(string: string, { allowWhitespace = false } = {}) { + this.string = string; + this.allowWhitespace = allowWhitespace; + } + + /** Parses the arguements as strings. */ + parseAsStrings() { + const args = []; + let currentWord = ''; + let quotedWord = ''; + const string = this.allowWhitespace ? this.string : this.string.trim(); + const iterator = new StringIterator(string); + while (!iterator.inEOF) { + const char = iterator.get(); + if (char === undefined) break; + + if (this.isOpeningQuote(char) && iterator.prevChar !== '\\') { + currentWord += char; + const closingQuote = ArgumentInterpreter.QUOTES[char]; + + // Quote iteration + while (!iterator.inEOF) { + const quotedChar = iterator.get(); + + // Unexpected EOF + if (quotedChar === undefined) { + args.push(...currentWord.split(' ')); + break; + } + + if (quotedChar == '\\') { + currentWord += quotedChar; + const nextChar = iterator.get(); + + if (nextChar === undefined) { + args.push(...currentWord.split(' ')); + break; + } + + currentWord += nextChar; + // Escaped quote + if (ArgumentInterpreter.ALL_QUOTES.includes(nextChar)) { + quotedWord += nextChar; + } else { + // Ignore escape + quotedWord += quotedChar + nextChar; + } + continue; + } + + // Closing quote + if (quotedChar == closingQuote) { + currentWord = ''; + args.push(quotedWord); + quotedWord = ''; + break; + } + + currentWord += quotedChar; + quotedWord += quotedChar; + } + continue; + } + + if (/^\s$/.test(char)) { + if (currentWord) args.push(currentWord); + currentWord = ''; + continue; + } + + currentWord += char; + } + + if (currentWord.length) args.push(...currentWord.split(' ')); + return args; + } + + /** + * Checks whether or not a character is an opening quote + * @param char The character to check + */ + isOpeningQuote(char: string) { + return Object.keys(ArgumentInterpreter.QUOTES).includes(char); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..168e376 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,61 @@ +import VoltareCommand from './modules/commands/command'; +import * as Revolt from 'better-revolt-js'; +import { TextBasedChannel } from 'better-revolt-js/dist/structures/interfaces/TextBasedChannel'; + +/** @hidden */ +export interface RevoltEvents { + [Revolt.Events.MESSAGE]: (message: Revolt.Message) => void; + [Revolt.Events.MESSAGE_DELETE]: (message: Revolt.Message) => void; + [Revolt.Events.MESSAGE_UPDATE]: (oldMessage: Revolt.Message, newMessage: Revolt.Message) => void; + + [Revolt.Events.SERVER_CREATE]: (server: Revolt.Server) => void; + [Revolt.Events.SERVER_DELETE]: (server: Revolt.Server) => void; + [Revolt.Events.SERVER_UPDATE]: (oldServer: Revolt.Server, newServer: Revolt.Server) => void; + + [Revolt.Events.READY]: (client: Revolt.Client) => void; + [Revolt.Events.DEBUG]: (message: string) => void; + [Revolt.Events.ERROR]: (error: unknown) => void; + [Revolt.Events.RAW]: (packet: unknown) => void; + + [Revolt.Events.USER_UPDATE]: (oldUser: Revolt.User, newUser: Revolt.User) => void; + + [Revolt.Events.CHANNEL_CREATE]: (channel: Revolt.Channel) => void; + [Revolt.Events.CHANNEL_DELETE]: (channel: Revolt.Channel) => void; + [Revolt.Events.CHANNEL_UPDATE]: (oldChannel: Revolt.Channel, newChannel: Revolt.Channel) => void; + + [Revolt.Events.SERVER_MEMBER_JOIN]: (member: Revolt.ServerMember) => void; + [Revolt.Events.SERVER_MEMBER_LEAVE]: (member: Revolt.ServerMember) => void; + [Revolt.Events.SERVER_MEMBER_UPDATE]: ( + oldMember: Revolt.ServerMember, + newMember: Revolt.ServerMember + ) => void; + + [Revolt.Events.ROLE_CREATE]: (role: Revolt.Role) => void; // not impl + [Revolt.Events.ROLE_DELETE]: (role: Revolt.Role) => void; + // [Revolt.Events.ROLE_UPDATE]: (oldRole: Revolt.Role, newRole: Revolt.Role) => void; + + [Revolt.Events.TYPING_START]: (channel: TextBasedChannel, user: Revolt.User) => void; + [Revolt.Events.TYPING_STOP]: (channel: TextBasedChannel, user: Revolt.User) => void; + + [Revolt.Events.GROUP_JOIN]: (group: Revolt.GroupChannel, user: Revolt.User) => void; + [Revolt.Events.GROUP_LEAVE]: (group: Revolt.GroupChannel, user: Revolt.User) => void; +} + +/** @hidden */ +interface LoggerExtraBase { + [key: string]: any; +} + +/** Extra data for logger events. */ +export interface LoggerExtra extends LoggerExtraBase { + command?: VoltareCommand; + id?: number; + trace?: string[]; +} + +/** The object for checking permissions. */ +export interface PermissionObject { + user: Revolt.User; + member?: Revolt.ServerMember; + message?: Revolt.Message; +} diff --git a/src/util/index.ts b/src/util/index.ts new file mode 100644 index 0000000..ce8b565 --- /dev/null +++ b/src/util/index.ts @@ -0,0 +1,108 @@ +import * as path from 'path'; +import * as fs from 'fs'; + +/** + * Iterates through a folder and calls back on every .js found. + * @param folder The path to check + * @param callback The function to call on each file found + * @param extension The extension to look for + */ +export async function iterateFolder( + folder: string, + callback: (path: string) => Promise, + extension = '.js' +): Promise { + const files = fs.readdirSync(folder); + return Promise.all( + files.map(async (file) => { + const filePath = path.join(folder, file); + const stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) { + const realPath = fs.readlinkSync(filePath); + if (stat.isFile() && file.endsWith(extension)) { + return callback(realPath); + } else if (stat.isDirectory()) { + return iterateFolder(realPath, callback); + } + } else if (stat.isFile() && file.endsWith(extension)) return callback(filePath); + else if (stat.isDirectory()) return iterateFolder(filePath, callback); + }) + ); +} + +/** + * Escapes a string from regex. + * @param str The string to escape + */ +export function escapeRegex(str: string) { + return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); +} + +/** + * Truncates string into a limit, appending an ellipsis when truncated. + * @param text The text to truncate + * @param limit The length to truncate at + */ +export function truncate(text: string, limit = 2000) { + return text.length > limit ? text.slice(0, limit - 1) + '…' : text; +} + +/** + * Iterates an object's keys and runs a function with a key and value + * @param obj The object to iterate + * @param func The function to run each key + */ +export function keyValueForEach(obj: any, func: (key: string, value: any) => void) { + Object.keys(obj).map((key) => func(key, obj[key])); +} + +/** Options for splitting a message. */ +export interface SplitOptions { + /** Maximum character length per message piece */ + maxLength?: number; + /** Character(s) or Regex(s) to split the message with, an array can be used to split multiple times */ + char?: string | string[] | RegExp | RegExp[]; + /** Text to prepend to every piece except the first */ + prepend?: string; + /** Text to append to every piece except the last */ + append?: string; +} + +/** + * Splits a string into multiple chunks at a designated character that do not exceed a specific length. + * @param text Content to split + * @param options Options controlling the behavior of the split + */ +export function splitMessage( + text: string, + { maxLength = 2000, char = '\n', prepend = '', append = '' }: SplitOptions = {} +) { + if (text.length <= maxLength) return [text]; + let splitText: any = [text]; + if (Array.isArray(char)) { + while (char.length > 0 && splitText.some((elem: string | any[]) => elem.length > maxLength)) { + const currentChar = char.shift(); + if (currentChar instanceof RegExp) { + splitText = splitText.flatMap((chunk: string) => chunk.match(currentChar)); + } else { + splitText = splitText.flatMap((chunk: { split: (arg0: string | undefined) => any }) => + chunk.split(currentChar) + ); + } + } + } else { + splitText = text.split(char); + } + if (splitText.some((elem: string | any[]) => elem.length > maxLength)) + throw new RangeError('SPLIT_MAX_LEN'); + const messages = []; + let msg = ''; + for (const chunk of splitText) { + if (msg && (msg + char + chunk + append).length > maxLength) { + messages.push(msg + append); + msg = prepend; + } + msg += (msg && msg !== prepend ? char : '') + chunk; + } + return messages.concat(msg).filter((m) => m); +} diff --git a/src/util/logger.ts b/src/util/logger.ts new file mode 100644 index 0000000..d2985b7 --- /dev/null +++ b/src/util/logger.ts @@ -0,0 +1,63 @@ +import VoltareClient from '../client'; +import { LoggerExtra } from '../types'; + +/** A helper for modules to log events to Voltare */ +export default class LoggerHandler> { + private readonly client: T; + private readonly module: string; + + constructor(client: T, moduleName: string) { + this.client = client; + this.module = moduleName; + } + + /** + * Logs to Voltare on the `debug` level. + * @param args The arguments to log + */ + debug(...args: any[]) { + return this.send('debug', args); + } + + /** + * Logs to Voltare on the `debug` level. + * @param args The arguments to log + */ + log(...args: any[]) { + return this.send('debug', args); + } + + /** + * Logs to Voltare on the `info` level. + * @param args The arguments to log + */ + info(...args: any[]) { + return this.send('info', args); + } + + /** + * Logs to Voltare on the `warn` level. + * @param args The arguments to log + */ + warn(...args: any[]) { + return this.send('warn', args); + } + + /** + * Logs to Voltare on the `error` level. + * @param args The arguments to log + */ + error(...args: any[]) { + return this.send('error', args); + } + + /** + * Logs to Voltare. + * @param level The level to log to + * @param args The arguments to log + * @param extra The extra variables to log with + */ + send(level: string, args: any[], extra?: LoggerExtra) { + return this.client.emit('logger', level, this.module, args, extra); + } +} diff --git a/src/util/typedEmitter.ts b/src/util/typedEmitter.ts new file mode 100644 index 0000000..ffa754d --- /dev/null +++ b/src/util/typedEmitter.ts @@ -0,0 +1,47 @@ +/** + * From typed-emitter + * https://npm.im/typed-emitter + */ + +/** @hidden */ +export type Arguments = [T] extends [(...args: infer U) => any] ? U : [T] extends [void] ? [] : [T]; + +/** + * Type-safe event emitter. + * @hidden + */ +export default interface TypedEventEmitter { + /** @hidden */ + addListener(event: E, listener: Events[E]): this; + /** @hidden */ + on(event: E, listener: Events[E]): this; + /** @hidden */ + once(event: E, listener: Events[E]): this; + /** @hidden */ + prependListener(event: E, listener: Events[E]): this; + /** @hidden */ + prependOnceListener(event: E, listener: Events[E]): this; + + /** @hidden */ + off(event: E, listener: Events[E]): this; + /** @hidden */ + removeAllListeners(event?: E): this; + /** @hidden */ + removeListener(event: E, listener: Events[E]): this; + + /** @hidden */ + emit(event: E, ...args: Arguments): boolean; + /** @hidden */ + eventNames(): (keyof Events | string | symbol)[]; + /** @hidden */ + rawListeners(event: E): Function[]; + /** @hidden */ + listeners(event: E): Function[]; + /** @hidden */ + listenerCount(event: E): number; + + /** @hidden */ + getMaxListeners(): number; + /** @hidden */ + setMaxListeners(maxListeners: number): this; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3636339 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "declaration": true, + "outDir": "lib", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "resolveJsonModule": true + }, + "include": [ + "**/*" + ], + "exclude": [ + "node_modules", + "lib", + "test", + "testing", + "scripts", + "examples", + "docs" + ] +}