Compare commits

...

129 Commits

Author SHA1 Message Date
fec0bc31f1 1.1.4
All checks were successful
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 56s
2023-11-19 20:25:32 +01:00
1bfcaa95f9 Merge pull request 'feat/40-reroll-on-disinterest' (#54) from feat/40-reroll-on-disinterest into master
Reviewed-on: #54
2023-11-19 20:24:35 +01:00
fb4ab59dc6 rename emotes to validvoteemotes
All checks were successful
Run unit tests / test (pull_request) Successful in 14s
Compile the repository / compile (pull_request) Successful in 16s
2023-11-19 20:22:14 +01:00
6d40930dc1 fix incorrect log regarding update cancellation, fixes return type of function to use Maybe
All checks were successful
Compile the repository / compile (pull_request) Successful in 17s
Run unit tests / test (pull_request) Successful in 14s
2023-11-19 20:17:51 +01:00
4e9fe587b0 rename to getOpenPollEvent
All checks were successful
Compile the repository / compile (pull_request) Successful in 17s
Run unit tests / test (pull_request) Successful in 13s
2023-11-19 20:13:49 +01:00
03b6a30ffa remove unnecessary if
All checks were successful
Compile the repository / compile (pull_request) Successful in 16s
Run unit tests / test (pull_request) Successful in 18s
2023-11-19 20:11:03 +01:00
7d794a8001 refactor voteInfo to include event instead of eventid and startDate
All checks were successful
Run unit tests / test (pull_request) Successful in 16s
Compile the repository / compile (pull_request) Successful in 18s
2023-11-19 20:04:30 +01:00
8df180898e pad logging level to always be 5 characters 2023-11-19 20:04:06 +01:00
976175242b reorder close poll and use message identifier
All checks were successful
Compile the repository / compile (pull_request) Successful in 28s
Run unit tests / test (pull_request) Successful in 16s
2023-11-19 18:56:39 +01:00
68546b0b50 adjust message identifier in test 2023-11-19 18:56:08 +01:00
1348abbd48 make message identifiers actually work properly with LSP 2023-11-19 18:55:51 +01:00
fce9091114 rename message type union to better reflect its intention 2023-11-19 18:24:33 +01:00
081f3c6201 fix incorrect branded type 2023-11-19 18:24:13 +01:00
ca99987a20 clean up variable and function names
Some checks failed
Compile the repository / compile (pull_request) Failing after 16s
Run unit tests / test (pull_request) Failing after 13s
2023-11-19 18:21:51 +01:00
fc64728a78 msg -> message
All checks were successful
Compile the repository / compile (pull_request) Successful in 21s
Run unit tests / test (pull_request) Successful in 13s
2023-11-18 18:26:45 +01:00
20da25f2bf comment filter function
All checks were successful
Compile the repository / compile (pull_request) Successful in 39s
Run unit tests / test (pull_request) Successful in 14s
2023-11-18 18:22:11 +01:00
a455fd8ff7 message -> messageText
All checks were successful
Compile the repository / compile (pull_request) Successful in 16s
Run unit tests / test (pull_request) Successful in 13s
2023-11-18 18:15:27 +01:00
119343c916 fix comment 2023-11-18 18:15:13 +01:00
296a490e93 rename filter function 2023-11-18 18:14:56 +01:00
66507cb08f msg -> message
All checks were successful
Compile the repository / compile (pull_request) Successful in 16s
Run unit tests / test (pull_request) Successful in 13s
2023-11-18 17:40:50 +01:00
4600820889 move preparation of vote Message sending into vote controller
All checks were successful
Compile the repository / compile (pull_request) Successful in 17s
Run unit tests / test (pull_request) Successful in 13s
event only needs to supply information, text creation, sending and pinning happens in the vote controller
2023-11-18 17:28:44 +01:00
4a3e8809be Merge branch 'master' into feat/40-reroll-on-disinterest
All checks were successful
Compile the repository / compile (pull_request) Successful in 15s
Run unit tests / test (pull_request) Successful in 13s
2023-11-18 16:46:28 +01:00
690ba697b6 Merge pull request 'Unit Test Setup' (#58) from feat/unit-test-setup into master
Reviewed-on: #58
2023-11-18 16:45:58 +01:00
71343d6742 update packagelock
All checks were successful
Run unit tests / test (pull_request) Successful in 1m23s
Compile the repository / compile (pull_request) Successful in 59s
2023-11-18 16:42:40 +01:00
3f6e558d39 make logger silent during unit tests, add logging const for more concise requestId/guildid handling 2023-11-18 16:42:27 +01:00
ca259c5f24 update tsconfig
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m10s
Run unit tests / test (pull_request) Successful in 2m0s
2023-11-18 16:38:52 +01:00
b1c581ca6e npm test script
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m40s
Run unit tests / test (pull_request) Successful in 1m21s
2023-11-18 16:28:51 +01:00
96189c2392 adjust docker file to enable better build flow for tests 2023-11-18 16:28:40 +01:00
700353cff4 include a test-env file to setup environment variables for unit tests 2023-11-18 16:28:18 +01:00
f705b97804 move testenv to correct location
All checks were successful
Run unit tests / test (pull_request) Successful in 1m0s
Compile the repository / compile (pull_request) Successful in 20s
2023-10-24 22:42:03 +02:00
9cdc6e1934 add fake env vars for unit tests
Some checks failed
Compile the repository / compile (pull_request) Successful in 39s
Run unit tests / test (pull_request) Failing after 45s
2023-10-24 22:39:57 +02:00
c73cd20ccf add test-relevant fallback values for unit tests
All checks were successful
Compile the repository / compile (pull_request) Successful in 38s
Run unit tests / test (pull_request) Successful in 1m26s
2023-10-21 15:05:25 +02:00
e66aebc88c make top pick retain optional during reroll via env var
Some checks failed
Compile the repository / compile (pull_request) Successful in 32s
Run unit tests / test (pull_request) Failing after 40s
2023-10-21 14:56:33 +02:00
599243990e remove console.logs 2023-10-21 14:56:15 +02:00
eef3a9c358 add missing role to test
Some checks failed
Compile the repository / compile (pull_request) Successful in 2m13s
Run unit tests / test (pull_request) Failing after 1m10s
2023-10-21 14:11:03 +02:00
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
2ebc7fbdbe restructure docker build a bit
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m12s
Run unit tests / test (pull_request) Successful in 1m39s
2023-08-06 02:37:49 +02:00
8ff5aeff03 logging
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m21s
Run unit tests / test (pull_request) Successful in 1m26s
2023-08-06 02:33:28 +02:00
1101a84501 imports 2023-08-06 02:33:23 +02:00
91ec2ece7e explicit typing 2023-08-06 02:33:17 +02:00
5e58765cf4 also enabled NONE_OF_THAT to be handled 2023-08-06 02:32:44 +02:00
a2adef808f add guildscheduledevents to unit test mock
All checks were successful
Compile the repository / compile (pull_request) Successful in 14m58s
Run unit tests / test (pull_request) Successful in 4m18s
2023-07-17 23:31:00 +02:00
dc66c277b2 big refactoring of none_of_that handler
extracting, better typing, reduction of complexity
2023-07-17 23:30:48 +02:00
c022cc32d5 refactor eventId parsing to separate function
All checks were successful
Compile the repository / compile (pull_request) Successful in 49s
Run unit tests / test (pull_request) Successful in 1m35s
prepare for querying discord api for event info instead of parsing via regex
2023-07-17 22:50:24 +02:00
e763e76413 add new test for eventId parsing 2023-07-17 22:49:12 +02:00
137d156981 fix date string in vote message 2023-07-17 22:48:57 +02:00
fdfe7ce404 move date parsing to separate function
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m10s
Run unit tests / test (pull_request) Successful in 2m6s
2023-07-17 21:30:02 +02:00
146848b759 add none of that as expected value to test
Some checks failed
Compile the repository / compile (pull_request) Successful in 13s
Run unit tests / test (pull_request) Failing after 31s
2023-07-17 21:29:47 +02:00
e54f03292e add a message parser to vote controller
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m33s
Run unit tests / test (pull_request) Successful in 1m27s
parses a vote message line by line to extract
- eventdate
- eventid
- movies
- votes
This depends on the structure of the message to not change substantially.
as such it's quite brittle
2023-07-13 22:47:28 +02:00
fe45445811 add a test case to check for proper message parsing 2023-07-13 22:46:28 +02:00
8f02e11dba add ticket to emoji list 2023-07-13 22:46:14 +02:00
878c81bfa7 linting 2023-07-13 22:46:03 +02:00
ca19168cf4 add early abort message to announce watch party 2023-07-13 22:45:28 +02:00
e8893646f0 add config values
All checks were successful
Compile the repository / compile (pull_request) Successful in 12s
Run unit tests / test (pull_request) Successful in 10s
2023-07-05 23:22:25 +02:00
e61b3a7b16 split vote message handling 2023-07-05 23:22:13 +02:00
9383cee4a0 scaffolding for poll reroll function 2023-07-05 23:22:01 +02:00
0748097a1f refactor datestring function 2023-07-05 23:21:44 +02:00
ffba737e5a update tsconfig 2023-07-05 22:56:01 +02:00
4cd9c771f0 transfer many poll functions to VoteController 2023-07-05 22:55:24 +02:00
8c3cf7829b use branded types for messageType determination 2023-07-05 22:54:43 +02:00
1a13638ed9 linting
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m0s
Run unit tests / test (pull_request) Successful in 1m33s
2023-06-27 20:34:20 +02:00
c351e27fdd perform vote message check in reaction handler
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m15s
Run unit tests / test (pull_request) Successful in 1m26s
2023-06-27 20:23:36 +02:00
6d3bea169e return on bot reaction 2023-06-27 20:23:22 +02:00
3f071c8a4e remove duplicate check for none_of_that vote 2023-06-27 20:22:44 +02:00
98d1ca73b5 fix newRequestId function
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m15s
Run unit tests / test (pull_request) Successful in 1m29s
2023-06-27 20:19:42 +02:00
ee742018e9 adds comment to fetchAnnouncementChannelMessage
All checks were successful
Run unit tests / test (pull_request) Successful in 1m54s
Compile the repository / compile (pull_request) Successful in 57s
2023-06-27 20:08:39 +02:00
8ad651c753 prepare unicode representation of emoji for cleaner handling as pure ASCII
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m45s
Run unit tests / test (pull_request) Successful in 1m34s
emoji handling in editors and browsers is iffy, as such a pure ascii code base is easier to handle (imho)
2023-06-26 23:51:14 +02:00
a4a834ad27 refactor reaction handling
- rename
- externalise handling of none_of_that to vote controller
- base for extensions for more reaction handling
2023-06-26 23:48:52 +02:00
e8dcfd8340 add votecontroller to consolidate handling of votes 2023-06-26 23:47:43 +02:00
d9d1d74ef9 WIP: basic handling of adding a reaction to a message and deciding whether to reroll or not
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m21s
Run unit tests / test (pull_request) Successful in 1m53s
2023-06-25 22:49:21 +02:00
331ff89060 fetch all message from announcement channel on start
This is necessary because message sent before the bot has started up are not cached and reactions will not be registered.
If the messages are cached manually the reactions will be received and can be processed using the regular event handling
2023-06-25 22:48:55 +02:00
f6476c609b fetch members of roleId from guild 2023-06-25 22:47:06 +02:00
6220268b14 move emotes and reaction constants 2023-06-25 22:46:46 +02:00
b6034d4fb7 use message identifiers 2023-06-25 02:20:45 +02:00
ca0a9e3cb8 more message identifiers 2023-06-25 02:20:34 +02:00
b8a32aab40 stub for reactionhandling
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m14s
Run unit tests / test (pull_request) Successful in 1m38s
2023-06-25 01:57:40 +02:00
e3e755011d add messageIdentifier helper 2023-06-25 01:57:30 +02:00
5a6c66cb3e export newRequestId from logger 2023-06-25 01:57:14 +02:00
0d3c62c6ad Merge pull request 'feat/formatting' (#53) from feat/formatting into master
Reviewed-on: #53
2023-06-24 22:55:42 +02:00
5816db48e6 Merge branch 'master' into feat/formatting
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m16s
Run unit tests / test (pull_request) Successful in 1m33s
2023-06-24 21:55:51 +02:00
66f843b399 format more files
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m12s
2023-06-24 21:09:56 +02:00
d82a7cffd2 same config for all
All checks were successful
Compile the repository / compile (pull_request) Successful in 6s
2023-06-24 21:07:41 +02:00
8a06a661fa adjust some files to new formatting
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m28s
2023-06-24 21:05:43 +02:00
4084f675cd change to tab indents 2023-06-24 21:05:33 +02:00
3bd26a9d6c Merge pull request 'feat/testing' (#52) from feat/testing into master
Reviewed-on: #52
2023-06-24 20:59:39 +02:00
e7b21fa658 apply editorconfig to ts files
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m7s
2023-06-24 20:58:41 +02:00
2d32f9b680 format many files 2023-06-24 20:56:58 +02:00
5503aa8713 add editorconfig 2023-06-24 20:56:22 +02:00
25bb676fda readd compile
All checks were successful
Compile the repository / compile (pull_request) Successful in 10s
Run unit tests / test (pull_request) Successful in 8s
2023-06-24 20:37:59 +02:00
9f5abb8a90 Merge branch 'master' into feat/testing 2023-06-24 20:37:08 +02:00
0e67252976 Merge pull request 'use bash magic to get an env var from the package.json' (#51) from feat/build-versioned-image into master
Reviewed-on: #51
2023-06-24 20:35:23 +02:00
37b798818c handle timezone correctly in docker build
All checks were successful
Run unit tests / test (pull_request) Successful in 1m53s
Compile the repository / compile (pull_request) Successful in 1m10s
2023-06-24 20:31:37 +02:00
af414d0bad rename human facing name for test job
All checks were successful
Compile the repository / compile (pull_request) Successful in 7s
Run unit tests / test (pull_request) Successful in 7s
2023-06-24 20:17:04 +02:00
c32434a7eb rename test job
All checks were successful
Compile the repository / compile (pull_request) Successful in 9s
Compile the repository / test (pull_request) Successful in 9s
2023-06-24 20:16:03 +02:00
c133570d8c update other workflows to use staged builds
All checks were successful
Compile the repository / compile (pull_request) Successful in 8s
2023-06-24 20:11:39 +02:00
65cdee36e9 update testcase
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m58s
2023-06-24 20:09:52 +02:00
6b0e84669a update dockerfile to support test stage 2023-06-24 20:09:09 +02:00
dd72f8e165 add automatic jest test in docker build to workflows 2023-06-24 20:09:00 +02:00
a6f19ccd2b add date test (WIP) 2023-06-24 19:56:49 +02:00
c39f9c6ee1 add first passing test 2023-06-24 19:56:30 +02:00
f41194ba71 add base jest setup 2023-06-24 19:11:12 +02:00
fa49dc0f76 use bash magic to get an env var from the package.json
All checks were successful
Compile the repository / compile (pull_request) Successful in 7s
this is shamelessly stolen from work
2023-06-24 02:14:53 +02:00
e52e845851 1.1.3
All checks were successful
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 1m59s
2023-06-23 23:46:52 +02:00
61544feaba Fix stupid timezone issues 2023-06-23 23:46:11 +02:00
1966640239 Merge branch 'master' of ssh://gitea.brudi.xyz:222/kenobi/jellyfin-discord-bot 2023-06-23 21:24:32 +02:00
fa9998e92c Unallow transcoding per default for new users 2023-06-23 21:23:54 +02:00
c1a449bafe 1.1.2
All checks were successful
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 1m50s
2023-06-23 19:46:20 +02:00
d5d82043f0 temporarily remove second tag on docker build 2023-06-23 19:46:06 +02:00
51ebf2e939 1.1.1
All checks were successful
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 12s
2023-06-23 19:44:58 +02:00
f314b2f355 maybe fix a docker build typo 2023-06-23 19:44:44 +02:00
a4d7c57d10 1.1.0
All checks were successful
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 14s
2023-06-23 19:33:31 +02:00
2802afa7d5 Merge pull request 'feat/#42_announce_manual_watchparty' (#50) from feat/#42_announce_manual_watchparty into master
Reviewed-on: #50
2023-06-23 19:31:57 +02:00
3a5ea5d4ff improve message clarity when no start date in event found
All checks were successful
Compile the repository / compile (pull_request) Successful in 4m8s
2023-06-23 19:30:17 +02:00
45d87275bf prevent announcement when description contains !private
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m43s
2023-06-23 15:56:36 +02:00
31e440434e properly clean up wp announcements without event only 2023-06-23 15:51:48 +02:00
3d70b56eb7 Merge pull request 'feat/#43_append_event_invite' (#49) from feat/#43_append_event_invite into master
Reviewed-on: #49
2023-06-23 15:49:59 +02:00
3298c7a244 Merge pull request 'Create option "none of that" in voting' (#48) from feat/#39_none_of_that into master
Reviewed-on: #48
2023-06-23 15:49:43 +02:00
5b98c9bf2f Rename event files to specific case
We can add multiple eventhandlers per eventname. To avoid confusion and large files and to improve concise file names the event files were renamed
2023-06-23 14:37:41 +02:00
ee363e065c 1.0.4
All checks were successful
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Successful in 47s
2023-06-22 23:23:56 +02:00
9af847f234 Fix docker-build for good 2023-06-22 23:23:38 +02:00
a18406e7e4 1.0.3
Some checks failed
Build a docker image for node-jellyfin-role-bot / build-docker-image (push) Failing after 8s
2023-06-22 23:18:41 +02:00
b9f65125dc Hopefully fix ci/cd 2023-06-22 23:18:06 +02:00
9da8f47784 add event box to vote closed message
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m18s
2023-06-22 19:38:59 +02:00
e8c58d5ff8 add event box to create message 2023-06-22 19:35:47 +02:00
8569a3e1e6 Create option "none of that" in voting
All checks were successful
Compile the repository / compile (pull_request) Successful in 1m19s
2023-06-22 18:53:29 +02:00
47 changed files with 14507 additions and 13651 deletions

7
.editorconfig Normal file
View File

@ -0,0 +1,7 @@
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
uses: actions/checkout@v3
- name: Build Container
run: docker build .
run: docker build --target compile .

View File

@ -11,7 +11,6 @@ env:
jobs:
build-docker-image:
runs-on: ubuntu-latest
#if: gitea.ref == 'refs/heads/master'
container: catthehacker/ubuntu:act-latest
permissions:
contents: read
@ -19,11 +18,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Get Package Version
run: VERSION = node -p "require('./package.json').version"
- name: Log in to the Container registry
run: docker login -u ${{ env.USER }} -p ${{ secrets.TOKEN }} ${{ env.REGISTRY }}
- name: Build Container
run: docker build -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}".
run: docker build --target compile -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.VERSION }}" .
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
run: docker push --all-tags "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
run: docker push --all-tags "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"

View File

@ -0,0 +1,18 @@
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,11 +1,22 @@
FROM node:alpine as Build
ENV NODE_ENV=production
FROM node:alpine as files
ENV TZ="Europe/Berlin"
WORKDIR /app
COPY [ "package-lock.json", "package.json", "index.ts", "tsconfig.json", "./" ]
COPY server ./server
FROM files as proddependencies
ENV NODE_ENV=production
RUN npm ci --omit=dev
FROM proddependencies as compile
COPY server ./server
RUN npm run build
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

@ -5,21 +5,21 @@ import { JellyfinHandler } from "./server/jellyfin/handler"
import { attachedImages } from "./server/assets/attachments"
const requestId = 'startup'
export const jellyfinHandler = new JellyfinHandler({jellyfinToken: config.bot.workaround_token, jellyfinUrl: config.bot.jellyfin_url, movieCollectionId: config.bot.jf_collection_id, collectionUser: config.bot.jf_user})
export const yavinJellyfinHandler = new JellyfinHandler({jellyfinToken: config.bot.yavin_jellyfin_token, jellyfinUrl: config.bot.yavin_jellyfin_url, movieCollectionId: config.bot.yavin_collection_id, collectionUser: config.bot.yavin_jellyfin_collection_user})
export const jellyfinHandler = new JellyfinHandler({ jellyfinToken: config.bot.workaround_token, jellyfinUrl: config.bot.jellyfin_url, movieCollectionId: config.bot.jf_collection_id, collectionUser: config.bot.jf_user })
export const yavinJellyfinHandler = new JellyfinHandler({ jellyfinToken: config.bot.yavin_jellyfin_token, jellyfinUrl: config.bot.yavin_jellyfin_url, movieCollectionId: config.bot.yavin_collection_id, collectionUser: config.bot.yavin_jellyfin_collection_user })
export const client = new ExtendedClient(jellyfinHandler)
export const attachmentImages = attachedImages
async function init() {
try {
const users = await jellyfinHandler.getCurrentUsers("", requestId)
logger.info(`Fetched ${users.map(x => x.name).join(', ')} from JF`, { requestId })
} catch (error) {
logger.error(`Error fetching existing users from Jellyfin`, { requestId })
}
logger.info(`Starting client`, { requestId })
client.start()
try {
const users = await jellyfinHandler.getCurrentUsers("", requestId)
logger.info(`Fetched ${users.map(x => x.name).join(', ')} from JF`, { requestId })
} catch (error) {
logger.error(`Error fetching existing users from Jellyfin`, { requestId })
}
logger.info(`Starting client`, { requestId })
client.start()
}
init()

19
jest.config.js Normal file
View File

@ -0,0 +1,19 @@
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'
],
};

24202
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -6,116 +6,117 @@ import { Maybe } from '../interfaces'
import { logger } from '../logger'
import { Command } from '../structures/command'
import { RunOptions } from '../types/commandTypes'
import { isInitialAnnouncement } from '../helper/messageIdentifiers'
export default new Command({
name: 'announce',
description: 'Neues announcement im announcement Channel an alle senden.',
options: [{
name: "typ",
type: ApplicationCommandOptionType.String,
description:"Was für ein announcement?",
choices: [{name: "initial", value:"initial"},{name: "votepls", value:"votepls"},{name: "cancel", value:"cancel"}],
required: true
}],
run: async (interaction: RunOptions) => {
const command = interaction.interaction
const requestId = uuid()
if(!command.guildId) {
logger.error("COMMAND DOES NOT HAVE A GUILD ID; CANCELLING!!!", {requestId})
return
}
const guildId = command.guildId
const announcementType = command.options.data.find(option => option.name.includes("typ"))
logger.info(`Got command for announcing ${announcementType?.value}!`, { guildId, requestId })
name: 'announce',
description: 'Neues announcement im announcement Channel an alle senden.',
options: [{
name: "typ",
type: ApplicationCommandOptionType.String,
description: "Was für ein announcement?",
choices: [{ name: "initial", value: "initial" }, { name: "votepls", value: "votepls" }, { name: "cancel", value: "cancel" }],
required: true
}],
run: async (interaction: RunOptions) => {
const command = interaction.interaction
const requestId = uuid()
if (!command.guildId) {
logger.error("COMMAND DOES NOT HAVE A GUILD ID; CANCELLING!!!", { requestId })
return
}
const guildId = command.guildId
const announcementType = command.options.data.find(option => option.name.includes("typ"))
logger.info(`Got command for announcing ${announcementType?.value}!`, { guildId, requestId })
if(!announcementType) {
logger.error("Did not get an announcement type!", { guildId, requestId })
return
}
if (!announcementType) {
logger.error("Did not get an announcement type!", { guildId, requestId })
return
}
if (!isAdmin(command.member)) {
logger.info(`Announcement was requested by ${command.member.displayName} but they are not an admin! Not sending announcement.`, { guildId, requestId })
return
} else {
logger.info(`User ${command.member.displayName} seems to be admin`)
}
if (!isAdmin(command.member)) {
logger.info(`Announcement was requested by ${command.member.displayName} but they are not an admin! Not sending announcement.`, { guildId, requestId })
return
} else {
logger.info(`User ${command.member.displayName} seems to be admin`)
}
if((<string>announcementType.value).includes("initial")) {
sendInitialAnnouncement(guildId, requestId)
command.followUp("Ist rausgeschickt!")
} else {
command.followUp(`${announcementType.value} ist aktuell noch nicht implementiert`)
}
}
if ((<string>announcementType.value).includes("initial")) {
sendInitialAnnouncement(guildId, requestId)
command.followUp("Ist rausgeschickt!")
} else {
command.followUp(`${announcementType.value} ist aktuell noch nicht implementiert`)
}
}
})
function isAdmin(member: GuildMember): boolean {
return member.roles.cache.find((role) => role.id === config.bot.jf_admin_role) !== undefined
return member.roles.cache.find((role) => role.id === config.bot.jf_admin_role) !== undefined
}
async function sendInitialAnnouncement(guildId: string, requestId: string): Promise<void> {
logger.info("Sending initial announcement")
const announcementChannel: Maybe<TextChannel> = client.getAnnouncementChannelForGuild(guildId)
if(!announcementChannel) {
logger.error("Could not find announcement channel. Aborting", { guildId, requestId })
return
}
logger.info("Sending initial announcement")
const announcementChannel: Maybe<TextChannel> = client.getAnnouncementChannelForGuild(guildId)
if (!announcementChannel) {
logger.error("Could not find announcement channel. Aborting", { guildId, requestId })
return
}
const currentPinnedAnnouncementMessages = (await announcementChannel.messages.fetchPinned()).filter(message => message.cleanContent.includes("[initial]"))
currentPinnedAnnouncementMessages.forEach(async (message) => await message.unpin())
currentPinnedAnnouncementMessages.forEach(message => message.delete())
const currentPinnedAnnouncementMessages = (await announcementChannel.messages.fetchPinned()).filter(message => isInitialAnnouncement(message))
currentPinnedAnnouncementMessages.forEach(async (message) => await message.unpin())
currentPinnedAnnouncementMessages.forEach(message => message.delete())
const body = `[initial] Hey! @everyone! Hier ist der Watchparty Bot vom Hartzarett.
const body = `[initial] Hey! @everyone! Hier ist der Watchparty Bot vom Hartzarett.
Wir machen in Zukunft regelmäßig Watchparties in denen wir zusammen Filme gucken! Falls du mitmachen möchtest, reagiere einfach auf diesen Post mit 🎫, dann bekommst du automatisch eine Rolle zugewiesen und wirst benachrichtigt sobald es in der Zukunft weitere Watchparties und Filme zum abstimmen gibt.
Für eine Erklärung wie das alles funktioniert mach einfach /mitgucken für eine lange Erklärung am Stück oder /guides wenn du auswählen möchtest wozu du Infos bekommst.`
const options: MessageCreateOptions = {
allowedMentions: { parse: ['everyone'] },
content: body
}
const message: Message<true> = await announcementChannel.send(options)
await message.react("🎫")
await message.pin()
const options: MessageCreateOptions = {
allowedMentions: { parse: ['everyone'] },
content: body
}
const message: Message<true> = await announcementChannel.send(options)
await message.react("🎫")
await message.pin()
}
export async function manageAnnouncementRoles(guild: Guild, reaction: MessageReaction, requestId: string) {
const guildId = guild.id
logger.info("Managing roles", { guildId, requestId })
const guildId = guild.id
logger.info("Managing roles", { guildId, requestId })
const announcementRole: Role | undefined = (await guild.roles.fetch()).find(role => role.id === config.bot.announcement_role)
if (!announcementRole) {
logger.error(`Could not find announcement role! Aborting! Was looking for role with id: ${config.bot.announcement_role}`, { guildId, requestId })
return
}
const announcementRole: Role | undefined = (await guild.roles.fetch()).find(role => role.id === config.bot.announcement_role)
if (!announcementRole) {
logger.error(`Could not find announcement role! Aborting! Was looking for role with id: ${config.bot.announcement_role}`, { guildId, requestId })
return
}
const usersWhoWantRole: User[] = (await reaction.users.fetch()).filter(user => !user.bot).map(user => user)
const usersWhoWantRole: User[] = (await reaction.users.fetch()).filter(user => !user.bot).map(user => user)
const allUsers = (await guild.members.fetch())
const allUsers = (await guild.members.fetch())
const usersWhoHaveRole: GuildMember[] = allUsers
.filter(member=> member.roles.cache
.find(role => role.id === config.bot.announcement_role) !== undefined)
.map(member => member)
const usersWhoHaveRole: GuildMember[] = allUsers
.filter(member => member.roles.cache
.find(role => role.id === config.bot.announcement_role) !== undefined)
.map(member => member)
const usersWhoNeedRoleRevoked: GuildMember[] = usersWhoHaveRole
.filter(userWhoHas => !usersWhoWantRole.map(wanter => wanter.id).includes(userWhoHas.id))
const usersWhoNeedRoleRevoked: GuildMember[] = usersWhoHaveRole
.filter(userWhoHas => !usersWhoWantRole.map(wanter => wanter.id).includes(userWhoHas.id))
const usersWhoDontHaveRole: GuildMember[] = allUsers
.filter(member => member.roles.cache
.find(role=> role.id === config.bot.announcement_role) === undefined)
.map(member => member)
const usersWhoDontHaveRole: GuildMember[] = allUsers
.filter(member => member.roles.cache
.find(role => role.id === config.bot.announcement_role) === undefined)
.map(member => member)
const usersWhoNeedRole: GuildMember[] = usersWhoDontHaveRole
.filter(userWhoNeeds => usersWhoWantRole.map(wanter => wanter.id).includes(userWhoNeeds.id))
const usersWhoNeedRole: GuildMember[] = usersWhoDontHaveRole
.filter(userWhoNeeds => usersWhoWantRole.map(wanter => wanter.id).includes(userWhoNeeds.id))
logger.debug(`Theses users will get the role removed: ${JSON.stringify(usersWhoNeedRoleRevoked)}`, {guildId, requestId})
logger.debug(`Theses users will get the role added: ${JSON.stringify(usersWhoNeedRole)}`, {guildId, requestId})
logger.debug(`Theses users will get the role removed: ${JSON.stringify(usersWhoNeedRoleRevoked)}`, { guildId, requestId })
logger.debug(`Theses users will get the role added: ${JSON.stringify(usersWhoNeedRole)}`, { guildId, requestId })
usersWhoNeedRoleRevoked.forEach(user => user.roles.remove(announcementRole))
usersWhoNeedRole.forEach(user => user.roles.add(announcementRole))
usersWhoNeedRoleRevoked.forEach(user => user.roles.remove(announcementRole))
usersWhoNeedRole.forEach(user => user.roles.add(announcementRole))
}

View File

@ -1,184 +1,25 @@
import { addDays, differenceInDays, format, isAfter, toDate } from 'date-fns'
import { Guild, GuildScheduledEvent, GuildScheduledEventEditOptions, GuildScheduledEventSetStatusArg, GuildScheduledEventStatus, Message, MessageCreateOptions, TextChannel } from 'discord.js'
import { v4 as uuid } from 'uuid'
import { client } from '../..'
import { config } from '../configuration'
import { Emotes } from '../events/guildScheduledEventCreate'
import { Maybe } from '../interfaces'
import { logger } from '../logger'
import { Command } from '../structures/command'
import { RunOptions } from '../types/commandTypes'
export default new Command({
name: 'closepoll',
description: 'Aktuelle Umfrage für nächste Watchparty beenden und Gewinner in Event eintragen.',
options: [],
run: async (interaction: RunOptions) => {
const command = interaction.interaction
const requestId = uuid()
if (!command.guild) {
logger.error("No guild found in interaction. Cancelling closing request", { requestId })
command.followUp("Es gab leider ein Problem. Ich konnte deine Anfrage nicht bearbeiten :(")
return
}
const guildId = command.guildId
logger.info("Got command for closing poll!", { guildId, requestId })
name: 'closepoll',
description: 'Aktuelle Umfrage für nächste Watchparty beenden und Gewinner in Event eintragen.',
options: [],
run: async (interaction: RunOptions) => {
const command = interaction.interaction
const requestId = uuid()
if (!command.guild) {
logger.error("No guild found in interaction. Cancelling closing request", { requestId })
command.followUp("Es gab leider ein Problem. Ich konnte deine Anfrage nicht bearbeiten :(")
return
}
const guildId = command.guildId
logger.info("Got command for closing poll!", { guildId, requestId })
command.followUp("Alles klar, beende die Umfrage :)")
closePoll(command.guild, requestId)
}
command.followUp("Alles klar, beende die Umfrage :)")
client.voteController.closePoll(command.guild, requestId)
}
})
export async function closePoll(guild: Guild, requestId: string) {
const guildId = guild.id
logger.info("stopping poll", { guildId, requestId })
const announcementChannel: Maybe<TextChannel> = client.getAnnouncementChannelForGuild(guildId)
if (!announcementChannel) {
logger.error("Could not find the textchannel. Unable to close poll.", { guildId, requestId })
return
}
const messages: Message<true>[] = (await announcementChannel.messages.fetch()) //todo: fetch only pinned messages
.map((value) => value)
.filter(message => !message.cleanContent.includes("[Abstimmung beendet]") && message.cleanContent.includes("[Abstimmung]"))
.sort((a, b) => b.createdTimestamp - a.createdTimestamp)
if (!messages || messages.length <= 0) {
logger.info("Could not find any vote messages. Cancelling pollClose", { guildId, requestId })
return
}
const lastMessage: Message<true> = messages[0]
logger.debug(`Found messages: ${JSON.stringify(messages, null, 2)}`, { guildId, requestId })
logger.debug(`Last message: ${JSON.stringify(lastMessage, null, 2)}`, { guildId, requestId })
const votes = await (await getVotesByEmote(lastMessage, guildId, requestId))
.sort((a, b) => b.count - a.count)
logger.debug(`votes: ${JSON.stringify(votes, null, 2)}`, { guildId, requestId })
logger.info("Deleting vote message")
await lastMessage.delete()
const event = await getEvent(guild, guild.id, requestId)
if (event) {
updateEvent(event, votes, guild, guildId, requestId)
sendVoteClosedMessage(event, votes[0].movie, guildId, requestId)
}
//lastMessage.unpin() //todo: uncomment when bot has permission to pin/unpin
}
async function sendVoteClosedMessage(event: GuildScheduledEvent, movie: string, guildId: string, requestId: string) {
const date = event.scheduledStartAt ? format(event.scheduledStartAt, "dd.MM") : "Fehler, event hatte kein Datum"
const time = event.scheduledStartAt ? format(event.scheduledStartAt, "HH:mm") : "Fehler, event hatte kein Datum"
const body = `[Abstimmung beendet] <@&${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,6 +1,7 @@
import { ApplicationCommandOptionType } from 'discord.js'
import { Command } from '../structures/command'
import { RunOptions } from '../types/commandTypes'
import { logger } from '../logger'
export default new Command({
name: 'echo',
description: 'Echoes a text',
@ -13,7 +14,7 @@ export default new Command({
}
],
run: async (interaction: RunOptions) => {
console.log('echo called')
logger.info('echo called')
interaction.interaction.reply(interaction.toString())
}
})

View File

@ -8,76 +8,76 @@ import { RunOptions } from '../types/commandTypes'
import { configureServer, explainRole, installation, loginInfo, useSyncgroup } from './mitgucken'
export default new Command({
name: 'guides',
description: 'Bekomme eine Auswahl von Guides per DM',
options: [],
run: async (interaction: RunOptions) => {
const requestId = uuid()
const guildId = interaction.interaction.guild?.id
logger.info(`Starting guides interaction for user ${interaction.interaction.user.id}`, { requestId, guildId })
name: 'guides',
description: 'Bekomme eine Auswahl von Guides per DM',
options: [],
run: async (interaction: RunOptions) => {
const requestId = uuid()
const guildId = interaction.interaction.guild?.id
logger.info(`Starting guides interaction for user ${interaction.interaction.user.id}`, { requestId, guildId })
const mediaPlayerGuideButton = new ButtonBuilder()
.setCustomId('jfInstallation')
.setLabel('Media Player Installation')
.setStyle(ButtonStyle.Primary)
const mediaPlayerGuideButton = new ButtonBuilder()
.setCustomId('jfInstallation')
.setLabel('Media Player Installation')
.setStyle(ButtonStyle.Primary)
const accountSetupGuideButton = new ButtonBuilder()
.setCustomId('configureServer')
.setLabel('Server einstellen')
.setStyle(ButtonStyle.Primary)
const accountSetupGuideButton = new ButtonBuilder()
.setCustomId('configureServer')
.setLabel('Server einstellen')
.setStyle(ButtonStyle.Primary)
const loginGuideButton = new ButtonBuilder()
.setCustomId('login')
.setLabel('Einloggen')
.setStyle(ButtonStyle.Primary)
const loginGuideButton = new ButtonBuilder()
.setCustomId('login')
.setLabel('Einloggen')
.setStyle(ButtonStyle.Primary)
const useSyncGroupGuideButton = new ButtonBuilder()
.setCustomId('useSyncGroup')
.setLabel('Watch Parties nutzen')
.setStyle(ButtonStyle.Primary)
const useSyncGroupGuideButton = new ButtonBuilder()
.setCustomId('useSyncGroup')
.setLabel('Watch Parties nutzen')
.setStyle(ButtonStyle.Primary)
const roleExplanationButton = new ButtonBuilder()
.setCustomId('explainRoles')
.setLabel('Wie bekomme ich Zugang')
.setStyle(ButtonStyle.Primary)
const roleExplanationButton = new ButtonBuilder()
.setCustomId('explainRoles')
.setLabel('Wie bekomme ich Zugang')
.setStyle(ButtonStyle.Primary)
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(mediaPlayerGuideButton, accountSetupGuideButton, loginGuideButton, useSyncGroupGuideButton, roleExplanationButton)
const row = new ActionRowBuilder<ButtonBuilder>()
.addComponents(mediaPlayerGuideButton, accountSetupGuideButton, loginGuideButton, useSyncGroupGuideButton, roleExplanationButton)
//const userDMchannel = await interaction.interaction.user.createDM()
const response = await interaction.interaction.followUp({
content: `Hier ist eine Auswahl von Guides.`,
components: [row]
})
//const userDMchannel = await interaction.interaction.user.createDM()
const response = await interaction.interaction.followUp({
content: `Hier ist eine Auswahl von Guides.`,
components: [row]
})
try {
try {
const guideSelection = await response.awaitMessageComponent({ time: 60_000 })
const guideSelection = await response.awaitMessageComponent({ time: 60_000 })
if (guideSelection.customId === 'jfInstallation') {
const userDMChannel = await guideSelection.user.createDM()
userDMChannel.send({ embeds: installation(), files: [splashScreen] })
} else if (guideSelection.customId === 'configureServer') {
const userDMChannel = await guideSelection.user.createDM()
userDMChannel.send({ embeds: configureServer(), files: [startScreen, serverConnection] })
} else if (guideSelection.customId === 'login') {
const userDMChannel = await guideSelection.user.createDM()
userDMChannel.send({ embeds: loginInfo(), files: [accountChoice, loginScreen] })
} else if (guideSelection.customId === 'useSyncGroup') {
const userDMChannel = await guideSelection.user.createDM()
userDMChannel.send({ embeds: useSyncgroup(), files: [overview, joingroup, resume, leavegroup] })
} else if (guideSelection.customId === 'explainRoles') {
const userDMChannel = await guideSelection.user.createDM()
userDMChannel.send({ embeds: explainRole() })
}
if (guideSelection.customId === 'jfInstallation') {
const userDMChannel = await guideSelection.user.createDM()
userDMChannel.send({ embeds: installation(), files: [splashScreen] })
} else if (guideSelection.customId === 'configureServer') {
const userDMChannel = await guideSelection.user.createDM()
userDMChannel.send({ embeds: configureServer(), files: [startScreen, serverConnection] })
} else if (guideSelection.customId === 'login') {
const userDMChannel = await guideSelection.user.createDM()
userDMChannel.send({ embeds: loginInfo(), files: [accountChoice, loginScreen] })
} else if (guideSelection.customId === 'useSyncGroup') {
const userDMChannel = await guideSelection.user.createDM()
userDMChannel.send({ embeds: useSyncgroup(), files: [overview, joingroup, resume, leavegroup] })
} else if (guideSelection.customId === 'explainRoles') {
const userDMChannel = await guideSelection.user.createDM()
userDMChannel.send({ embeds: explainRole() })
}
guideSelection.update({ content: "Hab ich dir per DM geschickt :)", components: [] })
guideSelection.update({ content: "Hab ich dir per DM geschickt :)", components: [] })
} catch (error) {
await interaction.interaction.editReply({ content: 'Das dauert mir zu lange, frag mich nochmal wenn du nen Guide brauchst', components: [] });
}
} catch (error) {
await interaction.interaction.editReply({ content: 'Das dauert mir zu lange, frag mich nochmal wenn du nen Guide brauchst', components: [] });
}
}
}
})

View File

@ -7,139 +7,139 @@ import { attachmentImages } from '../..'
const color = 0x0099FF
export default new Command({
name: 'mitgucken',
description: 'Erfahre wie die Verbindung mit Jellyfin funktioniert und eine WatchTogether Gruppe funktioniert.',
options: [],
run: async (interaction: RunOptions) => {
const requestId = uuid()
interaction.interaction.followUp('Ich schicke dir einen Guide per DM!')
const embedList: APIEmbed[] = []
embedList.push(...installation())
embedList.push(...configureServer())
embedList.push(...explainRole())
embedList.push(...loginInfo())
embedList.push(...useSyncgroup())
name: 'mitgucken',
description: 'Erfahre wie die Verbindung mit Jellyfin funktioniert und eine WatchTogether Gruppe funktioniert.',
options: [],
run: async (interaction: RunOptions) => {
const requestId = uuid()
interaction.interaction.followUp('Ich schicke dir einen Guide per DM!')
const embedList: APIEmbed[] = []
embedList.push(...installation())
embedList.push(...configureServer())
embedList.push(...explainRole())
embedList.push(...loginInfo())
embedList.push(...useSyncgroup())
//logger.info(`Trying to use ${splashScreen.name}`, { requestId, guildId: interaction.interaction.guild?.id })
logger.info(`Sending guide to ${interaction.interaction.user.id}`, { requestId, guildId: interaction.interaction.guild?.id })
const userDMchannel = await interaction.interaction.user.createDM()
userDMchannel.send({ embeds: embedList, files: attachmentImages })
}
//logger.info(`Trying to use ${splashScreen.name}`, { requestId, guildId: interaction.interaction.guild?.id })
logger.info(`Sending guide to ${interaction.interaction.user.id}`, { requestId, guildId: interaction.interaction.guild?.id })
const userDMchannel = await interaction.interaction.user.createDM()
userDMchannel.send({ embeds: embedList, files: attachmentImages })
}
})
export function explainRole(): APIEmbed[] {
return [{
color,
title: "Wie du an einen Account kommst",
description: roleExplanation
}]
return [{
color,
title: "Wie du an einen Account kommst",
description: roleExplanation
}]
}
export function installation(): APIEmbed[] {
const embedList: APIEmbed[] = []
// DownloadLink and installation
embedList.push({
color,
title: 'Jellyfin Media Player Installation',
description: 'Du kannst den Jellyfin Media Player von github herunterladen.\n Der Mediaplayer muss genutzt werden, da ein Schauen direkt über das Webinterface den Server zum Schmelzen bringt.\nFühre die Datei aus und installiere den Jellyfin Media Player an den Ort deiner Wahl.',
fields: [
{ name: "Windows", value: "https://github.com/jellyfin/jellyfin-media-player/releases/download/v1.9.1/JellyfinMediaPlayer-1.9.1-windows-x64.exe" },
{ name: "Mac", value: "https://github.com/jellyfin/jellyfin-media-player/releases/download/v1.9.1/JellyfinMediaPlayer-1.9.1-macos-notarized.dmg" }
],
image: {
url: 'attachment://set_splashscreen.png'
}
})
return embedList
const embedList: APIEmbed[] = []
// DownloadLink and installation
embedList.push({
color,
title: 'Jellyfin Media Player Installation',
description: 'Du kannst den Jellyfin Media Player von github herunterladen.\n Der Mediaplayer muss genutzt werden, da ein Schauen direkt über das Webinterface den Server zum Schmelzen bringt.\nFühre die Datei aus und installiere den Jellyfin Media Player an den Ort deiner Wahl.',
fields: [
{ name: "Windows", value: "https://github.com/jellyfin/jellyfin-media-player/releases/download/v1.9.1/JellyfinMediaPlayer-1.9.1-windows-x64.exe" },
{ name: "Mac", value: "https://github.com/jellyfin/jellyfin-media-player/releases/download/v1.9.1/JellyfinMediaPlayer-1.9.1-macos-notarized.dmg" }
],
image: {
url: 'attachment://set_splashscreen.png'
}
})
return embedList
}
export function configureServer(): APIEmbed[] {
const embedList: APIEmbed[] = []
// Login
embedList.push({
color,
title: "Server Auswahl",
description: "Die Jellyfin App kann sich mit mehreren Servern verbinden.\n Hattest du noch nie eine Server Verbindung wähle hier 'Server hinzufügen'.",
image: {
url: 'attachment://start_screen.png'
}
})
// Server Address
embedList.push({
color,
title: "Server Verbindung",
description: "Stelle eine Verbindung zum Hartzarett Jellyfin Server her",
fields: [
{ name: "Server Adresse", value: "`https://media.hartzarett.ruhr`" }
],
image: {
url: 'attachment://server_verbindung.png'
}
})
return embedList
const embedList: APIEmbed[] = []
// Login
embedList.push({
color,
title: "Server Auswahl",
description: "Die Jellyfin App kann sich mit mehreren Servern verbinden.\n Hattest du noch nie eine Server Verbindung wähle hier 'Server hinzufügen'.",
image: {
url: 'attachment://start_screen.png'
}
})
// Server Address
embedList.push({
color,
title: "Server Verbindung",
description: "Stelle eine Verbindung zum Hartzarett Jellyfin Server her",
fields: [
{ name: "Server Adresse", value: "`https://media.hartzarett.ruhr`" }
],
image: {
url: 'attachment://server_verbindung.png'
}
})
return embedList
}
export function loginInfo(): APIEmbed[] {
const embedList: APIEmbed[] = []
// Account choice
embedList.push({
color,
title: "Account Auswahl",
description: "In der Regel sind die Accounts aus Datenschutzgründen versteckt.\nWähle 'Manuelle Anmeldung' aus",
image: {
url: 'attachment://auswahl_anmeldung.png'
}
})
// password screen
embedList.push({
color,
title: "Login",
description: "Melde dich mit dem Usernamen und Passwort an, welches dir von mir zugeschickt wird. Falls du ein neues brauchst führe einmal `/passwort_reset` aus :)",
image: {
url: 'attachment://login_screen.png'
}
})
return embedList
const embedList: APIEmbed[] = []
// Account choice
embedList.push({
color,
title: "Account Auswahl",
description: "In der Regel sind die Accounts aus Datenschutzgründen versteckt.\nWähle 'Manuelle Anmeldung' aus",
image: {
url: 'attachment://auswahl_anmeldung.png'
}
})
// password screen
embedList.push({
color,
title: "Login",
description: "Melde dich mit dem Usernamen und Passwort an, welches dir von mir zugeschickt wird. Falls du ein neues brauchst führe einmal `/passwort_reset` aus :)",
image: {
url: 'attachment://login_screen.png'
}
})
return embedList
}
export function useSyncgroup(): APIEmbed[] {
const embedList: APIEmbed[] = []
embedList.push({
color,
title: "SyncPlay Menü",
image: {
url: 'attachment://jellyfin_ubersicht.png'
},
description: "Im Hauptbildschirm findest du die 'SyncPlay' Einstellungen oben rechts.",
})
// join group
embedList.push({
color,
title: "Gruppe beitreten",
image: {
url: 'attachment://gruppe_beitreten.png'
},
description: "Suche dir aus dem Dropdown die SyncPlay Gruppe aus, die zu deinem Event gehört.",
})
// leave group
embedList.push({
color,
title: "Gruppe verlassen",
image: {
url: 'attachment://gruppe_verlassen.png'
},
description: "Wenn du die Watchparty verlassen möchtest, kannst du das ebenfalls über das Menü oben rechts tun.",
})
//resume playback
embedList.push({
color,
title: "Wiedergabe fortsetzen",
image: {
url: 'attachment://wiedergabe_fortsetzen.png'
},
description: "Wenn du aus der Watchparty rausgeflogen bist, oder die Wiedergabe verlassen hast, kannst du über das Menü oben rechts auch wieder zurückkehren.",
})
return embedList
const embedList: APIEmbed[] = []
embedList.push({
color,
title: "SyncPlay Menü",
image: {
url: 'attachment://jellyfin_ubersicht.png'
},
description: "Im Hauptbildschirm findest du die 'SyncPlay' Einstellungen oben rechts.",
})
// join group
embedList.push({
color,
title: "Gruppe beitreten",
image: {
url: 'attachment://gruppe_beitreten.png'
},
description: "Suche dir aus dem Dropdown die SyncPlay Gruppe aus, die zu deinem Event gehört.",
})
// leave group
embedList.push({
color,
title: "Gruppe verlassen",
image: {
url: 'attachment://gruppe_verlassen.png'
},
description: "Wenn du die Watchparty verlassen möchtest, kannst du das ebenfalls über das Menü oben rechts tun.",
})
//resume playback
embedList.push({
color,
title: "Wiedergabe fortsetzen",
image: {
url: 'attachment://wiedergabe_fortsetzen.png'
},
description: "Wenn du aus der Watchparty rausgeflogen bist, oder die Wiedergabe verlassen hast, kannst du über das Menü oben rechts auch wieder zurückkehren.",
})
return embedList
}
const roleExplanation = `Mit einer Rolle kann dafür gesorgt werden, dass du einen dauerhaften Account auf dem Mediaserver hast. Wende dich bei Bedarf an Samantha oder Markus.\n

View File

@ -2,15 +2,16 @@ import { v4 as uuid } from 'uuid'
import { jellyfinHandler } from "../.."
import { Command } from '../structures/command'
import { RunOptions } from '../types/commandTypes'
import { logger } from '../logger'
export default new Command({
name: 'passwort_reset',
description: 'Ich vergebe dir ein neues Passwort und schicke es dir per DM zu. Kostet auch nix! Versprochen! 😉',
options: [],
run: async (interaction: RunOptions) => {
console.log('PasswortReset called')
logger.info('PasswortReset called')
interaction.interaction.followUp('Yo, ich schick dir eins!')
console.log(JSON.stringify(interaction.interaction.member, null, 2))
logger.info(JSON.stringify(interaction.interaction.member, null, 2))
jellyfinHandler.resetUserPasswort(interaction.interaction.member, uuid())
}
})

View File

@ -2,73 +2,77 @@ import dotenv from "dotenv"
dotenv.config()
interface options {
[k: string]: boolean | number | string | undefined
[k: string]: boolean | number | string | undefined
}
interface bodyParserOptions {
urlEncodedOptions: options,
jsonOptions: options
urlEncodedOptions: options,
jsonOptions: options
}
export interface Config {
server: { bodyParser: bodyParserOptions },
bot: {
debug: boolean
silent: boolean
token: string
guild_id: string
client_id: string
jellfin_token: string
jellyfin_url: string
port: number
workaround_token: string
watcher_role: string
jf_admin_role: string
announcement_role: string
announcement_channel_id: string
jf_collection_id: string
jf_user: string
yavin_collection_id: string
yavin_jellyfin_url: string
yavin_jellyfin_token: string
yavin_jellyfin_collection_user: string
}
server: { bodyParser: bodyParserOptions },
bot: {
debug: boolean
silent: boolean
token: string
guild_id: string
client_id: string
jellfin_token: string
jellyfin_url: string
port: number
workaround_token: string
watcher_role: string
jf_admin_role: string
announcement_role: string
announcement_channel_id: string
jf_collection_id: string
jf_user: string
yavin_collection_id: string
yavin_jellyfin_url: string
yavin_jellyfin_token: string
yavin_jellyfin_collection_user: string
random_movie_count: number
reroll_retains_top_picks: boolean
}
}
export const config: Config = {
server: {
bodyParser: {
urlEncodedOptions: {
inflate: true,
limit: '5mb',
type: 'application/x-www-form-urlencoded',
extended: true,
parameterLimit: 1000
},
jsonOptions: {
inflate: true,
limit: '5mb',
type: 'application/json',
strict: true
}
}
},
bot: {
debug: true,
silent: false,
port: 1234,
token: process.env.BOT_TOKEN ?? "",
guild_id: process.env.GUILD_ID ?? "",
client_id: process.env.CLIENT_ID ?? "",
jellfin_token: process.env.JELLYFIN_TOKEN ?? "",
jellyfin_url: process.env.JELLYFIN_URL ?? "",
workaround_token: process.env.TOKEN ?? "",
watcher_role: process.env.WATCHER_ROLE ?? "",
jf_admin_role: process.env.ADMIN_ROLE ?? "",
announcement_role: process.env.WATCHPARTY_ANNOUNCEMENT_ROLE ?? "",
announcement_channel_id: process.env.CHANNEL_ID ?? "",
jf_collection_id: process.env.JELLYFIN_COLLECTION_ID ?? "",
yavin_collection_id: process.env.YAVIN_COLLECTION_ID ?? "",
yavin_jellyfin_url: process.env.YAVIN_JELLYFIN_URL ?? "",
yavin_jellyfin_token: process.env.YAVIN_TOKEN ?? "",
yavin_jellyfin_collection_user: process.env.YAVIN_COLLECTION_USER ?? "",
jf_user: process.env.JELLYFIN_USER ?? ""
}
server: {
bodyParser: {
urlEncodedOptions: {
inflate: true,
limit: '5mb',
type: 'application/x-www-form-urlencoded',
extended: true,
parameterLimit: 1000
},
jsonOptions: {
inflate: true,
limit: '5mb',
type: 'application/json',
strict: true
}
}
},
bot: {
debug: true,
silent: false,
port: 1234,
token: process.env.BOT_TOKEN ?? "",
guild_id: process.env.GUILD_ID ?? "",
client_id: process.env.CLIENT_ID ?? "",
jellfin_token: process.env.JELLYFIN_TOKEN ?? "",
jellyfin_url: process.env.JELLYFIN_URL ?? "",
workaround_token: process.env.TOKEN ?? "TOKEN",
watcher_role: process.env.WATCHER_ROLE ?? "WATCHER_ROLE",
jf_admin_role: process.env.ADMIN_ROLE ?? "ADMIN_ROLE",
announcement_role: process.env.WATCHPARTY_ANNOUNCEMENT_ROLE ?? "ANNOUNCE_ROLE",
announcement_channel_id: process.env.CHANNEL_ID ?? "ANNOUNCE_CHANNEL",
jf_collection_id: process.env.JELLYFIN_COLLECTION_ID ?? "",
yavin_collection_id: process.env.YAVIN_COLLECTION_ID ?? "",
yavin_jellyfin_url: process.env.YAVIN_JELLYFIN_URL ?? "",
yavin_jellyfin_token: process.env.YAVIN_TOKEN ?? "",
yavin_jellyfin_collection_user: process.env.YAVIN_COLLECTION_USER ?? "",
jf_user: process.env.JELLYFIN_USER ?? "",
random_movie_count: parseInt(process.env.RANDOM_MOVIE_COUNT ?? "5") ?? 5,
reroll_retains_top_picks: process.env.REROLL_RETAIN === "true"
}
}

17
server/constants.ts Normal file
View File

@ -0,0 +1,17 @@
export enum ValidVoteEmotes { "1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6⃣", "7⃣", "8⃣", "9⃣", "🔟" }
export const NONE_OF_THAT = "❌"
// WIP
export const Emoji = {
"one": "\u0031\uFE0F\u20E3",
"two": "\u0032\uFE0F\u20E3",
"three": "\u0033\uFE0F\u20E3",
"four": "\u0034\uFE0F\u20E3",
"five": "\u0035\uFE0F\u20E3",
"six": "\u0036\uFE0F\u20E3",
"seven": "\u0037\uFE0F\u20E3",
"eight": "\u0038\uFE0F\u20E3",
"nine": "\u0039\uFE0F\u20E3",
"ten": "\uD83D\uDD1F",
"ticket": "🎫"
}

View File

@ -0,0 +1,52 @@
import { GuildScheduledEvent, TextChannel } from "discord.js";
import { v4 as uuid } from "uuid";
import { client } from "../..";
import { config } from "../configuration";
import { createDateStringFromEvent } from "../helper/dateHelper";
import { Maybe } from "../interfaces";
import { logger } from "../logger";
export const name = 'guildScheduledEventCreate'
export async function execute(event: GuildScheduledEvent) {
const guildId = event.guildId
const requestId = uuid()
try {
if (!event.description) {
logger.debug("Got GuildScheduledEventCreate event. But has no description. Aborting.")
return
}
if (event.description.includes("!wp")) {
logger.info("Got manual create event of watchparty event!", { guildId, requestId })
if (event.description.includes("!private")) {
logger.info("Event description contains \"!private\". Won't announce.", { guildId, requestId })
return
}
const channel: Maybe<TextChannel> = client.getAnnouncementChannelForGuild(guildId)
if (!channel) {
logger.error("Could not obtain announcement channel. Aborting announcement.", { guildId, requestId })
return
}
if (!event.scheduledStartAt) {
logger.error('Event has no start date, bailing out')
return
}
const message = `[Watchparty] https://discord.com/events/${event.guildId}/${event.id} \nHey <@&${config.bot.announcement_role}>, wir gucken ${event.name} ${createDateStringFromEvent(event.scheduledStartAt, guildId, requestId)}`
channel.send(message)
} else {
logger.debug("Got GuildScheduledEventCreate event but no !wp in description. Not creating manual wp announcement.", { guildId, requestId })
}
} catch (error) {
// sendFailureDM(error)
logger.error(<string>error, { guildId, requestId })
}
}

View File

@ -0,0 +1,44 @@
import { GuildScheduledEvent, TextChannel } from "discord.js";
import { v4 as uuid } from "uuid";
import { client, yavinJellyfinHandler } from "../..";
import { Maybe } from "../interfaces";
import { logger } from "../logger";
export const name = 'guildScheduledEventCreate'
export async function execute(event: GuildScheduledEvent) {
const requestId = uuid()
if (event.name.toLowerCase().includes("!nextwp")) {
logger.info("Event was a placeholder event to start a new watchparty and voting. Creating vote!", { guildId: event.guildId, requestId })
logger.debug("Renaming event", { guildId: event.guildId, requestId })
event.edit({ name: "Watchparty - Voting offen" })
const movies = await yavinJellyfinHandler.getRandomMovieNames(5, event.guildId, requestId)
logger.info(`Got ${movies.length} random movies. Creating voting`, { guildId: event.guildId, requestId })
logger.debug(`Movies: ${JSON.stringify(movies)}`, { guildId: event.guildId, requestId })
const announcementChannel: Maybe<TextChannel> = client.getAnnouncementChannelForGuild(event.guildId)
if (!announcementChannel) {
logger.error("Could not find announcement channel. Aborting", { guildId: event.guildId, requestId })
return
}
logger.debug(`Found channel ${JSON.stringify(announcementChannel, null, 2)}`, { guildId: event.guildId, requestId })
if (!event.scheduledStartAt) {
logger.info("Event does not have a start date, cancelling", { guildId: event.guildId, requestId })
return
}
const sentMessage = await client.voteController.prepareAndSendVoteMessage({
movies,
startDate: event.scheduledStartAt,
event,
announcementChannel,
pinAfterSending: true
},
event.guildId,
requestId)
logger.debug(JSON.stringify(sentMessage))
}
}

View File

@ -0,0 +1,53 @@
import { Collection, GuildScheduledEvent, GuildScheduledEventStatus, Message } from "discord.js";
import { v4 as uuid } from "uuid";
import { client } from "../..";
import { logger } from "../logger";
import { isInitialAnnouncement } from "../helper/messageIdentifiers";
export const name = 'guildScheduledEventUpdate'
export async function execute(oldEvent: GuildScheduledEvent, newEvent: GuildScheduledEvent) {
const requestId = uuid()
try {
if (!newEvent.guild) {
logger.error("Event has no guild, aborting.", { guildId: newEvent.guildId, requestId })
return
}
const guildId = newEvent.guildId
if (newEvent.description?.toLowerCase().includes("!wp") && newEvent.status === GuildScheduledEventStatus.Completed) {
logger.info("A watchparty ended. Cleaning up announcements!", { guildId, requestId })
const announcementChannel = client.getAnnouncementChannelForGuild(newEvent.guild.id)
if (!announcementChannel) {
logger.error("Could not find announcement channel. Aborting", { guildId: newEvent.guild.id, requestId })
return
}
const events = await newEvent.guild.scheduledEvents.fetch()
const wpAnnouncements = (await announcementChannel.messages.fetch()).filter(message => !isInitialAnnouncement(message))
const announcementsWithoutEvent = filterAnnouncementsByPendingWPs(wpAnnouncements, events)
logger.info(`Deleting ${announcementsWithoutEvent.length} announcements.`, { guildId, requestId })
announcementsWithoutEvent.forEach(message => message.delete())
}
} catch (error) {
logger.error(<string>error, { guildId: newEvent.guildId, requestId })
}
}
function filterAnnouncementsByPendingWPs(messages: Collection<string, Message<true>>, events: Collection<string, GuildScheduledEvent<GuildScheduledEventStatus>>): Message<true>[] {
const filteredMessages: Message<true>[] = []
for (const message of messages.values()) {
let foundEventForMessage = false
for (const event of events.values()) {
if (message.cleanContent.includes(event.id)) { //announcement always has eventid because of eventbox
foundEventForMessage = true
}
}
if (!foundEventForMessage) {
filteredMessages.push(message)
}
}
return filteredMessages
}

View File

@ -1,30 +0,0 @@
import { Collection, GuildMember } from "discord.js"
import { filterRolesFromMemberUpdate, getGuildSpecificTriggerRoleId } from "../helper/roleFilter"
import { ChangedRoles, PermissionLevel } from "../interfaces"
import { jellyfinHandler } from "../.."
import { v4 as uuid } from "uuid"
export const name = 'guildMemberUpdate'
export async function execute(oldMember: GuildMember, newMember: GuildMember) {
try {
const requestId = uuid()
const changedRoles: ChangedRoles = filterRolesFromMemberUpdate(oldMember, newMember)
const triggerRoleIds: Collection<string, PermissionLevel> = getGuildSpecificTriggerRoleId()
triggerRoleIds.forEach((level, key) => {
const addedRoleMatches = changedRoles.addedRoles.find(aRole => aRole.id === key)
if (addedRoleMatches) {
jellyfinHandler.upsertUser(newMember, level, requestId)
}
const removedRoleMatches = changedRoles.removedRoles.find(rRole => rRole.id === key)
if (removedRoleMatches) {
jellyfinHandler.removeUser(newMember, level, requestId)
}
})
} catch (error) {
console.error(error)
}
}

View File

@ -1,63 +0,0 @@
import { format } from "date-fns";
import { GuildScheduledEvent, Message, MessageCreateOptions, TextChannel } from "discord.js";
import { ScheduledTask } from "node-cron";
import { v4 as uuid } from "uuid";
import { client, yavinJellyfinHandler } from "../..";
import { config } from "../configuration";
import { Maybe } from "../interfaces";
import { logger } from "../logger";
export const name = 'guildScheduledEventCreate'
export enum Emotes { "1⃣", "2⃣", "3⃣", "4⃣", "5⃣", "6⃣", "7⃣", "8⃣", "9⃣", "🔟" }
export let task: ScheduledTask | undefined
export async function execute(event: GuildScheduledEvent) {
const requestId = uuid()
logger.debug(`New event created: ${JSON.stringify(event, null, 2)}`, { guildId: event.guildId, requestId })
if (event.name.toLowerCase().includes("!nextwp")) {
logger.info("Event was a placeholder event to start a new watchparty and voting. Creating vote!", { guildId: event.guildId, requestId })
logger.debug("Renaming event", { guildId: event.guildId, requestId })
event.edit({ name: "Watchparty - Voting offen" })
const movies = await yavinJellyfinHandler.getRandomMovieNames(5, event.guildId, requestId)
logger.info(`Got ${movies.length} random movies. Creating voting`, { guildId: event.guildId, requestId })
logger.debug(`Movies: ${JSON.stringify(movies)}`, { guildId: event.guildId, requestId })
const announcementChannel: Maybe<TextChannel> = client.getAnnouncementChannelForGuild(event.guildId)
if(!announcementChannel) {
logger.error("Could not find announcement channel. Aborting", { guildId: event.guildId, requestId })
return
}
logger.debug(`Found channel ${JSON.stringify(announcementChannel, null, 2)}`, { guildId: event.guildId, requestId })
if(!event.scheduledStartAt) {
logger.info("EVENT DOES NOT HAVE STARTDATE; CANCELLING", {guildId: event.guildId, requestId})
return
}
const date = format(event.scheduledStartAt, "dd.MM")
const time = format(event.scheduledStartAt, "HH:mm")
let message = `[Abstimmung]\n<@&${config.bot.announcement_role}> Es gibt eine neue Abstimmung für die nächste Watchparty am ${date} um ${time}}! Stimme hierunter für den nächsten Film ab!\n`
for (let i = 0; i < movies.length; i++) {
message = message.concat(Emotes[i]).concat(": ").concat(movies[i]).concat("\n")
}
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.pin() //todo: uncomment when bot has permission to pin messages. Also update closepoll.ts to only fetch pinned messages
}
}

View File

@ -1,64 +0,0 @@
import { GuildMember, GuildScheduledEvent, GuildScheduledEventStatus } from "discord.js";
import { v4 as uuid } from "uuid";
import { client, jellyfinHandler } from "../..";
import { getGuildSpecificTriggerRoleId } from "../helper/roleFilter";
import { logger } from "../logger";
export const name = 'guildScheduledEventUpdate'
export async function execute(oldEvent: GuildScheduledEvent, newEvent: GuildScheduledEvent) {
try {
const requestId = uuid()
logger.debug(`Got scheduledEvent update. New Event: ${JSON.stringify(newEvent, null, 2)}`, { guildId: newEvent.guildId, requestId })
if (!newEvent.guild) {
logger.error("Event has no guild, aborting.", { guildId: newEvent.guildId, requestId })
return
}
if (newEvent.description?.toLowerCase().includes("!wp") && [GuildScheduledEventStatus.Active, GuildScheduledEventStatus.Completed].includes(newEvent.status)) {
const roles = getGuildSpecificTriggerRoleId().map((key, value) => value)
const eventMembers = (await newEvent.fetchSubscribers({ withMember: true })).filter(member => !member.member.roles.cache.hasAny(...roles)).map((value) => value.member)
const channelMembers = newEvent.channel?.members.filter(member => !member.roles.cache.hasAny(...roles)).map((value) => value)
const allMembers = eventMembers.concat(channelMembers ?? [])
const members: GuildMember[] = []
for (const member of allMembers) {
if (!members.find(x => x.id == member.id))
members.push(member)
}
if (newEvent.status === GuildScheduledEventStatus.Active)
createJFUsers(members, newEvent.name, requestId)
else {
const announcementChannel = await client.getAnnouncementChannelForGuild(newEvent.guild.id)
if(!announcementChannel) {
logger.error("Could not find announcement channel. Aborting", { guildId: newEvent.guild.id, requestId })
return
}
const announcements = (await announcementChannel.messages.fetch()).filter(message => !message.pinned)
announcements.forEach(message => message.delete())
members.forEach(member => {
member.createDM().then(channel => channel.send(`Die Watchparty ist vorbei, dein Account wurde wieder gelöscht. Wenn du einen permanenten Account haben möchtest, melde dich bei Samantha oder Marukus.`))
})
deleteJFUsers(newEvent.guildId, requestId)
}
}
} catch (error) {
logger.error(error)
}
}
async function createJFUsers(members: GuildMember[], movieName: string, requestId?: string) {
logger.info(`Creating users for: \n ${JSON.stringify(members, null, 2)}`)
members.forEach(member => {
member.createDM().then(channel => channel.send(`Hey! Du hast dich für die Watchparty von ${movieName} angemeldet! Es geht gleich los!`))
jellyfinHandler.upsertUser(member, "TEMPORARY", requestId)
})
}
async function deleteJFUsers(guildId: string, requestId?: string) {
logger.info(`Watchparty ended, deleting tmp users`, { guildId, requestId })
jellyfinHandler.purge(guildId, requestId)
}

View File

@ -0,0 +1,46 @@
import { Message, MessageReaction, User } from "discord.js";
import { logger, newRequestId, noGuildId } from "../logger";
import { Emoji, ValidVoteEmotes, NONE_OF_THAT } from "../constants";
import { client } from "../..";
import { isInitialAnnouncement, isVoteMessage } from "../helper/messageIdentifiers";
export const name = 'messageReactionAdd'
export async function execute(messageReaction: MessageReaction, user: User) {
if (user.id == client.user?.id) {
logger.info('Skipping bot reaction')
return
}
const requestId = newRequestId()
const guildId = messageReaction.message.inGuild() ? messageReaction.message.guildId : noGuildId
const reactedUponMessage: Message = messageReaction.message.partial ? await messageReaction.message.fetch() : messageReaction.message
if (!messageReaction.message.guild) {
logger.warn(`Received messageReactionAdd on non-guild message.`, { requestId })
return
}
logger.info(`Got reaction on message`, { requestId, guildId })
//logger.debug(`reactedUponMessage payload: ${JSON.stringify(reactedUponMessage)}`)
logger.info(`emoji: ${messageReaction.emoji.toString()}`)
if (!Object.values(ValidVoteEmotes).includes(messageReaction.emoji.toString()) && messageReaction.emoji.toString() !== NONE_OF_THAT) {
logger.info(`${messageReaction.emoji.toString()} currently not handled`)
return
}
logger.info(`Found a match for ${messageReaction.emoji.toString()}`)
if (isVoteMessage(reactedUponMessage)) {
if (messageReaction.emoji.toString() === NONE_OF_THAT) {
logger.info(`Reaction is NONE_OF_THAT on a vote message. Handling`, { requestId, guildId })
return client.voteController.handleNoneOfThatVote(messageReaction, reactedUponMessage, requestId, guildId)
}
}
else if (isInitialAnnouncement(reactedUponMessage)) {
if (messageReaction.emoji.toString() === Emoji.ticket) {
logger.error(`Got a role emoji. Not implemented yet. ${reactedUponMessage.id}`)
}
return
}
}

View File

@ -0,0 +1,30 @@
import { Collection, GuildMember } from "discord.js"
import { filterRolesFromMemberUpdate, getGuildSpecificTriggerRoleId } from "../helper/roleFilter"
import { ChangedRoles, PermissionLevel } from "../interfaces"
import { jellyfinHandler } from "../.."
import { v4 as uuid } from "uuid"
export const name = 'guildMemberUpdate'
export async function execute(oldMember: GuildMember, newMember: GuildMember) {
try {
const requestId = uuid()
const changedRoles: ChangedRoles = filterRolesFromMemberUpdate(oldMember, newMember)
const triggerRoleIds: Collection<string, PermissionLevel> = getGuildSpecificTriggerRoleId()
triggerRoleIds.forEach((level, key) => {
const addedRoleMatches = changedRoles.addedRoles.find(aRole => aRole.id === key)
if (addedRoleMatches) {
jellyfinHandler.upsertUser(newMember, level, requestId)
}
const removedRoleMatches = changedRoles.removedRoles.find(rRole => rRole.id === key)
if (removedRoleMatches) {
jellyfinHandler.removeUser(newMember, level, requestId)
}
})
} catch (error) {
console.error(error)
}
}

View File

@ -0,0 +1,59 @@
import { VoiceState } from "discord.js";
import { v4 as uuid } from "uuid";
import { jellyfinHandler } from "../..";
import { UserUpsertResult } from "../jellyfin/handler";
import { logger } from "../logger";
export const name = 'voiceStateUpdate'
export async function execute(oldState: VoiceState, newState: VoiceState) {
try {
logger.info(JSON.stringify(newState, null, 2))
//ignore events like mute/unmute
if (newState.channel?.id === oldState.channel?.id) {
logger.info("Not handling VoiceState event because channelid of old and new was the same (i.e. mute/unmute event)")
return
}
const scheduledEvents = (await newState.guild.scheduledEvents.fetch())
.filter((key) => key.description?.toLowerCase().includes("!wp") && key.isActive())
.map((key) => key)
const scheduledEventUsers = (await Promise.all(scheduledEvents.map(event => event.fetchSubscribers({ withMember: true }))))
//Dont handle users, that are already subscribed to the event. We only want to handle unsubscribed users here
let userFound = false;
scheduledEventUsers.forEach(collection => {
collection.each(key => {
logger.info(JSON.stringify(key, null, 2))
if (key.member.user.id === newState.member?.user.id)
userFound = true;
})
})
if (userFound) {
logger.info(`Not handling VoiceState event because user was already subscribed and got an account from there. User: ${JSON.stringify(newState.member, null, 2)}`)
return
}
if (scheduledEvents.find(event => event.channelId === newState.channelId)) {
if (newState.member) {
logger.info("YO! Da ist jemand dem Channel mit dem Event beigetreten, ich kümmer mich mal um nen Account!")
const result = await jellyfinHandler.upsertUser(newState.member, "TEMPORARY", uuid())
if (result === UserUpsertResult.created) {
newState.member.createDM().then(channel => channel.send(`Hey! Du bist unserer Watchparty beigetreten, ich hab dir gerade die Zugangsdaten für den Mediaserver geschickt!`))
} else {
newState.member.createDM().then(channel => channel.send(`Hey! Du bist unserer Watchparty beigetreten aber du hast bereits einen Account. Falls du ein neues Passwort brauchst nutze /reset_passwort!`))
}
} else {
logger.error("WTF? Expected Member?? When doing things")
}
} else {
logger.info("VoiceState channelId was not the id of any channel with events")
}
} catch (error) {
logger.error(error)
}
}

View File

@ -0,0 +1,58 @@
import { GuildMember, GuildScheduledEvent, GuildScheduledEventStatus } from "discord.js";
import { v4 as uuid } from "uuid";
import { jellyfinHandler } from "../..";
import { getGuildSpecificTriggerRoleId } from "../helper/roleFilter";
import { logger } from "../logger";
export const name = 'guildScheduledEventUpdate'
export async function execute(oldEvent: GuildScheduledEvent, newEvent: GuildScheduledEvent) {
try {
const requestId = uuid()
// logger.debug(`Got scheduledEvent update. New Event: ${JSON.stringify(newEvent, null, 2)}`, { guildId: newEvent.guildId, requestId })
if (!newEvent.guild) {
logger.error("Event has no guild, aborting.", { guildId: newEvent.guildId, requestId })
return
}
if (newEvent.description?.toLowerCase().includes("!wp") && [GuildScheduledEventStatus.Active, GuildScheduledEventStatus.Completed].includes(newEvent.status)) {
const roles = getGuildSpecificTriggerRoleId().map((key, value) => value)
const eventMembers = (await newEvent.fetchSubscribers({ withMember: true })).filter(member => !member.member.roles.cache.hasAny(...roles)).map((value) => value.member)
const channelMembers = newEvent.channel?.members.filter(member => !member.roles.cache.hasAny(...roles)).map((value) => value)
const allMembers = eventMembers.concat(channelMembers ?? [])
const members: GuildMember[] = []
for (const member of allMembers) {
if (!members.find(x => x.id == member.id))
members.push(member)
}
if (newEvent.status === GuildScheduledEventStatus.Active)
createJFUsers(members, newEvent.name, requestId)
else {
members.forEach(member => {
member.createDM().then(channel => channel.send(`Die Watchparty ist vorbei, dein Account wurde wieder gelöscht. Wenn du einen permanenten Account haben möchtest, melde dich bei Samantha oder Marukus.`))
})
deleteJFUsers(newEvent.guildId, requestId)
}
}
} catch (error) {
logger.error(error)
}
}
async function createJFUsers(members: GuildMember[], movieName: string, requestId?: string) {
logger.info(`Creating users for: \n ${JSON.stringify(members, null, 2)}`)
members.forEach(member => {
member.createDM().then(channel => channel.send(`Hey! Du hast dich für die Watchparty von ${movieName} angemeldet! Es geht gleich los!`))
jellyfinHandler.upsertUser(member, "TEMPORARY", requestId)
})
}
async function deleteJFUsers(guildId: string, requestId?: string) {
logger.info(`Watchparty ended, deleting tmp users`, { guildId, requestId })
jellyfinHandler.purge(guildId, requestId)
}

View File

@ -5,17 +5,17 @@ import { logger } from "../logger"
export const name = 'interactionCreate'
export async function execute(interaction: ExtendedInteraction) {
//console.dir(interaction, { depth: null })
if (interaction.isCommand()) {
logger.info(`Interaction is a command.`, { guildId: interaction.guild?.id })
await interaction.deferReply({ ephemeral: true })
const command = client.commands.get(interaction.commandName)
if (!command)
return interaction.followUp('Invalid command')
command.run({
args: interaction.options as CommandInteractionOptionResolver,
client,
interaction
})
}
//console.dir(interaction, { depth: null })
if (interaction.isCommand()) {
logger.info(`Interaction is a command.`, { guildId: interaction.guild?.id })
await interaction.deferReply({ ephemeral: true })
const command = client.commands.get(interaction.commandName)
if (!command)
return interaction.followUp('Invalid command')
command.run({
args: interaction.options as CommandInteractionOptionResolver,
client,
interaction
})
}
}

View File

@ -1,6 +1,7 @@
import { Message } from "discord.js"
import { logger } from "../logger"
export const name = 'messageCreate'
export function execute(message: Message) {
console.log(`${JSON.stringify(message)} has been created`)
logger.info(`${JSON.stringify(message)} has been created`)
}

View File

@ -1,59 +0,0 @@
import { VoiceState } from "discord.js";
import { v4 as uuid } from "uuid";
import { jellyfinHandler } from "../..";
import { UserUpsertResult } from "../jellyfin/handler";
import { logger } from "../logger";
export const name = 'voiceStateUpdate'
export async function execute(oldState: VoiceState, newState: VoiceState) {
try {
logger.info(JSON.stringify(newState, null, 2))
//ignore events like mute/unmute
if(newState.channel?.id === oldState.channel?.id) {
logger.info("Not handling VoiceState event because channelid of old and new was the same (i.e. mute/unmute event)")
return
}
const scheduledEvents = (await newState.guild.scheduledEvents.fetch())
.filter((key) => key.description?.toLowerCase().includes("!wp") && key.isActive())
.map((key) => key)
const scheduledEventUsers = (await Promise.all(scheduledEvents.map(event => event.fetchSubscribers({withMember: true}))))
//Dont handle users, that are already subscribed to the event. We only want to handle unsubscribed users here
let userFound = false;
scheduledEventUsers.forEach(collection => {
collection.each(key => {
logger.info(JSON.stringify(key, null, 2))
if(key.member.user.id === newState.member?.user.id)
userFound = true;
})
})
if(userFound) {
logger.info(`Not handling VoiceState event because user was already subscribed and got an account from there. User: ${JSON.stringify(newState.member, null, 2)}`)
return
}
if (scheduledEvents.find(event => event.channelId === newState.channelId)) {
if(newState.member){
logger.info("YO! Da ist jemand dem Channel mit dem Event beigetreten, ich kümmer mich mal um nen Account!")
const result = await jellyfinHandler.upsertUser(newState.member, "TEMPORARY", uuid())
if (result === UserUpsertResult.created) {
newState.member.createDM().then(channel => channel.send(`Hey! Du bist unserer Watchparty beigetreten, ich hab dir gerade die Zugangsdaten für den Mediaserver geschickt!`))
} else {
newState.member.createDM().then(channel => channel.send(`Hey! Du bist unserer Watchparty beigetreten aber du hast bereits einen Account. Falls du ein neues Passwort brauchst nutze /reset_passwort!`))
}
} else {
logger.error("WTF? Expected Member?? When doing things")
}
} else {
logger.info("VoiceState channelId was not the id of any channel with events")
}
}catch(error){
logger.error(error)
}
}

View File

@ -0,0 +1,23 @@
import { format, isToday } from "date-fns";
import { utcToZonedTime } from "date-fns-tz"
import { logger } from "../logger";
import de from "date-fns/locale/de";
import { Maybe } from "../interfaces";
export function createDateStringFromEvent(eventStartDate: Maybe<Date>, requestId: string, guildId?: string): string {
if (!eventStartDate) {
logger.error("Event has no start. Cannot create dateString.", { guildId, requestId })
return `"habe keinen Startzeitpunkt ermitteln können"`
}
const timeZone = 'Europe/Berlin'
const zonedDateTime = utcToZonedTime(eventStartDate, timeZone)
const time = format(zonedDateTime, "HH:mm", { locale: de })
if (isToday(zonedDateTime)) {
return `heute um ${time}`
}
const date = format(zonedDateTime, "eeee dd.MM.", { locale: de })
return `am ${date} um ${time}`
}

View File

@ -0,0 +1,20 @@
import { Message } from "discord.js";
// branded types to differentiate objects of identical Type but different contents
export type VoteEndMessage = Message<true> & { readonly __brand: 'voteend' }
export type AnnouncementMessage = Message<true> & { readonly __brand: 'announcement' }
export type VoteMessage = Message<true> & { readonly __brand: 'vote' }
export type KnownDiscordMessage = VoteMessage | VoteEndMessage | AnnouncementMessage
export function isVoteMessage(message: Message): message is VoteMessage {
return message.cleanContent.includes('[Abstimmung]')
}
export function isInitialAnnouncement(message: Message): message is AnnouncementMessage {
return message.cleanContent.includes("[initial]")
}
export function isVoteEndedMessage(message: Message): message is VoteEndMessage {
return message.cleanContent.includes("[Abstimmung beendet]")
}

View File

@ -1,24 +1,31 @@
import { Collection, GuildMember } from "discord.js"
import { ChangedRoles, PermissionLevel } from "../interfaces"
import { Collection, Guild, GuildMember, Role } from "discord.js"
import { ChangedRoles, Maybe, PermissionLevel } from "../interfaces"
import { logger } from "../logger"
import { config } from "../configuration"
export function filterRolesFromMemberUpdate(oldMember: GuildMember, newMember: GuildMember): ChangedRoles {
const oldRoles = oldMember.roles.cache
const newRoles = newMember.roles.cache
const oldRoles = oldMember.roles.cache
const newRoles = newMember.roles.cache
const removedRoles = oldRoles.filter(x => newRoles.find(y => y.id === x.id) == undefined)
const addedRoles = newRoles.filter(x => oldRoles.find(y => y.id === x.id) == undefined)
logger.info(`Member ${oldMember.id} RemovedRoles: ${removedRoles.map(x => x.name)}`, { guildId: oldMember.guild.id })
logger.info(`Member ${oldMember.id} AddedRoles: ${addedRoles.map(x => x.name)}`, { guildId: oldMember.guild.id })
const removedRoles = oldRoles.filter(x => newRoles.find(y => y.id === x.id) == undefined)
const addedRoles = newRoles.filter(x => oldRoles.find(y => y.id === x.id) == undefined)
logger.info(`Member ${oldMember.id} RemovedRoles: ${removedRoles.map(x => x.name)}`, { guildId: oldMember.guild.id })
logger.info(`Member ${oldMember.id} AddedRoles: ${addedRoles.map(x => x.name)}`, { guildId: oldMember.guild.id })
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> {
const outVal = new Collection<string, PermissionLevel>()
outVal.set(config.bot.watcher_role, "VIEWER")
outVal.set(config.bot.jf_admin_role, "ADMIN")
return outVal
const outVal = new Collection<string, PermissionLevel>()
outVal.set(config.bot.watcher_role, "VIEWER")
outVal.set(config.bot.jf_admin_role, "ADMIN")
return outVal
}

View File

@ -1,12 +1,13 @@
import { CustomError, errorCodes } from "../interfaces"
import { logger } from "../logger"
import { ExtendedClient } from "../structures/client"
export async function sendFailureDM(creatorMessage: string, client: ExtendedClient, creatorId?: string): Promise<void> {
if (!creatorId) throw new CustomError('No creator ID present', errorCodes.no_creator_id)
const creator = await client.users.fetch(creatorId)
console.log(`Creator ${JSON.stringify(creator)}`)
if (creator)
if (!creator.dmChannel)
await creator.createDM()
await creator.dmChannel?.send(creatorMessage)
if (!creatorId) throw new CustomError('No creator ID present', errorCodes.no_creator_id)
const creator = await client.users.fetch(creatorId)
logger.info(`Creator ${JSON.stringify(creator)}`)
if (creator)
if (!creator.dmChannel)
await creator.createDM()
await creator.dmChannel?.send(creatorMessage)
}

View File

@ -0,0 +1,361 @@
import { Guild, GuildScheduledEvent, GuildScheduledEventEditOptions, GuildScheduledEventSetStatusArg, GuildScheduledEventStatus, Message, MessageCreateOptions, MessageReaction, TextChannel } from "discord.js"
import { ValidVoteEmotes, NONE_OF_THAT } from "../constants"
import { logger, newRequestId } from "../logger"
import { getMembersWithRoleFromGuild } from "./roleFilter"
import { config } from "../configuration"
import { VoteMessage, isVoteEndedMessage, isVoteMessage } from "./messageIdentifiers"
import { createDateStringFromEvent } from "./dateHelper"
import { Maybe, voteMessageInputInformation as prepareVoteMessageInput } from "../interfaces"
import format from "date-fns/format"
import toDate from "date-fns/toDate"
import differenceInDays from "date-fns/differenceInDays"
import addDays from "date-fns/addDays"
import isAfter from "date-fns/isAfter"
import { ExtendedClient } from "../structures/client"
import { JellyfinHandler } from "../jellyfin/handler"
export type Vote = {
emote: string, //todo habs nicht hinbekommen hier Emotes zu nutzen
count: number,
movie: string
}
export type VoteMessageInfo = {
votes: Vote[],
event: GuildScheduledEvent,
}
export default class VoteController {
private client: ExtendedClient
private yavinJellyfinHandler: JellyfinHandler
public constructor(_client: ExtendedClient, _yavin: JellyfinHandler) {
this.client = _client
this.yavinJellyfinHandler = _yavin
}
public async handleNoneOfThatVote(messageReaction: MessageReaction, reactedUponMessage: VoteMessage, requestId: string, guildId: string) {
if (!messageReaction.message.guild) return 'No guild'
const guild = messageReaction.message.guild
logger.debug(`${reactedUponMessage.id} is vote message`, { requestId, guildId })
const watcherRoleMember = await getMembersWithRoleFromGuild(config.bot.announcement_role, messageReaction.message.guild)
logger.info("ROLE MEMBERS " + JSON.stringify(watcherRoleMember), { requestId, guildId })
const watcherRoleMemberCount = watcherRoleMember.size
logger.info(`MEMBER COUNT: ${watcherRoleMemberCount}`, { requestId, guildId })
const noneOfThatReactions = reactedUponMessage.reactions.cache.get(NONE_OF_THAT)?.users.cache.filter(x => x.id !== this.client.user?.id).size ?? 0
const memberThreshold = (watcherRoleMemberCount / 2)
logger.info(`Reroll ${noneOfThatReactions} > ${memberThreshold} ?`, { requestId, guildId })
if (noneOfThatReactions > memberThreshold)
logger.info(`No reroll`, { requestId, guildId })
else {
logger.info('Starting poll reroll', { requestId, guildId })
await this.handleReroll(reactedUponMessage, guild.id, requestId)
logger.info(`Finished handling NONE_OF_THAT vote`, { requestId, guildId })
}
}
private async removeMessage(message: Message): Promise<Message<boolean>> {
if (message.pinned) {
await message.unpin()
}
return await message.delete()
}
/**
* returns true if a Vote object contains at least one vote
* @param {Vote} vote
*/
private hasAtLeastOneVote(vote: Vote): boolean {
// subtracting the bots initial vote
const overOneVote = (vote.count - 1) >= 1
logger.debug(`${vote.movie} : ${vote.count} -> above: ${overOneVote}`)
return overOneVote
}
public async generateRerollMovieList(voteInfo: VoteMessageInfo, guildId: string, requestId: string) {
if (config.bot.reroll_retains_top_picks) {
const votedOnMovies = voteInfo.votes.filter(this.hasAtLeastOneVote).filter(x => x.emote !== NONE_OF_THAT)
logger.info(`Found ${votedOnMovies.length} with votes`, { requestId, guildId })
const newMovieCount: number = config.bot.random_movie_count - votedOnMovies.length
logger.info(`Fetching ${newMovieCount} from jellyfin`)
const newMovies: string[] = await this.yavinJellyfinHandler.getRandomMovieNames(newMovieCount, guildId, requestId)
// merge
return newMovies.concat(votedOnMovies.map(x => x.movie))
} else {
// get movies from jellyfin to fill the remaining slots
const newMovieCount: number = config.bot.random_movie_count
logger.info(`Fetching ${newMovieCount} from jellyfin`)
return await this.yavinJellyfinHandler.getRandomMovieNames(newMovieCount, guildId, requestId)
}
}
public async handleReroll(voteMessage: VoteMessage, guildId: string, requestId: string) {
// get the movies currently being voted on, their votes, the eventId and its date
const voteInfo: VoteMessageInfo = await this.parseVoteInfoFromVoteMessage(voteMessage, requestId)
if (!voteInfo.event.scheduledStartAt) {
logger.info("Event does not have a start date, cancelling", { guildId: voteInfo.event.guildId, requestId })
return
}
let movies: string[] = await this.generateRerollMovieList(voteInfo, guildId, requestId)
const announcementChannel = this.client.getAnnouncementChannelForGuild(guildId)
if (!announcementChannel) {
logger.error(`No announcementChannel found for ${guildId}, can't post poll`)
return
}
try {
logger.info(`Trying to remove old vote Message`, { requestId, guildId })
this.removeMessage(voteMessage)
} catch (err) {
// TODO: integrate failure DM to media Admin to inform about inability to delete old message
logger.error(`Error during removeMessage: ${err}`)
}
const sentMessage = this.prepareAndSendVoteMessage({
event: voteInfo.event,
movies,
announcementChannel,
startDate: voteInfo.event.scheduledStartAt,
pinAfterSending: true
},
guildId,
requestId)
logger.debug(`Sent reroll message: ${JSON.stringify(sentMessage)}`, { requestId, guildId })
}
private async fetchEventByEventId(guild: Guild, eventId: string, requestId: string): Promise<Maybe<GuildScheduledEvent>> {
const guildEvent: GuildScheduledEvent = await guild.scheduledEvents.fetch(eventId)
if (!guildEvent) logger.error(`GuildScheduledEvent with id${eventId} could not be found`, { requestId, guildId: guild.id })
return guildEvent
}
public async parseVoteInfoFromVoteMessage(message: VoteMessage, requestId: string): Promise<VoteMessageInfo> {
const lines = message.cleanContent.split('\n')
let parsedIds = this.parseGuildIdAndEventIdFromWholeMessage(message.cleanContent)
if (!message.guild)
throw new Error(`Message ${message.id} not a guild message`)
const event: Maybe<GuildScheduledEvent> = await this.fetchEventByEventId(message.guild, parsedIds.eventId, requestId)
let votes: Vote[] = []
for (const line of lines) {
if (line.slice(0, 5).includes(':')) {
const splitLine = line.split(":")
const [emoji, movie] = splitLine
const fetchedVoteFromMessage = message.reactions.cache.get(emoji)
if (fetchedVoteFromMessage) {
if (emoji === NONE_OF_THAT) {
votes.push({ movie: NONE_OF_THAT, emote: NONE_OF_THAT, count: fetchedVoteFromMessage.count })
} else
votes.push({ movie: movie.trim(), emote: emoji, count: fetchedVoteFromMessage.count })
} else {
logger.error(`No vote reaction found for movie, assuming 0`, requestId)
votes.push({ movie, emote: emoji, count: 0 })
}
}
}
return <VoteMessageInfo>{ event, votes }
}
public parseEventDateFromMessage(message: string, guildId: string, requestId: string): Date {
logger.warn(`Falling back to RegEx parsing to get Event Date`, { guildId, requestId })
const datematcher = RegExp(/((?:0[1-9]|[12][0-9]|3[01])\.(?:0[1-9]|1[012])\.)(?:\ um\ )((?:(?:[01][0-9]|[2][0-3])\:[0-5][0-9])|(?:[2][4]\:00))!/i)
const result: RegExpMatchArray | null = message.match(datematcher)
const timeFromResult = result?.at(-1)
const dateFromResult = result?.at(1)?.concat(format(new Date(), 'yyyy')).concat(" " + timeFromResult) ?? ""
return new Date(dateFromResult)
}
public parseGuildIdAndEventIdFromWholeMessage(message: string) {
const idmatch = RegExp(/(?:http|https):\/\/discord\.com\/events\/(\d*)\/(\d*)/)
const matches = message.match(idmatch)
if (matches && matches.length == 3)
return { guildId: matches[1], eventId: matches[2] }
throw Error(`Could not find eventId in Vote Message`)
}
public async prepareAndSendVoteMessage(inputInfo: prepareVoteMessageInput, guildId: string, requestId: string) {
const messageText = this.createVoteMessageText(inputInfo.event, inputInfo.movies, guildId, requestId)
const sentMessage = await this.sendVoteMessage(messageText, inputInfo.movies.length, inputInfo.announcementChannel)
if (inputInfo.pinAfterSending)
sentMessage.pin()
return sentMessage
}
public createVoteMessageText(event: GuildScheduledEvent, movies: string[], guildId: string, requestId: string): string {
let message = `[Abstimmung] für https://discord.com/events/${guildId}/${event.id} \n<@&${config.bot.announcement_role}> Es gibt eine neue Abstimmung für die nächste Watchparty ${createDateStringFromEvent(event.scheduledStartAt, guildId, requestId)}! Stimme hierunter für den nächsten Film ab!\n`
for (let i = 0; i < movies.length; i++) {
message = message.concat(ValidVoteEmotes[i]).concat(": ").concat(movies[i]).concat("\n")
}
message = message.concat(NONE_OF_THAT).concat(": Wenn dir nichts davon gefällt.")
return message
}
// TODO: Refactor into separate message controller
public async sendVoteMessage(messageText: string, movieCount: number, announcementChannel: TextChannel) {
const options: MessageCreateOptions = {
allowedMentions: { parse: ["roles"] },
content: messageText,
}
const sentMessage: Message<true> = await (await announcementChannel.fetch()).send(options)
for (let i = 0; i < movieCount; i++) {
sentMessage.react(ValidVoteEmotes[i])
}
sentMessage.react(NONE_OF_THAT)
return sentMessage
}
public async closePoll(guild: Guild, requestId: string) {
const guildId = guild.id
logger.info("stopping poll", { guildId, requestId })
const announcementChannel: Maybe<TextChannel> = this.client.getAnnouncementChannelForGuild(guildId)
if (!announcementChannel) {
logger.error("Could not find the textchannel. Unable to close poll.", { guildId, requestId })
return
}
const messages: Message<true>[] = (await announcementChannel.messages.fetch()) //todo: fetch only pinned messages
.map((value) => value)
.filter(message => !isVoteEndedMessage(message) && isVoteMessage(message))
.sort((a, b) => b.createdTimestamp - a.createdTimestamp)
if (!messages || messages.length <= 0) {
logger.info("Could not find any vote messages. Cancelling pollClose", { guildId, requestId })
return
}
const lastMessage: Message<true> = messages[0]
if (!isVoteMessage(lastMessage)) {
logger.error(`Found message that is not a vote message, can't proceed`, { guildId, requestId })
logger.debug(`Found messages: ${JSON.stringify(messages, null, 2)}`, { guildId, requestId })
logger.debug(`Last message: ${JSON.stringify(lastMessage, null, 2)}`, { guildId, requestId })
}
else {
const votes = (await this.getVotesByEmote(lastMessage, guildId, requestId))
.sort((a, b) => b.count - a.count)
logger.debug(`votes: ${JSON.stringify(votes, null, 2)}`, { guildId, requestId })
logger.info("Deleting vote message")
lastMessage.unpin()
await lastMessage.delete()
const event = await this.getOpenPollEvent(guild, guild.id, requestId)
if (event && votes?.length > 0) {
this.updateOpenPollEventWithVoteResults(event, votes, guild, guildId, requestId)
this.sendVoteClosedMessage(event, votes[0].movie, guildId, requestId)
}
}
}
/**
* gets votes for the movies without the NONE_OF_THAT votes
*/
public async getVotesByEmote(message: VoteMessage, guildId: string, requestId: string): Promise<Vote[]> {
const votes: Vote[] = []
logger.debug(`Number of items in emotes: ${Object.values(ValidVoteEmotes).length}`, { guildId, requestId })
for (let i = 0; i < Object.keys(ValidVoteEmotes).length / 2; i++) {
const emote = ValidVoteEmotes[i]
logger.debug(`Getting reaction for emote ${emote}`, { guildId, requestId })
const reaction = message.reactions.resolve(emote)
logger.debug(`Reaction for emote ${emote}: ${JSON.stringify(reaction, null, 2)}`, { guildId, requestId })
if (reaction) {
const vote: Vote = { emote: emote, count: reaction.count, movie: this.extractMovieFromMessageByEmote(message, emote) }
votes.push(vote)
}
}
return votes
}
public async getOpenPollEvent(guild: Guild, guildId: string, requestId: string): Promise<Maybe<GuildScheduledEvent>> {
const voteEvents = (await guild.scheduledEvents.fetch())
.map((value) => value)
.filter(event => event.name.toLowerCase().includes("voting offen"))
logger.debug(`Found events: ${JSON.stringify(voteEvents, null, 2)}`, { guildId, requestId })
if (!voteEvents || voteEvents.length <= 0) {
logger.error("Could not find an open vote event.", { guildId, requestId })
return
}
return voteEvents[0]
}
public async updateOpenPollEventWithVoteResults(voteEvent: GuildScheduledEvent, votes: Vote[], guild: Guild, guildId: string, requestId: string) {
logger.info(`Updating event with movie ${votes[0].movie}.`, { guildId, requestId })
const options: GuildScheduledEventEditOptions<GuildScheduledEventStatus.Scheduled, GuildScheduledEventSetStatusArg<GuildScheduledEventStatus.Scheduled>> = {
name: votes[0].movie,
description: `!wp\nNummer 2: ${votes[1].movie} mit ${votes[1].count - 1} Stimmen\nNummer 3: ${votes[2].movie} mit ${votes[2].count - 1} Stimmen`
}
logger.debug(`Updating event: ${JSON.stringify(voteEvent, null, 2)}`, { guildId, requestId })
logger.info("Updating event.", { guildId, requestId })
voteEvent.edit(options)
}
public async sendVoteClosedMessage(event: GuildScheduledEvent, movie: string, guildId: string, requestId: string): Promise<Message<boolean>> {
const date = event.scheduledStartAt ? format(event.scheduledStartAt, "dd.MM.") : "Fehler, Event hatte kein Datum"
const time = event.scheduledStartAt ? format(event.scheduledStartAt, "HH:mm") : "Fehler, Event hatte keine Uhrzeit"
const body = `[Abstimmung beendet] für https://discord.com/events/${event.guildId}/${event.id}\n<@&${config.bot.announcement_role}> Wir gucken ${movie} am ${date} um ${time}`
const options: MessageCreateOptions = {
content: body,
allowedMentions: { parse: ["roles"] }
}
const announcementChannel = this.client.getAnnouncementChannelForGuild(guildId)
logger.info("Sending vote closed message.", { guildId, requestId })
if (!announcementChannel) {
const errorMessageText = "Could not find announcement channel. Please fix!"
logger.error(errorMessageText, { guildId, requestId })
throw errorMessageText
}
return announcementChannel.send(options)
}
private extractMovieFromMessageByEmote(voteMessage: VoteMessage, emote: string): string {
const lines = voteMessage.cleanContent.split("\n")
const emoteLines = lines.filter(line => line.includes(emote))
if (!emoteLines) {
return ""
}
const movie = emoteLines[0].substring(emoteLines[0].indexOf(emote) + emote.length + 2) // plus colon and space
return movie
}
public async checkForPollsToClose(guild: Guild): Promise<void> {
const requestId = newRequestId()
logger.info(`Automatic check for poll closing.`, { guildId: guild.id, requestId })
const events = (await guild.scheduledEvents.fetch()).filter(event => event.name.toLocaleLowerCase().includes("voting offen")).map(event => event)
if (events.length > 1) {
logger.error("Handling more than one Event is not implemented yet. Found more than one poll to close")
return
} else if (events.length == 0) {
logger.info("Could not find any events. Cancelling", { guildId: guild.id, requestId })
}
const updatedEvent = events[0] //add two hours because of different timezones in discord api and Date.now()
if (!updatedEvent.scheduledStartTimestamp) {
logger.error("Event does not have a scheduled start time. Cancelling", { guildId: guild.id, requestId })
return
}
const createDate: Date = toDate(updatedEvent.createdTimestamp)
const eventDate: Date = toDate(updatedEvent.scheduledStartTimestamp)
const difference: number = differenceInDays(createDate, eventDate)
if (difference <= 2) {
logger.info("Less than two days between event create and event start. Not closing poll.", { guildId: guild.id, requestId })
return
}
const closePollDate: Date = addDays(eventDate, -2)
if (isAfter(Date.now(), closePollDate)) {
logger.info("Less than two days until event. Closing poll", { guildId: guild.id, requestId })
this.closePoll(guild, requestId)
} else {
logger.info(`ScheduledStart: ${closePollDate}. Now: ${toDate(Date.now())}`, { guildId: guild.id, requestId })
}
}
}

View File

@ -1,41 +1,48 @@
import { Collection } from "@discordjs/collection"
import { Role } from "discord.js"
import { GuildScheduledEvent, Role, TextChannel } from "discord.js"
export type Maybe<T> = T | undefined | null
export interface Player {
name: string
name: string
}
export type supported_languages = "german" | "english"
export interface localized_string {
[k: string]: {
[k in supported_languages]: string
}
[k: string]: {
[k in supported_languages]: string
}
}
export class CustomError extends Error {
private code: string
public constructor(message: string, errorCode: string) {
super(message)
this.code = errorCode
}
public getCode() { return this.code }
private code: string
public constructor(message: string, errorCode: string) {
super(message)
this.code = errorCode
}
public getCode() { return this.code }
}
export const errorCodes = {
no_end_date: 'no_end_date',
no_string_present: 'no_string_present',
no_schedule: 'no_schedule',
schedule_not_supported: 'schedule_not_supported',
no_repetition_amount: 'no_repetition_amount',
invalid_repetition_string: 'invalid_repetition_string',
no_creator_id: "no_creator_id",
no_end_date: 'no_end_date',
no_string_present: 'no_string_present',
no_schedule: 'no_schedule',
schedule_not_supported: 'schedule_not_supported',
no_repetition_amount: 'no_repetition_amount',
invalid_repetition_string: 'invalid_repetition_string',
no_creator_id: "no_creator_id",
}
export interface ChangedRoles {
addedRoles: Collection<string, Role>
removedRoles: Collection<string, Role>
addedRoles: Collection<string, Role>
removedRoles: Collection<string, Role>
}
export interface JellyfinConfig {
jellyfinUrl: string,
jellyfinToken: string,
movieCollectionId: string,
collectionUser: string
jellyfinUrl: string,
jellyfinToken: string,
movieCollectionId: string,
collectionUser: string
}
export type PermissionLevel = "VIEWER" | "ADMIN" | "TEMPORARY"
export interface voteMessageInputInformation {
movies: string[],
startDate: Date,
event: GuildScheduledEvent,
announcementChannel: TextChannel,
pinAfterSending: boolean,
}

View File

@ -2,260 +2,282 @@ import { GuildMember } from "discord.js";
import { JellyfinConfig, Maybe, PermissionLevel } from "../interfaces";
import { logger } from "../logger";
import { CreateUserByNameOperationRequest, DeleteUserRequest, GetItemsRequest, ItemsApi, SystemApi, UpdateUserPasswordOperationRequest, UpdateUserPolicyOperationRequest, UserApi } from "./apis";
import { BaseItemDto, UpdateUserPasswordRequest } from "./models";
import { BaseItemDto, UpdateUserPasswordRequest, UpdateUserPolicyRequest } from "./models";
import { UserDto } from "./models/UserDto";
import { Configuration, ConfigurationParameters } from "./runtime";
export class JellyfinHandler {
private userApi: UserApi
private systemApi: SystemApi
private moviesApi: ItemsApi
private token: string
private authHeader: { headers: { 'X-Emby-Authorization': string } }
private config: JellyfinConfig
private serverName = "";
private userApi: UserApi
private systemApi: SystemApi
private moviesApi: ItemsApi
private token: string
private authHeader: { headers: { 'X-Emby-Authorization': string } }
private config: JellyfinConfig
private serverName = "";
constructor(_config: JellyfinConfig, _userApi?: UserApi, _systemApi?: SystemApi, _itemsApi?: ItemsApi) {
this.config = _config
this.token = this.config.jellyfinToken
this.authHeader = {
headers: {
"X-Emby-Authorization": this.config.jellyfinToken
}
}
const userApiConfigurationParams: ConfigurationParameters = {
basePath: this.config.jellyfinUrl,
headers: this.authHeader.headers
}
const systemApiConfigurationParams: ConfigurationParameters = {
basePath: this.config.jellyfinUrl,
headers: this.authHeader.headers
}
const libraryApiConfigurationParams: ConfigurationParameters = {
basePath: this.config.jellyfinUrl,
headers: this.authHeader.headers
}
constructor(_config: JellyfinConfig, _userApi?: UserApi, _systemApi?: SystemApi, _itemsApi?: ItemsApi) {
this.config = _config
this.token = this.config.jellyfinToken
this.authHeader = {
headers: {
"X-Emby-Authorization": this.config.jellyfinToken
}
}
const userApiConfigurationParams: ConfigurationParameters = {
basePath: this.config.jellyfinUrl,
headers: this.authHeader.headers
}
const systemApiConfigurationParams: ConfigurationParameters = {
basePath: this.config.jellyfinUrl,
headers: this.authHeader.headers
}
const libraryApiConfigurationParams: ConfigurationParameters = {
basePath: this.config.jellyfinUrl,
headers: this.authHeader.headers
}
this.userApi = _userApi ?? new UserApi(new Configuration(userApiConfigurationParams))
this.systemApi = _systemApi ?? new SystemApi(new Configuration(systemApiConfigurationParams))
this.moviesApi = _itemsApi ?? new ItemsApi(new Configuration(libraryApiConfigurationParams))
logger.info(`Initialized Jellyfin handler`, { requestId: 'Init' })
}
this.userApi = _userApi ?? new UserApi(new Configuration(userApiConfigurationParams))
this.systemApi = _systemApi ?? new SystemApi(new Configuration(systemApiConfigurationParams))
this.moviesApi = _itemsApi ?? new ItemsApi(new Configuration(libraryApiConfigurationParams))
logger.info(`Initialized Jellyfin handler`, { requestId: 'Init' })
}
private generateJFUserName(discordUser: GuildMember, level: PermissionLevel): string {
return `${discordUser.displayName}${level == "TEMPORARY" ? "_tmp" : ""}`
}
private generateJFUserName(discordUser: GuildMember, level: PermissionLevel): string {
return `${discordUser.displayName}${level == "TEMPORARY" ? "_tmp" : ""}`
}
private generatePasswordForUser(): string {
return (Math.random() * 10000 + 10000).toFixed(0)
}
private generatePasswordForUser(): string {
return (Math.random() * 10000 + 10000).toFixed(0)
}
public async createUserAccountForDiscordUser(discordUser: GuildMember, level: PermissionLevel, guildId?: string, requestId?: string): Promise<UserDto> {
const newUserName = this.generateJFUserName(discordUser, level)
logger.info(`New Username for ${discordUser.displayName}: ${newUserName}`, { guildId, requestId })
const req: CreateUserByNameOperationRequest = {
createUserByNameRequest: {
name: newUserName,
password: this.generatePasswordForUser(),
}
}
logger.debug(JSON.stringify(req), { requestId, guildId })
const createResult = await this.userApi.createUserByName(req)
if (createResult) {
(await discordUser.createDM()).send(`Ich hab dir mal nen Account angelegt :)\nDein Username ist ${createResult.name}, dein Password ist "${req.createUserByNameRequest.password}"!`)
return createResult
}
else throw new Error('Could not create User in Jellyfin')
}
public async createUserAccountForDiscordUser(discordUser: GuildMember, level: PermissionLevel, requestId: string, guildId?: string): Promise<UserDto> {
const newUserName = this.generateJFUserName(discordUser, level)
logger.info(`New Username for ${discordUser.displayName}: ${newUserName}`, { guildId, requestId })
const req: CreateUserByNameOperationRequest = {
createUserByNameRequest: {
name: newUserName,
password: this.generatePasswordForUser()
}
}
logger.debug(JSON.stringify(req), { requestId, guildId })
const createResult = await this.userApi.createUserByName(req)
if (createResult) {
if (createResult.policy) {
this.setUserPermissions(createResult, requestId, guildId)
}
(await discordUser.createDM()).send(`Ich hab dir mal nen Account angelegt :)\nDein Username ist ${createResult.name}, dein Password ist "${req.createUserByNameRequest.password}"!`)
return createResult
}
else throw new Error('Could not create User in Jellyfin')
}
public async isUserAlreadyPresent(discordUser: GuildMember, requestId?: string): Promise<boolean> {
const jfuser = await this.getUser(discordUser, requestId)
logger.debug(`Presence for DiscordUser ${discordUser.id}:${jfuser !== undefined}`, { guildId: discordUser.guild.id, requestId })
return jfuser !== undefined
}
public async setUserPermissions(user: UserDto, requestId: string, guildId?: string) {
if (!user.policy || !user.id) {
logger.error(`Cannot update user policy. User ${user.name} has no policy to modify`, { guildId, requestId })
return
}
user.policy.enableVideoPlaybackTranscoding = false
public async getCurrentUsers(guildId: string, requestId?: string): Promise<UserDto[]> {
try {
logger.info(`Fetching current users from Jellyfin`, { requestId, guildId })
const result = await this.userApi.getUsers(undefined, this.authHeader)
return result
} catch (error) {
logger.error(`Could not fetch current users from jellyfin`, { guildId, requestId })
}
return []
}
const operation: UpdateUserPolicyRequest = {
...user.policy,
enableVideoPlaybackTranscoding: false
}
public async getUser(discordUser: GuildMember, requestId?: string): Promise<Maybe<UserDto>> {
logger.info(`Getting user for discord member ${discordUser.displayName}`, { requestId, guildId: discordUser.guild.id })
const jfUsers = await this.getCurrentUsers(discordUser.guild.id, requestId)
const foundUser = jfUsers.find(x => x.name?.includes(discordUser.displayName))
return foundUser
}
const request: UpdateUserPolicyOperationRequest = {
userId: user.id,
updateUserPolicyRequest: operation
}
this.userApi.updateUserPolicy(request)
}
public async removeUser(newMember: GuildMember, level: PermissionLevel, requestId?: string) {
logger.info(`${level == "TEMPORARY" ? "Deleting" : "Disabling"} user ${newMember.displayName}, but method is not implemented`, { requestId, guildId: newMember.guild.id })
const jfuser = await this.getUser(newMember, requestId)
if (jfuser && jfuser.id) {
if (level === "TEMPORARY") {
const r: DeleteUserRequest = {
userId: jfuser.id
}
this.userApi.deleteUser(r)
}
else
await this.disableUser(jfuser, newMember.guild.id, requestId)
}
}
public async isUserAlreadyPresent(discordUser: GuildMember, requestId?: string): Promise<boolean> {
const jfuser = await this.getUser(discordUser, requestId)
logger.debug(`Presence for DiscordUser ${discordUser.id}:${jfuser !== undefined}`, { guildId: discordUser.guild.id, requestId })
return jfuser !== undefined
}
public async purge(guildId: string, requestId?: string) {
logger.info("Deleting tmp users", { requestId, guildId })
const users = (await this.userApi.getUsers()).filter(user => user.name?.endsWith("_tmp"))
public async getCurrentUsers(guildId: string, requestId?: string): Promise<UserDto[]> {
try {
logger.info(`Fetching current users from Jellyfin`, { requestId, guildId })
const result = await this.userApi.getUsers(undefined, this.authHeader)
return result
} catch (error) {
logger.error(`Could not fetch current users from jellyfin`, { guildId, requestId })
}
return []
}
users.forEach(user => {
if (user.id) {
const r: DeleteUserRequest = {
userId: user.id
}
this.userApi.deleteUser(r)
}
})
}
public async getUser(discordUser: GuildMember, requestId?: string): Promise<Maybe<UserDto>> {
logger.info(`Getting user for discord member ${discordUser.displayName}`, { requestId, guildId: discordUser.guild.id })
const jfUsers = await this.getCurrentUsers(discordUser.guild.id, requestId)
const foundUser = jfUsers.find(x => x.name?.includes(discordUser.displayName))
return foundUser
}
public async resetUserPasswort(member: GuildMember, requestId?: string) {
logger.info(`Resetting password for user ${member.displayName}`, { requestId, guildId: member.guild.id })
const jfUser = await this.getUser(member, requestId)
if (jfUser && jfUser.id) {
public async removeUser(newMember: GuildMember, level: PermissionLevel, requestId?: string) {
logger.info(`${level == "TEMPORARY" ? "Deleting" : "Disabling"} user ${newMember.displayName}, but method is not implemented`, { requestId, guildId: newMember.guild.id })
const jfuser = await this.getUser(newMember, requestId)
if (jfuser && jfuser.id) {
if (level === "TEMPORARY") {
const r: DeleteUserRequest = {
userId: jfuser.id
}
this.userApi.deleteUser(r)
}
else
await this.disableUser(jfuser, newMember.guild.id, requestId)
}
}
// const reset: UpdateUserPasswordRequest = {
// resetPassword: true
// }
public async purge(guildId: string, requestId?: string) {
logger.info("Deleting tmp users", { requestId, guildId })
const users = (await this.userApi.getUsers()).filter(user => user.name?.endsWith("_tmp"))
// const shit: UpdateUserPasswordOperationRequest = {
// updateUserPasswordRequest: reset,
// userId: jfUser.id
// }
users.forEach(user => {
if (user.id) {
const r: DeleteUserRequest = {
userId: user.id
}
this.userApi.deleteUser(r)
}
})
}
// logger.info(JSON.stringify(jfUser.policy, null, 2))
public async resetUserPasswort(member: GuildMember, requestId?: string) {
logger.info(`Resetting password for user ${member.displayName}`, { requestId, guildId: member.guild.id })
const jfUser = await this.getUser(member, requestId)
if (jfUser && jfUser.id) {
// logger.info("Resetting password", {requestId})
// await this.userApi.updateUserPassword(shit);
// const reset: UpdateUserPasswordRequest = {
// resetPassword: true
// }
const password = this.generatePasswordForUser()
const passwordRequest: UpdateUserPasswordRequest = {
// resetPassword: true,
currentPw: "",
newPw: password
}
// const shit: UpdateUserPasswordOperationRequest = {
// updateUserPasswordRequest: reset,
// userId: jfUser.id
// }
const passwordOperationRequest: UpdateUserPasswordOperationRequest = {
updateUserPasswordRequest: passwordRequest,
userId: jfUser.id
}
// logger.info(JSON.stringify(jfUser.policy, null, 2))
logger.info("Setting new password", { requestId, guildId: member.guild.id })
await this.userApi.updateUserPassword(passwordOperationRequest);
// logger.info("Resetting password", {requestId})
// await this.userApi.updateUserPassword(shit);
const password = this.generatePasswordForUser()
const passwordRequest: UpdateUserPasswordRequest = {
// resetPassword: true,
currentPw: "",
newPw: password
}
const passwordOperationRequest: UpdateUserPasswordOperationRequest = {
updateUserPasswordRequest: passwordRequest,
userId: jfUser.id
}
logger.info("Setting new password", { requestId, guildId: member.guild.id })
await this.userApi.updateUserPassword(passwordOperationRequest);
(await member.createDM()).send(`Hier ist dein neues Passwort: ${password}`)
} else {
(await member.createDM()).send("Ich konnte leider keinen User von dir auf Jellyfin finden. Bitte melde dich bei Markus oder Samantha!")
}
(await member.createDM()).send(`Hier ist dein neues Passwort: ${password}`)
} else {
(await member.createDM()).send("Ich konnte leider keinen User von dir auf Jellyfin finden. Bitte melde dich bei Markus oder Samantha!")
}
}
}
public async disableUser(user: UserDto, guildId?: string, requestId?: string): Promise<void> {
if (user.id) {
const jfUser = await this.getUser(<GuildMember>{ displayName: user.name, guild: { id: guildId } }, requestId)
logger.info(`Trying to disable user: ${user.name}|${user.id}|${JSON.stringify(jfUser, null, 2)}`, { guildId, requestId })
const r: UpdateUserPolicyOperationRequest = {
userId: user.id ?? "",
updateUserPolicyRequest: {
...jfUser?.policy,
isDisabled: true,
}
}
await this.userApi.updateUserPolicy(r)
logger.info(`Succeeded with disabling user: ${user.name}`, { guildId, requestId })
}
else {
logger.error(`Can not disable user ${JSON.stringify(user)}, has no id?!`, { requestId, guildId })
}
}
public async disableUser(user: UserDto, guildId?: string, requestId?: string): Promise<void> {
if (user.id) {
const jfUser = await this.getUser(<GuildMember>{ displayName: user.name, guild: { id: guildId } }, requestId)
logger.info(`Trying to disable user: ${user.name}|${user.id}|${JSON.stringify(jfUser, null, 2)}`, { guildId, requestId })
const r: UpdateUserPolicyOperationRequest = {
userId: user.id ?? "",
updateUserPolicyRequest: {
...jfUser?.policy,
isDisabled: true,
}
}
await this.userApi.updateUserPolicy(r)
logger.info(`Succeeded with disabling user: ${user.name}`, { guildId, requestId })
}
else {
logger.error(`Can not disable user ${JSON.stringify(user)}, has no id?!`, { requestId, guildId })
}
}
public async enableUser(user: UserDto, guildId: string, requestId?: string): Promise<void> {
if (user.id) {
const jfUser = await this.getUser(<GuildMember>{ displayName: user.name, guild: { id: guildId } }, requestId)
logger.info(`Trying to enable user: ${user.name}|${user.id}|${JSON.stringify(jfUser, null, 2)}`, { guildId, requestId })
const r: UpdateUserPolicyOperationRequest = {
userId: user.id ?? "",
updateUserPolicyRequest: {
...jfUser?.policy,
isDisabled: false,
}
}
await this.userApi.updateUserPolicy(r)
logger.info(`Succeeded with enabling user: ${user.name}`, { guildId, requestId })
}
else {
logger.error(`Can not enable user ${JSON.stringify(user)}, has no id?!`, { requestId, guildId })
}
}
public async enableUser(user: UserDto, guildId: string, requestId?: string): Promise<void> {
if (user.id) {
const jfUser = await this.getUser(<GuildMember>{ displayName: user.name, guild: { id: guildId } }, requestId)
logger.info(`Trying to enable user: ${user.name}|${user.id}|${JSON.stringify(jfUser, null, 2)}`, { guildId, requestId })
const r: UpdateUserPolicyOperationRequest = {
userId: user.id ?? "",
updateUserPolicyRequest: {
...jfUser?.policy,
isDisabled: false,
}
}
await this.userApi.updateUserPolicy(r)
logger.info(`Succeeded with enabling user: ${user.name}`, { guildId, requestId })
}
else {
logger.error(`Can not enable user ${JSON.stringify(user)}, has no id?!`, { requestId, guildId })
}
}
public async upsertUser(newMember: GuildMember, level: PermissionLevel, requestId?: string): Promise<UserUpsertResult> {
logger.info(`Trying to upsert user ${newMember.displayName}, with permissionLevel ${level}`, { guildId: newMember.guild.id, requestId })
const jfuser = await this.getUser(newMember, requestId)
if (jfuser && !jfuser.policy?.isDisabled) {
logger.info(`User with name ${newMember.displayName} is already present`, { guildId: newMember.guild.id, requestId })
await this.enableUser(jfuser, newMember.guild.id, requestId)
return UserUpsertResult.enabled
} else {
this.createUserAccountForDiscordUser(newMember, level, newMember.guild.id, requestId)
return UserUpsertResult.created
}
}
public async upsertUser(newMember: GuildMember, level: PermissionLevel, requestId?: string): Promise<UserUpsertResult> {
logger.info(`Trying to upsert user ${newMember.displayName}, with permissionLevel ${level}`, { guildId: newMember.guild.id, requestId })
const jfuser = await this.getUser(newMember, requestId)
if (jfuser && !jfuser.policy?.isDisabled) {
logger.info(`User with name ${newMember.displayName} is already present`, { guildId: newMember.guild.id, requestId })
await this.enableUser(jfuser, newMember.guild.id, requestId)
return UserUpsertResult.enabled
} else {
this.createUserAccountForDiscordUser(newMember, level, newMember.guild.id, requestId)
return UserUpsertResult.created
}
}
public async getAllMovies(guildId: string, requestId: string): Promise<BaseItemDto[]> {
logger.info("requesting all movies from jellyfin", { guildId, requestId })
public async getAllMovies(guildId: string, requestId: string): Promise<BaseItemDto[]> {
logger.info("requesting all movies from jellyfin", { guildId, requestId })
const searchParams: GetItemsRequest = {
userId: this.config.collectionUser,
parentId: this.config.movieCollectionId // collection ID for all movies
}
const movies = (await (this.moviesApi.getItems(searchParams))).items?.filter(item => !item.isFolder)
// logger.debug(JSON.stringify(movies, null, 2), { guildId: guildId, requestId })
logger.info(`Found ${movies?.length} movies in total`, { guildId, requestId })
return movies ?? []
}
const searchParams: GetItemsRequest = {
userId: this.config.collectionUser,
parentId: this.config.movieCollectionId // collection ID for all movies
}
const movies = (await (this.moviesApi.getItems(searchParams))).items?.filter(item => !item.isFolder)
// logger.debug(JSON.stringify(movies, null, 2), { guildId: guildId, requestId })
logger.info(`Found ${movies?.length} movies in total`, { guildId, requestId })
return movies ?? []
}
public async getRandomMovies(count: number, guildId: string, requestId: string): Promise<BaseItemDto[]> {
logger.info(`${count} random movies requested.`, { guildId, requestId })
const allMovies = await this.getAllMovies(guildId, requestId)
if (count >= allMovies.length) {
logger.info(`${count} random movies requested but found only ${allMovies.length}. Returning all Movies.`, { guildId, requestId })
return allMovies
}
const movies: BaseItemDto[] = []
for (let i = 0; i < count; i++) {
const index = Math.floor(Math.random() * allMovies.length)
movies.push(...allMovies.splice(index, 1)) // maybe out of bounds? ?
}
return movies
}
public async getRandomMovies(count: number, guildId: string, requestId: string): Promise<BaseItemDto[]> {
logger.info(`${count} random movies requested.`, { guildId, requestId })
const allMovies = await this.getAllMovies(guildId, requestId)
if (count >= allMovies.length) {
logger.info(`${count} random movies requested but found only ${allMovies.length}. Returning all Movies.`, { guildId, requestId })
return allMovies
}
const movies: BaseItemDto[] = []
for (let i = 0; i < count; i++) {
const index = Math.floor(Math.random() * allMovies.length)
movies.push(...allMovies.splice(index, 1)) // maybe out of bounds? ?
}
return movies
}
public async getRandomMovieNames(count: number, guildId: string, requestId: string): Promise<string[]> {
logger.info(`${count} random movie names requested`, { guildId, requestId })
public async getRandomMovieNames(count: number, guildId: string, requestId: string): Promise<string[]> {
logger.info(`${count} random movie names requested`, { guildId, requestId })
let movieCount = 0
let movieNames: string[]
do {
movieNames = (await this.getRandomMovies(count, guildId, requestId)).filter(movie => movie.name && movie.name.length > 0).map(movie => <string> movie.name)
movieCount = movieNames.length
} while (movieCount < count)
return movieNames
}
let movieCount = 0
let movieNames: string[]
do {
movieNames = (await this.getRandomMovies(count, guildId, requestId)).filter(movie => movie.name && movie.name.length > 0).map(movie => <string>movie.name)
movieCount = movieNames.length
} while (movieCount < count)
return movieNames
}
}

View File

@ -16,72 +16,72 @@
export const BASE_PATH = "http://localhost".replace(/\/+$/, "");
export interface ConfigurationParameters {
basePath?: string; // override base path
fetchApi?: FetchAPI; // override for fetch implementation
middleware?: Middleware[]; // middleware to apply before/after fetch requests
queryParamsStringify?: (params: HTTPQuery) => string; // stringify function for query strings
username?: string; // parameter for basic security
password?: string; // parameter for basic security
apiKey?: string | ((name: string) => string); // parameter for apiKey security
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string | Promise<string>); // parameter for oauth2 security
headers?: HTTPHeaders; //header params we want to use on every request
credentials?: RequestCredentials; //value for the credentials param we want to use on each request
basePath?: string; // override base path
fetchApi?: FetchAPI; // override for fetch implementation
middleware?: Middleware[]; // middleware to apply before/after fetch requests
queryParamsStringify?: (params: HTTPQuery) => string; // stringify function for query strings
username?: string; // parameter for basic security
password?: string; // parameter for basic security
apiKey?: string | ((name: string) => string); // parameter for apiKey security
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string | Promise<string>); // parameter for oauth2 security
headers?: HTTPHeaders; //header params we want to use on every request
credentials?: RequestCredentials; //value for the credentials param we want to use on each request
}
export class Configuration {
constructor(private configuration: ConfigurationParameters = {}) {}
constructor(private configuration: ConfigurationParameters = {}) { }
set config(configuration: Configuration) {
this.configuration = configuration;
}
set config(configuration: Configuration) {
this.configuration = configuration;
}
get basePath(): string {
return this.configuration.basePath != null ? this.configuration.basePath : BASE_PATH;
}
get basePath(): string {
return this.configuration.basePath != null ? this.configuration.basePath : BASE_PATH;
}
get fetchApi(): FetchAPI | undefined {
return this.configuration.fetchApi;
}
get fetchApi(): FetchAPI | undefined {
return this.configuration.fetchApi;
}
get middleware(): Middleware[] {
return this.configuration.middleware || [];
}
get middleware(): Middleware[] {
return this.configuration.middleware || [];
}
get queryParamsStringify(): (params: HTTPQuery) => string {
return this.configuration.queryParamsStringify || querystring;
}
get queryParamsStringify(): (params: HTTPQuery) => string {
return this.configuration.queryParamsStringify || querystring;
}
get username(): string | undefined {
return this.configuration.username;
}
get username(): string | undefined {
return this.configuration.username;
}
get password(): string | undefined {
return this.configuration.password;
}
get password(): string | undefined {
return this.configuration.password;
}
get apiKey(): ((name: string) => string) | undefined {
const apiKey = this.configuration.apiKey;
if (apiKey) {
return typeof apiKey === 'function' ? apiKey : () => apiKey;
}
return undefined;
}
get apiKey(): ((name: string) => string) | undefined {
const apiKey = this.configuration.apiKey;
if (apiKey) {
return typeof apiKey === 'function' ? apiKey : () => apiKey;
}
return undefined;
}
get accessToken(): ((name?: string, scopes?: string[]) => string | Promise<string>) | undefined {
const accessToken = this.configuration.accessToken;
if (accessToken) {
return typeof accessToken === 'function' ? accessToken : async () => accessToken;
}
return undefined;
}
get accessToken(): ((name?: string, scopes?: string[]) => string | Promise<string>) | undefined {
const accessToken = this.configuration.accessToken;
if (accessToken) {
return typeof accessToken === 'function' ? accessToken : async () => accessToken;
}
return undefined;
}
get headers(): HTTPHeaders | undefined {
return this.configuration.headers;
}
get headers(): HTTPHeaders | undefined {
return this.configuration.headers;
}
get credentials(): RequestCredentials | undefined {
return this.configuration.credentials;
}
get credentials(): RequestCredentials | undefined {
return this.configuration.credentials;
}
}
export const DefaultConfig = new Configuration();
@ -91,192 +91,192 @@ export const DefaultConfig = new Configuration();
*/
export class BaseAPI {
private static readonly jsonRegex = new RegExp('^(:?application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(:?;.*)?$', 'i');
private middleware: Middleware[];
private static readonly jsonRegex = new RegExp('^(:?application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(:?;.*)?$', 'i');
private middleware: Middleware[];
constructor(protected configuration = DefaultConfig) {
this.middleware = configuration.middleware;
}
constructor(protected configuration = DefaultConfig) {
this.middleware = configuration.middleware;
}
withMiddleware<T extends BaseAPI>(this: T, ...middlewares: Middleware[]) {
const next = this.clone<T>();
next.middleware = next.middleware.concat(...middlewares);
return next;
}
withMiddleware<T extends BaseAPI>(this: T, ...middlewares: Middleware[]) {
const next = this.clone<T>();
next.middleware = next.middleware.concat(...middlewares);
return next;
}
withPreMiddleware<T extends BaseAPI>(this: T, ...preMiddlewares: Array<Middleware['pre']>) {
const middlewares = preMiddlewares.map((pre) => ({ pre }));
return this.withMiddleware<T>(...middlewares);
}
withPreMiddleware<T extends BaseAPI>(this: T, ...preMiddlewares: Array<Middleware['pre']>) {
const middlewares = preMiddlewares.map((pre) => ({ pre }));
return this.withMiddleware<T>(...middlewares);
}
withPostMiddleware<T extends BaseAPI>(this: T, ...postMiddlewares: Array<Middleware['post']>) {
const middlewares = postMiddlewares.map((post) => ({ post }));
return this.withMiddleware<T>(...middlewares);
}
withPostMiddleware<T extends BaseAPI>(this: T, ...postMiddlewares: Array<Middleware['post']>) {
const middlewares = postMiddlewares.map((post) => ({ post }));
return this.withMiddleware<T>(...middlewares);
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
protected isJsonMime(mime: string | null | undefined): boolean {
if (!mime) {
return false;
}
return BaseAPI.jsonRegex.test(mime);
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
protected isJsonMime(mime: string | null | undefined): boolean {
if (!mime) {
return false;
}
return BaseAPI.jsonRegex.test(mime);
}
protected async request(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction): Promise<Response> {
const { url, init } = await this.createFetchParams(context, initOverrides);
const response = await this.fetchApi(url, init);
if (response && (response.status >= 200 && response.status < 300)) {
return response;
}
throw new ResponseError(response, 'Response returned an error code');
}
protected async request(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction): Promise<Response> {
const { url, init } = await this.createFetchParams(context, initOverrides);
const response = await this.fetchApi(url, init);
if (response && (response.status >= 200 && response.status < 300)) {
return response;
}
throw new ResponseError(response, 'Response returned an error code');
}
private async createFetchParams(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction) {
let url = this.configuration.basePath + context.path;
if (context.query !== undefined && Object.keys(context.query).length !== 0) {
// only add the querystring to the URL if there are query parameters.
// this is done to avoid urls ending with a "?" character which buggy webservers
// do not handle correctly sometimes.
url += '?' + this.configuration.queryParamsStringify(context.query);
}
private async createFetchParams(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction) {
let url = this.configuration.basePath + context.path;
if (context.query !== undefined && Object.keys(context.query).length !== 0) {
// only add the querystring to the URL if there are query parameters.
// this is done to avoid urls ending with a "?" character which buggy webservers
// do not handle correctly sometimes.
url += '?' + this.configuration.queryParamsStringify(context.query);
}
const headers = Object.assign({}, this.configuration.headers, context.headers);
Object.keys(headers).forEach(key => headers[key] === undefined ? delete headers[key] : {});
const headers = Object.assign({}, this.configuration.headers, context.headers);
Object.keys(headers).forEach(key => headers[key] === undefined ? delete headers[key] : {});
const initOverrideFn =
typeof initOverrides === "function"
? initOverrides
: async () => initOverrides;
const initOverrideFn =
typeof initOverrides === "function"
? initOverrides
: async () => initOverrides;
const initParams = {
method: context.method,
headers,
body: context.body,
credentials: this.configuration.credentials,
};
const initParams = {
method: context.method,
headers,
body: context.body,
credentials: this.configuration.credentials,
};
const overriddenInit: RequestInit = {
...initParams,
...(await initOverrideFn({
init: initParams,
context,
}))
};
const overriddenInit: RequestInit = {
...initParams,
...(await initOverrideFn({
init: initParams,
context,
}))
};
const init: RequestInit = {
...overriddenInit,
body:
isFormData(overriddenInit.body) ||
overriddenInit.body instanceof URLSearchParams ||
isBlob(overriddenInit.body)
? overriddenInit.body
: JSON.stringify(overriddenInit.body),
};
const init: RequestInit = {
...overriddenInit,
body:
isFormData(overriddenInit.body) ||
overriddenInit.body instanceof URLSearchParams ||
isBlob(overriddenInit.body)
? overriddenInit.body
: JSON.stringify(overriddenInit.body),
};
return { url, init };
}
return { url, init };
}
private fetchApi = async (url: string, init: RequestInit) => {
let fetchParams = { url, init };
for (const middleware of this.middleware) {
if (middleware.pre) {
fetchParams = await middleware.pre({
fetch: this.fetchApi,
...fetchParams,
}) || fetchParams;
}
}
let response: Response | undefined = undefined;
try {
response = await (this.configuration.fetchApi || fetch)(fetchParams.url, fetchParams.init);
} catch (e) {
for (const middleware of this.middleware) {
if (middleware.onError) {
response = await middleware.onError({
fetch: this.fetchApi,
url: fetchParams.url,
init: fetchParams.init,
error: e,
response: response ? response.clone() : undefined,
}) || response;
}
}
if (response === undefined) {
if (e instanceof Error) {
throw new FetchError(e, 'The request failed and the interceptors did not return an alternative response');
} else {
throw e;
}
}
}
for (const middleware of this.middleware) {
if (middleware.post) {
response = await middleware.post({
fetch: this.fetchApi,
url: fetchParams.url,
init: fetchParams.init,
response: response.clone(),
}) || response;
}
}
return response;
}
private fetchApi = async (url: string, init: RequestInit) => {
let fetchParams = { url, init };
for (const middleware of this.middleware) {
if (middleware.pre) {
fetchParams = await middleware.pre({
fetch: this.fetchApi,
...fetchParams,
}) || fetchParams;
}
}
let response: Response | undefined = undefined;
try {
response = await (this.configuration.fetchApi || fetch)(fetchParams.url, fetchParams.init);
} catch (e) {
for (const middleware of this.middleware) {
if (middleware.onError) {
response = await middleware.onError({
fetch: this.fetchApi,
url: fetchParams.url,
init: fetchParams.init,
error: e,
response: response ? response.clone() : undefined,
}) || response;
}
}
if (response === undefined) {
if (e instanceof Error) {
throw new FetchError(e, 'The request failed and the interceptors did not return an alternative response');
} else {
throw e;
}
}
}
for (const middleware of this.middleware) {
if (middleware.post) {
response = await middleware.post({
fetch: this.fetchApi,
url: fetchParams.url,
init: fetchParams.init,
response: response.clone(),
}) || response;
}
}
return response;
}
/**
* Create a shallow clone of `this` by constructing a new instance
* and then shallow cloning data members.
*/
private clone<T extends BaseAPI>(this: T): T {
const constructor = this.constructor as any;
const next = new constructor(this.configuration);
next.middleware = this.middleware.slice();
return next;
}
/**
* Create a shallow clone of `this` by constructing a new instance
* and then shallow cloning data members.
*/
private clone<T extends BaseAPI>(this: T): T {
const constructor = this.constructor as any;
const next = new constructor(this.configuration);
next.middleware = this.middleware.slice();
return next;
}
};
function isBlob(value: any): value is Blob {
return typeof Blob !== 'undefined' && value instanceof Blob;
return typeof Blob !== 'undefined' && value instanceof Blob;
}
function isFormData(value: any): value is FormData {
return typeof FormData !== "undefined" && value instanceof FormData;
return typeof FormData !== "undefined" && value instanceof FormData;
}
export class ResponseError extends Error {
override name: "ResponseError" = "ResponseError";
constructor(public response: Response, msg?: string) {
super(msg);
}
override name: "ResponseError" = "ResponseError";
constructor(public response: Response, errorMessage?: string) {
super(errorMessage);
}
}
export class FetchError extends Error {
override name: "FetchError" = "FetchError";
constructor(public cause: Error, msg?: string) {
super(msg);
}
override name: "FetchError" = "FetchError";
constructor(public cause: Error, errorMessage?: string) {
super(errorMessage);
}
}
export class RequiredError extends Error {
override name: "RequiredError" = "RequiredError";
constructor(public field: string, msg?: string) {
super(msg);
}
override name: "RequiredError" = "RequiredError";
constructor(public field: string, errorMessage?: string) {
super(errorMessage);
}
}
export const COLLECTION_FORMATS = {
csv: ",",
ssv: " ",
tsv: "\t",
pipes: "|",
csv: ",",
ssv: " ",
tsv: "\t",
pipes: "|",
};
export type FetchAPI = WindowOrWorkerGlobalScope['fetch'];
@ -292,134 +292,134 @@ export type ModelPropertyNaming = 'camelCase' | 'snake_case' | 'PascalCase' | 'o
export type InitOverrideFunction = (requestContext: { init: HTTPRequestInit, context: RequestOpts }) => Promise<RequestInit>
export interface FetchParams {
url: string;
init: RequestInit;
url: string;
init: RequestInit;
}
export interface RequestOpts {
path: string;
method: HTTPMethod;
headers: HTTPHeaders;
query?: HTTPQuery;
body?: HTTPBody;
path: string;
method: HTTPMethod;
headers: HTTPHeaders;
query?: HTTPQuery;
body?: HTTPBody;
}
export function exists(json: any, key: string) {
const value = json[key];
return value !== null && value !== undefined;
const value = json[key];
return value !== null && value !== undefined;
}
export function querystring(params: HTTPQuery, prefix: string = ''): string {
return Object.keys(params)
.map(key => querystringSingleKey(key, params[key], prefix))
.filter(part => part.length > 0)
.join('&');
return Object.keys(params)
.map(key => querystringSingleKey(key, params[key], prefix))
.filter(part => part.length > 0)
.join('&');
}
function querystringSingleKey(key: string, value: string | number | null | undefined | boolean | Array<string | number | null | boolean> | Set<string | number | null | boolean> | HTTPQuery, keyPrefix: string = ''): string {
const fullKey = keyPrefix + (keyPrefix.length ? `[${key}]` : key);
if (value instanceof Array) {
const multiValue = value.map(singleValue => encodeURIComponent(String(singleValue)))
.join(`&${encodeURIComponent(fullKey)}=`);
return `${encodeURIComponent(fullKey)}=${multiValue}`;
}
if (value instanceof Set) {
const valueAsArray = Array.from(value);
return querystringSingleKey(key, valueAsArray, keyPrefix);
}
if (value instanceof Date) {
return `${encodeURIComponent(fullKey)}=${encodeURIComponent(value.toISOString())}`;
}
if (value instanceof Object) {
return querystring(value as HTTPQuery, fullKey);
}
return `${encodeURIComponent(fullKey)}=${encodeURIComponent(String(value))}`;
const fullKey = keyPrefix + (keyPrefix.length ? `[${key}]` : key);
if (value instanceof Array) {
const multiValue = value.map(singleValue => encodeURIComponent(String(singleValue)))
.join(`&${encodeURIComponent(fullKey)}=`);
return `${encodeURIComponent(fullKey)}=${multiValue}`;
}
if (value instanceof Set) {
const valueAsArray = Array.from(value);
return querystringSingleKey(key, valueAsArray, keyPrefix);
}
if (value instanceof Date) {
return `${encodeURIComponent(fullKey)}=${encodeURIComponent(value.toISOString())}`;
}
if (value instanceof Object) {
return querystring(value as HTTPQuery, fullKey);
}
return `${encodeURIComponent(fullKey)}=${encodeURIComponent(String(value))}`;
}
export function mapValues(data: any, fn: (item: any) => any) {
return Object.keys(data).reduce(
(acc, key) => ({ ...acc, [key]: fn(data[key]) }),
{}
);
return Object.keys(data).reduce(
(acc, key) => ({ ...acc, [key]: fn(data[key]) }),
{}
);
}
export function canConsumeForm(consumes: Consume[]): boolean {
for (const consume of consumes) {
if ('multipart/form-data' === consume.contentType) {
return true;
}
}
return false;
for (const consume of consumes) {
if ('multipart/form-data' === consume.contentType) {
return true;
}
}
return false;
}
export interface Consume {
contentType: string;
contentType: string;
}
export interface RequestContext {
fetch: FetchAPI;
url: string;
init: RequestInit;
fetch: FetchAPI;
url: string;
init: RequestInit;
}
export interface ResponseContext {
fetch: FetchAPI;
url: string;
init: RequestInit;
response: Response;
fetch: FetchAPI;
url: string;
init: RequestInit;
response: Response;
}
export interface ErrorContext {
fetch: FetchAPI;
url: string;
init: RequestInit;
error: unknown;
response?: Response;
fetch: FetchAPI;
url: string;
init: RequestInit;
error: unknown;
response?: Response;
}
export interface Middleware {
pre?(context: RequestContext): Promise<FetchParams | void>;
post?(context: ResponseContext): Promise<Response | void>;
onError?(context: ErrorContext): Promise<Response | void>;
pre?(context: RequestContext): Promise<FetchParams | void>;
post?(context: ResponseContext): Promise<Response | void>;
onError?(context: ErrorContext): Promise<Response | void>;
}
export interface ApiResponse<T> {
raw: Response;
value(): Promise<T>;
raw: Response;
value(): Promise<T>;
}
export interface ResponseTransformer<T> {
(json: any): T;
(json: any): T;
}
export class JSONApiResponse<T> {
constructor(public raw: Response, private transformer: ResponseTransformer<T> = (jsonValue: any) => jsonValue) {}
constructor(public raw: Response, private transformer: ResponseTransformer<T> = (jsonValue: any) => jsonValue) { }
async value(): Promise<T> {
return this.transformer(await this.raw.json());
}
async value(): Promise<T> {
return this.transformer(await this.raw.json());
}
}
export class VoidApiResponse {
constructor(public raw: Response) {}
constructor(public raw: Response) { }
async value(): Promise<void> {
return undefined;
}
async value(): Promise<void> {
return undefined;
}
}
export class BlobApiResponse {
constructor(public raw: Response) {}
constructor(public raw: Response) { }
async value(): Promise<Blob> {
return await this.raw.blob();
};
async value(): Promise<Blob> {
return await this.raw.blob();
};
}
export class TextApiResponse {
constructor(public raw: Response) {}
constructor(public raw: Response) { }
async value(): Promise<string> {
return await this.raw.text();
};
async value(): Promise<string> {
return await this.raw.text();
};
}

View File

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

View File

@ -8,174 +8,193 @@ import { Maybe } from "../interfaces";
import { JellyfinHandler } from "../jellyfin/handler";
import { logger } from "../logger";
import { CommandType } from "../types/commandTypes";
import { checkForPollsToClose } from "../commands/closepoll";
import { isInitialAnnouncement } from "../helper/messageIdentifiers";
import VoteController from "../helper/vote.controller";
import { yavinJellyfinHandler } from "../..";
export class ExtendedClient extends Client {
private eventFilePath = `${__dirname}/../events`
private commandFilePath = `${__dirname}/../commands`
private jellyfin: JellyfinHandler
public commands: Collection<string, CommandType> = new Collection()
private announcementChannels: Collection<string, TextChannel> = new Collection() //guildId to TextChannel
private announcementRoleHandlerTask: Collection<string, ScheduledTask> = new Collection() //one task per guild
private pollCloseBackgroundTasks: Collection<string, ScheduledTask> = new Collection()
public constructor(jf: JellyfinHandler) {
const intents: IntentsBitField = new IntentsBitField()
intents.add(IntentsBitField.Flags.GuildMembers, IntentsBitField.Flags.MessageContent, IntentsBitField.Flags.Guilds, IntentsBitField.Flags.DirectMessages, IntentsBitField.Flags.GuildScheduledEvents, IntentsBitField.Flags.GuildVoiceStates)
const options: ClientOptions = { intents }
super(options)
this.jellyfin = jf
}
public async start() {
if (process.env.NODE_ENV === 'test') return
const promises: Promise<any>[] = []
promises.push(this.registerSlashCommands())
promises.push(this.registerEventCallback())
Promise.all(promises).then(() => {
this.login(config.bot.token)
})
}
private async importFile(filepath: string): Promise<any> {
logger.debug(`Importing ${filepath}`)
const imported = await import(filepath)
logger.debug(`Imported ${JSON.stringify(imported)}`)
return imported.default ?? imported
}
public async registerCommands(cmds: ApplicationCommandDataResolvable[], guildIds: Collection<Snowflake, Guild>) {
if (guildIds) {
guildIds.forEach(guild => {
this.guilds.cache.get(guild.id)?.commands.set(cmds)
logger.info(`Registering commands to ${guild.name}|${guild.id}`)
})
} else {
this.application?.commands.set(cmds)
logger.info(`Registering global commands`)
}
return
}
public async registerSlashCommands(): Promise<void> {
try {
const slashCommands: ApplicationCommandDataResolvable[] = []
const commandFiles = fs.readdirSync(this.commandFilePath).filter(file => file.endsWith('.ts') || file.endsWith('.js'))
for (const commandFile of commandFiles) {
const filePath = `${this.commandFilePath}/${commandFile}`
const command = await this.importFile(filePath)
logger.debug(JSON.stringify(command))
if (!command.name) return
this.commands.set(command.name, command)
slashCommands.push(command)
}
this.on("ready", async (client: Client) => {
//logger.info(`Ready processing ${JSON.stringify(client)}`)
logger.info(`SlashCommands: ${JSON.stringify(slashCommands)}`)
const guilds = client.guilds.cache
private eventFilePath = `${__dirname}/../events`
private commandFilePath = `${__dirname}/../commands`
private jellyfin: JellyfinHandler
public voteController: VoteController = new VoteController(this, yavinJellyfinHandler)
public commands: Collection<string, CommandType> = new Collection()
private announcementChannels: Collection<string, TextChannel> = new Collection() //guildId to TextChannel
private announcementRoleHandlerTask: Collection<string, ScheduledTask> = new Collection() //one task per guild
private pollCloseBackgroundTasks: Collection<string, ScheduledTask> = new Collection()
public constructor(jf: JellyfinHandler) {
const intents: IntentsBitField = new IntentsBitField()
intents.add(IntentsBitField.Flags.GuildMembers, IntentsBitField.Flags.MessageContent, IntentsBitField.Flags.Guilds, IntentsBitField.Flags.DirectMessages, IntentsBitField.Flags.GuildScheduledEvents, IntentsBitField.Flags.GuildMessageReactions, IntentsBitField.Flags.GuildVoiceStates)
const options: ClientOptions = { intents }
super(options)
this.jellyfin = jf
}
public async start() {
if (process.env.NODE_ENV === 'test') return
const promises: Promise<any>[] = []
promises.push(this.registerSlashCommands())
promises.push(this.registerEventCallback())
Promise.all(promises).then(() => {
this.login(config.bot.token)
})
}
private async importFile(filepath: string): Promise<any> {
logger.debug(`Importing ${filepath}`)
const imported = await import(filepath)
logger.debug(`Imported ${JSON.stringify(imported)}`)
return imported.default ?? imported
}
public async registerCommands(cmds: ApplicationCommandDataResolvable[], guildIds: Collection<Snowflake, Guild>) {
if (guildIds) {
guildIds.forEach(guild => {
this.guilds.cache.get(guild.id)?.commands.set(cmds)
logger.info(`Registering commands to ${guild.name}|${guild.id}`)
})
} else {
this.application?.commands.set(cmds)
logger.info(`Registering global commands`)
}
return
}
public async registerSlashCommands(): Promise<void> {
try {
const slashCommands: ApplicationCommandDataResolvable[] = []
const commandFiles = fs.readdirSync(this.commandFilePath).filter(file => file.endsWith('.ts') || file.endsWith('.js'))
for (const commandFile of commandFiles) {
const filePath = `${this.commandFilePath}/${commandFile}`
const command = await this.importFile(filePath)
logger.debug(JSON.stringify(command))
if (!command.name) return
this.commands.set(command.name, command)
slashCommands.push(command)
}
this.on("ready", async (client: Client) => {
//logger.info(`Ready processing ${JSON.stringify(client)}`)
logger.info(`SlashCommands: ${JSON.stringify(slashCommands)}`)
const guilds = client.guilds.cache
this.registerCommands(slashCommands, guilds)
this.cacheUsers(guilds)
await this.cacheAnnouncementServer(guilds)
this.startAnnouncementRoleBackgroundTask(guilds)
this.startPollCloseBackgroundTasks()
})
} catch (error) {
logger.info(`Error refreshing slash commands: ${error}`)
}
}
private async cacheAnnouncementServer(guilds: Collection<Snowflake, Guild>) {
for (const guild of guilds.values()) {
const channels: TextChannel[] = <TextChannel[]>(await guild.channels.fetch())
?.filter(channel => channel?.id === config.bot.announcement_channel_id)
.map((value) => value)
this.registerCommands(slashCommands, guilds)
this.cacheUsers(guilds)
await this.cacheAnnouncementServer(guilds)
this.fetchAnnouncementChannelMessage(this.announcementChannels)
this.startAnnouncementRoleBackgroundTask(guilds)
this.startPollCloseBackgroundTasks()
})
} catch (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>) {
for (const guild of guilds.values()) {
const channels: TextChannel[] = <TextChannel[]>(await guild.channels.fetch())
?.filter(channel => channel?.id === config.bot.announcement_channel_id)
.map((value) => value)
if (!channels || channels.length != 1) {
logger.error(`Could not find announcement channel for guild ${guild.name} with guildId ${guild.id}. Found ${channels}`)
continue
}
logger.info(`Fetched announcement channel: ${JSON.stringify(channels[0])}`)
this.announcementChannels.set(guild.id, channels[0])
}
}
public getAnnouncementChannelForGuild(guildId: string): Maybe<TextChannel> {
return this.announcementChannels.get(guildId)
}
public async cacheUsers(guilds: Collection<Snowflake, Guild>) {
guilds.forEach((guild: Guild, id: Snowflake) => {
logger.info(`Fetching members for ${guild.name}|${id}`)
guild.members.fetch()
logger.info(`Fetched: ${guild.memberCount} members`)
})
}
public async registerEventCallback() {
try {
const eventFiles = fs.readdirSync(this.eventFilePath).filter(file => file.endsWith('.ts') || file.endsWith('.js'));
for (const file of eventFiles) {
const filePath = `${this.eventFilePath}/${file}`
const event = await this.importFile(filePath)
if (event.once) {
logger.info(`Registering once ${file}`)
this.once(event.name, (...args: any[]) => event.execute(...args))
}
else {
logger.info(`Registering on ${file}`)
this.on(event.name, (...args: any[]) => event.execute(...args))
}
}
logger.info(`Registered event names ${this.eventNames()}`)
} catch (error) {
logger.error(error)
}
}
if (!channels || channels.length != 1) {
logger.error(`Could not find announcement channel for guild ${guild.name} with guildId ${guild.id}. Found ${channels}`)
continue
}
logger.info(`Fetched announcement channel: ${JSON.stringify(channels[0])}`)
this.announcementChannels.set(guild.id, channels[0])
}
}
public getAnnouncementChannelForGuild(guildId: string): Maybe<TextChannel> {
return this.announcementChannels.get(guildId)
}
public async cacheUsers(guilds: Collection<Snowflake, Guild>) {
guilds.forEach((guild: Guild, id: Snowflake) => {
logger.info(`Fetching members for ${guild.name}|${id}`)
guild.members.fetch()
logger.info(`Fetched: ${guild.memberCount} members`)
})
}
public async registerEventCallback() {
try {
const eventFiles = fs.readdirSync(this.eventFilePath).filter(file => file.endsWith('.ts') || file.endsWith('.js'));
for (const file of eventFiles) {
const filePath = `${this.eventFilePath}/${file}`
const event = await this.importFile(filePath)
if (event.once) {
logger.info(`Registering once ${file}`)
this.once(event.name, (...args: any[]) => event.execute(...args))
}
else {
logger.info(`Registering on ${file}`)
this.on(event.name, (...args: any[]) => event.execute(...args))
}
}
logger.info(`Registered event names ${this.eventNames()}`)
} catch (error) {
logger.error(error)
}
}
public async startAnnouncementRoleBackgroundTask(guilds: Collection<string, Guild>) {
for (const guild of guilds.values()) {
logger.info("Starting background task for announcement role", { guildId: guild.id })
const textChannel: Maybe<TextChannel> = this.getAnnouncementChannelForGuild(guild.id)
if(!textChannel) {
logger.error("Could not find announcement channel. Aborting", { guildId: guild.id })
return
}
this.announcementRoleHandlerTask.set(guild.id, schedule("*/10 * * * * *", async () => {
const requestId = uuid()
const messages = (await textChannel.messages.fetchPinned()).filter(message => message.cleanContent.includes("[initial]"))
public async startAnnouncementRoleBackgroundTask(guilds: Collection<string, Guild>) {
for (const guild of guilds.values()) {
logger.info("Starting background task for announcement role", { guildId: guild.id })
const textChannel: Maybe<TextChannel> = this.getAnnouncementChannelForGuild(guild.id)
if (!textChannel) {
logger.error("Could not find announcement channel. Aborting", { guildId: guild.id })
return
}
this.announcementRoleHandlerTask.set(guild.id, schedule("*/10 * * * * *", async () => {
const requestId = uuid()
const messages = (await textChannel.messages.fetchPinned()).filter(message => isInitialAnnouncement(message))
if (messages.size > 1) {
logger.error("More than one pinned announcement Messages found. Unable to know which one people react to. Please fix!", { guildId: guild.id, requestId })
return
} else if (messages.size == 0) {
logger.error("Could not find any pinned announcement messages. Unable to manage roles!", { guildId: guild.id, requestId })
return
}
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 })
return
} else if (messages.size == 0) {
logger.error("Could not find any pinned announcement messages. Unable to manage roles!", { guildId: guild.id, requestId })
return
}
const message = await messages.at(0)?.fetch()
if (!message) {
logger.error(`No pinned message found`, { guildId: guild.id, requestId })
return
}
//logger.debug(`Message: ${JSON.stringify(message, null, 2)}`, { guildId: guild.id, requestId })
const message = await messages.at(0)?.fetch()
if (!message) {
logger.error(`No pinned message found`, { guildId: guild.id, requestId })
return
}
//logger.debug(`Message: ${JSON.stringify(message, null, 2)}`, { guildId: guild.id, requestId })
const reactions = message.reactions.resolve("🎫")
//logger.debug(`reactions: ${JSON.stringify(reactions, null, 2)}`, { guildId: guild.id, requestId })
if (reactions) {
manageAnnouncementRoles(message.guild, reactions, requestId)
} else {
logger.error("Did not get reactions! Aborting!", { guildId: guild.id, requestId })
}
}))
}
}
const reactions = message.reactions.resolve("🎫")
//logger.debug(`reactions: ${JSON.stringify(reactions, null, 2)}`, { guildId: guild.id, requestId })
if (reactions) {
manageAnnouncementRoles(message.guild, reactions, requestId)
} else {
logger.error("Did not get reactions! Aborting!", { guildId: guild.id, requestId })
}
}))
}
}
public stopAnnouncementRoleBackgroundTask(guildId: string, requestId: string) {
const task: Maybe<ScheduledTask> = this.announcementRoleHandlerTask.get(guildId)
if (!task) {
logger.error(`No task found for guildID ${guildId}.`, { guildId, requestId })
return
}
task.stop()
}
public stopAnnouncementRoleBackgroundTask(guildId: string, requestId: string) {
const task: Maybe<ScheduledTask> = this.announcementRoleHandlerTask.get(guildId)
if (!task) {
logger.error(`No task found for guildID ${guildId}.`, { guildId, requestId })
return
}
task.stop()
}
private async startPollCloseBackgroundTasks() {
for(const guild of this.guilds.cache) {
this.pollCloseBackgroundTasks.set(guild[1].id, schedule("0 * * * * *", () => checkForPollsToClose(guild[1])))
}
}
private async startPollCloseBackgroundTasks() {
for (const guild of this.guilds.cache) {
this.pollCloseBackgroundTasks.set(guild[1].id, schedule("0 * * * * *", () => this.voteController.checkForPollsToClose(guild[1])))
}
}
}

View File

@ -1,8 +1,8 @@
import { ClientEvents } from "discord.js";
export class Event<Key extends keyof ClientEvents>{
constructor(
public event: Key,
public run: (...args: ClientEvents[Key]) => unknown
) { }
constructor(
public event: Key,
public run: (...args: ClientEvents[Key]) => unknown
) { }
}

View File

@ -0,0 +1,81 @@
import { Guild, GuildScheduledEvent, Message } from "discord.js"
import VoteController from "../../server/helper/vote.controller"
import { JellyfinHandler } from "../../server/jellyfin/handler"
import { ExtendedClient } from "../../server/structures/client"
import { Emoji, NONE_OF_THAT } from "../../server/constants"
import { isVoteMessage } from "../../server/helper/messageIdentifiers"
describe('vote controller - none_of_that functions', () => {
const testEventId = '1234321'
const testEventDate = new Date('2023-01-01')
const testGuildId = "888999888"
const testMovies = [
'Movie1',
'Movie2',
'Movie3',
'Movie4',
'Movie5',
]
const votesList = [
{ emote: Emoji.one, count: 1, movie: testMovies[0] },
{ emote: Emoji.two, count: 2, movie: testMovies[1] },
{ emote: Emoji.three, count: 3, movie: testMovies[2] },
{ emote: Emoji.four, count: 1, movie: testMovies[3] },
{ emote: Emoji.five, count: 1, movie: testMovies[4] },
{ emote: NONE_OF_THAT, count: 2, movie: NONE_OF_THAT },
]
const mockClient: ExtendedClient = <ExtendedClient><unknown>{
user: {
id: 'mockId'
}
}
const mockEvent: GuildScheduledEvent = <GuildScheduledEvent><unknown>{
scheduledStartAt: testEventDate,
id: testEventId,
guild: testGuildId
}
const mockJellyfinHandler: JellyfinHandler = <JellyfinHandler><unknown>{
getRandomMovieNames: jest.fn().mockReturnValue(["movie1"])
}
const votes = new VoteController(mockClient, mockJellyfinHandler)
const mockMessageContent = votes.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId")
test('sendVoteClosedMessage', async () => {
mockClient.getAnnouncementChannelForGuild = jest.fn().mockReturnValue({
send: jest.fn().mockImplementation((options: any) => {
return new Promise((resolve) => {
resolve(options)
})
})
})
const scheduledEvent: GuildScheduledEvent = <GuildScheduledEvent>{
scheduledStartAt: testEventDate,
guildId: testGuildId,
id: testEventId
}
const res = await votes.sendVoteClosedMessage(scheduledEvent, 'MovieNew', 'guild', 'request')
expect(res).toEqual({
allowedMentions: {
parse: ["roles"]
},
content: `[Abstimmung beendet] für https://discord.com/events/${testGuildId}/${testEventId}\n<@&WATCHPARTY_ANNOUNCEMENT_ROLE> Wir gucken MovieNew am 01.01. um 01:00`
})
})
test('getVotesByEmote', async () => {
const mockMessage: Message = <Message><unknown>{
cleanContent: mockMessageContent,
reactions: {
resolve: jest.fn().mockImplementation((input: any) => {
return votesList.find(e => e.emote === input)
})
}
}
if (isVoteMessage(mockMessage)) {
const result = await votes.getVotesByEmote(mockMessage, 'guildId', 'requestId')
expect(result.length).toEqual(5)
expect(result).toEqual(votesList.filter(x => x.movie != NONE_OF_THAT))
}
})
})

192
tests/discord/votes.test.ts Normal file
View File

@ -0,0 +1,192 @@
import { Emoji, NONE_OF_THAT } from "../../server/constants"
import VoteController, { VoteMessageInfo } from "../../server/helper/vote.controller"
import { JellyfinHandler } from "../../server/jellyfin/handler"
import { ExtendedClient } from "../../server/structures/client"
import { VoteMessage } from "../../server/helper/messageIdentifiers"
import { GuildScheduledEvent, MessageReaction } from "discord.js"
test('parse votes from vote message', async () => {
const testMovies = [
'Movie1',
'Movie2',
'Movie3',
'Movie4',
'Movie5',
]
const testEventId = '1234321'
const testEventDate = new Date('2023-01-01')
const testGuildId = "888999888"
const voteController: VoteController = new VoteController(<ExtendedClient>{}, <JellyfinHandler>{})
const mockEvent: GuildScheduledEvent = <GuildScheduledEvent><unknown>{
scheduledStartAt: testEventDate,
id: testEventId,
guild: testGuildId
}
const testMessage = voteController.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId")
const expectedResult: VoteMessageInfo = {
event: mockEvent,
votes: [
{ emote: Emoji.one, count: 1, movie: testMovies[0] },
{ emote: Emoji.two, count: 2, movie: testMovies[1] },
{ emote: Emoji.three, count: 3, movie: testMovies[2] },
{ emote: Emoji.four, count: 1, movie: testMovies[3] },
{ emote: Emoji.five, count: 1, movie: testMovies[4] },
{ emote: NONE_OF_THAT, count: 1, movie: NONE_OF_THAT },
]
}
const message: VoteMessage = <VoteMessage><unknown>{
cleanContent: testMessage,
guild: {
id: testGuildId,
scheduledEvents: {
fetch: jest.fn().mockImplementation((input: any) => {
if (input === testEventId)
return {
id: testEventId,
guild: testGuildId,
scheduledStartAt: testEventDate
}
})
}
},
reactions: {
cache: {
get: jest.fn().mockImplementation((input: any) => {
// Abusing duck typing
// Message Reaction has a method `count` and the expected votes
// have a field `count`
// this will evaluate to the same 'result'
return expectedResult.votes.find(e => e.emote === input)
})
}
}
}
const result = await voteController.parseVoteInfoFromVoteMessage(message, 'requestId')
console.log(JSON.stringify(result))
expect(Array.isArray(result)).toBe(false)
expect(result.event.id).toEqual(testEventId)
expect(result.event.scheduledStartAt).toEqual(testEventDate)
expect(result.votes.length).toEqual(expectedResult.votes.length)
expect(result).toEqual(expectedResult)
})
test('parse votes from vote message', () => {
const testMovies = [
'Movie1',
'Movie2',
'Movie3',
'Movie4',
'Movie5',
]
const testEventId = '1234321'
const testEventDate = new Date('2023-01-01')
const testGuildId = "888999888"
const voteController: VoteController = new VoteController(<ExtendedClient>{}, <JellyfinHandler>{})
const mockEvent: GuildScheduledEvent = <GuildScheduledEvent><unknown>{
scheduledStartAt: testEventDate,
id: testEventId,
guild: testGuildId
}
const testMessage = voteController.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId")
const result = voteController.parseGuildIdAndEventIdFromWholeMessage(testMessage)
expect(result).toEqual({ guildId: testGuildId, eventId: testEventId })
})
test.skip('handles complete none_of_that vote', () => {
const mockJellyfinHandler: JellyfinHandler = <JellyfinHandler><unknown>{
getRandomMovieNames: jest.fn().mockReturnValue(["movie1"])
}
const testMovies = [
'Movie1',
'Movie2',
'Movie3',
'Movie4',
'Movie5',
]
const testEventId = '1234321'
const testEventDate = new Date('2023-01-01')
const testGuildId = "888999888"
const mockClient: ExtendedClient = <ExtendedClient><unknown>{
user: {
id: 'mockId'
}
}
const voteController = new VoteController(mockClient, mockJellyfinHandler)
const mockEvent: GuildScheduledEvent = <GuildScheduledEvent><unknown>{
scheduledStartAt: testEventDate,
id: testEventId,
guild: testGuildId
}
const mockMessageContent = voteController.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId")
const reactedUponMessage: VoteMessage = <VoteMessage><unknown>{
cleanContent: mockMessageContent,
guild: {
id: 'id',
roles: {
resolve: jest.fn().mockReturnValue({
members: [{}, {}, {}, {}, {}]//content does not matter
})
},
scheduledEvents: {
fetch: jest.fn().mockReturnValue([
{
name: 'voting offen'
}
])
}
},
unpin: jest.fn().mockImplementation(() => {
}),
delete: jest.fn().mockImplementation(() => {
}),
reactions: {
resolve: jest.fn().mockImplementation((input: any) => {
console.log(JSON.stringify(input))
}),
cache: {
get: jest.fn().mockReturnValue({
users: {
cache: [
{
id: "mockId"//to filter out
},
{
id: "userId1"
},
{
id: "userId2"
},
{
id: "userId3"
}
]
}
})
}
}
}
const messageReaction: MessageReaction = <MessageReaction><unknown>{
message: reactedUponMessage
}
mockClient.getAnnouncementChannelForGuild = jest.fn().mockReturnValue({
messages: {
fetch: jest.fn().mockReturnValue([
reactedUponMessage
])
}
})
const res = voteController.handleNoneOfThatVote(messageReaction, reactedUponMessage, 'requestId', 'guildId')
})

View File

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

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

15
tests/testenv.js Normal file
View File

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