Библиотека для лёгкого написания дискорд бота на JavaScript
Главная цель - повысить читабельность кода команд, а также улучшить опыт разработки (VSCode подсвечивает все типы данных)
Единственная зависимость - discord.js (версия 12 и выше!)
Разрабатывалось это всё на NodeJS
версии 14.15.4
. Более старые я не тестил, да и не вижу смысла.
В библиотеку встроена только команда help
, да и ту можно отключить. Примеры базовых команд расписаны в этом README и в picbot-9
// 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
):
{
"compilerOptions": {
"strict": true,
"checkJs": true, // не нужно с TypeScript
}
}
Это будет полезно, например, при написании команд - редактор будет проверять необязательные аргументы на значения null
(подробнее об этом ниже)
2. Все примеры в README написаны в стиле ESM (то есть с import
и export
). Для этого в package.json
укажите тип пакета как модуль
:
{
"type": "module",
"main": "./src/index.js" // путь до главного файла,
}
⚠ CommonJS (
require
) с этой библиотекой больше не работает.
Запустить проект можно через команду node .
Все команды будем писать в папке src/commands
// 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!');
},
});
// 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);
},
});
// 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));
},
});
// 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
- некоторые полезные расширения, которые обычно разработчик прописывает самостоятельно
Пример подключения события в файле:
// 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
// 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 };
});
// 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
, бот забанит этого участника.
Сначала мы объявим состояния
для базы данных:
// 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;
// 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:
// 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
:
// 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
в настроках бота. Тогда библиотека будет доставать префиксы из БД!
// 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
});
Потом мы резко захотели сделать поиск по предупреждённым участникам сервера. Для этого в библиотеке есть селекторы
// 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)
)
*/
});
и используем селектор в команде:
// 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
. Расписывать данный увлекательный процесс тут я не буду. Уж простите, лень.
В настройках есть параметр fetchLocale
, который отвечает за язык (локаль) на конкретном сервере. По аналогии с fetchPrefixes
туда можно поставить состояние сервера:
src/states/locale.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;
// src/index.js
import localeState from "./states/locale.js";
const bot = new Bot({
// ...
fetchLocale: localeState,
// ...
});
Т.е. теперь по стандарту стандартный язык сервера - русский, а опционально мы поддерживаем ещё и английский
Cтандартная команда help уже переведена на русский! Просто убедитель, что строка locale - 'ru'
А теперь я опишу концепт системы перевода. Допустим, что в какой-то команде у нас есть набор ключевых фраз (терминов). Сначала мы должны объявить коллекцию терминов в папке ./src/terms
(повторюсь, все пути к папкам можно настроить).
// 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;
Теперь переведём эти фразы на мой ломаный английский:
// src/translations/en/prefix.js
import { TranslationCollection } from "picbot-engine";
import prefixTerms from "../../terms/prefix.js";
// мы нигде не будем импортировать перевод,
// поэтому достаточно просто export default
export 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)
// 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
// 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 подхватит все переводы на нужный язык!