first release
This commit is contained in:
commit
9ab9d13119
|
@ -0,0 +1,4 @@
|
|||
.vscode
|
||||
node_modules
|
||||
.env
|
||||
.env.test
|
|
@ -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.
|
|
@ -0,0 +1,9 @@
|
|||
# Помощник для огранизации турниров по TETR.IO
|
||||
|
||||
Данный дискорд бот способен проверять информацию о игроках, которые хотят принимать участие в ваших турнирах. Бот отправляет запросы к TETR.IO API, сначала он ищет игрока по его профилю в Discord. Если находит, то достаёт информацию о его аккаунте и если игрок не соотвествует критериям, он не принимается на турнир.
|
||||
|
||||
Также имеет доп. функционал по ограничению достпуа к турнирам на основании местонахождения игрока (определяется по стране, указанной в профиле TETR.IO. Если факт нахождения игрока в СНГ известен и достоверен, есть возможность добавить его в белый список и наоборот)
|
||||
|
||||
Турниры также могут быть ограничены и по рангу (минимальный и максимальный ранг для участия). Для предотвращения смурфинга ранг проверяется по параметру `bestrank`, то есть по наивысшему рангу, который игрок когда-либо имел.
|
||||
|
||||
Список игроков формируется на основании их рейтинга в Тетра Лиге.
|
|
@ -0,0 +1 @@
|
|||
["954316772654350407"]
|
|
@ -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();
|
||||
},
|
||||
};
|
|
@ -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();
|
||||
},
|
||||
};
|
|
@ -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));
|
||||
},
|
||||
};
|
|
@ -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();
|
||||
},
|
||||
};
|
|
@ -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`)}` });
|
||||
},
|
||||
};
|
|
@ -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();
|
||||
},
|
||||
};
|
|
@ -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();
|
||||
},
|
||||
};
|
|
@ -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] });
|
||||
},
|
||||
};
|
|
@ -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] });
|
||||
},
|
||||
};
|
|
@ -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] });
|
||||
},
|
||||
};
|
|
@ -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 });
|
||||
},
|
||||
};
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
})();
|
|
@ -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);
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
[]
|
Loading…
Reference in New Issue