diff --git a/src/features/april-fools/american-spelling.ts b/src/features/april-fools/american-spelling.ts new file mode 100644 index 00000000..9e981258 --- /dev/null +++ b/src/features/april-fools/american-spelling.ts @@ -0,0 +1,59 @@ +import { ChannelHandlers } from "../../types"; + +const AMERICAN_SPELLING_MAP: Record = { + colour: "color", + flavour: "flavor", + honour: "honor", + humour: "humor", + labour: "labor", + neighbour: "neighbor", + rumour: "rumor", + savour: "savor", + valour: "valor", + behaviour: "behavior", + favour: "favor", + harbour: "harbor", + odour: "odor", + armour: "armor", +}; + +const SUPER_SARCASTIC_REPLIES_TO_BRITISH_SPELLING_WORDS = [ + "Oh, splendid, another dose of British charm! But I'm afraid we're strictly American today, darling.", + "Well, well, well, what do we have here? Looks like someone's trying to add a little extra 'u' to our lives. Nice try!", + "Oh, lovely attempt at being fancy! But sorry, old chap, we're sticking to our American roots today.", + "Ah, a bit of British flair, how quaint! But alas, it's not quite the style we're going for today.", + "Oh, bravo! Another British gem shining through. But sorry, it's strictly red, white, and blue today!", + "Well, aren't you just a rebel with your extra letters? Sorry, but we're cutting ties with the 'u' today.", + "Ah, the British invasion continues! But I'm afraid your linguistic imperialism won't fly here today.", + "Oh, delightful! Another nod to our friends across the pond. But sorry, we're staying true to our American roots today.", + "Splendid attempt at British sophistication! But sorry, we're keeping it simple and straightforward today.", + "Oh, look at you being all fancy with your British spellings! But I'm afraid it's strictly Yankee Doodle Dandy around here today.", +]; + +export default { + handleMessage: async ({ msg: maybeMessage }) => { + const msg = maybeMessage.partial + ? await maybeMessage.fetch() + : maybeMessage; + + const content = msg.content.toLowerCase(); + const hasBadWord = Object.keys(AMERICAN_SPELLING_MAP).find((word) => + content.match(new RegExp(`\\b${word}\\b`, "i")), + ); + + if (!hasBadWord) { + return; + } + + await msg.reply( + SUPER_SARCASTIC_REPLIES_TO_BRITISH_SPELLING_WORDS[ + Math.floor( + Math.random() * + SUPER_SARCASTIC_REPLIES_TO_BRITISH_SPELLING_WORDS.length, + ) + ], + ); + + await msg.delete(); + }, +} as ChannelHandlers; diff --git a/src/features/april-fools/community-timeout-vote.ts b/src/features/april-fools/community-timeout-vote.ts new file mode 100644 index 00000000..dfe24012 --- /dev/null +++ b/src/features/april-fools/community-timeout-vote.ts @@ -0,0 +1,176 @@ +import { GuildMember, MessageReaction, TextChannel, User } from "discord.js"; +import { ChannelHandlers } from "../../types"; +import { sleep } from "../../helpers/misc"; + +const RECENT_CHATTERS = new Set(); + +let timedOutUserId: string | null = null; +let isProcessingPlea = false; +let isWorthy = true; +let dissenter: GuildMember | null = null; +let conformer: GuildMember | null = null; + +const TIMEOUT_DURATION_MINS = 10; +const VOTING_DURATION_MINS = 2; +const NUM_RECENT_CHATTERS_TO_TRIGGER_CHAOS = 10; + +const getRandomReactorId = ( + reactions: MessageReaction, + botId: string, +): User | null => { + const users = reactions.users.cache; + const usersArray = Array.from(users.values()); + const randomUser = usersArray[Math.floor(Math.random() * usersArray.length)]; + + if (usersArray.length === 1 && randomUser.id === botId) { + return null; + } + + while (randomUser.id === botId) { + console.log("Trying to get a random user again"); + return getRandomReactorId(reactions, botId); + } + + return randomUser; +}; + +/** + * This feature allows the community to vote on whether a user should be timed out. + * If the user gets timed out, they have to write a pleading message in a certain channel and the AI gods have to deem them worthy enough to be untimedout. + * If they are deemed worthy, it randomly times one of the community voters out for an hour and they can't plead. + */ +export default { + handleMessage: async ({ msg: maybeMessage, bot }) => { + const msg = maybeMessage.partial + ? await maybeMessage.fetch() + : maybeMessage; + + if (msg.author.bot) { + return; + } + + if (timedOutUserId === msg.author.id) { + if (isProcessingPlea || !isWorthy) { + await msg.delete(); + return; + } + + isProcessingPlea = true; + await msg.reply("Let's see if the AI gods deem you worthy...."); + await sleep(3 + Math.floor(Math.random() * 7)); + isProcessingPlea = false; + isWorthy = Math.random() > 0.5; + + if (isWorthy) { + await msg.reply( + "The AI gods have deemed you worthy. You are free to go.", + ); + timedOutUserId = null; + + if (conformer) { + try { + await conformer.timeout(TIMEOUT_DURATION_MINS * 60 * 1000); + } catch (error) { + console.error(error); + } + const channel = msg.channel as TextChannel; + await channel.send(`Chaos reigns upon <@${conformer.id}> instead.`); + } + return; + } + + await msg.reply( + "The AI gods have deemed you unworthy. You will remain timed out.", + ); + + await msg.guild?.members.cache + .get(timedOutUserId) + ?.timeout(TIMEOUT_DURATION_MINS * 60 * 1000); + + sleep(TIMEOUT_DURATION_MINS * 60).then(() => { + // If the person is still timed out, remove the timeout + // Otherwise, somebody else has been timed out so we just let it go + if (timedOutUserId === msg.author.id) { + timedOutUserId = null; + } + }); + + return; + } + + RECENT_CHATTERS.add(msg.author.id); + + if (RECENT_CHATTERS.size >= NUM_RECENT_CHATTERS_TO_TRIGGER_CHAOS) { + // Get a random user from recent chatters + const userIds = Array.from(RECENT_CHATTERS); + const randomUser = userIds[Math.floor(Math.random() * userIds.length)]; + const user = await msg.client.users.fetch(randomUser); + if (!user) return; + + const channel = msg.channel as TextChannel; + const message = await channel.send( + `Chaos is here. React with 👍 to time out <@${user.id}> or 👎 to let them live.`, + ); + + const filter = (reaction: MessageReaction, user: User) => + user.id !== message.author.id && user.id !== bot.user?.id; + + const collector = message.createReactionCollector({ + filter, + time: 1000 * 60 * VOTING_DURATION_MINS, + }); + + await message.react("👍"); + await message.react("👎"); + + collector.on("end", async () => { + const yesReactions = message.reactions.cache.find( + (reaction) => reaction.emoji.name === "👍", + ); + const noReactions = message.reactions.cache.find( + (reaction) => reaction.emoji.name === "👎", + ); + + if (!yesReactions || !noReactions) { + return; + } + + const randomConformerId = bot?.user?.id + ? getRandomReactorId(yesReactions, bot.user.id) + : null; + conformer = randomConformerId + ? await message.guild?.members.fetch(randomConformerId) + : null; + + const randomDissenterId = bot?.user?.id + ? getRandomReactorId(noReactions, bot.user.id) + : null; + dissenter = randomDissenterId + ? await message.guild?.members.fetch(randomDissenterId) + : null; + + if ( + yesReactions?.count && + yesReactions.count > (noReactions?.count ?? 0) + ) { + await channel.send( + `The community has spoken. <@${user.id}> has been timed out. <@${user.id}>, your next message is your one attempt to plead your case. If the AI gods deem you worthy, you will be spared and chaos will reign on somebody else.`, + ); + + timedOutUserId = user.id; + } else { + await channel.send( + `The community has spared <@${user.id}>. Do not let this happen again.`, + ); + + if (dissenter) { + await dissenter.timeout(TIMEOUT_DURATION_MINS * 60 * 1000); + await channel.send(`Chaos reigns upon <@${dissenter.id}> instead.`); + } + } + }); + + RECENT_CHATTERS.clear(); + } + }, +} as ChannelHandlers; diff --git a/src/features/april-fools/im-watching-you.ts b/src/features/april-fools/im-watching-you.ts new file mode 100644 index 00000000..fd78bca2 --- /dev/null +++ b/src/features/april-fools/im-watching-you.ts @@ -0,0 +1,31 @@ +import { sleep } from "../../helpers/misc"; +import { ChannelHandlers } from "../../types"; + +const EMOJIS = [ + [0.02, "👀"], + [0.02, "🤔"], + [0.02, "❓"], +] as const; + +export default { + handleMessage: async ({ msg: maybeMessage }) => { + const msg = maybeMessage.partial + ? await maybeMessage.fetch() + : maybeMessage; + + for (const [chance, emoji] of EMOJIS) { + const random = Math.random(); + if (random < chance) { + const resp = await msg.react(emoji); + + // For added chaos, remove the reaction in ~30-60s + const duration = Math.floor(Math.random() * 30) + 30; + sleep(duration).then(() => { + resp.remove(); + }); + + return; + } + } + }, +} as ChannelHandlers; diff --git a/src/features/april-fools/typing-status.ts b/src/features/april-fools/typing-status.ts new file mode 100644 index 00000000..b1bd6c3e --- /dev/null +++ b/src/features/april-fools/typing-status.ts @@ -0,0 +1,19 @@ +import { ChannelHandlers } from "../../types"; + +const CHANCE_TO_TYPE = 0.02; + +export default { + handleMessage: async ({ msg: maybeMessage }) => { + const msg = maybeMessage.partial + ? await maybeMessage.fetch() + : maybeMessage; + + const random = Math.random(); + if (random > CHANCE_TO_TYPE) { + return; + } + + const channel = msg.channel; + await channel.sendTyping(); // This will start typing for 10 seconds (a Discord value, not customizable) + }, +} as ChannelHandlers; diff --git a/src/index.ts b/src/index.ts index e62278ce..1236b8a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,10 @@ import { scheduleTask } from "./helpers/schedule"; import { discordToken } from "./helpers/env"; import { registerCommand, deployCommands } from "./helpers/deploy-commands"; import resumeReviewPdf from "./features/resume-review"; +import imWatchingYou from "./features/april-fools/im-watching-you"; +import typingStatus from "./features/april-fools/typing-status"; +import americanSpelling from "./features/april-fools/american-spelling"; +import communityTimeoutVote from "./features/april-fools/community-timeout-vote"; export const bot = new discord.Client({ intents: [ @@ -185,6 +189,12 @@ const threadChannels = [CHANNELS.helpJs, CHANNELS.helpThreadsReact]; addHandler(threadChannels, autothread); addHandler(CHANNELS.resumeReview, resumeReviewPdf); +addHandler(CHANNELS.random, [ + imWatchingYou, + typingStatus, + americanSpelling, + communityTimeoutVote, +]); bot.on("ready", () => { deployCommands(bot);