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