Compare commits
	
		
			95 Commits
		
	
	
		
			b1c581ca6e
			...
			feat/20-re
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a60fc2db7e | |||
| a50ac1716f | |||
| ef39c6315d | |||
| 1f372b0aac | |||
| d1aacbb3d3 | |||
| 1ae8278fb8 | |||
| 417b24d408 | |||
| 88061c361c | |||
| f83f54749d | |||
| 90b0b07080 | |||
| 6d0eaed426 | |||
| 8f320cee5c | |||
| 016bb243cc | |||
| 2c8cd96ac7 | |||
| ba4aefed8e | |||
| 8efae12907 | |||
| fec0bc31f1 | |||
| 1bfcaa95f9 | |||
| fb4ab59dc6 | |||
| 6d40930dc1 | |||
| 4e9fe587b0 | |||
| 03b6a30ffa | |||
| 7d794a8001 | |||
| 8df180898e | |||
| 976175242b | |||
| 68546b0b50 | |||
| 1348abbd48 | |||
| fce9091114 | |||
| 081f3c6201 | |||
| ca99987a20 | |||
| fc64728a78 | |||
| 20da25f2bf | |||
| a455fd8ff7 | |||
| 119343c916 | |||
| 296a490e93 | |||
| 66507cb08f | |||
| 4600820889 | |||
| 4a3e8809be | |||
| 690ba697b6 | |||
| 71343d6742 | |||
| 3f6e558d39 | |||
| ca259c5f24 | |||
| f705b97804 | |||
| 9cdc6e1934 | |||
| c73cd20ccf | |||
| e66aebc88c | |||
| 599243990e | |||
| eef3a9c358 | |||
| 1e912b20ef | |||
| ce4dc81f7d | |||
| b76df79d2a | |||
| 4e563d57fd | |||
| b6a1e06b03 | |||
| 2ebc7fbdbe | |||
| 8ff5aeff03 | |||
| 1101a84501 | |||
| 91ec2ece7e | |||
| 5e58765cf4 | |||
| a2adef808f | |||
| dc66c277b2 | |||
| c022cc32d5 | |||
| e763e76413 | |||
| 137d156981 | |||
| fdfe7ce404 | |||
| 146848b759 | |||
| e54f03292e | |||
| fe45445811 | |||
| 8f02e11dba | |||
| 878c81bfa7 | |||
| ca19168cf4 | |||
| e8893646f0 | |||
| e61b3a7b16 | |||
| 9383cee4a0 | |||
| 0748097a1f | |||
| ffba737e5a | |||
| 4cd9c771f0 | |||
| 8c3cf7829b | |||
| 1a13638ed9 | |||
| c351e27fdd | |||
| 6d3bea169e | |||
| 3f071c8a4e | |||
| 98d1ca73b5 | |||
| ee742018e9 | |||
| 8ad651c753 | |||
| a4a834ad27 | |||
| e8dcfd8340 | |||
| d9d1d74ef9 | |||
| 331ff89060 | |||
| f6476c609b | |||
| 6220268b14 | |||
| b6034d4fb7 | |||
| ca0a9e3cb8 | |||
| b8a32aab40 | |||
| e3e755011d | |||
| 5a6c66cb3e | 
							
								
								
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@@ -1,12 +1,12 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "node-jellyfin-discord-bot",
 | 
			
		||||
  "version": "1.1.3",
 | 
			
		||||
	"version": "1.1.4",
 | 
			
		||||
	"lockfileVersion": 2,
 | 
			
		||||
	"requires": true,
 | 
			
		||||
	"packages": {
 | 
			
		||||
		"": {
 | 
			
		||||
			"name": "node-jellyfin-discord-bot",
 | 
			
		||||
      "version": "1.1.3",
 | 
			
		||||
			"version": "1.1.4",
 | 
			
		||||
			"license": "MIT",
 | 
			
		||||
			"dependencies": {
 | 
			
		||||
				"@discordjs/rest": "^1.7.0",
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "node-jellyfin-discord-bot",
 | 
			
		||||
	"version": "1.1.3",
 | 
			
		||||
	"version": "1.1.4",
 | 
			
		||||
	"description": "A discord bot to sync jellyfin accounts with discord roles",
 | 
			
		||||
	"main": "index.js",
 | 
			
		||||
	"license": "MIT",
 | 
			
		||||
 
 | 
			
		||||
@@ -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())
 | 
			
		||||
 | 
			
		||||
@@ -81,41 +82,5 @@ Für eine Erklärung wie das alles funktioniert mach einfach /mitgucken für ein
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function manageAnnouncementRoles(guild: Guild, reaction: MessageReaction, requestId: string) {
 | 
			
		||||
	const guildId = guild.id
 | 
			
		||||
	logger.info("Managing roles", { guildId, requestId })
 | 
			
		||||
 | 
			
		||||
	const announcementRole: Role | undefined = (await guild.roles.fetch()).find(role => role.id === config.bot.announcement_role)
 | 
			
		||||
	if (!announcementRole) {
 | 
			
		||||
		logger.error(`Could not find announcement role! Aborting! Was looking for role with id: ${config.bot.announcement_role}`, { guildId, requestId })
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const usersWhoWantRole: User[] = (await reaction.users.fetch()).filter(user => !user.bot).map(user => user)
 | 
			
		||||
 | 
			
		||||
	const allUsers = (await guild.members.fetch())
 | 
			
		||||
 | 
			
		||||
	const usersWhoHaveRole: GuildMember[] = allUsers
 | 
			
		||||
		.filter(member => member.roles.cache
 | 
			
		||||
			.find(role => role.id === config.bot.announcement_role) !== undefined)
 | 
			
		||||
		.map(member => member)
 | 
			
		||||
 | 
			
		||||
	const usersWhoNeedRoleRevoked: GuildMember[] = usersWhoHaveRole
 | 
			
		||||
		.filter(userWhoHas => !usersWhoWantRole.map(wanter => wanter.id).includes(userWhoHas.id))
 | 
			
		||||
 | 
			
		||||
	const usersWhoDontHaveRole: GuildMember[] = allUsers
 | 
			
		||||
		.filter(member => member.roles.cache
 | 
			
		||||
			.find(role => role.id === config.bot.announcement_role) === undefined)
 | 
			
		||||
		.map(member => member)
 | 
			
		||||
 | 
			
		||||
	const usersWhoNeedRole: GuildMember[] = usersWhoDontHaveRole
 | 
			
		||||
		.filter(userWhoNeeds => usersWhoWantRole.map(wanter => wanter.id).includes(userWhoNeeds.id))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
	logger.debug(`Theses users will get the role removed: ${JSON.stringify(usersWhoNeedRoleRevoked)}`, { guildId, requestId })
 | 
			
		||||
	logger.debug(`Theses users will get the role added: ${JSON.stringify(usersWhoNeedRole)}`, { guildId, requestId })
 | 
			
		||||
 | 
			
		||||
	usersWhoNeedRoleRevoked.forEach(user => user.roles.remove(announcementRole))
 | 
			
		||||
	usersWhoNeedRole.forEach(user => user.roles.add(announcementRole))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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<TextChannel> = 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 => !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<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 (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<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)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function 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]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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<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 = 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<void> {
 | 
			
		||||
	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 })
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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())
 | 
			
		||||
	}
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -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())
 | 
			
		||||
	}
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										17
									
								
								server/constants.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								server/constants.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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": "🎫"
 | 
			
		||||
}
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -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<true> = 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))
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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())
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										46
									
								
								server/events/handleMessageReactionAdd.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								server/events/handleMessageReactionAdd.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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.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. ${reactedUponMessage.id}`)
 | 
			
		||||
			return client.roleController.addMediaRoleToUser(user, messageReaction.message.guild, requestId)
 | 
			
		||||
		}
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										33
									
								
								server/events/handleMessageReactionRemove.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								server/events/handleMessageReactionRemove.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,33 @@
 | 
			
		||||
 | 
			
		||||
import { Message, MessageReaction, User } from "discord.js";
 | 
			
		||||
import { logger, newRequestId, noGuildId } from "../logger";
 | 
			
		||||
import { Emoji } from "../constants";
 | 
			
		||||
import { client } from "../..";
 | 
			
		||||
import { isInitialAnnouncement } from "../helper/messageIdentifiers";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export const name = 'messageReactionRemove'
 | 
			
		||||
 | 
			
		||||
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 messageReactionRemove on non-guild message.`, { requestId })
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	logger.info(`Got reaction on message`, { requestId, guildId })
 | 
			
		||||
 | 
			
		||||
	logger.info(`emoji: ${messageReaction.emoji.toString()}`)
 | 
			
		||||
	if (isInitialAnnouncement(reactedUponMessage)) {
 | 
			
		||||
		if (messageReaction.emoji.toString() === Emoji.ticket) {
 | 
			
		||||
			logger.info(`User: ${user.id}, ${user.username} has removed a ticket reaction. Starting role management`, { requestId, guildId })
 | 
			
		||||
			return client.roleController.removeMediaRoleFromUser(user, messageReaction.message.guild, requestId)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -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`)
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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<Date>, 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}`
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								server/helper/messageIdentifiers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								server/helper/messageIdentifiers.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
import { Message } from "discord.js";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// branded types to differentiate objects of identical Type but different contents
 | 
			
		||||
export type VoteEndMessage = Message<true> & { readonly __brand: 'voteend' }
 | 
			
		||||
export type AnnouncementMessage = Message<true> & { readonly __brand: 'announcement' }
 | 
			
		||||
export type VoteMessage = Message<true> & { 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]")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										83
									
								
								server/helper/role.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								server/helper/role.controller.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,83 @@
 | 
			
		||||
import { Guild, MessageReaction, Role, User } from "discord.js";
 | 
			
		||||
import { GuildMember } from "discord.js";
 | 
			
		||||
import { logger } from "../logger";
 | 
			
		||||
import { config } from "../configuration";
 | 
			
		||||
import { Maybe } from "../interfaces";
 | 
			
		||||
 | 
			
		||||
export default class RoleController {
 | 
			
		||||
 | 
			
		||||
	constructor() { }
 | 
			
		||||
	private getAnnounceRoleIdForGuild(guildId: string): string {
 | 
			
		||||
		const role = config.bot.announcement_role
 | 
			
		||||
		if (!role) throw new Error(`No announcementRole defined for guild ${guildId}`)
 | 
			
		||||
		return role
 | 
			
		||||
	}
 | 
			
		||||
	public async addRoleToUser(member: GuildMember, role: Role, guildId: string, requestId: string) {
 | 
			
		||||
		logger.info(`Adding Role ${role.id} to user ${member.id}|${member.user.username}`, { requestId, guildId })
 | 
			
		||||
		return await member.roles.add(role)
 | 
			
		||||
	}
 | 
			
		||||
	private async removeRoleFromUser(member: GuildMember, role: Role, guildId: string, requestId: string) {
 | 
			
		||||
		logger.info(`Removing Role ${role.id} from user ${member.id}|${member.user.username}`, { requestId, guildId })
 | 
			
		||||
		return await member.roles.remove(role)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async addMediaRoleToUser(user: User, guild: Guild, requestId: string) {
 | 
			
		||||
		const roleToAdd = await this.getAnnouncementRoleForGuild(guild, requestId)
 | 
			
		||||
		if (!roleToAdd) throw new Error(`No announcementRole found to add to user`)
 | 
			
		||||
		const guildMember = await guild.members.fetch(user)
 | 
			
		||||
		return this.addRoleToUser(guildMember, roleToAdd, guild.id, requestId)
 | 
			
		||||
	}
 | 
			
		||||
	public async removeMediaRoleFromUser(user: User, guild: Guild, requestId: string) {
 | 
			
		||||
		const roleToRemove = await this.getAnnouncementRoleForGuild(guild, requestId)
 | 
			
		||||
		if (!roleToRemove) throw new Error(`No announcementRole found to remove from user`)
 | 
			
		||||
		const guildMember = await guild.members.fetch(user)
 | 
			
		||||
		return this.removeRoleFromUser(guildMember, roleToRemove, guild.id, requestId)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async getAnnouncementRoleForGuild(guild: Guild, requestId: string): Promise<Role> {
 | 
			
		||||
		const mediaRole = this.getAnnounceRoleIdForGuild(guild.id)
 | 
			
		||||
		const announcement_role = await guild.roles.fetch()
 | 
			
		||||
			.then(fetchedRoles => fetchedRoles.find(role => role.id === mediaRole))
 | 
			
		||||
			.catch(error => {
 | 
			
		||||
				logger.error(`Could not find announcement_role with id ${config.bot.announcement_role}. Error: ${error}`, { requestId, guildId: guild.id })
 | 
			
		||||
				throw error
 | 
			
		||||
			})
 | 
			
		||||
		if (!announcement_role) throw new Error(`Could not find announcement_role with id ${config.bot.announcement_role}.`)
 | 
			
		||||
		return announcement_role
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public async assignAnnouncementRolesFromReaction(guild: Guild, reaction: MessageReaction, requestId: string) {
 | 
			
		||||
		const guildId = guild.id
 | 
			
		||||
		logger.info("Managing roles", { guildId, requestId })
 | 
			
		||||
 | 
			
		||||
		const announcementRole = await this.getAnnouncementRoleForGuild(guild, requestId)
 | 
			
		||||
 | 
			
		||||
		const usersWhoWantRole: User[] = (await reaction.users.fetch()).filter(user => !user.bot).map(user => user)
 | 
			
		||||
 | 
			
		||||
		const allUsers = await guild.members.fetch()
 | 
			
		||||
 | 
			
		||||
		const usersWhoHaveRole: GuildMember[] = allUsers
 | 
			
		||||
			.filter(member => member.roles.cache
 | 
			
		||||
				.find(role => role.id === announcementRole.id) !== undefined)
 | 
			
		||||
			.map(member => member)
 | 
			
		||||
 | 
			
		||||
		const usersWhoNeedRoleRevoked: GuildMember[] = usersWhoHaveRole
 | 
			
		||||
			.filter(userWhoHas => !usersWhoWantRole.map(wanter => wanter.id).includes(userWhoHas.id))
 | 
			
		||||
 | 
			
		||||
		const usersWhoDontHaveRole: GuildMember[] = allUsers
 | 
			
		||||
			.filter(member => member.roles.cache
 | 
			
		||||
				.find(role => role.id === announcementRole.id) === undefined)
 | 
			
		||||
			.map(member => member)
 | 
			
		||||
 | 
			
		||||
		const usersWhoNeedRole: GuildMember[] = usersWhoDontHaveRole
 | 
			
		||||
			.filter(userWhoNeeds => usersWhoWantRole.map(wanter => wanter.id).includes(userWhoNeeds.id))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		logger.debug(`Theses users will get the role removed: ${JSON.stringify(usersWhoNeedRoleRevoked)}`, { guildId, requestId })
 | 
			
		||||
		logger.debug(`Theses users will get the role added: ${JSON.stringify(usersWhoNeedRole)}`, { guildId, requestId })
 | 
			
		||||
 | 
			
		||||
		usersWhoNeedRoleRevoked.forEach(user => this.removeRoleFromUser(user, announcementRole, guild.id, requestId))
 | 
			
		||||
		usersWhoNeedRole.forEach(user => this.addRoleToUser(user, announcementRole, guild.id, requestId))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
@@ -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<Collection<string, GuildMember>> {
 | 
			
		||||
	const emptyResponse = new Collection<string, GuildMember>
 | 
			
		||||
	const guildRole: Maybe<Role> = guild.roles.resolve(roleId)
 | 
			
		||||
	if (!guildRole) return emptyResponse
 | 
			
		||||
	return guildRole.members
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function getGuildSpecificTriggerRoleId(): Collection<string, PermissionLevel> {
 | 
			
		||||
	const outVal = new Collection<string, PermissionLevel>()
 | 
			
		||||
	outVal.set(config.bot.watcher_role, "VIEWER")
 | 
			
		||||
 
 | 
			
		||||
@@ -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<void> {
 | 
			
		||||
	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()
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										361
									
								
								server/helper/vote.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										361
									
								
								server/helper/vote.controller.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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, 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<Message<boolean>> {
 | 
			
		||||
		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<Maybe<GuildScheduledEvent>> {
 | 
			
		||||
		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<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`)
 | 
			
		||||
 | 
			
		||||
		const event: Maybe<GuildScheduledEvent> = 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 <VoteMessageInfo>{ 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<true> = 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<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]
 | 
			
		||||
 | 
			
		||||
		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<Vote[]> {
 | 
			
		||||
		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<Maybe<GuildScheduledEvent>> {
 | 
			
		||||
		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<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 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<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 })
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@@ -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> = T | undefined | null
 | 
			
		||||
export interface Player {
 | 
			
		||||
@@ -39,3 +39,10 @@ export interface JellyfinConfig {
 | 
			
		||||
	collectionUser: string
 | 
			
		||||
}
 | 
			
		||||
export type PermissionLevel = "VIEWER" | "ADMIN" | "TEMPORARY"
 | 
			
		||||
export interface prepareVoteMessageInput {
 | 
			
		||||
	movies: string[],
 | 
			
		||||
	startDate: Date,
 | 
			
		||||
	event: GuildScheduledEvent,
 | 
			
		||||
	announcementChannel: TextChannel,
 | 
			
		||||
	pinAfterSending: boolean,
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,12 @@
 | 
			
		||||
import { createLogger, format, transports } from "winston"
 | 
			
		||||
import { config } from "./configuration"
 | 
			
		||||
import { v4 } from "uuid"
 | 
			
		||||
export function newRequestId() { return v4() }
 | 
			
		||||
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(
 | 
			
		||||
@@ -13,7 +16,8 @@ const logFormat = format.combine(
 | 
			
		||||
 | 
			
		||||
const consoleTransports = [
 | 
			
		||||
	new transports.Console({
 | 
			
		||||
		format: logFormat
 | 
			
		||||
		format: logFormat,
 | 
			
		||||
		silent: process.env.NODE_ENV === 'testing'
 | 
			
		||||
	})
 | 
			
		||||
]
 | 
			
		||||
export const logger = createLogger({
 | 
			
		||||
 
 | 
			
		||||
@@ -2,13 +2,15 @@ import { ApplicationCommandDataResolvable, Client, ClientOptions, Collection, Gu
 | 
			
		||||
import fs from 'fs';
 | 
			
		||||
import { ScheduledTask, schedule } from "node-cron";
 | 
			
		||||
import { v4 as uuid } from 'uuid';
 | 
			
		||||
import { manageAnnouncementRoles } from "../commands/announce";
 | 
			
		||||
import { config } from "../configuration";
 | 
			
		||||
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 "../..";
 | 
			
		||||
import RoleController from "../helper/role.controller";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -16,13 +18,15 @@ export class ExtendedClient extends Client {
 | 
			
		||||
	private eventFilePath = `${__dirname}/../events`
 | 
			
		||||
	private commandFilePath = `${__dirname}/../commands`
 | 
			
		||||
	private jellyfin: JellyfinHandler
 | 
			
		||||
	public voteController: VoteController = new VoteController(this, yavinJellyfinHandler)
 | 
			
		||||
	public roleController: RoleController = new RoleController()
 | 
			
		||||
	public commands: Collection<string, CommandType> = new Collection()
 | 
			
		||||
	private announcementChannels: Collection<string, TextChannel> = new Collection() //guildId to TextChannel
 | 
			
		||||
	private announcementRoleHandlerTask: Collection<string, ScheduledTask> = new Collection() //one task per guild
 | 
			
		||||
	private pollCloseBackgroundTasks: Collection<string, ScheduledTask> = 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 +78,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 +86,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<string, TextChannel>} channels - All channels which should be fecthed for reactionTracking	
 | 
			
		||||
		*/
 | 
			
		||||
	private async fetchAnnouncementChannelMessage(channels: Collection<string, TextChannel>): Promise<void> {
 | 
			
		||||
		channels.each(async ch => {
 | 
			
		||||
			ch.messages.fetch()
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
	private async cacheAnnouncementServer(guilds: Collection<Snowflake, Guild>) {
 | 
			
		||||
		for (const guild of guilds.values()) {
 | 
			
		||||
			const channels: TextChannel[] = <TextChannel[]>(await guild.channels.fetch())
 | 
			
		||||
@@ -136,7 +156,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 })
 | 
			
		||||
@@ -153,10 +173,10 @@ export class ExtendedClient extends Client {
 | 
			
		||||
				}
 | 
			
		||||
				//logger.debug(`Message: ${JSON.stringify(message, null, 2)}`, { guildId: guild.id, requestId })
 | 
			
		||||
 | 
			
		||||
				const reactions = message.reactions.resolve("🎫")
 | 
			
		||||
				const ticketReaction = message.reactions.resolve("🎫")
 | 
			
		||||
				//logger.debug(`reactions: ${JSON.stringify(reactions, null, 2)}`, { guildId: guild.id, requestId })
 | 
			
		||||
				if (reactions) {
 | 
			
		||||
					manageAnnouncementRoles(message.guild, reactions, requestId)
 | 
			
		||||
				if (ticketReaction) {
 | 
			
		||||
					this.roleController.assignAnnouncementRolesFromReaction(message.guild, ticketReaction, requestId)
 | 
			
		||||
				} else {
 | 
			
		||||
					logger.error("Did not get reactions! Aborting!", { guildId: guild.id, requestId })
 | 
			
		||||
				}
 | 
			
		||||
@@ -175,7 +195,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])))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										81
									
								
								tests/discord/noneofthat.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								tests/discord/noneofthat.test.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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 = <ExtendedClient><unknown>{
 | 
			
		||||
		user: {
 | 
			
		||||
			id: 'mockId'
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	const mockEvent: GuildScheduledEvent = <GuildScheduledEvent><unknown>{
 | 
			
		||||
		scheduledStartAt: testEventDate,
 | 
			
		||||
		id: testEventId,
 | 
			
		||||
		guild: testGuildId
 | 
			
		||||
	}
 | 
			
		||||
	const mockJellyfinHandler: JellyfinHandler = <JellyfinHandler><unknown>{
 | 
			
		||||
		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 = <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 = <Message><unknown>{
 | 
			
		||||
			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))
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										192
									
								
								tests/discord/votes.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										192
									
								
								tests/discord/votes.test.ts
									
									
									
									
									
										Normal file
									
								
							@@ -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(<ExtendedClient>{}, <JellyfinHandler>{})
 | 
			
		||||
	const mockEvent: GuildScheduledEvent = <GuildScheduledEvent><unknown>{
 | 
			
		||||
		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 = <VoteMessage><unknown>{
 | 
			
		||||
		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(<ExtendedClient>{}, <JellyfinHandler>{})
 | 
			
		||||
	const mockEvent: GuildScheduledEvent = <GuildScheduledEvent><unknown>{
 | 
			
		||||
		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 = <JellyfinHandler><unknown>{
 | 
			
		||||
		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 = <ExtendedClient><unknown>{
 | 
			
		||||
		user: {
 | 
			
		||||
			id: 'mockId'
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	const voteController = new VoteController(mockClient, mockJellyfinHandler)
 | 
			
		||||
	const mockEvent: GuildScheduledEvent = <GuildScheduledEvent><unknown>{
 | 
			
		||||
		scheduledStartAt: testEventDate,
 | 
			
		||||
		id: testEventId,
 | 
			
		||||
		guild: testGuildId
 | 
			
		||||
	}
 | 
			
		||||
	const mockMessageContent = voteController.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId")
 | 
			
		||||
	const reactedUponMessage: VoteMessage = <VoteMessage><unknown>{
 | 
			
		||||
		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 = <MessageReaction><unknown>{
 | 
			
		||||
		message: reactedUponMessage
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	mockClient.getAnnouncementChannelForGuild = jest.fn().mockReturnValue({
 | 
			
		||||
		messages: {
 | 
			
		||||
			fetch: jest.fn().mockReturnValue([
 | 
			
		||||
				reactedUponMessage
 | 
			
		||||
			])
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	const res = voteController.handleNoneOfThatVote(messageReaction, reactedUponMessage, 'requestId', 'guildId')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
})
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
import { GuildScheduledEvent } from "discord.js"
 | 
			
		||||
import { createDateStringFromEvent } from "../../server/helper/dateHelper"
 | 
			
		||||
import MockDate from 'mockdate'
 | 
			
		||||
 | 
			
		||||
@@ -6,11 +5,11 @@ beforeAll(() => {
 | 
			
		||||
	MockDate.set('01-01-2023')
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function getTestDate(date: string): GuildScheduledEvent {
 | 
			
		||||
  return <GuildScheduledEvent>{ 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-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')
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -1,61 +1,44 @@
 | 
			
		||||
{
 | 
			
		||||
	"extends":"@tsconfig/recommended/tsconfig.json",
 | 
			
		||||
	"exclude":["node_modules"],
 | 
			
		||||
	"extends": "@tsconfig/recommended/tsconfig.json",
 | 
			
		||||
	"exclude": [
 | 
			
		||||
		"node_modules"
 | 
			
		||||
	],
 | 
			
		||||
	"compilerOptions": {
 | 
			
		||||
		/* Basic Options */
 | 
			
		||||
		"target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */,
 | 
			
		||||
		"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
 | 
			
		||||
		"resolveJsonModule": true,
 | 
			
		||||
    // "lib": [],                             /* Specify library files to be included in the compilation. */
 | 
			
		||||
    // "allowJs": true,                       /* Allow javascript files to be compiled. */
 | 
			
		||||
    // "checkJs": true,                       /* Report errors in .js files. */
 | 
			
		||||
    // "jsx": "preserve",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
 | 
			
		||||
    // "declaration": true,                   /* Generates corresponding '.d.ts' file. */
 | 
			
		||||
    // "declarationMap": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */
 | 
			
		||||
    // "sourceMap": true,                     /* Generates corresponding '.map' file. */
 | 
			
		||||
    // "outFile": "./",                       /* Concatenate and emit output to single file. */
 | 
			
		||||
		"outDir": "./build" /* Redirect output structure to the directory. */,
 | 
			
		||||
		// "rootDir": "./",                       /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
 | 
			
		||||
		// "composite": true,                     /* Enable project compilation */
 | 
			
		||||
    // "removeComments": true,                /* Do not emit comments to output. */
 | 
			
		||||
    // "noEmit": true,                        /* Do not emit outputs. */
 | 
			
		||||
    // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */
 | 
			
		||||
    // "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
 | 
			
		||||
    // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
 | 
			
		||||
 | 
			
		||||
		"removeComments": true,                /* Do not emit comments to output. */
 | 
			
		||||
		/* Strict Type-Checking Options */
 | 
			
		||||
		"strict": true /* Enable all strict type-checking options. */,
 | 
			
		||||
    // "noImplicitAny": true,                 /* Raise error on expressions and declarations with an implied 'any' type. */
 | 
			
		||||
    // "strictNullChecks": true,              /* Enable strict null checks. */
 | 
			
		||||
    // "strictFunctionTypes": true,           /* Enable strict checking of function types. */
 | 
			
		||||
		"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
 | 
			
		||||
		"strictNullChecks": true, /* Enable strict null checks. */
 | 
			
		||||
		"strictFunctionTypes": true, /* Enable strict checking of function types. */
 | 
			
		||||
		// "strictBindCallApply": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
 | 
			
		||||
		// "strictPropertyInitialization": true,  /* Enable strict checking of property initialization in classes. */
 | 
			
		||||
		// "noImplicitThis": true,                /* Raise error on 'this' expressions with an implied 'any' type. */
 | 
			
		||||
    // "alwaysStrict": true,                  /* Parse in strict mode and emit "use strict" for each source file. */
 | 
			
		||||
 | 
			
		||||
		"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
 | 
			
		||||
		//"noUncheckedIndexedAccess": true,
 | 
			
		||||
		/* Additional Checks */
 | 
			
		||||
    // "noUnusedLocals": true,                /* Report errors on unused locals. */
 | 
			
		||||
		//"noUnusedLocals": true,                /* Report errors on unused locals. */
 | 
			
		||||
		// "noUnusedParameters": true,            /* Report errors on unused parameters. */
 | 
			
		||||
		// "noImplicitReturns": true,             /* Report error when not all code paths in function return a value. */
 | 
			
		||||
		// "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */
 | 
			
		||||
 | 
			
		||||
		/* Module Resolution Options */
 | 
			
		||||
		"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
 | 
			
		||||
		// "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */
 | 
			
		||||
		// "paths": {},                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
 | 
			
		||||
		// "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */
 | 
			
		||||
    // "typeRoots": [],                       /* List of folders to include type definitions from. */
 | 
			
		||||
    // "types": [],                           /* Type declaration files to be included in compilation. */
 | 
			
		||||
		"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
 | 
			
		||||
		"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
 | 
			
		||||
		// "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */
 | 
			
		||||
 | 
			
		||||
		/* Source Map Options */
 | 
			
		||||
		// "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */
 | 
			
		||||
		// "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */
 | 
			
		||||
		"inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */
 | 
			
		||||
    // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
 | 
			
		||||
 | 
			
		||||
		/* Experimental Options */
 | 
			
		||||
		// "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
 | 
			
		||||
		// "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user