Compare commits
	
		
			139 Commits
		
	
	
		
			3298c7a244
			...
			feat/20-re
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a60fc2db7e | |||
| a50ac1716f | |||
| ef39c6315d | |||
| 1f372b0aac | |||
| d1aacbb3d3 | |||
| 1ae8278fb8 | |||
| 417b24d408 | |||
| 88061c361c | |||
| f83f54749d | |||
| 90b0b07080 | |||
| 6d0eaed426 | |||
| 8f320cee5c | |||
| 016bb243cc | |||
| 2c8cd96ac7 | |||
| ba4aefed8e | |||
| 8efae12907 | |||
| fec0bc31f1 | |||
| 1bfcaa95f9 | |||
| fb4ab59dc6 | |||
| 6d40930dc1 | |||
| 4e9fe587b0 | |||
| 03b6a30ffa | |||
| 7d794a8001 | |||
| 8df180898e | |||
| 976175242b | |||
| 68546b0b50 | |||
| 1348abbd48 | |||
| fce9091114 | |||
| 081f3c6201 | |||
| ca99987a20 | |||
| fc64728a78 | |||
| 20da25f2bf | |||
| a455fd8ff7 | |||
| 119343c916 | |||
| 296a490e93 | |||
| 66507cb08f | |||
| 4600820889 | |||
| 4a3e8809be | |||
| 690ba697b6 | |||
| 71343d6742 | |||
| 3f6e558d39 | |||
| ca259c5f24 | |||
| b1c581ca6e | |||
| 96189c2392 | |||
| 700353cff4 | |||
| f705b97804 | |||
| 9cdc6e1934 | |||
| c73cd20ccf | |||
| e66aebc88c | |||
| 599243990e | |||
| eef3a9c358 | |||
| 1e912b20ef | |||
| ce4dc81f7d | |||
| b76df79d2a | |||
| 4e563d57fd | |||
| b6a1e06b03 | |||
| 2ebc7fbdbe | |||
| 8ff5aeff03 | |||
| 1101a84501 | |||
| 91ec2ece7e | |||
| 5e58765cf4 | |||
| a2adef808f | |||
| dc66c277b2 | |||
| c022cc32d5 | |||
| e763e76413 | |||
| 137d156981 | |||
| fdfe7ce404 | |||
| 146848b759 | |||
| e54f03292e | |||
| fe45445811 | |||
| 8f02e11dba | |||
| 878c81bfa7 | |||
| ca19168cf4 | |||
| e8893646f0 | |||
| e61b3a7b16 | |||
| 9383cee4a0 | |||
| 0748097a1f | |||
| ffba737e5a | |||
| 4cd9c771f0 | |||
| 8c3cf7829b | |||
| 1a13638ed9 | |||
| c351e27fdd | |||
| 6d3bea169e | |||
| 3f071c8a4e | |||
| 98d1ca73b5 | |||
| ee742018e9 | |||
| 8ad651c753 | |||
| a4a834ad27 | |||
| e8dcfd8340 | |||
| d9d1d74ef9 | |||
| 331ff89060 | |||
| f6476c609b | |||
| 6220268b14 | |||
| b6034d4fb7 | |||
| ca0a9e3cb8 | |||
| b8a32aab40 | |||
| e3e755011d | |||
| 5a6c66cb3e | |||
| 0d3c62c6ad | |||
| 5816db48e6 | |||
| 66f843b399 | |||
| d82a7cffd2 | |||
| 8a06a661fa | |||
| 4084f675cd | |||
| 3bd26a9d6c | |||
| e7b21fa658 | |||
| 2d32f9b680 | |||
| 5503aa8713 | |||
| 25bb676fda | |||
| 9f5abb8a90 | |||
| 0e67252976 | |||
| 37b798818c | |||
| af414d0bad | |||
| c32434a7eb | |||
| c133570d8c | |||
| 65cdee36e9 | |||
| 6b0e84669a | |||
| dd72f8e165 | |||
| a6f19ccd2b | |||
| c39f9c6ee1 | |||
| f41194ba71 | |||
| fa49dc0f76 | |||
| e52e845851 | |||
| 61544feaba | |||
| 1966640239 | |||
| fa9998e92c | |||
| c1a449bafe | |||
| d5d82043f0 | |||
| 51ebf2e939 | |||
| f314b2f355 | |||
| a4d7c57d10 | |||
| 2802afa7d5 | |||
| 3a5ea5d4ff | |||
| 45d87275bf | |||
| 31e440434e | |||
| 3d70b56eb7 | |||
| 5b98c9bf2f | |||
| 9da8f47784 | |||
| e8c58d5ff8 | 
							
								
								
									
										7
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | ||||
| root = true | ||||
| [*] | ||||
| indent_style = tab | ||||
| tab_width = 4 | ||||
| [*.ts] | ||||
| indent_style = tab | ||||
| tab_width = 4 | ||||
| @ -14,4 +14,4 @@ jobs: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v3 | ||||
|       - name: Build Container | ||||
|         run: docker build . | ||||
|         run: docker build --target compile . | ||||
|  | ||||
| @ -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 | ||||
| @ -22,6 +21,8 @@ jobs: | ||||
|       - 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 }}:${{ node -p "require('./package.json').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 }}" | ||||
|  | ||||
							
								
								
									
										18
									
								
								.gitea/workflows/test.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								.gitea/workflows/test.yaml
									
									
									
									
									
										Normal 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 . | ||||
|  | ||||
							
								
								
									
										19
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -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 | ||||
|  | ||||
							
								
								
									
										4
									
								
								index.ts
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								index.ts
									
									
									
									
									
								
							| @ -5,8 +5,8 @@ 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) | ||||
|  | ||||
|  | ||||
							
								
								
									
										19
									
								
								jest.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								jest.config.js
									
									
									
									
									
										Normal 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' | ||||
| 	], | ||||
| }; | ||||
							
								
								
									
										46
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										46
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,12 +1,12 @@ | ||||
| { | ||||
| 	"name": "node-jellyfin-discord-bot", | ||||
|   "version": "1.0.4", | ||||
| 	"version": "1.1.4", | ||||
| 	"lockfileVersion": 2, | ||||
| 	"requires": true, | ||||
| 	"packages": { | ||||
| 		"": { | ||||
| 			"name": "node-jellyfin-discord-bot", | ||||
|       "version": "1.0.4", | ||||
| 			"version": "1.1.4", | ||||
| 			"license": "MIT", | ||||
| 			"dependencies": { | ||||
| 				"@discordjs/rest": "^1.7.0", | ||||
| @ -17,6 +17,7 @@ | ||||
| 				"@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", | ||||
| @ -29,12 +30,13 @@ | ||||
| 				"winston": "^3.8.2" | ||||
| 			}, | ||||
| 			"devDependencies": { | ||||
|         "@types/jest": "^29.5.0", | ||||
| 				"@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" | ||||
| @ -1567,9 +1569,9 @@ | ||||
| 			} | ||||
| 		}, | ||||
| 		"node_modules/@types/jest": { | ||||
|       "version": "29.5.0", | ||||
|       "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.0.tgz", | ||||
|       "integrity": "sha512-3Emr5VOl/aoBwnWcH/EFQvlSAmjV+XtV9GGu5mwdYew5vhQh0IUZx/60x0TzHDu09Bi7HMx10t/namdJw5QIcg==", | ||||
| 			"version": "29.5.2", | ||||
| 			"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.2.tgz", | ||||
| 			"integrity": "sha512-mSoZVJF5YzGVCk+FsDxzDuH7s+SCkzrgKZzf0Z0T2WudhBUPoF6ktoTPC4R0ZoCPCV5xUvuU6ias5NvxcBcMMg==", | ||||
| 			"dev": true, | ||||
| 			"dependencies": { | ||||
| 				"expect": "^29.0.0", | ||||
| @ -2626,6 +2628,14 @@ | ||||
| 				"url": "https://opencollective.com/date-fns" | ||||
| 			} | ||||
| 		}, | ||||
| 		"node_modules/date-fns-tz": { | ||||
| 			"version": "2.0.0", | ||||
| 			"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.0.tgz", | ||||
| 			"integrity": "sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==", | ||||
| 			"peerDependencies": { | ||||
| 				"date-fns": ">=2.0.0" | ||||
| 			} | ||||
| 		}, | ||||
| 		"node_modules/debug": { | ||||
| 			"version": "4.3.4", | ||||
| 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", | ||||
| @ -4980,6 +4990,12 @@ | ||||
| 				"node": ">=10" | ||||
| 			} | ||||
| 		}, | ||||
| 		"node_modules/mockdate": { | ||||
| 			"version": "3.0.5", | ||||
| 			"resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", | ||||
| 			"integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==", | ||||
| 			"dev": true | ||||
| 		}, | ||||
| 		"node_modules/ms": { | ||||
| 			"version": "2.1.2", | ||||
| 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", | ||||
| @ -8130,9 +8146,9 @@ | ||||
| 			} | ||||
| 		}, | ||||
| 		"@types/jest": { | ||||
|       "version": "29.5.0", | ||||
|       "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.0.tgz", | ||||
|       "integrity": "sha512-3Emr5VOl/aoBwnWcH/EFQvlSAmjV+XtV9GGu5mwdYew5vhQh0IUZx/60x0TzHDu09Bi7HMx10t/namdJw5QIcg==", | ||||
| 			"version": "29.5.2", | ||||
| 			"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.2.tgz", | ||||
| 			"integrity": "sha512-mSoZVJF5YzGVCk+FsDxzDuH7s+SCkzrgKZzf0Z0T2WudhBUPoF6ktoTPC4R0ZoCPCV5xUvuU6ias5NvxcBcMMg==", | ||||
| 			"dev": true, | ||||
| 			"requires": { | ||||
| 				"expect": "^29.0.0", | ||||
| @ -8905,6 +8921,12 @@ | ||||
| 			"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", | ||||
| 			"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==" | ||||
| 		}, | ||||
| 		"date-fns-tz": { | ||||
| 			"version": "2.0.0", | ||||
| 			"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.0.tgz", | ||||
| 			"integrity": "sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==", | ||||
| 			"requires": {} | ||||
| 		}, | ||||
| 		"debug": { | ||||
| 			"version": "4.3.4", | ||||
| 			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", | ||||
| @ -10705,6 +10727,12 @@ | ||||
| 			"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", | ||||
| 			"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" | ||||
| 		}, | ||||
| 		"mockdate": { | ||||
| 			"version": "3.0.5", | ||||
| 			"resolved": "https://registry.npmjs.org/mockdate/-/mockdate-3.0.5.tgz", | ||||
| 			"integrity": "sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==", | ||||
| 			"dev": true | ||||
| 		}, | ||||
| 		"ms": { | ||||
| 			"version": "2.1.2", | ||||
| 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", | ||||
|  | ||||
							
								
								
									
										10
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								package.json
									
									
									
									
									
								
							| @ -1,6 +1,6 @@ | ||||
| { | ||||
| 	"name": "node-jellyfin-discord-bot", | ||||
|   "version": "1.0.4", | ||||
| 	"version": "1.1.4", | ||||
| 	"description": "A discord bot to sync jellyfin accounts with discord roles", | ||||
| 	"main": "index.js", | ||||
| 	"license": "MIT", | ||||
| @ -13,6 +13,7 @@ | ||||
| 		"@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", | ||||
| @ -32,15 +33,18 @@ | ||||
| 		"debuggable": "node build/index.js --inspect-brk", | ||||
| 		"monitor": "nodemon build/index.js", | ||||
| 		"lint": "eslint . --ext .ts", | ||||
|     "lint-fix": "eslint . --ext .ts --fix" | ||||
| 		"lint-fix": "eslint . --ext .ts --fix", | ||||
| 		"test": "jest --runInBand", | ||||
| 		"test-watch": "jest --watch" | ||||
| 	}, | ||||
| 	"devDependencies": { | ||||
|     "@types/jest": "^29.5.0", | ||||
| 		"@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" | ||||
|  | ||||
| @ -6,6 +6,7 @@ import { Maybe } from '../interfaces' | ||||
| import { logger } from '../logger' | ||||
| import { Command } from '../structures/command' | ||||
| import { RunOptions } from '../types/commandTypes' | ||||
| import { isInitialAnnouncement } from '../helper/messageIdentifiers' | ||||
|  | ||||
| export default new Command({ | ||||
| 	name: 'announce', | ||||
| @ -13,22 +14,22 @@ export default new Command({ | ||||
| 	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"}], | ||||
| 		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}) | ||||
| 		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) { | ||||
| 		if (!announcementType) { | ||||
| 			logger.error("Did not get an announcement type!", { guildId, requestId }) | ||||
| 			return | ||||
| 		} | ||||
| @ -40,7 +41,7 @@ export default new Command({ | ||||
| 			logger.info(`User ${command.member.displayName} seems to be admin`) | ||||
| 		} | ||||
|  | ||||
|         if((<string>announcementType.value).includes("initial")) { | ||||
| 		if ((<string>announcementType.value).includes("initial")) { | ||||
| 			sendInitialAnnouncement(guildId, requestId) | ||||
| 			command.followUp("Ist rausgeschickt!") | ||||
| 		} else { | ||||
| @ -56,12 +57,12 @@ function isAdmin(member: GuildMember): boolean { | ||||
| async function sendInitialAnnouncement(guildId: string, requestId: string): Promise<void> { | ||||
| 	logger.info("Sending initial announcement") | ||||
| 	const announcementChannel: Maybe<TextChannel> = client.getAnnouncementChannelForGuild(guildId) | ||||
|     if(!announcementChannel) { | ||||
| 	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]")) | ||||
| 	const currentPinnedAnnouncementMessages = (await announcementChannel.messages.fetchPinned()).filter(message => isInitialAnnouncement(message)) | ||||
| 	currentPinnedAnnouncementMessages.forEach(async (message) => await message.unpin()) | ||||
| 	currentPinnedAnnouncementMessages.forEach(message => message.delete()) | ||||
|  | ||||
| @ -81,41 +82,5 @@ Für eine Erklärung wie das alles funktioniert mach einfach /mitgucken für ein | ||||
|  | ||||
| } | ||||
|  | ||||
| export async function manageAnnouncementRoles(guild: Guild, reaction: MessageReaction, requestId: string) { | ||||
|     const guildId = guild.id | ||||
|     logger.info("Managing roles", { guildId, requestId }) | ||||
|  | ||||
|     const announcementRole: Role | undefined = (await guild.roles.fetch()).find(role => role.id === config.bot.announcement_role) | ||||
|     if (!announcementRole) { | ||||
|         logger.error(`Could not find announcement role! Aborting! Was looking for role with id: ${config.bot.announcement_role}`, { guildId, requestId }) | ||||
|         return | ||||
|     } | ||||
|  | ||||
|     const usersWhoWantRole: User[] = (await reaction.users.fetch()).filter(user => !user.bot).map(user => user) | ||||
|  | ||||
|     const allUsers = (await guild.members.fetch()) | ||||
|  | ||||
|     const usersWhoHaveRole: GuildMember[] = allUsers | ||||
|         .filter(member=> member.roles.cache | ||||
|             .find(role => role.id === config.bot.announcement_role) !== undefined) | ||||
|         .map(member => member) | ||||
|  | ||||
|     const usersWhoNeedRoleRevoked: GuildMember[] = usersWhoHaveRole | ||||
|         .filter(userWhoHas => !usersWhoWantRole.map(wanter => wanter.id).includes(userWhoHas.id)) | ||||
|  | ||||
|     const usersWhoDontHaveRole: GuildMember[] = allUsers | ||||
|         .filter(member => member.roles.cache | ||||
|             .find(role=> role.id === config.bot.announcement_role) === undefined) | ||||
|         .map(member => member) | ||||
|  | ||||
|     const usersWhoNeedRole: GuildMember[] = usersWhoDontHaveRole | ||||
|         .filter(userWhoNeeds => usersWhoWantRole.map(wanter => wanter.id).includes(userWhoNeeds.id)) | ||||
|  | ||||
|  | ||||
|     logger.debug(`Theses users will get the role removed: ${JSON.stringify(usersWhoNeedRoleRevoked)}`, {guildId, requestId}) | ||||
|     logger.debug(`Theses users will get the role added: ${JSON.stringify(usersWhoNeedRole)}`, {guildId, requestId}) | ||||
|  | ||||
|     usersWhoNeedRoleRevoked.forEach(user => user.roles.remove(announcementRole)) | ||||
|     usersWhoNeedRole.forEach(user => user.roles.add(announcementRole)) | ||||
| } | ||||
|  | ||||
|  | ||||
| @ -1,10 +1,5 @@ | ||||
| import { addDays, differenceInDays, format, isAfter, toDate } from 'date-fns' | ||||
| import { Guild, GuildScheduledEvent, GuildScheduledEventEditOptions, GuildScheduledEventSetStatusArg, GuildScheduledEventStatus, Message, MessageCreateOptions, TextChannel } from 'discord.js' | ||||
| import { v4 as uuid } from 'uuid' | ||||
| import { client } from '../..' | ||||
| import { config } from '../configuration' | ||||
| import { Emotes } from '../events/guildScheduledEventCreate' | ||||
| import { Maybe } from '../interfaces' | ||||
| import { logger } from '../logger' | ||||
| import { Command } from '../structures/command' | ||||
| import { RunOptions } from '../types/commandTypes' | ||||
| @ -25,160 +20,6 @@ export default new Command({ | ||||
| 		logger.info("Got command for closing poll!", { guildId, requestId }) | ||||
|  | ||||
| 		command.followUp("Alles klar, beende die Umfrage :)") | ||||
|         closePoll(command.guild, requestId) | ||||
| 		client.voteController.closePoll(command.guild, requestId) | ||||
| 	} | ||||
| }) | ||||
|  | ||||
| export async function closePoll(guild: Guild, requestId: string) { | ||||
|     const guildId = guild.id | ||||
|     logger.info("stopping poll", { guildId, requestId }) | ||||
|  | ||||
|     const announcementChannel: Maybe<TextChannel> = client.getAnnouncementChannelForGuild(guildId) | ||||
|     if (!announcementChannel) { | ||||
|         logger.error("Could not find the textchannel. Unable to close poll.", { guildId, requestId }) | ||||
|         return | ||||
|     } | ||||
|  | ||||
|     const messages: Message<true>[] = (await announcementChannel.messages.fetch()) //todo: fetch only pinned messages | ||||
|         .map((value) => value) | ||||
|         .filter(message => !message.cleanContent.includes("[Abstimmung beendet]") && message.cleanContent.includes("[Abstimmung]")) | ||||
|         .sort((a, b) => b.createdTimestamp - a.createdTimestamp) | ||||
|  | ||||
|     if (!messages || messages.length <= 0) { | ||||
|         logger.info("Could not find any vote messages. Cancelling pollClose", { guildId, requestId }) | ||||
|         return | ||||
|     } | ||||
|  | ||||
|     const lastMessage: Message<true> = messages[0] | ||||
|  | ||||
|     logger.debug(`Found messages: ${JSON.stringify(messages, null, 2)}`, { guildId, requestId }) | ||||
|  | ||||
|     logger.debug(`Last message: ${JSON.stringify(lastMessage, null, 2)}`, { guildId, requestId }) | ||||
|  | ||||
|  | ||||
|     const votes = await (await getVotesByEmote(lastMessage, guildId, requestId)) | ||||
|         .sort((a, b) => b.count - a.count) | ||||
|  | ||||
|     logger.debug(`votes: ${JSON.stringify(votes, null, 2)}`, { guildId, requestId }) | ||||
|  | ||||
|     logger.info("Deleting vote message") | ||||
|     await lastMessage.delete() | ||||
|     const event = await getEvent(guild, guild.id, requestId) | ||||
|     if (event) { | ||||
|         updateEvent(event, votes, guild, guildId, requestId) | ||||
|         sendVoteClosedMessage(event, votes[0].movie, guildId, requestId) | ||||
|     } | ||||
|  | ||||
|     //lastMessage.unpin() //todo: uncomment when bot has permission to pin/unpin | ||||
| } | ||||
|  | ||||
| async function sendVoteClosedMessage(event: GuildScheduledEvent, movie: string, guildId: string, requestId: string) { | ||||
|     const date = event.scheduledStartAt ? format(event.scheduledStartAt, "dd.MM") : "Fehler, event hatte kein Datum" | ||||
|     const time = event.scheduledStartAt ? format(event.scheduledStartAt, "HH:mm") : "Fehler, event hatte kein Datum" | ||||
|     const body = `[Abstimmung beendet] <@&${config.bot.announcement_role}> Wir gucken  ${movie} am ${date} um ${time}` | ||||
|     const options: MessageCreateOptions = { | ||||
|         content: body, | ||||
|         allowedMentions: { parse: ["roles"] } | ||||
|     } | ||||
|     const announcementChannel = client.getAnnouncementChannelForGuild(guildId) | ||||
|     logger.info("Sending vote closed message.", { guildId, requestId }) | ||||
|     if (!announcementChannel) { | ||||
|         logger.error("Could not find announcement channel. Please fix!", { guildId, requestId }) | ||||
|         return | ||||
|     } | ||||
|     announcementChannel.send(options) | ||||
| } | ||||
|  | ||||
| async function updateEvent(voteEvent: GuildScheduledEvent, votes: Vote[], guild: Guild, guildId: string, requestId: string) { | ||||
|     logger.info(`Updating event with movie ${votes[0].movie}.`, { guildId, requestId }) | ||||
|     const options: GuildScheduledEventEditOptions<GuildScheduledEventStatus.Scheduled, GuildScheduledEventSetStatusArg<GuildScheduledEventStatus.Scheduled>> = { | ||||
|         name: votes[0].movie, | ||||
|         description: `!wp\nNummer 2: ${votes[1].movie} mit ${votes[1].count - 1} Stimmen\nNummer 3: ${votes[2].movie} mit ${votes[2].count - 1} Stimmen` | ||||
|     } | ||||
|     logger.debug(`Updating event: ${JSON.stringify(voteEvent, null, 2)}`, { guildId, requestId }) | ||||
|     logger.info("Updating event.", { guildId, requestId }) | ||||
|     voteEvent.edit(options) | ||||
| } | ||||
|  | ||||
| async function getEvent(guild: Guild, guildId: string, requestId: string): Promise<GuildScheduledEvent | null> { | ||||
|     const voteEvents = (await guild.scheduledEvents.fetch()) | ||||
|         .map((value) => value) | ||||
|         .filter(event => event.name.toLowerCase().includes("voting offen")) | ||||
|     logger.debug(`Found events: ${JSON.stringify(voteEvents, null, 2)}`, { guildId, requestId }) | ||||
|  | ||||
|     if (!voteEvents || voteEvents.length <= 0) { | ||||
|         logger.error("Could not find vote event. Cancelling update!", { guildId, requestId }) | ||||
|         return null | ||||
|     } | ||||
|     return voteEvents[0] | ||||
| } | ||||
|  | ||||
| type Vote = { | ||||
|     emote: string, //todo habs nicht hinbekommen hier Emotes zu nutzen | ||||
|     count: number, | ||||
|     movie: string | ||||
| } | ||||
|  | ||||
| async function getVotesByEmote(message: Message, guildId: string, requestId: string): Promise<Vote[]> { | ||||
|     const votes: Vote[] = [] | ||||
|     logger.debug(`Number of items in emotes: ${Object.values(Emotes).length}`, { guildId, requestId }) | ||||
|     for (let i = 0; i < Object.keys(Emotes).length / 2; i++) { | ||||
|         const emote = Emotes[i] | ||||
|         logger.debug(`Getting reaction for emote ${emote}`, { guildId, requestId }) | ||||
|         const reaction = await message.reactions.resolve(emote) | ||||
|         logger.debug(`Reaction for emote ${emote}: ${JSON.stringify(reaction, null, 2)}`, { guildId, requestId }) | ||||
|         if (reaction) { | ||||
|             const vote: Vote = { emote: emote, count: reaction.count, movie: extractMovieFromMessageByEmote(message, emote) } | ||||
|             votes.push(vote) | ||||
|         } | ||||
|     } | ||||
|     return votes | ||||
| } | ||||
|  | ||||
| function extractMovieFromMessageByEmote(message: Message, emote: string): string { | ||||
|     const lines = message.cleanContent.split("\n") | ||||
|     const emoteLines = lines.filter(line => line.includes(emote)) | ||||
|  | ||||
|     if (!emoteLines) { | ||||
|         return "" | ||||
|     } | ||||
|     const movie = emoteLines[0].substring(emoteLines[0].indexOf(emote) + emote.length + 2) // plus colon and space | ||||
|  | ||||
|     return movie | ||||
| } | ||||
|  | ||||
| export async function checkForPollsToClose(guild: Guild): Promise<void> { | ||||
|     const requestId = uuid() | ||||
|     logger.info(`Automatic check for poll closing.`, { guildId: guild.id, requestId }) | ||||
|     const events = (await guild.scheduledEvents.fetch()).filter(event => event.name.toLocaleLowerCase().includes("voting offen")).map(event => event) | ||||
|     if (events.length > 1) { | ||||
|         logger.error("Handling more than one Event is not implemented yet. Found more than one poll to close") | ||||
|         return | ||||
|     } else if (events.length == 0) { | ||||
|         logger.info("Could not find any events. Cancelling", { guildId: guild.id, requestId }) | ||||
|     } | ||||
|  | ||||
|     const updatedEvent = events[0]             //add two hours because of different timezones in discord api and Date.now() | ||||
|     if (!updatedEvent.scheduledStartTimestamp) { | ||||
|         logger.error("Event does not have a scheduled start time. Cancelling", { guildId: guild.id, requestId }) | ||||
|         return | ||||
|     } | ||||
|  | ||||
|     const createDate: Date = toDate(updatedEvent.createdTimestamp) | ||||
|     const eventDate: Date = toDate(updatedEvent.scheduledStartTimestamp) | ||||
|     const difference: number = differenceInDays(createDate, eventDate) | ||||
|  | ||||
|     if (difference <= 2) { | ||||
|         logger.info("Less than two days between event create and event start. Not closing poll.", { guildId: guild.id, requestId }) | ||||
|         return | ||||
|     } | ||||
|  | ||||
|     const closePollDate: Date = addDays(eventDate, -2) | ||||
|  | ||||
|     if (isAfter(Date.now(), closePollDate)) { | ||||
|         logger.info("Less than two days until event. Closing poll", { guildId: guild.id, requestId }) | ||||
|         closePoll(guild, requestId) | ||||
|     } else { | ||||
|         logger.info(`ScheduledStart: ${closePollDate}. Now: ${toDate(Date.now())}`, { guildId: guild.id, requestId }) | ||||
|     } | ||||
| } | ||||
| @ -1,6 +1,7 @@ | ||||
| import { ApplicationCommandOptionType } from 'discord.js' | ||||
| import { Command } from '../structures/command' | ||||
| import { RunOptions } from '../types/commandTypes' | ||||
| import { logger } from '../logger' | ||||
| export default new Command({ | ||||
| 	name: 'echo', | ||||
| 	description: 'Echoes a text', | ||||
| @ -13,7 +14,7 @@ export default new Command({ | ||||
| 		} | ||||
| 	], | ||||
| 	run: async (interaction: RunOptions) => { | ||||
| 		console.log('echo called') | ||||
| 		logger.info('echo called') | ||||
| 		interaction.interaction.reply(interaction.toString()) | ||||
| 	} | ||||
| }) | ||||
|  | ||||
| @ -2,15 +2,16 @@ import { v4 as uuid } from 'uuid' | ||||
| import { jellyfinHandler } from "../.." | ||||
| import { Command } from '../structures/command' | ||||
| import { RunOptions } from '../types/commandTypes' | ||||
| import { logger } from '../logger' | ||||
|  | ||||
| export default new Command({ | ||||
| 	name: 'passwort_reset', | ||||
| 	description: 'Ich vergebe dir ein neues Passwort und schicke es dir per DM zu. Kostet auch nix! Versprochen! 😉', | ||||
| 	options: [], | ||||
| 	run: async (interaction: RunOptions) => { | ||||
| 		console.log('PasswortReset called') | ||||
| 		logger.info('PasswortReset called') | ||||
| 		interaction.interaction.followUp('Yo, ich schick dir eins!') | ||||
| 		console.log(JSON.stringify(interaction.interaction.member, null, 2)) | ||||
| 		logger.info(JSON.stringify(interaction.interaction.member, null, 2)) | ||||
| 		jellyfinHandler.resetUserPasswort(interaction.interaction.member, uuid()) | ||||
| 	} | ||||
| }) | ||||
|  | ||||
| @ -30,6 +30,8 @@ export interface Config { | ||||
| 		yavin_jellyfin_url: string | ||||
| 		yavin_jellyfin_token: string | ||||
| 		yavin_jellyfin_collection_user: string | ||||
| 		random_movie_count: number | ||||
| 		reroll_retains_top_picks: boolean | ||||
| 	} | ||||
| } | ||||
| export const config: Config = { | ||||
| @ -59,16 +61,18 @@ export const config: Config = { | ||||
| 		client_id: process.env.CLIENT_ID ?? "", | ||||
| 		jellfin_token: process.env.JELLYFIN_TOKEN ?? "", | ||||
| 		jellyfin_url: process.env.JELLYFIN_URL ?? "", | ||||
|     workaround_token: process.env.TOKEN ?? "", | ||||
|     watcher_role: process.env.WATCHER_ROLE ?? "", | ||||
|     jf_admin_role: process.env.ADMIN_ROLE ?? "", | ||||
|     announcement_role: process.env.WATCHPARTY_ANNOUNCEMENT_ROLE ?? "", | ||||
|     announcement_channel_id: process.env.CHANNEL_ID ?? "", | ||||
| 		workaround_token: process.env.TOKEN ?? "TOKEN", | ||||
| 		watcher_role: process.env.WATCHER_ROLE ?? "WATCHER_ROLE", | ||||
| 		jf_admin_role: process.env.ADMIN_ROLE ?? "ADMIN_ROLE", | ||||
| 		announcement_role: process.env.WATCHPARTY_ANNOUNCEMENT_ROLE ?? "ANNOUNCE_ROLE", | ||||
| 		announcement_channel_id: process.env.CHANNEL_ID ?? "ANNOUNCE_CHANNEL", | ||||
| 		jf_collection_id: process.env.JELLYFIN_COLLECTION_ID ?? "", | ||||
| 		yavin_collection_id: process.env.YAVIN_COLLECTION_ID ?? "", | ||||
| 		yavin_jellyfin_url: process.env.YAVIN_JELLYFIN_URL ?? "", | ||||
| 		yavin_jellyfin_token: process.env.YAVIN_TOKEN ?? "", | ||||
| 		yavin_jellyfin_collection_user: process.env.YAVIN_COLLECTION_USER ?? "", | ||||
|     jf_user: process.env.JELLYFIN_USER ?? "" | ||||
| 		jf_user: process.env.JELLYFIN_USER ?? "", | ||||
| 		random_movie_count: parseInt(process.env.RANDOM_MOVIE_COUNT ?? "5") ?? 5, | ||||
| 		reroll_retains_top_picks: process.env.REROLL_RETAIN === "true" | ||||
| 	} | ||||
| } | ||||
|  | ||||
							
								
								
									
										17
									
								
								server/constants.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								server/constants.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,17 @@ | ||||
|  | ||||
| export enum ValidVoteEmotes { "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟" } | ||||
| export const NONE_OF_THAT = "❌" | ||||
| // WIP | ||||
| export const Emoji = { | ||||
| 	"one": "\u0031\uFE0F\u20E3", | ||||
| 	"two": "\u0032\uFE0F\u20E3", | ||||
| 	"three": "\u0033\uFE0F\u20E3", | ||||
| 	"four": "\u0034\uFE0F\u20E3", | ||||
| 	"five": "\u0035\uFE0F\u20E3", | ||||
| 	"six": "\u0036\uFE0F\u20E3", | ||||
| 	"seven": "\u0037\uFE0F\u20E3", | ||||
| 	"eight": "\u0038\uFE0F\u20E3", | ||||
| 	"nine": "\u0039\uFE0F\u20E3", | ||||
| 	"ten": "\uD83D\uDD1F", | ||||
| 	"ticket": "🎫" | ||||
| } | ||||
							
								
								
									
										52
									
								
								server/events/announceManualWatchparty.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								server/events/announceManualWatchparty.ts
									
									
									
									
									
										Normal 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 }) | ||||
| 	} | ||||
|  | ||||
|  | ||||
| } | ||||
							
								
								
									
										44
									
								
								server/events/autoCreateVoteByWPEvent.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								server/events/autoCreateVoteByWPEvent.ts
									
									
									
									
									
										Normal 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)) | ||||
| 	} | ||||
| } | ||||
|  | ||||
							
								
								
									
										53
									
								
								server/events/deleteAnnouncementsWhenWPEnds.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								server/events/deleteAnnouncementsWhenWPEnds.ts
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
| @ -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) | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -1,67 +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"; | ||||
| import { SemanticClassificationFormat } from "typescript"; | ||||
|  | ||||
|  | ||||
| export const name = 'guildScheduledEventCreate' | ||||
|  | ||||
| export enum Emotes { "1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟" } | ||||
| export const NONE_OF_THAT = "❌" | ||||
|  | ||||
| export let task: ScheduledTask | undefined | ||||
|  | ||||
| export async function execute(event: GuildScheduledEvent) { | ||||
|     const requestId = uuid() | ||||
|     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") | ||||
|         } | ||||
|         message = message.concat(NONE_OF_THAT).concat(": Wenn dir nichts davon gefällt.") | ||||
|  | ||||
|         const options: MessageCreateOptions = { | ||||
|             allowedMentions: { parse: ["roles"]}, | ||||
|             content: message | ||||
|         } | ||||
|  | ||||
|         const sentMessage: Message<true> = await (await announcementChannel.fetch()).send(options) | ||||
|  | ||||
|         for (let i = 0; i < movies.length; i++) { | ||||
|             sentMessage.react(Emotes[i]) | ||||
|         } | ||||
|         sentMessage.react(NONE_OF_THAT) | ||||
|  | ||||
|         // sentMessage.pin() //todo: uncomment when bot has permission to pin messages. Also update closepoll.ts to only fetch pinned messages | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -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) | ||||
| } | ||||
							
								
								
									
										46
									
								
								server/events/handleMessageReactionAdd.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								server/events/handleMessageReactionAdd.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | ||||
|  | ||||
| import { Message, MessageReaction, User } from "discord.js"; | ||||
| import { logger, newRequestId, noGuildId } from "../logger"; | ||||
| import { Emoji, ValidVoteEmotes, NONE_OF_THAT } from "../constants"; | ||||
| import { client } from "../.."; | ||||
| import { isInitialAnnouncement, isVoteMessage } from "../helper/messageIdentifiers"; | ||||
|  | ||||
|  | ||||
| export const name = 'messageReactionAdd' | ||||
|  | ||||
| export async function execute(messageReaction: MessageReaction, user: User) { | ||||
| 	if (user.id == client.user?.id) { | ||||
| 		logger.info('Skipping bot reaction') | ||||
| 		return | ||||
| 	} | ||||
| 	const requestId = newRequestId() | ||||
| 	const guildId = messageReaction.message.inGuild() ? messageReaction.message.guildId : noGuildId | ||||
| 	const reactedUponMessage: Message = messageReaction.message.partial ? await messageReaction.message.fetch() : messageReaction.message | ||||
| 	if (!messageReaction.message.guild) { | ||||
| 		logger.warn(`Received messageReactionAdd on non-guild message.`, { requestId }) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	logger.info(`Got reaction on message`, { requestId, guildId }) | ||||
|  | ||||
| 	logger.info(`emoji: ${messageReaction.emoji.toString()}`) | ||||
|  | ||||
| 	if (!Object.values(ValidVoteEmotes).includes(messageReaction.emoji.toString()) && messageReaction.emoji.toString() !== NONE_OF_THAT) { | ||||
| 		logger.info(`${messageReaction.emoji.toString()} currently not handled`) | ||||
| 		return | ||||
| 	} | ||||
| 	logger.info(`Found a match for ${messageReaction.emoji.toString()}`) | ||||
| 	if (isVoteMessage(reactedUponMessage)) { | ||||
| 		if (messageReaction.emoji.toString() === NONE_OF_THAT) { | ||||
| 			logger.info(`Reaction is NONE_OF_THAT on a vote message. Handling`, { requestId, guildId }) | ||||
| 			return client.voteController.handleNoneOfThatVote(messageReaction, reactedUponMessage, requestId, guildId) | ||||
| 		} | ||||
| 	} | ||||
| 	else if (isInitialAnnouncement(reactedUponMessage)) { | ||||
| 		if (messageReaction.emoji.toString() === Emoji.ticket) { | ||||
| 			logger.error(`Got a role emoji. ${reactedUponMessage.id}`) | ||||
| 			return client.roleController.addMediaRoleToUser(user, messageReaction.message.guild, requestId) | ||||
| 		} | ||||
| 		return | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										33
									
								
								server/events/handleMessageReactionRemove.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								server/events/handleMessageReactionRemove.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,33 @@ | ||||
|  | ||||
| import { Message, MessageReaction, User } from "discord.js"; | ||||
| import { logger, newRequestId, noGuildId } from "../logger"; | ||||
| import { Emoji } from "../constants"; | ||||
| import { client } from "../.."; | ||||
| import { isInitialAnnouncement } from "../helper/messageIdentifiers"; | ||||
|  | ||||
|  | ||||
| export const name = 'messageReactionRemove' | ||||
|  | ||||
| export async function execute(messageReaction: MessageReaction, user: User) { | ||||
| 	if (user.id == client.user?.id) { | ||||
| 		logger.info('Skipping bot reaction') | ||||
| 		return | ||||
| 	} | ||||
| 	const requestId = newRequestId() | ||||
| 	const guildId = messageReaction.message.inGuild() ? messageReaction.message.guildId : noGuildId | ||||
| 	const reactedUponMessage: Message = messageReaction.message.partial ? await messageReaction.message.fetch() : messageReaction.message | ||||
| 	if (!messageReaction.message.guild) { | ||||
| 		logger.warn(`Received messageReactionRemove on non-guild message.`, { requestId }) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	logger.info(`Got reaction on message`, { requestId, guildId }) | ||||
|  | ||||
| 	logger.info(`emoji: ${messageReaction.emoji.toString()}`) | ||||
| 	if (isInitialAnnouncement(reactedUponMessage)) { | ||||
| 		if (messageReaction.emoji.toString() === Emoji.ticket) { | ||||
| 			logger.info(`User: ${user.id}, ${user.username} has removed a ticket reaction. Starting role management`, { requestId, guildId }) | ||||
| 			return client.roleController.removeMediaRoleFromUser(user, messageReaction.message.guild, requestId) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										30
									
								
								server/events/handlePermJFAccountByRole.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								server/events/handlePermJFAccountByRole.ts
									
									
									
									
									
										Normal 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) | ||||
| 	} | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
							
								
								
									
										59
									
								
								server/events/handleTempJFUserByVoiceEvent.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								server/events/handleTempJFUserByVoiceEvent.ts
									
									
									
									
									
										Normal 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) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										58
									
								
								server/events/handleTempJFUsersByWPEvents.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								server/events/handleTempJFUsersByWPEvents.ts
									
									
									
									
									
										Normal 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) | ||||
| } | ||||
| @ -1,6 +1,7 @@ | ||||
| import { Message } from "discord.js" | ||||
| import { logger } from "../logger" | ||||
|  | ||||
| export const name = 'messageCreate' | ||||
| export function execute(message: Message) { | ||||
| 	console.log(`${JSON.stringify(message)} has been created`) | ||||
| 	logger.info(`${JSON.stringify(message)} has been created`) | ||||
| } | ||||
|  | ||||
| @ -1,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) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										23
									
								
								server/helper/dateHelper.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								server/helper/dateHelper.ts
									
									
									
									
									
										Normal 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}` | ||||
| } | ||||
							
								
								
									
										20
									
								
								server/helper/messageIdentifiers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								server/helper/messageIdentifiers.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | ||||
| import { Message } from "discord.js"; | ||||
|  | ||||
|  | ||||
| // branded types to differentiate objects of identical Type but different contents | ||||
| export type VoteEndMessage = Message<true> & { readonly __brand: 'voteend' } | ||||
| export type AnnouncementMessage = Message<true> & { readonly __brand: 'announcement' } | ||||
| export type VoteMessage = Message<true> & { readonly __brand: 'vote' } | ||||
|  | ||||
| export type KnownDiscordMessage = VoteMessage | VoteEndMessage | AnnouncementMessage | ||||
|  | ||||
| export function isVoteMessage(message: Message): message is VoteMessage { | ||||
| 	return message.cleanContent.includes('[Abstimmung]') | ||||
| } | ||||
| export function isInitialAnnouncement(message: Message): message is AnnouncementMessage { | ||||
| 	return message.cleanContent.includes("[initial]") | ||||
| } | ||||
| export function isVoteEndedMessage(message: Message): message is VoteEndMessage { | ||||
| 	return message.cleanContent.includes("[Abstimmung beendet]") | ||||
| } | ||||
|  | ||||
							
								
								
									
										83
									
								
								server/helper/role.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								server/helper/role.controller.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,83 @@ | ||||
| import { Guild, MessageReaction, Role, User } from "discord.js"; | ||||
| import { GuildMember } from "discord.js"; | ||||
| import { logger } from "../logger"; | ||||
| import { config } from "../configuration"; | ||||
| import { Maybe } from "../interfaces"; | ||||
|  | ||||
| export default class RoleController { | ||||
|  | ||||
| 	constructor() { } | ||||
| 	private getAnnounceRoleIdForGuild(guildId: string): string { | ||||
| 		const role = config.bot.announcement_role | ||||
| 		if (!role) throw new Error(`No announcementRole defined for guild ${guildId}`) | ||||
| 		return role | ||||
| 	} | ||||
| 	public async addRoleToUser(member: GuildMember, role: Role, guildId: string, requestId: string) { | ||||
| 		logger.info(`Adding Role ${role.id} to user ${member.id}|${member.user.username}`, { requestId, guildId }) | ||||
| 		return await member.roles.add(role) | ||||
| 	} | ||||
| 	private async removeRoleFromUser(member: GuildMember, role: Role, guildId: string, requestId: string) { | ||||
| 		logger.info(`Removing Role ${role.id} from user ${member.id}|${member.user.username}`, { requestId, guildId }) | ||||
| 		return await member.roles.remove(role) | ||||
| 	} | ||||
|  | ||||
| 	public async addMediaRoleToUser(user: User, guild: Guild, requestId: string) { | ||||
| 		const roleToAdd = await this.getAnnouncementRoleForGuild(guild, requestId) | ||||
| 		if (!roleToAdd) throw new Error(`No announcementRole found to add to user`) | ||||
| 		const guildMember = await guild.members.fetch(user) | ||||
| 		return this.addRoleToUser(guildMember, roleToAdd, guild.id, requestId) | ||||
| 	} | ||||
| 	public async removeMediaRoleFromUser(user: User, guild: Guild, requestId: string) { | ||||
| 		const roleToRemove = await this.getAnnouncementRoleForGuild(guild, requestId) | ||||
| 		if (!roleToRemove) throw new Error(`No announcementRole found to remove from user`) | ||||
| 		const guildMember = await guild.members.fetch(user) | ||||
| 		return this.removeRoleFromUser(guildMember, roleToRemove, guild.id, requestId) | ||||
| 	} | ||||
|  | ||||
| 	public async getAnnouncementRoleForGuild(guild: Guild, requestId: string): Promise<Role> { | ||||
| 		const mediaRole = this.getAnnounceRoleIdForGuild(guild.id) | ||||
| 		const announcement_role = await guild.roles.fetch() | ||||
| 			.then(fetchedRoles => fetchedRoles.find(role => role.id === mediaRole)) | ||||
| 			.catch(error => { | ||||
| 				logger.error(`Could not find announcement_role with id ${config.bot.announcement_role}. Error: ${error}`, { requestId, guildId: guild.id }) | ||||
| 				throw error | ||||
| 			}) | ||||
| 		if (!announcement_role) throw new Error(`Could not find announcement_role with id ${config.bot.announcement_role}.`) | ||||
| 		return announcement_role | ||||
| 	} | ||||
|  | ||||
| 	public async assignAnnouncementRolesFromReaction(guild: Guild, reaction: MessageReaction, requestId: string) { | ||||
| 		const guildId = guild.id | ||||
| 		logger.info("Managing roles", { guildId, requestId }) | ||||
|  | ||||
| 		const announcementRole = await this.getAnnouncementRoleForGuild(guild, requestId) | ||||
|  | ||||
| 		const usersWhoWantRole: User[] = (await reaction.users.fetch()).filter(user => !user.bot).map(user => user) | ||||
|  | ||||
| 		const allUsers = await guild.members.fetch() | ||||
|  | ||||
| 		const usersWhoHaveRole: GuildMember[] = allUsers | ||||
| 			.filter(member => member.roles.cache | ||||
| 				.find(role => role.id === announcementRole.id) !== undefined) | ||||
| 			.map(member => member) | ||||
|  | ||||
| 		const usersWhoNeedRoleRevoked: GuildMember[] = usersWhoHaveRole | ||||
| 			.filter(userWhoHas => !usersWhoWantRole.map(wanter => wanter.id).includes(userWhoHas.id)) | ||||
|  | ||||
| 		const usersWhoDontHaveRole: GuildMember[] = allUsers | ||||
| 			.filter(member => member.roles.cache | ||||
| 				.find(role => role.id === announcementRole.id) === undefined) | ||||
| 			.map(member => member) | ||||
|  | ||||
| 		const usersWhoNeedRole: GuildMember[] = usersWhoDontHaveRole | ||||
| 			.filter(userWhoNeeds => usersWhoWantRole.map(wanter => wanter.id).includes(userWhoNeeds.id)) | ||||
|  | ||||
|  | ||||
| 		logger.debug(`Theses users will get the role removed: ${JSON.stringify(usersWhoNeedRoleRevoked)}`, { guildId, requestId }) | ||||
| 		logger.debug(`Theses users will get the role added: ${JSON.stringify(usersWhoNeedRole)}`, { guildId, requestId }) | ||||
|  | ||||
| 		usersWhoNeedRoleRevoked.forEach(user => this.removeRoleFromUser(user, announcementRole, guild.id, requestId)) | ||||
| 		usersWhoNeedRole.forEach(user => this.addRoleToUser(user, announcementRole, guild.id, requestId)) | ||||
| 	} | ||||
|  | ||||
| } | ||||
| @ -1,5 +1,5 @@ | ||||
| import { Collection, GuildMember } from "discord.js" | ||||
| import { ChangedRoles, PermissionLevel } from "../interfaces" | ||||
| import { Collection, Guild, GuildMember, Role } from "discord.js" | ||||
| import { ChangedRoles, Maybe, PermissionLevel } from "../interfaces" | ||||
| import { logger } from "../logger" | ||||
| import { config } from "../configuration" | ||||
|  | ||||
| @ -16,6 +16,13 @@ export function filterRolesFromMemberUpdate(oldMember: GuildMember, newMember: G | ||||
| 	return { addedRoles, removedRoles } | ||||
| } | ||||
|  | ||||
| export async function getMembersWithRoleFromGuild(roleId: string, guild: Guild): Promise<Collection<string, GuildMember>> { | ||||
| 	const emptyResponse = new Collection<string, GuildMember> | ||||
| 	const guildRole: Maybe<Role> = guild.roles.resolve(roleId) | ||||
| 	if (!guildRole) return emptyResponse | ||||
| 	return guildRole.members | ||||
| } | ||||
|  | ||||
| export function getGuildSpecificTriggerRoleId(): Collection<string, PermissionLevel> { | ||||
| 	const outVal = new Collection<string, PermissionLevel>() | ||||
| 	outVal.set(config.bot.watcher_role, "VIEWER") | ||||
|  | ||||
| @ -1,10 +1,11 @@ | ||||
| import { CustomError, errorCodes } from "../interfaces" | ||||
| import { logger } from "../logger" | ||||
| import { ExtendedClient } from "../structures/client" | ||||
|  | ||||
| export async function sendFailureDM(creatorMessage: string, client: ExtendedClient, creatorId?: string): Promise<void> { | ||||
| 	if (!creatorId) throw new CustomError('No creator ID present', errorCodes.no_creator_id) | ||||
| 	const creator = await client.users.fetch(creatorId) | ||||
|   console.log(`Creator ${JSON.stringify(creator)}`) | ||||
| 	logger.info(`Creator ${JSON.stringify(creator)}`) | ||||
| 	if (creator) | ||||
| 		if (!creator.dmChannel) | ||||
| 			await creator.createDM() | ||||
|  | ||||
							
								
								
									
										361
									
								
								server/helper/vote.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										361
									
								
								server/helper/vote.controller.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,361 @@ | ||||
| import { Guild, GuildScheduledEvent, GuildScheduledEventEditOptions, GuildScheduledEventSetStatusArg, GuildScheduledEventStatus, Message, MessageCreateOptions, MessageReaction, TextChannel } from "discord.js" | ||||
| import { ValidVoteEmotes, NONE_OF_THAT } from "../constants" | ||||
| import { logger, newRequestId } from "../logger" | ||||
| import { getMembersWithRoleFromGuild } from "./roleFilter" | ||||
| import { config } from "../configuration" | ||||
| import { VoteMessage, isVoteEndedMessage, isVoteMessage } from "./messageIdentifiers" | ||||
| import { createDateStringFromEvent } from "./dateHelper" | ||||
| import { Maybe, prepareVoteMessageInput } from "../interfaces" | ||||
| import format from "date-fns/format" | ||||
| import toDate from "date-fns/toDate" | ||||
| import differenceInDays from "date-fns/differenceInDays" | ||||
| import addDays from "date-fns/addDays" | ||||
| import isAfter from "date-fns/isAfter" | ||||
| import { ExtendedClient } from "../structures/client" | ||||
| import { JellyfinHandler } from "../jellyfin/handler" | ||||
|  | ||||
| export type Vote = { | ||||
| 	emote: string, //todo habs nicht hinbekommen hier Emotes zu nutzen | ||||
| 	count: number, | ||||
| 	movie: string | ||||
| } | ||||
| export type VoteMessageInfo = { | ||||
| 	votes: Vote[], | ||||
| 	event: GuildScheduledEvent, | ||||
| } | ||||
| export default class VoteController { | ||||
| 	private client: ExtendedClient | ||||
| 	private yavinJellyfinHandler: JellyfinHandler | ||||
|  | ||||
| 	public constructor(_client: ExtendedClient, _yavin: JellyfinHandler) { | ||||
| 		this.client = _client | ||||
| 		this.yavinJellyfinHandler = _yavin | ||||
| 	} | ||||
|  | ||||
| 	public async handleNoneOfThatVote(messageReaction: MessageReaction, reactedUponMessage: VoteMessage, requestId: string, guildId: string) { | ||||
| 		if (!messageReaction.message.guild) return 'No guild' | ||||
| 		const guild = messageReaction.message.guild | ||||
| 		logger.debug(`${reactedUponMessage.id} is vote message`, { requestId, guildId }) | ||||
|  | ||||
| 		const watcherRoleMember = await getMembersWithRoleFromGuild(config.bot.announcement_role, messageReaction.message.guild) | ||||
| 		logger.info("ROLE MEMBERS " + JSON.stringify(watcherRoleMember), { requestId, guildId }) | ||||
|  | ||||
| 		const watcherRoleMemberCount = watcherRoleMember.size | ||||
| 		logger.info(`MEMBER COUNT: ${watcherRoleMemberCount}`, { requestId, guildId }) | ||||
|  | ||||
| 		const noneOfThatReactions = reactedUponMessage.reactions.cache.get(NONE_OF_THAT)?.users.cache.filter(x => x.id !== this.client.user?.id).size ?? 0 | ||||
|  | ||||
| 		const memberThreshold = (watcherRoleMemberCount / 2) | ||||
| 		logger.info(`Reroll ${noneOfThatReactions} > ${memberThreshold} ?`, { requestId, guildId }) | ||||
| 		if (noneOfThatReactions > memberThreshold) | ||||
| 			logger.info(`No reroll`, { requestId, guildId }) | ||||
| 		else { | ||||
| 			logger.info('Starting poll reroll', { requestId, guildId }) | ||||
| 			await this.handleReroll(reactedUponMessage, guild.id, requestId) | ||||
| 			logger.info(`Finished handling NONE_OF_THAT vote`, { requestId, guildId }) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private async removeMessage(message: Message): Promise<Message<boolean>> { | ||||
| 		if (message.pinned) { | ||||
| 			await message.unpin() | ||||
| 		} | ||||
| 		return await message.delete() | ||||
| 	} | ||||
|  | ||||
| 	/** | ||||
| 	 * returns true if a Vote object contains at least one vote | ||||
| 	 * @param {Vote} vote | ||||
| 	 */ | ||||
| 	private hasAtLeastOneVote(vote: Vote): boolean { | ||||
| 		// subtracting the bots initial vote | ||||
| 		const overOneVote = (vote.count - 1) >= 1 | ||||
| 		logger.debug(`${vote.movie} : ${vote.count} -> above: ${overOneVote}`) | ||||
| 		return overOneVote | ||||
| 	} | ||||
|  | ||||
| 	public async generateRerollMovieList(voteInfo: VoteMessageInfo, guildId: string, requestId: string) { | ||||
| 		if (config.bot.reroll_retains_top_picks) { | ||||
| 			const votedOnMovies = voteInfo.votes.filter(this.hasAtLeastOneVote).filter(x => x.emote !== NONE_OF_THAT) | ||||
| 			logger.info(`Found ${votedOnMovies.length} with votes`, { requestId, guildId }) | ||||
| 			const newMovieCount: number = config.bot.random_movie_count - votedOnMovies.length | ||||
| 			logger.info(`Fetching ${newMovieCount} from jellyfin`) | ||||
| 			const newMovies: string[] = await this.yavinJellyfinHandler.getRandomMovieNames(newMovieCount, guildId, requestId) | ||||
| 			// merge | ||||
| 			return newMovies.concat(votedOnMovies.map(x => x.movie)) | ||||
| 		} else { | ||||
| 			// get movies from jellyfin to fill the remaining slots | ||||
| 			const newMovieCount: number = config.bot.random_movie_count | ||||
| 			logger.info(`Fetching ${newMovieCount} from jellyfin`) | ||||
| 			return await this.yavinJellyfinHandler.getRandomMovieNames(newMovieCount, guildId, requestId) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public async handleReroll(voteMessage: VoteMessage, guildId: string, requestId: string) { | ||||
| 		// get the movies currently being voted on, their votes, the eventId and its date | ||||
| 		const voteInfo: VoteMessageInfo = await this.parseVoteInfoFromVoteMessage(voteMessage, requestId) | ||||
| 		if (!voteInfo.event.scheduledStartAt) { | ||||
| 			logger.info("Event does not have a start date, cancelling", { guildId: voteInfo.event.guildId, requestId }) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		let movies: string[] = await this.generateRerollMovieList(voteInfo, guildId, requestId) | ||||
|  | ||||
| 		const announcementChannel = this.client.getAnnouncementChannelForGuild(guildId) | ||||
| 		if (!announcementChannel) { | ||||
| 			logger.error(`No announcementChannel found for ${guildId}, can't post poll`) | ||||
| 			return | ||||
| 		} | ||||
| 		try { | ||||
| 			logger.info(`Trying to remove old vote Message`, { requestId, guildId }) | ||||
| 			this.removeMessage(voteMessage) | ||||
| 		} catch (err) { | ||||
| 			// TODO: integrate failure DM to media Admin to inform about inability to delete old message | ||||
| 			logger.error(`Error during removeMessage: ${err}`) | ||||
| 		} | ||||
| 		const sentMessage = this.prepareAndSendVoteMessage({ | ||||
| 			event: voteInfo.event, | ||||
| 			movies, | ||||
| 			announcementChannel, | ||||
| 			startDate: voteInfo.event.scheduledStartAt, | ||||
| 			pinAfterSending: true | ||||
| 		}, | ||||
| 			guildId, | ||||
| 			requestId) | ||||
| 		logger.debug(`Sent reroll message: ${JSON.stringify(sentMessage)}`, { requestId, guildId }) | ||||
| 	} | ||||
|  | ||||
| 	private async fetchEventByEventId(guild: Guild, eventId: string, requestId: string): Promise<Maybe<GuildScheduledEvent>> { | ||||
| 		const guildEvent: GuildScheduledEvent = await guild.scheduledEvents.fetch(eventId) | ||||
| 		if (!guildEvent) logger.error(`GuildScheduledEvent with id${eventId} could not be found`, { requestId, guildId: guild.id }) | ||||
| 		return guildEvent | ||||
| 	} | ||||
|  | ||||
| 	public async parseVoteInfoFromVoteMessage(message: VoteMessage, requestId: string): Promise<VoteMessageInfo> { | ||||
| 		const lines = message.cleanContent.split('\n') | ||||
| 		let parsedIds = this.parseGuildIdAndEventIdFromWholeMessage(message.cleanContent) | ||||
|  | ||||
| 		if (!message.guild) | ||||
| 			throw new Error(`Message ${message.id} not a guild message`) | ||||
|  | ||||
| 		const event: Maybe<GuildScheduledEvent> = await this.fetchEventByEventId(message.guild, parsedIds.eventId, requestId) | ||||
|  | ||||
| 		let votes: Vote[] = [] | ||||
| 		for (const line of lines) { | ||||
| 			if (line.slice(0, 5).includes(':')) { | ||||
| 				const splitLine = line.split(":") | ||||
| 				const [emoji, movie] = splitLine | ||||
| 				const fetchedVoteFromMessage = message.reactions.cache.get(emoji) | ||||
| 				if (fetchedVoteFromMessage) { | ||||
| 					if (emoji === NONE_OF_THAT) { | ||||
| 						votes.push({ movie: NONE_OF_THAT, emote: NONE_OF_THAT, count: fetchedVoteFromMessage.count }) | ||||
| 					} else | ||||
| 						votes.push({ movie: movie.trim(), emote: emoji, count: fetchedVoteFromMessage.count }) | ||||
| 				} else { | ||||
| 					logger.error(`No vote reaction found for movie, assuming 0`, requestId) | ||||
| 					votes.push({ movie, emote: emoji, count: 0 }) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 		return <VoteMessageInfo>{ event, votes } | ||||
| 	} | ||||
| 	public parseEventDateFromMessage(message: string, guildId: string, requestId: string): Date { | ||||
| 		logger.warn(`Falling back to RegEx parsing to get Event Date`, { guildId, requestId }) | ||||
| 		const datematcher = RegExp(/((?:0[1-9]|[12][0-9]|3[01])\.(?:0[1-9]|1[012])\.)(?:\ um\ )((?:(?:[01][0-9]|[2][0-3])\:[0-5][0-9])|(?:[2][4]\:00))!/i) | ||||
| 		const result: RegExpMatchArray | null = message.match(datematcher) | ||||
| 		const timeFromResult = result?.at(-1) | ||||
| 		const dateFromResult = result?.at(1)?.concat(format(new Date(), 'yyyy')).concat(" " + timeFromResult) ?? "" | ||||
| 		return new Date(dateFromResult) | ||||
| 	} | ||||
| 	public parseGuildIdAndEventIdFromWholeMessage(message: string) { | ||||
| 		const idmatch = RegExp(/(?:http|https):\/\/discord\.com\/events\/(\d*)\/(\d*)/) | ||||
| 		const matches = message.match(idmatch) | ||||
| 		if (matches && matches.length == 3) | ||||
| 			return { guildId: matches[1], eventId: matches[2] } | ||||
| 		throw Error(`Could not find eventId in Vote Message`) | ||||
| 	} | ||||
|  | ||||
| 	public async prepareAndSendVoteMessage(inputInfo: prepareVoteMessageInput, guildId: string, requestId: string) { | ||||
| 		const messageText = this.createVoteMessageText(inputInfo.event, inputInfo.movies, guildId, requestId) | ||||
| 		const sentMessage = await this.sendVoteMessage(messageText, inputInfo.movies.length, inputInfo.announcementChannel) | ||||
| 		if (inputInfo.pinAfterSending) | ||||
| 			sentMessage.pin() | ||||
| 		return sentMessage | ||||
| 	} | ||||
|  | ||||
| 	public createVoteMessageText(event: GuildScheduledEvent, movies: string[], guildId: string, requestId: string): string { | ||||
| 		let message = `[Abstimmung] für https://discord.com/events/${guildId}/${event.id} \n<@&${config.bot.announcement_role}> Es gibt eine neue Abstimmung für die nächste Watchparty ${createDateStringFromEvent(event.scheduledStartAt, guildId, requestId)}! Stimme hierunter für den nächsten Film ab!\n` | ||||
|  | ||||
| 		for (let i = 0; i < movies.length; i++) { | ||||
| 			message = message.concat(ValidVoteEmotes[i]).concat(": ").concat(movies[i]).concat("\n") | ||||
| 		} | ||||
| 		message = message.concat(NONE_OF_THAT).concat(": Wenn dir nichts davon gefällt.") | ||||
|  | ||||
| 		return message | ||||
| 	} | ||||
|  | ||||
| 	// TODO: Refactor into separate message controller | ||||
| 	public async sendVoteMessage(messageText: string, movieCount: number, announcementChannel: TextChannel) { | ||||
|  | ||||
| 		const options: MessageCreateOptions = { | ||||
| 			allowedMentions: { parse: ["roles"] }, | ||||
| 			content: messageText, | ||||
| 		} | ||||
|  | ||||
| 		const sentMessage: Message<true> = await (await announcementChannel.fetch()).send(options) | ||||
|  | ||||
| 		for (let i = 0; i < movieCount; i++) { | ||||
| 			sentMessage.react(ValidVoteEmotes[i]) | ||||
| 		} | ||||
| 		sentMessage.react(NONE_OF_THAT) | ||||
|  | ||||
| 		return sentMessage | ||||
| 	} | ||||
|  | ||||
| 	public async closePoll(guild: Guild, requestId: string) { | ||||
| 		const guildId = guild.id | ||||
| 		logger.info("stopping poll", { guildId, requestId }) | ||||
|  | ||||
| 		const announcementChannel: Maybe<TextChannel> = this.client.getAnnouncementChannelForGuild(guildId) | ||||
| 		if (!announcementChannel) { | ||||
| 			logger.error("Could not find the textchannel. Unable to close poll.", { guildId, requestId }) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		const messages: Message<true>[] = (await announcementChannel.messages.fetch()) //todo: fetch only pinned messages | ||||
| 			.map((value) => value) | ||||
| 			.filter(message => !isVoteEndedMessage(message) && isVoteMessage(message)) | ||||
| 			.sort((a, b) => b.createdTimestamp - a.createdTimestamp) | ||||
|  | ||||
| 		if (!messages || messages.length <= 0) { | ||||
| 			logger.info("Could not find any vote messages. Cancelling pollClose", { guildId, requestId }) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		const lastMessage: Message<true> = messages[0] | ||||
|  | ||||
| 		if (!isVoteMessage(lastMessage)) { | ||||
| 			logger.error(`Found message that is not a vote message, can't proceed`, { guildId, requestId }) | ||||
| 			logger.debug(`Found messages: ${JSON.stringify(messages, null, 2)}`, { guildId, requestId }) | ||||
| 			logger.debug(`Last message: ${JSON.stringify(lastMessage, null, 2)}`, { guildId, requestId }) | ||||
| 		} | ||||
| 		else { | ||||
| 			const votes = (await this.getVotesByEmote(lastMessage, guildId, requestId)) | ||||
| 				.sort((a, b) => b.count - a.count) | ||||
|  | ||||
| 			logger.debug(`votes: ${JSON.stringify(votes, null, 2)}`, { guildId, requestId }) | ||||
|  | ||||
| 			logger.info("Deleting vote message") | ||||
| 			lastMessage.unpin() | ||||
| 			await lastMessage.delete() | ||||
| 			const event = await this.getOpenPollEvent(guild, guild.id, requestId) | ||||
| 			if (event && votes?.length > 0) { | ||||
| 				this.updateOpenPollEventWithVoteResults(event, votes, guild, guildId, requestId) | ||||
| 				this.sendVoteClosedMessage(event, votes[0].movie, guildId, requestId) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	/** | ||||
| 	 * gets votes for the movies without the NONE_OF_THAT votes | ||||
| 	*/ | ||||
| 	public async getVotesByEmote(message: VoteMessage, guildId: string, requestId: string): Promise<Vote[]> { | ||||
| 		const votes: Vote[] = [] | ||||
| 		logger.debug(`Number of items in emotes: ${Object.values(ValidVoteEmotes).length}`, { guildId, requestId }) | ||||
| 		for (let i = 0; i < Object.keys(ValidVoteEmotes).length / 2; i++) { | ||||
| 			const emote = ValidVoteEmotes[i] | ||||
| 			logger.debug(`Getting reaction for emote ${emote}`, { guildId, requestId }) | ||||
| 			const reaction = message.reactions.resolve(emote) | ||||
| 			logger.debug(`Reaction for emote ${emote}: ${JSON.stringify(reaction, null, 2)}`, { guildId, requestId }) | ||||
| 			if (reaction) { | ||||
| 				const vote: Vote = { emote: emote, count: reaction.count, movie: this.extractMovieFromMessageByEmote(message, emote) } | ||||
| 				votes.push(vote) | ||||
| 			} | ||||
| 		} | ||||
| 		return votes | ||||
| 	} | ||||
| 	public async getOpenPollEvent(guild: Guild, guildId: string, requestId: string): Promise<Maybe<GuildScheduledEvent>> { | ||||
| 		const voteEvents = (await guild.scheduledEvents.fetch()) | ||||
| 			.map((value) => value) | ||||
| 			.filter(event => event.name.toLowerCase().includes("voting offen")) | ||||
| 		logger.debug(`Found events: ${JSON.stringify(voteEvents, null, 2)}`, { guildId, requestId }) | ||||
|  | ||||
| 		if (!voteEvents || voteEvents.length <= 0) { | ||||
| 			logger.error("Could not find an open vote event.", { guildId, requestId }) | ||||
| 			return | ||||
| 		} | ||||
| 		return voteEvents[0] | ||||
| 	} | ||||
| 	public async updateOpenPollEventWithVoteResults(voteEvent: GuildScheduledEvent, votes: Vote[], guild: Guild, guildId: string, requestId: string) { | ||||
| 		logger.info(`Updating event with movie ${votes[0].movie}.`, { guildId, requestId }) | ||||
| 		const options: GuildScheduledEventEditOptions<GuildScheduledEventStatus.Scheduled, GuildScheduledEventSetStatusArg<GuildScheduledEventStatus.Scheduled>> = { | ||||
| 			name: votes[0].movie, | ||||
| 			description: `!wp\nNummer 2: ${votes[1].movie} mit ${votes[1].count - 1} Stimmen\nNummer 3: ${votes[2].movie} mit ${votes[2].count - 1} Stimmen` | ||||
| 		} | ||||
| 		logger.debug(`Updating event: ${JSON.stringify(voteEvent, null, 2)}`, { guildId, requestId }) | ||||
| 		logger.info("Updating event.", { guildId, requestId }) | ||||
| 		voteEvent.edit(options) | ||||
| 	} | ||||
| 	public async sendVoteClosedMessage(event: GuildScheduledEvent, movie: string, guildId: string, requestId: string): Promise<Message<boolean>> { | ||||
| 		const date = event.scheduledStartAt ? format(event.scheduledStartAt, "dd.MM.") : "Fehler, Event hatte kein Datum" | ||||
| 		const time = event.scheduledStartAt ? format(event.scheduledStartAt, "HH:mm") : "Fehler, Event hatte keine Uhrzeit" | ||||
| 		const body = `[Abstimmung beendet] für https://discord.com/events/${event.guildId}/${event.id}\n<@&${config.bot.announcement_role}> Wir gucken ${movie} am ${date} um ${time}` | ||||
| 		const options: MessageCreateOptions = { | ||||
| 			content: body, | ||||
| 			allowedMentions: { parse: ["roles"] } | ||||
| 		} | ||||
| 		const announcementChannel = this.client.getAnnouncementChannelForGuild(guildId) | ||||
| 		logger.info("Sending vote closed message.", { guildId, requestId }) | ||||
| 		if (!announcementChannel) { | ||||
| 			const errorMessageText = "Could not find announcement channel. Please fix!" | ||||
| 			logger.error(errorMessageText, { guildId, requestId }) | ||||
| 			throw errorMessageText | ||||
| 		} | ||||
| 		return announcementChannel.send(options) | ||||
| 	} | ||||
| 	private extractMovieFromMessageByEmote(voteMessage: VoteMessage, emote: string): string { | ||||
| 		const lines = voteMessage.cleanContent.split("\n") | ||||
| 		const emoteLines = lines.filter(line => line.includes(emote)) | ||||
|  | ||||
| 		if (!emoteLines) { | ||||
| 			return "" | ||||
| 		} | ||||
| 		const movie = emoteLines[0].substring(emoteLines[0].indexOf(emote) + emote.length + 2) // plus colon and space | ||||
|  | ||||
| 		return movie | ||||
| 	} | ||||
| 	public async checkForPollsToClose(guild: Guild): Promise<void> { | ||||
| 		const requestId = newRequestId() | ||||
| 		logger.info(`Automatic check for poll closing.`, { guildId: guild.id, requestId }) | ||||
| 		const events = (await guild.scheduledEvents.fetch()).filter(event => event.name.toLocaleLowerCase().includes("voting offen")).map(event => event) | ||||
| 		if (events.length > 1) { | ||||
| 			logger.error("Handling more than one Event is not implemented yet. Found more than one poll to close") | ||||
| 			return | ||||
| 		} else if (events.length == 0) { | ||||
| 			logger.info("Could not find any events. Cancelling", { guildId: guild.id, requestId }) | ||||
| 		} | ||||
|  | ||||
| 		const updatedEvent = events[0]             //add two hours because of different timezones in discord api and Date.now() | ||||
| 		if (!updatedEvent.scheduledStartTimestamp) { | ||||
| 			logger.error("Event does not have a scheduled start time. Cancelling", { guildId: guild.id, requestId }) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		const createDate: Date = toDate(updatedEvent.createdTimestamp) | ||||
| 		const eventDate: Date = toDate(updatedEvent.scheduledStartTimestamp) | ||||
| 		const difference: number = differenceInDays(createDate, eventDate) | ||||
|  | ||||
| 		if (difference <= 2) { | ||||
| 			logger.info("Less than two days between event create and event start. Not closing poll.", { guildId: guild.id, requestId }) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		const closePollDate: Date = addDays(eventDate, -2) | ||||
|  | ||||
| 		if (isAfter(Date.now(), closePollDate)) { | ||||
| 			logger.info("Less than two days until event. Closing poll", { guildId: guild.id, requestId }) | ||||
| 			this.closePoll(guild, requestId) | ||||
| 		} else { | ||||
| 			logger.info(`ScheduledStart: ${closePollDate}. Now: ${toDate(Date.now())}`, { guildId: guild.id, requestId }) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| @ -1,5 +1,5 @@ | ||||
| import { Collection } from "@discordjs/collection" | ||||
| import { Role } from "discord.js" | ||||
| import { GuildScheduledEvent, Role, TextChannel } from "discord.js" | ||||
|  | ||||
| export type Maybe<T> = T | undefined | null | ||||
| export interface Player { | ||||
| @ -39,3 +39,10 @@ export interface JellyfinConfig { | ||||
| 	collectionUser: string | ||||
| } | ||||
| export type PermissionLevel = "VIEWER" | "ADMIN" | "TEMPORARY" | ||||
| export interface prepareVoteMessageInput { | ||||
| 	movies: string[], | ||||
| 	startDate: Date, | ||||
| 	event: GuildScheduledEvent, | ||||
| 	announcementChannel: TextChannel, | ||||
| 	pinAfterSending: boolean, | ||||
| } | ||||
|  | ||||
| @ -2,7 +2,7 @@ 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"; | ||||
|  | ||||
| @ -52,24 +52,46 @@ export class JellyfinHandler { | ||||
| 		return (Math.random() * 10000 + 10000).toFixed(0) | ||||
| 	} | ||||
|  | ||||
|   public async createUserAccountForDiscordUser(discordUser: GuildMember, level: PermissionLevel, guildId?: string, requestId?: string): Promise<UserDto> { | ||||
| 	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(), | ||||
| 				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 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 | ||||
|  | ||||
| 		const operation: UpdateUserPolicyRequest = { | ||||
| 			...user.policy, | ||||
| 			enableVideoPlaybackTranscoding: false | ||||
| 		} | ||||
|  | ||||
| 		const request: UpdateUserPolicyOperationRequest = { | ||||
| 			userId: user.id, | ||||
| 			updateUserPolicyRequest: operation | ||||
| 		} | ||||
| 		this.userApi.updateUserPolicy(request) | ||||
| 	} | ||||
|  | ||||
| 	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 }) | ||||
| @ -251,7 +273,7 @@ export class JellyfinHandler { | ||||
| 		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) | ||||
| 			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 | ||||
|  | ||||
| @ -29,7 +29,7 @@ export interface ConfigurationParameters { | ||||
| } | ||||
|  | ||||
| export class Configuration { | ||||
|     constructor(private configuration: ConfigurationParameters = {}) {} | ||||
| 	constructor(private configuration: ConfigurationParameters = {}) { } | ||||
|  | ||||
| 	set config(configuration: Configuration) { | ||||
| 		this.configuration = configuration; | ||||
| @ -253,22 +253,22 @@ function isFormData(value: any): value is FormData { | ||||
|  | ||||
| export class ResponseError extends Error { | ||||
| 	override name: "ResponseError" = "ResponseError"; | ||||
|     constructor(public response: Response, msg?: string) { | ||||
|         super(msg); | ||||
| 	constructor(public response: Response, errorMessage?: string) { | ||||
| 		super(errorMessage); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export class FetchError extends Error { | ||||
| 	override name: "FetchError" = "FetchError"; | ||||
|     constructor(public cause: Error, msg?: string) { | ||||
|         super(msg); | ||||
| 	constructor(public cause: Error, errorMessage?: string) { | ||||
| 		super(errorMessage); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export class RequiredError extends Error { | ||||
| 	override name: "RequiredError" = "RequiredError"; | ||||
|     constructor(public field: string, msg?: string) { | ||||
|         super(msg); | ||||
| 	constructor(public field: string, errorMessage?: string) { | ||||
| 		super(errorMessage); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -393,7 +393,7 @@ export interface ResponseTransformer<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()); | ||||
| @ -401,7 +401,7 @@ export class JSONApiResponse<T> { | ||||
| } | ||||
|  | ||||
| export class VoidApiResponse { | ||||
|     constructor(public raw: Response) {} | ||||
| 	constructor(public raw: Response) { } | ||||
|  | ||||
| 	async value(): Promise<void> { | ||||
| 		return undefined; | ||||
| @ -409,7 +409,7 @@ export class VoidApiResponse { | ||||
| } | ||||
|  | ||||
| export class BlobApiResponse { | ||||
|     constructor(public raw: Response) {} | ||||
| 	constructor(public raw: Response) { } | ||||
|  | ||||
| 	async value(): Promise<Blob> { | ||||
| 		return await this.raw.blob(); | ||||
| @ -417,7 +417,7 @@ export class BlobApiResponse { | ||||
| } | ||||
|  | ||||
| export class TextApiResponse { | ||||
|     constructor(public raw: Response) {} | ||||
| 	constructor(public raw: Response) { } | ||||
|  | ||||
| 	async value(): Promise<string> { | ||||
| 		return await this.raw.text(); | ||||
|  | ||||
| @ -1,9 +1,12 @@ | ||||
| import { createLogger, format, transports } from "winston" | ||||
| import { config } from "./configuration" | ||||
| import { v4 } from "uuid" | ||||
| export function newRequestId() { return v4() } | ||||
| export const noGuildId = 'NoGuildId' | ||||
|  | ||||
|  | ||||
| const printFn = format.printf(({ guildId, level, message, errorCode, requestId, timestamp: logTimestamp }: { [k: string]: string }) => { | ||||
|   return `[${guildId ?? ''}][${level}][${logTimestamp}][${errorCode ?? ''}][${requestId ?? ''}]:${message}` | ||||
| 	return `[${guildId ?? ''}][${level.padStart(5, " ")}][${logTimestamp}][${errorCode ?? ''}][${requestId ?? ''}]:${message}` | ||||
| }) | ||||
|  | ||||
| const logFormat = format.combine( | ||||
| @ -13,7 +16,8 @@ const logFormat = format.combine( | ||||
|  | ||||
| const consoleTransports = [ | ||||
| 	new transports.Console({ | ||||
|     format: logFormat | ||||
| 		format: logFormat, | ||||
| 		silent: process.env.NODE_ENV === 'testing' | ||||
| 	}) | ||||
| ] | ||||
| export const logger = createLogger({ | ||||
|  | ||||
| @ -2,13 +2,15 @@ import { ApplicationCommandDataResolvable, Client, ClientOptions, Collection, Gu | ||||
| import fs from 'fs'; | ||||
| import { ScheduledTask, schedule } from "node-cron"; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import { manageAnnouncementRoles } from "../commands/announce"; | ||||
| import { config } from "../configuration"; | ||||
| import { Maybe } from "../interfaces"; | ||||
| import { JellyfinHandler } from "../jellyfin/handler"; | ||||
| import { logger } from "../logger"; | ||||
| import { CommandType } from "../types/commandTypes"; | ||||
| import { checkForPollsToClose } from "../commands/closepoll"; | ||||
| import { isInitialAnnouncement } from "../helper/messageIdentifiers"; | ||||
| import VoteController from "../helper/vote.controller"; | ||||
| import { yavinJellyfinHandler } from "../.."; | ||||
| import RoleController from "../helper/role.controller"; | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -16,13 +18,15 @@ export class ExtendedClient extends Client { | ||||
| 	private eventFilePath = `${__dirname}/../events` | ||||
| 	private commandFilePath = `${__dirname}/../commands` | ||||
| 	private jellyfin: JellyfinHandler | ||||
| 	public voteController: VoteController = new VoteController(this, yavinJellyfinHandler) | ||||
| 	public roleController: RoleController = new RoleController() | ||||
| 	public commands: Collection<string, CommandType> = new Collection() | ||||
| 	private announcementChannels: Collection<string, TextChannel> = new Collection() //guildId to TextChannel | ||||
| 	private announcementRoleHandlerTask: Collection<string, ScheduledTask> = new Collection() //one task per guild | ||||
| 	private pollCloseBackgroundTasks: Collection<string, ScheduledTask> = new Collection() | ||||
| 	public constructor(jf: JellyfinHandler) { | ||||
| 		const intents: IntentsBitField = new IntentsBitField() | ||||
|     intents.add(IntentsBitField.Flags.GuildMembers, IntentsBitField.Flags.MessageContent, IntentsBitField.Flags.Guilds, IntentsBitField.Flags.DirectMessages, IntentsBitField.Flags.GuildScheduledEvents, IntentsBitField.Flags.GuildVoiceStates) | ||||
| 		intents.add(IntentsBitField.Flags.GuildMembers, IntentsBitField.Flags.MessageContent, IntentsBitField.Flags.Guilds, IntentsBitField.Flags.DirectMessages, IntentsBitField.Flags.GuildScheduledEvents, IntentsBitField.Flags.GuildMessageReactions, IntentsBitField.Flags.GuildVoiceStates) | ||||
| 		const options: ClientOptions = { intents } | ||||
| 		super(options) | ||||
| 		this.jellyfin = jf | ||||
| @ -74,6 +78,7 @@ export class ExtendedClient extends Client { | ||||
| 				this.registerCommands(slashCommands, guilds) | ||||
| 				this.cacheUsers(guilds) | ||||
| 				await this.cacheAnnouncementServer(guilds) | ||||
| 				this.fetchAnnouncementChannelMessage(this.announcementChannels) | ||||
| 				this.startAnnouncementRoleBackgroundTask(guilds) | ||||
| 				this.startPollCloseBackgroundTasks() | ||||
| 			}) | ||||
| @ -81,6 +86,21 @@ export class ExtendedClient extends Client { | ||||
| 			logger.info(`Error refreshing slash commands: ${error}`) | ||||
| 		} | ||||
| 	} | ||||
| 	/** | ||||
| 		* Fetches all messages from the provided channel collection. | ||||
| 		* This is necessary for announcementChannels, because 'old' messages don't receive | ||||
| 		* messageReactionAdd Events, only messages that were sent while the bot is online are tracked | ||||
| 		* automatically. | ||||
| 		* To prevent the need for a dedicated 'Collector' implementation which would listen on specific | ||||
| 		* it's easiest to just fetch all messages from the backlog, which automatically makes the bot track them | ||||
| 		* again. | ||||
| 		* @param {Collection<string, TextChannel>} channels - All channels which should be fecthed for reactionTracking	 | ||||
| 		*/ | ||||
| 	private async fetchAnnouncementChannelMessage(channels: Collection<string, TextChannel>): Promise<void> { | ||||
| 		channels.each(async ch => { | ||||
| 			ch.messages.fetch() | ||||
| 		}) | ||||
| 	} | ||||
| 	private async cacheAnnouncementServer(guilds: Collection<Snowflake, Guild>) { | ||||
| 		for (const guild of guilds.values()) { | ||||
| 			const channels: TextChannel[] = <TextChannel[]>(await guild.channels.fetch()) | ||||
| @ -130,13 +150,13 @@ export class ExtendedClient extends Client { | ||||
| 		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) { | ||||
| 			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]")) | ||||
| 				const messages = (await textChannel.messages.fetchPinned()).filter(message => isInitialAnnouncement(message)) | ||||
|  | ||||
| 				if (messages.size > 1) { | ||||
| 					logger.error("More than one pinned announcement Messages found. Unable to know which one people react to. Please fix!", { guildId: guild.id, requestId }) | ||||
| @ -153,10 +173,10 @@ export class ExtendedClient extends Client { | ||||
| 				} | ||||
| 				//logger.debug(`Message: ${JSON.stringify(message, null, 2)}`, { guildId: guild.id, requestId }) | ||||
|  | ||||
|         const reactions = message.reactions.resolve("🎫") | ||||
| 				const ticketReaction = message.reactions.resolve("🎫") | ||||
| 				//logger.debug(`reactions: ${JSON.stringify(reactions, null, 2)}`, { guildId: guild.id, requestId }) | ||||
|         if (reactions) { | ||||
|           manageAnnouncementRoles(message.guild, reactions, requestId) | ||||
| 				if (ticketReaction) { | ||||
| 					this.roleController.assignAnnouncementRolesFromReaction(message.guild, ticketReaction, requestId) | ||||
| 				} else { | ||||
| 					logger.error("Did not get reactions! Aborting!", { guildId: guild.id, requestId }) | ||||
| 				} | ||||
| @ -174,8 +194,8 @@ export class ExtendedClient extends Client { | ||||
| 	} | ||||
|  | ||||
| 	private async startPollCloseBackgroundTasks() { | ||||
|     for(const guild of this.guilds.cache) { | ||||
|       this.pollCloseBackgroundTasks.set(guild[1].id, schedule("0 * * * * *", () => checkForPollsToClose(guild[1]))) | ||||
| 		for (const guild of this.guilds.cache) { | ||||
| 			this.pollCloseBackgroundTasks.set(guild[1].id, schedule("0 * * * * *", () => this.voteController.checkForPollsToClose(guild[1]))) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
							
								
								
									
										81
									
								
								tests/discord/noneofthat.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								tests/discord/noneofthat.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,81 @@ | ||||
| import { Guild, GuildScheduledEvent, Message } from "discord.js" | ||||
| import VoteController from "../../server/helper/vote.controller" | ||||
| import { JellyfinHandler } from "../../server/jellyfin/handler" | ||||
| import { ExtendedClient } from "../../server/structures/client" | ||||
| import { Emoji, NONE_OF_THAT } from "../../server/constants" | ||||
| import { isVoteMessage } from "../../server/helper/messageIdentifiers" | ||||
|  | ||||
| describe('vote controller - none_of_that functions', () => { | ||||
| 	const testEventId = '1234321' | ||||
| 	const testEventDate = new Date('2023-01-01') | ||||
| 	const testGuildId = "888999888" | ||||
| 	const testMovies = [ | ||||
| 		'Movie1', | ||||
| 		'Movie2', | ||||
| 		'Movie3', | ||||
| 		'Movie4', | ||||
| 		'Movie5', | ||||
| 	] | ||||
| 	const votesList = [ | ||||
| 		{ emote: Emoji.one, count: 1, movie: testMovies[0] }, | ||||
| 		{ emote: Emoji.two, count: 2, movie: testMovies[1] }, | ||||
| 		{ emote: Emoji.three, count: 3, movie: testMovies[2] }, | ||||
| 		{ emote: Emoji.four, count: 1, movie: testMovies[3] }, | ||||
| 		{ emote: Emoji.five, count: 1, movie: testMovies[4] }, | ||||
| 		{ emote: NONE_OF_THAT, count: 2, movie: NONE_OF_THAT }, | ||||
| 	] | ||||
| 	const mockClient: ExtendedClient = <ExtendedClient><unknown>{ | ||||
| 		user: { | ||||
| 			id: 'mockId' | ||||
| 		} | ||||
| 	} | ||||
| 	const mockEvent: GuildScheduledEvent = <GuildScheduledEvent><unknown>{ | ||||
| 		scheduledStartAt: testEventDate, | ||||
| 		id: testEventId, | ||||
| 		guild: testGuildId | ||||
| 	} | ||||
| 	const mockJellyfinHandler: JellyfinHandler = <JellyfinHandler><unknown>{ | ||||
| 		getRandomMovieNames: jest.fn().mockReturnValue(["movie1"]) | ||||
| 	} | ||||
| 	const votes = new VoteController(mockClient, mockJellyfinHandler) | ||||
| 	const mockMessageContent = votes.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId") | ||||
|  | ||||
| 	test('sendVoteClosedMessage', async () => { | ||||
| 		mockClient.getAnnouncementChannelForGuild = jest.fn().mockReturnValue({ | ||||
| 			send: jest.fn().mockImplementation((options: any) => { | ||||
| 				return new Promise((resolve) => { | ||||
| 					resolve(options) | ||||
| 				}) | ||||
| 			}) | ||||
| 		}) | ||||
| 		const scheduledEvent: GuildScheduledEvent = <GuildScheduledEvent>{ | ||||
| 			scheduledStartAt: testEventDate, | ||||
| 			guildId: testGuildId, | ||||
| 			id: testEventId | ||||
| 		} | ||||
|  | ||||
| 		const res = await votes.sendVoteClosedMessage(scheduledEvent, 'MovieNew', 'guild', 'request') | ||||
| 		expect(res).toEqual({ | ||||
| 			allowedMentions: { | ||||
| 				parse: ["roles"] | ||||
| 			}, | ||||
| 			content: `[Abstimmung beendet] für https://discord.com/events/${testGuildId}/${testEventId}\n<@&WATCHPARTY_ANNOUNCEMENT_ROLE> Wir gucken MovieNew am 01.01. um 01:00` | ||||
| 		}) | ||||
| 	}) | ||||
|  | ||||
| 	test('getVotesByEmote', async () => { | ||||
| 		const mockMessage: Message = <Message><unknown>{ | ||||
| 			cleanContent: mockMessageContent, | ||||
| 			reactions: { | ||||
| 				resolve: jest.fn().mockImplementation((input: any) => { | ||||
| 					return votesList.find(e => e.emote === input) | ||||
| 				}) | ||||
| 			} | ||||
| 		} | ||||
| 		if (isVoteMessage(mockMessage)) { | ||||
| 			const result = await votes.getVotesByEmote(mockMessage, 'guildId', 'requestId') | ||||
| 			expect(result.length).toEqual(5) | ||||
| 			expect(result).toEqual(votesList.filter(x => x.movie != NONE_OF_THAT)) | ||||
| 		} | ||||
| 	}) | ||||
| }) | ||||
							
								
								
									
										192
									
								
								tests/discord/votes.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										192
									
								
								tests/discord/votes.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,192 @@ | ||||
| import { Emoji, NONE_OF_THAT } from "../../server/constants" | ||||
| import VoteController, { VoteMessageInfo } from "../../server/helper/vote.controller" | ||||
| import { JellyfinHandler } from "../../server/jellyfin/handler" | ||||
| import { ExtendedClient } from "../../server/structures/client" | ||||
| import { VoteMessage } from "../../server/helper/messageIdentifiers" | ||||
| import { GuildScheduledEvent, MessageReaction } from "discord.js" | ||||
| test('parse votes from vote message', async () => { | ||||
| 	const testMovies = [ | ||||
| 		'Movie1', | ||||
| 		'Movie2', | ||||
| 		'Movie3', | ||||
| 		'Movie4', | ||||
| 		'Movie5', | ||||
| 	] | ||||
| 	const testEventId = '1234321' | ||||
| 	const testEventDate = new Date('2023-01-01') | ||||
| 	const testGuildId = "888999888" | ||||
| 	const voteController: VoteController = new VoteController(<ExtendedClient>{}, <JellyfinHandler>{}) | ||||
| 	const mockEvent: GuildScheduledEvent = <GuildScheduledEvent><unknown>{ | ||||
| 		scheduledStartAt: testEventDate, | ||||
| 		id: testEventId, | ||||
| 		guild: testGuildId | ||||
| 	} | ||||
| 	const testMessage = voteController.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId") | ||||
|  | ||||
|  | ||||
| 	const expectedResult: VoteMessageInfo = { | ||||
| 		event: mockEvent, | ||||
| 		votes: [ | ||||
| 			{ emote: Emoji.one, count: 1, movie: testMovies[0] }, | ||||
| 			{ emote: Emoji.two, count: 2, movie: testMovies[1] }, | ||||
| 			{ emote: Emoji.three, count: 3, movie: testMovies[2] }, | ||||
| 			{ emote: Emoji.four, count: 1, movie: testMovies[3] }, | ||||
| 			{ emote: Emoji.five, count: 1, movie: testMovies[4] }, | ||||
| 			{ emote: NONE_OF_THAT, count: 1, movie: NONE_OF_THAT }, | ||||
| 		] | ||||
| 	} | ||||
|  | ||||
| 	const message: VoteMessage = <VoteMessage><unknown>{ | ||||
| 		cleanContent: testMessage, | ||||
| 		guild: { | ||||
| 			id: testGuildId, | ||||
| 			scheduledEvents: { | ||||
| 				fetch: jest.fn().mockImplementation((input: any) => { | ||||
| 					if (input === testEventId) | ||||
| 						return { | ||||
| 							id: testEventId, | ||||
| 							guild: testGuildId, | ||||
| 							scheduledStartAt: testEventDate | ||||
| 						} | ||||
| 				}) | ||||
| 			} | ||||
| 		}, | ||||
| 		reactions: { | ||||
| 			cache: { | ||||
| 				get: jest.fn().mockImplementation((input: any) => { | ||||
| 					// Abusing duck typing | ||||
| 					// Message Reaction has a method `count` and the expected votes | ||||
| 					// have a field `count` | ||||
| 					// this will evaluate to the same 'result' | ||||
| 					return expectedResult.votes.find(e => e.emote === input) | ||||
| 				}) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	const result = await voteController.parseVoteInfoFromVoteMessage(message, 'requestId') | ||||
| 	console.log(JSON.stringify(result)) | ||||
| 	expect(Array.isArray(result)).toBe(false) | ||||
| 	expect(result.event.id).toEqual(testEventId) | ||||
| 	expect(result.event.scheduledStartAt).toEqual(testEventDate) | ||||
| 	expect(result.votes.length).toEqual(expectedResult.votes.length) | ||||
| 	expect(result).toEqual(expectedResult) | ||||
| }) | ||||
|  | ||||
| test('parse votes from vote message', () => { | ||||
| 	const testMovies = [ | ||||
| 		'Movie1', | ||||
| 		'Movie2', | ||||
| 		'Movie3', | ||||
| 		'Movie4', | ||||
| 		'Movie5', | ||||
| 	] | ||||
| 	const testEventId = '1234321' | ||||
| 	const testEventDate = new Date('2023-01-01') | ||||
| 	const testGuildId = "888999888" | ||||
| 	const voteController: VoteController = new VoteController(<ExtendedClient>{}, <JellyfinHandler>{}) | ||||
| 	const mockEvent: GuildScheduledEvent = <GuildScheduledEvent><unknown>{ | ||||
| 		scheduledStartAt: testEventDate, | ||||
| 		id: testEventId, | ||||
| 		guild: testGuildId | ||||
| 	} | ||||
| 	const testMessage = voteController.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId") | ||||
|  | ||||
| 	const result = voteController.parseGuildIdAndEventIdFromWholeMessage(testMessage) | ||||
| 	expect(result).toEqual({ guildId: testGuildId, eventId: testEventId }) | ||||
| }) | ||||
|  | ||||
|  | ||||
| test.skip('handles complete none_of_that vote', () => { | ||||
|  | ||||
| 	const mockJellyfinHandler: JellyfinHandler = <JellyfinHandler><unknown>{ | ||||
| 		getRandomMovieNames: jest.fn().mockReturnValue(["movie1"]) | ||||
| 	} | ||||
|  | ||||
| 	const testMovies = [ | ||||
| 		'Movie1', | ||||
| 		'Movie2', | ||||
| 		'Movie3', | ||||
| 		'Movie4', | ||||
| 		'Movie5', | ||||
| 	] | ||||
| 	const testEventId = '1234321' | ||||
| 	const testEventDate = new Date('2023-01-01') | ||||
| 	const testGuildId = "888999888" | ||||
| 	const mockClient: ExtendedClient = <ExtendedClient><unknown>{ | ||||
| 		user: { | ||||
| 			id: 'mockId' | ||||
| 		} | ||||
| 	} | ||||
| 	const voteController = new VoteController(mockClient, mockJellyfinHandler) | ||||
| 	const mockEvent: GuildScheduledEvent = <GuildScheduledEvent><unknown>{ | ||||
| 		scheduledStartAt: testEventDate, | ||||
| 		id: testEventId, | ||||
| 		guild: testGuildId | ||||
| 	} | ||||
| 	const mockMessageContent = voteController.createVoteMessageText(mockEvent, testMovies, testGuildId, "requestId") | ||||
| 	const reactedUponMessage: VoteMessage = <VoteMessage><unknown>{ | ||||
| 		cleanContent: mockMessageContent, | ||||
| 		guild: { | ||||
| 			id: 'id', | ||||
| 			roles: { | ||||
| 				resolve: jest.fn().mockReturnValue({ | ||||
| 					members: [{}, {}, {}, {}, {}]//content does not matter | ||||
| 				}) | ||||
| 			}, | ||||
| 			scheduledEvents: { | ||||
| 				fetch: jest.fn().mockReturnValue([ | ||||
| 					{ | ||||
| 						name: 'voting offen' | ||||
| 					} | ||||
| 				]) | ||||
| 			} | ||||
| 		}, | ||||
| 		unpin: jest.fn().mockImplementation(() => { | ||||
|  | ||||
| 		}), | ||||
| 		delete: jest.fn().mockImplementation(() => { | ||||
|  | ||||
| 		}), | ||||
| 		reactions: { | ||||
| 			resolve: jest.fn().mockImplementation((input: any) => { | ||||
| 				console.log(JSON.stringify(input)) | ||||
| 			}), | ||||
| 			cache: { | ||||
| 				get: jest.fn().mockReturnValue({ | ||||
| 					users: { | ||||
| 						cache: [ | ||||
| 							{ | ||||
| 								id: "mockId"//to filter out | ||||
| 							}, | ||||
| 							{ | ||||
| 								id: "userId1" | ||||
| 							}, | ||||
| 							{ | ||||
| 								id: "userId2" | ||||
| 							}, | ||||
| 							{ | ||||
| 								id: "userId3" | ||||
| 							} | ||||
| 						] | ||||
| 					} | ||||
| 				}) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	const messageReaction: MessageReaction = <MessageReaction><unknown>{ | ||||
| 		message: reactedUponMessage | ||||
| 	} | ||||
|  | ||||
| 	mockClient.getAnnouncementChannelForGuild = jest.fn().mockReturnValue({ | ||||
| 		messages: { | ||||
| 			fetch: jest.fn().mockReturnValue([ | ||||
| 				reactedUponMessage | ||||
| 			]) | ||||
| 		} | ||||
| 	}) | ||||
|  | ||||
| 	const res = voteController.handleNoneOfThatVote(messageReaction, reactedUponMessage, 'requestId', 'guildId') | ||||
|  | ||||
|  | ||||
| }) | ||||
							
								
								
									
										15
									
								
								tests/helpers/date.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								tests/helpers/date.test.ts
									
									
									
									
									
										Normal 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') | ||||
| }) | ||||
							
								
								
									
										28
									
								
								tests/helpers/memberRoles.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								tests/helpers/memberRoles.test.ts
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										15
									
								
								tests/testenv.js
									
									
									
									
									
										Normal 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" | ||||
| @ -1,61 +1,44 @@ | ||||
| { | ||||
| 	"extends":"@tsconfig/recommended/tsconfig.json", | ||||
| 	"exclude":["node_modules"], | ||||
| 	"extends": "@tsconfig/recommended/tsconfig.json", | ||||
| 	"exclude": [ | ||||
| 		"node_modules" | ||||
| 	], | ||||
| 	"compilerOptions": { | ||||
| 		/* Basic Options */ | ||||
| 		"target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, | ||||
| 		"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, | ||||
| 		"resolveJsonModule": true, | ||||
|     // "lib": [],                             /* Specify library files to be included in the compilation. */ | ||||
|     // "allowJs": true,                       /* Allow javascript files to be compiled. */ | ||||
|     // "checkJs": true,                       /* Report errors in .js files. */ | ||||
|     // "jsx": "preserve",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ | ||||
|     // "declaration": true,                   /* Generates corresponding '.d.ts' file. */ | ||||
|     // "declarationMap": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */ | ||||
|     // "sourceMap": true,                     /* Generates corresponding '.map' file. */ | ||||
|     // "outFile": "./",                       /* Concatenate and emit output to single file. */ | ||||
| 		"outDir": "./build" /* Redirect output structure to the directory. */, | ||||
| 		// "rootDir": "./",                       /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ | ||||
| 		// "composite": true,                     /* Enable project compilation */ | ||||
|     // "removeComments": true,                /* Do not emit comments to output. */ | ||||
|     // "noEmit": true,                        /* Do not emit outputs. */ | ||||
|     // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */ | ||||
|     // "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ | ||||
|     // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ | ||||
|  | ||||
| 		"removeComments": true,                /* Do not emit comments to output. */ | ||||
| 		/* Strict Type-Checking Options */ | ||||
| 		"strict": true /* Enable all strict type-checking options. */, | ||||
|     // "noImplicitAny": true,                 /* Raise error on expressions and declarations with an implied 'any' type. */ | ||||
|     // "strictNullChecks": true,              /* Enable strict null checks. */ | ||||
|     // "strictFunctionTypes": true,           /* Enable strict checking of function types. */ | ||||
| 		"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ | ||||
| 		"strictNullChecks": true, /* Enable strict null checks. */ | ||||
| 		"strictFunctionTypes": true, /* Enable strict checking of function types. */ | ||||
| 		// "strictBindCallApply": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ | ||||
| 		// "strictPropertyInitialization": true,  /* Enable strict checking of property initialization in classes. */ | ||||
| 		// "noImplicitThis": true,                /* Raise error on 'this' expressions with an implied 'any' type. */ | ||||
|     // "alwaysStrict": true,                  /* Parse in strict mode and emit "use strict" for each source file. */ | ||||
|  | ||||
| 		"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ | ||||
| 		//"noUncheckedIndexedAccess": true, | ||||
| 		/* Additional Checks */ | ||||
|     // "noUnusedLocals": true,                /* Report errors on unused locals. */ | ||||
| 		//"noUnusedLocals": true,                /* Report errors on unused locals. */ | ||||
| 		// "noUnusedParameters": true,            /* Report errors on unused parameters. */ | ||||
| 		// "noImplicitReturns": true,             /* Report error when not all code paths in function return a value. */ | ||||
| 		// "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */ | ||||
|  | ||||
| 		/* Module Resolution Options */ | ||||
| 		"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ | ||||
| 		// "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */ | ||||
| 		// "paths": {},                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ | ||||
| 		// "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */ | ||||
|     // "typeRoots": [],                       /* List of folders to include type definitions from. */ | ||||
|     // "types": [],                           /* Type declaration files to be included in compilation. */ | ||||
| 		"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ | ||||
| 		"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, | ||||
| 		// "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */ | ||||
|  | ||||
| 		/* Source Map Options */ | ||||
| 		// "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */ | ||||
| 		// "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */ | ||||
| 		"inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */ | ||||
|     // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ | ||||
|  | ||||
| 		/* Experimental Options */ | ||||
| 		// "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */ | ||||
| 		// "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */ | ||||
|  | ||||
		Reference in New Issue
	
	Block a user