contest.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. import { ICommand } from "./command";
  2. import { Dict, compareNumbers } from "../util";
  3. import { ReactionCollector, Message, TextChannel, RichEmbed, MessageReaction, User, Collector, Collection, SnowflakeUtil } from "discord.js";
  4. import yaml from "yaml";
  5. import { getRepository, getManager } from "typeorm";
  6. import { Contest } from "@db/entity/Contest";
  7. import emoji_regex from "emoji-regex";
  8. import { client } from "../client";
  9. import { scheduleJob } from "node-schedule";
  10. import { ContestEntry } from "@db/entity/ContestEntry";
  11. import { ContestVote } from "@db/entity/ContestVote";
  12. const CHANNEL_ID_PATTERN = /<#(\d+)>/;
  13. let activeContests: Dict<ActiveContest> = {};
  14. async function init() {
  15. let contestRepo = getRepository(Contest);
  16. let contests = await contestRepo.find({
  17. where: { active: true },
  18. relations: ["entries"]
  19. });
  20. let now = new Date();
  21. for (let contest of contests)
  22. await updateContestStatus(contest);
  23. client.on("messageReactionAdd", onReact);
  24. }
  25. function diffEntryVotes(votes: ContestVote[], currentUsers: Collection<string, User>) {
  26. let votedUsersIds = new Set(votes.map(v => v.userId));
  27. let currentUsersIds = new Set(currentUsers.keys());
  28. for (let currentUserId of currentUsersIds)
  29. if (votedUsersIds.has(currentUserId))
  30. votedUsersIds.delete(currentUserId);
  31. for (let votedUserId of votedUsersIds)
  32. if (currentUsersIds.has(votedUserId))
  33. currentUsersIds.delete(votedUserId);
  34. return [currentUsersIds, votedUsersIds];
  35. }
  36. type ContestEntryMessage = {
  37. msgId?: string;
  38. timestamp?: Date;
  39. };
  40. //TODO: Convert into a transaction
  41. async function updateContestStatus(contest: Contest) {
  42. let voteRepo = getRepository(ContestVote);
  43. let entryRepo = getRepository(ContestEntry);
  44. let channel = client.channels.get(contest.channel) as TextChannel;
  45. if (!channel) {
  46. console.log(`Channel ${contest.channel} has been deleted! Removing contest ${contest.id}...`);
  47. await removeContest(contest.id);
  48. return;
  49. }
  50. let entries = contest.entries.reduce((p, c) => {
  51. p[c.msgId] = c;
  52. return p;
  53. }, <Dict<ContestEntry>>{});
  54. let newestEntry: Date = null;
  55. for (let entry of contest.entries) {
  56. try {
  57. let msg = await channel.fetchMessage(entry.msgId);
  58. let voteReaction = msg.reactions.get(contest.voteReaction);
  59. let users = await voteReaction.fetchUsers();
  60. let existingVotes = await voteRepo.find({ where: { contest: contest } });
  61. let [newVotes, removedVotes] = diffEntryVotes(existingVotes, users);
  62. voteRepo.remove(existingVotes.filter(v => removedVotes.has(v.userId)));
  63. let newVoteEntries = [...newVotes].map(i => voteRepo.create({
  64. userId: i,
  65. contest: contest,
  66. contestEntry: entry
  67. }));
  68. await voteRepo.save(newVoteEntries);
  69. entry.votes = [
  70. ...newVoteEntries,
  71. ...existingVotes.filter(v => !removedVotes.has(v.userId))
  72. ];
  73. await entryRepo.save(entry);
  74. if (!newestEntry || msg.createdAt > newestEntry)
  75. newestEntry = msg.createdAt;
  76. } catch (err) {
  77. console.log(`Failed to update entry ${entry.msgId} for contest ${contest.id} because ${err}!`);
  78. delete entries[entry.msgId];
  79. }
  80. let newEntries = (await channel.fetchMessages({
  81. after: SnowflakeUtil.generate(newestEntry || contest.startDate)
  82. })).filter(m => m.attachments.size != 0);
  83. for(let [_, msg] of newEntries)
  84. await registerEntry(msg, contest);
  85. }
  86. if(contest.endDate < new Date()){
  87. contest.active = false;
  88. if(contest.announceWinners)
  89. await printResults(contest, channel);
  90. }
  91. }
  92. async function registerEntry(msg: Message, contest: Contest) {
  93. let entryRepo = getRepository(ContestEntry);
  94. let voteRepo = getRepository(ContestVote);
  95. let entry = entryRepo.create({
  96. msgId: msg.id,
  97. contest: contest
  98. });
  99. await entryRepo.save(entry);
  100. let voteReaction = msg.reactions.find(r => r.emoji.toString() == contest.voteReaction);
  101. let votedUsers = await voteReaction.fetchUsers();
  102. await voteRepo.save(votedUsers.map(u => voteRepo.create({
  103. userId: u.id,
  104. contest: contest,
  105. contestEntry: entry
  106. })));
  107. }
  108. interface ActiveContest {
  109. id: number;
  110. voteReaction: string;
  111. }
  112. interface ContestCreationOptions {
  113. in?: string;
  114. duration?: string;
  115. announce_winners?: boolean;
  116. vote_reaction?: string;
  117. max_winners?: number;
  118. unique_winners?: boolean
  119. }
  120. const CONTEST_DEFAULTS: ContestCreationOptions = {
  121. duration: "1d",
  122. announce_winners: false,
  123. vote_reaction: "❤️",
  124. max_winners: 1,
  125. unique_winners: true
  126. };
  127. const DURATION_MULTIPLIERS: Dict<number> = {
  128. "s": 1000,
  129. "min": 60 * 1000,
  130. "h": 60 * 60 * 1000,
  131. "d": 24 * 60 * 60 * 1000,
  132. "mon": 30 * 24 * 60 * 60 * 1000,
  133. "y": 365 * 24 * 60 * 60 * 1000
  134. };
  135. const DURATION_REGEX_STR = `(\\d+) ?(${Object.keys(DURATION_MULTIPLIERS).join("|")})`;
  136. const DURATION_REGEX = new RegExp(DURATION_REGEX_STR, "i");
  137. function parseDuration(duration: string): number | undefined {
  138. let match = DURATION_REGEX.exec(duration);
  139. if (match.length == 0)
  140. return undefined;
  141. let num = match[1];
  142. let unit = match[2];
  143. return +num * DURATION_MULTIPLIERS[unit];
  144. }
  145. async function removeContest(contestId: number) {
  146. await getManager().transaction(async em => {
  147. let contestRepo = em.getRepository(Contest);
  148. let contestEntryRepo = em.getRepository(ContestEntry);
  149. let contestVoteRepo = em.getRepository(ContestVote);
  150. let contest = contestRepo.create({
  151. id: contestId
  152. });
  153. await contestRepo.delete({
  154. id: contestId
  155. });
  156. await contestVoteRepo.delete({
  157. contest: contest
  158. });
  159. await contestEntryRepo.delete({
  160. contest: contest
  161. });
  162. });
  163. }
  164. type ContestEntryWithMessage = ContestEntry & { message: Message };
  165. async function pickValidEntries(channel: TextChannel, contestEntries: ContestEntry[], max: number, unique = true) {
  166. let addedUsers = new Set<string>();
  167. let result: ContestEntryWithMessage[] = [];
  168. let maxResults = Math.min(max, contestEntries.length);
  169. for (let entry of contestEntries) {
  170. try {
  171. let msg = await channel.fetchMessage(entry.msgId);
  172. if (unique && addedUsers.has(msg.author.id))
  173. continue;
  174. result.push({ ...entry, message: msg });
  175. addedUsers.add(msg.author.id);
  176. if (result.length == maxResults)
  177. break;
  178. } catch (err) { }
  179. }
  180. return result;
  181. }
  182. function numberToOrdered(num: number) {
  183. const prefixes = ["st", "nd", "rd"];
  184. let s = num % 10;
  185. return 0 < s && s <= prefixes.length ? `${num}${prefixes[s - 1]}` : `${num}th`;
  186. }
  187. async function printResults(contest: Contest, channel: TextChannel) {
  188. let entryRepo = getRepository(ContestEntry);
  189. let entries = await entryRepo.find({
  190. where: { contest: contest },
  191. relations: ["votes"]
  192. });
  193. if (entries.length == 0) {
  194. // Hmmm... maybe rich embeds?
  195. await channel.send("No entries were sent into this contest! Therefore I declare myself a winner!");
  196. return;
  197. }
  198. let winningEntries = await pickValidEntries(channel, entries.sort(compareNumbers(o => o.votes.length)), contest.maxWinners, contest.uniqueWinners);
  199. let totalVotes = entries.reduce((p, c) => p + c.votes.length, 0);
  200. let embed = new RichEmbed({
  201. title: "🎆 Contest results 🎆",
  202. color: 0xff3b8dc4,
  203. timestamp: new Date(),
  204. description: `The contest has ended!\nCollected ${totalVotes} votes.\nHere are the results:`,
  205. fields: winningEntries.map((e, i) => ({
  206. name: `${numberToOrdered(i + 1)} place`,
  207. value: `${e.message.toString()} ([View entry](${e.message.url}))`
  208. }))
  209. });
  210. await channel.sendEmbed(embed);
  211. }
  212. async function stopContest(contestId: number) {
  213. let repo = getRepository(Contest);
  214. let contest = await repo.findOne(contestId);
  215. let channel = client.channels.get(contest.channel) as TextChannel;
  216. if (!channel) {
  217. // TODO: Don't remove; instead report in web manager
  218. console.log(`Channel ${contest.channel} has been deleted! Removing contest ${contest.id}...`);
  219. await removeContest(contestId);
  220. return;
  221. }
  222. await channel.send(`Current contest has ended! Thank you for your participation!`);
  223. if (contest.announceWinners)
  224. await printResults(contest, channel);
  225. await repo.update(contestId, {
  226. active: false
  227. });
  228. }
  229. async function createContest(msg: Message, info: ContestCreationOptions) {
  230. if (info.in) {
  231. let matches = CHANNEL_ID_PATTERN.exec(info.in);
  232. if (matches.length == 0) {
  233. await msg.channel.send(`${msg.author.toString()} I can't see such a channel!`);
  234. return;
  235. }
  236. let channelId = matches[1];
  237. if (!msg.guild.channels.exists("id", channelId)) {
  238. await msg.channel.send(`${msg.author.toString()} This channel is not in the current guild!`);
  239. return;
  240. }
  241. info.in = channelId;
  242. } else
  243. info.in = msg.channel.id;
  244. if (info.max_winners < 1) {
  245. await msg.channel.send(`${msg.author.toString()} The contest must have at least one possible winner!`);
  246. return;
  247. }
  248. let dur = parseDuration(info.duration);
  249. if (!dur) {
  250. await msg.channel.send(`${msg.author.toString()} Duration format is invalid!`);
  251. return;
  252. }
  253. if (!msg.guild.emojis.find(e => e.toString() == info.vote_reaction) && !emoji_regex().exec(info.vote_reaction)) {
  254. await msg.channel.send(`${msg.author.toString()} The vote emote must be accessible by everyone on the server!`);
  255. return;
  256. }
  257. let repo = getRepository(Contest);
  258. let contest = await repo.findOne({
  259. where: { channel: info.in }
  260. });
  261. if (contest) {
  262. await msg.channel.send(`${msg.author.toString()} The channel already has a contest running!`);
  263. return;
  264. }
  265. contest = repo.create({
  266. channel: info.in,
  267. startDate: new Date(),
  268. endDate: new Date(Date.now() + dur),
  269. announceWinners: info.announce_winners,
  270. voteReaction: info.vote_reaction,
  271. maxWinners: info.max_winners,
  272. uniqueWinners: info.unique_winners,
  273. active: true
  274. });
  275. await repo.save(contest);
  276. await msg.channel.send(`${msg.author.toString()} Started contest (ID: ${contest.id})`);
  277. scheduleJob(contest.endDate, stopContest.bind(null, contest.id));
  278. }
  279. async function onMessage(actionsDone: boolean, m: Message, content: string) {
  280. if (m.attachments.size == 0)
  281. return false;
  282. let channel = m.channel;
  283. let contestRepo = getRepository(Contest);
  284. let entryRepo = getRepository(ContestEntry);
  285. let contest = await contestRepo.findOne({
  286. where: {
  287. channel: channel.id,
  288. active: true
  289. },
  290. select: ["id", "voteReaction"]
  291. });
  292. if (!contest)
  293. return false;
  294. await registerEntry(m);
  295. // Don't prevent further actions
  296. return false;
  297. }
  298. async function onReact(reaction: MessageReaction, user: User) {
  299. let channel = reaction.message.channel;
  300. let activeContest = activeContests[channel.id];
  301. if (!activeContest)
  302. return;
  303. if (reaction.emoji.toString() != activeContest.voteReaction)
  304. return;
  305. let entryRepo = getRepository(ContestEntry);
  306. let voteRepo = getRepository(ContestVote);
  307. let entry = await entryRepo.findOne({
  308. where: { msgId: reaction.message.id }
  309. });
  310. if (!entry)
  311. return;
  312. let vote = await voteRepo.findOne({
  313. where: {
  314. userId: user.id,
  315. contest: {
  316. id: activeContest.id
  317. }
  318. }
  319. });
  320. if (!vote)
  321. vote = voteRepo.create({
  322. userId: user.id,
  323. contest: {
  324. id: activeContest.id
  325. }
  326. });
  327. vote.contestEntry = entry;
  328. await voteRepo.save(vote);
  329. }
  330. export default <ICommand>{
  331. commands: [
  332. {
  333. pattern: "create contest",
  334. action: async (m, contents) => {
  335. let message = m.content.trim().substr(client.user.toString().length).trim().substr("create contest".length).trim();
  336. let contestData: ContestCreationOptions = { ...CONTEST_DEFAULTS, ...yaml.parse(message) };
  337. await createContest(m, contestData);
  338. }
  339. },
  340. {
  341. pattern: "contests",
  342. action: async (m) => {
  343. await m.channel.send("Heck");
  344. }
  345. },
  346. {
  347. pattern: /end contest( (\d*))?/,
  348. action: async (m, contents, matches) => {
  349. await m.channel.send("Heck");
  350. }
  351. },
  352. {
  353. pattern: "announce winners",
  354. action: async (m, contents, matches) => {
  355. await m.channel.send("Heck");
  356. }
  357. }
  358. ],
  359. onStart: init,
  360. onMessage: onMessage
  361. };