diff --git a/server/commands/announce.ts b/server/commands/announce.ts index 4a6eff3..80472d5 100644 --- a/server/commands/announce.ts +++ b/server/commands/announce.ts @@ -6,6 +6,7 @@ import { Maybe } from '../interfaces' import { logger } from '../logger' import { Command } from '../structures/command' import { RunOptions } from '../types/commandTypes' +import { isInitialAnnouncement } from '../helper/messageIdentifiers' export default new Command({ name: 'announce', @@ -61,7 +62,7 @@ async function sendInitialAnnouncement(guildId: string, requestId: string): Prom return } - const currentPinnedAnnouncementMessages = (await announcementChannel.messages.fetchPinned()).filter(message => message.cleanContent.includes("[initial]")) + const currentPinnedAnnouncementMessages = (await announcementChannel.messages.fetchPinned()).filter(message => isInitialAnnouncement(message)) currentPinnedAnnouncementMessages.forEach(async (message) => await message.unpin()) currentPinnedAnnouncementMessages.forEach(message => message.delete()) diff --git a/server/commands/closepoll.ts b/server/commands/closepoll.ts index 7cf05c2..38ff732 100644 --- a/server/commands/closepoll.ts +++ b/server/commands/closepoll.ts @@ -1,10 +1,5 @@ -import { addDays, differenceInDays, format, isAfter, toDate } from 'date-fns' -import { Guild, GuildScheduledEvent, GuildScheduledEventEditOptions, GuildScheduledEventSetStatusArg, GuildScheduledEventStatus, Message, MessageCreateOptions, TextChannel } from 'discord.js' import { v4 as uuid } from 'uuid' import { client } from '../..' -import { config } from '../configuration' -import { Emotes } from '../events/autoCreateVoteByWPEvent' -import { Maybe } from '../interfaces' import { logger } from '../logger' import { Command } from '../structures/command' import { RunOptions } from '../types/commandTypes' @@ -25,160 +20,6 @@ export default new Command({ logger.info("Got command for closing poll!", { guildId, requestId }) command.followUp("Alles klar, beende die Umfrage :)") - closePoll(command.guild, requestId) + client.voteController.closePoll(command.guild, requestId) } }) - -export async function closePoll(guild: Guild, requestId: string) { - const guildId = guild.id - logger.info("stopping poll", { guildId, requestId }) - - const announcementChannel: Maybe = client.getAnnouncementChannelForGuild(guildId) - if (!announcementChannel) { - logger.error("Could not find the textchannel. Unable to close poll.", { guildId, requestId }) - return - } - - const messages: Message[] = (await announcementChannel.messages.fetch()) //todo: fetch only pinned messages - .map((value) => value) - .filter(message => !message.cleanContent.includes("[Abstimmung beendet]") && message.cleanContent.includes("[Abstimmung]")) - .sort((a, b) => b.createdTimestamp - a.createdTimestamp) - - if (!messages || messages.length <= 0) { - logger.info("Could not find any vote messages. Cancelling pollClose", { guildId, requestId }) - return - } - - const lastMessage: Message = messages[0] - - logger.debug(`Found messages: ${JSON.stringify(messages, null, 2)}`, { guildId, requestId }) - - logger.debug(`Last message: ${JSON.stringify(lastMessage, null, 2)}`, { guildId, requestId }) - - - const votes = await (await getVotesByEmote(lastMessage, guildId, requestId)) - .sort((a, b) => b.count - a.count) - - logger.debug(`votes: ${JSON.stringify(votes, null, 2)}`, { guildId, requestId }) - - logger.info("Deleting vote message") - await lastMessage.delete() - const event = await getEvent(guild, guild.id, requestId) - if (event) { - updateEvent(event, votes, guild, guildId, requestId) - sendVoteClosedMessage(event, votes[0].movie, guildId, requestId) - } - - //lastMessage.unpin() //todo: uncomment when bot has permission to pin/unpin -} - -async function sendVoteClosedMessage(event: GuildScheduledEvent, movie: string, guildId: string, requestId: string) { - const date = event.scheduledStartAt ? format(event.scheduledStartAt, "dd.MM") : "Fehler, event hatte kein Datum" - const time = event.scheduledStartAt ? format(event.scheduledStartAt, "HH:mm") : "Fehler, event hatte kein Datum" - const body = `[Abstimmung beendet] für https://discord.com/events/${event.guildId}/${event.id}\n<@&${config.bot.announcement_role}> Wir gucken ${movie} am ${date} um ${time}` - const options: MessageCreateOptions = { - content: body, - allowedMentions: { parse: ["roles"] } - } - const announcementChannel = client.getAnnouncementChannelForGuild(guildId) - logger.info("Sending vote closed message.", { guildId, requestId }) - if (!announcementChannel) { - logger.error("Could not find announcement channel. Please fix!", { guildId, requestId }) - return - } - announcementChannel.send(options) -} - -async function updateEvent(voteEvent: GuildScheduledEvent, votes: Vote[], guild: Guild, guildId: string, requestId: string) { - logger.info(`Updating event with movie ${votes[0].movie}.`, { guildId, requestId }) - const options: GuildScheduledEventEditOptions> = { - name: votes[0].movie, - description: `!wp\nNummer 2: ${votes[1].movie} mit ${votes[1].count - 1} Stimmen\nNummer 3: ${votes[2].movie} mit ${votes[2].count - 1} Stimmen` - } - logger.debug(`Updating event: ${JSON.stringify(voteEvent, null, 2)}`, { guildId, requestId }) - logger.info("Updating event.", { guildId, requestId }) - voteEvent.edit(options) -} - -async function getEvent(guild: Guild, guildId: string, requestId: string): Promise { - const voteEvents = (await guild.scheduledEvents.fetch()) - .map((value) => value) - .filter(event => event.name.toLowerCase().includes("voting offen")) - logger.debug(`Found events: ${JSON.stringify(voteEvents, null, 2)}`, { guildId, requestId }) - - if (!voteEvents || voteEvents.length <= 0) { - logger.error("Could not find vote event. Cancelling update!", { guildId, requestId }) - return null - } - return voteEvents[0] -} - -type Vote = { - emote: string, //todo habs nicht hinbekommen hier Emotes zu nutzen - count: number, - movie: string -} - -async function getVotesByEmote(message: Message, guildId: string, requestId: string): Promise { - const votes: Vote[] = [] - logger.debug(`Number of items in emotes: ${Object.values(Emotes).length}`, { guildId, requestId }) - for (let i = 0; i < Object.keys(Emotes).length / 2; i++) { - const emote = Emotes[i] - logger.debug(`Getting reaction for emote ${emote}`, { guildId, requestId }) - const reaction = await message.reactions.resolve(emote) - logger.debug(`Reaction for emote ${emote}: ${JSON.stringify(reaction, null, 2)}`, { guildId, requestId }) - if (reaction) { - const vote: Vote = { emote: emote, count: reaction.count, movie: extractMovieFromMessageByEmote(message, emote) } - votes.push(vote) - } - } - return votes -} - -function extractMovieFromMessageByEmote(message: Message, emote: string): string { - const lines = message.cleanContent.split("\n") - const emoteLines = lines.filter(line => line.includes(emote)) - - if (!emoteLines) { - return "" - } - const movie = emoteLines[0].substring(emoteLines[0].indexOf(emote) + emote.length + 2) // plus colon and space - - return movie -} - -export async function checkForPollsToClose(guild: Guild): Promise { - const requestId = uuid() - logger.info(`Automatic check for poll closing.`, { guildId: guild.id, requestId }) - const events = (await guild.scheduledEvents.fetch()).filter(event => event.name.toLocaleLowerCase().includes("voting offen")).map(event => event) - if (events.length > 1) { - logger.error("Handling more than one Event is not implemented yet. Found more than one poll to close") - return - } else if (events.length == 0) { - logger.info("Could not find any events. Cancelling", { guildId: guild.id, requestId }) - } - - const updatedEvent = events[0] //add two hours because of different timezones in discord api and Date.now() - if (!updatedEvent.scheduledStartTimestamp) { - logger.error("Event does not have a scheduled start time. Cancelling", { guildId: guild.id, requestId }) - return - } - - const createDate: Date = toDate(updatedEvent.createdTimestamp) - const eventDate: Date = toDate(updatedEvent.scheduledStartTimestamp) - const difference: number = differenceInDays(createDate, eventDate) - - if (difference <= 2) { - logger.info("Less than two days between event create and event start. Not closing poll.", { guildId: guild.id, requestId }) - return - } - - const closePollDate: Date = addDays(eventDate, -2) - - if (isAfter(Date.now(), closePollDate)) { - logger.info("Less than two days until event. Closing poll", { guildId: guild.id, requestId }) - closePoll(guild, requestId) - } else { - logger.info(`ScheduledStart: ${closePollDate}. Now: ${toDate(Date.now())}`, { guildId: guild.id, requestId }) - } -} diff --git a/server/commands/echo.ts b/server/commands/echo.ts index 4bf385b..98d7b79 100644 --- a/server/commands/echo.ts +++ b/server/commands/echo.ts @@ -1,6 +1,7 @@ import { ApplicationCommandOptionType } from 'discord.js' import { Command } from '../structures/command' import { RunOptions } from '../types/commandTypes' +import { logger } from '../logger' export default new Command({ name: 'echo', description: 'Echoes a text', @@ -13,7 +14,7 @@ export default new Command({ } ], run: async (interaction: RunOptions) => { - console.log('echo called') + logger.info('echo called') interaction.interaction.reply(interaction.toString()) } }) diff --git a/server/commands/resetPassword.ts b/server/commands/resetPassword.ts index 28eac2c..8aab706 100644 --- a/server/commands/resetPassword.ts +++ b/server/commands/resetPassword.ts @@ -2,15 +2,16 @@ import { v4 as uuid } from 'uuid' import { jellyfinHandler } from "../.." import { Command } from '../structures/command' import { RunOptions } from '../types/commandTypes' +import { logger } from '../logger' export default new Command({ name: 'passwort_reset', description: 'Ich vergebe dir ein neues Passwort und schicke es dir per DM zu. Kostet auch nix! Versprochen! 😉', options: [], run: async (interaction: RunOptions) => { - console.log('PasswortReset called') + logger.info('PasswortReset called') interaction.interaction.followUp('Yo, ich schick dir eins!') - console.log(JSON.stringify(interaction.interaction.member, null, 2)) + logger.info(JSON.stringify(interaction.interaction.member, null, 2)) jellyfinHandler.resetUserPasswort(interaction.interaction.member, uuid()) } }) diff --git a/server/configuration.ts b/server/configuration.ts index 1e5f848..b43eef0 100644 --- a/server/configuration.ts +++ b/server/configuration.ts @@ -30,6 +30,8 @@ export interface Config { yavin_jellyfin_url: string yavin_jellyfin_token: string yavin_jellyfin_collection_user: string + random_movie_count: number + reroll_retains_top_picks: boolean } } export const config: Config = { @@ -59,16 +61,18 @@ export const config: Config = { client_id: process.env.CLIENT_ID ?? "", jellfin_token: process.env.JELLYFIN_TOKEN ?? "", jellyfin_url: process.env.JELLYFIN_URL ?? "", - workaround_token: process.env.TOKEN ?? "", - watcher_role: process.env.WATCHER_ROLE ?? "", - jf_admin_role: process.env.ADMIN_ROLE ?? "", - announcement_role: process.env.WATCHPARTY_ANNOUNCEMENT_ROLE ?? "", - announcement_channel_id: process.env.CHANNEL_ID ?? "", + workaround_token: process.env.TOKEN ?? "TOKEN", + watcher_role: process.env.WATCHER_ROLE ?? "WATCHER_ROLE", + jf_admin_role: process.env.ADMIN_ROLE ?? "ADMIN_ROLE", + announcement_role: process.env.WATCHPARTY_ANNOUNCEMENT_ROLE ?? "ANNOUNCE_ROLE", + announcement_channel_id: process.env.CHANNEL_ID ?? "ANNOUNCE_CHANNEL", jf_collection_id: process.env.JELLYFIN_COLLECTION_ID ?? "", yavin_collection_id: process.env.YAVIN_COLLECTION_ID ?? "", yavin_jellyfin_url: process.env.YAVIN_JELLYFIN_URL ?? "", yavin_jellyfin_token: process.env.YAVIN_TOKEN ?? "", yavin_jellyfin_collection_user: process.env.YAVIN_COLLECTION_USER ?? "", - jf_user: process.env.JELLYFIN_USER ?? "" + jf_user: process.env.JELLYFIN_USER ?? "", + random_movie_count: parseInt(process.env.RANDOM_MOVIE_COUNT ?? "5") ?? 5, + reroll_retains_top_picks: process.env.REROLL_RETAIN === "true" } } diff --git a/server/constants.ts b/server/constants.ts new file mode 100644 index 0000000..ace6c75 --- /dev/null +++ b/server/constants.ts @@ -0,0 +1,17 @@ + +export enum ValidVoteEmotes { "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟" } +export const NONE_OF_THAT = "❌" +// WIP +export const Emoji = { + "one": "\u0031\uFE0F\u20E3", + "two": "\u0032\uFE0F\u20E3", + "three": "\u0033\uFE0F\u20E3", + "four": "\u0034\uFE0F\u20E3", + "five": "\u0035\uFE0F\u20E3", + "six": "\u0036\uFE0F\u20E3", + "seven": "\u0037\uFE0F\u20E3", + "eight": "\u0038\uFE0F\u20E3", + "nine": "\u0039\uFE0F\u20E3", + "ten": "\uD83D\uDD1F", + "ticket": "🎫" +} diff --git a/server/events/announceManualWatchparty.ts b/server/events/announceManualWatchparty.ts index acaccf6..b4f38ab 100644 --- a/server/events/announceManualWatchparty.ts +++ b/server/events/announceManualWatchparty.ts @@ -32,7 +32,11 @@ export async function execute(event: GuildScheduledEvent) { return } - const message = `[Watchparty] https://discord.com/events/${event.guildId}/${event.id} \nHey <@&${config.bot.announcement_role}>, wir gucken ${event.name} ${createDateStringFromEvent(event, guildId, requestId)}` + if (!event.scheduledStartAt) { + logger.error('Event has no start date, bailing out') + return + } + const message = `[Watchparty] https://discord.com/events/${event.guildId}/${event.id} \nHey <@&${config.bot.announcement_role}>, wir gucken ${event.name} ${createDateStringFromEvent(event.scheduledStartAt, guildId, requestId)}` channel.send(message) } else { diff --git a/server/events/autoCreateVoteByWPEvent.ts b/server/events/autoCreateVoteByWPEvent.ts index 8ccdddb..89cd3e0 100644 --- a/server/events/autoCreateVoteByWPEvent.ts +++ b/server/events/autoCreateVoteByWPEvent.ts @@ -1,20 +1,11 @@ -import { GuildScheduledEvent, Message, MessageCreateOptions, TextChannel } from "discord.js"; -import { ScheduledTask } from "node-cron"; +import { GuildScheduledEvent, TextChannel } from "discord.js"; import { v4 as uuid } from "uuid"; import { client, yavinJellyfinHandler } from "../.."; -import { config } from "../configuration"; -import { createDateStringFromEvent } from "../helper/dateHelper"; import { Maybe } from "../interfaces"; import { logger } from "../logger"; - export const name = 'guildScheduledEventCreate' -export enum Emotes { "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟" } -export const NONE_OF_THAT = "❌" - -export let task: ScheduledTask | undefined - export async function execute(event: GuildScheduledEvent) { const requestId = uuid() @@ -33,31 +24,21 @@ export async function execute(event: GuildScheduledEvent) { return } logger.debug(`Found channel ${JSON.stringify(announcementChannel, null, 2)}`, { guildId: event.guildId, requestId }) - if (!event.scheduledStartAt) { - logger.info("EVENT DOES NOT HAVE STARTDATE; CANCELLING", { guildId: event.guildId, requestId }) + logger.info("Event does not have a start date, cancelling", { guildId: event.guildId, requestId }) return } - let message = `[Abstimmung] für https://discord.com/events/${event.guildId}/${event.id}\n<@&${config.bot.announcement_role}> Es gibt eine neue Abstimmung für die nächste Watchparty ${createDateStringFromEvent(event, event.guildId, requestId)}! Stimme hierunter für den nächsten Film ab!\n` + const sentMessage = await client.voteController.prepareAndSendVoteMessage({ + movies, + startDate: event.scheduledStartAt, + event, + announcementChannel, + pinAfterSending: true + }, + event.guildId, + requestId) - for (let i = 0; i < movies.length; i++) { - message = message.concat(Emotes[i]).concat(": ").concat(movies[i]).concat("\n") - } - message = message.concat(NONE_OF_THAT).concat(": Wenn dir nichts davon gefällt.") - - const options: MessageCreateOptions = { - allowedMentions: { parse: ["roles"] }, - content: message, - } - - const sentMessage: Message = await (await announcementChannel.fetch()).send(options) - - for (let i = 0; i < movies.length; i++) { - sentMessage.react(Emotes[i]) - } - sentMessage.react(NONE_OF_THAT) - - // sentMessage.pin() //todo: uncomment when bot has permission to pin messages. Also update closepoll.ts to only fetch pinned messages + logger.debug(JSON.stringify(sentMessage)) } } diff --git a/server/events/deleteAnnouncementsWhenWPEnds.ts b/server/events/deleteAnnouncementsWhenWPEnds.ts index 8d7acfe..18c572d 100644 --- a/server/events/deleteAnnouncementsWhenWPEnds.ts +++ b/server/events/deleteAnnouncementsWhenWPEnds.ts @@ -2,6 +2,7 @@ import { Collection, GuildScheduledEvent, GuildScheduledEventStatus, Message } f import { v4 as uuid } from "uuid"; import { client } from "../.."; import { logger } from "../logger"; +import { isInitialAnnouncement } from "../helper/messageIdentifiers"; export const name = 'guildScheduledEventUpdate' @@ -25,7 +26,7 @@ export async function execute(oldEvent: GuildScheduledEvent, newEvent: GuildSche const events = await newEvent.guild.scheduledEvents.fetch() - const wpAnnouncements = (await announcementChannel.messages.fetch()).filter(message => !message.cleanContent.includes("[initial]")) + const wpAnnouncements = (await announcementChannel.messages.fetch()).filter(message => !isInitialAnnouncement(message)) const announcementsWithoutEvent = filterAnnouncementsByPendingWPs(wpAnnouncements, events) logger.info(`Deleting ${announcementsWithoutEvent.length} announcements.`, { guildId, requestId }) announcementsWithoutEvent.forEach(message => message.delete()) diff --git a/server/events/handleMessageReactionAdd.ts b/server/events/handleMessageReactionAdd.ts new file mode 100644 index 0000000..38cb212 --- /dev/null +++ b/server/events/handleMessageReactionAdd.ts @@ -0,0 +1,46 @@ + +import { Message, MessageReaction, User } from "discord.js"; +import { logger, newRequestId, noGuildId } from "../logger"; +import { Emoji, ValidVoteEmotes, NONE_OF_THAT } from "../constants"; +import { client } from "../.."; +import { isInitialAnnouncement, isVoteMessage } from "../helper/messageIdentifiers"; + + +export const name = 'messageReactionAdd' + +export async function execute(messageReaction: MessageReaction, user: User) { + if (user.id == client.user?.id) { + logger.info('Skipping bot reaction') + return + } + const requestId = newRequestId() + const guildId = messageReaction.message.inGuild() ? messageReaction.message.guildId : noGuildId + const reactedUponMessage: Message = messageReaction.message.partial ? await messageReaction.message.fetch() : messageReaction.message + if (!messageReaction.message.guild) { + logger.warn(`Received messageReactionAdd on non-guild message.`, { requestId }) + return + } + + logger.info(`Got reaction on message`, { requestId, guildId }) + //logger.debug(`reactedUponMessage payload: ${JSON.stringify(reactedUponMessage)}`) + + logger.info(`emoji: ${messageReaction.emoji.toString()}`) + + if (!Object.values(ValidVoteEmotes).includes(messageReaction.emoji.toString()) && messageReaction.emoji.toString() !== NONE_OF_THAT) { + logger.info(`${messageReaction.emoji.toString()} currently not handled`) + return + } + logger.info(`Found a match for ${messageReaction.emoji.toString()}`) + if (isVoteMessage(reactedUponMessage)) { + if (messageReaction.emoji.toString() === NONE_OF_THAT) { + logger.info(`Reaction is NONE_OF_THAT on a vote message. Handling`, { requestId, guildId }) + return client.voteController.handleNoneOfThatVote(messageReaction, reactedUponMessage, requestId, guildId) + } + } + else if (isInitialAnnouncement(reactedUponMessage)) { + if (messageReaction.emoji.toString() === Emoji.ticket) { + logger.error(`Got a role emoji. Not implemented yet. ${reactedUponMessage.id}`) + } + return + } +} diff --git a/server/events/messageCreate.ts b/server/events/messageCreate.ts index 3d3620a..bf92054 100644 --- a/server/events/messageCreate.ts +++ b/server/events/messageCreate.ts @@ -1,6 +1,7 @@ import { Message } from "discord.js" +import { logger } from "../logger" export const name = 'messageCreate' export function execute(message: Message) { - console.log(`${JSON.stringify(message)} has been created`) + logger.info(`${JSON.stringify(message)} has been created`) } diff --git a/server/helper/dateHelper.ts b/server/helper/dateHelper.ts index dd9c5b2..7c75b79 100644 --- a/server/helper/dateHelper.ts +++ b/server/helper/dateHelper.ts @@ -1,23 +1,23 @@ -import { format, isToday, toDate } from "date-fns"; +import { format, isToday } from "date-fns"; import { utcToZonedTime } from "date-fns-tz" -import { GuildScheduledEvent } from "discord.js"; import { logger } from "../logger"; import de from "date-fns/locale/de"; +import { Maybe } from "../interfaces"; -export function createDateStringFromEvent(event: GuildScheduledEvent, requestId: string, guildId?: string): string { - if (!event.scheduledStartAt) { +export function createDateStringFromEvent(eventStartDate: Maybe, requestId: string, guildId?: string): string { + if (!eventStartDate) { logger.error("Event has no start. Cannot create dateString.", { guildId, requestId }) return `"habe keinen Startzeitpunkt ermitteln können"` } const timeZone = 'Europe/Berlin' - const zonedDateTime = utcToZonedTime(event.scheduledStartAt, timeZone) + const zonedDateTime = utcToZonedTime(eventStartDate, timeZone) const time = format(zonedDateTime, "HH:mm", { locale: de }) if (isToday(zonedDateTime)) { return `heute um ${time}` } - const date = format(zonedDateTime, "eeee dd.MM", { locale: de }) + const date = format(zonedDateTime, "eeee dd.MM.", { locale: de }) return `am ${date} um ${time}` } diff --git a/server/helper/messageIdentifiers.ts b/server/helper/messageIdentifiers.ts new file mode 100644 index 0000000..fa6a0e1 --- /dev/null +++ b/server/helper/messageIdentifiers.ts @@ -0,0 +1,20 @@ +import { Message } from "discord.js"; + + +// branded types to differentiate objects of identical Type but different contents +export type VoteEndMessage = Message & { readonly __brand: 'voteend' } +export type AnnouncementMessage = Message & { readonly __brand: 'announcement' } +export type VoteMessage = Message & { readonly __brand: 'vote' } + +export type KnownDiscordMessage = VoteMessage | VoteEndMessage | AnnouncementMessage + +export function isVoteMessage(message: Message): message is VoteMessage { + return message.cleanContent.includes('[Abstimmung]') +} +export function isInitialAnnouncement(message: Message): message is AnnouncementMessage { + return message.cleanContent.includes("[initial]") +} +export function isVoteEndedMessage(message: Message): message is VoteEndMessage { + return message.cleanContent.includes("[Abstimmung beendet]") +} + diff --git a/server/helper/roleFilter.ts b/server/helper/roleFilter.ts index 23ea237..af65678 100644 --- a/server/helper/roleFilter.ts +++ b/server/helper/roleFilter.ts @@ -1,5 +1,5 @@ -import { Collection, GuildMember } from "discord.js" -import { ChangedRoles, PermissionLevel } from "../interfaces" +import { Collection, Guild, GuildMember, Role } from "discord.js" +import { ChangedRoles, Maybe, PermissionLevel } from "../interfaces" import { logger } from "../logger" import { config } from "../configuration" @@ -16,6 +16,13 @@ export function filterRolesFromMemberUpdate(oldMember: GuildMember, newMember: G return { addedRoles, removedRoles } } +export async function getMembersWithRoleFromGuild(roleId: string, guild: Guild): Promise> { + const emptyResponse = new Collection + const guildRole: Maybe = guild.roles.resolve(roleId) + if (!guildRole) return emptyResponse + return guildRole.members +} + export function getGuildSpecificTriggerRoleId(): Collection { const outVal = new Collection() outVal.set(config.bot.watcher_role, "VIEWER") diff --git a/server/helper/sendFailureDM.ts b/server/helper/sendFailureDM.ts index 78a5dfa..d1adf5f 100644 --- a/server/helper/sendFailureDM.ts +++ b/server/helper/sendFailureDM.ts @@ -1,10 +1,11 @@ import { CustomError, errorCodes } from "../interfaces" +import { logger } from "../logger" import { ExtendedClient } from "../structures/client" export async function sendFailureDM(creatorMessage: string, client: ExtendedClient, creatorId?: string): Promise { if (!creatorId) throw new CustomError('No creator ID present', errorCodes.no_creator_id) const creator = await client.users.fetch(creatorId) - console.log(`Creator ${JSON.stringify(creator)}`) + logger.info(`Creator ${JSON.stringify(creator)}`) if (creator) if (!creator.dmChannel) await creator.createDM() diff --git a/server/helper/vote.controller.ts b/server/helper/vote.controller.ts new file mode 100644 index 0000000..064aff1 --- /dev/null +++ b/server/helper/vote.controller.ts @@ -0,0 +1,361 @@ +import { Guild, GuildScheduledEvent, GuildScheduledEventEditOptions, GuildScheduledEventSetStatusArg, GuildScheduledEventStatus, Message, MessageCreateOptions, MessageReaction, TextChannel } from "discord.js" +import { ValidVoteEmotes, NONE_OF_THAT } from "../constants" +import { logger, newRequestId } from "../logger" +import { getMembersWithRoleFromGuild } from "./roleFilter" +import { config } from "../configuration" +import { VoteMessage, isVoteEndedMessage, isVoteMessage } from "./messageIdentifiers" +import { createDateStringFromEvent } from "./dateHelper" +import { Maybe, voteMessageInputInformation as prepareVoteMessageInput } from "../interfaces" +import format from "date-fns/format" +import toDate from "date-fns/toDate" +import differenceInDays from "date-fns/differenceInDays" +import addDays from "date-fns/addDays" +import isAfter from "date-fns/isAfter" +import { ExtendedClient } from "../structures/client" +import { JellyfinHandler } from "../jellyfin/handler" + +export type Vote = { + emote: string, //todo habs nicht hinbekommen hier Emotes zu nutzen + count: number, + movie: string +} +export type VoteMessageInfo = { + votes: Vote[], + event: GuildScheduledEvent, +} +export default class VoteController { + private client: ExtendedClient + private yavinJellyfinHandler: JellyfinHandler + + public constructor(_client: ExtendedClient, _yavin: JellyfinHandler) { + this.client = _client + this.yavinJellyfinHandler = _yavin + } + + public async handleNoneOfThatVote(messageReaction: MessageReaction, reactedUponMessage: VoteMessage, requestId: string, guildId: string) { + if (!messageReaction.message.guild) return 'No guild' + const guild = messageReaction.message.guild + logger.debug(`${reactedUponMessage.id} is vote message`, { requestId, guildId }) + + const watcherRoleMember = await getMembersWithRoleFromGuild(config.bot.announcement_role, messageReaction.message.guild) + logger.info("ROLE MEMBERS " + JSON.stringify(watcherRoleMember), { requestId, guildId }) + + const watcherRoleMemberCount = watcherRoleMember.size + logger.info(`MEMBER COUNT: ${watcherRoleMemberCount}`, { requestId, guildId }) + + const noneOfThatReactions = reactedUponMessage.reactions.cache.get(NONE_OF_THAT)?.users.cache.filter(x => x.id !== this.client.user?.id).size ?? 0 + + const memberThreshold = (watcherRoleMemberCount / 2) + logger.info(`Reroll ${noneOfThatReactions} > ${memberThreshold} ?`, { requestId, guildId }) + if (noneOfThatReactions > memberThreshold) + logger.info(`No reroll`, { requestId, guildId }) + else { + logger.info('Starting poll reroll', { requestId, guildId }) + await this.handleReroll(reactedUponMessage, guild.id, requestId) + logger.info(`Finished handling NONE_OF_THAT vote`, { requestId, guildId }) + } + } + + private async removeMessage(message: Message): Promise> { + if (message.pinned) { + await message.unpin() + } + return await message.delete() + } + + /** + * returns true if a Vote object contains at least one vote + * @param {Vote} vote + */ + private hasAtLeastOneVote(vote: Vote): boolean { + // subtracting the bots initial vote + const overOneVote = (vote.count - 1) >= 1 + logger.debug(`${vote.movie} : ${vote.count} -> above: ${overOneVote}`) + return overOneVote + } + + public async generateRerollMovieList(voteInfo: VoteMessageInfo, guildId: string, requestId: string) { + if (config.bot.reroll_retains_top_picks) { + const votedOnMovies = voteInfo.votes.filter(this.hasAtLeastOneVote).filter(x => x.emote !== NONE_OF_THAT) + logger.info(`Found ${votedOnMovies.length} with votes`, { requestId, guildId }) + const newMovieCount: number = config.bot.random_movie_count - votedOnMovies.length + logger.info(`Fetching ${newMovieCount} from jellyfin`) + const newMovies: string[] = await this.yavinJellyfinHandler.getRandomMovieNames(newMovieCount, guildId, requestId) + // merge + return newMovies.concat(votedOnMovies.map(x => x.movie)) + } else { + // get movies from jellyfin to fill the remaining slots + const newMovieCount: number = config.bot.random_movie_count + logger.info(`Fetching ${newMovieCount} from jellyfin`) + return await this.yavinJellyfinHandler.getRandomMovieNames(newMovieCount, guildId, requestId) + } + } + + public async handleReroll(voteMessage: VoteMessage, guildId: string, requestId: string) { + // get the movies currently being voted on, their votes, the eventId and its date + const voteInfo: VoteMessageInfo = await this.parseVoteInfoFromVoteMessage(voteMessage, requestId) + if (!voteInfo.event.scheduledStartAt) { + logger.info("Event does not have a start date, cancelling", { guildId: voteInfo.event.guildId, requestId }) + return + } + + let movies: string[] = await this.generateRerollMovieList(voteInfo, guildId, requestId) + + const announcementChannel = this.client.getAnnouncementChannelForGuild(guildId) + if (!announcementChannel) { + logger.error(`No announcementChannel found for ${guildId}, can't post poll`) + return + } + try { + logger.info(`Trying to remove old vote Message`, { requestId, guildId }) + this.removeMessage(voteMessage) + } catch (err) { + // TODO: integrate failure DM to media Admin to inform about inability to delete old message + logger.error(`Error during removeMessage: ${err}`) + } + const sentMessage = this.prepareAndSendVoteMessage({ + event: voteInfo.event, + movies, + announcementChannel, + startDate: voteInfo.event.scheduledStartAt, + pinAfterSending: true + }, + guildId, + requestId) + logger.debug(`Sent reroll message: ${JSON.stringify(sentMessage)}`, { requestId, guildId }) + } + + private async fetchEventByEventId(guild: Guild, eventId: string, requestId: string): Promise> { + const guildEvent: GuildScheduledEvent = await guild.scheduledEvents.fetch(eventId) + if (!guildEvent) logger.error(`GuildScheduledEvent with id${eventId} could not be found`, { requestId, guildId: guild.id }) + return guildEvent + } + + public async parseVoteInfoFromVoteMessage(message: VoteMessage, requestId: string): Promise { + const lines = message.cleanContent.split('\n') + let parsedIds = this.parseGuildIdAndEventIdFromWholeMessage(message.cleanContent) + + if (!message.guild) + throw new Error(`Message ${message.id} not a guild message`) + + const event: Maybe = await this.fetchEventByEventId(message.guild, parsedIds.eventId, requestId) + + let votes: Vote[] = [] + for (const line of lines) { + if (line.slice(0, 5).includes(':')) { + const splitLine = line.split(":") + const [emoji, movie] = splitLine + const fetchedVoteFromMessage = message.reactions.cache.get(emoji) + if (fetchedVoteFromMessage) { + if (emoji === NONE_OF_THAT) { + votes.push({ movie: NONE_OF_THAT, emote: NONE_OF_THAT, count: fetchedVoteFromMessage.count }) + } else + votes.push({ movie: movie.trim(), emote: emoji, count: fetchedVoteFromMessage.count }) + } else { + logger.error(`No vote reaction found for movie, assuming 0`, requestId) + votes.push({ movie, emote: emoji, count: 0 }) + } + } + } + return { event, votes } + } + public parseEventDateFromMessage(message: string, guildId: string, requestId: string): Date { + logger.warn(`Falling back to RegEx parsing to get Event Date`, { guildId, requestId }) + const datematcher = RegExp(/((?:0[1-9]|[12][0-9]|3[01])\.(?:0[1-9]|1[012])\.)(?:\ um\ )((?:(?:[01][0-9]|[2][0-3])\:[0-5][0-9])|(?:[2][4]\:00))!/i) + const result: RegExpMatchArray | null = message.match(datematcher) + const timeFromResult = result?.at(-1) + const dateFromResult = result?.at(1)?.concat(format(new Date(), 'yyyy')).concat(" " + timeFromResult) ?? "" + return new Date(dateFromResult) + } + public parseGuildIdAndEventIdFromWholeMessage(message: string) { + const idmatch = RegExp(/(?:http|https):\/\/discord\.com\/events\/(\d*)\/(\d*)/) + const matches = message.match(idmatch) + if (matches && matches.length == 3) + return { guildId: matches[1], eventId: matches[2] } + throw Error(`Could not find eventId in Vote Message`) + } + + public async prepareAndSendVoteMessage(inputInfo: prepareVoteMessageInput, guildId: string, requestId: string) { + const messageText = this.createVoteMessageText(inputInfo.event, inputInfo.movies, guildId, requestId) + const sentMessage = await this.sendVoteMessage(messageText, inputInfo.movies.length, inputInfo.announcementChannel) + if (inputInfo.pinAfterSending) + sentMessage.pin() + return sentMessage + } + + public createVoteMessageText(event: GuildScheduledEvent, movies: string[], guildId: string, requestId: string): string { + let message = `[Abstimmung] für https://discord.com/events/${guildId}/${event.id} \n<@&${config.bot.announcement_role}> Es gibt eine neue Abstimmung für die nächste Watchparty ${createDateStringFromEvent(event.scheduledStartAt, guildId, requestId)}! Stimme hierunter für den nächsten Film ab!\n` + + for (let i = 0; i < movies.length; i++) { + message = message.concat(ValidVoteEmotes[i]).concat(": ").concat(movies[i]).concat("\n") + } + message = message.concat(NONE_OF_THAT).concat(": Wenn dir nichts davon gefällt.") + + return message + } + + // TODO: Refactor into separate message controller + public async sendVoteMessage(messageText: string, movieCount: number, announcementChannel: TextChannel) { + + const options: MessageCreateOptions = { + allowedMentions: { parse: ["roles"] }, + content: messageText, + } + + const sentMessage: Message = await (await announcementChannel.fetch()).send(options) + + for (let i = 0; i < movieCount; i++) { + sentMessage.react(ValidVoteEmotes[i]) + } + sentMessage.react(NONE_OF_THAT) + + return sentMessage + } + + public async closePoll(guild: Guild, requestId: string) { + const guildId = guild.id + logger.info("stopping poll", { guildId, requestId }) + + const announcementChannel: Maybe = this.client.getAnnouncementChannelForGuild(guildId) + if (!announcementChannel) { + logger.error("Could not find the textchannel. Unable to close poll.", { guildId, requestId }) + return + } + + const messages: Message[] = (await announcementChannel.messages.fetch()) //todo: fetch only pinned messages + .map((value) => value) + .filter(message => !isVoteEndedMessage(message) && isVoteMessage(message)) + .sort((a, b) => b.createdTimestamp - a.createdTimestamp) + + if (!messages || messages.length <= 0) { + logger.info("Could not find any vote messages. Cancelling pollClose", { guildId, requestId }) + return + } + + const lastMessage: Message = messages[0] + + if (!isVoteMessage(lastMessage)) { + logger.error(`Found message that is not a vote message, can't proceed`, { guildId, requestId }) + logger.debug(`Found messages: ${JSON.stringify(messages, null, 2)}`, { guildId, requestId }) + logger.debug(`Last message: ${JSON.stringify(lastMessage, null, 2)}`, { guildId, requestId }) + } + else { + const votes = (await this.getVotesByEmote(lastMessage, guildId, requestId)) + .sort((a, b) => b.count - a.count) + + logger.debug(`votes: ${JSON.stringify(votes, null, 2)}`, { guildId, requestId }) + + logger.info("Deleting vote message") + lastMessage.unpin() + await lastMessage.delete() + const event = await this.getOpenPollEvent(guild, guild.id, requestId) + if (event && votes?.length > 0) { + this.updateOpenPollEventWithVoteResults(event, votes, guild, guildId, requestId) + this.sendVoteClosedMessage(event, votes[0].movie, guildId, requestId) + } + } + } + /** + * gets votes for the movies without the NONE_OF_THAT votes + */ + public async getVotesByEmote(message: VoteMessage, guildId: string, requestId: string): Promise { + const votes: Vote[] = [] + logger.debug(`Number of items in emotes: ${Object.values(ValidVoteEmotes).length}`, { guildId, requestId }) + for (let i = 0; i < Object.keys(ValidVoteEmotes).length / 2; i++) { + const emote = ValidVoteEmotes[i] + logger.debug(`Getting reaction for emote ${emote}`, { guildId, requestId }) + const reaction = message.reactions.resolve(emote) + logger.debug(`Reaction for emote ${emote}: ${JSON.stringify(reaction, null, 2)}`, { guildId, requestId }) + if (reaction) { + const vote: Vote = { emote: emote, count: reaction.count, movie: this.extractMovieFromMessageByEmote(message, emote) } + votes.push(vote) + } + } + return votes + } + public async getOpenPollEvent(guild: Guild, guildId: string, requestId: string): Promise> { + const voteEvents = (await guild.scheduledEvents.fetch()) + .map((value) => value) + .filter(event => event.name.toLowerCase().includes("voting offen")) + logger.debug(`Found events: ${JSON.stringify(voteEvents, null, 2)}`, { guildId, requestId }) + + if (!voteEvents || voteEvents.length <= 0) { + logger.error("Could not find an open vote event.", { guildId, requestId }) + return + } + return voteEvents[0] + } + public async updateOpenPollEventWithVoteResults(voteEvent: GuildScheduledEvent, votes: Vote[], guild: Guild, guildId: string, requestId: string) { + logger.info(`Updating event with movie ${votes[0].movie}.`, { guildId, requestId }) + const options: GuildScheduledEventEditOptions> = { + name: votes[0].movie, + description: `!wp\nNummer 2: ${votes[1].movie} mit ${votes[1].count - 1} Stimmen\nNummer 3: ${votes[2].movie} mit ${votes[2].count - 1} Stimmen` + } + logger.debug(`Updating event: ${JSON.stringify(voteEvent, null, 2)}`, { guildId, requestId }) + logger.info("Updating event.", { guildId, requestId }) + voteEvent.edit(options) + } + public async sendVoteClosedMessage(event: GuildScheduledEvent, movie: string, guildId: string, requestId: string): Promise> { + const date = event.scheduledStartAt ? format(event.scheduledStartAt, "dd.MM.") : "Fehler, Event hatte kein Datum" + const time = event.scheduledStartAt ? format(event.scheduledStartAt, "HH:mm") : "Fehler, Event hatte keine Uhrzeit" + const body = `[Abstimmung beendet] für https://discord.com/events/${event.guildId}/${event.id}\n<@&${config.bot.announcement_role}> Wir gucken ${movie} am ${date} um ${time}` + const options: MessageCreateOptions = { + content: body, + allowedMentions: { parse: ["roles"] } + } + const announcementChannel = this.client.getAnnouncementChannelForGuild(guildId) + logger.info("Sending vote closed message.", { guildId, requestId }) + if (!announcementChannel) { + const errorMessageText = "Could not find announcement channel. Please fix!" + logger.error(errorMessageText, { guildId, requestId }) + throw errorMessageText + } + return announcementChannel.send(options) + } + private extractMovieFromMessageByEmote(voteMessage: VoteMessage, emote: string): string { + const lines = voteMessage.cleanContent.split("\n") + const emoteLines = lines.filter(line => line.includes(emote)) + + if (!emoteLines) { + return "" + } + const movie = emoteLines[0].substring(emoteLines[0].indexOf(emote) + emote.length + 2) // plus colon and space + + return movie + } + public async checkForPollsToClose(guild: Guild): Promise { + const requestId = newRequestId() + logger.info(`Automatic check for poll closing.`, { guildId: guild.id, requestId }) + const events = (await guild.scheduledEvents.fetch()).filter(event => event.name.toLocaleLowerCase().includes("voting offen")).map(event => event) + if (events.length > 1) { + logger.error("Handling more than one Event is not implemented yet. Found more than one poll to close") + return + } else if (events.length == 0) { + logger.info("Could not find any events. Cancelling", { guildId: guild.id, requestId }) + } + + const updatedEvent = events[0] //add two hours because of different timezones in discord api and Date.now() + if (!updatedEvent.scheduledStartTimestamp) { + logger.error("Event does not have a scheduled start time. Cancelling", { guildId: guild.id, requestId }) + return + } + + const createDate: Date = toDate(updatedEvent.createdTimestamp) + const eventDate: Date = toDate(updatedEvent.scheduledStartTimestamp) + const difference: number = differenceInDays(createDate, eventDate) + + if (difference <= 2) { + logger.info("Less than two days between event create and event start. Not closing poll.", { guildId: guild.id, requestId }) + return + } + + const closePollDate: Date = addDays(eventDate, -2) + + if (isAfter(Date.now(), closePollDate)) { + logger.info("Less than two days until event. Closing poll", { guildId: guild.id, requestId }) + this.closePoll(guild, requestId) + } else { + logger.info(`ScheduledStart: ${closePollDate}. Now: ${toDate(Date.now())}`, { guildId: guild.id, requestId }) + } + } +} diff --git a/server/interfaces.ts b/server/interfaces.ts index 982c1ec..a6fc009 100644 --- a/server/interfaces.ts +++ b/server/interfaces.ts @@ -1,5 +1,5 @@ import { Collection } from "@discordjs/collection" -import { Role } from "discord.js" +import { GuildScheduledEvent, Role, TextChannel } from "discord.js" export type Maybe = T | undefined | null export interface Player { @@ -39,3 +39,10 @@ export interface JellyfinConfig { collectionUser: string } export type PermissionLevel = "VIEWER" | "ADMIN" | "TEMPORARY" +export interface voteMessageInputInformation { + movies: string[], + startDate: Date, + event: GuildScheduledEvent, + announcementChannel: TextChannel, + pinAfterSending: boolean, +} diff --git a/server/jellyfin/runtime.ts b/server/jellyfin/runtime.ts index 93efc22..c1494b8 100644 --- a/server/jellyfin/runtime.ts +++ b/server/jellyfin/runtime.ts @@ -253,22 +253,22 @@ function isFormData(value: any): value is FormData { export class ResponseError extends Error { override name: "ResponseError" = "ResponseError"; - constructor(public response: Response, msg?: string) { - super(msg); + constructor(public response: Response, errorMessage?: string) { + super(errorMessage); } } export class FetchError extends Error { override name: "FetchError" = "FetchError"; - constructor(public cause: Error, msg?: string) { - super(msg); + constructor(public cause: Error, errorMessage?: string) { + super(errorMessage); } } export class RequiredError extends Error { override name: "RequiredError" = "RequiredError"; - constructor(public field: string, msg?: string) { - super(msg); + constructor(public field: string, errorMessage?: string) { + super(errorMessage); } } diff --git a/server/logger.ts b/server/logger.ts index b81d8ee..f25cc88 100644 --- a/server/logger.ts +++ b/server/logger.ts @@ -6,7 +6,7 @@ export const noGuildId = 'NoGuildId' const printFn = format.printf(({ guildId, level, message, errorCode, requestId, timestamp: logTimestamp }: { [k: string]: string }) => { - return `[${guildId ?? ''}][${level}][${logTimestamp}][${errorCode ?? ''}][${requestId ?? ''}]:${message}` + return `[${guildId ?? ''}][${level.padStart(5, " ")}][${logTimestamp}][${errorCode ?? ''}][${requestId ?? ''}]:${message}` }) const logFormat = format.combine( diff --git a/server/structures/client.ts b/server/structures/client.ts index 10be4af..caaad67 100644 --- a/server/structures/client.ts +++ b/server/structures/client.ts @@ -8,7 +8,9 @@ import { Maybe } from "../interfaces"; import { JellyfinHandler } from "../jellyfin/handler"; import { logger } from "../logger"; import { CommandType } from "../types/commandTypes"; -import { checkForPollsToClose } from "../commands/closepoll"; +import { isInitialAnnouncement } from "../helper/messageIdentifiers"; +import VoteController from "../helper/vote.controller"; +import { yavinJellyfinHandler } from "../.."; @@ -16,13 +18,14 @@ export class ExtendedClient extends Client { private eventFilePath = `${__dirname}/../events` private commandFilePath = `${__dirname}/../commands` private jellyfin: JellyfinHandler + public voteController: VoteController = new VoteController(this, yavinJellyfinHandler) public commands: Collection = new Collection() private announcementChannels: Collection = new Collection() //guildId to TextChannel private announcementRoleHandlerTask: Collection = new Collection() //one task per guild private pollCloseBackgroundTasks: Collection = new Collection() public constructor(jf: JellyfinHandler) { const intents: IntentsBitField = new IntentsBitField() - intents.add(IntentsBitField.Flags.GuildMembers, IntentsBitField.Flags.MessageContent, IntentsBitField.Flags.Guilds, IntentsBitField.Flags.DirectMessages, IntentsBitField.Flags.GuildScheduledEvents, IntentsBitField.Flags.GuildVoiceStates) + intents.add(IntentsBitField.Flags.GuildMembers, IntentsBitField.Flags.MessageContent, IntentsBitField.Flags.Guilds, IntentsBitField.Flags.DirectMessages, IntentsBitField.Flags.GuildScheduledEvents, IntentsBitField.Flags.GuildMessageReactions, IntentsBitField.Flags.GuildVoiceStates) const options: ClientOptions = { intents } super(options) this.jellyfin = jf @@ -74,6 +77,7 @@ export class ExtendedClient extends Client { this.registerCommands(slashCommands, guilds) this.cacheUsers(guilds) await this.cacheAnnouncementServer(guilds) + this.fetchAnnouncementChannelMessage(this.announcementChannels) this.startAnnouncementRoleBackgroundTask(guilds) this.startPollCloseBackgroundTasks() }) @@ -81,6 +85,21 @@ export class ExtendedClient extends Client { logger.info(`Error refreshing slash commands: ${error}`) } } + /** + * Fetches all messages from the provided channel collection. + * This is necessary for announcementChannels, because 'old' messages don't receive + * messageReactionAdd Events, only messages that were sent while the bot is online are tracked + * automatically. + * To prevent the need for a dedicated 'Collector' implementation which would listen on specific + * it's easiest to just fetch all messages from the backlog, which automatically makes the bot track them + * again. + * @param {Collection} channels - All channels which should be fecthed for reactionTracking + */ + private async fetchAnnouncementChannelMessage(channels: Collection): Promise { + channels.each(async ch => { + ch.messages.fetch() + }) + } private async cacheAnnouncementServer(guilds: Collection) { for (const guild of guilds.values()) { const channels: TextChannel[] = (await guild.channels.fetch()) @@ -136,7 +155,7 @@ export class ExtendedClient extends Client { } this.announcementRoleHandlerTask.set(guild.id, schedule("*/10 * * * * *", async () => { const requestId = uuid() - const messages = (await textChannel.messages.fetchPinned()).filter(message => message.cleanContent.includes("[initial]")) + const messages = (await textChannel.messages.fetchPinned()).filter(message => isInitialAnnouncement(message)) if (messages.size > 1) { logger.error("More than one pinned announcement Messages found. Unable to know which one people react to. Please fix!", { guildId: guild.id, requestId }) @@ -175,7 +194,7 @@ export class ExtendedClient extends Client { private async startPollCloseBackgroundTasks() { for (const guild of this.guilds.cache) { - this.pollCloseBackgroundTasks.set(guild[1].id, schedule("0 * * * * *", () => checkForPollsToClose(guild[1]))) + this.pollCloseBackgroundTasks.set(guild[1].id, schedule("0 * * * * *", () => this.voteController.checkForPollsToClose(guild[1]))) } } } diff --git a/tests/discord/noneofthat.test.ts b/tests/discord/noneofthat.test.ts new file mode 100644 index 0000000..5469192 --- /dev/null +++ b/tests/discord/noneofthat.test.ts @@ -0,0 +1,81 @@ +import { Guild, GuildScheduledEvent, Message } from "discord.js" +import VoteController from "../../server/helper/vote.controller" +import { JellyfinHandler } from "../../server/jellyfin/handler" +import { ExtendedClient } from "../../server/structures/client" +import { Emoji, NONE_OF_THAT } from "../../server/constants" +import { isVoteMessage } from "../../server/helper/messageIdentifiers" + +describe('vote controller - none_of_that functions', () => { + const testEventId = '1234321' + const testEventDate = new Date('2023-01-01') + const testGuildId = "888999888" + const testMovies = [ + 'Movie1', + 'Movie2', + 'Movie3', + 'Movie4', + 'Movie5', + ] + const votesList = [ + { emote: Emoji.one, count: 1, movie: testMovies[0] }, + { emote: Emoji.two, count: 2, movie: testMovies[1] }, + { emote: Emoji.three, count: 3, movie: testMovies[2] }, + { emote: Emoji.four, count: 1, movie: testMovies[3] }, + { emote: Emoji.five, count: 1, movie: testMovies[4] }, + { emote: NONE_OF_THAT, count: 2, movie: NONE_OF_THAT }, + ] + const mockClient: ExtendedClient = { + user: { + id: 'mockId' + } + } + const mockEvent: GuildScheduledEvent = { + scheduledStartAt: testEventDate, + id: testEventId, + guild: testGuildId + } + const mockJellyfinHandler: JellyfinHandler = { + getRandomMovieNames: jest.fn().mockReturnValue(["movie1"]) + } + const votes = new VoteController(mockClient, mockJellyfinHandler) + const mockMessageContent = votes.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId") + + test('sendVoteClosedMessage', async () => { + mockClient.getAnnouncementChannelForGuild = jest.fn().mockReturnValue({ + send: jest.fn().mockImplementation((options: any) => { + return new Promise((resolve) => { + resolve(options) + }) + }) + }) + const scheduledEvent: GuildScheduledEvent = { + scheduledStartAt: testEventDate, + guildId: testGuildId, + id: testEventId + } + + const res = await votes.sendVoteClosedMessage(scheduledEvent, 'MovieNew', 'guild', 'request') + expect(res).toEqual({ + allowedMentions: { + parse: ["roles"] + }, + content: `[Abstimmung beendet] für https://discord.com/events/${testGuildId}/${testEventId}\n<@&WATCHPARTY_ANNOUNCEMENT_ROLE> Wir gucken MovieNew am 01.01. um 01:00` + }) + }) + + test('getVotesByEmote', async () => { + const mockMessage: Message = { + cleanContent: mockMessageContent, + reactions: { + resolve: jest.fn().mockImplementation((input: any) => { + return votesList.find(e => e.emote === input) + }) + } + } + if (isVoteMessage(mockMessage)) { + const result = await votes.getVotesByEmote(mockMessage, 'guildId', 'requestId') + expect(result.length).toEqual(5) + expect(result).toEqual(votesList.filter(x => x.movie != NONE_OF_THAT)) + } + }) +}) diff --git a/tests/discord/votes.test.ts b/tests/discord/votes.test.ts new file mode 100644 index 0000000..4855428 --- /dev/null +++ b/tests/discord/votes.test.ts @@ -0,0 +1,192 @@ +import { Emoji, NONE_OF_THAT } from "../../server/constants" +import VoteController, { VoteMessageInfo } from "../../server/helper/vote.controller" +import { JellyfinHandler } from "../../server/jellyfin/handler" +import { ExtendedClient } from "../../server/structures/client" +import { VoteMessage } from "../../server/helper/messageIdentifiers" +import { GuildScheduledEvent, MessageReaction } from "discord.js" +test('parse votes from vote message', async () => { + const testMovies = [ + 'Movie1', + 'Movie2', + 'Movie3', + 'Movie4', + 'Movie5', + ] + const testEventId = '1234321' + const testEventDate = new Date('2023-01-01') + const testGuildId = "888999888" + const voteController: VoteController = new VoteController({}, {}) + const mockEvent: GuildScheduledEvent = { + scheduledStartAt: testEventDate, + id: testEventId, + guild: testGuildId + } + const testMessage = voteController.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId") + + + const expectedResult: VoteMessageInfo = { + event: mockEvent, + votes: [ + { emote: Emoji.one, count: 1, movie: testMovies[0] }, + { emote: Emoji.two, count: 2, movie: testMovies[1] }, + { emote: Emoji.three, count: 3, movie: testMovies[2] }, + { emote: Emoji.four, count: 1, movie: testMovies[3] }, + { emote: Emoji.five, count: 1, movie: testMovies[4] }, + { emote: NONE_OF_THAT, count: 1, movie: NONE_OF_THAT }, + ] + } + + const message: VoteMessage = { + cleanContent: testMessage, + guild: { + id: testGuildId, + scheduledEvents: { + fetch: jest.fn().mockImplementation((input: any) => { + if (input === testEventId) + return { + id: testEventId, + guild: testGuildId, + scheduledStartAt: testEventDate + } + }) + } + }, + reactions: { + cache: { + get: jest.fn().mockImplementation((input: any) => { + // Abusing duck typing + // Message Reaction has a method `count` and the expected votes + // have a field `count` + // this will evaluate to the same 'result' + return expectedResult.votes.find(e => e.emote === input) + }) + } + } + } + + const result = await voteController.parseVoteInfoFromVoteMessage(message, 'requestId') + console.log(JSON.stringify(result)) + expect(Array.isArray(result)).toBe(false) + expect(result.event.id).toEqual(testEventId) + expect(result.event.scheduledStartAt).toEqual(testEventDate) + expect(result.votes.length).toEqual(expectedResult.votes.length) + expect(result).toEqual(expectedResult) +}) + +test('parse votes from vote message', () => { + const testMovies = [ + 'Movie1', + 'Movie2', + 'Movie3', + 'Movie4', + 'Movie5', + ] + const testEventId = '1234321' + const testEventDate = new Date('2023-01-01') + const testGuildId = "888999888" + const voteController: VoteController = new VoteController({}, {}) + const mockEvent: GuildScheduledEvent = { + scheduledStartAt: testEventDate, + id: testEventId, + guild: testGuildId + } + const testMessage = voteController.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId") + + const result = voteController.parseGuildIdAndEventIdFromWholeMessage(testMessage) + expect(result).toEqual({ guildId: testGuildId, eventId: testEventId }) +}) + + +test.skip('handles complete none_of_that vote', () => { + + const mockJellyfinHandler: JellyfinHandler = { + getRandomMovieNames: jest.fn().mockReturnValue(["movie1"]) + } + + const testMovies = [ + 'Movie1', + 'Movie2', + 'Movie3', + 'Movie4', + 'Movie5', + ] + const testEventId = '1234321' + const testEventDate = new Date('2023-01-01') + const testGuildId = "888999888" + const mockClient: ExtendedClient = { + user: { + id: 'mockId' + } + } + const voteController = new VoteController(mockClient, mockJellyfinHandler) + const mockEvent: GuildScheduledEvent = { + scheduledStartAt: testEventDate, + id: testEventId, + guild: testGuildId + } + const mockMessageContent = voteController.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId") + const reactedUponMessage: VoteMessage = { + cleanContent: mockMessageContent, + guild: { + id: 'id', + roles: { + resolve: jest.fn().mockReturnValue({ + members: [{}, {}, {}, {}, {}]//content does not matter + }) + }, + scheduledEvents: { + fetch: jest.fn().mockReturnValue([ + { + name: 'voting offen' + } + ]) + } + }, + unpin: jest.fn().mockImplementation(() => { + + }), + delete: jest.fn().mockImplementation(() => { + + }), + reactions: { + resolve: jest.fn().mockImplementation((input: any) => { + console.log(JSON.stringify(input)) + }), + cache: { + get: jest.fn().mockReturnValue({ + users: { + cache: [ + { + id: "mockId"//to filter out + }, + { + id: "userId1" + }, + { + id: "userId2" + }, + { + id: "userId3" + } + ] + } + }) + } + } + } + const messageReaction: MessageReaction = { + message: reactedUponMessage + } + + mockClient.getAnnouncementChannelForGuild = jest.fn().mockReturnValue({ + messages: { + fetch: jest.fn().mockReturnValue([ + reactedUponMessage + ]) + } + }) + + const res = voteController.handleNoneOfThatVote(messageReaction, reactedUponMessage, 'requestId', 'guildId') + + +}) diff --git a/tests/helpers/date.test.ts b/tests/helpers/date.test.ts index 52f7ff9..106c3b0 100644 --- a/tests/helpers/date.test.ts +++ b/tests/helpers/date.test.ts @@ -1,16 +1,15 @@ -import { GuildScheduledEvent } from "discord.js" import { createDateStringFromEvent } from "../../server/helper/dateHelper" import MockDate from 'mockdate' beforeAll(() => { - MockDate.set('01-01-2023') + MockDate.set('01-01-2023') }) -function getTestDate(date: string): GuildScheduledEvent { - return { scheduledStartAt: new Date(date) } +function getTestDate(date: string): Date { + return new Date(date) } test('createDateStringFromEvent - correct formatting', () => { - expect(createDateStringFromEvent(getTestDate('01-01-2023 12:30'), "")).toEqual('heute um 12:30') - expect(createDateStringFromEvent(getTestDate('01-02-2023 12:30'), "")).toEqual('am Montag 02.01 um 12:30') - expect(createDateStringFromEvent(getTestDate('01-03-2023 12:30'), "")).toEqual('am Dienstag 03.01 um 12:30') + expect(createDateStringFromEvent(getTestDate('01-01-2023 12:30'), "")).toEqual('heute um 12:30') + expect(createDateStringFromEvent(getTestDate('01-02-2023 12:30'), "")).toEqual('am Montag 02.01. um 12:30') + expect(createDateStringFromEvent(getTestDate('01-03-2023 12:30'), "")).toEqual('am Dienstag 03.01. um 12:30') })