123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- 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<ActiveContest> = {};
- 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<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];
- }
- 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;
- }, <Dict<ContestEntry>>{});
- 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<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];
- }
- 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) {
- 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 <ICommand>{
- 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
- };
|