Compare commits

...

5 Commits

Author SHA1 Message Date
1e912b20ef formatting for package.json
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m23s
Run unit tests / test (pull_request) Successful in 1m55s
2023-08-13 18:35:48 +02:00
ce4dc81f7d fix incorrect reroll behaviour
now correctly fetches old movies, filters already voted on movies, gets new movies, creates new poll message, deletes old message
2023-08-13 18:35:22 +02:00
b76df79d2a testcases 2023-08-13 18:33:45 +02:00
4e563d57fd fix else branch of memberthreshold 2023-08-13 18:33:45 +02:00
b6a1e06b03 update default movie env var 2023-08-13 18:14:16 +02:00
5 changed files with 289 additions and 77 deletions

View File

@ -1,52 +1,52 @@
{ {
"name": "node-jellyfin-discord-bot", "name": "node-jellyfin-discord-bot",
"version": "1.1.3", "version": "1.1.3",
"description": "A discord bot to sync jellyfin accounts with discord roles", "description": "A discord bot to sync jellyfin accounts with discord roles",
"main": "index.js", "main": "index.js",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@discordjs/rest": "^1.7.0", "@discordjs/rest": "^1.7.0",
"@tsconfig/recommended": "^1.0.2", "@tsconfig/recommended": "^1.0.2",
"@types/node": "^18.15.11", "@types/node": "^18.15.11",
"@types/node-cron": "^3.0.7", "@types/node-cron": "^3.0.7",
"@types/request": "^2.48.8", "@types/request": "^2.48.8",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"axios": "^1.3.5", "axios": "^1.3.5",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"date-fns-tz": "^2.0.0", "date-fns-tz": "^2.0.0",
"discord-api-types": "^0.37.38", "discord-api-types": "^0.37.38",
"discord.js": "^14.9.0", "discord.js": "^14.9.0",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"jellyfin-apiclient": "^1.10.0", "jellyfin-apiclient": "^1.10.0",
"node-cron": "^3.0.2", "node-cron": "^3.0.2",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.6",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"winston": "^3.8.2" "winston": "^3.8.2"
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"buildwatch": "tsc --watch", "buildwatch": "tsc --watch",
"clean": "rimraf build", "clean": "rimraf build",
"start": "node build/index.js", "start": "node build/index.js",
"debuggable": "node build/index.js --inspect-brk", "debuggable": "node build/index.js --inspect-brk",
"monitor": "nodemon build/index.js", "monitor": "nodemon build/index.js",
"lint": "eslint . --ext .ts", "lint": "eslint . --ext .ts",
"lint-fix": "eslint . --ext .ts --fix", "lint-fix": "eslint . --ext .ts --fix",
"test": "jest", "test": "jest --runInBand",
"test-watch": "jest --watch" "test-watch": "jest --watch"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@typescript-eslint/eslint-plugin": "^5.58.0", "@typescript-eslint/eslint-plugin": "^5.58.0",
"@typescript-eslint/parser": "^5.58.0", "@typescript-eslint/parser": "^5.58.0",
"eslint": "^8.38.0", "eslint": "^8.38.0",
"jest": "^29.5.0", "jest": "^29.5.0",
"jest-cli": "^29.5.0", "jest-cli": "^29.5.0",
"mockdate": "^3.0.5", "mockdate": "^3.0.5",
"nodemon": "^2.0.22", "nodemon": "^2.0.22",
"rimraf": "^5.0.0", "rimraf": "^5.0.0",
"ts-jest": "^29.1.0" "ts-jest": "^29.1.0"
} }
} }

View File

@ -71,6 +71,6 @@ export const config: Config = {
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 random_movie_count: parseInt(process.env.RANDOM_MOVIE_COUNT ?? "5") ?? 5
} }
} }

View File

@ -50,37 +50,60 @@ export default class VoteController {
logger.info(`Reroll ${noneOfThatReactions} > ${memberThreshold} ?`, { requestId, guildId }) logger.info(`Reroll ${noneOfThatReactions} > ${memberThreshold} ?`, { requestId, guildId })
if (noneOfThatReactions > memberThreshold) if (noneOfThatReactions > memberThreshold)
logger.info(`No reroll`, { requestId, guildId }) logger.info(`No reroll`, { requestId, guildId })
else else {
logger.info('Starting poll reroll', { requestId, guildId }) logger.info('Starting poll reroll', { requestId, guildId })
await this.handleReroll(reactedUponMessage, guild, guild.id, requestId) await this.handleReroll(reactedUponMessage, guild, guild.id, requestId)
logger.info(`Finished handling NONE_OF_THAT vote`, { requestId, guildId }) logger.info(`Finished handling NONE_OF_THAT vote`, { requestId, guildId })
}
} }
private async removeMessage(msg: Message): Promise<Message<boolean>> {
if (msg.pinned) {
await msg.unpin()
}
return await msg.delete()
}
public isAboveThreshold(vote: Vote): boolean {
const aboveThreshold = (vote.count - 1) >= 1
logger.debug(`${vote.movie} : ${vote.count} -> above: ${aboveThreshold}`)
return aboveThreshold
}
public async handleReroll(voteMessage: VoteMessage, guild: Guild, guildId: string, requestId: string) { public async handleReroll(voteMessage: VoteMessage, guild: Guild, guildId: string, requestId: string) {
//get movies that already had votes to give them a second chance //get movies that already had votes to give them a second chance
const voteInfo: VoteMessageInfo = await this.parseVoteInfoFromVoteMessage(voteMessage, requestId) const voteInfo: VoteMessageInfo = await this.parseVoteInfoFromVoteMessage(voteMessage, requestId)
const votedOnMovies = voteInfo.votes.filter(this.isAboveThreshold).filter(x => x.emote !== NONE_OF_THAT)
logger.info(`Found ${votedOnMovies.length} with votes`, { requestId, guildId })
// get movies from jellyfin to fill the remaining slots // get movies from jellyfin to fill the remaining slots
const newMovieCount: number = config.bot.random_movie_count - voteInfo.votes.filter(x => x.count > 2).length 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) const newMovies: string[] = await this.yavinJellyfinHandler.getRandomMovieNames(newMovieCount, guildId, requestId)
// merge // merge
const movies: string[] = newMovies.concat(voteInfo.votes.map(x => x.movie)) const movies: string[] = newMovies.concat(votedOnMovies.map(x => x.movie))
// create new message // create new message
await this.closePoll(guild, requestId)
const message = this.createVoteMessageText(guild.id, voteInfo.eventDate, movies, guildId, requestId) logger.info(`Creating new poll message with new movies: ${movies}`, { requestId, guildId })
const message = this.createVoteMessageText(voteInfo.eventId, voteInfo.eventDate, movies, guildId, requestId)
const announcementChannel = this.client.getAnnouncementChannelForGuild(guildId) const announcementChannel = this.client.getAnnouncementChannelForGuild(guildId)
if (!announcementChannel) { if (!announcementChannel) {
logger.error(`No announcementChannel found for ${guildId}, can't post poll`) logger.error(`No announcementChannel found for ${guildId}, can't post poll`)
return return
} }
try {
logger.info(`Trying to remove old vote Message`, { requestId, guildId })
this.removeMessage(voteMessage)
} catch (err) {
logger.error(`Error during removeMessage: ${err}`)
}
const sentMessage = await this.sendVoteMessage(message, movies.length, announcementChannel) const sentMessage = await this.sendVoteMessage(message, movies.length, announcementChannel)
sentMessage.pin() sentMessage.pin()
logger.info(`Sent and pinned new poll message`, { requestId, guildId })
} }
private async fetchEventStartDateByEventId(guild: Guild, eventId: string, requestId: string): Promise<Maybe<Date>> { private async fetchEventStartDateByEventId(guild: Guild, eventId: string, requestId: string): Promise<Maybe<Date>> {
const guildEvent: GuildScheduledEvent = await guild.scheduledEvents.fetch(eventId) const guildEvent: GuildScheduledEvent = await guild.scheduledEvents.fetch(eventId)
if (!guildEvent) logger.error(`GuildScheduledEvent with id${eventId} could not be found`, { requestId, guildId: guild.id }) if (!guildEvent) logger.error(`GuildScheduledEvent with id${eventId} could not be found`, { requestId, guildId: guild.id })
@ -195,14 +218,15 @@ export default class VoteController {
logger.info("Deleting vote message") logger.info("Deleting vote message")
await lastMessage.delete() await lastMessage.delete()
const event = await this.getEvent(guild, guild.id, requestId) const event = await this.getEvent(guild, guild.id, requestId)
if (event) { if (event && votes?.length > 0) {
this.updateEvent(event, votes, guild, guildId, requestId) this.updateEvent(event, votes, guild, guildId, requestId)
this.sendVoteClosedMessage(event, votes[0].movie, guildId, requestId) this.sendVoteClosedMessage(event, votes[0].movie, guildId, requestId)
} }
lastMessage.unpin() //todo: uncomment when bot has permission to pin/unpin lastMessage.unpin() //todo: uncomment when bot has permission to pin/unpin
} }
/**
* gets votes for the movies without the NONE_OF_THAT votes
*/
public async getVotesByEmote(message: Message, guildId: string, requestId: string): Promise<Vote[]> { public async getVotesByEmote(message: Message, guildId: string, requestId: string): Promise<Vote[]> {
const votes: Vote[] = [] const votes: Vote[] = []
logger.debug(`Number of items in emotes: ${Object.values(Emotes).length}`, { guildId, requestId }) logger.debug(`Number of items in emotes: ${Object.values(Emotes).length}`, { guildId, requestId })
@ -240,10 +264,10 @@ export default class VoteController {
logger.info("Updating event.", { guildId, requestId }) logger.info("Updating event.", { guildId, requestId })
voteEvent.edit(options) voteEvent.edit(options)
} }
public async sendVoteClosedMessage(event: GuildScheduledEvent, movie: string, guildId: string, requestId: string) { 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 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 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 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 = { const options: MessageCreateOptions = {
content: body, content: body,
allowedMentions: { parse: ["roles"] } allowedMentions: { parse: ["roles"] }
@ -251,13 +275,14 @@ export default class VoteController {
const announcementChannel = this.client.getAnnouncementChannelForGuild(guildId) const announcementChannel = this.client.getAnnouncementChannelForGuild(guildId)
logger.info("Sending vote closed message.", { guildId, requestId }) logger.info("Sending vote closed message.", { guildId, requestId })
if (!announcementChannel) { if (!announcementChannel) {
logger.error("Could not find announcement channel. Please fix!", { guildId, requestId }) const errorMessages = "Could not find announcement channel. Please fix!"
return logger.error(errorMessages, { guildId, requestId })
throw errorMessages
} }
announcementChannel.send(options) return announcementChannel.send(options)
} }
private extractMovieFromMessageByEmote(message: Message, emote: string): string { private extractMovieFromMessageByEmote(lastMessages: Message, emote: string): string {
const lines = message.cleanContent.split("\n") const lines = lastMessages.cleanContent.split("\n")
const emoteLines = lines.filter(line => line.includes(emote)) const emoteLines = lines.filter(line => line.includes(emote))
if (!emoteLines) { if (!emoteLines) {

View File

@ -0,0 +1,96 @@
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"
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 mockJellyfinHandler: JellyfinHandler = <JellyfinHandler><unknown>{
getRandomMovieNames: jest.fn().mockReturnValue(["movie1"])
}
const votes = new VoteController(mockClient, mockJellyfinHandler)
const mockMessageContent = votes.createVoteMessageText(testEventId, testEventDate, 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<@&> Wir gucken MovieNew am 01.01. um 01:00`
})
})
// test('checkForPollsToClose', async () => {
//
// const testGuild: Guild = <Guild><unknown>{
// scheduledEvents: {
// fetch: jest.fn().mockImplementation(() => {
// return new Promise(resolve => {
// resolve([
// { name: "Event Name" },
// { name: "Event: VOTING OFFEN", scheduledStartTimestamp: "" },
// { name: "another voting" },
// ]
// )
// })
// })
// }
// }
//
// const result = await votes.checkForPollsToClose(testGuild)
//
//
//
//
// })
test('getVotesByEmote', async () => {
const mockMessage: Message = <Message><unknown>{
cleanContent: mockMessageContent,
reactions: {
resolve: jest.fn().mockImplementation((input: any) => {
return votesList.find(e => e.emote === input)
})
}
}
const result = await votes.getVotesByEmote(mockMessage, 'guildId', 'requestId')
expect(result.length).toEqual(5)
expect(result).toEqual(votesList.filter(x => x.movie != NONE_OF_THAT))
})
})

View File

@ -3,6 +3,7 @@ import VoteController, { Vote, VoteMessageInfo } from "../../server/helper/vote.
import { JellyfinHandler } from "../../server/jellyfin/handler" import { JellyfinHandler } from "../../server/jellyfin/handler"
import { ExtendedClient } from "../../server/structures/client" import { ExtendedClient } from "../../server/structures/client"
import { VoteMessage } from "../../server/helper/messageIdentifiers" import { VoteMessage } from "../../server/helper/messageIdentifiers"
import { Message, MessageReaction } from "discord.js"
test('parse votes from vote message', async () => { test('parse votes from vote message', async () => {
const testMovies = [ const testMovies = [
'Movie1', 'Movie1',
@ -33,11 +34,11 @@ test('parse votes from vote message', async () => {
const msg: VoteMessage = <VoteMessage><unknown>{ const msg: VoteMessage = <VoteMessage><unknown>{
cleanContent: testMessage, cleanContent: testMessage,
guild:{ guild: {
id:testGuildId, id: testGuildId,
scheduledEvents:{ scheduledEvents: {
fetch: jest.fn().mockImplementation((input:any)=>{ fetch: jest.fn().mockImplementation((input: any) => {
if(input === testEventId) if (input === testEventId)
return { return {
scheduledStartAt: testEventDate scheduledStartAt: testEventDate
} }
@ -83,3 +84,93 @@ test('parse votes from vote message', () => {
const result = voteController.parseGuildIdAndEventIdFromWholeMessage(testMessage) const result = voteController.parseGuildIdAndEventIdFromWholeMessage(testMessage)
expect(result).toEqual({ guildId: testGuildId, eventId: testEventId }) 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 mockMessageContent = voteController.createVoteMessageText(testEventId, testEventDate, 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 msgReaction: MessageReaction = <MessageReaction><unknown>{
message: reactedUponMessage
}
mockClient.getAnnouncementChannelForGuild = jest.fn().mockReturnValue({
messages: {
fetch: jest.fn().mockReturnValue([
reactedUponMessage
])
}
})
const res = voteController.handleNoneOfThatVote(msgReaction, reactedUponMessage, 'requestId', 'guildId')
})