2023-08-06 02:33:23 +02:00
import { Guild , GuildScheduledEvent , GuildScheduledEventEditOptions , GuildScheduledEventSetStatusArg , GuildScheduledEventStatus , Message , MessageCreateOptions , MessageReaction , TextChannel } from "discord.js"
import { Emotes , NONE_OF_THAT } from "../constants"
2023-07-05 22:55:24 +02:00
import { logger , newRequestId } from "../logger"
2023-06-26 23:47:43 +02:00
import { getMembersWithRoleFromGuild } from "./roleFilter"
import { config } from "../configuration"
2023-07-05 22:55:24 +02:00
import { VoteMessage , isVoteEndedMessage , isVoteMessage } from "./messageIdentifiers"
import { createDateStringFromEvent } from "./dateHelper"
2023-11-18 17:28:44 +01:00
import { Maybe , voteMessageInputInformation as prepareVoteMessageInput } from "../interfaces"
2023-07-05 22:55:24 +02:00
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"
2023-07-13 22:47:28 +02:00
import { ExtendedClient } from "../structures/client"
import { JellyfinHandler } from "../jellyfin/handler"
2023-06-26 23:47:43 +02:00
2023-07-05 22:55:24 +02:00
export type Vote = {
emote : string , //todo habs nicht hinbekommen hier Emotes zu nutzen
count : number ,
movie : string
}
2023-07-13 22:47:28 +02:00
export type VoteMessageInfo = {
votes : Vote [ ] ,
eventId : string ,
eventDate : Date
}
2023-06-26 23:47:43 +02:00
export default class VoteController {
2023-07-13 22:47:28 +02:00
private client : ExtendedClient
private yavinJellyfinHandler : JellyfinHandler
public constructor ( _client : ExtendedClient , _yavin : JellyfinHandler ) {
this . client = _client
this . yavinJellyfinHandler = _yavin
}
2023-06-26 23:47:43 +02:00
2023-07-17 23:30:48 +02:00
public async handleNoneOfThatVote ( messageReaction : MessageReaction , reactedUponMessage : VoteMessage , requestId : string , guildId : string ) {
2023-06-26 23:47:43 +02:00
if ( ! messageReaction . message . guild ) return 'No guild'
2023-07-17 23:30:48 +02:00
const guild = messageReaction . message . guild
2023-06-27 20:22:44 +02:00
logger . debug ( ` ${ reactedUponMessage . id } is vote message ` , { requestId , guildId } )
2023-07-05 22:55:24 +02:00
2023-06-27 20:22:44 +02:00
const watcherRoleMember = await getMembersWithRoleFromGuild ( config . bot . announcement_role , messageReaction . message . guild )
logger . info ( "ROLE MEMBERS " + JSON . stringify ( watcherRoleMember ) , { requestId , guildId } )
2023-07-05 22:55:24 +02:00
2023-06-27 20:22:44 +02:00
const watcherRoleMemberCount = watcherRoleMember . size
logger . info ( ` MEMBER COUNT: ${ watcherRoleMemberCount } ` , { requestId , guildId } )
2023-07-05 22:55:24 +02:00
2023-07-17 23:30:48 +02:00
const noneOfThatReactions = reactedUponMessage . reactions . cache . get ( NONE_OF_THAT ) ? . users . cache . filter ( x = > x . id !== this . client . user ? . id ) . size ? ? 0
2023-06-26 23:47:43 +02:00
2023-06-27 20:22:44 +02:00
const memberThreshold = ( watcherRoleMemberCount / 2 )
logger . info ( ` Reroll ${ noneOfThatReactions } > ${ memberThreshold } ? ` , { requestId , guildId } )
2023-07-17 23:30:48 +02:00
if ( noneOfThatReactions > memberThreshold )
logger . info ( ` No reroll ` , { requestId , guildId } )
2023-08-13 18:31:15 +02:00
else {
2023-06-27 20:22:44 +02:00
logger . info ( 'Starting poll reroll' , { requestId , guildId } )
2023-10-21 14:56:33 +02:00
await this . handleReroll ( reactedUponMessage , guild . id , requestId )
2023-08-13 18:31:15 +02:00
logger . info ( ` Finished handling NONE_OF_THAT vote ` , { requestId , guildId } )
}
2023-06-26 23:47:43 +02:00
}
2023-08-06 02:33:28 +02:00
2023-11-18 17:40:50 +01:00
private async removeMessage ( message : Message ) : Promise < Message < boolean > > {
if ( message . pinned ) {
await message . unpin ( )
2023-08-13 18:35:22 +02:00
}
2023-11-18 17:40:50 +01:00
return await message . delete ( )
2023-08-13 18:35:22 +02:00
}
2023-11-18 18:14:56 +01:00
public 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
2023-08-13 18:35:22 +02:00
}
2023-10-21 14:56:33 +02:00
public async handleReroll ( voteMessage : VoteMessage , guildId : string , requestId : string ) {
2023-11-18 18:15:13 +01:00
// get the movies currently being voted on, their votes, the eventId and its date
2023-08-06 02:33:17 +02:00
const voteInfo : VoteMessageInfo = await this . parseVoteInfoFromVoteMessage ( voteMessage , requestId )
2023-07-17 23:30:48 +02:00
2023-10-21 14:56:33 +02:00
let movies : string [ ] = Array ( )
if ( config . bot . reroll_retains_top_picks ) {
2023-11-18 18:14:56 +01:00
const votedOnMovies = voteInfo . votes . filter ( this . hasAtLeastOneVote ) . filter ( x = > x . emote !== NONE_OF_THAT )
2023-10-21 14:56:33 +02:00
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
movies = 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 ` )
movies = await this . yavinJellyfinHandler . getRandomMovieNames ( newMovieCount , guildId , requestId )
}
2023-07-17 23:30:48 +02:00
// create new message
2023-08-13 18:35:22 +02:00
logger . info ( ` Creating new poll message with new movies: ${ movies } ` , { requestId , guildId } )
2023-11-18 18:15:27 +01:00
const messageText = this . createVoteMessageText ( voteInfo . eventId , voteInfo . eventDate , movies , guildId , requestId )
2023-07-17 23:30:48 +02:00
const announcementChannel = this . client . getAnnouncementChannelForGuild ( guildId )
if ( ! announcementChannel ) {
logger . error ( ` No announcementChannel found for ${ guildId } , can't post poll ` )
return
}
2023-08-13 18:35:22 +02:00
try {
logger . info ( ` Trying to remove old vote Message ` , { requestId , guildId } )
this . removeMessage ( voteMessage )
} catch ( err ) {
logger . error ( ` Error during removeMessage: ${ err } ` )
}
2023-11-18 18:15:27 +01:00
const sentMessage = await this . sendVoteMessage ( messageText , movies . length , announcementChannel )
2023-07-17 23:30:48 +02:00
sentMessage . pin ( )
2023-08-13 18:35:22 +02:00
logger . info ( ` Sent and pinned new poll message ` , { requestId , guildId } )
2023-07-05 23:22:01 +02:00
}
2023-07-17 23:30:48 +02:00
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
2023-07-17 22:50:24 +02:00
}
2023-07-17 23:30:48 +02:00
public async parseVoteInfoFromVoteMessage ( message : VoteMessage , requestId : string ) : Promise < VoteMessageInfo > {
2023-07-13 22:47:28 +02:00
const lines = message . cleanContent . split ( '\n' )
2023-07-17 22:50:24 +02:00
let parsedIds = this . parseGuildIdAndEventIdFromWholeMessage ( message . cleanContent )
2023-07-17 23:30:48 +02:00
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 )
2023-07-13 22:47:28 +02:00
let votes : Vote [ ] = [ ]
for ( const line of lines ) {
2023-07-17 22:50:24 +02:00
if ( line . slice ( 0 , 5 ) . includes ( ':' ) ) {
2023-07-13 22:47:28 +02:00
const splitLine = line . split ( ":" )
const [ emoji , movie ] = splitLine
const fetchedVoteFromMessage = message . reactions . cache . get ( emoji )
if ( fetchedVoteFromMessage ) {
2023-07-17 21:30:02 +02:00
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 } )
2023-07-13 22:47:28 +02:00
} else {
logger . error ( ` No vote reaction found for movie, assuming 0 ` , requestId )
votes . push ( { movie , emote : emoji , count : 0 } )
}
}
}
2023-07-17 23:30:48 +02:00
return < VoteMessageInfo > { eventId : parsedIds.eventId , eventDate : eventStartDate , votes }
2023-07-05 23:22:01 +02:00
}
2023-07-17 23:30:48 +02:00
public parseEventDateFromMessage ( message : string , guildId : string , requestId : string ) : Date {
logger . warn ( ` Falling back to RegEx parsing to get Event Date ` , { guildId , requestId } )
2023-07-17 22:50:24 +02:00
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 )
2023-07-17 21:30:02 +02:00
const timeFromResult = result ? . at ( - 1 )
2023-07-17 22:50:24 +02:00
const dateFromResult = result ? . at ( 1 ) ? . concat ( format ( new Date ( ) , 'yyyy' ) ) . concat ( " " + timeFromResult ) ? ? ""
2023-07-17 21:30:02 +02:00
return new Date ( dateFromResult )
2023-07-05 23:22:01 +02:00
}
2023-07-17 23:30:48 +02:00
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 ` )
}
2023-07-05 22:55:24 +02:00
2023-11-18 17:28:44 +01:00
public async prepareAndSendVoteMessage ( inputInfo : prepareVoteMessageInput , guildId : string , requestId : string ) {
const messageText = this . createVoteMessageText ( inputInfo . event . id , inputInfo . startDate , inputInfo . movies , guildId , requestId )
const sentMessage = await this . sendVoteMessage ( messageText , inputInfo . movies . length , inputInfo . announcementChannel )
if ( inputInfo . pinAfterSending )
sentMessage . pin ( )
return sentMessage
}
2023-07-05 23:22:01 +02:00
public createVoteMessageText ( eventId : string , eventStartDate : Date , movies : string [ ] , guildId : string , requestId : string ) : string {
2023-07-13 22:47:28 +02:00
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 `
2023-07-05 22:55:24 +02:00
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." )
2023-07-05 23:22:01 +02:00
return message
}
2023-11-18 17:28:44 +01:00
2023-07-05 23:22:01 +02:00
public async sendVoteMessage ( message : string , movieCount : number , announcementChannel : TextChannel ) {
2023-07-05 22:55:24 +02:00
const options : MessageCreateOptions = {
allowedMentions : { parse : [ "roles" ] } ,
content : message ,
}
const sentMessage : Message < true > = await ( await announcementChannel . fetch ( ) ) . send ( options )
2023-07-05 23:22:01 +02:00
for ( let i = 0 ; i < movieCount ; i ++ ) {
2023-07-05 22:55:24 +02:00
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 } )
2023-07-13 22:47:28 +02:00
const announcementChannel : Maybe < TextChannel > = this . client . getAnnouncementChannelForGuild ( guildId )
2023-07-05 22:55:24 +02:00
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 )
2023-08-13 18:35:22 +02:00
if ( event && votes ? . length > 0 ) {
2023-07-05 22:55:24 +02:00
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
}
2023-08-13 18:35:22 +02:00
/ * *
* gets votes for the movies without the NONE_OF_THAT votes
* /
2023-07-05 22:55:24 +02:00
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 )
}
2023-08-13 18:35:22 +02:00
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"
2023-07-05 22:55:24 +02:00
const time = event . scheduledStartAt ? format ( event . scheduledStartAt , "HH:mm" ) : "Fehler, event hatte kein Datum"
2023-08-13 18:35:22 +02:00
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 } `
2023-07-05 22:55:24 +02:00
const options : MessageCreateOptions = {
content : body ,
allowedMentions : { parse : [ "roles" ] }
}
2023-07-13 22:47:28 +02:00
const announcementChannel = this . client . getAnnouncementChannelForGuild ( guildId )
2023-07-05 22:55:24 +02:00
logger . info ( "Sending vote closed message." , { guildId , requestId } )
if ( ! announcementChannel ) {
2023-08-13 18:35:22 +02:00
const errorMessages = "Could not find announcement channel. Please fix!"
logger . error ( errorMessages , { guildId , requestId } )
throw errorMessages
2023-07-05 22:55:24 +02:00
}
2023-08-13 18:35:22 +02:00
return announcementChannel . send ( options )
2023-07-05 22:55:24 +02:00
}
2023-08-13 18:35:22 +02:00
private extractMovieFromMessageByEmote ( lastMessages : Message , emote : string ) : string {
const lines = lastMessages . cleanContent . split ( "\n" )
2023-07-05 22:55:24 +02:00
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 } )
}
}
2023-06-26 23:47:43 +02:00
}