Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/picalines/picbot-engine
Библиотека для лёгкого написания дискорд бота на JavaScript
https://github.com/picalines/picbot-engine
boilerplate discord discord-bot discord-js javascript picbot-engine typescript typescript-library
Last synced: 3 months ago
JSON representation
Библиотека для лёгкого написания дискорд бота на JavaScript
- Host: GitHub
- URL: https://github.com/picalines/picbot-engine
- Owner: Picalines
- License: mit
- Created: 2020-05-05T15:02:01.000Z (over 4 years ago)
- Default Branch: master
- Last Pushed: 2024-09-25T16:44:30.000Z (4 months ago)
- Last Synced: 2024-11-10T10:40:31.644Z (3 months ago)
- Topics: boilerplate, discord, discord-bot, discord-js, javascript, picbot-engine, typescript, typescript-library
- Language: TypeScript
- Homepage:
- Size: 549 KB
- Stars: 4
- Watchers: 2
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# picbot-engine
Библиотека для лёгкого написания дискорд бота на JavaScript
Главная цель - повысить читабельность кода команд, а также улучшить опыт разработки (*VSCode подсвечивает все типы данных*)
Единственная зависимость - [discord.js](https://github.com/discordjs/discord.js) (версия 12 и выше!)
Разрабатывалось это всё на `NodeJS` версии `14.15.4`. Более старые я не тестил, да и не вижу смысла.
В библиотеку встроена только команда `help`, да и ту можно отключить. Примеры базовых команд расписаны в этом README и в [picbot-9](https://github.com/Picalines/picbot-9)
## Главный файл
```js
// src/index.js
import { Client } from "discord.js";
import { Bot } from "picbot-engine";const client = new Client();
const bot = new Bot(client, {
token: 'token.txt',
tokenType: 'file', // библиотека возьмёт токен из 'token.txt'
fetchPrefixes: ['picbot.'],
});bot.load(); // начнёт загрузку бота
```Бот загружает команды и другие штуки из папок `src/{commands,...}`. Эти пути можно изменить в настройках бота (`importerOptions`)
## *Рекомендации*
**1**. Создайте `jsconfig.json` в корне проекта (либо добавьте эти настройки в `tsconfig.json`, если используете `TypeScript`):
```json5
{
"compilerOptions": {
"strict": true,
"checkJs": true, // не нужно с TypeScript
}
}
```Это будет полезно, например, при написании команд - редактор будет проверять необязательные аргументы на значения `null` (подробнее об этом ниже)
**2**. Все примеры в README написаны в стиле ESM (то есть с `import` и `export`). Для этого в `package.json` укажите тип пакета как `модуль`:
```json5
{
"type": "module",
"main": "./src/index.js" // путь до главного файла,
}
```> ⚠ CommonJS (`require`) с этой библиотекой больше не работает.
Запустить проект можно через команду `node .`
## Примеры команд
Все команды будем писать в папке `src/commands`
### Ping
```js
// src/commands/ping.js
import { Command } from "picbot-engine";export default new Command({
name: 'ping',
group: 'Тестирование',
description: 'Бот отвечает тебе сообщением `pong!`',tutorial: '`!ping` напишет `pong!`',
execute: ({ message }) => {
message.reply('pong!');
},
});
```### Сложение двух чисел
```js
// src/commands/sum.js
import { Command, ArgumentSequence, numberReader, unorderedList } from "picbot-engine";export default new Command({
name: 'sum',
group: 'Математика',
description: 'Пишет сумму 2 чисел',arguments: new ArgumentSequence(
{
description: 'Первое число',
reader: numberReader('float'), // подробнее об этом ниже
},
{
description: 'Второе число',
reader: numberReader('float'),
},
),tutorial: unorderedList( // добавит '•' в начало каждой строки
'`!sum 3 2` напишет 5',
'`!sum 5 4` = `9`',
),execute: ({ message, args: [first, second] }) => {
message.reply(first + second);
},
});
```### Сложение N чисел
```js
// src/commands/sum.js
import { Command, ArgumentSequence, restReader, numberReader, unorderedList } from 'picbot-engine';export default new Command({
name: 'sum',
group: 'Математика',
description: 'Пишет сумму N чисел',arguments: new ArgumentSequence(
{
description: 'Числа',
reader: restReader(numberReader('float'), 2),
},
),tutorial: unorderedList(
'`!sum 1 2 3 ...` напишет сумму всех введённых чисел',
'`!sum 1` даст ошибку (нужно минимум 2 числа)',
),execute: ({ message, args: [numbers] }) => {
// редактор определит numbers как массив с минимум 2 числами!
message.reply(numbers.reduce((sum, cur) => sum + cur));
},
});
```### Ban
```js
// src/commands/ban.js
import {
Command, ArgumentSequence, unorderedList,
memberReader, remainingTextReader, optionalReader,
} from "picbot-engine";export default new Command({
name: 'ban',
group: 'Администрирование',
description: 'Банит участника сервера',permissions: ['BAN_MEMBERS'],
arguments: new ArgumentSequence(
{
description: 'Жертва 😈',
reader: memberReader,
},
{
description: 'Причина бана',
reader: optionalReader(remainingTextReader, 'Злобные админы :/'),
},
),tutorial: unorderedList(
'`ban @Test` забанит @Test по причине "Злобные админы :/"',
'`ban @Test спам` забанит @Test по причине "спам"',
),execute: async ({ message, executor, args: [target, reason] }) => {
if (executor.id == target.id) {
throw new Error('Нельзя забанить самого себя!');
}
if (!target.bannable) {
throw new Error('Я не могу забанить этого участника сервера :/');
}await target.ban({ reason });
await message.reply(`**${target.displayName}** успешно забанен`);
},
});
```## Система событий
Каждое событие в библиотеке представлено в виде отдельного объекта класса `Event`. Все события некоторой сущности обычно лежат в свойстве с именем `events` (например, события базы данных бота можно найти в `bot.database.events`)
У самого бота есть два свойства с событиями:
- `clientEvents` - обычные события из `discord.js`
- `events` - некоторые полезные расширения, которые обычно разработчик прописывает самостоятельноПример подключения события в файле:
```js
// src/events/guildMemberMessage.js
import { BotEventListener } from "picbot-engine";export default new BotEventListener(
bot => bot.events.guildMemberMessage, // указываем путь до события в боте// первым аргументом библиотека вставляет бота
// все последующие аргументы диктует выбранное выше событие
(bot, message) => {
message.reply('pong!');
}
);
```## Чтение аргументов
Для чтения аргументов библиотека использует специальные "функции чтения"
Функция чтения примает строку ввода пользователя и внешние данные (*контекст*). Вернуть она должна либо информацию об аргументе (его длину в строке ввода и переведённое значение), либо ошибку.
Бот не хранит какой-то конкретный список таких функций внутри себя. Эти функции можно объявить хоть в коде самой команды, однако гораздо чаще вы будете импортировать их напрямую из библиотеки.
Вот список встроенных функций для чтения аргументов:
* `remainingTextReader` - читает весь оставшийся текст в сообщении (использует `String.trim`)
* `memberReader` - упоминание участника сервера
* `textChannelReader` - упоминание текстового канала
* `roleReader` - упоминание роли
* `numberReader('int' | 'float', [min, max])` - возвращает функцию чтения числа
- `'int'` - строго целое число, `'float'` - дробное
- `[min, max]` - отрезок, в котором находится число. По стандарту он равен `[-Infinity, Infinity]`* `wordReader` - слово (последовательность символов до пробела)
* `stringReader` - строку в кавычках или апострофах
* `keywordReader(...)` - ключевые слова.
- `keywordReader('add', 'rm')` - прочитает либо `add`, либо `rm`, либо кинет ошибку
- `keywordReader('a', 'b', 'c', 'd', ...)`* `optionalReader(otherReader, defaultValue)` - делает аргумент необязательным
- `otherReader` - другая функция чтения
- `defaultValue` - стандартное значение аргумента. Библиотека подставит его, если вместо аргумента будет получен `EOL` (конец строки команды)* `mergeReaders(reader_1, reader_2, ...)` - соединяет несколько функций чтения в одну
- `mergeReaders(memberReader, numberReader('int'))` -> `[GuildMember, number]`* `repeatReader(reader, times)` - вызывает функцию чтения `times` раз
* `restReader(reader)` - использует функцию чтения до конца команды
- `restReader(memberReader)` - прочитает столько упоминаний, сколько введёт пользователь (вернёт пустой массив, если ничего не получено)
- `restReader(memberReader, 3)` - кинет ошибку, если пользователь введёт меньше 3-х упоминаний### Кастомные аргументы команд
Выше я описал концепцию функций чтения. Логично, что вы можете реализовать свои собственные функции чтения. Тут я приведу простой пример функции, которая прочитает кастомный класс `Vector`
```js
// src/vector.js
import { parsedRegexReader } from "picbot-engine";export class Vector {
constructor(x, y) {
this.x = Number(x);
this.y = Number(y);
}/**
* @param {Vector} v
*/
add(v) {
return new Vector(this.x + v.x, this.y + v.y);
}toString() {
return `{${this.x}; ${this.y}}`;
}
}export const vectorReader = parsedRegexReader(/\d+(\.\d*)?\s+\d+(\.\d*)?/, userInput => {
const [xInput, yInput] = userInput.split(' ');const vector = new Vector(parseFloat(xInput), parseFloat(yInput))
return { isError: false, value: vector };
});
``````js
// src/commands/vectorSum.js
import { Command, ArgumentSequence, restReader } from "picbot-engine";import { vectorReader } from "../vector.js";
export default new Command({
name: 'vectorsum',
group: 'Геометрия',
description: 'Пишет сумму введённых векторов',arguments: new ArgumentSequence(
{
description: 'Векторы',
reader: restReader(vectorReader, 2),
},
),tutorial: '`!vectorsum 2 3 10 5` напишет `{12; 8}`',
execute: ({ message, args: [vectors] }) => {
// редактор определил vectors как массив Vector'ов!
const vectorSum = vectors.reduce((r, c) => r.add(c));
message.reply(vectorSum.toString());
},
});
```## Работа с базой данных
### Состояния
Представим, что вы делаете команду `warn`. Она должна увеличивать счётчик warn'ов у указанного участника. Как только этот счётчик достигнет некой отметки, которая, например, настраивается отдельной командой `setmaxwarns`, бот забанит этого участника.
Сначала мы объявим `состояния` для базы данных:
```js
// src/states/warns.js
import { State, numberAccess } from "picbot-engine";// счётчик warn'ов у каждого участника сервера
export const warnsState = new State({
name: 'warns', // уникальное название свойства в базе данных
entityType: 'member', // тип сущности, у которой есть свойство ('user' / 'member' / 'guild')
defaultValue: 0, // изначальное кол-во warn'ов// значение счётчика всегда больше или равно нулю. Подробнее об этом ниже
accessFabric: numberAccess([0, Infinity]),
});export default warnsState;
``````js
// src/states/maxWarns.js
import { State, numberAccess } from "picbot-engine";// максимальное кол-во warn'ов у каждого сервера
export const maxWarnsState = new State({
name: 'warns',
entityType: 'guild',
defaultValue: 3,
accessFabric: numberAccess([1, Infinity]),
});export default maxWarnsState;
```Теперь сделаем команду warn:
```js
// src/commands/warn.js
import { Command, ArgumentSequence, memberReader } from "picbot-engine";import warnsState from "../states/warns.js";
import maxWarnsState from "../states/maxWarns.js";export default new Command({
name: 'warn',
group: 'Администрирование',
description: 'Предупреждает участника сервера',permissions: ['BAN_MEMBERS'],
arguments: new ArgumentSequence(
{
description: 'Жертва',
reader: memberReader,
}
),tutorial: '`warn @Test` кинет предупреждение участнику @Test',
execute: async ({ message, bot: { database }, args: [target] }) => {
// database.accessState даёт доступ к чтению / записи значения свойства
// (будем говорить, что accessState возвращает объект доступа)
const targetWarns = database.accessState(target, warnsState);
const maxWarns = database.accessState(target.guild, maxWarnsState);const newTargetWarns = await targetWarns.increase(1);
const maxWarnsValue = await maxWarns.value();/*
У обоих свойств мы ставили параметр accessorFabric на numberAccess.
Это было нужно, чтобы у 'объектов доступа' был метод increase,
который увеличивает значение свойства как с оператором +=По стандарту (если не указывать accessFabric) у объекта доступа
есть методы value и set (прочитать и записать новое значение).
Метод increase у numberAccess на самом деле просто использует
set и value, а нужен только для упрощения кода.Также numberAccess проверяет значения в set (валидация). Первым аргументом
мы указывали интервал от 0 до ∞, так что warn'ы и maxWarns не могут быть
отрицательными. Также внутри есть защита от NaN!
*/if (newTargetWarns >= maxWarnsValue) {
const reason = `Слишком много предупреждений (${newTargetWarns})`;
await target.ban({ reason });
await message.reply('участник сервера был успешно забанен по причине: ' + reason);
return;
}await message.reply(`участник сервера получил предупрежение (${newTargetWarns}/${maxWarnsValue})`);
},
});
```и команда `setmaxwarns`:
```js
// src/commands/setMaxWarns.js
import { Command, ArgumentSequence, numberReader } from "picbot-engine";import maxWarnsState from "../states/maxWarns.js";
export default new Command({
name: 'setmaxwarns',
group: 'Администрирование',
description: 'Ставит максимальное кол-во предупреждений для участников сервера',permissions: ['MANAGE_GUILD'],
arguments: new ArgumentSequence(
{
name: 'newMaxWarns',
description: 'Новое максимальное кол-во предупреждений',
reader: numberReader('int', [3, Infinity]),
}
),tutorial: '`setmaxwarns 10` поставит максимальное кол-во предупреждений на 10',
execute: async ({ message, database, executor: { guild }, args: [newMaxWarns] }) => {
// функция database.accessState синхронная, а await нам нужен для вызова set.
// это нужно await'ать для совместимости с любыми типами базы данных! (об этом ниже)
await database.accessState(guild, maxWarnsState).set(newMaxWarns);// валидация аргумента сначала пройдёт в numberReader, а потом в numberAccess
await message.reply(`Максимальное кол-во предупреждений на сервере теперь \`${newMaxWarns}\``);
},
});
```#### *Полезный факт!*
Вы можете объявить состояние префиксов у сервера (`State<'guild', string[]>`), и вставить его в `fetchPrefixes` в настроках бота. Тогда библиотека будет доставать префиксы из БД!
```js
// src/states/prefixes.js
export const prefixesState = new State({
name: 'prefixes',
entityType: 'guild',
defaultValue: ['dev.'],
});// src/index.js
export const bot = new Bot({
// ...
fetchPrefixes: prefixesState
});
```### Селекторы
Потом мы резко захотели сделать поиск по предупреждённым участникам сервера. Для этого в библиотеке есть *селекторы*
```js
// src/selectors/minWarns.js
import { EntitySelector } from "picbot-engine";import warnsState from "../states/warns.js";
export default new EntitySelector({
entityType: 'member', // кого ищемvariables: {
minWarns: Number, // параметр minWarns будем читать из аргументов
},expression: q => q.gte(warnsState, q.var('minWarns')), // 'boolean' выражение
/*
Это выражение можно мысленно представить в виде стрелочной функции:
member => member.warns >= minwarnsОднако представлены они не так для оптимизации под разные виды баз данных - выражение
интерпретируется в нужный вид перед использованием. Например, стандартная json
база данных превращает expression в стрелочную функцию через рекурсию и
прочие страшные штуки. Все результаты кэшируются по мере необходимости (lazy load)!Доступные операторы:
gte - GreaterThanEquals - >=
gt - GreaterThan - >
lt - <, lte - <=
eq - ==
and - &&, or - ||, not - !Пример сложного выражения:
q => q.and(
q.eq(xpProperty, 0),
q.gt(warnsProperty, 1)
)
*/
});
```и используем селектор в команде:
```js
// src/commands/findwarned.js
import { Command, ArgumentSequence, optionalReader, numberReader } from "picbot-engine";import minWarnsSelector from "../selectors/minWarns.js";
export default new Command({
name: 'findwarned',
group: 'Информация',
description: 'Ищет предупреждённых участников',arguments: new ArgumentSequence(
{
description: 'Минимальное кол-во варнов',
reader: optionalReader(numberReader('int', [1, Infinity]), 1),
}
),tutorial: '`!findwarned 2` напишет имена участников сервера с 2 варнами и больше',
execute: async ({ message, database, args: [minWarns] }) => {
const selected = await database.selectEntities(minWarnsSelector, {
manager: message.guild.members, // если искать сервера, то нужно указать client.guilds (а если юзеров - client.users)
variables: { minWarns }, // переменные для селектора (не указываются, если переменных нет в самом селекторе)
maxCount: 10, // максимальное кол-во результатов
});const names = selected.map(m => m.displayName);
await message.reply(names.join(', '));
},
});```
### Итог по БД
А теперь главное. Весь код команд и свойств никак не зависит от базы данных, которую выберет пользователь.
По стандарту в библиотеке реализована простая база данных на json, которая сохраняет и загружает все данные из локальной папки `database` (не забудьте добавить в `.gitignore`!). Однако кроме json вы можете реализовать свою базу данных. Для этого смотрите опцию `databaseHandler` в `./src/bot/Options.ts`. Json'овская БД прописана в `./src/database/json/Handler.ts`. Расписывать данный увлекательный процесс тут я не буду. Уж простите, *лень*.
## Система перевода на другие языки
### `options.fetchLocale`
В настройках есть параметр `fetchLocale`, который отвечает за язык (локаль) на конкретном сервере. По аналогии с `fetchPrefixes` туда можно поставить состояние сервера:
`src/states/locale.js`:
```js
import { State, validatedAccess } from "picbot-engine";export const supportedLocales = ['ru', 'en'];
/**
* @param {string} locale
*/
export const isLocaleSupported = (locale) => supportedLocales.includes(locale);export const localeState = new State({
name: 'locale',
entityType: 'guild',
defaultValue: 'ru',
accessFabric: validatedAccess(isLocaleSupported),
});export default localeState;
``````js
// src/index.js
import localeState from "./states/locale.js";const bot = new Bot({
// ...
fetchLocale: localeState,
// ...
});
```Т.е. теперь по стандарту стандартный язык сервера - русский, а опционально мы поддерживаем ещё и английский
> Cтандартная команда help уже переведена на русский! Просто убедитель, что строка locale - 'ru'
### TermCollection
А теперь я опишу концепт системы перевода. Допустим, что в какой-то команде у нас есть набор ключевых фраз (*терминов*). Сначала мы должны объявить *коллекцию терминов* в папке `./src/terms` (повторюсь, все пути к папкам можно настроить).
```js
// src/terms/prefix.js
import { TermCollection } from "picbot-engine";export const prefixTerms = new TermCollection({
prefixWasAdded: ['prefix', ({ prefix }) => `Префикс \`${prefix}\` успешно добавлен`],// unableToAddPrefix - кодовое имя термина, которое мы будет использовать в коде команд
// строками в начале массива мы перечисляем "контекст" термина (переменные из вне нужные для формирования фразы)
// а в конце указываем функцию, которая использует контекст и выдаёт итоговую строку
unableToAddPrefix: ['prefix', ({ prefix }) => `Невозможно добавить префкис \`${prefix}\``],// в контексте может быть сколько угодно строк!
test: ['a', 'b', 'c', ({ a, b, c }) => [a, b, c].join(', ')]// если термин не требует данных из вне, то он определяется просто как строка
// (квадратные скобки всё ещё нужны для работы редактора :/)
randomError: ['Что-то пошло не так :/']
});// Я пишу так, чтобы редактор мог автоматически импортировать prefixTerms
export default prefixTerms;
```### TranslationCollection
Теперь переведём эти фразы на *мой ломаный* английский:
```js
// src/translations/en/prefix.js
import { TranslationCollection } from "picbot-engine";import prefixTerms from "../../terms/prefix.js";
// мы нигде не будем импортировать перевод,
// поэтому достаточно просто export defaultexport default new TranslationCollection({
terms: prefixTerms,
locale: 'en',
translations: {
// в переводе мы указываем те же фукнции, использующие контекст терминов для формирования фразы
unableToAddPrefix: ({ prefix }) => `Unable to add prefix \`${prefix}\``,prefixWasAdded: ({ prefix }) => `Prefix \`${prefix}\` was successfully added`,
// однако для 'константных' терминов нужна только строка
randomError: 'Something went wrong :/',
},
});
```> Если библиотека найдёт два перевода одной TermCollection на один язык, то "победит" последний выбранный перевод
> Вы можете найти в библиотеке переводы help на русский и переопределить их!
### Использование в коде команды
А теперь используем это в команде в воображаемой `prefix` (полностью прописывать её код не буду, сейчас важен только перевод фраз. Полный пример есть в [picbot-9](https://github.com/Picalines/picbot-9))
```js
// src/commands/prefix.js
import { Command } from "picbot-engine";import prefixTerms from "../terms/prefix.js";
export default new Command({
name: 'prefix',// ...
execute: ({ message, translate }) => {
// translate переведёт термины prefixTerms на текущую локаль сервера
// (либо вторым аргументом можно указать нужный язык)
// (текущий язык можно достать через аргумент locale)// tr - это переводы терминов из TranslationCollection
// (либо стандартные переводы из TermCollection, если на текущую локаль мы ничего не переводили)
const tr = translate(prefixTerms);// prefixWasAdded - метод tr, которому в аргументе нужно передать контекст термина
message.reply(tr.prefixWasAdded({ prefix }));// 'константные' термины вызывать не нужно
message.reply(tr.randomError);// Библиотека не кидает ошибку, если перевода для термина на язык сервера нет,
// т.к. у каждого термина всегда есть стандартный перевод
},
});
```### Перевод описания команд
Класс `Command` генерирует термины и стандартные переводы в конструкторе. Т.е. для перевода нам нужно создать `TermCollection` с переводом `command.infoTerms`
```js
// src/translations/en/commands/ban.js
import { TranslationCollection, unorderedList } from "picbot-engine";import banCommand from "../../../commands/ban.js";
export default new TranslationCollection({
terms: banCommand.infoTerms,
locale: 'en',
translations: {
group: 'Admin',
description: 'Bans a guild member',argument_0_description: 'Victim 😈',
// argument_1_description,
// argument_2_description... редактор определит кол-во аргументов!tutorial: unorderedList(
'`!ban @Test` will ban @Test with reason "Angry admins :/"',
'`!ban @Test spam` will ban @Test with reason "spam"',
),
},
});
```> Стандартная команда help подхватит все переводы на нужный язык!