first release

This commit is contained in:
dan63047 2024-12-28 17:04:25 +03:00
commit 9ab9d13119
25 changed files with 2600 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.vscode
node_modules
.env
.env.test

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 dan63
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.

9
README.md Normal file
View File

@ -0,0 +1,9 @@
# Помощник для огранизации турниров по TETR.IO
Данный дискорд бот способен проверять информацию о игроках, которые хотят принимать участие в ваших турнирах. Бот отправляет запросы к TETR.IO API, сначала он ищет игрока по его профилю в Discord. Если находит, то достаёт информацию о его аккаунте и если игрок не соотвествует критериям, он не принимается на турнир.
Также имеет доп. функционал по ограничению достпуа к турнирам на основании местонахождения игрока (определяется по стране, указанной в профиле TETR.IO. Если факт нахождения игрока в СНГ известен и достоверен, есть возможность добавить его в белый список и наоборот)
Турниры также могут быть ограничены и по рангу (минимальный и максимальный ранг для участия). Для предотвращения смурфинга ранг проверяется по параметру `bestrank`, то есть по наивысшему рангу, который игрок когда-либо имел.
Список игроков формируется на основании их рейтинга в Тетра Лиге.

1
blacklist.json Normal file
View File

@ -0,0 +1 @@
["954316772654350407"]

View File

@ -0,0 +1,18 @@
const { SlashCommandBuilder, MessageFlags, EmbedBuilder } = require('discord.js');
const { updateBlacklistJSON } = require("../../utils.js");
const { tournaments, blacklist, whitelist } = require("../../index.js");
module.exports = {
data: new SlashCommandBuilder()
.setName('add_to_blacklist')
.setDescription('Добавить игрока в черный список, чтобы он не смог участвовать в локальных турнирах')
.addUserOption(option =>
option.setName('user')
.setDescription('Пользователь, с которым ассоциируется данный игрок')
.setRequired(true)),
async execute(interaction) {
blacklist.add(interaction.options.getUser('user').id);
interaction.reply({ content: `Готово`, flags: MessageFlags.Ephemeral });
updateBlacklistJSON();
},
};

View File

@ -0,0 +1,18 @@
const { SlashCommandBuilder, MessageFlags, EmbedBuilder } = require('discord.js');
const { updateWhitelistJSON } = require("../../utils.js");
const { tournaments, blacklist, whitelist } = require("../../index.js");
module.exports = {
data: new SlashCommandBuilder()
.setName('add_to_whitelist')
.setDescription('Добавить игрока в белый список, чтобы он смог участвовать в локальных турнирах')
.addUserOption(option =>
option.setName('user')
.setDescription('Пользователь, с которым ассоциируется данный игрок')
.setRequired(true)),
async execute(interaction) {
whitelist.add(interaction.options.getUser('user').id);
interaction.reply({ content: `Готово`, flags: MessageFlags.Ephemeral });
updateWhitelistJSON();
},
};

View File

@ -0,0 +1,165 @@
const { SlashCommandBuilder, MessageFlags, EmbedBuilder } = require('discord.js');
const { Tournament } = require("../../data_objects/tournament.js");
const { tetrioRanks } = require("../../data_objects/tetrio_ranks.js");
const { xhr, reactionCheck, unreactionCheck, updateTournamentsJSON } = require("../../utils.js");
const { tournaments, blacklist, whitelist, trackedTournaments } = require("../../index.js");
module.exports = {
data: new SlashCommandBuilder()
.setName('create_event')
.setDescription('Создать событие, доступное для регистрации')
.addStringOption(option =>
option.setName('title')
.setDescription('Название события')
.setRequired(true))
.addIntegerOption(option =>
option.setName('unix_registration_end')
.setDescription('Время завершения регистрации в формате UNIX времени')
.setRequired(true))
.addIntegerOption(option =>
option.setName('unix_checkin_start')
.setDescription('Время старта checkin в формате UNIX времени')
.setRequired(true))
.addIntegerOption(option =>
option.setName('unix_checkin_end')
.setDescription('Время завершения checkin в формате UNIX времени')
.setRequired(true))
.addIntegerOption(option =>
option.setName('unix_tournament_start')
.setDescription('Время планируемого старта турнира в формате UNIX времени')
.setRequired(true))
.addStringOption(option =>
option.setName('description')
.setDescription('Описание события'))
.addStringOption(option =>
option.setName('prize_pool')
.setDescription('Призовой фонд турнира'))
.addStringOption(option =>
option.setName('rank_floor')
.setDescription('Минимальный ранг для участия'))
.addStringOption(option =>
option.setName('rank_roof')
.setDescription('Максимальный ранг для участия'))
.addBooleanOption(option =>
option.setName('international')
.setDescription('Данный турнир международный')),
async execute(interaction) {
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
const teto = new Tournament(
interaction.options.getString('title'),
interaction.options.getInteger('unix_registration_end'),
interaction.options.getInteger('unix_checkin_start'),
interaction.options.getInteger('unix_checkin_end'),
interaction.options.getInteger('unix_tournament_start'),
interaction.options.getString('rank_floor')??null,
interaction.options.getString('rank_roof')??null,
interaction.options.getBoolean('international')??null,
interaction.options.getString('description')??null,
interaction.options.getString('prize_pool')??null,
);
// Checking, if Tournament information is valid
const current_time = Date.now()/1000;
if (teto.unix_reg_end < current_time) {
const exampleEmbed = new EmbedBuilder()
.setColor(0xFF0000)
.setTitle('Некорректная информация о турнире')
.setDescription('Время завершения регистрации уже наступило')
.setTimestamp()
interaction.followUp({ embeds: [exampleEmbed], flags: MessageFlags.Ephemeral }).then(msg => {
setTimeout(() => msg.delete(), 10000)
});
return
}
if (teto.unix_checkin_end < current_time) {
const exampleEmbed = new EmbedBuilder()
.setColor(0xFF0000)
.setTitle('Некорректная информация о турнире')
.setDescription('Время завершения checkin уже наступило')
.setTimestamp()
interaction.followUp({ embeds: [exampleEmbed], flags: MessageFlags.Ephemeral }).then(msg => {
setTimeout(() => msg.delete(), 10000)
});
return
}
if (teto.unix_tournament_start < current_time) {
const exampleEmbed = new EmbedBuilder()
.setColor(0xFF0000)
.setTitle('Некорректная информация о турнире')
.setDescription('Время старта турнира уже наступило')
.setTimestamp()
interaction.followUp({ embeds: [exampleEmbed], flags: MessageFlags.Ephemeral }).then(msg => {
setTimeout(() => msg.delete(), 10000)
});
return
}
if (teto.rank_floor !== null && !tetrioRanks.includes(teto.rank_floor.toLowerCase())) {
const exampleEmbed = new EmbedBuilder()
.setColor(0xFF0000)
.setTitle('Некорректная информация о турнире')
.setDescription(`Нет такого ранга ${teto.rank_floor}`)
.setTimestamp()
interaction.followUp({ embeds: [exampleEmbed], flags: MessageFlags.Ephemeral }).then(msg => {
setTimeout(() => msg.delete(), 10000)
});
return
}
if (teto.rank_roof !== null && !tetrioRanks.includes(teto.rank_roof.toLowerCase())) {
const exampleEmbed = new EmbedBuilder()
.setColor(0xFF0000)
.setTitle('Некорректная информация о турнире')
.setDescription(`Нет такого ранга ${teto.rank_roof}`)
.setTimestamp()
interaction.followUp({ embeds: [exampleEmbed], flags: MessageFlags.Ephemeral }).then(msg => {
setTimeout(() => msg.delete(), 10000)
});
return
}
const reg_role = await interaction.guild.roles.create(
{
name: `Регистрант на ${teto.title}`,
reason: 'Роль необходима для отслеживания регистрантов турнира',
permissions: 0n
}
);
const check_in_role = await interaction.guild.roles.create(
{
name: `Участник ${teto.title}`,
reason: 'Роль необходима для отслеживания участников турнира',
permissions: 0n
}
);
teto.setRoles(reg_role.id, check_in_role.id);
tournaments.set(current_time.toString(), teto);
const tournamentEmbed = new EmbedBuilder()
.setColor(0x00FF00)
.setTitle(teto.title)
.setDescription(`${teto.description ? `${teto.description}\n` : ""}${teto.international ? "Международный" : "Только для игроков из стран СНГ"}${teto.prize_pool ? `\n**Призовой фонд: ${teto.prize_pool}**` : ""}${teto.rank_floor ? `\nМинимальный ранг для участия: ${teto.rank_floor}` : ""}${teto.rank_roof ? `\nМаксимальный ранг для участия: ${teto.rank_roof}` : ""}`)
.setFooter({ text: 'Поставьте ✅, чтобы зарегистрироваться' })
.addFields(
{ name: "Завершение регистрации", value: `<t:${teto.unix_reg_end}:f>\n(<t:${teto.unix_reg_end}:R>)`},
{ name: "Check in", value: `<t:${teto.unix_checkin_start}:f>\n(<t:${teto.unix_checkin_start}:R>)`},
{ name: "Завершение check in", value: `<t:${teto.unix_checkin_end}:f>\n(<t:${teto.unix_checkin_end}:R>)`},
{ name: "**Старт турнира**", value: `<t:${teto.unix_start}:f>\n(<t:${teto.unix_start}:R>)`},
);
// Send a message into the channel where command was triggered from
const message = await interaction.followUp({ embeds: [tournamentEmbed], fetchReply: true });
teto.setMessageID(message.id, message.channel.id);
message.react('✅');
updateTournamentsJSON();
trackedTournaments.push(current_time.toString());
const collectorFilter = (reaction, user) => {
return reaction.emoji.name === '✅' && user.id !== message.author.id;
};
// We gonna make sure, that user is eligible for a participation
const collector = message.createReactionCollector({ filter: collectorFilter, time: teto.unix_reg_end*1000 - current_time*1000, dispose: true });
collector.on('collect', async (reaction, user) => reactionCheck(reaction, user, interaction.client, interaction.guild, teto, reg_role));
collector.on('remove', async (reaction, user) => unreactionCheck(reaction, user, interaction.guild, teto, reg_role));
},
};

View File

@ -0,0 +1,30 @@
const { SlashCommandBuilder, MessageFlags, EmbedBuilder } = require('discord.js');
const { Tournament } = require("../../data_objects/tournament.js");
const { tetrioRanks } = require("../../data_objects/tetrio_ranks.js");
const { updateTournamentsJSON } = require("../../utils.js");
const { tournaments, blacklist, whitelist } = require("../../index.js");
module.exports = {
data: new SlashCommandBuilder()
.setName('delete_event')
.setDescription('Удалить событие и всё связанное с ним')
.addStringOption(option =>
option.setName('key')
.setDescription('Время создания события')
.setRequired(true)),
async execute(interaction) {
const t = tournaments.get(interaction.options.getString('key'));
try{
await interaction.guild.roles.delete(t.participant_role, 'Информация о турнире удалена');
await interaction.guild.roles.delete(t.checked_in_role, 'Информация о турнире удалена');
const msg_channel = await interaction.client.channels.fetch(value.channelID);
const msg = await msg_channel.messages.fetch(value.messageID);
msg.delete();
interaction.reply({ content: `Готово`, flags: MessageFlags.Ephemeral });
}catch(e){
interaction.reply({ content: `Не всё прошло гладко, но турнир из бота был удален\n\`${e}\``, flags: MessageFlags.Ephemeral });
}
tournaments.delete(interaction.options.getString('key'));
updateTournamentsJSON();
},
};

View File

@ -0,0 +1,21 @@
const { SlashCommandBuilder, MessageFlags, EmbedBuilder } = require('discord.js');
const { Tournament } = require("../../data_objects/tournament.js");
const { tetrioRanks } = require("../../data_objects/tetrio_ranks.js");
const { xhr } = require("../../utils.js");
const { tournaments, blacklist, whitelist } = require("../../index.js");
module.exports = {
data: new SlashCommandBuilder()
.setName('get_participant_list')
.setDescription('Получить список участников')
.addStringOption(option =>
option.setName('key')
.setDescription('Время создания события')
.setRequired(true)),
async execute(interaction) {
console.log(tournaments);
const t = tournaments.get(interaction.options.getString('key'))
t.checked_in.sort((a, b) => b.tr - a.tr);
interaction.reply({ content: `${t.checked_in.map((element) => `${element.username}\n`)}` });
},
};

View File

@ -0,0 +1,18 @@
const { SlashCommandBuilder, MessageFlags, EmbedBuilder } = require('discord.js');
const { updateBlacklistJSON } = require("../../utils.js");
const { tournaments, blacklist, whitelist } = require("../../index.js");
module.exports = {
data: new SlashCommandBuilder()
.setName('remove_from_blacklist')
.setDescription('Убрать игрока из черного списка')
.addUserOption(option =>
option.setName('user')
.setDescription('Пользователь, с которым ассоциируется данный игрок')
.setRequired(true)),
async execute(interaction) {
blacklist.delete(interaction.options.getUser('user').id);
interaction.reply({ content: `Готово`, flags: MessageFlags.Ephemeral });
updateBlacklistJSON();
},
};

View File

@ -0,0 +1,18 @@
const { SlashCommandBuilder, MessageFlags, EmbedBuilder } = require('discord.js');
const { updateWhitelistJSON } = require("../../utils.js");
const { tournaments, blacklist, whitelist } = require("../../index.js");
module.exports = {
data: new SlashCommandBuilder()
.setName('remove_from_whitelist')
.setDescription('Убрать игрока из белого списка')
.addUserOption(option =>
option.setName('user')
.setDescription('Пользователь, с которым ассоциируется данный игрок')
.setRequired(true)),
async execute(interaction) {
whitelist.delete(interaction.options.getUser('user').id);
interaction.reply({ content: `Готово`, flags: MessageFlags.Ephemeral });
updateWhitelistJSON();
},
};

View File

@ -0,0 +1,20 @@
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
const { blacklist } = require("../../index.js");
module.exports = {
data: new SlashCommandBuilder()
.setName('view_blacklist')
.setDescription('Посмотреть на черный список'),
async execute(interaction) {
console.log(blacklist);
const embed = new EmbedBuilder()
.setColor(0x0000FF)
.setTitle(`Игроков в ЧС: ${blacklist.size}`)
let ds = blacklist.size === 0 ? '*Пусто*' : '';
blacklist.forEach((value, key, map) => {
ds += `<@${value}>\n`;
});
embed.setDescription(ds)
interaction.reply({ embeds: [embed] });
},
};

View File

@ -0,0 +1,21 @@
const { SlashCommandBuilder, MessageFlags, EmbedBuilder } = require('discord.js');
const { Tournament } = require("../../data_objects/tournament.js");
const { tetrioRanks } = require("../../data_objects/tetrio_ranks.js");
const { xhr } = require("../../utils.js");
const { tournaments, blacklist, whitelist } = require("../../index.js");
module.exports = {
data: new SlashCommandBuilder()
.setName('view_events')
.setDescription('Посмотреть, за какими событиями бот сейчас наблюдает'),
async execute(interaction) {
console.log(tournaments);
const tournamentsEmbed = new EmbedBuilder()
.setColor(0x0000FF)
.setTitle(`Турниров: ${tournaments.size}`)
tournaments.forEach((value, key, map) => {
tournamentsEmbed.addFields({ name: `${key}: ${value.title}`, value: `Участников: ${value.participants.length}\n\`${value.participants.map((element) => `${element.username.padEnd(18)}${Intl.NumberFormat("ru-RU", {minimumFractionDigits: 2, maximumFractionDigits: 2,}).format(element.tr)} TR\n`)}\`` })
});
interaction.reply({ embeds: [tournamentsEmbed] });
},
};

View File

@ -0,0 +1,20 @@
const { SlashCommandBuilder, EmbedBuilder } = require('discord.js');
const { whitelist } = require("../../index.js");
module.exports = {
data: new SlashCommandBuilder()
.setName('view_whitelist')
.setDescription('Посмотреть на белый список'),
async execute(interaction) {
console.log(whitelist);
const embed = new EmbedBuilder()
.setColor(0x0000FF)
.setTitle(`Игроков в БС: ${whitelist.size}`)
let ds = whitelist.size === 0 ? '*Пусто*' : '';
whitelist.forEach((value, key, map) => {
ds += `<@${value}>\n`;
});
embed.setDescription(ds);
interaction.reply({ embeds: [embed] });
},
};

10
commands/utility/ping.js Normal file
View File

@ -0,0 +1,10 @@
const { SlashCommandBuilder, MessageFlags } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Проверить, жив ли бот'),
async execute(interaction) {
await interaction.reply({ content: `Pong!\nWebsocket heartbeat: ${interaction.client.ws.ping}ms.`, flags: MessageFlags.Ephemeral });
},
};

View File

@ -0,0 +1,16 @@
class Participant{
constructor(discordID, userData, tlData){
this.discordID = discordID;
this.id = userData.data._id;
this.username = userData.data.username;
this.country = userData.data.country;
this.tr = tlData.data.tr;
this.oldSeasonTR = tlData.data.past["1"]?.tr??null;
this.rank = tlData.data.bestrank;
this.oldSeasonRank = tlData.data.past["1"]?.rank??null;
}
}
module.exports = {
Participant
}

View File

@ -0,0 +1,24 @@
const tetrioRanks = [
"d",
"d+",
"c-",
"c",
"c+",
"b-",
"b",
"b+",
"a-",
"a",
"a+",
"s-",
"s",
"s+",
"ss",
"u",
"x",
"x+"
];
module.exports = {
tetrioRanks
}

View File

@ -0,0 +1,88 @@
'use strict';
const { Participant } = require("./participant.js");
class Tournament {
/**
* Tournament object to track our event
* @param {string} title Event Title
* @param {number} unix_reg_end Timestamp for registration end
* @param {number} unix_checkin_start Timestamp for check in start
* @param {number} unix_checkin_end Timestamp for check in end
* @param {number} unix_start Timestamp for tournament start
* @param {string} rank_floor Player should have at least that rank to play
* @param {string} rank_roof Player shouldn't have higher rank, that that
* @param {boolean} international If everyone can join, not only CIS players
* @param {string} prize_pool Tells, what our players can win in this event
*/
constructor(title, unix_reg_end, unix_checkin_start, unix_checkin_end, unix_start, rank_floor, rank_roof, international, description, prize_pool){
this.messageID = null;
this.checkInMessageID = null;
this.channelID = null;
this.ts = Date.now()/1000;
this.title = title;
this.unix_reg_end = unix_reg_end;
this.unix_checkin_start = unix_checkin_start;
this.unix_checkin_end = unix_checkin_end;
this.unix_start = unix_start;
this.rank_floor = rank_floor;
this.rank_roof = rank_roof;
this.international = international;
this.description = description;
this.prize_pool = prize_pool;
this.participants = [];
this.checked_in = [];
this.status = 0; // 0 - registration opened; 1 - checkins opened; 2 - participants list is ready
this.participant_role = null;
this.checked_in_role = null;
}
static fromObject(obj){
return Object.assign(new Tournament(), obj);
}
setRoles(participant_role, checked_in_role){
this.participant_role = participant_role;
this.checked_in_role = checked_in_role;
}
setMessageID(messageID, channelID){
this.messageID = messageID;
this.channelID = channelID;
}
setCheckInMessageID(messageID){
this.checkInMessageID = messageID;
}
register(discordID, userData, tlData) {
if (this.participants.find((element) => element.id === userData.data._id)) return;
this.participants.push(new Participant(discordID, userData, tlData));
}
check_in(discordID) {
if (this.checked_in.find((element) => element.discordID === discordID)) return false;
const dude = this.participants.find((element) => element.discordID === discordID);
if (dude){
this.checked_in.push(dude);
return true;
} else {
return false;
}
}
removeParticipant(userID) {
if (!this.participants.find((element) => element.discordID === userID)) return;
let index = this.participants.findIndex((element) => element.discordID === userID);
this.participants.splice(index, 1);
}
removeChecked(userID) {
if (!this.checked_in.find((element) => element.discordID === userID)) return;
let index = this.checked_in.findIndex((element) => element.discordID === userID);
this.checked_in.splice(index, 1);
}
}
module.exports = {
Tournament
}

46
deploy-commands.js Normal file
View File

@ -0,0 +1,46 @@
const { REST, Routes } = require('discord.js');
require('dotenv/config');
const fs = require('node:fs');
const path = require('node:path');
const commands = [];
// Grab all the command folders from the commands directory you created earlier
const foldersPath = path.join(__dirname, 'commands');
const commandFolders = fs.readdirSync(foldersPath);
for (const folder of commandFolders) {
// Grab all the command files from the commands directory you created earlier
const commandsPath = path.join(foldersPath, folder);
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);
if ('data' in command && 'execute' in command) {
commands.push(command.data.toJSON());
} else {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
}
}
}
// Construct and prepare an instance of the REST module
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
// and deploy your commands!
(async () => {
try {
console.log(`Started refreshing ${commands.length} application (/) commands.`);
// The put method is used to fully refresh all commands in the guild with the current set
const data = await rest.put(
Routes.applicationGuildCommands(process.env.APP_ID, process.env.GUILD_ID),
{ body: commands },
);
console.log(`Successfully reloaded ${data.length} application (/) commands.`);
} catch (error) {
// And of course, make sure you catch and log any errors!
console.error(error);
}
})();

184
index.js Normal file
View File

@ -0,0 +1,184 @@
const fs = require('node:fs');
require('dotenv/config');
const path = require('node:path');
const { Client, Collection, Events, GatewayIntentBits, REST, Routes, MessageFlags, Partials, EmbedBuilder } = require('discord.js');
const { reactionCheck, unreactionCheck, updateTournamentsJSON } = require('./utils.js');
const { Tournament } = require('./data_objects/tournament.js');
function readTournamentsJSON() {
try {
const data = fs.readFileSync('./tournaments.json', { encoding: 'utf8' });
return new Map(Object.entries(JSON.parse(data)));
} catch (err) {
return new Map();
}
}
function readWhitelistJSON() {
try {
const data = fs.readFileSync('./whitelist.json', { encoding: 'utf8' });
return new Set(JSON.parse(data));
} catch (err) {
return new Set();
}
}
function readBlacklistJSON() {
try {
const data = fs.readFileSync('./blacklist.json', { encoding: 'utf8' });
return new Set(JSON.parse(data));
} catch (err) {
return new Set();
}
}
const tournaments = readTournamentsJSON();
const trackedTournaments = [];
const whitelist = readWhitelistJSON();
const blacklist = readBlacklistJSON();
module.exports = {
tournaments,
trackedTournaments,
blacklist,
whitelist
}
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessageReactions],
partials: [Partials.Message, Partials.Channel, Partials.Reaction, Partials.User],
}
);
client.commands = new Collection();
const foldersPath = path.join(__dirname, 'commands');
const commandFolders = fs.readdirSync(foldersPath);
for (const folder of commandFolders) {
const commandsPath = path.join(foldersPath, folder);
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);
// Set a new item in the Collection with the key as the command name and the value as the exported module
if ('data' in command && 'execute' in command) {
client.commands.set(command.data.name, command);
} else {
console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
}
}
}
client.on('ready', () => {
console.log(`Logged in as ${client.user.tag}!`);
// Thing, that keeps track of clocks
setInterval(
() => {
let current_time = Date.now()/1000;
tournaments.forEach(async (value, key, map) => {
if (!trackedTournaments.find((element) => element === key) && value.status === 0 && value.unix_checkin_start > current_time){
// need to recheck for participants
const msg_channel = await client.channels.fetch(value.channelID);
const msg = await msg_channel.messages.fetch(value.messageID);
const collectorFilter = (reaction, user) => {
return reaction.emoji.name === '✅' && user.id !== msg.author.id;
};
tournaments.set(key, Tournament.fromObject(value));
// We gonna make sure, that user is eligible for a participation
const collector = msg.createReactionCollector({ filter: collectorFilter, time: value.unix_reg_end*1000 - current_time*1000, dispose: true });
collector.on('collect', async (reaction, user) => reactionCheck(reaction, user, client, msg_channel.guild, tournaments.get(key), value.participant_role));
collector.on('remove', async (reaction, user) => unreactionCheck(reaction, user, msg_channel.guild, tournaments.get(key), value.participant_role));
trackedTournaments.push(key);
}
if (value.status === 0 && value.unix_checkin_start < current_time) {
value.status = 1;
console.log("Check in started");
const checkInEmbed = new EmbedBuilder()
.setColor(0x00FF00)
.setTitle(value.title)
.setDescription(`Время подтверждать регистрацию\nКоличество регистрантов: ${value.participants.length}`)
.setFooter({ text: 'Поставьте ✅, чтобы подтвердить участие' })
.addFields(
{ name: "Завершение check in", value: `<t:${value.unix_checkin_end}:f>\n(<t:${value.unix_checkin_end}:R>)`},
{ name: "**Старт турнира**", value: `<t:${value.unix_start}:f>\n(<t:${value.unix_start}:R>)`},
);
const check_in_channel = await client.channels.fetch(value.channelID);
const check_in_message = await check_in_channel.send({ content: `<@&${value.participant_role}>`, embeds: [checkInEmbed] });
value.setCheckInMessageID()
const collectorFilter = (reaction, user) => {
return reaction.emoji.name === '✅' && user.id !== client.user.id;
};
check_in_message.react('✅');
const collector = check_in_message.createReactionCollector({ filter: collectorFilter, time: value.unix_checkin_end*1000 - current_time*1000, dispose: true });
collector.on('collect', async (reaction, user) => {
try{
if (value.check_in(user.id)){
check_in_message.guild.members.addRole({ user: user, reason: "Подтвердил участие", role: value.checked_in_role }); //h
console.log(`${user.tag} checked in for a ${value.title} event`);
}else{
reaction.users.remove(user.id);
const exampleEmbed = new EmbedBuilder()
.setColor(0xFF0000)
.setTitle("Вы не регистрировались на данный турнир")
.setDescription(value.unix_reg_end < current_time ? "Участники на данный турнир больше не принимаются" : "...но вы всё ещё можете зарегистрироваться!")
.setTimestamp();
check_in_channel.send({ content: `<@${user.id}>`, embeds: [exampleEmbed] }).then(msg => {
setTimeout(() => msg.delete(), 10000)
});
}
} catch (error) {
const check_in_channel = await client.channels.fetch(process.env.BOT_LOGS_CHANNEL);
check_in_channel.send({ content: `Я поймал ошибку:\n\`${error}\`` });
return;
}
updateTournamentsJSON();
});
collector.on('remove', async (reaction, user) => {
check_in_message.guild.members.addRole({ user: user, reason: "Расхотел участвовать", role: value.checked_in_role });
console.log(`${user.tag} unregistred for a ${value.title} event`);
teto.removeChecked(user.id);
updateTournamentsJSON();
});
}
if (value.status === 1 && value.unix_checkin_end < current_time) {
value.status = 2;
console.log("Check in ended");
value.checked_in.sort((a, b) => b.tr - a.tr);
const checkInEmbed = new EmbedBuilder()
.setColor(0x0000FF)
.setTitle(`Check-in на ${value.title} завершен`)
.setDescription(`Количество участников: ${value.checked_in.length}\n\`${value.participants.map((element) => `${element.username.padEnd(18)}${Intl.NumberFormat("ru-RU", {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(element.tr)} TR\n`)}\``)
.setFooter({ text: `Старт планировался в <t:${value.unix_start}:f> (<t:${value.unix_start}:R>)` });
const check_in_channel = await client.channels.fetch(process.env.RESULTS_CHANNEL);
check_in_channel.send({ embeds: [checkInEmbed] });
}
});
},
1000);
});
client.on(Events.InteractionCreate, async interaction => {
if (!interaction.isChatInputCommand()) return;
const command = interaction.client.commands.get(interaction.commandName);
if (!command) {
console.error(`No command matching ${interaction.commandName} was found.`);
return;
}
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: 'There was an error while executing this command!', flags: MessageFlags.Ephemeral });
} else {
await interaction.reply({ content: 'There was an error while executing this command!', flags: MessageFlags.Ephemeral });
}
}
});
client.login(process.env.DISCORD_TOKEN);

1654
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "dan63-tournament-helper-bot",
"private": true,
"version": "1.0.0",
"description": "Discord bot, that helps Tetra Team Events to organize their events",
"main": "index.js",
"type": "commonjs",
"engines": {
"node": ">=18.x"
},
"scripts": {
"start": "node index.js",
"register": "node deploy-commands.js",
"dev": "nodemon node index.js"
},
"author": "dan63",
"license": "MIT",
"dependencies": {
"discord.js": "^14.16.3",
"dotenv": "^16.0.3"
},
"devDependencies": {
"nodemon": "^3.1.9"
}
}

1
tournaments.json Normal file
View File

@ -0,0 +1 @@
{}

167
utils.js Normal file
View File

@ -0,0 +1,167 @@
const https = require('node:https');
const fs = require('node:fs');
require('dotenv/config');
const { SlashCommandBuilder, MessageFlags, EmbedBuilder } = require('discord.js');
const { Tournament } = require("./data_objects/tournament.js");
const { tetrioRanks } = require("./data_objects/tetrio_ranks.js");
const xhr = {
get: (uri) => {
return new Promise((resolve, reject) => {
https.get(uri, (res) => {
const { statusCode } = res;
const contentType = res.headers['content-type'];
let error;
// Any 2xx status code signals a successful response but
// here we're only checking for 200.
if (statusCode !== 200) {
error = new Error('Request Failed.\n' +
`Status Code: ${statusCode}`);
} else if (!/^application\/json/.test(contentType)) {
error = new Error('Invalid content-type.\n' +
`Expected application/json but received ${contentType}`);
}
if (error) {
console.error(error.message);
// Consume response data to free up memory
res.resume();
return;
}
res.setEncoding('utf8');
let rawData = '';
res.on('data', (chunk) => { rawData += chunk; });
res.on('end', () => {
try {
const parsedData = JSON.parse(rawData);
if (parsedData.success) {
resolve(parsedData);
} else {
reject(parsedData);
}
} catch (e) {
reject(e.message);
}
});
}).on('error', (e) => {
console.error(`Got error: ${e.message}`);
})
});
},
};
async function reactionCheck(reaction, user, client, guild, teto, reg_role) {
async function deny(embedTitle, embedReason) {
reaction.users.remove(user.id);
const exampleEmbed = new EmbedBuilder()
.setColor(0xFF0000)
.setTitle(embedTitle)
.setDescription(embedReason)
.setTimestamp();
const channel = await client.channels.cache.get(reaction.message.channelId);
channel.send({ content: `<@${user.id}>`, embeds: [exampleEmbed] }).then(msg => {
setTimeout(() => msg.delete(), 10000)
});
}
try {
// Checking, if registration is open
const current_time = Date.now()/1000;
if(teto.unix_reg_end < current_time) {deny('Участники на данный турнир больше не принимаются', `Время на регистрацию истекло`); return}
// Checking, if user has linked his TETR.IO account
const search = await xhr.get(`https://ch.tetr.io/api/users/search/discord:${user.id}`);
if (!search.success) {
deny('Ваша регистрация отклонена', `По какой-то причине, бот не смог получить информацию о вашем профиле в TETR.IO`); return}
if (!search.data) {
deny('Ваша регистрация отклонена', `Вы не привязали этот Discord аккаунт к своему TETR.IO аккаунту.\n\n Чтобы это сделать, с главного меню TETR.IO перейдите в Config -> Account и проскролльте до самого конца`); return}
// Trying to get data about the user
const userData = await Promise.all([xhr.get(`https://ch.tetr.io/api/users/${search.data.user._id}`), xhr.get(`https://ch.tetr.io/api/users/${search.data.user._id}/summaries/league`)]);
if (!userData[0].success || !userData[1].success){ // If we failed to do this
deny('Ваша регистрация отклонена', `По какой-то причине, бот не смог получить информацию о вашем профиле в TETR.IO`); return}
// Checking, if user is not banned
if(userData[0].data.role === "banned"){
deny('Ваша регистрация отклонена', `Ваш аккаунт в TETR.IO забанен`); return}
// Check for rank restricted events if user even have rank
if((teto.rank_floor || teto.rank_roof) && (!userData[1].data.bestrank || userData[1].data.bestrank === "z")){
deny('Ваша регистрация отклонена', `Турнир имеет ограничения по рангу. У вас меньше 10 игр в Тетра Лиге и мы не можем понять, стоит ли вас пускать`); return}
// Checking, if user's rank is higher, than rank floor
if(teto.rank_floor && (tetrioRanks.indexOf(teto.rank_floor.toLowerCase()) > tetrioRanks.indexOf(userData[1].data.bestrank))){
deny('Ваша регистрация отклонена', `Ваш ранг слишком низкий для участия в данном турнире`); return}
// Checking, if user's rank is lower, than rank roof
if(teto.rank_roof && (tetrioRanks.indexOf(teto.rank_roof.toLowerCase()) < tetrioRanks.indexOf(userData[1].data.bestrank))){
deny('Ваша регистрация отклонена', `Ваш ранг слишком высокий для участия в данном турнире`); return}
// Checking, if user is from CIS
const cisCountries = ["RU", "BY", "AM", "AZ", "KZ", "KG", "MD", "TJ", "UZ", "TM", "UA"];
const { blacklist, whitelist } = require("./index.js");
if (!teto.international && (!cisCountries.includes(userData[0].data.country) || whitelist.includes(userData[0].data._id) || blacklist.includes(userData[0].data._id))){deny('Ваша регистрация отклонена', `${blacklist.includes(userData[0].data._id) ? "По данным, которые есть у нашей организации" : "Судя по вашему профилю в TETR.IO"}, вы не из СНГ`); return}
// Finally, if everything is ok - add him to participants list
teto.register(user.id, userData[0], userData[1]);
guild.members.addRole({ user: user, reason: "Захотел участвовать", role: reg_role });
console.log(`${user.tag} registred for a ${teto.title} event`);
} catch (error) {
const check_in_channel = await client.channels.fetch(process.env.BOT_LOGS_CHANNEL);
check_in_channel.send({ content: `Я поймал ошибку:\n\`${error}\`` });
return;
}
updateTournamentsJSON();
}
async function unreactionCheck(reaction, user, guild, teto, reg_role) {
guild.members.removeRole({ user: user, reason: "Расхотел участвовать", role: reg_role });
console.log(`${user.tag} unregistred for a ${teto.title} event`);
teto.removeParticipant(user.id);
updateTournamentsJSON();
}
function updateTournamentsJSON(){
const { tournaments } = require('./index.js');
fs.writeFile('./tournaments.json', JSON.stringify(Object.fromEntries(tournaments)), err => {
if (err) {
console.error(err);
} else {
console.log("tournaments.json was updated");
}
}
);
}
function updateWhitelistJSON(){
const { whitelist } = require('./index.js');
fs.writeFile('./whitelist.json', JSON.stringify([...whitelist]), err => {
if (err) {
console.error(err);
} else {
console.log("whitelist.json was updated");
}
}
);
}
function updateBlacklistJSON(){
const { blacklist } = require('./index.js');
fs.writeFile('./blacklist.json', JSON.stringify([...blacklist]), err => {
if (err) {
console.error(err);
} else {
console.log("blacklist.json was updated");
}
}
);
}
module.exports = {
xhr,
reactionCheck,
unreactionCheck,
updateTournamentsJSON,
updateWhitelistJSON,
updateBlacklistJSON
}

1
whitelist.json Normal file
View File

@ -0,0 +1 @@
[]