Browse Source

Implement initial contest result parsing

ghorsington 5 years ago
parent
commit
fc97151c72
3 changed files with 182 additions and 36 deletions
  1. 160 36
      bot/src/commands/contest.ts
  2. 13 0
      bot/src/util.ts
  3. 9 0
      db/src/entity/Contest.ts

+ 160 - 36
bot/src/commands/contest.ts

@@ -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
 };

+ 13 - 0
bot/src/util.ts

@@ -45,6 +45,19 @@ export async function isAuthorisedAsync(member : GuildMember) {
     return false;
 }
 
+export function compareNumbers<T>(prop : (o : T) => number) {
+    return (a: T, b: T) => {
+        let ap = prop(a);
+        let bp = prop(b);
+
+        if(ap < bp)
+            return 1;
+        else if(ap > bp)
+            return -1;
+        return 0;
+    };
+}
+
 export type Dict<TVal> = { [key: string]: TVal };
 
 export class NeighBuilder {

+ 9 - 0
db/src/entity/Contest.ts

@@ -23,6 +23,15 @@ export class Contest {
     @Column()
     voteReaction: string;
 
+    @Column()
+    maxWinners: number;
+
+    @Column()
+    uniqueWinners: boolean;
+
+    @Column()
+    active: boolean;
+
     @OneToMany(type => ContestEntry, entry => entry.contest)
     entries: ContestEntry[];