|
@@ -1,487 +0,0 @@
|
|
|
-import { Dict, compareNumbers, isAuthorisedAsync } from "../util";
|
|
|
-import { Message, TextChannel, RichEmbed, MessageReaction, User, Collector, Collection, SnowflakeUtil } from "discord.js";
|
|
|
-import yaml from "yaml";
|
|
|
-import { getRepository, getManager } from "typeorm";
|
|
|
-import { Contest } from "@shared/db/entity/Contest";
|
|
|
-import emoji_regex from "emoji-regex";
|
|
|
-import { client } from "../client";
|
|
|
-import { scheduleJob } from "node-schedule";
|
|
|
-import { ContestEntry } from "@shared/db/entity/ContestEntry";
|
|
|
-import { ContestVote } from "@shared/db/entity/ContestVote";
|
|
|
-import { CommandSet, Command, Action, ActionType } from "src/model/command";
|
|
|
-
|
|
|
-const CHANNEL_ID_PATTERN = /<#(\d+)>/;
|
|
|
-
|
|
|
-interface ActiveContest {
|
|
|
- id: number;
|
|
|
- voteReaction: string;
|
|
|
-}
|
|
|
-
|
|
|
-interface ContestCreationOptions {
|
|
|
- in?: string;
|
|
|
- duration?: string;
|
|
|
- announce_winners?: boolean;
|
|
|
- vote_reaction?: string;
|
|
|
- max_winners?: number;
|
|
|
- unique_winners?: boolean
|
|
|
-}
|
|
|
-
|
|
|
-const CONTEST_DEFAULTS: ContestCreationOptions = {
|
|
|
- duration: "1d",
|
|
|
- announce_winners: false,
|
|
|
- vote_reaction: "❤",
|
|
|
- max_winners: 1,
|
|
|
- unique_winners: true
|
|
|
-};
|
|
|
-
|
|
|
-const DURATION_MULTIPLIERS: Dict<number> = {
|
|
|
- "s": 1000,
|
|
|
- "min": 60 * 1000,
|
|
|
- "h": 60 * 60 * 1000,
|
|
|
- "d": 24 * 60 * 60 * 1000,
|
|
|
- "mon": 30 * 24 * 60 * 60 * 1000,
|
|
|
- "y": 365 * 24 * 60 * 60 * 1000
|
|
|
-};
|
|
|
-
|
|
|
-const DURATION_REGEX_STR = `(\\d+) ?(${Object.keys(DURATION_MULTIPLIERS).join("|")})`;
|
|
|
-const DURATION_REGEX = new RegExp(DURATION_REGEX_STR, "i");
|
|
|
-
|
|
|
-function parseDuration(duration: string): number | undefined {
|
|
|
- let match = DURATION_REGEX.exec(duration);
|
|
|
-
|
|
|
- if (match.length == 0)
|
|
|
- return undefined;
|
|
|
-
|
|
|
- let num = match[1];
|
|
|
- let unit = match[2];
|
|
|
-
|
|
|
- return +num * DURATION_MULTIPLIERS[unit];
|
|
|
-}
|
|
|
-
|
|
|
-type ContestEntryWithMessage = ContestEntry & { message: Message };
|
|
|
-
|
|
|
-function numberToOrdered(num: number) {
|
|
|
- const prefixes = ["st", "nd", "rd"];
|
|
|
- let s = num % 10;
|
|
|
- return 0 < s && s <= prefixes.length ? `${num}${prefixes[s - 1]}` : `${num}th`;
|
|
|
-}
|
|
|
-
|
|
|
-@CommandSet
|
|
|
-/*export*/ class ContestCommands {
|
|
|
-
|
|
|
- activeContests: Dict<ActiveContest> = {};
|
|
|
-
|
|
|
- diffEntryVotes(votes: ContestVote[], currentUsers: Collection<string, User>) {
|
|
|
- let votedUsersIds = new Set(votes.map(v => v.userId));
|
|
|
- let currentUsersIds = new Set(currentUsers.keys());
|
|
|
-
|
|
|
- for (let currentUserId of currentUsersIds)
|
|
|
- if (votedUsersIds.has(currentUserId))
|
|
|
- votedUsersIds.delete(currentUserId);
|
|
|
-
|
|
|
- for (let votedUserId of votedUsersIds)
|
|
|
- if (currentUsersIds.has(votedUserId))
|
|
|
- currentUsersIds.delete(votedUserId);
|
|
|
-
|
|
|
- return [currentUsersIds, votedUsersIds];
|
|
|
- }
|
|
|
-
|
|
|
- async updateContestStatus(contest: Contest) {
|
|
|
- let voteRepo = getRepository(ContestVote);
|
|
|
- let entryRepo = getRepository(ContestEntry);
|
|
|
-
|
|
|
- let channel = client.channels.get(contest.channel) as TextChannel;
|
|
|
- if (!channel) {
|
|
|
- console.log(`Channel ${contest.channel} has been deleted! Removing contest ${contest.id}...`);
|
|
|
- await this.removeContest(contest.id);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- let newestEntry: Date = null;
|
|
|
- let contestEntryMessageIds = new Set<string>(contest.entries.map(e => e.msgId));
|
|
|
- for (let entry of contest.entries) {
|
|
|
- try {
|
|
|
- let msg = await channel.fetchMessage(entry.msgId);
|
|
|
- let existingVotes = await voteRepo.find({ where: { contest: contest, contestEntry: entry } });
|
|
|
-
|
|
|
- let voteReaction = msg.reactions.find(r => r.emoji.toString() == contest.voteReaction);
|
|
|
- if (!voteReaction) {
|
|
|
- await voteRepo.remove(existingVotes);
|
|
|
- continue;
|
|
|
- }
|
|
|
-
|
|
|
- let users = await voteReaction.fetchUsers();
|
|
|
- let [newVotes, removedVotes] = this.diffEntryVotes(existingVotes, users);
|
|
|
- voteRepo.remove(existingVotes.filter(v => removedVotes.has(v.userId)));
|
|
|
- let newVoteEntries = [...newVotes].map(i => voteRepo.create({
|
|
|
- userId: i,
|
|
|
- contest: contest,
|
|
|
- contestEntry: entry
|
|
|
- }));
|
|
|
- await voteRepo.save(newVoteEntries);
|
|
|
-
|
|
|
- entry.votes = [
|
|
|
- ...newVoteEntries,
|
|
|
- ...existingVotes.filter(v => !removedVotes.has(v.userId))
|
|
|
- ];
|
|
|
- await entryRepo.save(entry);
|
|
|
-
|
|
|
- if (!newestEntry || msg.createdAt > newestEntry)
|
|
|
- newestEntry = msg.createdAt;
|
|
|
- } catch (err) {
|
|
|
- console.log(`Failed to update entry ${entry.msgId} for contest ${contest.id} because ${err}!`);
|
|
|
-
|
|
|
- await voteRepo.delete({ contestEntry: entry });
|
|
|
- await entryRepo.delete({ msgId: entry.msgId });
|
|
|
- contestEntryMessageIds.delete(entry.msgId);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- let newEntries = (await channel.fetchMessages({
|
|
|
- after: SnowflakeUtil.generate(newestEntry || contest.startDate)
|
|
|
- })).filter(m => m.attachments.size != 0 && !contestEntryMessageIds.has(m.id));
|
|
|
-
|
|
|
- for (let [_, msg] of newEntries)
|
|
|
- await this.registerEntry(msg, contest);
|
|
|
-
|
|
|
- if (contest.endDate < new Date()) {
|
|
|
- await this.stopContest(contest.id);
|
|
|
- } else {
|
|
|
- scheduleJob(contest.endDate, this.stopContest.bind(this, contest.id));
|
|
|
- this.activeContests[channel.id] = {
|
|
|
- id: contest.id,
|
|
|
- voteReaction: contest.voteReaction
|
|
|
- };
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- async registerEntry(msg: Message, contest: Contest) {
|
|
|
- let entryRepo = getRepository(ContestEntry);
|
|
|
- let voteRepo = getRepository(ContestVote);
|
|
|
-
|
|
|
- let entry = entryRepo.create({
|
|
|
- msgId: msg.id,
|
|
|
- contest: contest
|
|
|
- });
|
|
|
-
|
|
|
- await entryRepo.save(entry);
|
|
|
-
|
|
|
- let voteReaction = msg.reactions.find(r => r.emoji.toString() == contest.voteReaction);
|
|
|
-
|
|
|
- if (!voteReaction)
|
|
|
- return;
|
|
|
-
|
|
|
- let votedUsers = await voteReaction.fetchUsers();
|
|
|
-
|
|
|
- await voteRepo.save(votedUsers.map(u => voteRepo.create({
|
|
|
- userId: u.id,
|
|
|
- contest: contest,
|
|
|
- contestEntry: entry
|
|
|
- })));
|
|
|
- }
|
|
|
-
|
|
|
- async removeContest(contestId: number) {
|
|
|
- await getManager().transaction(async em => {
|
|
|
- let contestRepo = em.getRepository(Contest);
|
|
|
- let contestEntryRepo = em.getRepository(ContestEntry);
|
|
|
- let contestVoteRepo = em.getRepository(ContestVote);
|
|
|
-
|
|
|
- let contest = contestRepo.create({
|
|
|
- id: contestId
|
|
|
- });
|
|
|
-
|
|
|
- await contestRepo.delete({
|
|
|
- id: contestId
|
|
|
- });
|
|
|
- await contestVoteRepo.delete({
|
|
|
- contest: contest
|
|
|
- });
|
|
|
- await contestEntryRepo.delete({
|
|
|
- contest: contest
|
|
|
- });
|
|
|
- });
|
|
|
- }
|
|
|
-
|
|
|
- async pickValidEntries(channel: TextChannel, contestEntries: ContestEntry[], max: number, unique = true) {
|
|
|
- let addedUsers = new Set<string>();
|
|
|
- let result: ContestEntryWithMessage[] = [];
|
|
|
- let maxResults = Math.min(max, contestEntries.length);
|
|
|
-
|
|
|
- for (let entry of contestEntries) {
|
|
|
- try {
|
|
|
- let msg = await channel.fetchMessage(entry.msgId);
|
|
|
- if (unique && addedUsers.has(msg.author.id))
|
|
|
- continue;
|
|
|
-
|
|
|
- result.push({ ...entry, message: msg });
|
|
|
- addedUsers.add(msg.author.id);
|
|
|
- if (result.length == maxResults)
|
|
|
- break;
|
|
|
- } catch (err) { }
|
|
|
- }
|
|
|
-
|
|
|
- return result;
|
|
|
- }
|
|
|
-
|
|
|
- async printResults(contest: Contest, channel: TextChannel) {
|
|
|
- let entryRepo = getRepository(ContestEntry);
|
|
|
-
|
|
|
- let entries = await entryRepo.find({
|
|
|
- where: { contest: contest },
|
|
|
- relations: ["votes"]
|
|
|
- });
|
|
|
-
|
|
|
- if (entries.length == 0) {
|
|
|
- // Hmmm... maybe rich embeds?
|
|
|
- await channel.send("No entries were sent into this contest! Therefore I declare myself a winner!");
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- let winningEntries = await this.pickValidEntries(channel, entries.sort(compareNumbers(o => o.votes.length)), contest.maxWinners, contest.uniqueWinners);
|
|
|
- let totalVotes = entries.reduce((p, c) => p + c.votes.length, 0);
|
|
|
-
|
|
|
- let embed = new RichEmbed({
|
|
|
- title: "🎆 Contest results 🎆",
|
|
|
- color: 0x3b8dc4,
|
|
|
- timestamp: new Date(),
|
|
|
- description: `The contest has ended!\nCollected ${totalVotes} votes.\nHere are the results:`,
|
|
|
- fields: winningEntries.map((e, i) => ({
|
|
|
- name: `${numberToOrdered(i + 1)} place (${e.votes.length} votes)`,
|
|
|
- value: `${e.message.author.toString()} ([View entry](${e.message.url}))`
|
|
|
- }))
|
|
|
- });
|
|
|
-
|
|
|
- await channel.send(embed);
|
|
|
- }
|
|
|
-
|
|
|
- async stopContest(contestId: number) {
|
|
|
- let repo = getRepository(Contest);
|
|
|
- let contest = await repo.findOne(contestId);
|
|
|
-
|
|
|
- let channel = client.channels.get(contest.channel) as TextChannel;
|
|
|
- if (!channel) {
|
|
|
- // TODO: Don't remove; instead report in web manager
|
|
|
- console.log(`Channel ${contest.channel} has been deleted! Removing contest ${contest.id}...`);
|
|
|
- await this.removeContest(contestId);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- await channel.send(`Current contest has ended! Thank you for your participation!`);
|
|
|
-
|
|
|
- if (contest.announceWinners)
|
|
|
- await this.printResults(contest, channel);
|
|
|
-
|
|
|
- await repo.update(contestId, { active: false });
|
|
|
- }
|
|
|
-
|
|
|
- async createContest(msg: Message, info: ContestCreationOptions) {
|
|
|
- if (info.in) {
|
|
|
- let matches = CHANNEL_ID_PATTERN.exec(info.in);
|
|
|
- if (matches.length == 0) {
|
|
|
- await msg.channel.send(`${msg.author.toString()} I can't see such a channel!`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- let channelId = matches[1];
|
|
|
- if (!msg.guild.channels.exists("id", channelId)) {
|
|
|
- await msg.channel.send(`${msg.author.toString()} This channel is not in the current guild!`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- info.in = channelId;
|
|
|
- } else
|
|
|
- info.in = msg.channel.id;
|
|
|
-
|
|
|
- if (info.max_winners < 1) {
|
|
|
- await msg.channel.send(`${msg.author.toString()} The contest must have at least one possible winner!`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- let dur = parseDuration(info.duration);
|
|
|
- if (!dur) {
|
|
|
- await msg.channel.send(`${msg.author.toString()} Duration format is invalid!`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (!msg.guild.emojis.find(e => e.toString() == info.vote_reaction) && !emoji_regex().exec(info.vote_reaction)) {
|
|
|
- await msg.channel.send(`${msg.author.toString()} The vote emote must be accessible by everyone on the server!`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- let repo = getRepository(Contest);
|
|
|
-
|
|
|
- let contest = await repo.findOne({
|
|
|
- where: { channel: info.in, active: true }
|
|
|
- });
|
|
|
-
|
|
|
- if (contest) {
|
|
|
- await msg.channel.send(`${msg.author.toString()} The channel already has a contest running!`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- contest = repo.create({
|
|
|
- channel: info.in,
|
|
|
- startDate: new Date(),
|
|
|
- endDate: new Date(Date.now() + dur),
|
|
|
- announceWinners: info.announce_winners,
|
|
|
- voteReaction: info.vote_reaction,
|
|
|
- maxWinners: info.max_winners,
|
|
|
- uniqueWinners: info.unique_winners,
|
|
|
- active: true
|
|
|
- });
|
|
|
-
|
|
|
- await repo.save(contest);
|
|
|
- await msg.channel.send(`${msg.author.toString()} Started contest (ID: ${contest.id})`);
|
|
|
-
|
|
|
- scheduleJob(contest.endDate, this.stopContest.bind(this, contest.id));
|
|
|
- this.activeContests[contest.channel] = {
|
|
|
- id: contest.id,
|
|
|
- voteReaction: contest.voteReaction
|
|
|
- };
|
|
|
- }
|
|
|
-
|
|
|
- async onReact(reaction: MessageReaction, user: User) {
|
|
|
- if (user.bot)
|
|
|
- return;
|
|
|
-
|
|
|
- let channel = reaction.message.channel;
|
|
|
- let activeContest = this.activeContests[channel.id];
|
|
|
- if (!activeContest || reaction.emoji.toString() != activeContest.voteReaction)
|
|
|
- return;
|
|
|
-
|
|
|
- let entryRepo = getRepository(ContestEntry);
|
|
|
- let voteRepo = getRepository(ContestVote);
|
|
|
-
|
|
|
- let entry = await entryRepo.findOne({
|
|
|
- where: { msgId: reaction.message.id }
|
|
|
- });
|
|
|
- if (!entry)
|
|
|
- return;
|
|
|
-
|
|
|
- let vote = await voteRepo.findOne({
|
|
|
- where: { userId: user.id, contestId: activeContest.id }
|
|
|
- });
|
|
|
- if (!vote)
|
|
|
- vote = voteRepo.create({ userId: user.id, contestId: activeContest.id });
|
|
|
-
|
|
|
- vote.contestEntry = entry;
|
|
|
- await voteRepo.save(vote);
|
|
|
- }
|
|
|
-
|
|
|
- @Action(ActionType.MESSAGE)
|
|
|
- async addEntry(actionsDone: boolean, m: Message, content: string) {
|
|
|
- if (m.attachments.size == 0)
|
|
|
- return false;
|
|
|
-
|
|
|
- let channel = m.channel;
|
|
|
-
|
|
|
- let contestRepo = getRepository(Contest);
|
|
|
-
|
|
|
- let contest = await contestRepo.findOne({
|
|
|
- where: {
|
|
|
- channel: channel.id,
|
|
|
- active: true
|
|
|
- },
|
|
|
- select: ["id", "voteReaction"]
|
|
|
- });
|
|
|
-
|
|
|
- if (!contest)
|
|
|
- return false;
|
|
|
-
|
|
|
- await this.registerEntry(m, contest);
|
|
|
-
|
|
|
- // Don't prevent further actions
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- async onStart() {
|
|
|
- let contestRepo = getRepository(Contest);
|
|
|
- let contests = await contestRepo.find({
|
|
|
- where: { active: true },
|
|
|
- relations: ["entries"]
|
|
|
- });
|
|
|
-
|
|
|
- for (let contest of contests)
|
|
|
- await this.updateContestStatus(contest);
|
|
|
-
|
|
|
- client.on("messageReactionAdd", this.onReact);
|
|
|
- }
|
|
|
-
|
|
|
- @Command({ pattern: "create contest", auth: true })
|
|
|
- async startContest(m: Message) {
|
|
|
- if (!await isAuthorisedAsync(m.member)) {
|
|
|
- m.channel.send(`${m.author.toString()} You're not authorised to create contests!`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- let message = m.content.trim().substr(client.user.toString().length).trim().substr("create contest".length).trim();
|
|
|
- let contestData: ContestCreationOptions = { ...CONTEST_DEFAULTS, ...yaml.parse(message) };
|
|
|
- await this.createContest(m, contestData);
|
|
|
- }
|
|
|
-
|
|
|
- @Command({ pattern: "contests" })
|
|
|
- async listContests(m: Message) {
|
|
|
- let repo = getRepository(Contest);
|
|
|
- let contests = await repo.find({ where: { active: true } });
|
|
|
-
|
|
|
- let contestsData = contests.map(c => ({
|
|
|
- contest: c,
|
|
|
- channel: client.channels.get(c.channel) as TextChannel
|
|
|
- })).filter(c => c.channel);
|
|
|
-
|
|
|
- if (contestsData.length == 0)
|
|
|
- await m.channel.send(`${m.author.toString()} There are no currently running contests!`);
|
|
|
- else
|
|
|
- await m.channel.send(`${m.author.toString()} Currently there are contests active in the following channels:\n${contestsData.map((c, i) => `${i + 1}. ${c.channel.toString()}`).join("\n")}`);
|
|
|
- }
|
|
|
-
|
|
|
- @Command({ pattern: /end contest( (\d*))?/, auth: true })
|
|
|
- async endContest(m: Message, contents: string, matches: RegExpMatchArray) {
|
|
|
- if (!await isAuthorisedAsync(m.member)) {
|
|
|
- m.channel.send(`${m.author.toString()} You're not authorised to create contests!`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- let repo = getRepository(Contest);
|
|
|
- let contestId = +matches[1];
|
|
|
-
|
|
|
- let contest = await repo.findOne({
|
|
|
- where: { id: contestId },
|
|
|
- select: ["id", "active"]
|
|
|
- });
|
|
|
-
|
|
|
- if (!contest) {
|
|
|
- await m.channel.send(`${m.author.toString()} Can't find contest with ID ${contestId}`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (!contest.active) {
|
|
|
- await m.channel.send(`${m.author.toString()} The contest with ID ${contestId} is already inactive!`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- await this.stopContest(contest.id);
|
|
|
- }
|
|
|
-
|
|
|
- @Command({ pattern: "announce winners", auth: true })
|
|
|
- async announceWinners(m: Message) {
|
|
|
- let repo = getRepository(Contest);
|
|
|
-
|
|
|
- let contest = await repo.findOne({
|
|
|
- where: { channel: m.channel.id },
|
|
|
- order: { endDate: "DESC" }
|
|
|
- });
|
|
|
-
|
|
|
- if (!contest) {
|
|
|
- await m.channel.send(`${m.author.toString()} There have never been any contests!`);
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (contest.active) {
|
|
|
- await this.stopContest(contest.id);
|
|
|
- if (contest.announceWinners)
|
|
|
- return;
|
|
|
- }
|
|
|
- await this.printResults(contest, m.channel as TextChannel);
|
|
|
- }
|
|
|
-};
|