import { ICommand } from "./command"; import { Dict, compareNumbers } from "../util"; import { ReactionCollector, Message, TextChannel, RichEmbed, MessageReaction, User, Collector, Collection, SnowflakeUtil } from "discord.js"; import yaml from "yaml"; import { getRepository, getManager } from "typeorm"; import { Contest } from "@db/entity/Contest"; import emoji_regex from "emoji-regex"; import { client } from "../client"; import { scheduleJob } from "node-schedule"; import { ContestEntry } from "@db/entity/ContestEntry"; import { ContestVote } from "@db/entity/ContestVote"; const CHANNEL_ID_PATTERN = /<#(\d+)>/; let activeContests: Dict = {}; async function init() { let contestRepo = getRepository(Contest); let contests = await contestRepo.find({ where: { active: true }, relations: ["entries"] }); let now = new Date(); for (let contest of contests) await updateContestStatus(contest); client.on("messageReactionAdd", onReact); } function diffEntryVotes(votes: ContestVote[], currentUsers: Collection) { 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]; } type ContestEntryMessage = { msgId?: string; timestamp?: Date; }; //TODO: Convert into a transaction async function 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 removeContest(contest.id); return; } let entries = contest.entries.reduce((p, c) => { p[c.msgId] = c; return p; }, >{}); let newestEntry: Date = null; for (let entry of contest.entries) { try { let msg = await channel.fetchMessage(entry.msgId); let voteReaction = msg.reactions.get(contest.voteReaction); let users = await voteReaction.fetchUsers(); let existingVotes = await voteRepo.find({ where: { contest: contest } }); let [newVotes, removedVotes] = 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}!`); delete entries[entry.msgId]; } let newEntries = (await channel.fetchMessages({ after: SnowflakeUtil.generate(newestEntry || contest.startDate) })).filter(m => m.attachments.size != 0); for(let [_, msg] of newEntries) await registerEntry(msg, contest); } if(contest.endDate < new Date()){ contest.active = false; if(contest.announceWinners) await printResults(contest, channel); } } async function 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); let votedUsers = await voteReaction.fetchUsers(); await voteRepo.save(votedUsers.map(u => voteRepo.create({ userId: u.id, contest: contest, contestEntry: entry }))); } 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 = { "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]; } 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(); 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) { 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 } }); 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, stopContest.bind(null, contest.id)); } async function onMessage(actionsDone: boolean, m: Message, content: string) { if (m.attachments.size == 0) return false; let channel = m.channel; let contestRepo = getRepository(Contest); let entryRepo = getRepository(ContestEntry); let contest = await contestRepo.findOne({ where: { channel: channel.id, active: true }, select: ["id", "voteReaction"] }); if (!contest) return false; await registerEntry(m); // Don't prevent further actions return false; } async function onReact(reaction: MessageReaction, user: User) { let channel = reaction.message.channel; let activeContest = activeContests[channel.id]; if (!activeContest) return; if (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, contest: { id: activeContest.id } } }); if (!vote) vote = voteRepo.create({ userId: user.id, contest: { id: activeContest.id } }); vote.contestEntry = entry; await voteRepo.save(vote); } export default { commands: [ { pattern: "create contest", action: async (m, contents) => { 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 createContest(m, contestData); } }, { pattern: "contests", action: async (m) => { await m.channel.send("Heck"); } }, { pattern: /end contest( (\d*))?/, action: async (m, contents, matches) => { await m.channel.send("Heck"); } }, { pattern: "announce winners", action: async (m, contents, matches) => { await m.channel.send("Heck"); } } ], onStart: init, onMessage: onMessage };