Compare commits
	
		
			74 Commits
		
	
	
		
			690ba697b6
			...
			1bfcaa95f9
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1bfcaa95f9 | |||
| fb4ab59dc6 | |||
| 6d40930dc1 | |||
| 4e9fe587b0 | |||
| 03b6a30ffa | |||
| 7d794a8001 | |||
| 8df180898e | |||
| 976175242b | |||
| 68546b0b50 | |||
| 1348abbd48 | |||
| fce9091114 | |||
| 081f3c6201 | |||
| ca99987a20 | |||
| fc64728a78 | |||
| 20da25f2bf | |||
| a455fd8ff7 | |||
| 119343c916 | |||
| 296a490e93 | |||
| 66507cb08f | |||
| 4600820889 | |||
| 4a3e8809be | |||
| 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 | 
| @ -6,6 +6,7 @@ import { Maybe } from '../interfaces' | |||||||
| import { logger } from '../logger' | import { logger } from '../logger' | ||||||
| import { Command } from '../structures/command' | import { Command } from '../structures/command' | ||||||
| import { RunOptions } from '../types/commandTypes' | import { RunOptions } from '../types/commandTypes' | ||||||
|  | import { isInitialAnnouncement } from '../helper/messageIdentifiers' | ||||||
|  |  | ||||||
| export default new Command({ | export default new Command({ | ||||||
| 	name: 'announce', | 	name: 'announce', | ||||||
| @ -61,7 +62,7 @@ async function sendInitialAnnouncement(guildId: string, requestId: string): Prom | |||||||
| 		return | 		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(async (message) => await message.unpin()) | ||||||
| 	currentPinnedAnnouncementMessages.forEach(message => message.delete()) | 	currentPinnedAnnouncementMessages.forEach(message => message.delete()) | ||||||
|  |  | ||||||
|  | |||||||
| @ -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 { v4 as uuid } from 'uuid' | ||||||
| import { client } from '../..' | import { client } from '../..' | ||||||
| import { config } from '../configuration' |  | ||||||
| import { Emotes } from '../events/autoCreateVoteByWPEvent' |  | ||||||
| import { Maybe } from '../interfaces' |  | ||||||
| import { logger } from '../logger' | import { logger } from '../logger' | ||||||
| import { Command } from '../structures/command' | import { Command } from '../structures/command' | ||||||
| import { RunOptions } from '../types/commandTypes' | import { RunOptions } from '../types/commandTypes' | ||||||
| @ -25,160 +20,6 @@ export default new Command({ | |||||||
| 		logger.info("Got command for closing poll!", { guildId, requestId }) | 		logger.info("Got command for closing poll!", { guildId, requestId }) | ||||||
|  |  | ||||||
| 		command.followUp("Alles klar, beende die Umfrage :)") | 		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 { ApplicationCommandOptionType } from 'discord.js' | ||||||
| import { Command } from '../structures/command' | import { Command } from '../structures/command' | ||||||
| import { RunOptions } from '../types/commandTypes' | import { RunOptions } from '../types/commandTypes' | ||||||
|  | import { logger } from '../logger' | ||||||
| export default new Command({ | export default new Command({ | ||||||
| 	name: 'echo', | 	name: 'echo', | ||||||
| 	description: 'Echoes a text', | 	description: 'Echoes a text', | ||||||
| @ -13,7 +14,7 @@ export default new Command({ | |||||||
| 		} | 		} | ||||||
| 	], | 	], | ||||||
| 	run: async (interaction: RunOptions) => { | 	run: async (interaction: RunOptions) => { | ||||||
| 		console.log('echo called') | 		logger.info('echo called') | ||||||
| 		interaction.interaction.reply(interaction.toString()) | 		interaction.interaction.reply(interaction.toString()) | ||||||
| 	} | 	} | ||||||
| }) | }) | ||||||
|  | |||||||
| @ -2,15 +2,16 @@ import { v4 as uuid } from 'uuid' | |||||||
| import { jellyfinHandler } from "../.." | import { jellyfinHandler } from "../.." | ||||||
| import { Command } from '../structures/command' | import { Command } from '../structures/command' | ||||||
| import { RunOptions } from '../types/commandTypes' | import { RunOptions } from '../types/commandTypes' | ||||||
|  | import { logger } from '../logger' | ||||||
|  |  | ||||||
| export default new Command({ | export default new Command({ | ||||||
| 	name: 'passwort_reset', | 	name: 'passwort_reset', | ||||||
| 	description: 'Ich vergebe dir ein neues Passwort und schicke es dir per DM zu. Kostet auch nix! Versprochen! 😉', | 	description: 'Ich vergebe dir ein neues Passwort und schicke es dir per DM zu. Kostet auch nix! Versprochen! 😉', | ||||||
| 	options: [], | 	options: [], | ||||||
| 	run: async (interaction: RunOptions) => { | 	run: async (interaction: RunOptions) => { | ||||||
| 		console.log('PasswortReset called') | 		logger.info('PasswortReset called') | ||||||
| 		interaction.interaction.followUp('Yo, ich schick dir eins!') | 		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()) | 		jellyfinHandler.resetUserPasswort(interaction.interaction.member, uuid()) | ||||||
| 	} | 	} | ||||||
| }) | }) | ||||||
|  | |||||||
| @ -30,6 +30,8 @@ export interface Config { | |||||||
| 		yavin_jellyfin_url: string | 		yavin_jellyfin_url: string | ||||||
| 		yavin_jellyfin_token: string | 		yavin_jellyfin_token: string | ||||||
| 		yavin_jellyfin_collection_user: string | 		yavin_jellyfin_collection_user: string | ||||||
|  | 		random_movie_count: number | ||||||
|  | 		reroll_retains_top_picks: boolean | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| export const config: Config = { | export const config: Config = { | ||||||
| @ -59,16 +61,18 @@ export const config: Config = { | |||||||
| 		client_id: process.env.CLIENT_ID ?? "", | 		client_id: process.env.CLIENT_ID ?? "", | ||||||
| 		jellfin_token: process.env.JELLYFIN_TOKEN ?? "", | 		jellfin_token: process.env.JELLYFIN_TOKEN ?? "", | ||||||
| 		jellyfin_url: process.env.JELLYFIN_URL ?? "", | 		jellyfin_url: process.env.JELLYFIN_URL ?? "", | ||||||
| 		workaround_token: process.env.TOKEN ?? "", | 		workaround_token: process.env.TOKEN ?? "TOKEN", | ||||||
| 		watcher_role: process.env.WATCHER_ROLE ?? "", | 		watcher_role: process.env.WATCHER_ROLE ?? "WATCHER_ROLE", | ||||||
| 		jf_admin_role: process.env.ADMIN_ROLE ?? "", | 		jf_admin_role: process.env.ADMIN_ROLE ?? "ADMIN_ROLE", | ||||||
| 		announcement_role: process.env.WATCHPARTY_ANNOUNCEMENT_ROLE ?? "", | 		announcement_role: process.env.WATCHPARTY_ANNOUNCEMENT_ROLE ?? "ANNOUNCE_ROLE", | ||||||
| 		announcement_channel_id: process.env.CHANNEL_ID ?? "", | 		announcement_channel_id: process.env.CHANNEL_ID ?? "ANNOUNCE_CHANNEL", | ||||||
| 		jf_collection_id: process.env.JELLYFIN_COLLECTION_ID ?? "", | 		jf_collection_id: process.env.JELLYFIN_COLLECTION_ID ?? "", | ||||||
| 		yavin_collection_id: process.env.YAVIN_COLLECTION_ID ?? "", | 		yavin_collection_id: process.env.YAVIN_COLLECTION_ID ?? "", | ||||||
| 		yavin_jellyfin_url: process.env.YAVIN_JELLYFIN_URL ?? "", | 		yavin_jellyfin_url: process.env.YAVIN_JELLYFIN_URL ?? "", | ||||||
| 		yavin_jellyfin_token: process.env.YAVIN_TOKEN ?? "", | 		yavin_jellyfin_token: process.env.YAVIN_TOKEN ?? "", | ||||||
| 		yavin_jellyfin_collection_user: process.env.YAVIN_COLLECTION_USER ?? "", | 		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 | 				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) | 			channel.send(message) | ||||||
| 		} else { | 		} else { | ||||||
|  | |||||||
| @ -1,20 +1,11 @@ | |||||||
| import { GuildScheduledEvent, Message, MessageCreateOptions, TextChannel } from "discord.js"; | import { GuildScheduledEvent, TextChannel } from "discord.js"; | ||||||
| import { ScheduledTask } from "node-cron"; |  | ||||||
| import { v4 as uuid } from "uuid"; | import { v4 as uuid } from "uuid"; | ||||||
| import { client, yavinJellyfinHandler } from "../.."; | import { client, yavinJellyfinHandler } from "../.."; | ||||||
| import { config } from "../configuration"; |  | ||||||
| import { createDateStringFromEvent } from "../helper/dateHelper"; |  | ||||||
| import { Maybe } from "../interfaces"; | import { Maybe } from "../interfaces"; | ||||||
| import { logger } from "../logger"; | import { logger } from "../logger"; | ||||||
|  |  | ||||||
|  |  | ||||||
| export const name = 'guildScheduledEventCreate' | 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) { | export async function execute(event: GuildScheduledEvent) { | ||||||
| 	const requestId = uuid() | 	const requestId = uuid() | ||||||
|  |  | ||||||
| @ -33,31 +24,21 @@ export async function execute(event: GuildScheduledEvent) { | |||||||
| 			return | 			return | ||||||
| 		} | 		} | ||||||
| 		logger.debug(`Found channel ${JSON.stringify(announcementChannel, null, 2)}`, { guildId: event.guildId, requestId }) | 		logger.debug(`Found channel ${JSON.stringify(announcementChannel, null, 2)}`, { guildId: event.guildId, requestId }) | ||||||
|  |  | ||||||
| 		if (!event.scheduledStartAt) { | 		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 | 			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++) { | 		logger.debug(JSON.stringify(sentMessage)) | ||||||
| 			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 |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ import { Collection, GuildScheduledEvent, GuildScheduledEventStatus, Message } f | |||||||
| import { v4 as uuid } from "uuid"; | import { v4 as uuid } from "uuid"; | ||||||
| import { client } from "../.."; | import { client } from "../.."; | ||||||
| import { logger } from "../logger"; | import { logger } from "../logger"; | ||||||
|  | import { isInitialAnnouncement } from "../helper/messageIdentifiers"; | ||||||
|  |  | ||||||
|  |  | ||||||
| export const name = 'guildScheduledEventUpdate' | export const name = 'guildScheduledEventUpdate' | ||||||
| @ -25,7 +26,7 @@ export async function execute(oldEvent: GuildScheduledEvent, newEvent: GuildSche | |||||||
|  |  | ||||||
| 			const events = await newEvent.guild.scheduledEvents.fetch() | 			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) | 			const announcementsWithoutEvent = filterAnnouncementsByPendingWPs(wpAnnouncements, events) | ||||||
| 			logger.info(`Deleting ${announcementsWithoutEvent.length} announcements.`, { guildId, requestId }) | 			logger.info(`Deleting ${announcementsWithoutEvent.length} announcements.`, { guildId, requestId }) | ||||||
| 			announcementsWithoutEvent.forEach(message => message.delete()) | 			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.debug(`reactedUponMessage payload: ${JSON.stringify(reactedUponMessage)}`) | ||||||
|  |  | ||||||
|  | 	logger.info(`emoji: ${messageReaction.emoji.toString()}`) | ||||||
|  |  | ||||||
|  | 	if (!Object.values(ValidVoteEmotes).includes(messageReaction.emoji.toString()) && messageReaction.emoji.toString() !== NONE_OF_THAT) { | ||||||
|  | 		logger.info(`${messageReaction.emoji.toString()} currently not handled`) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	logger.info(`Found a match for ${messageReaction.emoji.toString()}`) | ||||||
|  | 	if (isVoteMessage(reactedUponMessage)) { | ||||||
|  | 		if (messageReaction.emoji.toString() === NONE_OF_THAT) { | ||||||
|  | 			logger.info(`Reaction is NONE_OF_THAT on a vote message. Handling`, { requestId, guildId }) | ||||||
|  | 			return client.voteController.handleNoneOfThatVote(messageReaction, reactedUponMessage, requestId, guildId) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	else if (isInitialAnnouncement(reactedUponMessage)) { | ||||||
|  | 		if (messageReaction.emoji.toString() === Emoji.ticket) { | ||||||
|  | 			logger.error(`Got a role emoji. Not implemented yet. ${reactedUponMessage.id}`) | ||||||
|  | 		} | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @ -1,6 +1,7 @@ | |||||||
| import { Message } from "discord.js" | import { Message } from "discord.js" | ||||||
|  | import { logger } from "../logger" | ||||||
|  |  | ||||||
| export const name = 'messageCreate' | export const name = 'messageCreate' | ||||||
| export function execute(message: Message) { | 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 { utcToZonedTime } from "date-fns-tz" | ||||||
| import { GuildScheduledEvent } from "discord.js"; |  | ||||||
| import { logger } from "../logger"; | import { logger } from "../logger"; | ||||||
| import de from "date-fns/locale/de"; | import de from "date-fns/locale/de"; | ||||||
|  | import { Maybe } from "../interfaces"; | ||||||
|  |  | ||||||
| export function createDateStringFromEvent(event: GuildScheduledEvent, requestId: string, guildId?: string): string { | export function createDateStringFromEvent(eventStartDate: Maybe<Date>, requestId: string, guildId?: string): string { | ||||||
| 	if (!event.scheduledStartAt) { | 	if (!eventStartDate) { | ||||||
| 		logger.error("Event has no start. Cannot create dateString.", { guildId, requestId }) | 		logger.error("Event has no start. Cannot create dateString.", { guildId, requestId }) | ||||||
| 		return `"habe keinen Startzeitpunkt ermitteln können"` | 		return `"habe keinen Startzeitpunkt ermitteln können"` | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	const timeZone = 'Europe/Berlin' | 	const timeZone = 'Europe/Berlin' | ||||||
| 	const zonedDateTime = utcToZonedTime(event.scheduledStartAt, timeZone) | 	const zonedDateTime = utcToZonedTime(eventStartDate, timeZone) | ||||||
| 	const time = format(zonedDateTime, "HH:mm", { locale: de }) | 	const time = format(zonedDateTime, "HH:mm", { locale: de }) | ||||||
|  |  | ||||||
| 	if (isToday(zonedDateTime)) { | 	if (isToday(zonedDateTime)) { | ||||||
| 		return `heute um ${time}` | 		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}` | 	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]") | ||||||
|  | } | ||||||
|  |  | ||||||
| @ -1,5 +1,5 @@ | |||||||
| import { Collection, GuildMember } from "discord.js" | import { Collection, Guild, GuildMember, Role } from "discord.js" | ||||||
| import { ChangedRoles, PermissionLevel } from "../interfaces" | import { ChangedRoles, Maybe, PermissionLevel } from "../interfaces" | ||||||
| import { logger } from "../logger" | import { logger } from "../logger" | ||||||
| import { config } from "../configuration" | import { config } from "../configuration" | ||||||
|  |  | ||||||
| @ -16,6 +16,13 @@ export function filterRolesFromMemberUpdate(oldMember: GuildMember, newMember: G | |||||||
| 	return { addedRoles, removedRoles } | 	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> { | export function getGuildSpecificTriggerRoleId(): Collection<string, PermissionLevel> { | ||||||
| 	const outVal = new Collection<string, PermissionLevel>() | 	const outVal = new Collection<string, PermissionLevel>() | ||||||
| 	outVal.set(config.bot.watcher_role, "VIEWER") | 	outVal.set(config.bot.watcher_role, "VIEWER") | ||||||
|  | |||||||
| @ -1,10 +1,11 @@ | |||||||
| import { CustomError, errorCodes } from "../interfaces" | import { CustomError, errorCodes } from "../interfaces" | ||||||
|  | import { logger } from "../logger" | ||||||
| import { ExtendedClient } from "../structures/client" | import { ExtendedClient } from "../structures/client" | ||||||
|  |  | ||||||
| export async function sendFailureDM(creatorMessage: string, client: ExtendedClient, creatorId?: string): Promise<void> { | 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) | 	if (!creatorId) throw new CustomError('No creator ID present', errorCodes.no_creator_id) | ||||||
| 	const creator = await client.users.fetch(creatorId) | 	const creator = await client.users.fetch(creatorId) | ||||||
| 	console.log(`Creator ${JSON.stringify(creator)}`) | 	logger.info(`Creator ${JSON.stringify(creator)}`) | ||||||
| 	if (creator) | 	if (creator) | ||||||
| 		if (!creator.dmChannel) | 		if (!creator.dmChannel) | ||||||
| 			await creator.createDM() | 			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, voteMessageInputInformation as prepareVoteMessageInput } from "../interfaces" | ||||||
|  | import format from "date-fns/format" | ||||||
|  | import toDate from "date-fns/toDate" | ||||||
|  | import differenceInDays from "date-fns/differenceInDays" | ||||||
|  | import addDays from "date-fns/addDays" | ||||||
|  | import isAfter from "date-fns/isAfter" | ||||||
|  | import { ExtendedClient } from "../structures/client" | ||||||
|  | import { JellyfinHandler } from "../jellyfin/handler" | ||||||
|  |  | ||||||
|  | export type Vote = { | ||||||
|  | 	emote: string, //todo habs nicht hinbekommen hier Emotes zu nutzen | ||||||
|  | 	count: number, | ||||||
|  | 	movie: string | ||||||
|  | } | ||||||
|  | export type VoteMessageInfo = { | ||||||
|  | 	votes: Vote[], | ||||||
|  | 	event: GuildScheduledEvent, | ||||||
|  | } | ||||||
|  | export default class VoteController { | ||||||
|  | 	private client: ExtendedClient | ||||||
|  | 	private yavinJellyfinHandler: JellyfinHandler | ||||||
|  |  | ||||||
|  | 	public constructor(_client: ExtendedClient, _yavin: JellyfinHandler) { | ||||||
|  | 		this.client = _client | ||||||
|  | 		this.yavinJellyfinHandler = _yavin | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async handleNoneOfThatVote(messageReaction: MessageReaction, reactedUponMessage: VoteMessage, requestId: string, guildId: string) { | ||||||
|  | 		if (!messageReaction.message.guild) return 'No guild' | ||||||
|  | 		const guild = messageReaction.message.guild | ||||||
|  | 		logger.debug(`${reactedUponMessage.id} is vote message`, { requestId, guildId }) | ||||||
|  |  | ||||||
|  | 		const watcherRoleMember = await getMembersWithRoleFromGuild(config.bot.announcement_role, messageReaction.message.guild) | ||||||
|  | 		logger.info("ROLE MEMBERS " + JSON.stringify(watcherRoleMember), { requestId, guildId }) | ||||||
|  |  | ||||||
|  | 		const watcherRoleMemberCount = watcherRoleMember.size | ||||||
|  | 		logger.info(`MEMBER COUNT: ${watcherRoleMemberCount}`, { requestId, guildId }) | ||||||
|  |  | ||||||
|  | 		const noneOfThatReactions = reactedUponMessage.reactions.cache.get(NONE_OF_THAT)?.users.cache.filter(x => x.id !== this.client.user?.id).size ?? 0 | ||||||
|  |  | ||||||
|  | 		const memberThreshold = (watcherRoleMemberCount / 2) | ||||||
|  | 		logger.info(`Reroll ${noneOfThatReactions} > ${memberThreshold} ?`, { requestId, guildId }) | ||||||
|  | 		if (noneOfThatReactions > memberThreshold) | ||||||
|  | 			logger.info(`No reroll`, { requestId, guildId }) | ||||||
|  | 		else { | ||||||
|  | 			logger.info('Starting poll reroll', { requestId, guildId }) | ||||||
|  | 			await this.handleReroll(reactedUponMessage, guild.id, requestId) | ||||||
|  | 			logger.info(`Finished handling NONE_OF_THAT vote`, { requestId, guildId }) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	private async removeMessage(message: Message): Promise<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 { Collection } from "@discordjs/collection" | ||||||
| import { Role } from "discord.js" | import { GuildScheduledEvent, Role, TextChannel } from "discord.js" | ||||||
|  |  | ||||||
| export type Maybe<T> = T | undefined | null | export type Maybe<T> = T | undefined | null | ||||||
| export interface Player { | export interface Player { | ||||||
| @ -39,3 +39,10 @@ export interface JellyfinConfig { | |||||||
| 	collectionUser: string | 	collectionUser: string | ||||||
| } | } | ||||||
| export type PermissionLevel = "VIEWER" | "ADMIN" | "TEMPORARY" | export type PermissionLevel = "VIEWER" | "ADMIN" | "TEMPORARY" | ||||||
|  | export interface voteMessageInputInformation { | ||||||
|  | 	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 { | export class ResponseError extends Error { | ||||||
| 	override name: "ResponseError" = "ResponseError"; | 	override name: "ResponseError" = "ResponseError"; | ||||||
| 	constructor(public response: Response, msg?: string) { | 	constructor(public response: Response, errorMessage?: string) { | ||||||
| 		super(msg); | 		super(errorMessage); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| export class FetchError extends Error { | export class FetchError extends Error { | ||||||
| 	override name: "FetchError" = "FetchError"; | 	override name: "FetchError" = "FetchError"; | ||||||
| 	constructor(public cause: Error, msg?: string) { | 	constructor(public cause: Error, errorMessage?: string) { | ||||||
| 		super(msg); | 		super(errorMessage); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| export class RequiredError extends Error { | export class RequiredError extends Error { | ||||||
| 	override name: "RequiredError" = "RequiredError"; | 	override name: "RequiredError" = "RequiredError"; | ||||||
| 	constructor(public field: string, msg?: string) { | 	constructor(public field: string, errorMessage?: string) { | ||||||
| 		super(msg); | 		super(errorMessage); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ export const noGuildId = 'NoGuildId' | |||||||
|  |  | ||||||
|  |  | ||||||
| const printFn = format.printf(({ guildId, level, message, errorCode, requestId, timestamp: logTimestamp }: { [k: string]: string }) => { | 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( | const logFormat = format.combine( | ||||||
|  | |||||||
| @ -8,7 +8,9 @@ import { Maybe } from "../interfaces"; | |||||||
| import { JellyfinHandler } from "../jellyfin/handler"; | import { JellyfinHandler } from "../jellyfin/handler"; | ||||||
| import { logger } from "../logger"; | import { logger } from "../logger"; | ||||||
| import { CommandType } from "../types/commandTypes"; | import { CommandType } from "../types/commandTypes"; | ||||||
| import { checkForPollsToClose } from "../commands/closepoll"; | import { isInitialAnnouncement } from "../helper/messageIdentifiers"; | ||||||
|  | import VoteController from "../helper/vote.controller"; | ||||||
|  | import { yavinJellyfinHandler } from "../.."; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -16,13 +18,14 @@ export class ExtendedClient extends Client { | |||||||
| 	private eventFilePath = `${__dirname}/../events` | 	private eventFilePath = `${__dirname}/../events` | ||||||
| 	private commandFilePath = `${__dirname}/../commands` | 	private commandFilePath = `${__dirname}/../commands` | ||||||
| 	private jellyfin: JellyfinHandler | 	private jellyfin: JellyfinHandler | ||||||
|  | 	public voteController: VoteController = new VoteController(this, yavinJellyfinHandler) | ||||||
| 	public commands: Collection<string, CommandType> = new Collection() | 	public commands: Collection<string, CommandType> = new Collection() | ||||||
| 	private announcementChannels: Collection<string, TextChannel> = new Collection() //guildId to TextChannel | 	private announcementChannels: Collection<string, TextChannel> = new Collection() //guildId to TextChannel | ||||||
| 	private announcementRoleHandlerTask: Collection<string, ScheduledTask> = new Collection() //one task per guild | 	private announcementRoleHandlerTask: Collection<string, ScheduledTask> = new Collection() //one task per guild | ||||||
| 	private pollCloseBackgroundTasks: Collection<string, ScheduledTask> = new Collection() | 	private pollCloseBackgroundTasks: Collection<string, ScheduledTask> = new Collection() | ||||||
| 	public constructor(jf: JellyfinHandler) { | 	public constructor(jf: JellyfinHandler) { | ||||||
| 		const intents: IntentsBitField = new IntentsBitField() | 		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 } | 		const options: ClientOptions = { intents } | ||||||
| 		super(options) | 		super(options) | ||||||
| 		this.jellyfin = jf | 		this.jellyfin = jf | ||||||
| @ -74,6 +77,7 @@ export class ExtendedClient extends Client { | |||||||
| 				this.registerCommands(slashCommands, guilds) | 				this.registerCommands(slashCommands, guilds) | ||||||
| 				this.cacheUsers(guilds) | 				this.cacheUsers(guilds) | ||||||
| 				await this.cacheAnnouncementServer(guilds) | 				await this.cacheAnnouncementServer(guilds) | ||||||
|  | 				this.fetchAnnouncementChannelMessage(this.announcementChannels) | ||||||
| 				this.startAnnouncementRoleBackgroundTask(guilds) | 				this.startAnnouncementRoleBackgroundTask(guilds) | ||||||
| 				this.startPollCloseBackgroundTasks() | 				this.startPollCloseBackgroundTasks() | ||||||
| 			}) | 			}) | ||||||
| @ -81,6 +85,21 @@ export class ExtendedClient extends Client { | |||||||
| 			logger.info(`Error refreshing slash commands: ${error}`) | 			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>) { | 	private async cacheAnnouncementServer(guilds: Collection<Snowflake, Guild>) { | ||||||
| 		for (const guild of guilds.values()) { | 		for (const guild of guilds.values()) { | ||||||
| 			const channels: TextChannel[] = <TextChannel[]>(await guild.channels.fetch()) | 			const channels: TextChannel[] = <TextChannel[]>(await guild.channels.fetch()) | ||||||
| @ -136,7 +155,7 @@ export class ExtendedClient extends Client { | |||||||
| 			} | 			} | ||||||
| 			this.announcementRoleHandlerTask.set(guild.id, schedule("*/10 * * * * *", async () => { | 			this.announcementRoleHandlerTask.set(guild.id, schedule("*/10 * * * * *", async () => { | ||||||
| 				const requestId = uuid() | 				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) { | 				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 }) | 					logger.error("More than one pinned announcement Messages found. Unable to know which one people react to. Please fix!", { guildId: guild.id, requestId }) | ||||||
| @ -175,7 +194,7 @@ export class ExtendedClient extends Client { | |||||||
|  |  | ||||||
| 	private async startPollCloseBackgroundTasks() { | 	private async startPollCloseBackgroundTasks() { | ||||||
| 		for (const guild of this.guilds.cache) { | 		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 { createDateStringFromEvent } from "../../server/helper/dateHelper" | ||||||
| import MockDate from 'mockdate' | import MockDate from 'mockdate' | ||||||
|  |  | ||||||
| @ -6,11 +5,11 @@ beforeAll(() => { | |||||||
| 	MockDate.set('01-01-2023') | 	MockDate.set('01-01-2023') | ||||||
| }) | }) | ||||||
|  |  | ||||||
| function getTestDate(date: string): GuildScheduledEvent { | function getTestDate(date: string): Date { | ||||||
|   return <GuildScheduledEvent>{ scheduledStartAt: new Date(date) } | 	return new Date(date) | ||||||
| } | } | ||||||
| test('createDateStringFromEvent - correct formatting', () => { | test('createDateStringFromEvent - correct formatting', () => { | ||||||
| 	expect(createDateStringFromEvent(getTestDate('01-01-2023 12:30'), "")).toEqual('heute um 12:30') | 	expect(createDateStringFromEvent(getTestDate('01-01-2023 12:30'), "")).toEqual('heute um 12:30') | ||||||
|   expect(createDateStringFromEvent(getTestDate('01-02-2023 12:30'), "")).toEqual('am Montag 02.01 um 12:30') | 	expect(createDateStringFromEvent(getTestDate('01-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-03-2023 12:30'), "")).toEqual('am Dienstag 03.01. um 12:30') | ||||||
| }) | }) | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user