kenobi
ce4dc81f7d
now correctly fetches old movies, filters already voted on movies, gets new movies, creates new poll message, deletes old message
331 lines
15 KiB
TypeScript
331 lines
15 KiB
TypeScript
import { Guild, GuildScheduledEvent, GuildScheduledEventEditOptions, GuildScheduledEventSetStatusArg, GuildScheduledEventStatus, Message, MessageCreateOptions, MessageReaction, TextChannel } from "discord.js"
|
|
import { Emotes, 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 } 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[],
|
|
eventId: string,
|
|
eventDate: Date
|
|
}
|
|
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, guild.id, requestId)
|
|
logger.info(`Finished handling NONE_OF_THAT vote`, { requestId, guildId })
|
|
}
|
|
}
|
|
|
|
private async removeMessage(msg: Message): Promise<Message<boolean>> {
|
|
if (msg.pinned) {
|
|
await msg.unpin()
|
|
}
|
|
return await msg.delete()
|
|
}
|
|
public isAboveThreshold(vote: Vote): boolean {
|
|
const aboveThreshold = (vote.count - 1) >= 1
|
|
logger.debug(`${vote.movie} : ${vote.count} -> above: ${aboveThreshold}`)
|
|
return aboveThreshold
|
|
}
|
|
public async handleReroll(voteMessage: VoteMessage, guild: Guild, guildId: string, requestId: string) {
|
|
//get movies that already had votes to give them a second chance
|
|
const voteInfo: VoteMessageInfo = await this.parseVoteInfoFromVoteMessage(voteMessage, requestId)
|
|
const votedOnMovies = voteInfo.votes.filter(this.isAboveThreshold).filter(x => x.emote !== NONE_OF_THAT)
|
|
logger.info(`Found ${votedOnMovies.length} with votes`, { requestId, guildId })
|
|
|
|
// get movies from jellyfin to fill the remaining slots
|
|
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
|
|
const movies: string[] = newMovies.concat(votedOnMovies.map(x => x.movie))
|
|
|
|
// create new message
|
|
|
|
logger.info(`Creating new poll message with new movies: ${movies}`, { requestId, guildId })
|
|
const message = this.createVoteMessageText(voteInfo.eventId, voteInfo.eventDate, movies, 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) {
|
|
logger.error(`Error during removeMessage: ${err}`)
|
|
}
|
|
|
|
const sentMessage = await this.sendVoteMessage(message, movies.length, announcementChannel)
|
|
sentMessage.pin()
|
|
logger.info(`Sent and pinned new poll message`, { requestId, guildId })
|
|
}
|
|
|
|
private async fetchEventStartDateByEventId(guild: Guild, eventId: string, requestId: string): Promise<Maybe<Date>> {
|
|
const guildEvent: GuildScheduledEvent = await guild.scheduledEvents.fetch(eventId)
|
|
if (!guildEvent) logger.error(`GuildScheduledEvent with id${eventId} could not be found`, { requestId, guildId: guild.id })
|
|
if (guildEvent.scheduledStartAt)
|
|
return guildEvent.scheduledStartAt
|
|
}
|
|
|
|
public async parseVoteInfoFromVoteMessage(message: VoteMessage, requestId: string): Promise<VoteMessageInfo> {
|
|
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`)
|
|
|
|
let eventStartDate: Maybe<Date> = await this.fetchEventStartDateByEventId(message.guild, parsedIds.eventId, requestId)
|
|
if (!eventStartDate) eventStartDate = this.parseEventDateFromMessage(message.cleanContent, message.guild.id, 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 <VoteMessageInfo>{ eventId: parsedIds.eventId, eventDate: eventStartDate, 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 createVoteMessageText(eventId: string, eventStartDate: Date, movies: string[], guildId: string, requestId: string): string {
|
|
let message = `[Abstimmung] für https://discord.com/events/${guildId}/${eventId} \n<@&${config.bot.announcement_role}> Es gibt eine neue Abstimmung für die nächste Watchparty ${createDateStringFromEvent(eventStartDate, guildId, requestId)}! Stimme hierunter für den nächsten Film ab!\n`
|
|
|
|
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.")
|
|
|
|
return message
|
|
}
|
|
public async sendVoteMessage(message: string, movieCount: number, announcementChannel: TextChannel) {
|
|
|
|
const options: MessageCreateOptions = {
|
|
allowedMentions: { parse: ["roles"] },
|
|
content: message,
|
|
}
|
|
|
|
const sentMessage: Message<true> = await (await announcementChannel.fetch()).send(options)
|
|
|
|
for (let i = 0; i < movieCount; i++) {
|
|
sentMessage.react(Emotes[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<TextChannel> = this.client.getAnnouncementChannelForGuild(guildId)
|
|
if (!announcementChannel) {
|
|
logger.error("Could not find the textchannel. Unable to close poll.", { guildId, requestId })
|
|
return
|
|
}
|
|
|
|
const messages: Message<true>[] = (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<true> = 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 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")
|
|
await lastMessage.delete()
|
|
const event = await this.getEvent(guild, guild.id, requestId)
|
|
if (event && votes?.length > 0) {
|
|
this.updateEvent(event, votes, guild, guildId, requestId)
|
|
this.sendVoteClosedMessage(event, votes[0].movie, guildId, requestId)
|
|
}
|
|
lastMessage.unpin() //todo: uncomment when bot has permission to pin/unpin
|
|
}
|
|
/**
|
|
* gets votes for the movies without the NONE_OF_THAT votes
|
|
*/
|
|
public async getVotesByEmote(message: Message, guildId: string, requestId: string): Promise<Vote[]> {
|
|
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 = 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 getEvent(guild: Guild, guildId: string, requestId: string): Promise<GuildScheduledEvent | null> {
|
|
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]
|
|
}
|
|
public async 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<GuildScheduledEventStatus.Scheduled, GuildScheduledEventSetStatusArg<GuildScheduledEventStatus.Scheduled>> = {
|
|
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<Message<boolean>> {
|
|
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 = this.client.getAnnouncementChannelForGuild(guildId)
|
|
logger.info("Sending vote closed message.", { guildId, requestId })
|
|
if (!announcementChannel) {
|
|
const errorMessages = "Could not find announcement channel. Please fix!"
|
|
logger.error(errorMessages, { guildId, requestId })
|
|
throw errorMessages
|
|
}
|
|
return announcementChannel.send(options)
|
|
}
|
|
private extractMovieFromMessageByEmote(lastMessages: Message, emote: string): string {
|
|
const lines = lastMessages.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<void> {
|
|
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 })
|
|
}
|
|
}
|
|
}
|