|  | @@ -1,17 +1,19 @@
 | 
	
		
			
				|  |  |  import { ICommand } from "./command";
 | 
	
		
			
				|  |  | -import { Dict } from "../util";
 | 
	
		
			
				|  |  | -import { ReactionCollector, Message } from "discord.js";
 | 
	
		
			
				|  |  | +import { Dict, compareNumbers } from "../util";
 | 
	
		
			
				|  |  | +import { ReactionCollector, Message, TextChannel, RichEmbed } from "discord.js";
 | 
	
		
			
				|  |  |  import yaml from "yaml";
 | 
	
		
			
				|  |  | -import { getRepository } from "typeorm";
 | 
	
		
			
				|  |  | +import { getRepository, getManager } from "typeorm";
 | 
	
		
			
				|  |  |  import { Contest } from "@db/entity/Contest";
 | 
	
		
			
				|  |  |  import { isNumber } from "util";
 | 
	
		
			
				|  |  |  import emoji_regex from "emoji-regex";
 | 
	
		
			
				|  |  |  import { client } from "../client";
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | +import { scheduleJob } from "node-schedule";
 | 
	
		
			
				|  |  | +import { ContestEntry } from "../../../db/lib/src/entity/ContestEntry";
 | 
	
		
			
				|  |  | +import { ContestVote } from "../../../db/lib/src/entity/ContestVote";
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  const CHANNEL_ID_PATTERN = /<#(\d+)>/;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -let pendingReactors : Dict<ReactionCollector> = {};
 | 
	
		
			
				|  |  | +let pendingReactors: Dict<ReactionCollector> = {};
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  async function init() {
 | 
	
		
			
				|  |  |  
 | 
	
	
		
			
				|  | @@ -22,16 +24,20 @@ interface ContestCreationOptions {
 | 
	
		
			
				|  |  |      duration?: string;
 | 
	
		
			
				|  |  |      announce_winners?: boolean;
 | 
	
		
			
				|  |  |      vote_reaction?: string;
 | 
	
		
			
				|  |  | +    max_winners?: number;
 | 
	
		
			
				|  |  | +    unique_winners?: boolean
 | 
	
		
			
				|  |  |  }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -const CONTEST_DEFAULTS : ContestCreationOptions = {
 | 
	
		
			
				|  |  | +const CONTEST_DEFAULTS: ContestCreationOptions = {
 | 
	
		
			
				|  |  |      duration: "1d",
 | 
	
		
			
				|  |  |      announce_winners: false,
 | 
	
		
			
				|  |  | -    vote_reaction: "❤️"
 | 
	
		
			
				|  |  | +    vote_reaction: "❤️",
 | 
	
		
			
				|  |  | +    max_winners: 1,
 | 
	
		
			
				|  |  | +    unique_winners: true
 | 
	
		
			
				|  |  |  };
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -const DURATION_MULTIPLIERS : Dict<number> = {
 | 
	
		
			
				|  |  | -    "s" : 1000,
 | 
	
		
			
				|  |  | +const DURATION_MULTIPLIERS: Dict<number> = {
 | 
	
		
			
				|  |  | +    "s": 1000,
 | 
	
		
			
				|  |  |      "min": 60 * 1000,
 | 
	
		
			
				|  |  |      "h": 60 * 60 * 1000,
 | 
	
		
			
				|  |  |      "d": 24 * 60 * 60 * 1000,
 | 
	
	
		
			
				|  | @@ -42,10 +48,10 @@ const DURATION_MULTIPLIERS : Dict<number> = {
 | 
	
		
			
				|  |  |  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 {
 | 
	
		
			
				|  |  | +function parseDuration(duration: string): number | undefined {
 | 
	
		
			
				|  |  |      let match = DURATION_REGEX.exec(duration);
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    if(match.length == 0)
 | 
	
		
			
				|  |  | +    if (match.length == 0)
 | 
	
		
			
				|  |  |          return undefined;
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |      let num = match[1];
 | 
	
	
		
			
				|  | @@ -54,14 +60,140 @@ function parseDuration(duration: string) : number | undefined {
 | 
	
		
			
				|  |  |      return +num * DURATION_MULTIPLIERS[unit];
 | 
	
		
			
				|  |  |  }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +async function 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
 | 
	
		
			
				|  |  | +        });
 | 
	
		
			
				|  |  | +    });
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +type ContestEntryWithMessage = ContestEntry & { message: Message };
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +async function 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;
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +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`;
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +async function 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 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: 0xff3b8dc4,
 | 
	
		
			
				|  |  | +        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`,
 | 
	
		
			
				|  |  | +            value: `${e.message.toString()} ([View entry](${e.message.url}))`
 | 
	
		
			
				|  |  | +        }))
 | 
	
		
			
				|  |  | +    });
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    await channel.sendEmbed(embed);
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +async function 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 removeContest(contestId);
 | 
	
		
			
				|  |  | +        return;
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    await channel.send(`Current contest has ended! Thank you for your participation!`);
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    if (contest.announceWinners)
 | 
	
		
			
				|  |  | +        await printResults(contest, channel);
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    await repo.update(contestId, {
 | 
	
		
			
				|  |  | +        active: false
 | 
	
		
			
				|  |  | +    });
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  |  async function 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) {
 | 
	
		
			
				|  |  | +    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)) {
 | 
	
		
			
				|  |  | +    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;
 | 
	
		
			
				|  |  |      }
 | 
	
	
		
			
				|  | @@ -72,7 +204,7 @@ async function createContest(msg: Message, info: ContestCreationOptions) {
 | 
	
		
			
				|  |  |          where: { channel: info.in }
 | 
	
		
			
				|  |  |      });
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    if(contest) {
 | 
	
		
			
				|  |  | +    if (contest) {
 | 
	
		
			
				|  |  |          await msg.channel.send(`${msg.author.toString()} The channel already has a contest running!`);
 | 
	
		
			
				|  |  |          return;
 | 
	
		
			
				|  |  |      }
 | 
	
	
		
			
				|  | @@ -82,11 +214,22 @@ async function createContest(msg: Message, info: ContestCreationOptions) {
 | 
	
		
			
				|  |  |          startDate: new Date(),
 | 
	
		
			
				|  |  |          endDate: new Date(Date.now() + dur),
 | 
	
		
			
				|  |  |          announceWinners: info.announce_winners,
 | 
	
		
			
				|  |  | -        voteReaction: info.vote_reaction
 | 
	
		
			
				|  |  | +        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, stopContest.bind(null, contest.id));
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +async function onMessage(actionsDone: boolean, m: Message, content: string) {
 | 
	
		
			
				|  |  | +    let repo = getRepository(Contest);
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    return false;
 | 
	
		
			
				|  |  |  }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  export default <ICommand>{
 | 
	
	
		
			
				|  | @@ -95,27 +238,7 @@ export default <ICommand>{
 | 
	
		
			
				|  |  |              pattern: "create contest",
 | 
	
		
			
				|  |  |              action: async (m, contents) => {
 | 
	
		
			
				|  |  |                  let message = m.content.trim().substr(client.user.toString().length).trim().substr("create contest".length).trim();
 | 
	
		
			
				|  |  | -                // let message = contents.substr("create contest".length);
 | 
	
		
			
				|  |  | -                let contestData : ContestCreationOptions = { ...CONTEST_DEFAULTS, ...yaml.parse(message) };
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -                if(!contestData.in)
 | 
	
		
			
				|  |  | -                    contestData.in = m.channel.id;
 | 
	
		
			
				|  |  | -                else {
 | 
	
		
			
				|  |  | -                    let matches = CHANNEL_ID_PATTERN.exec(contestData.in);
 | 
	
		
			
				|  |  | -                    if(matches.length == 0) {
 | 
	
		
			
				|  |  | -                        await m.channel.send(`${m.author.toString()} I can't see such a channel!`);
 | 
	
		
			
				|  |  | -                        return;
 | 
	
		
			
				|  |  | -                    }
 | 
	
		
			
				|  |  | -                    
 | 
	
		
			
				|  |  | -                    let channelId = matches[1];
 | 
	
		
			
				|  |  | -                    if(!m.guild.channels.exists("id", channelId)) {
 | 
	
		
			
				|  |  | -                        await m.channel.send(`${m.author.toString()} This channel is not in the current guild!`);
 | 
	
		
			
				|  |  | -                        return;
 | 
	
		
			
				|  |  | -                    }
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -                    contestData.in = channelId;
 | 
	
		
			
				|  |  | -                }
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | +                let contestData: ContestCreationOptions = { ...CONTEST_DEFAULTS, ...yaml.parse(message) };
 | 
	
		
			
				|  |  |                  await createContest(m, contestData);
 | 
	
		
			
				|  |  |              }
 | 
	
		
			
				|  |  |          },
 | 
	
	
		
			
				|  | @@ -139,4 +262,5 @@ export default <ICommand>{
 | 
	
		
			
				|  |  |          }
 | 
	
		
			
				|  |  |      ],
 | 
	
		
			
				|  |  |      onStart: init,
 | 
	
		
			
				|  |  | +    onMessage: onMessage
 | 
	
		
			
				|  |  |  };
 |