Compare commits

..

41 Commits

Author SHA1 Message Date
mightypanders 36d1306180 remove test build 2023-06-24 02:13:05 +02:00
mightypanders 69bde313b5 test with latest
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 6s Details
2023-06-24 02:10:26 +02:00
mightypanders 7af3d87048 use new var in build
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 6s Details
2023-06-24 02:09:03 +02:00
mightypanders 73741230b1 adjust env method
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 7s Details
2023-06-24 02:08:26 +02:00
mightypanders 4f6d4f646a adjust echo script
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 6s Details
2023-06-24 02:07:19 +02:00
mightypanders 6169649261 add new step
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 6s Details
2023-06-24 02:06:10 +02:00
mightypanders 0560c4620c adjust awk script
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 6s Details
2023-06-24 02:05:01 +02:00
mightypanders be3ee5e493 echo version
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 6s Details
2023-06-24 02:02:42 +02:00
mightypanders 251e6ae3d6 add test build script
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Failing after 9s Details
2023-06-24 02:00:56 +02:00
mightypanders 2edd0312dc Merge branch 'master' into feat/cicd 2023-06-24 02:00:28 +02:00
mightypanders f2b5ee502f remove unnecessary scripts
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Successful in 10s Details
2023-06-14 22:35:59 +02:00
mightypanders 749e1c89ab clean up ci yaml 2023-06-14 22:35:51 +02:00
mightypanders 0d5799796a go back to normal docker commands
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Successful in 15s Details
2023-06-14 22:33:15 +02:00
mightypanders b7986d276b fix env access
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Successful in 24s Details
2023-06-14 22:31:34 +02:00
mightypanders 8540381834 use bash explicitly
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Successful in 8s Details
2023-06-14 22:29:55 +02:00
mightypanders 7e67d1fed9 adjust scripts
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Successful in 8s Details
2023-06-14 22:28:29 +02:00
mightypanders 0cb19ba8f1 use bash scripts for docker stage
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Successful in 11s Details
2023-06-14 22:27:18 +02:00
mightypanders 5dcf766593 dockerbuild and dockerpush scripts 2023-06-14 22:27:05 +02:00
mightypanders 808bdd033e string it
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Successful in 9s Details
2023-06-14 22:20:06 +02:00
mightypanders 33f031d333 simplify push
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Successful in 9s Details
2023-06-14 22:14:46 +02:00
mightypanders 40d9523e21 doing it manually 2023-06-14 22:14:19 +02:00
mightypanders 26e74a62c1 add branch 2023-06-14 21:54:22 +02:00
mightypanders c0f91aad79 add secrets to 'on' 2023-06-14 21:42:16 +02:00
mightypanders 79ffde5f34 check env step
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Failing after 23s Details
2023-06-14 21:30:23 +02:00
mightypanders 911b9e4884 adjust pipeline to be personalized
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Failing after 17s Details
2023-06-14 21:27:16 +02:00
mightypanders 31a9e0eb28 adjust url
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Failing after 22s Details
2023-06-14 19:44:50 +02:00
mightypanders bcf788293e hardcoded docker tags
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Failing after 28s Details
2023-06-14 19:27:28 +02:00
mightypanders 934b6dfead remove custom script
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Failing after 15s Details
2023-06-14 19:21:22 +02:00
mightypanders cd0c8c0017 latest tag by default
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Failing after 15s Details
2023-06-14 19:18:46 +02:00
mightypanders 83f803d0e7 remove metadata action
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Failing after 20s Details
2023-06-14 19:16:23 +02:00
mightypanders 2cb652aee6 add metadata and registry push
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Failing after 9s Details
2023-06-14 19:11:05 +02:00
mightypanders 034d14eb15 use a container with docker
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Successful in 2m3s Details
2023-06-14 18:59:07 +02:00
mightypanders c8bfc47ddf add docker login
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Failing after 14s Details
2023-06-13 23:47:22 +02:00
mightypanders b67982ed38 ls
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Failing after 9s Details
2023-06-13 23:29:29 +02:00
mightypanders e3144fc402 add checkout option 2023-06-13 23:29:03 +02:00
mightypanders 1970f4b0cb adjust docker build stage
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Failing after 34s Details
2023-06-13 23:24:56 +02:00
mightypanders 8ac4f568a0 add on push
Build a docker image for node-jellyfin-role-ot / build-docker-image (push) Failing after 41s Details
2023-06-13 21:51:26 +02:00
mightypanders 09f4efc96c change os 2023-06-13 21:50:21 +02:00
mightypanders 6e0c3b8ef6 add gitea cicd 2023-06-13 21:49:04 +02:00
mightypanders 8ee36f7510 add woodpecker integration 2023-06-13 20:31:33 +02:00
mightypanders 1593e126eb add docker build files 2023-06-13 20:31:19 +02:00
43 changed files with 13698 additions and 14401 deletions

View File

@ -1,7 +0,0 @@
root = true
[*]
indent_style = tab
tab_width = 4
[*.ts]
indent_style = tab
tab_width = 4

View File

@ -14,4 +14,4 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Build Container - name: Build Container
run: docker build --target compile . run: docker build .

View File

@ -11,6 +11,7 @@ env:
jobs: jobs:
build-docker-image: build-docker-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
#if: gitea.ref == 'refs/heads/master'
container: catthehacker/ubuntu:act-latest container: catthehacker/ubuntu:act-latest
permissions: permissions:
contents: read contents: read
@ -21,8 +22,6 @@ jobs:
- name: Log in to the Container registry - name: Log in to the Container registry
run: docker login -u ${{ env.USER }} -p ${{ secrets.TOKEN }} ${{ env.REGISTRY }} run: docker login -u ${{ env.USER }} -p ${{ secrets.TOKEN }} ${{ env.REGISTRY }}
- name: Build Container - name: Build Container
run: docker build --target compile -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}" . run: docker build -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" .
env:
version: $(cat package.json | awk 'match($0, /version/) {print $2}' | sed 's/[\",]//g') # extracts the version number from the package.json with bash magic
- name: Push Container - name: Push Container
run: docker push --all-tags "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" run: docker push --all-tags "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"

View File

@ -1,18 +0,0 @@
name: Run unit tests
on: [pull_request]
env:
REGISTRY: gitea.brudi.xyz
IMAGE_NAME: ${{ gitea.repository }}
USER: ${{ gitea.actor }}
jobs:
test:
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Run Tests
run: docker build --target test .

View File

@ -1,22 +1,11 @@
FROM node:alpine as files FROM node:alpine as Build
ENV TZ="Europe/Berlin"
WORKDIR /app
COPY [ "package-lock.json", "package.json", "index.ts", "tsconfig.json", "./" ]
FROM files as proddependencies
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /app
COPY [ "package-lock.json", "package.json", "index.ts", "tsconfig.json", "./" ]
COPY server ./server
RUN npm ci --omit=dev RUN npm ci --omit=dev
FROM proddependencies as compile
COPY server ./server
RUN npm run build RUN npm run build
CMD ["npm","run","start"] CMD ["npm","run","start"]
FROM files as dependencies
RUN npm ci
FROM dependencies as test
COPY server ./server
COPY jest.config.js .
COPY tests ./tests
RUN npm run test

View File

@ -1,19 +0,0 @@
module.exports = {
'roots': [
'<rootDir>/tests',
'<rootDir>/server'
],
'transform': {
'^.+\\.tsx?$': 'ts-jest'
},
'testRegex': '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
'setupFiles': ["<rootDir>/tests/testenv.js"],
'moduleFileExtensions': [
'ts',
'tsx',
'js',
'jsx',
'json',
'node'
],
};

31
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "node-jellyfin-discord-bot", "name": "node-jellyfin-discord-bot",
"version": "1.1.4", "version": "1.1.3",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "node-jellyfin-discord-bot", "name": "node-jellyfin-discord-bot",
"version": "1.1.4", "version": "1.1.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@discordjs/rest": "^1.7.0", "@discordjs/rest": "^1.7.0",
@ -30,13 +30,12 @@
"winston": "^3.8.2" "winston": "^3.8.2"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.2", "@types/jest": "^29.5.0",
"@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",
"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"
@ -1569,9 +1568,9 @@
} }
}, },
"node_modules/@types/jest": { "node_modules/@types/jest": {
"version": "29.5.2", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.2.tgz", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.0.tgz",
"integrity": "sha512-mSoZVJF5YzGVCk+FsDxzDuH7s+SCkzrgKZzf0Z0T2WudhBUPoF6ktoTPC4R0ZoCPCV5xUvuU6ias5NvxcBcMMg==", "integrity": "sha512-3Emr5VOl/aoBwnWcH/EFQvlSAmjV+XtV9GGu5mwdYew5vhQh0IUZx/60x0TzHDu09Bi7HMx10t/namdJw5QIcg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"expect": "^29.0.0", "expect": "^29.0.0",
@ -4990,12 +4989,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/mockdate": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz",
"integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==",
"dev": true
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -8146,9 +8139,9 @@
} }
}, },
"@types/jest": { "@types/jest": {
"version": "29.5.2", "version": "29.5.0",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.2.tgz", "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.0.tgz",
"integrity": "sha512-mSoZVJF5YzGVCk+FsDxzDuH7s+SCkzrgKZzf0Z0T2WudhBUPoF6ktoTPC4R0ZoCPCV5xUvuU6ias5NvxcBcMMg==", "integrity": "sha512-3Emr5VOl/aoBwnWcH/EFQvlSAmjV+XtV9GGu5mwdYew5vhQh0IUZx/60x0TzHDu09Bi7HMx10t/namdJw5QIcg==",
"dev": true, "dev": true,
"requires": { "requires": {
"expect": "^29.0.0", "expect": "^29.0.0",
@ -10727,12 +10720,6 @@
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="
}, },
"mockdate": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz",
"integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==",
"dev": true
},
"ms": { "ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "node-jellyfin-discord-bot", "name": "node-jellyfin-discord-bot",
"version": "1.1.4", "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",
@ -33,18 +33,15 @@
"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 --runInBand",
"test-watch": "jest --watch"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.2", "@types/jest": "^29.5.0",
"@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",
"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

@ -6,7 +6,6 @@ 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',
@ -62,7 +61,7 @@ async function sendInitialAnnouncement(guildId: string, requestId: string): Prom
return return
} }
const currentPinnedAnnouncementMessages = (await announcementChannel.messages.fetchPinned()).filter(message => isInitialAnnouncement(message)) const currentPinnedAnnouncementMessages = (await announcementChannel.messages.fetchPinned()).filter(message => message.cleanContent.includes("[initial]"))
currentPinnedAnnouncementMessages.forEach(async (message) => await message.unpin()) currentPinnedAnnouncementMessages.forEach(async (message) => await message.unpin())
currentPinnedAnnouncementMessages.forEach(message => message.delete()) currentPinnedAnnouncementMessages.forEach(message => message.delete())

View File

@ -1,5 +1,10 @@
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'
@ -20,6 +25,160 @@ 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 :)")
client.voteController.closePoll(command.guild, requestId) 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 })
}
}

View File

@ -1,7 +1,6 @@
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',
@ -14,7 +13,7 @@ export default new Command({
} }
], ],
run: async (interaction: RunOptions) => { run: async (interaction: RunOptions) => {
logger.info('echo called') console.log('echo called')
interaction.interaction.reply(interaction.toString()) interaction.interaction.reply(interaction.toString())
} }
}) })

View File

@ -2,16 +2,15 @@ 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) => {
logger.info('PasswortReset called') console.log('PasswortReset called')
interaction.interaction.followUp('Yo, ich schick dir eins!') interaction.interaction.followUp('Yo, ich schick dir eins!')
logger.info(JSON.stringify(interaction.interaction.member, null, 2)) console.log(JSON.stringify(interaction.interaction.member, null, 2))
jellyfinHandler.resetUserPasswort(interaction.interaction.member, uuid()) jellyfinHandler.resetUserPasswort(interaction.interaction.member, uuid())
} }
}) })

View File

@ -30,8 +30,6 @@ 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 = {
@ -61,18 +59,16 @@ 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 ?? "TOKEN", workaround_token: process.env.TOKEN ?? "",
watcher_role: process.env.WATCHER_ROLE ?? "WATCHER_ROLE", watcher_role: process.env.WATCHER_ROLE ?? "",
jf_admin_role: process.env.ADMIN_ROLE ?? "ADMIN_ROLE", jf_admin_role: process.env.ADMIN_ROLE ?? "",
announcement_role: process.env.WATCHPARTY_ANNOUNCEMENT_ROLE ?? "ANNOUNCE_ROLE", announcement_role: process.env.WATCHPARTY_ANNOUNCEMENT_ROLE ?? "",
announcement_channel_id: process.env.CHANNEL_ID ?? "ANNOUNCE_CHANNEL", announcement_channel_id: process.env.CHANNEL_ID ?? "",
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"
} }
} }

View File

@ -1,17 +0,0 @@
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": "🎫"
}

View File

@ -32,11 +32,7 @@ export async function execute(event: GuildScheduledEvent) {
return return
} }
if (!event.scheduledStartAt) { const message = `[Watchparty] https://discord.com/events/${event.guildId}/${event.id} \nHey <@&${config.bot.announcement_role}>, wir gucken ${event.name} ${createDateStringFromEvent(event, guildId, requestId)}`
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 {

View File

@ -1,11 +1,20 @@
import { GuildScheduledEvent, TextChannel } from "discord.js"; import { GuildScheduledEvent, Message, MessageCreateOptions, 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()
@ -24,21 +33,31 @@ 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 a start date, cancelling", { guildId: event.guildId, requestId }) logger.info("EVENT DOES NOT HAVE STARTDATE; CANCELLING", {guildId: event.guildId, requestId})
return return
} }
const sentMessage = await client.voteController.prepareAndSendVoteMessage({ 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`
movies,
startDate: event.scheduledStartAt,
event,
announcementChannel,
pinAfterSending: true
},
event.guildId,
requestId)
logger.debug(JSON.stringify(sentMessage)) for (let i = 0; i < movies.length; i++) {
message = message.concat(Emotes[i]).concat(": ").concat(movies[i]).concat("\n")
}
message = message.concat(NONE_OF_THAT).concat(": Wenn dir nichts davon gefällt.")
const options: MessageCreateOptions = {
allowedMentions: { parse: ["roles"]},
content: message,
}
const sentMessage: Message<true> = await (await announcementChannel.fetch()).send(options)
for (let i = 0; i < movies.length; i++) {
sentMessage.react(Emotes[i])
}
sentMessage.react(NONE_OF_THAT)
// sentMessage.pin() //todo: uncomment when bot has permission to pin messages. Also update closepoll.ts to only fetch pinned messages
} }
} }

View File

@ -2,7 +2,6 @@ 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'
@ -26,7 +25,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 => !isInitialAnnouncement(message)) const wpAnnouncements = (await announcementChannel.messages.fetch()).filter(message => !message.cleanContent.includes("[initial]"))
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())

View File

@ -1,46 +0,0 @@
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
}
}

View File

@ -1,7 +1,6 @@
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) {
logger.info(`${JSON.stringify(message)} has been created`) console.log(`${JSON.stringify(message)} has been created`)
} }

View File

@ -1,23 +1,23 @@
import { format, isToday } from "date-fns"; import { format, isToday, toDate } 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(eventStartDate: Maybe<Date>, requestId: string, guildId?: string): string { export function createDateStringFromEvent(event: GuildScheduledEvent, requestId: string, guildId?: string): string {
if (!eventStartDate) { if(!event.scheduledStartAt) {
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(eventStartDate, timeZone) const zonedDateTime = utcToZonedTime(event.scheduledStartAt, 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}`
} }

View File

@ -1,20 +0,0 @@
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]")
}

View File

@ -1,5 +1,5 @@
import { Collection, Guild, GuildMember, Role } from "discord.js" import { Collection, GuildMember } from "discord.js"
import { ChangedRoles, Maybe, PermissionLevel } from "../interfaces" import { ChangedRoles, PermissionLevel } from "../interfaces"
import { logger } from "../logger" import { logger } from "../logger"
import { config } from "../configuration" import { config } from "../configuration"
@ -16,13 +16,6 @@ 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")

View File

@ -1,11 +1,10 @@
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)
logger.info(`Creator ${JSON.stringify(creator)}`) console.log(`Creator ${JSON.stringify(creator)}`)
if (creator) if (creator)
if (!creator.dmChannel) if (!creator.dmChannel)
await creator.createDM() await creator.createDM()

View File

@ -1,361 +0,0 @@
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 })
}
}
}

View File

@ -1,5 +1,5 @@
import { Collection } from "@discordjs/collection" import { Collection } from "@discordjs/collection"
import { GuildScheduledEvent, Role, TextChannel } from "discord.js" import { Role } from "discord.js"
export type Maybe<T> = T | undefined | null export type Maybe<T> = T | undefined | null
export interface Player { export interface Player {
@ -39,10 +39,3 @@ 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,
}

View File

@ -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, errorMessage?: string) { constructor(public response: Response, msg?: string) {
super(errorMessage); super(msg);
} }
} }
export class FetchError extends Error { export class FetchError extends Error {
override name: "FetchError" = "FetchError"; override name: "FetchError" = "FetchError";
constructor(public cause: Error, errorMessage?: string) { constructor(public cause: Error, msg?: string) {
super(errorMessage); super(msg);
} }
} }
export class RequiredError extends Error { export class RequiredError extends Error {
override name: "RequiredError" = "RequiredError"; override name: "RequiredError" = "RequiredError";
constructor(public field: string, errorMessage?: string) { constructor(public field: string, msg?: string) {
super(errorMessage); super(msg);
} }
} }

View File

@ -1,12 +1,9 @@
import { createLogger, format, transports } from "winston" import { createLogger, format, transports } from "winston"
import { config } from "./configuration" import { config } from "./configuration"
import { v4 } from "uuid"
export function newRequestId() { return v4() }
export const noGuildId = 'NoGuildId'
const printFn = format.printf(({ guildId, level, message, errorCode, requestId, timestamp: logTimestamp }: { [k: string]: string }) => { const printFn = format.printf(({ guildId, level, message, errorCode, requestId, timestamp: logTimestamp }: { [k: string]: string }) => {
return `[${guildId ?? ''}][${level.padStart(5, " ")}][${logTimestamp}][${errorCode ?? ''}][${requestId ?? ''}]:${message}` return `[${guildId ?? ''}][${level}][${logTimestamp}][${errorCode ?? ''}][${requestId ?? ''}]:${message}`
}) })
const logFormat = format.combine( const logFormat = format.combine(
@ -16,8 +13,7 @@ const logFormat = format.combine(
const consoleTransports = [ const consoleTransports = [
new transports.Console({ new transports.Console({
format: logFormat, format: logFormat
silent: process.env.NODE_ENV === 'testing'
}) })
] ]
export const logger = createLogger({ export const logger = createLogger({

View File

@ -8,9 +8,7 @@ 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 { isInitialAnnouncement } from "../helper/messageIdentifiers"; import { checkForPollsToClose } from "../commands/closepoll";
import VoteController from "../helper/vote.controller";
import { yavinJellyfinHandler } from "../..";
@ -18,14 +16,13 @@ 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.GuildMessageReactions, IntentsBitField.Flags.GuildVoiceStates) intents.add(IntentsBitField.Flags.GuildMembers, IntentsBitField.Flags.MessageContent, IntentsBitField.Flags.Guilds, IntentsBitField.Flags.DirectMessages, IntentsBitField.Flags.GuildScheduledEvents, IntentsBitField.Flags.GuildVoiceStates)
const options: ClientOptions = { intents } const options: ClientOptions = { intents }
super(options) super(options)
this.jellyfin = jf this.jellyfin = jf
@ -77,7 +74,6 @@ 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()
}) })
@ -85,21 +81,6 @@ 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())
@ -155,7 +136,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 => isInitialAnnouncement(message)) const messages = (await textChannel.messages.fetchPinned()).filter(message => message.cleanContent.includes("[initial]"))
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 })
@ -194,7 +175,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 * * * * *", () => this.voteController.checkForPollsToClose(guild[1]))) this.pollCloseBackgroundTasks.set(guild[1].id, schedule("0 * * * * *", () => checkForPollsToClose(guild[1])))
} }
} }
} }

View File

@ -1,81 +0,0 @@
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))
}
})
})

View File

@ -1,192 +0,0 @@
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')
})

View File

@ -1,15 +0,0 @@
import { createDateStringFromEvent } from "../../server/helper/dateHelper"
import MockDate from 'mockdate'
beforeAll(() => {
MockDate.set('01-01-2023')
})
function getTestDate(date: string): Date {
return new Date(date)
}
test('createDateStringFromEvent - correct formatting', () => {
expect(createDateStringFromEvent(getTestDate('01-01-2023 12:30'), "")).toEqual('heute um 12:30')
expect(createDateStringFromEvent(getTestDate('01-02-2023 12:30'), "")).toEqual('am Montag 02.01. um 12:30')
expect(createDateStringFromEvent(getTestDate('01-03-2023 12:30'), "")).toEqual('am Dienstag 03.01. um 12:30')
})

View File

@ -1,28 +0,0 @@
import { Collection, GuildMember, Role } from "discord.js"
import { filterRolesFromMemberUpdate } from "../../server/helper/roleFilter"
function buildFakeRole(id: string, name: string): Role {
return <Role>{ id, name }
}
test('filterRolesFromMemberUpdate', () => {
const oldMemberRoles: Collection<string, Role> = new Collection<string, Role>()
oldMemberRoles.set('1', buildFakeRole('01', 'Role01'))
oldMemberRoles.set('2', buildFakeRole('02', 'Role02'))
const newMemberRoles: Collection<string, Role> = new Collection<string, Role>()
newMemberRoles.set('1', buildFakeRole('01', 'Role01'))
newMemberRoles.set('2', buildFakeRole('02', 'Role02'))
newMemberRoles.set('3', buildFakeRole('03', 'Role03'))
const oldMember: GuildMember = <GuildMember>{ roles: { cache: oldMemberRoles }, guild: { id: "guildid" } }
const newMember: GuildMember = <GuildMember>{ roles: { cache: newMemberRoles }, guild: { id: "guildid" } }
const output = filterRolesFromMemberUpdate(oldMember, newMember)
const expectedAddedRoles: Collection<string, Role> = new Collection<string, Role>()
expectedAddedRoles.set('3', buildFakeRole('03', 'Role03'))
const expectedRemovedRoles: Collection<string, Role> = new Collection<string, Role>()
expect(output.addedRoles).toEqual(expectedAddedRoles)
expect(output.removedRoles).toEqual(expectedRemovedRoles)
})

View File

@ -1,15 +0,0 @@
process.env.CLIENT_ID = "CLIENT_ID"
process.env.SECRET = "SECRET"
process.env.BOT_TOKEN = "BOT_TOKEN"
process.env.WATCHER_ROLE = "WATCHER_ROLE"
process.env.ADMIN_ROLE = "ADMIN_ROLE"
process.env.CHANNEL_ID = "CHANNEL_ID"
process.env.WATCHPARTY_ANNOUNCEMENT_ROLE = "WATCHPARTY_ANNOUNCEMENT_ROLE"
process.env.YAVIN_JELLYFIN_URL = "YAVIN_JELLYFIN_URL"
process.env.YAVIN_COLLECTION_ID = "YAVIN_COLLECTION_ID"
process.env.YAVIN_COLLECTION_USER = "YAVIN_COLLECTION_USER"
process.env.YAVIN_TOKEN = "YAVIN_TOKEN"
process.env.TOKEN = "TOKEN"
process.env.JELLYFIN_USER = "JELLYFIN_USER"
process.env.JELLYFIN_COLLECTION_ID = "JELLYFIN_COLLECTION_ID"
process.env.JELLYFIN_URL = "JELLYFIN_URL"

View File

@ -1,44 +1,61 @@
{ {
"extends":"@tsconfig/recommended/tsconfig.json", "extends":"@tsconfig/recommended/tsconfig.json",
"exclude": [ "exclude":["node_modules"],
"node_modules"
],
"compilerOptions": { "compilerOptions": {
/* Basic Options */ /* Basic Options */
"target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
"resolveJsonModule": true, "resolveJsonModule": true,
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./build" /* Redirect output structure to the directory. */, "outDir": "./build" /* Redirect output structure to the directory. */,
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */ // "composite": true, /* Enable project compilation */
"removeComments": true, /* Do not emit comments to output. */ // "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */ /* Strict Type-Checking Options */
"strict": true /* Enable all strict type-checking options. */, "strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* Enable strict null checks. */ // "strictNullChecks": true, /* Enable strict null checks. */
"strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
//"noUncheckedIndexedAccess": true,
/* Additional Checks */ /* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */ // "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */ /* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
/* Source Map Options */ /* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
"inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */ "inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */ /* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */