├── .dockerignore ├── .github └── workflows │ └── docker-build.yaml ├── .gitignore ├── Dockerfile ├── README.md ├── add-js-extensions.cjs ├── build.sh ├── clean.cjs ├── docker-compose.yml ├── eslint.config.mjs ├── package.json ├── src ├── DiscordBot.ts ├── api │ ├── DiscordBotUser.ts │ ├── automod.ts │ ├── bot-db.ts │ ├── dbConnect.ts │ ├── forumAPI.ts │ ├── migrations.ts │ ├── moderationWebhooks.ts │ ├── news.ts │ ├── queries │ │ ├── all.ts │ │ ├── other.ts │ │ ├── v1.ts │ │ ├── v2-collection.ts │ │ ├── v2-collectionRevisions.ts │ │ ├── v2-collections.ts │ │ ├── v2-collectionsdownloadtotals.ts │ │ ├── v2-finduser.ts │ │ ├── v2-game.ts │ │ ├── v2-ismodauthor.ts │ │ ├── v2-latestmods.ts │ │ ├── v2-mods.ts │ │ ├── v2-modsFiles.ts │ │ ├── v2-modsbymodid.ts │ │ ├── v2-modsbyuid.ts │ │ ├── v2-mycollections.ts │ │ ├── v2-news.ts │ │ ├── v2-updatedMods.ts │ │ ├── v2-users.ts │ │ └── v2.ts │ ├── server_role_conditions.ts │ ├── servers.ts │ ├── subscriptions.ts │ ├── tips.ts │ ├── users.ts │ └── util.ts ├── app.ts ├── events │ ├── error.ts │ ├── interactionCreate.ts │ ├── ready.ts │ ├── readyForAction.ts │ ├── reconnecting.ts │ └── resume.ts ├── feeds │ ├── AutoModManager.ts │ ├── NewsFeedManager.ts │ └── SubscriptionManager.ts ├── interactions │ ├── about.ts │ ├── automod.ts │ ├── claimrole.ts │ ├── commandupdate.ts │ ├── help.ts │ ├── link.ts │ ├── mytoken.ts │ ├── news.ts │ ├── profile.ts │ ├── refresh.ts │ ├── search.ts │ ├── settings.ts │ ├── status.ts │ ├── test.ts │ ├── tips-manager.ts │ ├── tips.ts │ ├── track.ts │ ├── triggerupdate.ts │ ├── unlink.ts │ ├── untrack.ts │ ├── user-profile.ts │ └── whois.ts ├── server │ ├── DiscordOAuth.ts │ ├── NexusModsOAuth.ts │ ├── forumWebhook.ts │ ├── public │ │ ├── css │ │ │ └── styles.css │ │ └── images │ │ │ ├── Discord.svg │ │ │ ├── github-mark-white.svg │ │ │ ├── readme.svg │ │ │ ├── retry.svg │ │ │ ├── standby.gif │ │ │ ├── success.gif │ │ │ ├── unlink.gif │ │ │ └── unlinkerror.gif │ ├── server.ts │ └── views │ │ ├── content.ejs │ │ ├── errormessage.ejs │ │ ├── footer.ejs │ │ ├── header.ejs │ │ ├── index.ejs │ │ ├── linkconfirm.ejs │ │ ├── linkerror.ejs │ │ ├── revoked.ejs │ │ ├── revokesuccess.ejs │ │ ├── success.ejs │ │ ├── tracking.ejs │ │ ├── trackingInfo.ejs │ │ ├── unlinkerror.ejs │ │ └── unlinkerrormessage.ejs ├── shards.ts └── types │ ├── DiscordTypes.ts │ ├── ForumWebhookTypes.ts │ ├── GQLTypes.ts │ ├── NexusModsAPIv1.ts │ ├── feeds.ts │ ├── servers.ts │ ├── subscriptions.ts │ ├── users.ts │ └── util.ts ├── tsconfig.json └── tsconfig.tsbuildinfo /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | #Ignore thumbnails created by Windows 3 | Thumbs.db 4 | #Ignore files built by Visual Studio 5 | *.obj 6 | *.exe 7 | *.pdb 8 | *.user 9 | *.aps 10 | *.pch 11 | *.vspscc 12 | *_i.c 13 | *_p.c 14 | *.ncb 15 | *.suo 16 | *.tlb 17 | *.tlh 18 | *.bak 19 | *.cache 20 | *.ilk 21 | *.log 22 | [Bb]in 23 | [Dd]ebug*/ 24 | *.lib 25 | *.sbr 26 | obj/ 27 | [Rr]elease*/ 28 | _ReSharper*/ 29 | [Tt]est[Rr]esult* 30 | .vs/ 31 | #Nuget packages folder 32 | packages/ 33 | *.env 34 | data/ 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Set up Docker Buildx 14 | uses: docker/setup-buildx-action@v3 15 | - 16 | name: Login to Docker Hub 17 | uses: docker/login-action@v3 18 | with: 19 | username: ${{ secrets.DOCKERHUB_USERNAME }} 20 | password: ${{ secrets.DOCKERHUB_TOKEN }} 21 | - 22 | name: Build and push 23 | uses: docker/build-push-action@v5 24 | with: 25 | push: true 26 | tags: nexusmods/discord-bot:latest 27 | 28 | redeploy: 29 | name: Redeploy webhook call 30 | runs-on: ubuntu-latest 31 | needs: [ docker ] 32 | steps: 33 | - name: Deploy docker container webhook 34 | uses: joelwmale/webhook-action@master 35 | env: 36 | WEBHOOK_URL: ${{ secrets.DEPLOY_WEBHOOK_URL }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | #Ignore thumbnails created by Windows 3 | Thumbs.db 4 | #Ignore files built by Visual Studio 5 | *.obj 6 | *.exe 7 | *.pdb 8 | *.user 9 | *.aps 10 | *.pch 11 | *.vspscc 12 | *_i.c 13 | *_p.c 14 | *.ncb 15 | *.suo 16 | *.tlb 17 | *.tlh 18 | *.bak 19 | *.cache 20 | *.ilk 21 | *.log 22 | [Bb]in 23 | [Dd]ebug*/ 24 | *.lib 25 | *.sbr 26 | obj/ 27 | [Rr]elease*/ 28 | _ReSharper*/ 29 | [Tt]est[Rr]esult* 30 | .vs/ 31 | #Nuget packages folder 32 | packages/ 33 | *.env 34 | data/ 35 | dist/ 36 | .yarn/ 37 | serverconfig.json 38 | config.json 39 | .yarnrc.yml 40 | package-lock.json 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN corepack enable 8 | RUN npm install 9 | 10 | # Copy the files over 11 | COPY . . 12 | 13 | RUN npm run build 14 | 15 | ENTRYPOINT npm run startSharded 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # discord-bot 2 | Nexus Mods Discord Bot 3 | 4 | Writen by: Pickysaurus (Nexus Mods) 5 | 6 | This Discord bot features interactions with the Nexus Mods API. Including linking your Discord and Nexus Mods accounts, searching games or mods and subscribing new and updated mods for a particular game. 7 | 8 | While you are welcome to take parts of this code for your own projects, please do not run your own instance of this Discord bot. 9 | -------------------------------------------------------------------------------- /add-js-extensions.cjs: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | // Directory where your compiled files are located 5 | const directory = './dist'; // Adjust to your actual compiled JS folder path 6 | 7 | // Function to update import statements to include `.js` if missing 8 | function updateImportsInFile(filePath) { 9 | let content = fs.readFileSync(filePath, 'utf-8'); 10 | 11 | // Match relative imports like './api/util' or '../api/users.js' (e.g., './api/util') 12 | content = content.replace(/(['"])([\.]+\/[^'"]+)(['"])/g, (match, p1, p2, p3) => { 13 | // If the path is relative and doesn't already have '.js' at the end 14 | if (!p2.endsWith('.js') && !p2.endsWith('.json') && !p2.includes('node_modules')) { 15 | return `${p1}${p2}.js${p3}`; 16 | } 17 | // Otherwise, leave it as is (even if it already has `.js`) 18 | return match; 19 | }); 20 | 21 | // Write the updated content back to the file 22 | fs.writeFileSync(filePath, content, 'utf-8'); 23 | } 24 | 25 | // Function to recursively traverse the directory and process each file 26 | function updateImportsInDirectory(directory) { 27 | const files = fs.readdirSync(directory); 28 | files.forEach((file) => { 29 | const filePath = path.join(directory, file); 30 | const stat = fs.statSync(filePath); 31 | if (stat.isDirectory()) { 32 | // Recurse into subdirectories 33 | updateImportsInDirectory(filePath); 34 | } else if (filePath.endsWith('.js')) { 35 | // Modify only .js files (compiled files) 36 | updateImportsInFile(filePath); 37 | } 38 | }); 39 | } 40 | 41 | // Start processing the compiled JS files 42 | updateImportsInDirectory(directory); 43 | console.log('Updated import statements in compiled JS files to include .js extension where necessary.'); 44 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | # Install dependencies 2 | npm install --no-package-lock \ 3 | @discordjs/uws@^10.149.0 \ 4 | request@^2.34 5 | 6 | # Install packages 7 | npm install 8 | 9 | node $WORK_DIR/index.js 10 | -------------------------------------------------------------------------------- /clean.cjs: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | 3 | function deleteFolderRecursive(path) { 4 | if (fs.existsSync(path) && fs.lstatSync(path).isDirectory()) { 5 | fs.readdirSync(path).forEach(function(file, index) { 6 | var curPath = path + '/' + file; 7 | 8 | if (fs.lstatSync(curPath).isDirectory()) { // recurse 9 | deleteFolderRecursive(curPath); 10 | } 11 | else { // delete file 12 | fs.unlinkSync(curPath); 13 | } 14 | }); 15 | 16 | // console.log(`Deleting directory "${path}"...`); 17 | fs.rmdirSync(path); 18 | } 19 | } 20 | 21 | console.log('Cleaning working tree...'); 22 | 23 | deleteFolderRecursive('./dist'); 24 | 25 | console.log('Successfully cleaned working tree!'); -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | # This is the Bot! 5 | bot: 6 | container_name: "nexus-mods-bot" 7 | # build from the Dockerfile 8 | build: . 9 | image: nexus-mods-bot 10 | # ports to expose to your machine (and others) - 11 | # again, overwrites the Dockerfile so you can run it on other ports locally if you choose. 12 | # e.g: "port to access on your machine:port the container is exposing" 13 | ports: 14 | - "80:80" 15 | volumes: 16 | - ./:/bot/ 17 | # links your bot to the database (networking wise) 18 | links: 19 | - postgres 20 | # Make sure your database starts up before the bot 21 | # Word of caution: this only makes the container start first, it doesn't wait until the container has finished starting up. 22 | depends_on: 23 | - postgres 24 | 25 | # This builds you a database 26 | postgres: 27 | image: postgres:12 28 | environment: 29 | # This is the user your bot will login to the database with 30 | POSTGRES_USER: postgres 31 | # This is the database name which will get created automagically for you. 32 | POSTGRES_DB: discord_bot 33 | # This is the port the database will use to communicate on. 34 | POSTGRES_PORT: '5432' 35 | # ports to expose to your machine (and others) 36 | ports: 37 | - "5432:5432" 38 | # This allows the database to keep its data between being destroyed and re-created 39 | volumes: 40 | - postgres_data:/var/lib/postgresql/data 41 | 42 | volumes: 43 | postgres_data: -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "eslint/config"; 2 | import globals from "globals"; 3 | import path from "node:path"; 4 | import { fileURLToPath } from "node:url"; 5 | import js from "@eslint/js"; 6 | import { FlatCompat } from "@eslint/eslintrc"; 7 | import tsPlugin from "@typescript-eslint/eslint-plugin"; // Add TypeScript plugin 8 | import tsParser from "@typescript-eslint/parser"; // Add TypeScript parser 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all 16 | }); 17 | 18 | export default defineConfig([{ 19 | extends: [ 20 | compat.extends("eslint:recommended"), 21 | "plugin:@typescript-eslint/recommended" // Add recommended TypeScript rules 22 | ], 23 | 24 | languageOptions: { 25 | parser: tsParser, // Use TypeScript parser 26 | parserOptions: { 27 | ecmaVersion: 2020, 28 | sourceType: "module", 29 | project: "./tsconfig.json", // Point to your TypeScript config file 30 | }, 31 | globals: { 32 | ...globals.node, 33 | }, 34 | }, 35 | 36 | plugins: [ 37 | "@typescript-eslint" // Add TypeScript plugin 38 | ], 39 | 40 | rules: { 41 | // JavaScript rules 42 | "arrow-spacing": ["warn", { 43 | before: true, 44 | after: true, 45 | }], 46 | 47 | "brace-style": ["error", "stroustrup", { 48 | allowSingleLine: true, 49 | }], 50 | 51 | "comma-dangle": ["error", "always-multiline"], 52 | "comma-spacing": "error", 53 | "comma-style": "error", 54 | curly: ["error", "multi-line", "consistent"], 55 | "dot-location": ["error", "property"], 56 | "handle-callback-err": "off", 57 | indent: ["error", "tab"], 58 | "keyword-spacing": "error", 59 | 60 | "max-nested-callbacks": ["error", { 61 | max: 4, 62 | }], 63 | 64 | "max-statements-per-line": ["error", { 65 | max: 2, 66 | }], 67 | 68 | "no-console": "off", 69 | "no-empty-function": "error", 70 | "no-floating-decimal": "error", 71 | "no-inline-comments": "error", 72 | "no-lonely-if": "error", 73 | "no-multi-spaces": "error", 74 | 75 | "no-multiple-empty-lines": ["error", { 76 | max: 2, 77 | maxEOF: 1, 78 | maxBOF: 0, 79 | }], 80 | 81 | "no-shadow": ["error", { 82 | allow: ["err", "resolve", "reject"], 83 | }], 84 | 85 | "no-trailing-spaces": ["error"], 86 | 87 | "no-unused-vars": ["error", { 88 | argsIgnorePattern: "^_", 89 | }], 90 | 91 | "no-var": "error", 92 | "object-curly-spacing": ["error", "always"], 93 | "prefer-const": "error", 94 | quotes: ["error", "single"], 95 | semi: ["error", "always"], 96 | "space-before-blocks": "error", 97 | 98 | "space-before-function-paren": ["error", { 99 | anonymous: "never", 100 | named: "never", 101 | asyncArrow: "always", 102 | }], 103 | 104 | "space-in-parens": "error", 105 | "space-infix-ops": "error", 106 | "space-unary-ops": "error", 107 | "spaced-comment": "error", 108 | "strict-boolean-expressions": "off", 109 | yoda: "error", 110 | 111 | // TypeScript-specific rules 112 | "@typescript-eslint/no-unused-vars": ["error", { 113 | argsIgnorePattern: "^_", 114 | }], 115 | "@typescript-eslint/no-empty-function": "error", 116 | "@typescript-eslint/explicit-function-return-type": "off", 117 | }, 118 | }]); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nexus-bot-typescript", 3 | "type": "module", 4 | "version": "3.14.3", 5 | "description": "A Discord bot for Nexus Mods, written in TypeScript", 6 | "main": "dist/app.js", 7 | "scripts": { 8 | "build": "npm run clean && npx tsc --build tsconfig.json && node add-js-extensions.cjs && npm run copy-assets", 9 | "copy-assets": "copyfiles -f -a ./src/server/views/* dist/server/views && copyfiles -f -a ./src/server/public/css/* dist/server/public/css && copyfiles -f -a ./src/server/public/images/* dist/server/public/images", 10 | "startSharded": "node dist/shards.js", 11 | "dev": "npm run build && npm run start", 12 | "devShard": "npm run build && npm run startSharded", 13 | "test": "echo \"Error: no test specified\" && exit 1", 14 | "clean": "node clean.cjs", 15 | "start": "node dist/app.js" 16 | }, 17 | "author": "Pickysaurus", 18 | "license": "ISC", 19 | "devDependencies": { 20 | "@eslint/eslintrc": "^3.3.1", 21 | "@eslint/js": "^9.23.0", 22 | "@types/html-to-text": "^9.0.4", 23 | "@typescript-eslint/eslint-plugin": "^8.29.0", 24 | "@typescript-eslint/parser": "^8.29.0", 25 | "copyfiles": "^2.4.1", 26 | "eslint": "^9.23.0", 27 | "globals": "^16.0.0", 28 | "typescript": "^5.8.2" 29 | }, 30 | "dependencies": { 31 | "@types/cookie-parser": "^1.4.8", 32 | "@types/express": "^5.0.1", 33 | "@types/jsonwebtoken": "^9.0.9", 34 | "@types/nconf": "^0.10.3", 35 | "@types/node": "^22.13.14", 36 | "@types/pg": "^8.11.11", 37 | "axios": "^1.8.4", 38 | "cookie-parser": "^1.4.7", 39 | "discord.js": "^14.18.0", 40 | "dotenv": "^16.4.7", 41 | "ejs": "^3.1.10", 42 | "express": "^4.21.2", 43 | "fuse.js": "^6.6.2", 44 | "graphql": "^16.10.0", 45 | "graphql-request": "^7.1.2", 46 | "html-to-text": "^9.0.5", 47 | "jsonwebtoken": "^9.0.2", 48 | "path": "^0.12.7", 49 | "pg": "^8.14.1", 50 | "tall": "^8.0.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/api/automod.ts: -------------------------------------------------------------------------------- 1 | import query from './dbConnect'; 2 | import { IAutomodRule, IBadFileRule } from "../types/util"; 3 | 4 | async function getAutomodRules(): Promise { 5 | try { 6 | const result = await query('SELECT * FROM automod_rules ORDER BY id ASC', [], 'GetAutomodRules'); 7 | return result.rows; 8 | } catch (error) { 9 | return Promise.reject(error); 10 | } 11 | } 12 | 13 | async function createAutomodRule(type: 'low' | 'high', filter: string, reason: string): Promise { 14 | try { 15 | const result = await query<{ id: number }>('INSERT INTO automod_rules (type, filter, reason) VALUES ($1, $2, $3) RETURNING id', [type, filter, reason], 'CreateAutomodRule'); 16 | return result.rows[0].id; 17 | } catch (error) { 18 | return Promise.reject(error); 19 | } 20 | } 21 | 22 | async function deleteAutomodRule(id: number): Promise { 23 | try { 24 | await query('DELETE FROM automod_rules WHERE id=$1', [id], 'DeleteAutomodRule'); 25 | } catch (error) { 26 | return Promise.reject(error); 27 | } 28 | } 29 | 30 | async function getBadFiles(): Promise { 31 | try { 32 | const result = await query('SELECT * FROM automod_badfiles ORDER BY id ASC', [], 'GetBadFiles'); 33 | return result.rows; 34 | } catch (error) { 35 | return Promise.reject(error); 36 | } 37 | } 38 | 39 | async function addBadFile(type: 'low' | 'high', func: string, test: string, flagMessage: string): Promise { 40 | try { 41 | const result = await query<{ id: number }>('INSERT INTO automod_badfiles (type, test, "flagMessage", "funcName") VALUES ($1, $2, $3, $4) RETURNING id', 42 | [type, test.toLowerCase(), flagMessage, func], 'AddBadFile'); 43 | return result.rows[0].id; 44 | } catch (error) { 45 | return Promise.reject(error); 46 | } 47 | } 48 | 49 | export { getAutomodRules, createAutomodRule, deleteAutomodRule, getBadFiles, addBadFile }; 50 | -------------------------------------------------------------------------------- /src/api/bot-db.ts: -------------------------------------------------------------------------------- 1 | //Used for reference: https://blog.logrocket.com/setting-up-a-restful-api-with-node-js-and-postgresql-d96d6fc892d8/ 2 | 3 | // USER MANAGEMENT FUNCTIONS 4 | import { getAllUsers, getUserByDiscordId, getUserByNexusModsName, getUserByNexusModsId, createUser, deleteUser, updateUser, userEmbed, userProfileEmbed } from './users'; 5 | 6 | // SERVER MANAGEMENT FUNCTIONS 7 | import { getAllServers, getServer, addServer, updateServer, deleteServer } from './servers'; 8 | 9 | // NEWS MANAGEMENT FUNCTIONS 10 | import { getSavedNews, updateSavedNews, ensureNewsDB } from './news'; 11 | 12 | // AUTOMOD 13 | import { getAutomodRules, createAutomodRule, deleteAutomodRule, getBadFiles, addBadFile } from './automod'; 14 | 15 | // TIPS 16 | import { getAllTips, addTip, deleteTip, editTip, setApprovedTip } from './tips'; 17 | 18 | // ROLE CONDITIONS 19 | import { getConditionsForRole, addConditionForRole, changeRoleForConditions, deleteConditionForRole, deleteAllConditionsForRole } from './server_role_conditions'; 20 | 21 | export { 22 | getAllUsers, getUserByDiscordId, getUserByNexusModsName, getUserByNexusModsId, createUser, deleteUser, updateUser, userEmbed, userProfileEmbed, 23 | getAllServers, getServer, addServer, updateServer, deleteServer, 24 | getSavedNews, updateSavedNews, ensureNewsDB, 25 | getAutomodRules, createAutomodRule, deleteAutomodRule, 26 | getBadFiles, addBadFile, 27 | getAllTips, addTip, deleteTip, editTip, setApprovedTip, 28 | getConditionsForRole, addConditionForRole, changeRoleForConditions, deleteConditionForRole, deleteAllConditionsForRole 29 | }; 30 | -------------------------------------------------------------------------------- /src/api/dbConnect.ts: -------------------------------------------------------------------------------- 1 | import pg, { PoolConfig, PoolClient, QueryResult, QueryResultRow } from 'pg'; 2 | const { Pool, DatabaseError } = pg; 3 | import { logger } from '../DiscordBot'; 4 | import dotenv from 'dotenv'; 5 | dotenv.config(); 6 | 7 | const isProduction = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === undefined; 8 | 9 | const poolConfig: PoolConfig = { 10 | user: process.env.DBUSER, 11 | password: process.env.DBPASS || '', 12 | host: process.env.HOST, 13 | database: process.env.DATABASE, 14 | port: process.env.PORT ? parseInt(process.env.PORT) : 0, 15 | ssl: isProduction 16 | ? { rejectUnauthorized: false } 17 | : false, 18 | // statement_timeout: 10000, 19 | connectionTimeoutMillis: 5000, 20 | idleTimeoutMillis: 2000, 21 | max: 10 22 | }; 23 | 24 | const pool = new Pool(poolConfig); 25 | 26 | export async function queryPromise(query: string, values?: any[], name?: string): Promise> { 27 | let client: PoolClient | undefined = undefined; 28 | 29 | try { 30 | client = await pool.connect(); 31 | const result = await client.query({ 32 | text: query, 33 | values, 34 | name, 35 | }); 36 | return result; 37 | 38 | } 39 | catch(err) { 40 | if (!client) logger.error('Error acquiring client', { query, err: (err as Error).message }); 41 | else logger.error('Error in query', { query, values, err }); 42 | throw handleDatabaseError(err); 43 | } 44 | finally { 45 | client?.release() 46 | } 47 | } 48 | 49 | function handleDatabaseError(error: Error | any): string { 50 | if (error instanceof DatabaseError) { 51 | logger.debug('Database error', { error }); 52 | switch (error.code) { 53 | case '23505': // Unique violation 54 | logger.error('Database error - Duplicate entry:', error.detail); 55 | return 'Duplicate record found. Please try again.'; 56 | case '23503': // Foreign key violation 57 | logger.error('Database error - Foreign key violation:', error.detail); 58 | return 'Invalid reference. Please check your data.'; 59 | case '22001': // String data too long 60 | logger.error('Database error - Value too long:', error.detail); 61 | return 'Input value is too long. Please shorten the text.'; 62 | case '42601': // Syntax error in SQL 63 | logger.error('Database error - Syntax error:', error.detail); 64 | return 'An unexpected error occurred (Syntax error). Please try again later.'; 65 | case '42703': // Undefined column 66 | logger.error('Database error - Undefined column:', error.detail); 67 | return 'An unexpected error occurred (Undefined column). Please try again later.'; 68 | default: 69 | logger.error(`Unhandled database error [${error.code}]:`, error.message); 70 | return `An unexpected database error occurred (${error.code}). Please try again later.`; 71 | } 72 | } else if (error.message === 'The server does not support SSL connections') { 73 | return 'SSL connection error. Please report this issue as it is a problem with the database settings.'; 74 | } else if (error.message.includes('no pg_hba.conf entry for host')) { 75 | logger.error('Database connection error - pg_hba.conf issue:', error.message); 76 | return 'Database connection error: Access denied. Please report this issue as it is a problem with the database settings.'; 77 | } else if (error.message.includes('timeout exceeded when trying to connect')) { 78 | logger.error('Database connection timed out.', { error }); 79 | return 'Database connection timed out.' 80 | } else { 81 | logger.error('Unknown error', { message: error.message, code: error.code, error }); 82 | return 'An unknown error occurred. Please try again later.'; 83 | } 84 | } 85 | 86 | export default queryPromise; -------------------------------------------------------------------------------- /src/api/forumAPI.ts: -------------------------------------------------------------------------------- 1 | // Functionality to interact with the forum API 2 | import dotenv from 'dotenv'; 3 | dotenv.config(); 4 | 5 | import { ForumTopic } from "../types/ForumWebhookTypes"; 6 | 7 | export async function getTopic(id: number): Promise { 8 | const APIKEY: string | undefined = process.env.FORUM_API_KEY; 9 | if (!APIKEY || !APIKEY.length) throw new Error('FORUM_API_KEY is not set in the environment variables.'); 10 | const params = new URLSearchParams({ key: APIKEY }); 11 | const forumsEndpoint = `https://forums.nexusmods.com/api/forums/topics/${id}?${params.toString()}`; 12 | 13 | const response = await fetch(forumsEndpoint, { 14 | method: 'GET', 15 | headers: { 16 | 'Content-Type': 'application/json', 17 | 'User-Agent': 'Nexus Mods Discord Bot', 18 | 'Accept': '*/*' 19 | }, 20 | }); 21 | 22 | if (!response.ok) { 23 | const bodyText = await response.text(); 24 | if (bodyText.includes('Cloudflare')) throw new Error('Cloudflare error, please try again later'); 25 | else throw new Error('Error fetching topic: '+response.statusText); 26 | } 27 | 28 | return await response.json(); 29 | } -------------------------------------------------------------------------------- /src/api/migrations.ts: -------------------------------------------------------------------------------- 1 | import { queryPromise } from './dbConnect'; 2 | 3 | 4 | async function migrationMoveConfigOptionsToJSON() { 5 | try { 6 | console.log('Migrating SubsribedItem settings to JSONB column'); 7 | 8 | await queryPromise( 9 | `ALTER TABLE SubscribedItems 10 | ADD COLUMN IF NOT EXISTS config JSONB;`, 11 | [], 12 | 'migrationMoveConfigOptionsToJSON - Add config column' 13 | ); 14 | 15 | await queryPromise( 16 | `UPDATE SubscribedItems 17 | SET config = CASE 18 | WHEN show_new IS NULL AND show_updates IS NULL AND nsfw IS NULL AND sfw IS NULL AND last_status IS NULL 19 | THEN NULL -- Set config to NULL if all columns are NULL 20 | ELSE COALESCE(config, '{}') -- Ensure the config column is not NULL 21 | || CASE WHEN show_new IS NOT NULL THEN jsonb_build_object('show_new', show_new) ELSE '{}'::jsonb END 22 | || CASE WHEN show_updates IS NOT NULL THEN jsonb_build_object('show_updates', show_updates) ELSE '{}'::jsonb END 23 | || CASE WHEN nsfw IS NOT NULL THEN jsonb_build_object('nsfw', nsfw) ELSE '{}'::jsonb END 24 | || CASE WHEN sfw IS NOT NULL THEN jsonb_build_object('sfw', sfw) ELSE '{}'::jsonb END 25 | || CASE WHEN last_status IS NOT NULL THEN jsonb_build_object('last_status', last_status) ELSE '{}'::jsonb END 26 | END`, 27 | [], 28 | 'migrationMoveConfigOptionsToJSON' 29 | ); 30 | 31 | // return queryPromise( 32 | // `ALTER TABLE SubscribedItems 33 | // DROP COLUMN IF EXISTS show_new, 34 | // DROP COLUMN IF EXISTS show_updates, 35 | // DROP COLUMN IF EXISTS nsfw, 36 | // DROP COLUMN IF EXISTS sfw, 37 | // DROP COLUMN IF EXISTS last_status;`, 38 | // [], 39 | // 'migrationMoveConfigOptionsToJSON - Add delete old columns' 40 | // ); 41 | } 42 | catch(err) { 43 | throw err 44 | } 45 | } 46 | 47 | async function migrationDeleteAPIkeyColumn(): Promise { 48 | try { 49 | await queryPromise('ALTER TABLE users DROP COLUMN IF EXISTS apikey', []); 50 | console.log('Deleted API key column from users table'); 51 | } 52 | catch (err) { 53 | throw err; 54 | } 55 | } 56 | 57 | export { migrationMoveConfigOptionsToJSON, migrationDeleteAPIkeyColumn }; -------------------------------------------------------------------------------- /src/api/moderationWebhooks.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { RESTPostAPIWebhookWithTokenJSONBody } from "discord.js"; 3 | import { Logger } from './util'; 4 | 5 | export interface ISlackMessage { 6 | blocks: (ISlackSectionBlock | ISlackHeaderBlock | ISlackDividerBlock)[] 7 | } 8 | 9 | interface ISlackSectionBlock { 10 | type: 'section', 11 | text: { 12 | type: 'mrkdwn', 13 | text: string, 14 | } 15 | accessory?: { 16 | type: "image", 17 | image_url: string 18 | alt_text: string 19 | } 20 | } 21 | 22 | interface ISlackHeaderBlock { 23 | type: 'header', 24 | text: { 25 | type: 'plain_text', 26 | text: string, 27 | } 28 | } 29 | 30 | interface ISlackDividerBlock { 31 | type: 'divider' 32 | } 33 | 34 | export async function PublishToSlack(data: ISlackMessage, logger: Logger): Promise { 35 | const slackWebhook: string = process.env['SLACK_WEBHOOK'] || ''; 36 | 37 | if (!slackWebhook) throw new Error('Slack webhook is not provided!'); 38 | 39 | if (data.blocks.length === 0) return true; 40 | 41 | try { 42 | await axios({ 43 | method: 'POST', 44 | url: slackWebhook, 45 | data: JSON.stringify(data, null, 2), 46 | headers: { 47 | 'Content-Type': 'application/json' 48 | }, 49 | }); 50 | 51 | return true 52 | } 53 | catch(err) { 54 | logger.warn('Error posting Slack Webhook', err, true); 55 | return false; 56 | } 57 | } 58 | 59 | 60 | export async function PublishToDiscord(data: RESTPostAPIWebhookWithTokenJSONBody, logger: Logger): Promise { 61 | const discordWebhook: string = process.env['DISCORD_WEBHOOK'] || ''; 62 | 63 | if (!discordWebhook) throw new Error('Discord webhook is not provided!'); 64 | 65 | // if (firstMods.users.size > firstMods.lastPostedAt) { 66 | // data.content = `Users uploading their first mod between - : **${firstMods.users.size}**`; 67 | // } 68 | 69 | try { 70 | await axios({ 71 | method: 'POST', 72 | url: discordWebhook, 73 | data: JSON.stringify(data, null, 2), 74 | headers: { 75 | 'Content-Type': 'application/json' 76 | }, 77 | }); 78 | 79 | return true 80 | } 81 | catch(err) { 82 | logger.warn('Error posting Discord Webhook', err, true); 83 | return false; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/api/news.ts: -------------------------------------------------------------------------------- 1 | import { queryPromise } from './dbConnect'; 2 | import { Logger } from './util'; 3 | import { SavedNewsData } from '../types/feeds'; 4 | 5 | async function getSavedNews(logger: Logger): Promise { 6 | try { 7 | const data = await queryPromise( 8 | 'SELECT * FROM news', 9 | [] 10 | ) 11 | return data.rows[0]; 12 | } 13 | catch(err) { 14 | logger.error('Error getting saved news', err, true); 15 | throw err; 16 | } 17 | } 18 | 19 | async function updateSavedNews(logger: Logger, title: string, date: Date, id: number): Promise { 20 | try { 21 | await queryPromise( 22 | 'DELETE FROM news', 23 | [] 24 | ); 25 | await queryPromise( 26 | 'INSERT INTO news (title, date, id) VALUES ($1, $2, $3)', 27 | [title, date, id] 28 | ); 29 | return true; 30 | } 31 | catch(err) { 32 | logger.error('Error updating news', err, true); 33 | throw err; 34 | } 35 | } 36 | 37 | async function ensureNewsDB(logger: Logger): Promise { 38 | try { 39 | await queryPromise( 40 | `CREATE TABLE IF NOT EXISTS public.news 41 | ( 42 | title character varying COLLATE pg_catalog."default" NOT NULL, 43 | date timestamp with time zone NOT NULL, 44 | id integer NOT NULL 45 | ) 46 | `, 47 | [] 48 | ); 49 | 50 | } 51 | catch(err) { 52 | logger.error('Error creating news table', err, true); 53 | throw err; 54 | } 55 | } 56 | 57 | export { getSavedNews, updateSavedNews, ensureNewsDB }; -------------------------------------------------------------------------------- /src/api/queries/all.ts: -------------------------------------------------------------------------------- 1 | import { quicksearch, updatedMods, modInfo, modFiles as modFilesV1, modChangelogs, games as gamesV1, game as gameV1, validate } from './v1'; 2 | import { isModAuthor } from './v2-ismodauthor'; 3 | import { game } from './v2-game'; 4 | import { mods as modsById } from './v2-modsbymodid'; 5 | import { mods } from './v2-mods'; 6 | import { myCollections } from './v2-mycollections'; 7 | import { collections } from './v2-collections'; 8 | import { collection } from './v2-collection'; 9 | import { collectionsDownloadTotals } from './v2-collectionsdownloadtotals'; 10 | import { findUser } from './v2-finduser'; 11 | import { updatedMods as updatedModsV2 } from './v2-updatedMods'; 12 | import { latestMods } from './v2-latestmods'; 13 | import { news } from './v2-news'; 14 | import { modFiles } from './v2-modsFiles'; 15 | import { Games as gamesJSON, ModDownloads, SiteStats, WebsiteStatus } from './other'; 16 | import { modsByUid } from './v2-modsbyuid'; 17 | import { users } from './v2-users'; 18 | import { collectionRevisions } from './v2-collectionRevisions'; 19 | 20 | export const v1 = { 21 | validate, 22 | updatedMods, 23 | quicksearch, 24 | modInfo, 25 | modFiles: modFilesV1, 26 | modChangelogs, 27 | games: gamesV1, 28 | game: gameV1 29 | }; 30 | 31 | export const v2 = { 32 | isModAuthor, 33 | game, 34 | mods, 35 | modsById, 36 | modsByUid, 37 | updatedMods: updatedModsV2, 38 | myCollections, 39 | collections, 40 | collection, 41 | collectionsDownloadTotals, 42 | findUser, 43 | latestMods, 44 | news, 45 | modFiles, 46 | users, 47 | collectionRevisions 48 | }; 49 | 50 | export const other = { 51 | Games: gamesJSON, 52 | ModDownloads, 53 | SiteStats, 54 | WebsiteStatus 55 | } -------------------------------------------------------------------------------- /src/api/queries/v1.ts: -------------------------------------------------------------------------------- 1 | import { IChangelogs, IGameInfo, IGameListEntry, IModFiles, IModInfo, IUpdateEntry, IValidateKeyResponse } from '../../types/NexusModsAPIv1'; 2 | import axios, { AxiosError } from 'axios'; 3 | import { NexusAPIServerError, NexusSearchResult } from '../../types/util'; 4 | import { Logger } from "../util"; 5 | 6 | const nexusAPI: string = 'https://api.nexusmods.com/'; 7 | 8 | async function v1APIQuery (logger: Logger, path: string, headers: Record, params?: { [key: string]: any }): Promise { 9 | const authType = 'OAUTH'; 10 | try { 11 | const query = await axios({ 12 | baseURL: nexusAPI, 13 | url: path, 14 | transformResponse: (data) => JSON.parse(data), 15 | headers, 16 | params, 17 | }); 18 | return query.data; 19 | } 20 | catch(err) { 21 | if (err as AxiosError) return Promise.reject(new NexusAPIServerError(err as AxiosError, authType, path)); 22 | logger.error('Unexpected API error', err, true); 23 | return Promise.reject(new Error(`Unexpected API error: ${(err as Error)?.message}`)); 24 | } 25 | } 26 | 27 | export async function quicksearch(query: string, bIncludeAdult: boolean, game_id: number = 0): Promise { 28 | query = query.split(' ').toString();//query.replace(/[^A-Za-z0-9\s]/gi, '').split(' ').join(','); 29 | try { 30 | const searchQuery = await axios({ 31 | baseURL: nexusAPI, 32 | url: '/mods', 33 | params: { 34 | terms: encodeURI(query), 35 | game_id, 36 | include_adult: bIncludeAdult, 37 | }, 38 | transformResponse: (data) => JSON.parse(data), 39 | timeout: 15000 40 | }); 41 | const results = { 42 | fullSearchURL: `https://www.nexusmods.com/search/?RH_ModList=nav:true,home:false,type:0,user_id:0,game_id:${game_id},advfilt:true,search%5Bfilename%5D:${query.split(',').join('+')},include_adult:${bIncludeAdult},page_size:20,show_game_filter:true`, 43 | ...searchQuery.data 44 | }; 45 | // const searchQuery = await requestPromise({ url: nexusSearchAPI, qs: { terms: encodeURI(query), game_id, include_adult: bIncludeAdult }, timeout: 15000 }); 46 | // let results = JSON.parse(searchQuery); 47 | // results.fullSearchURL = `https://www.nexusmods.com/search/?RH_ModList=nav:true,home:false,type:0,user_id:0,game_id:${game_id},advfilt:true,search%5Bfilename%5D:${query.split(',').join('+')},include_adult:${bIncludeAdult},page_size:20,show_game_filter:true`; 48 | return results; 49 | } 50 | catch(err) { 51 | return Promise.reject(err); 52 | // if ((err as Error).message.toLowerCase().includes('cloudflare')) return Promise.reject(new Error('Cloudflare error: Quicksearch request timed out.')); 53 | // return Promise.reject(new Error(`Nexus Mods Search API responded with ${(err as any).statusCode} while fetching results. Please try again later.`)); 54 | } 55 | } 56 | 57 | export async function updatedMods(headers: Record, logger: Logger, gameDomain: string, period: string = '1w', ) { 58 | return v1APIQuery(logger, `/v1/games/${gameDomain}/mods/updated.json`, headers, { period }); 59 | } 60 | 61 | export async function modInfo(headers: Record, logger: Logger, gameDomain: string, modId: number): Promise { 62 | return v1APIQuery(logger, `/v1/games/${gameDomain}/mods/${modId}.json`, headers); 63 | } 64 | 65 | export async function modFiles(headers: Record, logger: Logger, gameDomain: string, modId: number): Promise { 66 | return v1APIQuery(logger, `/v1/games/${gameDomain}/mods/${modId}/files.json`, headers); 67 | } 68 | 69 | export async function modChangelogs(headers: Record, logger: Logger, gameDomain: string, modId: number): Promise { 70 | return v1APIQuery(logger, `/v1/games/${gameDomain}/mods/${modId}/changelogs.json`, headers); 71 | } 72 | 73 | export async function games(headers: Record, logger: Logger,): Promise { 74 | return v1APIQuery(logger, `/v1/games.json`, headers); 75 | } 76 | 77 | export async function game(headers: Record, logger: Logger, domain: string): Promise { 78 | return v1APIQuery(logger, `/v1/games/${domain}.json`, headers); 79 | } 80 | 81 | export async function validate(headers: Record, logger: Logger,): Promise { 82 | return v1APIQuery(logger, '/v1/users/validate.json', headers); 83 | } 84 | -------------------------------------------------------------------------------- /src/api/queries/v2-collection.ts: -------------------------------------------------------------------------------- 1 | import { request, gql } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, ICollection, NexusGQLError } from './v2'; 4 | 5 | interface IResult { 6 | collection: ICollection; 7 | } 8 | 9 | const query = gql` 10 | query DiscordBotGetCollectionData($slug: String, $adult: Boolean, $domain: String) { 11 | collection(slug: $slug, viewAdultContent: $adult, domainName: $domain) { 12 | id 13 | slug 14 | name 15 | summary 16 | category { 17 | name 18 | } 19 | adultContent 20 | collectionStatus 21 | overallRating 22 | overallRatingCount 23 | endorsements 24 | totalDownloads 25 | draftRevisionNumber 26 | lastPublishedAt 27 | latestPublishedRevision { 28 | revisionNumber 29 | fileSize 30 | modCount 31 | adultContent 32 | updatedAt 33 | } 34 | game { 35 | id 36 | domainName 37 | name 38 | } 39 | user { 40 | memberId 41 | avatar 42 | name 43 | } 44 | tileImage { 45 | url 46 | altText 47 | thumbnailUrl(size: small) 48 | } 49 | } 50 | } 51 | `; 52 | 53 | export async function collection(headers: Record, logger: Logger, slug: string, domain: string, adult: boolean): Promise { 54 | const vars = { slug, adult, domain }; 55 | 56 | try { 57 | const result: IResult = await request(v2API, query, vars, headers); 58 | return result.collection; 59 | } 60 | catch(err) { 61 | const error = new NexusGQLError(err as any, 'collection'); 62 | logger.error('Error in collection v2 request', error, true); 63 | return undefined; 64 | } 65 | } -------------------------------------------------------------------------------- /src/api/queries/v2-collectionRevisions.ts: -------------------------------------------------------------------------------- 1 | import { request, gql } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, ICollection, NexusGQLError, ICollectionRevision } from './v2'; 4 | 5 | interface IResult { 6 | collection: ICollection & { revisions: ICollectionRevision[] }; 7 | } 8 | 9 | const query = gql` 10 | query DiscordBotGetCollectionRevisionData($slug: String, $domain: String) { 11 | collection(slug: $slug, viewAdultContent: true, domainName: $domain) { 12 | id 13 | slug 14 | name 15 | revisions { 16 | revisionNumber 17 | fileSize 18 | modCount 19 | adultContent 20 | updatedAt 21 | collectionChangelog { 22 | description 23 | } 24 | status 25 | } 26 | } 27 | } 28 | `; 29 | 30 | export async function collectionRevisions(headers: Record, logger: Logger, slug: string, domain: string): Promise { 31 | const vars = { slug, domain }; 32 | 33 | try { 34 | const result: IResult = await request(v2API, query, vars, headers); 35 | return result.collection; 36 | } 37 | catch(err) { 38 | const error = new NexusGQLError(err as any, 'collectionRevisions'); 39 | logger.error('Error in collectionRevisions v2 request', error, true); 40 | return undefined; 41 | } 42 | } -------------------------------------------------------------------------------- /src/api/queries/v2-collections.ts: -------------------------------------------------------------------------------- 1 | import { request, gql, Variables } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, ICollectionSearchResult, NexusGQLError } from './v2'; 4 | import * as GQLTypes from '../../types/GQLTypes'; 5 | 6 | interface IResult { 7 | collectionsV2: ICollectionSearchResult; 8 | } 9 | 10 | interface IQueryVariables extends Variables { 11 | filters: GQLTypes.ICollectionsFilter; 12 | count: number; 13 | sort: GQLTypes.CollectionsSort 14 | } 15 | 16 | const query = gql` 17 | query DiscordBotSearchCollections( 18 | $filters: CollectionsSearchFilter, 19 | $count: Int, 20 | $sort: [CollectionsSearchSort!] 21 | ) { 22 | collectionsV2(filter: $filters, count: $count, sort: $sort) { 23 | nodes { 24 | id 25 | slug 26 | name 27 | summary 28 | category { 29 | name 30 | } 31 | overallRating 32 | overallRatingCount 33 | endorsements 34 | totalDownloads 35 | firstPublishedAt 36 | updatedAt 37 | draftRevisionNumber 38 | latestPublishedRevision { 39 | adultContent 40 | fileSize 41 | modCount 42 | revisionNumber 43 | updatedAt 44 | } 45 | game { 46 | id 47 | domainName 48 | name 49 | } 50 | user { 51 | memberId 52 | avatar 53 | name 54 | } 55 | tileImage { 56 | url 57 | altText 58 | thumbnailUrl(size: small) 59 | } 60 | } 61 | nodesCount 62 | nodesFilter 63 | } 64 | } 65 | `; 66 | 67 | export async function collections(headers: Record, logger: Logger, filters: GQLTypes.ICollectionsFilter, sort: GQLTypes.CollectionsSort = {endorsements: { direction: 'DESC' }}, adultContent: boolean = true): Promise { 68 | const variables: IQueryVariables = { 69 | filters, 70 | sort, 71 | count: 20 72 | }; 73 | 74 | // Only specify adult content if we explictly don't want to see it. 75 | if (adultContent === false) variables.filters.adultContent = { value: adultContent, op: 'EQUALS' }; 76 | 77 | try { 78 | const result: IResult = await request(v2API, query, variables, headers); 79 | result.collectionsV2.searchURL = websiteLink(variables); 80 | return result.collectionsV2; 81 | } 82 | catch(err) { 83 | const error = new NexusGQLError(err as any, 'collections'); 84 | logger.error('Error in collections v2 request', error, true); 85 | return { nodes: [], nodesCount: 0, nodesFilter: '', searchURL: websiteLink(variables) }; 86 | } 87 | } 88 | 89 | const websiteLink = (variables: IQueryVariables): string => { 90 | const baseURL = 'https://next.nexusmods.com/search-results/collections?'; 91 | const urlParams = new URLSearchParams(); 92 | urlParams.append('adultContent', variables.filters.adultContent?.value === true ? '1' : '0'); 93 | if (variables.filters.generalSearch) urlParams.append('keyword', variables.filters.generalSearch.value); 94 | if (variables.filters.gameName) urlParams.append('gameName', variables.filters.gameName.value); 95 | return `${baseURL}${urlParams.toString()}`; 96 | } -------------------------------------------------------------------------------- /src/api/queries/v2-collectionsdownloadtotals.ts: -------------------------------------------------------------------------------- 1 | import { request, gql, Variables } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, NexusGQLError } from './v2'; 4 | import * as GQLTypes from '../../types/GQLTypes'; 5 | 6 | interface IResult { 7 | collectionsV2: { 8 | nodes: IDownloadStats[]; 9 | nodesCount: number; 10 | }; 11 | } 12 | 13 | interface IDownloadStats { 14 | slug: string; 15 | name: string; 16 | totalDownloads: number; 17 | uniqueDownloads: number; 18 | game: { 19 | domainName: number; 20 | } 21 | }; 22 | 23 | interface IQueryVariables extends Variables { 24 | filters: GQLTypes.ICollectionsFilter; 25 | offset: number; 26 | sort: GQLTypes.CollectionsSort 27 | } 28 | 29 | interface ITotals { 30 | totalDownloads: number; 31 | uniqueDownloads: number; 32 | } 33 | 34 | const query = gql` 35 | query DiscordBotGetTotalDownloadsForCollections( 36 | $filters: CollectionsSearchFilter, 37 | $offset: Int!, 38 | $sort: [CollectionsSearchSort!] 39 | ) { 40 | collectionsV2( 41 | filter: $filters, 42 | count: 20, 43 | offset: $offset, 44 | sort: $sort 45 | ) { 46 | nodes { 47 | slug 48 | name 49 | totalDownloads 50 | uniqueDownloads 51 | game { 52 | domainName 53 | name 54 | } 55 | } 56 | nodesCount 57 | nodesFilter 58 | } 59 | } 60 | `; 61 | 62 | export async function collectionsDownloadTotals(headers: Record, logger: Logger, id: number): Promise { 63 | const variables: IQueryVariables = { 64 | filters : { 65 | userId: { value: id.toString(), op: 'EQUALS' } 66 | }, 67 | sort: { 68 | downloads: { direction: 'DESC' } 69 | }, 70 | offset: 0, 71 | }; 72 | 73 | try { 74 | let totalRequested = 20; 75 | const result: IResult = await request(v2API, query, variables, headers); 76 | let stats = result.collectionsV2.nodes; 77 | const total = result.collectionsV2.nodesCount; 78 | while (total > stats.length) { 79 | // Fetch additional pages 80 | logger.info('Fetching additional collections page', { id, total, totalRequested }); 81 | variables.offset += 20; 82 | totalRequested += 20; 83 | const extraPage: IResult = await request(v2API, query, variables, headers); 84 | const extraItems = extraPage.collectionsV2.nodes; 85 | stats = [...stats, ...extraItems]; 86 | } 87 | // Consolidate the stats. 88 | const totals = stats.reduce((prev, cur) => { 89 | prev = { totalDownloads: prev.totalDownloads + cur.totalDownloads, uniqueDownloads: prev.uniqueDownloads + cur.uniqueDownloads }; 90 | return prev; 91 | }, { totalDownloads: 0, uniqueDownloads: 0 }); 92 | return totals; 93 | } 94 | catch(err) { 95 | const error = new NexusGQLError(err as any, 'collectionsDownloadTotals'); 96 | logger.error('Error in collectionsDownloadTotals v2 request', error, true); 97 | return { totalDownloads: 0, uniqueDownloads: 0 }; 98 | } 99 | } -------------------------------------------------------------------------------- /src/api/queries/v2-finduser.ts: -------------------------------------------------------------------------------- 1 | import { request, gql } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { NexusGQLError, v2API } from './v2'; 4 | 5 | interface IResult { 6 | user?: IUser; 7 | userByName?: IUser; 8 | } 9 | 10 | export interface IUser { 11 | name: string; 12 | memberId: number; 13 | avatar: string; 14 | recognizedAuthor: boolean; 15 | uniqueModDownloads: number; 16 | banned: boolean; 17 | deleted: boolean; 18 | } 19 | 20 | const idQuery = gql` 21 | query DiscordBotUserById($id: Int!) { 22 | user(id: $id) { 23 | name 24 | memberId 25 | avatar 26 | recognizedAuthor 27 | uniqueModDownloads 28 | banned 29 | deleted 30 | } 31 | } 32 | `; 33 | 34 | const nameQuery = gql` 35 | query UserByName($username: String!) { 36 | userByName(name: $username) { 37 | name 38 | memberId 39 | avatar 40 | recognizedAuthor 41 | uniqueModDownloads 42 | banned 43 | deleted 44 | } 45 | } 46 | `; 47 | 48 | export async function findUser(headers: Record, logger: Logger, idOrName: number | string): Promise { 49 | let vars: Record; 50 | let query: string = ``; 51 | 52 | if (typeof(idOrName) === 'number') { 53 | query = idQuery; 54 | vars = { id: idOrName }; 55 | 56 | } 57 | else if (typeof(idOrName) === 'string') { 58 | query = nameQuery; 59 | vars = { username: idOrName }; 60 | } 61 | else throw new Error('Invalid username or ID'); 62 | 63 | try { 64 | const result: IResult = await request(v2API, query, vars, headers); 65 | return result.user || result.userByName; 66 | } 67 | catch(err) { 68 | const error = new NexusGQLError(err as any, 'findUser'); 69 | logger.error('Error in findUser v2 request', error, true); 70 | return undefined; 71 | } 72 | } -------------------------------------------------------------------------------- /src/api/queries/v2-game.ts: -------------------------------------------------------------------------------- 1 | import { request, gql } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { NexusGQLError, v2API } from './v2'; 4 | 5 | interface IResult { 6 | game: IGame; 7 | } 8 | 9 | interface IGame { 10 | id: number; 11 | name: string; 12 | approvedAt: Date | null; 13 | domainName: string; 14 | collectionCount: number | null; 15 | } 16 | 17 | const query = gql` 18 | query DiscordBotGame { 19 | game { 20 | id 21 | name 22 | approvedAt 23 | domainName 24 | collectionCount 25 | } 26 | } 27 | `; 28 | 29 | export async function game(headers: Record, logger: Logger, id: number): Promise { 30 | // Games can't be queried by domain name at the moment. 31 | try { 32 | const result: IResult = await request(v2API, query, { id }, headers); 33 | return result.game; 34 | } 35 | catch(err) { 36 | const error = new NexusGQLError(err as any, 'games'); 37 | logger.error('Error in games v2 request', error, true); 38 | return undefined; 39 | } 40 | } -------------------------------------------------------------------------------- /src/api/queries/v2-ismodauthor.ts: -------------------------------------------------------------------------------- 1 | import { request, gql } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { NexusGQLError, v2API } from './v2'; 4 | 5 | export interface IResult { 6 | user: { 7 | name: string; 8 | recognizedAuthor: boolean; 9 | } 10 | } 11 | 12 | const query = gql` 13 | query DiscordBotGetModAuthorStatus($id: Int!) { 14 | user(id: $id) { 15 | name 16 | recognizedAuthor 17 | } 18 | } 19 | `; 20 | 21 | export async function isModAuthor(headers: Record, logger: Logger, id: number): Promise { 22 | const vars = { id }; 23 | 24 | try { 25 | const result: IResult = await request(v2API, query, vars, headers); 26 | return result.user?.recognizedAuthor; 27 | } 28 | catch(err) { 29 | const error = new NexusGQLError(err as any, 'isModAuthor'); 30 | logger.error('Error in isModAuthor v2 request', error, true); 31 | return false; 32 | } 33 | } -------------------------------------------------------------------------------- /src/api/queries/v2-latestmods.ts: -------------------------------------------------------------------------------- 1 | import { request, gql } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, IMod, NexusGQLError, IModsFilter, IModsSort } from './v2'; 4 | 5 | interface IResult { 6 | mods: IModResults; 7 | } 8 | 9 | export interface IModResults { 10 | nodes: Partial[]; 11 | totalCount: number; 12 | } 13 | 14 | const query = gql` 15 | query DiscordBotLatestMods($filter: ModsFilter, $sort: [ModsSort!]) { 16 | mods( 17 | filter: $filter, 18 | sort: $sort 19 | ) { 20 | nodes { 21 | uid 22 | name 23 | summary 24 | game { 25 | domainName 26 | name 27 | id 28 | } 29 | modId 30 | createdAt 31 | updatedAt 32 | description 33 | uploader { 34 | name 35 | memberId 36 | joined 37 | membershipRoles 38 | modCount 39 | } 40 | pictureUrl 41 | } 42 | totalCount 43 | } 44 | } 45 | `; 46 | 47 | export async function latestMods(headers: Record, logger: Logger, startDate: Date, gameIds?: number | number[], sort: IModsSort = { createdAt: { direction: 'DESC' }}): Promise { 48 | 49 | if (typeof startDate === 'string') { 50 | startDate = new Date(startDate) 51 | } 52 | 53 | // The API has a page size limit of 50 (default 20) so we need to break our request into pages. 54 | const filter: IModsFilter = { 55 | createdAt: { 56 | value: Math.floor(startDate.getTime() / 1000).toString(), 57 | op: 'GT' 58 | } 59 | }; 60 | 61 | if (!!gameIds && typeof gameIds === "number") filter.gameId = [{ value: gameIds.toString(), op: 'EQUALS' }]; 62 | else if (!!gameIds && Array.isArray(gameIds)) { 63 | filter.filter = [{ gameId: gameIds.map(id => ({ value: id.toString(), op: 'EQUALS' })), op: 'OR' }]; 64 | } 65 | 66 | const vars = { 67 | filter, 68 | sort, 69 | count: 50 70 | } 71 | 72 | try { 73 | const result: IResult = await request(v2API, query, vars, headers); 74 | // console.log(result.mods, filter) 75 | return result.mods; 76 | } 77 | catch(err) { 78 | const error = new NexusGQLError(err as any, 'mods'); 79 | logger.error('Error in latestmods v2 request', error, true); 80 | return { nodes: [], totalCount: 0 }; 81 | } 82 | } -------------------------------------------------------------------------------- /src/api/queries/v2-mods.ts: -------------------------------------------------------------------------------- 1 | import { request, gql } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, IMod, NexusGQLError, IModsFilter, IModsSort } from './v2'; 4 | 5 | interface IResult { 6 | mods: IModResults; 7 | } 8 | 9 | export interface IModResults { 10 | nodes: IMod[]; 11 | totalCount: number; 12 | // For backwards compatibility 13 | fullSearchUrl?: string; 14 | } 15 | 16 | const query = gql` 17 | query DiscordBotMods($filter: ModsFilter, $sort: [ModsSort!]) { 18 | mods( 19 | filter: $filter, 20 | sort: $sort 21 | ) { 22 | nodes { 23 | uid 24 | modId 25 | name 26 | createdAt 27 | updatedAt 28 | summary 29 | status 30 | author 31 | uploader { 32 | name 33 | avatar 34 | memberId 35 | } 36 | pictureUrl 37 | modCategory { 38 | name 39 | } 40 | adult 41 | version 42 | downloads 43 | game { 44 | domainName 45 | name 46 | id 47 | } 48 | } 49 | totalCount 50 | } 51 | } 52 | `; 53 | 54 | export async function mods(headers: Record, logger: Logger, filter: IModsFilter, sort: IModsSort = { endorsements: { direction: 'DESC' }}): Promise { 55 | 56 | const vars = { 57 | filter, 58 | sort, 59 | count: 10 60 | } 61 | 62 | try { 63 | const result: IResult = await request(v2API, query, vars, headers); 64 | return result.mods; 65 | } 66 | catch(err) { 67 | const error = new NexusGQLError(err as any, 'mods'); 68 | if (error.errors) logger.error('Error in mods v2 request', {error, headers}); 69 | else logger.warn('Server error in mods v2 request', {error, headers}); 70 | return { nodes: [], totalCount: 0 }; 71 | } 72 | } -------------------------------------------------------------------------------- /src/api/queries/v2-modsFiles.ts: -------------------------------------------------------------------------------- 1 | import { request, gql, Variables } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, NexusGQLError, IModFile } from './v2'; 4 | 5 | interface IResult { 6 | modFiles: IModFile[]; 7 | } 8 | 9 | interface IVariables extends Variables { 10 | modId: number; 11 | gameId: number; 12 | } 13 | 14 | const query = gql` 15 | query DiscordBotModFiles($modId: ID!, $gameId: ID!) { 16 | modFiles(modId: $modId, gameId: $gameId) { 17 | uid 18 | uri 19 | fileId 20 | name 21 | version 22 | category 23 | changelogText 24 | date 25 | } 26 | } 27 | `; 28 | 29 | export async function modFiles(headers: Record, logger: Logger, gameId: number, modId: number): Promise { 30 | 31 | const vars: IVariables = { 32 | gameId, 33 | modId 34 | } 35 | 36 | try { 37 | const result: IResult = await request(v2API, query, vars, headers); 38 | return result.modFiles.sort((a,b) => b.date - a.date); 39 | } 40 | catch(err) { 41 | const error = new NexusGQLError(err as any, 'modFiles'); 42 | logger.error('Error in modFiles v2 request', error, true); 43 | return []; 44 | } 45 | } -------------------------------------------------------------------------------- /src/api/queries/v2-modsbymodid.ts: -------------------------------------------------------------------------------- 1 | import { request, gql, ClientError } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, IMod, NexusGQLError } from './v2'; 4 | 5 | interface IResult { 6 | legacyModsByDomain: { 7 | nodes: IMod[]; 8 | }; 9 | } 10 | 11 | interface IModRequest { 12 | gameDomain: string; 13 | modId: number; 14 | } 15 | 16 | const query = gql` 17 | query DiscordBotModsByDomain($mods: [CompositeDomainWithIdInput!]!, $count: Int!, $offset: Int!) { 18 | legacyModsByDomain(ids: $mods, count: $count, offset: $offset) { 19 | nodes { 20 | uid 21 | modId 22 | name 23 | createdAt 24 | updatedAt 25 | summary 26 | description 27 | status 28 | author 29 | uploader { 30 | name 31 | avatar 32 | memberId 33 | } 34 | pictureUrl 35 | modCategory { 36 | name 37 | } 38 | adult 39 | version 40 | game { 41 | id 42 | domainName 43 | name 44 | } 45 | } 46 | } 47 | } 48 | `; 49 | 50 | export async function mods(headers: Record, logger: Logger, mods: IModRequest | IModRequest[]): Promise { 51 | // The API has a page size limit of 50 (default 20) so we need to break our request into pages. 52 | const ids: IModRequest[] = (!Array.isArray(mods)) ? [mods] : mods; 53 | if (!ids.length) return []; 54 | 55 | const pages: IModRequest[][] = []; 56 | let length = 0; 57 | while (length < (ids.length)) { 58 | pages.push(ids.slice(length, 50)); 59 | length += 50; 60 | } 61 | 62 | let results: any[] = []; 63 | 64 | for (const page of pages) { 65 | try { 66 | const pageData = await modsQuery(headers, logger, page); 67 | if (pageData.length != page.length) logger.warn('Did not get back the same number of mods as sent', { sent: page.length, got: pageData.length }, true); 68 | results = [...results, ...pageData]; 69 | } 70 | catch(err) { 71 | const error = new NexusGQLError(err as any, 'mods'); 72 | logger.error('Error fetching mod data', { error, auth: 'OAUTH' }, true); 73 | } 74 | } 75 | 76 | return results; 77 | } 78 | 79 | async function modsQuery(headers: Record, logger: Logger, mods: IModRequest[], offset: Number = 0, count: Number = 50): Promise { 80 | if (!mods.length) return []; 81 | 82 | try { 83 | const result: IResult = await request(v2API, query, { mods, offset, count }, headers); 84 | return result.legacyModsByDomain.nodes; 85 | } 86 | catch(err) { 87 | if (err as ClientError) { 88 | const error: ClientError = (err as ClientError); 89 | // console.log('ClientError', error); 90 | if (error.message.includes('Cannot return null for non-nullable field Mod.modCategory')) { 91 | const gameIds = new Set(mods.map(i => i.gameDomain)); 92 | const consolidatedIds = [...gameIds].map(game => { 93 | const gameMods = mods.filter(m => m.gameDomain === game).map(mod => mod.modId); 94 | return `${game}: ${gameMods.join(', ')}`; 95 | }); 96 | throw new Error('One or more mods are missing the category attribute.'+consolidatedIds.join('\n')); 97 | } 98 | else throw new Error('GraphQLError '+error); 99 | } 100 | logger.error('Unkown Mod Lookup Error!', err); 101 | throw new Error('Could not find some or all of the mods.'); 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /src/api/queries/v2-modsbyuid.ts: -------------------------------------------------------------------------------- 1 | import { request, gql } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, IMod, NexusGQLError } from './v2'; 4 | 5 | interface IResult { 6 | modsByUid: IModResults; 7 | } 8 | 9 | interface IModResults { 10 | nodes: IMod[]; 11 | totalCount: number; 12 | } 13 | 14 | const query = gql` 15 | query DiscordBotModsByUid($uids: [ID!]!) { 16 | modsByUid(uids: $uids) 17 | { 18 | nodes { 19 | uid 20 | modId 21 | name 22 | createdAt 23 | updatedAt 24 | summary 25 | status 26 | author 27 | uploader { 28 | name 29 | avatar 30 | memberId 31 | } 32 | pictureUrl 33 | # modCategory { 34 | # name 35 | # } 36 | adult 37 | version 38 | downloads 39 | game { 40 | domainName 41 | name 42 | id 43 | } 44 | } 45 | totalCount 46 | } 47 | } 48 | `; 49 | 50 | type IModWithoutCategory = Omit; 51 | 52 | export async function modsByUid(headers: Record, logger: Logger, uids: string[]): Promise { 53 | 54 | const vars = { 55 | uids 56 | } 57 | 58 | try { 59 | const result: IResult = await request(v2API, query, vars, headers); 60 | return result.modsByUid.nodes; 61 | } 62 | catch(err) { 63 | const error = new NexusGQLError(err as any, 'modsByUid'); 64 | logger.error('Error in modsbyuid v2 request', error, true); 65 | return []; 66 | } 67 | } -------------------------------------------------------------------------------- /src/api/queries/v2-mycollections.ts: -------------------------------------------------------------------------------- 1 | import { request, gql } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, ICollection, NexusGQLError } from './v2'; 4 | 5 | interface IResult { 6 | myCollections: { 7 | nodes: ICollection[]; 8 | nodesCount: number; 9 | }; 10 | 11 | } 12 | 13 | const query = gql` 14 | query DiscordBotMyCollections { 15 | myCollections( 16 | viewAdultContent: true, 17 | viewUnderModeration: true, 18 | viewUnlisted: true 19 | ) { 20 | nodesCount 21 | nodes { 22 | id 23 | slug 24 | name 25 | summary 26 | category { 27 | name 28 | } 29 | adultContent 30 | overallRating 31 | overallRatingCount 32 | endorsements 33 | totalDownloads 34 | draftRevisionNumber 35 | latestPublishedRevision { 36 | fileSize 37 | modCount 38 | } 39 | game { 40 | id 41 | domainName 42 | name 43 | } 44 | user { 45 | memberId 46 | avatar 47 | name 48 | } 49 | tileImage { 50 | url 51 | altText 52 | thumbnailUrl(size: small) 53 | } 54 | } 55 | } 56 | } 57 | `; 58 | 59 | export async function myCollections(headers: Record, logger: Logger,): Promise { 60 | try { 61 | const result: IResult = await request(v2API, query, {}, headers); 62 | return result.myCollections.nodes; 63 | } 64 | catch(err) { 65 | const error = new NexusGQLError(err as any, 'mycollections'); 66 | logger.error('Error in mycollections v2 request', error); 67 | return []; 68 | } 69 | } -------------------------------------------------------------------------------- /src/api/queries/v2-news.ts: -------------------------------------------------------------------------------- 1 | import { request, gql, Variables } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, NexusGQLError } from './v2'; 4 | import { INews, News } from "../../types/feeds"; 5 | 6 | interface IResult { 7 | news: INewResults; 8 | } 9 | 10 | interface INewsVariables extends Variables { 11 | gameId?: number; 12 | } 13 | 14 | interface INewResults { 15 | nodes: INews[]; 16 | } 17 | 18 | const query = gql` 19 | query DiscordBotNews($gameId: Int) { 20 | news(gameId: $gameId) { 21 | nodes { 22 | id 23 | title 24 | newsCategory { 25 | name 26 | } 27 | summary 28 | date 29 | author { 30 | name 31 | avatar 32 | } 33 | header 34 | image 35 | } 36 | 37 | } 38 | } 39 | `; 40 | 41 | export async function news(headers: Record, logger: Logger, gameId?: number): Promise { 42 | 43 | let vars: INewsVariables = {} 44 | 45 | if (gameId) vars.gameId = gameId; 46 | 47 | 48 | try { 49 | const result: IResult = await request(v2API, query, vars, headers); 50 | // Adult content filter is not available on the API yet, so we'll have to do it manually. 51 | const news =result.news.nodes.map(n => new News(n)); 52 | return news; 53 | } 54 | catch(err) { 55 | const error = new NexusGQLError(err as any, 'news'); 56 | logger.error('Error in news v2 request', error, true); 57 | return []; 58 | } 59 | } -------------------------------------------------------------------------------- /src/api/queries/v2-updatedMods.ts: -------------------------------------------------------------------------------- 1 | import { request, gql } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, IMod, NexusGQLError, IModsFilter, IModsSort } from './v2'; 4 | 5 | interface IResult { 6 | mods: IUpdatedModResults; 7 | } 8 | 9 | interface IUpdatedModResults { 10 | nodes: IMod[]; 11 | totalCount: number; 12 | // pageInfo?: { 13 | // hasNextPage: boolean; 14 | // hasPreviousPage: boolean; 15 | // startCursor: string; 16 | // endCursor: string; 17 | // } 18 | } 19 | 20 | const query = gql` 21 | query DiscordBotGetUpdatedMods($count: Int!, $filter: ModsFilter, $sort: [ModsSort!]) { 22 | mods( 23 | filter: $filter 24 | count: $count 25 | sort: $sort 26 | ) { 27 | totalCount 28 | nodes { 29 | uid 30 | name 31 | modId 32 | createdAt 33 | updatedAt 34 | summary 35 | description 36 | status 37 | author 38 | uploader { 39 | name 40 | avatar 41 | memberId 42 | joined 43 | membershipRoles 44 | modCount 45 | } 46 | pictureUrl 47 | modCategory { 48 | name 49 | } 50 | adult 51 | version 52 | game { 53 | id 54 | domainName 55 | name 56 | } 57 | } 58 | } 59 | } 60 | `; 61 | 62 | export async function updatedMods( 63 | headers: Record, 64 | logger: Logger, 65 | newSince: Date | number | string, 66 | includeAdult: boolean, 67 | gameIds?: number | number[], 68 | sort: IModsSort = { updatedAt: { direction: 'ASC' }} 69 | ): Promise { 70 | 71 | const sinceDate: number = Math.floor(new Date(newSince).getTime() / 1000) 72 | // The API has a page size limit of 50 (default 20) so we need to break our request into pages. 73 | const filter: IModsFilter = { 74 | hasUpdated: { 75 | value: true, 76 | op: 'EQUALS' 77 | }, 78 | updatedAt: { 79 | value: `${sinceDate}`, 80 | op: 'GT' 81 | } 82 | }; 83 | 84 | if (!!gameIds && typeof gameIds === "number") filter.gameId = [{ value: gameIds.toString(), op: 'EQUALS' }]; 85 | else if (!!gameIds && Array.isArray(gameIds)) { 86 | filter.filter = [{ gameId: gameIds.map(id => ({ value: id.toString(), op: 'EQUALS' })), op: 'OR' }]; 87 | } 88 | 89 | const vars = { 90 | filter, 91 | sort, 92 | count: 10 93 | } 94 | 95 | try { 96 | const result: IResult = await request(v2API, query, vars, headers); 97 | // Adult content filter is not available on the API yet, so we'll have to do it manually. 98 | if (!includeAdult) result.mods.nodes = result.mods.nodes.filter(m => m.adult === false); 99 | return result.mods; 100 | } 101 | catch(err) { 102 | const error = new NexusGQLError(err as any, 'updated mods'); 103 | logger.error('Error in updated mods v2 request', error); 104 | return { nodes: [], totalCount: 0 }; 105 | } 106 | } -------------------------------------------------------------------------------- /src/api/queries/v2-users.ts: -------------------------------------------------------------------------------- 1 | import { request, gql, Variables } from "graphql-request"; 2 | import { Logger } from "../util"; 3 | import { v2API, NexusGQLError } from './v2'; 4 | import { BaseFilterValue, BaseSortValue, FilterLogicalOperator } from "../../types/GQLTypes"; 5 | 6 | interface IResult { 7 | users: IUserResults; 8 | } 9 | 10 | interface IUserResults { 11 | nodes: IUser[]; 12 | totalCount: number; 13 | } 14 | 15 | interface IVariables extends Variables { 16 | filter: IUserSearchFilter; 17 | sort?: IUserSearchSort[]; 18 | offset?: number; 19 | count?: number; 20 | } 21 | 22 | interface IUserSearchFilter { 23 | filter?: IUserSearchFilter[]; 24 | op?: FilterLogicalOperator; 25 | nameExact?: BaseFilterValue; 26 | nameWildcard?: BaseFilterValue | BaseFilterValue[]; 27 | } 28 | 29 | interface IUserSearchSort { 30 | relevance?: BaseSortValue; 31 | name?: BaseSortValue; 32 | } 33 | 34 | interface IUser { 35 | avatar: string; 36 | name: string; 37 | memberId: number; 38 | } 39 | 40 | const query = gql` 41 | query DiscordBotUserSearch($filter: UsersSearchFilter, $sort: [UsersSearchSort!]) { 42 | users(filter: $filter, sort: $sort) 43 | { 44 | nodes { 45 | name 46 | memberId 47 | avatar 48 | } 49 | totalCount 50 | } 51 | } 52 | `; 53 | 54 | export async function users(headers: Record, logger: Logger, name: string): Promise { 55 | 56 | const vars: IVariables = { 57 | filter : { 58 | filter: [ 59 | { nameWildcard: { op: 'WILDCARD', value: name } }, 60 | { nameExact: { op: 'EQUALS', value: name } } 61 | ], 62 | op: 'OR' 63 | }, 64 | sort: [ 65 | { relevance: { direction: 'DESC' } }, 66 | { name: { direction: 'ASC' } } 67 | ] 68 | } 69 | 70 | try { 71 | const result: IResult = await request(v2API, query, vars, headers); 72 | return result.users.nodes; 73 | } 74 | catch(err) { 75 | const error = new NexusGQLError(err as any, 'users'); 76 | logger.error('Error in users v2 request', error, true); 77 | return []; 78 | } 79 | } -------------------------------------------------------------------------------- /src/api/queries/v2.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember } from "discord.js"; 2 | import { ClientError } from "graphql-request"; 3 | import * as GQLTypes from '../../types/GQLTypes'; 4 | 5 | 6 | export const v2API: string = 'https://api.nexusmods.com/v2/graphql'; 7 | 8 | export enum CollectionStatus { 9 | Listed = 'listed', 10 | Unlisted = 'unlisted', 11 | Moderated = 'under_moderation', 12 | Discarded = 'discarded' 13 | } 14 | 15 | export interface ICollection { 16 | id: number; 17 | slug: string; 18 | name: string; 19 | summary: string; 20 | collectionStatus: CollectionStatus 21 | category: { 22 | name: string; 23 | }; 24 | overallRating: number; 25 | overallRatingCount: number; 26 | endorsements: number; 27 | totalDownloads: number; 28 | draftRevisionNumber: number; 29 | lastPublishedAt: string; 30 | firstPublishedAt: string; 31 | latestPublishedRevision: { 32 | fileSize: number; 33 | modCount: number; 34 | revisionNumber: number; 35 | adultContent: boolean; 36 | updatedAt: string; 37 | } 38 | game: { 39 | id: number; 40 | domainName: string; 41 | name: string; 42 | } 43 | user: { 44 | memberId: number; 45 | avatar: string; 46 | name: string; 47 | } 48 | tileImage: { 49 | url: string; 50 | altText: string; 51 | thumbnailUrl: string; 52 | } 53 | updatedAt: string; 54 | } 55 | 56 | export interface ICollectionRevision { 57 | revisionNumber: number; 58 | fileSize: number; 59 | modCount: number; 60 | adultContent: boolean; 61 | updatedAt: string; 62 | collectionChangelog: ICollectionChangelog; 63 | status: 'draft' | 'published' | 'retracted'; 64 | } 65 | 66 | interface ICollectionChangelog { 67 | description: string; 68 | } 69 | 70 | export interface ICollectionSearchResult { 71 | nodes: ICollection[]; 72 | nodesFilter: string; 73 | nodesCount: number; 74 | searchURL?: string; 75 | } 76 | 77 | export interface IMod { 78 | uid: string; 79 | modId: number; 80 | name: string; 81 | createdAt: string; 82 | updatedAt: Date; 83 | summary: string; 84 | description: string; 85 | status: GQLTypes.ModStatus; 86 | downloads: number; 87 | author: string; 88 | uploader: { 89 | name: string; 90 | avatar: string; 91 | memberId: number; 92 | joined: string; 93 | membershipRoles: string[]; 94 | modCount: number; 95 | } 96 | pictureUrl: string; 97 | modCategory: { 98 | name: string 99 | }; 100 | adult: boolean; 101 | version: string; 102 | game: { 103 | id: number; 104 | domainName: string; 105 | name: string; 106 | } 107 | // Added by feed manager 108 | lastFileUpdate?: number; 109 | authorDiscord?: GuildMember | null; 110 | } 111 | 112 | export interface IModsSort { 113 | relevance?: GQLTypes.BaseSortValue 114 | name?: GQLTypes.BaseSortValue 115 | downloads?: GQLTypes.BaseSortValue 116 | endorsements?: GQLTypes.BaseSortValue 117 | random?: { seed: number } 118 | createdAt?: GQLTypes.BaseSortValue 119 | updatedAt?: GQLTypes.BaseSortValue 120 | } 121 | 122 | export interface IModsFilter { 123 | filter?: IModsFilter[]; 124 | op?: GQLTypes.FilterLogicalOperator; 125 | name?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 126 | nameStemmed?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 127 | gameId?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; //This is the numerical ID for a game, not the domain. 128 | gameDomainName?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 129 | createdAt?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 130 | updatedAt?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 131 | hasUpdated?: GQLTypes.BooleanFilterValue | GQLTypes.BooleanFilterValue[]; 132 | uploaderId?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 133 | adultContent?: GQLTypes.BooleanFilterValue | GQLTypes.BooleanFilterValue[]; 134 | fileSize?: GQLTypes.IntFilterValue | GQLTypes.IntFilterValue[]; 135 | downloads?: GQLTypes.IntFilterValue | GQLTypes.IntFilterValue[]; 136 | endorsements?: GQLTypes.IntFilterValue | GQLTypes.IntFilterValue[]; 137 | tag?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 138 | description?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 139 | author?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 140 | uploader?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 141 | supportsVortex?: GQLTypes.BooleanFilterValue | GQLTypes.BooleanFilterValue[]; 142 | languageName?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 143 | categoryName?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 144 | status?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 145 | gameName?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 146 | primaryImage?: GQLTypes.BaseFilterValue | GQLTypes.BaseFilterValue[]; 147 | } 148 | 149 | export class NexusGQLError extends Error { 150 | public code?: number; 151 | public errors?: string; 152 | public fullResponse?: any; 153 | 154 | constructor(clientError: ClientError, type: string) { 155 | super(); 156 | if (!(clientError instanceof ClientError)) return clientError; 157 | this.code = clientError.response.status; 158 | if (clientError.response.errors?.[0]?.message.startsWith('') ) { 159 | this.message = 'Request blocked by Cloudflare'; 160 | this.name = 'Cloudflare Error'; 161 | } 162 | else { 163 | const query = typeof clientError.request.query === 'string' ? clientError.request.query.replace('\\n', '\n') : clientError.request.query[0].replace('\\n', '\n'); 164 | const variables = clientError.request.variables || {}; 165 | this.errors = clientError.response.errors ? clientError.response.errors.join('\n') : clientError.message; 166 | this.message = `GraphQL ${type} request failed. ${this.code ? `\nStatus: ${this.code}` : null}\nQuery: ${query}\nVariables: ${JSON.stringify(variables)}\nErrors: ${this.errors}`; 167 | this.name = `Request failed ${type}`; 168 | if (this.code === 401) this.fullResponse = clientError.response; 169 | } 170 | } 171 | 172 | } 173 | 174 | export interface IModFile { 175 | uid: string; 176 | uri: string; 177 | category: ModFileCategory; 178 | changelogText: string[]; 179 | date: number; 180 | fileId: number; 181 | name: string; 182 | version: string; 183 | } 184 | 185 | export enum ModFileCategory { 186 | Main = 'MAIN', 187 | Update = 'UPDATE', 188 | Optional = 'OPTIONAL', 189 | Old = 'OLD_VERSION', 190 | Misc = 'MISCELLANEOUS', 191 | Removed = 'REMOVED', 192 | Archived = 'ARCHIVED' 193 | } -------------------------------------------------------------------------------- /src/api/server_role_conditions.ts: -------------------------------------------------------------------------------- 1 | import { queryPromise } from './dbConnect'; 2 | 3 | type ConditionType = 'modDownloads' | 'modsPublished'; 4 | type Operator = 'AND' | 'OR'; 5 | 6 | export interface IConditionForRole { 7 | id: number; 8 | server_id: string; 9 | role_id: string; 10 | type: ConditionType; 11 | game: string; 12 | min: number; 13 | op: Operator; 14 | } 15 | 16 | async function getConditionsForRole(serverId: string, roleId: string): Promise { 17 | try { 18 | const data = await queryPromise( 19 | 'SELECT id, server_id, role_id, type, game, min, op FROM server_role_conditions WHERE server_id=$1 AND role_id=$2 ORDER BY id', 20 | [serverId, roleId] 21 | ); 22 | return data.rows; 23 | } 24 | catch(error) { 25 | throw new Error(`Could not get role conditions from database. ${(error as Error).message}`); 26 | } 27 | } 28 | 29 | async function addConditionForRole(serverId: string, roleId: string, type: ConditionType, game: string, min: number, op: Operator = 'AND'): Promise { 30 | try { 31 | const data = await queryPromise( 32 | 'INSERT INTO server_role_conditions (server_id, role_id, type, game, min, op) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', 33 | [serverId, roleId, type, game, min, op] 34 | ); 35 | return data.rows[0]; 36 | } 37 | catch(error) { 38 | throw new Error(`Could not add role conditions from database. ${(error as Error).message}`); 39 | } 40 | } 41 | 42 | async function changeRoleForConditions(serverId: string, oldRoleId: string, newRoleId: string): Promise { 43 | try { 44 | const data = await queryPromise( 45 | 'UPDATE server_role_conditions SET role_id=$1 WHERE server_id=$2 AND role_id=$3 RETURNING *', 46 | [newRoleId, serverId, oldRoleId] 47 | ); 48 | return data.rows; 49 | } 50 | catch(error) { 51 | throw new Error(`Could not update role conditions in database. ${(error as Error).message}`); 52 | } 53 | } 54 | 55 | async function deleteAllConditionsForRole(serverId: string, roleId: string): Promise { 56 | try { 57 | await queryPromise( 58 | 'DELETE FROM server_role_conditions WHERE server_id=$1 AND role_id=$2', 59 | [serverId, roleId] 60 | ); 61 | } 62 | catch(error) { 63 | throw new Error(`Could not delete role conditions from database. ${(error as Error).message}`); 64 | } 65 | } 66 | 67 | async function deleteConditionForRole(id: number): Promise { 68 | try { 69 | await queryPromise( 70 | 'DELETE FROM server_role_conditions WHERE id=$1', 71 | [id] 72 | ); 73 | } 74 | catch(error) { 75 | throw new Error(`Could not delete role conditions from database. ${(error as Error).message}`); 76 | } 77 | } 78 | 79 | 80 | export { getConditionsForRole, addConditionForRole, changeRoleForConditions, deleteConditionForRole, deleteAllConditionsForRole }; -------------------------------------------------------------------------------- /src/api/servers.ts: -------------------------------------------------------------------------------- 1 | import query from '../api/dbConnect'; 2 | import { BotServer } from '../types/servers'; 3 | import { Guild } from 'discord.js'; 4 | import { logger } from '../DiscordBot'; 5 | 6 | async function getAllServers(): Promise { 7 | try { 8 | const result = await query('SELECT * FROM servers', [], 'GetAllServers'); 9 | return result.rows; 10 | } catch (error) { 11 | return Promise.reject(error); 12 | } 13 | } 14 | 15 | async function getServer(guild: Guild): Promise { 16 | try { 17 | const result = await query('SELECT * FROM servers WHERE id = $1', [guild.id], 'GetServer'); 18 | if (!result?.rows || result.rows.length === 0) { 19 | logger.warn('Server lookup. Guild not found: ', guild.name); 20 | try { 21 | await addServer(guild); 22 | const newResult = await getServer(guild); 23 | return newResult; 24 | } catch (err) { 25 | return Promise.reject('Not found and could not be created.'); 26 | } 27 | } 28 | return result.rows[0]; 29 | } catch (error) { 30 | return Promise.reject(error); 31 | } 32 | } 33 | 34 | async function addServer(guild: Guild): Promise { 35 | const owner = await guild.fetchOwner(); 36 | try { 37 | await query('INSERT INTO servers (id, server_owner) VALUES ($1, $2)', [guild.id, owner?.id], 'AddServer'); 38 | console.log(new Date().toLocaleString() + " - Added server to database: " + guild.name); 39 | return true; 40 | } catch (error) { 41 | return Promise.reject(error); 42 | } 43 | } 44 | 45 | async function updateServer(guildId: string, newData: any): Promise { 46 | try { 47 | let errors = 0; 48 | await Promise.all(Object.keys(newData).map((key) => 49 | query(`UPDATE servers SET ${key} = $1 WHERE id = $2`, [newData[key], guildId], `UpdateServer-${key}`) 50 | .catch(() => errors++) 51 | )); 52 | return errors === 0; 53 | } catch (error) { 54 | return Promise.reject(error); 55 | } 56 | } 57 | 58 | async function deleteServer(guildId: string): Promise { 59 | try { 60 | await query('DELETE FROM servers WHERE id = $1', [guildId], 'DeleteServer'); 61 | return true; 62 | } catch (error) { 63 | return Promise.reject(false); 64 | } 65 | } 66 | 67 | export { getAllServers, getServer, addServer, updateServer, deleteServer }; 68 | -------------------------------------------------------------------------------- /src/api/tips.ts: -------------------------------------------------------------------------------- 1 | import { queryPromise } from './dbConnect'; 2 | 3 | export interface ITip { 4 | id: number; 5 | prompt: string; 6 | title: string; 7 | embed: string | null; 8 | message: string | null; 9 | created: string; 10 | updated: string; 11 | author: string; 12 | approved: boolean; 13 | } 14 | 15 | async function getAllTips(): Promise { 16 | try { 17 | const data = await queryPromise('SELECT * FROM tips ORDER BY title', []); 18 | return data.rows; 19 | } 20 | catch(error) { 21 | throw new Error(`Could not get Tips from database. ${(error as Error).message}`); 22 | } 23 | 24 | } 25 | 26 | async function addTip(prompt: string, author: string, title: string, embed?: string, message?: string): Promise { 27 | try { 28 | const data = await queryPromise( 29 | 'INSERT INTO tips (prompt, title, embed, message, author) VALUES ($1 , $2, $3, $4, $5) RETURNING *', 30 | [prompt, title, embed, message, author] 31 | ); 32 | return data.rows[0]; 33 | } 34 | catch(error) { 35 | throw new Error(`Could not add Tip to database. ${(error as Error).message}`); 36 | } 37 | } 38 | 39 | async function editTip(prompt: string, author: string, title: string, embed?: string, message?: string): Promise { 40 | try { 41 | await queryPromise( 42 | 'UPDATE tips SET title=$1, embed=$2, message=$3, author=$4, updated=DEFAULT WHERE prompt=$5', 43 | [title, embed, message, author, prompt] 44 | ); 45 | return; 46 | } 47 | catch(error) { 48 | throw new Error(`Could not edit Tip in database. ${(error as Error).message}`); 49 | } 50 | } 51 | 52 | async function setApprovedTip(prompt: string, approved: boolean): Promise { 53 | try { 54 | await queryPromise( 55 | 'UPDATE tips SET approved=$1 WHERE prompt=$2', 56 | [prompt, approved] 57 | ); 58 | return; 59 | } 60 | catch(error) { 61 | throw new Error(`Could not approve Tip in database. ${(error as Error).message}`); 62 | } 63 | } 64 | 65 | async function deleteTip(prompt: string): Promise { 66 | try { 67 | await queryPromise( 68 | 'DELETE FROM tips WHERE prompt=$1', 69 | [prompt] 70 | ); 71 | return; 72 | } 73 | catch(error) { 74 | throw new Error(`Could not delete Tip from database: ${prompt}. ${(error as Error).message}`); 75 | } 76 | } 77 | 78 | export { addTip, editTip, setApprovedTip, deleteTip, getAllTips }; -------------------------------------------------------------------------------- /src/api/users.ts: -------------------------------------------------------------------------------- 1 | import query from '../api/dbConnect'; 2 | import { NexusUser } from '../types/users'; 3 | import { Client, EmbedBuilder, User, Snowflake } from 'discord.js'; 4 | import { nexusModsTrackingUrl } from './util'; 5 | import { DiscordBotUser } from './DiscordBotUser'; 6 | import { logger } from '../DiscordBot'; 7 | 8 | async function getAllUsers(): Promise { 9 | try { 10 | const result = await query('SELECT * FROM users', []); 11 | return result.rows; 12 | } 13 | catch (err) { 14 | logger.error('Error getting all users', err); 15 | return []; 16 | } 17 | } 18 | 19 | async function getUserByDiscordId(discordId: Snowflake | string): Promise { 20 | try { 21 | const result = await query('SELECT * FROM users WHERE d_id = $1', [discordId]); 22 | const user: NexusUser = result?.rows[0]; 23 | if (user) { 24 | return new DiscordBotUser(user, logger); 25 | } 26 | return undefined; 27 | } 28 | catch (err) { 29 | logger.error('Error in user lookup by Discord ID', { err, discordId }); 30 | return undefined; 31 | } 32 | } 33 | 34 | async function getUserByNexusModsName(username: string): Promise { 35 | try { 36 | const result = await query('SELECT * FROM users WHERE LOWER(name) = LOWER($1)', [username]); 37 | const user: NexusUser = result?.rows[0]; 38 | if (user) { 39 | return new DiscordBotUser(user, logger); 40 | } 41 | return undefined; 42 | } 43 | catch (err) { 44 | logger.error('Error in user lookup by Nexus Mods username', { err, username }); 45 | return undefined; 46 | } 47 | } 48 | 49 | async function getUserByNexusModsId(id: number): Promise { 50 | try { 51 | const result = await query('SELECT * FROM users WHERE id = $1', [id]); 52 | const user: NexusUser = result?.rows[0]; 53 | if (user) { 54 | return new DiscordBotUser(user, logger); 55 | } 56 | return undefined; 57 | } 58 | catch (err) { 59 | logger.error('Error in user lookup by Nexus Mods ID', { err, id }); 60 | return undefined; 61 | } 62 | } 63 | 64 | async function createUser(user: NexusUser): Promise { 65 | if (!user.nexus_refresh) { 66 | throw new Error('No auth information provided.'); 67 | } 68 | 69 | try { 70 | const result = await query( 71 | 'INSERT INTO users (d_id, id, name, avatar_url, supporter, premium, modauthor, nexus_access, nexus_expires, nexus_refresh, discord_access, discord_expires, discord_refresh, lastUpdate) ' + 72 | 'VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *', 73 | [ 74 | user.d_id, user.id, user.name, user.avatar_url, user.supporter, user.premium, user.modauthor || false, 75 | user.nexus_access, user.nexus_expires, user.nexus_refresh, 76 | user.discord_access, user.discord_expires, user.discord_refresh, 77 | new Date() 78 | ] 79 | ); 80 | return new DiscordBotUser(result?.rows[0], logger); 81 | } 82 | catch (err) { 83 | logger.error('Error inserting new user', err); 84 | throw err; 85 | } 86 | } 87 | 88 | async function deleteUser(discordId: string): Promise { 89 | try { 90 | await query('DELETE FROM users WHERE d_id = $1', [discordId]); 91 | } 92 | catch (err) { 93 | logger.error('Error deleting user', { discordId, err }); 94 | throw err; 95 | } 96 | } 97 | 98 | async function updateUser(discordId: string, newUser: Partial): Promise { 99 | newUser.lastupdate = new Date(); 100 | let values: any[] = []; 101 | let updateString: string[] = []; 102 | 103 | Object.entries(newUser).forEach(([key, value], idx) => { 104 | values.push(value); 105 | updateString.push(`${key} = $${idx + 1}`); 106 | }); 107 | values.push(discordId); 108 | 109 | const updateQuery = `UPDATE users SET ${updateString.join(', ')} WHERE d_id = $${values.length} RETURNING *`; 110 | 111 | try { 112 | const result = await query(updateQuery, values); 113 | return new DiscordBotUser(result?.rows[0], logger); 114 | } 115 | catch (err) { 116 | logger.error('Error updating user', { discordId, err }); 117 | throw err; 118 | } 119 | } 120 | 121 | async function userEmbed(userData: NexusUser, client: Client): Promise { 122 | try { 123 | const discordUser: User = await client.users.fetch(userData.d_id); 124 | if (!discordUser) throw new Error('Unknown User'); 125 | 126 | let embed = new EmbedBuilder() 127 | .setAuthor({ name: "Member Search Results", iconURL: discordUser.avatarURL() || undefined }) 128 | .addFields({ 129 | name: "Nexus Mods", 130 | value: `[${userData.name}](https://nexusmods.com/users/${userData.id})\n${userData.premium ? "Premium Member" : userData.supporter ? "Supporter" : "Member"}`, 131 | inline: true 132 | }) 133 | .addFields({ name: "Discord", value: `${discordUser.toString()}\n${discordUser.tag}`, inline: true }) 134 | .setColor(0xda8e35) 135 | .setThumbnail(userData.avatar_url || 'https://www.nexusmods.com/assets/images/default/avatar.png') 136 | .setTimestamp(userData.lastupdate) 137 | .setFooter({ text: 'Nexus Mods API link', iconURL: client.user?.avatarURL() || '' }); 138 | 139 | 140 | return embed; 141 | } 142 | catch (err) { 143 | logger.error('Error creating user embed', { userData, err }); 144 | throw err; 145 | } 146 | } 147 | 148 | async function userProfileEmbed(user: DiscordBotUser, client: Client): Promise { 149 | try { 150 | const discordUser: User = await user.Discord.User(client); 151 | if (!discordUser) throw new Error('Unknown User'); 152 | 153 | const roleToShow: string = user.NexusModsRoles.has('premium') 154 | ? "Premium Member" : user.NexusModsRoles.has('modauthor') 155 | ? "Mod Author" : user.NexusModsRoles.has('supporter') 156 | ? "Supporter" : "Member"; 157 | 158 | let embed = new EmbedBuilder() 159 | .setAuthor({ name: "Member Search Results", iconURL: discordUser.avatarURL() || undefined }) 160 | .addFields({ 161 | name: "Nexus Mods", 162 | value: `[${user.NexusModsUsername}](${nexusModsTrackingUrl(`https://nexusmods.com/users/${user.NexusModsId}`, 'profile')})\n${roleToShow}`, 163 | inline: true 164 | }) 165 | .addFields({ name: "Discord", value: `${discordUser.toString()}\n${discordUser.tag}`, inline: true }) 166 | .setColor(0xda8e35) 167 | .setThumbnail(user.NexusModsAvatar || 'https://www.nexusmods.com/assets/images/default/avatar.png') 168 | .setTimestamp(user.LastUpdated) 169 | .setFooter({ text: 'Nexus Mods API link', iconURL: client.user?.avatarURL() || '' }); 170 | 171 | return embed; 172 | } 173 | catch (err) { 174 | logger.error('Error creating user profile embed', { user, err }); 175 | throw err; 176 | } 177 | } 178 | 179 | export { getAllUsers, getUserByDiscordId, getUserByNexusModsName, createUser, deleteUser, updateUser, userEmbed, getUserByNexusModsId, userProfileEmbed }; 180 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | dotenv.config(); 3 | import { DiscordBot } from './DiscordBot'; 4 | import { AuthSite } from './server/server'; 5 | 6 | const bot = DiscordBot.getInstance(); 7 | start(); 8 | 9 | async function start() { 10 | // Log the shard ID (if running in a shard) 11 | if (bot.client.shard) { 12 | const shardId = bot.client.shard.ids[0]; 13 | bot.logger.info(`Starting shard ${shardId}`); 14 | } 15 | 16 | // Login with the Discord bot. 17 | try { 18 | await bot.connect(); 19 | } 20 | catch(err) { 21 | bot.logger.error('Failed to connect Discord bot', err); 22 | process.exit(); 23 | } 24 | 25 | // Set up slash commands (if required) 26 | try { 27 | await bot.setupInteractions(); 28 | } 29 | catch(err) { 30 | bot.logger.error('Failed to set up Discord bot interactions', err); 31 | process.exit(); 32 | } 33 | 34 | // Set up the OAuth portal 35 | try { 36 | const site = AuthSite.getInstance(bot.client, bot.logger); 37 | } 38 | catch(err) { 39 | bot.logger.error('Failed to set up Auth website', err); 40 | } 41 | } -------------------------------------------------------------------------------- /src/events/error.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'discord.js'; 2 | import { Logger } from '../api/util'; 3 | import { DiscordEventInterface } from '../types/DiscordTypes'; 4 | 5 | const main: DiscordEventInterface = { 6 | name: 'error', 7 | once: false, 8 | execute(client: Client, logger: Logger, error: Error) { 9 | logger.error('The bot has encountered an unexpected error', error); 10 | process.exit(); 11 | } 12 | } 13 | 14 | export default main; -------------------------------------------------------------------------------- /src/events/interactionCreate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | InteractionReplyOptions, GuildChannel, CommandInteraction, AutocompleteInteraction, 3 | MessageFlags 4 | } from 'discord.js'; 5 | import { isTesting, Logger, unexpectedErrorEmbed } from '../api/util'; 6 | import { DiscordEventInterface, DiscordInteraction, ClientExt } from '../types/DiscordTypes'; 7 | 8 | const ignoreErrors: string[] = [ 9 | 'Unknown interaction', 10 | 'The user aborted a request.' 11 | ]; 12 | 13 | const main: DiscordEventInterface = { 14 | name: 'interactionCreate', 15 | once: false, 16 | async execute(client: ClientExt, logger: Logger, interaction: CommandInteraction) { 17 | if (!interaction || (!interaction.isChatInputCommand() && !interaction.isContextMenuCommand() && !interaction.isAutocomplete())) return; // Not an interaction we want to handle. 18 | 19 | if (interaction.isAutocomplete()) return handleAutoComplete(client, interaction, logger); 20 | 21 | const interact: DiscordInteraction = client.interactions?.get(interaction.commandName); 22 | if (!interact) return logger.warn('Invalid interaction requested', {name: interaction.commandName, i: client.interactions, commands: await interaction.guild?.commands.fetch()}); 23 | else { 24 | logger.info('Interaction Triggered', 25 | { 26 | command: interaction.commandName, 27 | requestedBy: interaction.user.tag, 28 | server: `${interaction.guild?.name} (${interaction.guildId})`, 29 | channelName: (interaction.channel as GuildChannel)?.name, 30 | } 31 | ); 32 | return interact.action(client, interaction, logger).catch(err => {sendUnexpectedError(interaction, (interaction as CommandInteraction), err, logger)}); 33 | } 34 | } 35 | } 36 | 37 | async function handleAutoComplete(client: ClientExt, interaction: AutocompleteInteraction, logger: Logger) { 38 | const command: DiscordInteraction = client.interactions?.get(interaction.commandName); 39 | if (!command || !command.autocomplete) { 40 | return logger.warn('Invalid command or missing auto-complete', { name: interaction.commandName, autocomplete: !!command?.autocomplete, command }); 41 | } 42 | 43 | try { 44 | await command.autocomplete(client, interaction, logger); 45 | } 46 | catch(err) { 47 | if (!isTesting) logger.warn(`Failed to handle autocomplete: ${(err as Error).message}`, {command: interaction.commandName}); 48 | else logger.debug('Failed to handle autocomplete', {err, command: interaction.commandName}); 49 | } 50 | } 51 | 52 | export async function sendUnexpectedError(interaction: CommandInteraction|undefined, i:CommandInteraction, err:Error, logger: Logger):Promise { 53 | if (!interaction) return; 54 | const context = { 55 | server: `${interaction.guild?.name} (${interaction.guildId})`, 56 | channelName: (interaction.channel as any)?.name, 57 | requestedBy: interaction.user.tag, 58 | botVersion: process.env.npm_package_version, 59 | interaction: !i.isCommand() ? interaction.commandName : interaction.toString(), 60 | error: err.message || err 61 | } 62 | 63 | const reply:InteractionReplyOptions = { embeds: [unexpectedErrorEmbed(err, context)], flags: MessageFlags.Ephemeral}; 64 | if (ignoreErrors.includes(context.error.toString())) { 65 | return logger.error('Unknown interaction error', { err, inter: interaction.options, ...context }); 66 | } 67 | else logger.warn('Interaction action errored out', { err, interact: interaction.options, ...context }); 68 | 69 | if (interaction.replied || interaction.deferred) { 70 | if (!interaction.ephemeral) await interaction.deleteReply() 71 | interaction.ephemeral = true; 72 | interaction.followUp(reply).catch((replyError:Error) => errorReplyCatch(replyError, 'following up')); 73 | } else { 74 | interaction.reply(reply).catch((replyError:Error) => errorReplyCatch(replyError, 'replying')); 75 | } 76 | function errorReplyCatch(replyError: Error, action: String) { 77 | logger.error(`Error ${action} to failed interaction`, {replyError, ...context, err}); 78 | if(!ignoreErrors.includes(replyError.toString()) || !ignoreErrors.includes(replyError.message)) process.exit(1); 79 | } 80 | } 81 | 82 | 83 | 84 | export default main; -------------------------------------------------------------------------------- /src/events/ready.ts: -------------------------------------------------------------------------------- 1 | import { EmbedBuilder, Guild, TextChannel, GuildBasedChannel, ShardClientUtil } from 'discord.js'; 2 | import { getAllServers, deleteServer } from '../api/bot-db'; 3 | import { BotServer } from '../types/servers'; 4 | import { Logger } from '../api/util'; 5 | import { DiscordEventInterface, ClientExt } from '../types/DiscordTypes'; 6 | 7 | import { GameListCache } from '../types/util'; 8 | 9 | // Prepare the online status embed for quick reuse. 10 | const onlineEmbed = new EmbedBuilder() 11 | .setTitle('Nexus Mods Discord Bot is online.') 12 | .setColor(0x009933); 13 | 14 | const main: DiscordEventInterface = { 15 | name: 'ready', 16 | once: true, 17 | async execute(client: ClientExt, logger: Logger) { 18 | if (client.user?.username !== "Nexus Mods") client.user?.setUsername("Nexus Mods"); 19 | 20 | // Pre-cache games list 21 | try { 22 | client.gamesList = await new GameListCache().init(logger); 23 | } 24 | catch(err) { 25 | logger.warn('Could not pre-cache the games list', err); 26 | } 27 | 28 | // Publish online message to servers. (Cache server listing?) 29 | if (client.config.testing) { 30 | logger.debug('Testing mode - did not send online message'); 31 | logger.info(`v${process.env.npm_package_version} Startup complete. Ready to serve in ${client.guilds.cache.size} servers.`); 32 | client.emit('readyForAction'); 33 | return; 34 | } 35 | 36 | try { 37 | // Set the startup time 38 | onlineEmbed.setTimestamp(new Date()); 39 | // Get all known servers 40 | const servers: BotServer[] = await getAllServers().catch(() => []); 41 | for (const server of servers) { 42 | if (client.shard && ShardClientUtil.shardIdForGuildId(server.id, client.shard.count) !== client.shard.ids[0]) continue; 43 | // Check the server still exists (i.e. we are a member) 44 | const guild: Guild | undefined = await client.guilds.fetch(server.id).catch(() => undefined); 45 | if (!guild) { 46 | logger.info(`Deleting non-existent server: ${server.id}`); 47 | await deleteServer(server.id).catch((err) => logger.warn('Could not delete server', err)); 48 | continue; 49 | } 50 | if (!server.channel_nexus) continue; 51 | const postChannel: GuildBasedChannel | null = await guild.channels.fetch(server.channel_nexus).catch(() => null); 52 | // If the channel couldn't be resolved or we can't send messages. 53 | if (!postChannel || !(postChannel as TextChannel).send) continue; 54 | try { 55 | await (postChannel as TextChannel).send({ embeds: [onlineEmbed] }); 56 | } 57 | catch(err) { 58 | if (!['Missing Permissions', 'Missing Access'].includes((err as Error).message)) { 59 | logger.warn(`Error posting online notice to log channel in ${guild.name}`, { error: (err as Error).message }); 60 | } 61 | } 62 | } 63 | 64 | } 65 | catch(err) { 66 | logger.warn('Sending online message failed', err); 67 | 68 | } 69 | 70 | logger.info(`v${process.env.npm_package_version ?? '0.0.0'} Startup complete. Ready to serve in ${client.guilds.cache.size} servers.`); 71 | client.emit('readyForAction'); 72 | 73 | } 74 | } 75 | 76 | export default main; -------------------------------------------------------------------------------- /src/events/readyForAction.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../api/util'; 2 | import { DiscordEventInterface, ClientExt } from '../types/DiscordTypes'; 3 | 4 | import { NewsFeedManager } from '../feeds/NewsFeedManager'; 5 | import { AutoModManager } from '../feeds/AutoModManager'; 6 | import { SubscriptionManger } from '../feeds/SubscriptionManager'; 7 | 8 | const main: DiscordEventInterface = { 9 | name: 'readyForAction', 10 | once: true, 11 | async execute(client: ClientExt, logger: Logger) { 12 | logger.info('Setting up feeds') 13 | // Start up the feeds 14 | try { 15 | client.newsFeed = await NewsFeedManager.getInstance(client, logger); 16 | client.automod = await AutoModManager.getInstance(client, logger); 17 | client.subscriptions = await SubscriptionManger.getInstance(client, logger); 18 | } 19 | catch(err) { 20 | logger.error('Error starting up feeds', err); 21 | } 22 | } 23 | } 24 | 25 | export default main; -------------------------------------------------------------------------------- /src/events/reconnecting.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "discord.js"; 2 | import { Logger } from "../api/util"; 3 | import { DiscordEventInterface } from '../types/DiscordTypes'; 4 | 5 | const main: DiscordEventInterface = { 6 | name: 'reconnecting', 7 | once: false, 8 | execute(client: Client, logger: Logger) { 9 | logger.info('Reconnecting to Discord...'); 10 | } 11 | } 12 | 13 | export default main; -------------------------------------------------------------------------------- /src/events/resume.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "discord.js"; 2 | import { Logger } from "../api/util"; 3 | import { DiscordEventInterface } from '../types/DiscordTypes'; 4 | 5 | const main: DiscordEventInterface = { 6 | name: 'resume', 7 | once: false, 8 | execute(client: Client, logger: Logger, replayed: number) { 9 | logger.info(`Reconnected successfully, replaying ${replayed} events.`); 10 | } 11 | } 12 | 13 | export default main; -------------------------------------------------------------------------------- /src/interactions/about.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Client, SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction, 3 | EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageActionRowComponentBuilder, CommandInteraction, 4 | InteractionContextType, 5 | MessageFlags 6 | } from "discord.js"; 7 | import { DiscordInteraction } from "../types/DiscordTypes"; 8 | import { getAllUsers } from '../api/bot-db'; 9 | import { NexusUser } from "../types/users"; 10 | import { calcUptime, Logger } from "../api/util"; 11 | import { getAllSubscriptions } from "../api/subscriptions"; 12 | 13 | const discordInteraction: DiscordInteraction = { 14 | command: new SlashCommandBuilder() 15 | .setName('about') 16 | .setDescription('Information about this bot.') 17 | .addBooleanOption(option => 18 | option.setName('private') 19 | .setDescription('Only show to me.') 20 | .setRequired(false) 21 | ) 22 | .setContexts([InteractionContextType.Guild, InteractionContextType.BotDM]) 23 | .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages) as SlashCommandBuilder, 24 | public: true, 25 | action 26 | } 27 | 28 | // const minPermissions: { name: string, code: string }[] = [ 29 | // { 30 | // name: 'Read Messages/View Channels', 31 | // code: 'VIEW_CHANNEL', 32 | // }, 33 | // { 34 | // name: 'Send Messages', 35 | // code: 'SEND_MESSAGES' 36 | // }, 37 | // { 38 | // name: 'Manage Webhooks (Optional)', 39 | // code: 'MANAGE_WEBHOOKS' 40 | // }, 41 | // { 42 | // name: 'Manage Roles (Optional)', 43 | // code: 'MANAGE_ROLES' 44 | // } 45 | // ]; 46 | 47 | async function action(client: Client, baseInteraction: CommandInteraction, logger: Logger): Promise { 48 | const interaction = (baseInteraction as ChatInputCommandInteraction); 49 | 50 | const option: boolean | null = interaction.options.getBoolean('private'); 51 | const ephemeral: boolean = option !== null ? option : true; 52 | 53 | await interaction.deferReply({ flags: ephemeral ? MessageFlags.Ephemeral : undefined }).catch((err) => { throw err }); 54 | 55 | const upTime: string = calcUptime(process.uptime()); 56 | const allUsers: NexusUser[] = await getAllUsers(); 57 | const allFeeds = await getAllSubscriptions(); 58 | 59 | let guildCount = client.guilds.cache.size; 60 | if (client.shard) { 61 | const shardTotals = await client.shard.broadcastEval((client) => client.guilds.cache.size); 62 | guildCount = shardTotals.reduce((prev, cur) => prev+=cur, 0); 63 | } 64 | 65 | 66 | // const botPermissons: string[] = interaction.guild?.members.me?.permissions.toArray() || []; 67 | 68 | // const permissionsList: string = buildPermsList(botPermsissons, minPermissions); 69 | 70 | const info = new EmbedBuilder() 71 | .setTitle(`Nexus Mods Discord Bot v${process.env.npm_package_version}`) 72 | .setColor(0xda8e35) 73 | .setThumbnail(client.user?.avatarURL() || '') 74 | .setDescription(`Integrate your community with Nexus Mods using our Discord bot. Link accounts, search, get notified of the latest mods for your favourite games and more.`) 75 | .addFields([ 76 | { 77 | name: 'Stats', 78 | value: `Servers: ${guildCount.toLocaleString()}\n`+ 79 | `Linked Accounts: ${allUsers.length.toLocaleString()}\n`+ 80 | `Subscribed Items: ${allFeeds.length.toLocaleString()}`, 81 | inline: true 82 | }, 83 | ]) 84 | .setFooter({ text: `Uptime: ${upTime}`, iconURL: client.user?.avatarURL() || '' }) 85 | .setTimestamp(new Date()); 86 | 87 | const buttons = new ActionRowBuilder() 88 | .addComponents( 89 | new ButtonBuilder({ 90 | label: 'Docs', 91 | style: ButtonStyle.Link, 92 | url: 'https://modding.wiki/nexusmods/discord-bot' 93 | }), 94 | new ButtonBuilder({ 95 | label: 'Support', 96 | style: ButtonStyle.Link, 97 | url: 'https://discord.gg/nexusmods' 98 | }), 99 | new ButtonBuilder({ 100 | label: 'Source (GitHub)', 101 | style: ButtonStyle.Link, 102 | url: 'https://github.com/Nexus-Mods/discord-bot' 103 | }) 104 | ); 105 | 106 | return interaction.editReply({ embeds: [info], components: [buttons] }).catch(err => { throw err }); 107 | } 108 | 109 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/claimrole.ts: -------------------------------------------------------------------------------- 1 | import { SlashCommandBuilder, CommandInteraction, Role, EmbedBuilder, MessageFlags, InteractionContextType } from "discord.js"; 2 | import { ClientExt, DiscordInteraction } from "../types/DiscordTypes"; 3 | import { ConditionType } from "../types/util"; 4 | import { DiscordBotUser } from "../api/DiscordBotUser"; 5 | import { getUserByDiscordId, getServer, getConditionsForRole } from "../api/bot-db"; 6 | import { BotServer } from "../types/servers"; 7 | import { Logger } from "../api/util"; 8 | import { IConditionForRole } from "../api/server_role_conditions"; 9 | 10 | const discordInteraction: DiscordInteraction = { 11 | command: new SlashCommandBuilder() 12 | .setName('claimrole') 13 | .setDescription('Claim a role in this server.') 14 | .setContexts(InteractionContextType.Guild) as SlashCommandBuilder, 15 | public: true, 16 | guilds: [], 17 | action 18 | } 19 | 20 | async function action(client: ClientExt, interaction: CommandInteraction, logger: Logger): Promise { 21 | if (!interaction.guild) return interaction.reply('This command only works in servers.'); 22 | 23 | // Defer while we check this out. 24 | await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 25 | 26 | // Check if the user has linked their accounts 27 | let user : DiscordBotUser|undefined = undefined; 28 | 29 | // Get server info 30 | const server : BotServer = await getServer(interaction.guild); 31 | if (!server.role_author) return interaction.editReply('No claimable role in this server. Please check or [Linked Roles](https://support.discord.com/hc/en-us/articles/8063233404823-Connections-Linked-Roles-Community-Members#h_01GK286J648XF4HPGKZYW9AMQF) for more options.'); 32 | 33 | try { 34 | user = await getUserByDiscordId(interaction.user.id); 35 | if (!user) return interaction.editReply('Please [link your Nexus Mods account](https://discordbot.nexusmods.com/linked-role), to claim a role.') 36 | await user.NexusMods.Auth(); 37 | 38 | } 39 | catch(err) { 40 | logger.warn('Failed to get user info', err); 41 | return interaction.editReply('An error occured while verifying your account. Please [link your Nexus Mods account](https://discordbot.nexusmods.com/linked-role), to claim a role.') 42 | } 43 | 44 | const role: Role | null = await interaction.guild.roles.fetch(server.role_author); 45 | if (!role) return interaction.editReply('The claimable role in this server doesn\'t seem to exist anymore. \n-# ID: '+server.role_author); 46 | 47 | const conditions: IConditionForRole[] = await getConditionsForRole(server.id, server.role_author); 48 | const gameList = await client.gamesList?.getGames() ?? []; 49 | 50 | // Verify if the role can be assigned 51 | let shouldAllow = true; 52 | const conditionResults: (IConditionForRole & { result: 'skip' | 'pass' | 'fail' })[] = conditions.map(c => ({...c, result: 'skip'})); 53 | 54 | if (conditionResults.length) { 55 | let orChecks: boolean[] = []; 56 | // Process the rules 57 | for (const [i, condition] of conditionResults.entries()) { 58 | if (orChecks.length && condition.op === 'AND') { 59 | // We're doing an OR check but have moved onto an AND. 60 | if (!orChecks.includes(true)) { 61 | // None of the previous OR checks passed, so we failed. 62 | shouldAllow = false; 63 | break; 64 | } 65 | // If this check passed, reset the array. 66 | else orChecks = []; 67 | } 68 | const pass = await evaluateCondition(condition, user).catch(c => false); 69 | if (pass === true) condition.result = 'pass'; 70 | else condition.result = 'fail'; 71 | 72 | if (!pass && condition.op === 'AND') { 73 | shouldAllow = false; 74 | break; 75 | } 76 | else if (condition.op === 'OR') orChecks.push(pass); 77 | 78 | // If the last check is an OR and none of the checks passed. 79 | if (i+1 === conditionResults.length && condition.op === 'OR') { 80 | if (!orChecks.includes(true)) { 81 | // None of the previous OR checks passed, so we failed. 82 | shouldAllow = false; 83 | break; 84 | } 85 | } 86 | } 87 | } 88 | else shouldAllow = true; 89 | 90 | const resultsMessage = conditionResults.map(c => { 91 | var resEmoji = '' 92 | switch(c.result) { 93 | case "skip": resEmoji = '❔'; 94 | break; 95 | case "pass": resEmoji = '✅'; 96 | break; 97 | case "fail": resEmoji = '❌'; 98 | break; 99 | } 100 | return `- ${resEmoji} ${c.min.toLocaleString()}+ ${ConditionType[c.type]} for ${gameList.find(g => g.domain_name === c.game)!.name} :: ${c.op}` 101 | }); 102 | 103 | const embed = new EmbedBuilder() 104 | .setDescription(`**Role:** ${role.toString()}\n\n${resultsMessage.join('\n')}`); 105 | 106 | // Assign the role, or report the error. 107 | 108 | if (shouldAllow === false) { 109 | embed.setColor("DarkRed") 110 | .setTitle('You do not meet the criteria for this role') 111 | return interaction.editReply({ content: null, embeds: [embed] }); 112 | } 113 | 114 | try { 115 | const member = await interaction.guild.members.fetch({ user: interaction.user }) 116 | await member.roles.add(role); 117 | logger.info(`Assigned role ${role.name} to ${member.nickname}`); 118 | embed.setTitle('Role added!') 119 | .setColor("DarkGreen"); 120 | return interaction.editReply({ content: null, embeds: [embed] }); 121 | } 122 | catch(err) { 123 | if ((err as Error).message === 'Missing Permissions') { 124 | embed.setTitle('Missing Permissions!') 125 | .setColor('Red'); 126 | return interaction.editReply({content: 'Failed to add role due to a permissions error. Please ensure this bot has the correct permissions.', embeds: [embed]}); 127 | } 128 | logger.error('Failed to add role due to an error', err); 129 | return interaction.editReply('Failed to add role due to an error'); 130 | } 131 | 132 | } 133 | 134 | async function evaluateCondition(condition: IConditionForRole, user: DiscordBotUser): Promise { 135 | 136 | switch (condition.type) { 137 | case 'modDownloads' : { 138 | const mods = await user.NexusMods.API.v2.Mods( 139 | { 140 | uploaderId: { value: user.NexusMods.ID().toString(), op: 'EQUALS' }, 141 | gameDomainName: { value: condition.game, op: 'EQUALS' } 142 | }, 143 | { downloads: { direction: 'DESC' } }); 144 | const total = mods.nodes.reduce((prev, cur) => { return prev = prev + cur.downloads }, 0); 145 | return (total >= condition.min); 146 | } 147 | case 'modsPublished' : { 148 | const mods = await user.NexusMods.API.v2.Mods( 149 | { 150 | uploaderId: { value: user.NexusMods.ID().toString(), op: 'EQUALS' }, 151 | gameDomainName: { value: condition.game, op: 'EQUALS' } 152 | }, 153 | { downloads: { direction: 'DESC' } }); 154 | 155 | return (mods.totalCount >= condition.min); 156 | } 157 | default: throw new Error(`Unrecognised condition type: ${condition.type}`) 158 | } 159 | } 160 | 161 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/commandupdate.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SlashCommandBuilder, PermissionFlagsBits, CommandInteraction, 3 | ChatInputCommandInteraction, MessageFlags, InteractionContextType 4 | } from "discord.js"; 5 | import { ClientExt, DiscordInteraction } from "../types/DiscordTypes"; 6 | import { KnownDiscordServers, Logger } from "../api/util"; 7 | 8 | const discordInteraction: DiscordInteraction = { 9 | command: new SlashCommandBuilder() 10 | .setName('commandupdate') 11 | .setDescription('Update the commands used by this bot.') 12 | .setContexts(InteractionContextType.Guild) 13 | .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), 14 | public: false, 15 | guilds: [ 16 | KnownDiscordServers.BotDemo, 17 | KnownDiscordServers.Main 18 | ], 19 | action 20 | } 21 | 22 | async function action(client: ClientExt, baseInteraction: CommandInteraction, logger: Logger): Promise { 23 | const interaction = (baseInteraction as ChatInputCommandInteraction); 24 | 25 | await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch((err) => { throw err }); 26 | 27 | try { 28 | await client.updateInteractions?.(true); 29 | return interaction.followUp({content: 'Updated slash commands!', flags: MessageFlags.Ephemeral}); 30 | } 31 | catch(err) { 32 | return interaction.followUp({content: 'Failed to update slash commands: '+((err as Error).message || err), flags: MessageFlags.Ephemeral}); 33 | } 34 | 35 | 36 | } 37 | 38 | export { discordInteraction }; 39 | -------------------------------------------------------------------------------- /src/interactions/help.ts: -------------------------------------------------------------------------------- 1 | import { Client, EmbedBuilder, ActionRowBuilder, ButtonBuilder, SlashCommandBuilder, ButtonStyle, ChatInputCommandInteraction, CommandInteraction } from "discord.js"; 2 | import { DiscordInteraction } from "../types/DiscordTypes"; 3 | 4 | const discordInteraction: DiscordInteraction = { 5 | command: new SlashCommandBuilder() 6 | .setName('help') 7 | .setDescription('Information about this bot.'), 8 | public: true, 9 | action 10 | } 11 | 12 | const helpEmbed: EmbedBuilder = new EmbedBuilder() 13 | .setTitle('Help') 14 | .setDescription('You can visit Modding.wiki for a list of commands or report an issue on GitHub.') 15 | .setColor(0xda8e35) 16 | .setImage('https://images.nexusmods.com/oauth/applications/api_app_logo_1598554289_php9fzf1a.png'); 17 | 18 | const actions: ActionRowBuilder = new ActionRowBuilder() 19 | .addComponents( 20 | new ButtonBuilder() 21 | .setLabel('Command List') 22 | .setStyle(ButtonStyle.Link) 23 | .setURL('https://modding.wiki/en/nexusmods/discord-bot#commands'), 24 | new ButtonBuilder() 25 | .setLabel('Support') 26 | .setStyle(ButtonStyle.Link) 27 | .setURL('https://discord.gg/nexusmods'), 28 | new ButtonBuilder() 29 | .setLabel('Report an Issue') 30 | .setStyle(ButtonStyle.Link) 31 | .setURL('https://github.com/Nexus-Mods/discord-bot/issues'), 32 | ); 33 | 34 | async function action(client: Client, baseInteraction: CommandInteraction): Promise { 35 | const interaction = (baseInteraction as ChatInputCommandInteraction); 36 | return interaction.reply({ embeds: [helpEmbed], components: [actions] }); 37 | } 38 | 39 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/link.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandInteraction, Snowflake, EmbedBuilder, Client, SlashCommandBuilder, PermissionFlagsBits, 3 | ChatInputCommandInteraction, ActionRowBuilder, ButtonBuilder, ButtonStyle, 4 | MessageFlags 5 | } from "discord.js"; 6 | import { DiscordInteraction } from "../types/DiscordTypes"; 7 | import { getUserByDiscordId } from '../api/bot-db'; 8 | import { KnownDiscordServers, Logger } from '../api/util'; 9 | import { DiscordBotUser } from "../api/DiscordBotUser"; 10 | 11 | const discordInteraction: DiscordInteraction = { 12 | command: new SlashCommandBuilder() 13 | .setName('link') 14 | .setDescription('Link your Nexus Mods account to Discord.') 15 | .setDMPermission(true) 16 | .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), 17 | public: true, 18 | guilds: [ 19 | KnownDiscordServers.BotDemo 20 | ], 21 | action 22 | } 23 | 24 | async function action(client: Client, baseInteraction: CommandInteraction, logger: Logger): Promise { 25 | const interaction = (baseInteraction as ChatInputCommandInteraction); 26 | const discordId: Snowflake = interaction.user.id; 27 | await interaction.deferReply({flags: MessageFlags.Ephemeral}).catch(err => { throw err });; 28 | try { 29 | let userData: DiscordBotUser|undefined = await getUserByDiscordId(discordId); 30 | const response: { embeds: EmbedBuilder[], components: ActionRowBuilder[] } = await linkingEmbed(userData, discordId, client, logger); 31 | return interaction.editReply(response).catch(undefined); 32 | } 33 | catch(err) { 34 | logger.warn('Error in /link command', err); 35 | return interaction.editReply('Unexpected error! '+(err as Error).message); 36 | } 37 | 38 | } 39 | 40 | const linkButton = (discordId: string) => new ActionRowBuilder() 41 | .addComponents( 42 | new ButtonBuilder() 43 | .setLabel('Link Account') 44 | .setStyle(ButtonStyle.Link) 45 | .setURL(`https://discordbot.nexusmods.com/linked-role?id=${discordId}`) 46 | ); 47 | 48 | const linkingEmbed = async (user: DiscordBotUser|undefined, discordId: string, client: Client, logger: Logger): Promise<{ embeds: EmbedBuilder[], components: ActionRowBuilder[] }> => { 49 | let components = []; 50 | const embed = new EmbedBuilder() 51 | .setColor(0xda8e35) 52 | .addFields([ 53 | { 54 | name: 'Linked Roles', 55 | value: 'You can claim your roles using the "Linked Roles" option in the server drop-down menu.' 56 | } 57 | ]) 58 | .setFooter({ text: `Nexus Mods API Link`, iconURL: client.user?.avatarURL() || '' }); 59 | if (!!user) { 60 | try { 61 | await user.NexusMods.Auth(); 62 | // logMessage('Authorisation success for /link', { user: user.NexusModsUsername, discord: user.DiscordId }); 63 | } 64 | catch(err) { 65 | logger.warn('Authorisation failed for /link', { user: user.NexusModsUsername, discord: user.DiscordId, err }); 66 | embed.setTitle('Re-authorise your Discord account') 67 | .setDescription('Your Nexus Mods authorisation has expired, use the button below to re-link'); 68 | return { embeds: [embed], components: [ linkButton(discordId) as ActionRowBuilder ] }; 69 | } 70 | embed.setTitle(`Your Discord account is linked with ${user.NexusModsUsername}`) 71 | .setDescription('With your account linked you can now use all the features of the Discord bot!') 72 | .setAuthor({ name: user.NexusModsUsername, url: `https://nexusmods.com/users/${user.NexusModsId}`, iconURL: user.NexusModsAvatar }); 73 | 74 | const unlinkButton = new ActionRowBuilder() 75 | .addComponents( 76 | new ButtonBuilder() 77 | .setLabel('Unlink Account') 78 | .setStyle(ButtonStyle.Link) 79 | .setURL(`https://discordbot.nexusmods.com/revoke?id=${discordId}`) 80 | ); 81 | components.push(unlinkButton); 82 | 83 | } 84 | else { 85 | embed.setTitle('Connect your Discord account') 86 | .setURL(`https://discordbot.nexusmods.com/linked-role?id=${discordId}`) 87 | .setDescription(`Linking your account will allow you to use Game Feeds, Search and more!`) 88 | 89 | components.push(linkButton(discordId)); 90 | } 91 | 92 | return { embeds : [embed], components: (components as ActionRowBuilder[] ) }; 93 | } 94 | 95 | export { discordInteraction }; 96 | -------------------------------------------------------------------------------- /src/interactions/mytoken.ts: -------------------------------------------------------------------------------- 1 | import { Client, SlashCommandBuilder, ChatInputCommandInteraction, CommandInteraction, Snowflake, PermissionFlagsBits } from "discord.js"; 2 | import { DiscordInteraction } from "../types/DiscordTypes"; 3 | import { DiscordBotUser } from "../api/DiscordBotUser"; 4 | import { getUserByDiscordId } from "../api/users"; 5 | import { KnownDiscordServers } from "../api/util"; 6 | 7 | const discordInteraction: DiscordInteraction = { 8 | command: new SlashCommandBuilder() 9 | .setName('mytoken') 10 | .setDescription('Get access token.') 11 | .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), 12 | public: false, 13 | guilds: [ 14 | KnownDiscordServers.BotDemo 15 | ], 16 | action, 17 | } 18 | 19 | async function action(client: Client, baseInteraction: CommandInteraction): Promise { 20 | const interaction = (baseInteraction as ChatInputCommandInteraction); 21 | const discordId: Snowflake | undefined = interaction.user.id; 22 | await interaction.deferReply({ephemeral: true}).catch(err => { throw err });; 23 | // Check if they are already linked. 24 | let userData : DiscordBotUser | undefined; 25 | try { 26 | userData = !!discordId ? await getUserByDiscordId(discordId) : undefined; 27 | if (!userData) throw new Error('User not found; please link your account first.'); 28 | return interaction.editReply({ 29 | content: `OAuth Access Token`, 30 | files: [{ attachment: Buffer.from(userData.NexusMods.Token()?.access_token ?? 'No token', 'utf-8'), name: 'access_token.txt' }] 31 | }); 32 | } 33 | catch(err) { 34 | return interaction.editReply('Error getting user data: '+(err as Error).message); 35 | } 36 | 37 | } 38 | 39 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/news.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, CommandInteraction, EmbedBuilder, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; 2 | import { DiscordInteraction, ClientExt } from "../types/DiscordTypes"; 3 | import { autocompleteGameName, KnownDiscordServers, Logger } from "../api/util"; 4 | import { NewsFeedManager } from "../feeds/NewsFeedManager"; 5 | 6 | const discordInteraction: DiscordInteraction = { 7 | command: new SlashCommandBuilder() 8 | .setName('news') 9 | .setDescription('Refresh the news feed manually.') 10 | .addStringOption(option => 11 | option.setName('domain') 12 | .setDescription('Domain to check, for game-specific news.') 13 | .setAutocomplete(true) 14 | ) 15 | .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) as SlashCommandBuilder, 16 | public: false, 17 | guilds: [ 18 | KnownDiscordServers.BotDemo, 19 | KnownDiscordServers.Moderator, 20 | 21 | ], 22 | action, 23 | autocomplete: autocompleteGameName 24 | } 25 | 26 | async function action(client: ClientExt, baseInteraction: CommandInteraction, logger: Logger): Promise { 27 | const interaction = (baseInteraction as ChatInputCommandInteraction); 28 | await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 29 | 30 | const domain: string|null = interaction.options.getString('domain'); 31 | const newsInst: NewsFeedManager = await NewsFeedManager.getInstance(client, logger); 32 | 33 | try { 34 | const latest = await newsInst.forceUpdate(domain?.toLowerCase()); 35 | let embed: EmbedBuilder; 36 | embed = latest as EmbedBuilder; 37 | await interaction.editReply({ content: 'Update successful', embeds: [embed]}); 38 | } 39 | catch(err) { 40 | logger.warn('Failed to update news', err); 41 | return interaction.editReply('Failed to update news:'+(err as Error).message); 42 | } 43 | } 44 | 45 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/profile.ts: -------------------------------------------------------------------------------- 1 | import { DiscordInteraction } from "../types/DiscordTypes"; 2 | import { getUserByDiscordId } from '../api/bot-db'; 3 | import { CommandInteraction, Snowflake, EmbedBuilder, Client, CommandInteractionOption, SlashCommandBuilder, ChatInputCommandInteraction, InteractionContextType } from "discord.js"; 4 | import { KnownDiscordServers, Logger } from '../api/util'; 5 | import { DiscordBotUser } from "../api/DiscordBotUser"; 6 | 7 | const discordInteraction: DiscordInteraction = { 8 | command: new SlashCommandBuilder() 9 | .setName('profile') 10 | .setDescription('Show your profile card.') 11 | .addBooleanOption(option => 12 | option.setName('public') 13 | .setDescription('Make your card visible to all users?') 14 | .setRequired(false) 15 | ) 16 | .setContexts(InteractionContextType.Guild, InteractionContextType.BotDM) as SlashCommandBuilder, 17 | public: true, 18 | guilds: [ 19 | KnownDiscordServers.BotDemo 20 | ], 21 | action 22 | } 23 | 24 | async function action(client: Client, baseInteraction: CommandInteraction, logger: Logger): Promise { 25 | const interaction = (baseInteraction as ChatInputCommandInteraction); 26 | // Private? 27 | const showValue : (CommandInteractionOption | null) = interaction.options.get('public'); 28 | const show: boolean = !!showValue ? (showValue.value as boolean) : false; 29 | 30 | // Get sender info. 31 | const discordId: Snowflake | undefined = interaction.user.id; 32 | await interaction.deferReply({ephemeral: !show}).catch(err => { throw err });; 33 | // Check if they are already linked. 34 | let userData : DiscordBotUser | undefined; 35 | 36 | try { 37 | userData = !!discordId ? await getUserByDiscordId(discordId) : undefined; 38 | if (!userData) interaction.followUp('You haven\'t linked your account yet. Use the /link command to get started.'); 39 | else { 40 | const card: EmbedBuilder = await userData.ProfileEmbed(client); 41 | interaction.followUp({ embeds: [card] }); 42 | } 43 | } 44 | catch(err) { 45 | logger.warn('Error checking if user exists in DB when linking', err); 46 | interaction.followUp('An error occurred fetching your account details.'); 47 | } 48 | 49 | } 50 | 51 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/refresh.ts: -------------------------------------------------------------------------------- 1 | import { DiscordInteraction } from "../types/DiscordTypes"; 2 | import { NexusUser } from "../types/users"; 3 | import { getUserByDiscordId } from '../api/bot-db'; 4 | import { CommandInteraction, Snowflake, EmbedBuilder, Client, User, SlashCommandBuilder, ChatInputCommandInteraction, InteractionContextType } from "discord.js"; 5 | import { KnownDiscordServers, Logger } from '../api/util'; 6 | import { DiscordBotUser } from "../api/DiscordBotUser"; 7 | 8 | const cooldown: number = (1*60*1000); 9 | 10 | const discordInteraction: DiscordInteraction = { 11 | command: new SlashCommandBuilder() 12 | .setName('refresh') 13 | .setDescription('Update your profile card.') 14 | .setContexts(InteractionContextType.Guild, InteractionContextType.BotDM), 15 | public: true, 16 | guilds: [ 17 | KnownDiscordServers.BotDemo 18 | ], 19 | action 20 | } 21 | 22 | interface MetaData { modauthor?: '1' | '0', premium?: '1' | '0', supporter?: '1' | '0' }; 23 | 24 | const updateMeta = (prev: MetaData, cur: MetaData): boolean => { 25 | for (const key of Object.keys(prev)) { 26 | if (prev[key as keyof MetaData] != cur[key as keyof MetaData]) { 27 | return true; 28 | } 29 | } 30 | return false; 31 | }; 32 | 33 | const replyCard = (client: Client, nexus: DiscordBotUser, discord: User): EmbedBuilder => { 34 | let result = new EmbedBuilder() 35 | .setTitle('Updating user data...') 36 | .setColor(0xda8e35) 37 | .setThumbnail(nexus.NexusModsAvatar || discord.avatarURL() || '' ) 38 | .setFooter({text: `Nexus Mods API link - ${discord.tag}`, iconURL: client.user?.avatarURL() || '' }) 39 | return result; 40 | } 41 | 42 | const cancelCard = (client: Client, nexus: DiscordBotUser, discord: User) => { 43 | return new EmbedBuilder({ 44 | title: 'Update cancelled', 45 | description: `You must wait at least ${cooldown/1000/60} minute(s) before refreshing your account.`, 46 | color: 0xda8e35, 47 | thumbnail: { url: (nexus.NexusModsAvatar || (discord.avatarURL() as string) || '') }, 48 | footer: { 49 | text: `Nexus Mods API link - ${discord.tag}`, 50 | iconURL: client.user?.avatarURL() || '' 51 | } 52 | }) 53 | } 54 | 55 | async function action(client: Client, baseInteraction: CommandInteraction, logger: Logger): Promise { 56 | const interaction = (baseInteraction as ChatInputCommandInteraction); 57 | // Get sender info. 58 | const discordId: Snowflake | undefined = interaction.user.id; 59 | await interaction.deferReply({ephemeral: true}).catch(err => { throw err });; 60 | // Check if they are already linked. 61 | let userData : DiscordBotUser | undefined; 62 | 63 | let card : EmbedBuilder 64 | 65 | try { 66 | userData = !!discordId ? await getUserByDiscordId(discordId) : undefined; 67 | const nextUpdate = new Date( userData?.LastUpdated ? userData.LastUpdated.getTime() + cooldown : 0 ); 68 | try { 69 | await userData?.NexusMods.Auth() 70 | } 71 | catch(err) { 72 | return interaction.editReply({ content: 'There was a problem authorising your Nexus Mods account. Use /link to refresh your tokens.' }); 73 | } 74 | if (!userData) { 75 | await interaction.editReply('You haven\'t linked your account yet. Use the /link command to get started.'); 76 | return; 77 | } 78 | else if (nextUpdate > new Date()) { 79 | await interaction.editReply({ embeds: [ cancelCard(client, userData, interaction.user) ] }).catch((err) => logger.warn('Error updating interaction reply', { err }));; 80 | return; 81 | } 82 | else { 83 | card = replyCard(client, userData, interaction.user); 84 | await interaction.editReply({ embeds: [ card ] }).catch((err) => logger.warn('Error updating interaction reply', { err }));; 85 | } 86 | } 87 | catch(err) { 88 | logger.warn('Error checking if user exists in DB when linking', err); 89 | await interaction.editReply('An error occurred fetching your account details.').catch((err) => logger.warn('Error updating interaction reply', { err }));; 90 | return; 91 | } 92 | 93 | let newData: Partial = {}; 94 | newData.lastupdate = new Date(); 95 | // Master check if we need to update roles 96 | // let updateRoles: boolean = false; 97 | 98 | // Update membership status. 99 | try { 100 | await userData.NexusMods.Auth(); 101 | const newfields = await userData.NexusMods.Refresh(); 102 | if (newfields.length > 0) { 103 | const updatedFields: string[] = getFieldNames(newfields); 104 | card.addFields({ name: 'User Info', value: `Updated:\n ${updatedFields.join('\n')}`}); 105 | // updateRoles = true; 106 | try { 107 | const oldmeta = await userData.Discord.GetRemoteMetaData(); 108 | if (!oldmeta) throw new Error('No Discord tokens'); 109 | const meta = await userData.Discord.BuildMetaData(); 110 | if (updateMeta(oldmeta.metadata, meta)) { 111 | await userData.Discord.PushMetaData(meta); 112 | card.addFields({ name: 'Linked Roles', value: 'Updated successfully!'}); 113 | } 114 | else card.addFields({ name: 'Linked Roles', value: 'No changes required'}); 115 | } 116 | catch(err) { 117 | logger.warn('Discord metadata update error', (err as Error).message); 118 | if ((err as Error).message === 'No Discord tokens') card.addFields({ name: 'Linked Roles', value: 'If you would like to use linked roles, please [re-authorise here](https://discordbot.nexusmods.com/linked-role).'}) 119 | else card.addFields({ name: 'Linked Roles', value: 'Could not update metadata due to an unexpected error'}); 120 | } 121 | } 122 | else { 123 | card.addFields({ name: 'User Info', value: `No changes required`}); 124 | } 125 | 126 | } 127 | catch(err) { 128 | logger.warn('Error updating using info', { err, stack: (err as Error)?.stack }); 129 | card.addFields({ name: 'User Info', value: `Error updating user info: \n${err}`}); 130 | } 131 | 132 | // Update the interaction 133 | card.setTitle('Updating mod stats...'); 134 | await interaction.editReply({ embeds: [card] }).catch((err) => logger.warn('Error updating interaction reply', { err }));; 135 | 136 | // Update the interaction 137 | card.setTitle('Update complete'); 138 | await interaction.editReply({ embeds: [card] }).catch((err) => logger.warn('Error updating interaction reply', { err })); 139 | // // Recheck roles, if we have changed something. 140 | // if (updateRoles === true) await updateAllRoles(client, userData, interaction.user, false); 141 | 142 | } 143 | 144 | function getFieldNames(keys: string[]): string[] { 145 | return keys.map(k => { 146 | switch(k) { 147 | case 'id': return '- User ID'; 148 | case 'name': return '- Username'; 149 | case 'avatar_url': return '- Profile Image'; 150 | case 'supporter': return '- Supporter Membership'; 151 | case 'premium': return '- Premium Membership'; 152 | case 'modauthor': return '- Mod Author status'; 153 | case 'lastupdate': return '- Last updated time'; 154 | default: return k; 155 | 156 | } 157 | }); 158 | } 159 | 160 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/status.ts: -------------------------------------------------------------------------------- 1 | import {ChatInputCommandInteraction, CommandInteraction, EmbedBuilder, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; 2 | import { DiscordInteraction, ClientExt } from "../types/DiscordTypes"; 3 | import { getUserByDiscordId } from '../api/bot-db'; 4 | import { DiscordBotUser, DummyNexusModsUser } from "../api/DiscordBotUser"; 5 | import { IStatusPageFullResponse } from "../types/util"; 6 | import { KnownDiscordServers, Logger } from "../api/util"; 7 | 8 | const discordInteraction: DiscordInteraction = { 9 | command: new SlashCommandBuilder() 10 | .setName('status') 11 | .setDescription('Check the status of the Nexus Mods website and services.') 12 | .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), 13 | public: false, 14 | guilds: [ 15 | KnownDiscordServers.BotDemo, 16 | KnownDiscordServers.Moderator, 17 | 18 | ], 19 | action 20 | } 21 | 22 | async function action(client: ClientExt, baseInteraction: CommandInteraction, logger: Logger): Promise { 23 | const interaction = (baseInteraction as ChatInputCommandInteraction); 24 | await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 25 | const discordId = interaction.user.id; 26 | const botuser: DiscordBotUser = await getUserByDiscordId(discordId) ?? new DiscordBotUser(DummyNexusModsUser, logger); 27 | 28 | try { 29 | const statusPage: IStatusPageFullResponse = await botuser.NexusMods.API.Other.WebsiteStatus(true) as IStatusPageFullResponse; 30 | const embed = new EmbedBuilder() 31 | .setTitle('Nexus Mods Status - '+statusPage.status.description) 32 | .setColor("DarkBlue") 33 | .setDescription(` 34 | ## Incidents\n 35 | ${statusPage.incidents.length ? statusPage.incidents.map(c => `${c.name}\n${c.incident_updates[0].body}`).join('\n'): 'None'}\n 36 | ## Planned Maintainece 37 | ${statusPage.scheduled_maintenances.length ? statusPage.scheduled_maintenances.map(c => `${c.name}\n${c.incident_updates[0].body}`).join('\n'): 'None'} 38 | `); 39 | return interaction.editReply({ embeds: [embed] }); 40 | } 41 | catch(err) { 42 | throw err; 43 | } 44 | } 45 | 46 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/test.ts: -------------------------------------------------------------------------------- 1 | import { ActionRowBuilder, ButtonBuilder, ButtonStyle, ChatInputCommandInteraction, CommandInteraction, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } from "discord.js"; 2 | import { DiscordInteraction, ClientExt } from "../types/DiscordTypes"; 3 | import { getUserByDiscordId } from '../api/bot-db'; 4 | import { KnownDiscordServers, Logger } from "../api/util"; 5 | import { DiscordBotUser } from "../api/DiscordBotUser"; 6 | import { customEmojis } from "../types/util"; 7 | 8 | const discordInteraction: DiscordInteraction = { 9 | command: new SlashCommandBuilder() 10 | .setName('test') 11 | .setDescription('Testing Command.') 12 | .setContexts(InteractionContextType.Guild) 13 | .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), 14 | public: false, 15 | guilds: [ 16 | KnownDiscordServers.BotDemo, 17 | KnownDiscordServers.Moderator, 18 | 19 | ], 20 | action 21 | } 22 | 23 | async function action(client: ClientExt, baseInteraction: CommandInteraction, logger: Logger): Promise { 24 | const interaction = (baseInteraction as ChatInputCommandInteraction); 25 | await interaction.deferReply({ flags: MessageFlags.Ephemeral }); 26 | const discordId = interaction.user.id; 27 | const botuser: DiscordBotUser|undefined = await getUserByDiscordId(discordId); 28 | if (!botuser) return interaction.editReply({ content: 'Error! No linked user!' }); 29 | try { 30 | await botuser.NexusMods.Auth(); 31 | logger.info('Nexus Mods Auth verfied.'); 32 | 33 | const v2test = { 34 | IsModAuthor: (await botuser.NexusMods.API.v2.IsModAuthor(1)) === true, 35 | Mod: (await botuser.NexusMods.API.v2.Mod('skyrim', 3863))[0].name === 'SkyUI', 36 | Mods: (await botuser.NexusMods.API.v2.Mods({ name: { value: 'skyui', op: 'WILDCARD' } })).totalCount > 0, 37 | UpdatedMods: (await botuser.NexusMods.API.v2.UpdatedMods(new Date(0), true)).totalCount > 0, 38 | ModsByModId: (await botuser.NexusMods.API.v2.ModsByModId([{ gameDomain: 'skyrim', modId: 3863 }])).length > 0, 39 | MyCollections: (await botuser.NexusMods.API.v2.MyCollections()).length > 0, 40 | Collections: (await botuser.NexusMods.API.v2.Collections({}, { endorsements: { direction: 'DESC' }})).nodesCount > 0, 41 | Collection: (await botuser.NexusMods.API.v2.Collection('pkcov7', 'skyrimspecialedition', true))?.slug === 'pkcov7', 42 | CollectionsByUser: (await botuser.NexusMods.API.v2.CollectionsByUser(31179975)).nodesCount > 0, 43 | FindUserName: (await botuser.NexusMods.API.v2.FindUser('Janquel'))?.memberId === 51448566, 44 | CollectionDownloadTotals: (await botuser.NexusMods.API.v2.CollectionDownloadTotals(31179975))?.totalDownloads > 0, 45 | FindUserID: (await botuser.NexusMods.API.v2.FindUser(51448566))?.name === 'Janquel', 46 | LatestMods: (await botuser.NexusMods.API.v2.LatestMods(new Date(1))).totalCount > 0 47 | } 48 | 49 | const otherTest = { 50 | Games: (await botuser.NexusMods.API.Other.Games()).length > 1, 51 | WebsiteStatus: !!(await botuser.NexusMods.API.Other.WebsiteStatus()), 52 | } 53 | 54 | logger.info('API tests complete', { v2test, otherTest }); 55 | 56 | const format = (input: {[key: string]: boolean}): string => 57 | Object.entries(input).reduce((prev: string, cur: [string, boolean]) => { 58 | return prev + `${cur[0]}: ${cur[1] ? '✅' : '⚠️' }\n` 59 | }, ''); 60 | 61 | const formatted = `## V2 API Tests\n${format(v2test)}\n## Other\n${format(otherTest)}`; 62 | 63 | const embed = await botuser.ProfileEmbed(client); 64 | 65 | const button = new ActionRowBuilder().addComponents( 66 | new ButtonBuilder() 67 | .setLabel('Collections') 68 | .setStyle(ButtonStyle.Primary) 69 | .setEmoji(customEmojis.collection) 70 | .setCustomId('collections'), 71 | new ButtonBuilder() 72 | .setLabel('Mods') 73 | .setStyle(ButtonStyle.Primary) 74 | .setEmoji(customEmojis.mod) 75 | .setCustomId('mods') 76 | ); 77 | 78 | return interaction.editReply({ content: formatted, embeds: [embed], components: [button] }); 79 | } 80 | catch(err) { 81 | return interaction.editReply({ content: 'Error! '+err }); 82 | } 83 | } 84 | 85 | export { discordInteraction } -------------------------------------------------------------------------------- /src/interactions/tips.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandInteraction, EmbedBuilder, User, 3 | SlashCommandBuilder, ChatInputCommandInteraction, AutocompleteInteraction, 4 | EmbedData, InteractionEditReplyOptions 5 | } from "discord.js"; 6 | import { ClientExt, DiscordInteraction } from '../types/DiscordTypes'; 7 | import { Logger } from "../api/util"; 8 | import { ITip } from "../api/tips"; 9 | import { TipCache } from "../types/util"; 10 | 11 | const discordInteraction: DiscordInteraction = { 12 | command: new SlashCommandBuilder() 13 | .setName('tips') 14 | .setDescription('Return a quick info message on a number of topics.') 15 | .addStringOption(option => 16 | option.setName('prompt') 17 | .setDescription('Start typing the tip title or prompt.') 18 | .setRequired(true) 19 | .setAutocomplete(true) 20 | ) 21 | .addUserOption(option => 22 | option.setName('user') 23 | .setDescription('The user to ping in the reply. (Optional)') 24 | .setRequired(false) 25 | ) 26 | .setDMPermission(true) as SlashCommandBuilder, 27 | public: true, 28 | guilds: [], 29 | action, 30 | autocomplete 31 | } 32 | 33 | async function action(client: ClientExt, baseInteraction: CommandInteraction, logger: Logger): Promise { 34 | const interaction = (baseInteraction as ChatInputCommandInteraction); 35 | await interaction.deferReply().catch(err => { throw err }); 36 | 37 | const message: string = interaction.options.getString('prompt', true); 38 | const user: User | null = interaction.options.getUser('user'); 39 | 40 | if (!client.tipCache) client.tipCache = new TipCache(); 41 | const tips: ITip[] = await client.tipCache.getTips().catch(() => []); 42 | let replyMessage: InteractionEditReplyOptions = { content: '' }; 43 | 44 | if (!!message) { 45 | const tip: ITip | undefined = tips.find(t => t.prompt.toLowerCase() === message.toLowerCase()); 46 | if (!!tip) { 47 | if (user) replyMessage.content = replyMessage.content + `${user.toString()}\n`; 48 | if (tip.message) replyMessage.content = replyMessage.content + `${tip.message}`; 49 | if (tip.embed) { 50 | const embedData = JSON.parse(tip.embed) as EmbedData; 51 | const embedToShow = embedBulderWithOverrides(tip, embedData, interaction); 52 | replyMessage.embeds = [ embedToShow ] 53 | } 54 | else replyMessage.content = replyMessage.content + `\n-# Tip submitted by ${tip.author}`; 55 | 56 | // Clean out the content if it's blank 57 | if (replyMessage.content === '') delete replyMessage.content; 58 | } 59 | else replyMessage.content = `No results found for ${message}`; 60 | 61 | return interaction.editReply(replyMessage); 62 | } 63 | else throw new Error('Tip prompt was not provided.') 64 | 65 | } 66 | 67 | function embedBulderWithOverrides(tip: ITip, data: EmbedData, interaction: ChatInputCommandInteraction): EmbedBuilder { 68 | return new EmbedBuilder(data) 69 | .setFooter({ text:`Last updated by ${tip.author || '???'}`, iconURL: interaction.user.avatarURL() || '' } ) 70 | .setTimestamp(new Date(tip.updated)) 71 | .setColor(0xda8e35); 72 | } 73 | 74 | async function autocomplete(client: ClientExt, interaction: AutocompleteInteraction, logger: Logger) { 75 | const focused = interaction.options.getFocused().toLowerCase(); 76 | try { 77 | if (!client.tipCache) client.tipCache = new TipCache(); 78 | let tips = await client.tipCache.getApprovedTips(); 79 | if(focused.length) tips = tips.filter(t => t.prompt.toLowerCase().includes(focused) || t.title.toLowerCase().includes(focused) ); 80 | await interaction.respond( 81 | tips.map(t => ({ name: t.title, value: t.prompt })).slice(0, 25) 82 | ); 83 | } 84 | catch(err) { 85 | logger.warn('Error autocompleting tips', {err}); 86 | throw err; 87 | } 88 | } 89 | 90 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/triggerupdate.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, SlashCommandBuilder, ChatInputCommandInteraction, GuildChannel, PermissionFlagsBits, MessageFlags, InteractionContextType } from "discord.js"; 2 | import { ClientExt, DiscordInteraction } from '../types/DiscordTypes'; 3 | import { Logger } from "../api/util"; 4 | import { getSubscribedChannel } from "../api/subscriptions"; 5 | 6 | const timezones = [ 7 | { name: 'UTC, GMT, Europe/London', value: '+00:00' }, 8 | { name: 'Europe/Paris, Europe/Berlin', value: '+01:00' }, 9 | { name: 'Asia/Tokyo', value: '+09:00' }, 10 | { name: 'Asia/Kolkata', value: '+05:30' }, 11 | { name: 'America/New York', value: '-05:00' }, 12 | { name: 'America/Los Angeles', value: '-08:00' }, 13 | { name: 'America/Chicago', value: '-06:00' }, 14 | { name: 'America/Denver', value: '-07:00' }, 15 | { name: 'Australia/Sydney', value: '+11:00' }, 16 | { name: 'Asia/Dubai', value: '+04:00' }, 17 | { name: 'Asia/Singapore', value: '+08:00' }, 18 | { name: 'Africa/Johannesburg', value: '+02:00' } 19 | ]; 20 | 21 | const discordInteraction: DiscordInteraction = { 22 | command: new SlashCommandBuilder() 23 | .setName('trigger-update') 24 | .setDescription('Return a quick info message on a number of topics.') 25 | .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) 26 | .setContexts(InteractionContextType.Guild) 27 | .addStringOption(o => 28 | o.setName('date') 29 | .setDescription('YYYY-MM-DD') 30 | .setRequired(false) 31 | .setMinLength(10) 32 | .setMaxLength(10) 33 | ) 34 | .addStringOption(o => 35 | o.setName('time') 36 | .setDescription('HH:MM') 37 | .setRequired(false) 38 | .setMinLength(5) 39 | .setMaxLength(5) 40 | ) 41 | .addStringOption(o => 42 | o.setName('timezone') 43 | .setDescription('Timezone to use') 44 | .setRequired(false) 45 | .setChoices(...timezones) 46 | ) as SlashCommandBuilder, 47 | public: true, 48 | guilds: [], 49 | action, 50 | } 51 | 52 | async function action(client: ClientExt, baseInteraction: CommandInteraction, logger: Logger): Promise { 53 | const interaction = (baseInteraction as ChatInputCommandInteraction); 54 | await interaction.deferReply({flags: MessageFlags.Ephemeral }).catch(err => { throw err }); 55 | // Get any passed options 56 | const date = interaction.options.getString('date'); 57 | const time = interaction.options.getString('time'); 58 | const timezone = interaction.options.getString('timezone'); 59 | let timeToUse 60 | // No options, use the time now 61 | if (!date && !time && !timezone) { 62 | timeToUse = new Date(); 63 | } 64 | else if (!date && time) { 65 | const now = new Date(); 66 | const [hrs, mins] = time.split(':'); 67 | now.setHours(parseInt(hrs)); 68 | now.setMinutes(parseInt(mins)); 69 | timeToUse = now; 70 | } 71 | else { 72 | const dateString = `${date}T${time ?? '00:00'}:00${timezone ?? 'Z'}` 73 | timeToUse = new Date(dateString); 74 | if (isNaN(timeToUse.getTime())) { 75 | return interaction.editReply(`Invalid date: \`${dateString}\`. Remember to use the format YYYY-MM-DD HH:MM.`) 76 | } 77 | } 78 | // Update all subs to use this date. 79 | 80 | try { 81 | const epoch: number = Math.floor(timeToUse.getTime()/1000); 82 | let channel = await getSubscribedChannel(interaction.guildId!, interaction.channelId); 83 | if (!channel) return interaction.editReply('No subscribed items in this channel.'); 84 | logger.info('Subscription update triggered', { guild: interaction.guild?.name, channel: (interaction.channel as GuildChannel)?.name, timeToUse}); 85 | await channel.webHookClient.send(`-# Update triggered by ${interaction.user.toString()} for updates since for ${(await channel.getSubscribedItems()).length} tracked item(s).`); 86 | await client.subscriptions?.forceChannnelUpdate(channel,timeToUse); 87 | await interaction.editReply(`Updates for all tracked items since will be posted shortly.`); 88 | } 89 | catch(err) { 90 | logger.warn('Error updating subsriptions', err); 91 | return interaction.editReply('An error occurred updating subscriptions: '+(err as Error).message); 92 | } 93 | } 94 | 95 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/unlink.ts: -------------------------------------------------------------------------------- 1 | import { CommandInteraction, Snowflake, Client, SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, InteractionContextType, MessageFlags } from "discord.js"; 2 | import { DiscordInteraction } from "../types/DiscordTypes"; 3 | import { getUserByDiscordId } from '../api/bot-db'; 4 | import { KnownDiscordServers, Logger } from "../api/util"; 5 | 6 | const discordInteraction: DiscordInteraction = { 7 | command: new SlashCommandBuilder() 8 | .setName('unlink') 9 | .setDescription('Delete the link between your Nexus Mods account and Discord.') 10 | .setContexts(InteractionContextType.Guild, InteractionContextType.BotDM), 11 | public: true, 12 | guilds: [ 13 | KnownDiscordServers.BotDemo 14 | ], 15 | action 16 | } 17 | 18 | async function action(client: Client, baseInteraction: CommandInteraction, logger: Logger): Promise { 19 | const interaction = (baseInteraction as ChatInputCommandInteraction); 20 | const discordId: Snowflake = interaction.user.id; 21 | await interaction.deferReply({flags: MessageFlags.Ephemeral}).catch(err => { throw err });; 22 | // See if they have existing data 23 | const userData = await getUserByDiscordId(discordId); 24 | if (!!userData) { 25 | // Existing user 26 | const unlinkEmbed = [new EmbedBuilder() 27 | .setTitle('Unlink Nexus Mods account') 28 | .setColor(0xda8e35) 29 | .setURL(`https://discordbot.nexusmods.com/revoke?id=${discordId}`) 30 | .setDescription('Unlinking your account will remove all roles granted by your Nexus Mods account and you will not be able to use all features of the bot anymore.') 31 | .setThumbnail(userData.NexusModsAvatar || null) 32 | .setFooter({ text: 'Discord Bot - Nexus Mods', iconURL: client?.user?.avatarURL() || '' })]; 33 | 34 | const unlinkButton = [new ActionRowBuilder() 35 | .addComponents( 36 | new ButtonBuilder() 37 | .setLabel('Unlink accounts') 38 | .setStyle(ButtonStyle.Link) 39 | .setURL(`https://discordbot.nexusmods.com/revoke?id=${discordId}`) 40 | )]; 41 | 42 | return interaction.editReply({ embeds: unlinkEmbed, components: unlinkButton }); 43 | 44 | } 45 | else { 46 | // Not linked! 47 | const notLinkedEmbed = [new EmbedBuilder() 48 | .setTitle('Unlink Nexus Mods account') 49 | .setColor(0xda8e35) 50 | .setDescription('Your account is not current linked.') 51 | .setFooter({ text: 'Discord Bot - Nexus Mods', iconURL: client?.user?.avatarURL() || '' })]; 52 | 53 | return interaction.editReply({ embeds: notLinkedEmbed }); 54 | 55 | } 56 | 57 | } 58 | 59 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/untrack.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommandInteraction, EmbedBuilder, SlashCommandBuilder, ChatInputCommandInteraction, 3 | PermissionFlagsBits, GuildChannel, APIEmbedField,ActionRowBuilder, StringSelectMenuBuilder, 4 | StringSelectMenuOptionBuilder, ComponentType, 5 | MessageFlags, 6 | InteractionContextType 7 | } from "discord.js"; 8 | import { ClientExt, DiscordInteraction } from '../types/DiscordTypes'; 9 | import { SubscribedItem, SubscribedItemType } from "../types/subscriptions"; 10 | import { deleteSubscribedChannel, deleteSubscription, getSubscribedChannel } from "../api/subscriptions"; 11 | import { Logger } from "../api/util"; 12 | 13 | const discordInteraction: DiscordInteraction = { 14 | command: new SlashCommandBuilder() 15 | .setName('untrack') 16 | .setContexts(InteractionContextType.Guild) 17 | .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) 18 | .setDescription('Untrack a game, mod, collection or user in this channel'), 19 | public: true, 20 | guilds: [], 21 | action 22 | }; 23 | 24 | async function action(client: ClientExt, baseInteraction: CommandInteraction, logger: Logger): Promise { 25 | const interaction = (baseInteraction as ChatInputCommandInteraction); 26 | await interaction.deferReply({ flags: MessageFlags.Ephemeral }).catch(err => { throw err }); 27 | 28 | const channel = interaction.channel; 29 | if (channel?.isThread() || channel?.isDMBased()) { 30 | return interaction.editReply('This command cannot be used in threads or DMs. Please use it in a channel.'); 31 | } 32 | 33 | // Check if we have a subbed channel 34 | const subbedChannel = await getSubscribedChannel(interaction.guildId!, interaction.channelId); 35 | if (!subbedChannel) return interaction.editReply(`No tracked items for this channel.`); 36 | // Get items 37 | const items = await subbedChannel.getSubscribedItems(); 38 | if (!items.length) return interaction.editReply('No tracked items') 39 | 40 | 41 | // Show the user a list of subscribed items 42 | const embed = new EmbedBuilder() 43 | .setTitle(`Tracked items in ${(interaction.channel as GuildChannel).name}`) 44 | .setDescription('Use the selector below to untrack an item.') 45 | .addFields(items.map(subscribedItemEmbedField)); 46 | 47 | const selector = new StringSelectMenuBuilder() 48 | .setCustomId('item-selector') 49 | .setPlaceholder('Select the item(s) to untrack...') 50 | .addOptions( 51 | items.map(s => 52 | new StringSelectMenuOptionBuilder() 53 | .setLabel(s.title) 54 | .setValue(s.id.toString()) 55 | .setDescription(`${s.type.toUpperCase()} - ID: ${s.id}`) 56 | ) 57 | ) 58 | .setMinValues(0) 59 | .setMaxValues(items.length); 60 | 61 | const actionRow = new ActionRowBuilder() 62 | .addComponents(selector); 63 | 64 | const reply = await interaction.editReply({ embeds: [embed], components: [actionRow] }); 65 | 66 | const collector = reply.createMessageComponentCollector({ time: 3_600_000 }); 67 | 68 | collector.on('collect', async i => { 69 | await i.deferUpdate(); 70 | collector.stop('Completed action'); 71 | await i.editReply({ components: [] }); 72 | const selected = i.values; 73 | if (!selected || !selected.length) return i.followUp('No subscriptions were deleted.'); 74 | const promises = selected.map(async id => { 75 | const sub = items.find(i => i.id === parseInt(id)); 76 | if (!sub) return logger.warn('Subscription not found to delete', { id }); 77 | await deleteSubscription(sub.id); 78 | logger.info('Deleted subscription', { id, title: sub?.title }); 79 | return interaction.followUp(`-# Deleted ${sub.type} subsription for ${sub.title}.`); 80 | }); 81 | await Promise.all(promises); 82 | // Refresh the subs for the channel 83 | const newSubs = await subbedChannel.getSubscribedItems(true); 84 | // if there are no more subs, delete the channel 85 | if (!newSubs.length) { 86 | await deleteSubscribedChannel(subbedChannel); 87 | // Update the Subscription Manager 88 | client.subscriptions?.removeChannel(subbedChannel.id); 89 | } 90 | else { 91 | // Update the Subscription Manager 92 | client.subscriptions?.updateChannel(subbedChannel); 93 | } 94 | return i.editReply({ content:`Untracked ${selected.length} item(s)` }); 95 | }); 96 | } 97 | 98 | function subscribedItemEmbedField(i: SubscribedItem): APIEmbedField { 99 | switch (i.type) { 100 | case SubscribedItemType.Game: { 101 | const s = i as SubscribedItem; 102 | return {name: `${i.title} (ID: ${i.id})`, value:`Show New: ${s.config.show_new} | Show Updates: ${s.config.show_updates}\nAdult Content: ${s.config.nsfw} | Non-adult Content: ${s.config.sfw}\nCompact: ${i.compact} | Crosspost: ${i.crosspost}`} 103 | }; 104 | case SubscribedItemType.Mod: return {name: `${i.title} (ID: ${i.id})`, value:`Compact: ${i.compact} | Crosspost: ${i.crosspost}`}; 105 | case SubscribedItemType.Collection: return {name: `${i.title} (ID: ${i.id})`, value:`Compact: ${i.compact} | Crosspost: ${i.crosspost}`}; 106 | case SubscribedItemType.User: return {name: `${i.title} (ID: ${i.id})`, value:`Compact: ${i.compact} | Crosspost: ${i.crosspost}`}; 107 | default: return {name: `${i.title} (ID: ${i.id})`, value:`Unknown item type ${i.type}`}; 108 | } 109 | } 110 | 111 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/user-profile.ts: -------------------------------------------------------------------------------- 1 | import { Client, Snowflake, EmbedBuilder, ContextMenuCommandInteraction, ContextMenuCommandBuilder, ApplicationCommandType, CommandInteraction, ContextMenuCommandType, MessageFlags } from "discord.js"; 2 | import { DiscordInteraction, ClientExt } from "../types/DiscordTypes"; 3 | import { getUserByDiscordId, userProfileEmbed, userEmbed } from '../api/bot-db'; 4 | import { KnownDiscordServers, Logger } from "../api/util"; 5 | import { NexusUser } from "../types/users"; 6 | import { DiscordBotUser } from "../api/DiscordBotUser"; 7 | 8 | const discordInteraction: DiscordInteraction = { 9 | command: new ContextMenuCommandBuilder() 10 | .setName('Profile - Nexus Mods') 11 | .setType(ApplicationCommandType.User as ContextMenuCommandType), 12 | public: true, 13 | guilds: [ KnownDiscordServers.BotDemo ], 14 | action 15 | } 16 | 17 | async function action(client: Client, baseinteraction: CommandInteraction, logger: Logger): Promise { 18 | const interaction = (baseinteraction as any as ContextMenuCommandInteraction); 19 | await interaction.deferReply( { flags: MessageFlags.Ephemeral }); 20 | const member = interaction.targetId; 21 | const guildMember = await interaction.guild?.members?.fetch(member).catch(() => undefined); 22 | if (!guildMember) return interaction.editReply('This user is no longer a member of this server.'); 23 | 24 | if (client.user?.id === interaction.targetId) return interaction.editReply({ content: 'That\'s me!', embeds: [await userEmbed(botUser(client), client)] }); 25 | 26 | try { 27 | const user: DiscordBotUser|undefined = await getUserByDiscordId(interaction.targetId); 28 | if (!user) return interaction.editReply('No matching linked accounts.'); 29 | const isAdmin: boolean = (client as ClientExt).config.ownerID?.includes(interaction.user.id); 30 | const inGuild: boolean = !!interaction.guild 31 | const isMe: boolean = interaction.user.id === user.DiscordId; 32 | if (isAdmin || isMe || inGuild) return interaction.editReply({ embeds: [await userProfileEmbed(user, client)] }); 33 | else { 34 | logger.info('Profile view not authorised', {requester: interaction.user.tag, target: user, isAdmin, isMe, inGuild}); 35 | return interaction.editReply({ embeds: [ notAllowed(client) ] }); 36 | }; 37 | } 38 | catch(err) { 39 | throw err; 40 | } 41 | 42 | } 43 | 44 | const botUser = (client: Client): NexusUser => { 45 | const d_id: Snowflake = client.user?.id ? client.user?.id.toString() as Snowflake : '' as Snowflake; 46 | const avatar_url = client.user?.avatarURL() || ''; 47 | return { 48 | d_id, 49 | id: 1234042, 50 | name: 'Nexus Mods Discord Bot', 51 | avatar_url, 52 | premium: false, 53 | supporter: false, 54 | lastupdate: new Date() 55 | } 56 | } 57 | 58 | const notAllowed = (client: Client): EmbedBuilder => { 59 | return new EmbedBuilder() 60 | .setTitle('⛔ Profile Unavailable') 61 | .setColor('#ff0000') 62 | .setDescription('The user you are looking for is not a member of this server.') 63 | .setFooter({ text: `Nexus Mods API Link`, iconURL: client.user?.avatarURL() || '' }); 64 | } 65 | 66 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/interactions/whois.ts: -------------------------------------------------------------------------------- 1 | import { DiscordInteraction, ClientExt } from "../types/DiscordTypes"; 2 | import { NexusUser } from "../types/users"; 3 | import { getAllUsers, getUserByDiscordId, userEmbed, userProfileEmbed } from '../api/bot-db'; 4 | import { Snowflake, EmbedBuilder, Client, User, CommandInteractionOption, ChatInputCommandInteraction, SlashCommandBuilder, CommandInteraction, MessageFlags, InteractionContextType } from "discord.js"; 5 | import { KnownDiscordServers, Logger } from "../api/util"; 6 | import { DiscordBotUser } from "../api/DiscordBotUser"; 7 | 8 | 9 | const discordInteraction: DiscordInteraction = { 10 | command: new SlashCommandBuilder() 11 | .setName('whois') 12 | .setDescription('Find another user\'s profile card by Nexus Mods name or Discord ID.') 13 | .setContexts(InteractionContextType.Guild) 14 | .addUserOption(option => 15 | option.setName('discord') 16 | .setDescription('Discord account') 17 | .setRequired(false) 18 | ) 19 | .addStringOption(option => 20 | option.setName('nexus') 21 | .setDescription('Nexus Mods account') 22 | .setRequired(false) 23 | ) 24 | .addBooleanOption(option => 25 | option.setName('private') 26 | .setDescription('Should the result only be shown to you?') 27 | .setRequired(false) 28 | ) as SlashCommandBuilder, 29 | public: true, 30 | guilds: [ 31 | KnownDiscordServers.BotDemo 32 | ], 33 | action 34 | } 35 | 36 | async function action(client: Client, baseInteraction: CommandInteraction, logger: Logger): Promise { 37 | const interaction = (baseInteraction as ChatInputCommandInteraction); 38 | // Private? 39 | const showValue : (CommandInteractionOption | null) = interaction.options.get('private'); 40 | const show: boolean = !!showValue ? (showValue.value as boolean) : true; 41 | 42 | // User Ping? 43 | const user : (User | null) = interaction.options.getUser('discord'); 44 | 45 | // Nexus search? 46 | const nexus : (string | null) = interaction.options.getString('nexus'); 47 | 48 | // Get sender info. 49 | const discordId: Snowflake | undefined = interaction.user.id; 50 | await interaction.deferReply({ephemeral: show}).catch(err => { throw err }); 51 | // Check if they are already linked. 52 | let userData : DiscordBotUser | undefined = discordId ? await getUserByDiscordId(discordId).catch(() => undefined) : undefined; 53 | 54 | if (!userData) { 55 | interaction.followUp({content: 'You need to link a Nexus Mods account to use this feature. See /link for more.', flags: MessageFlags.Ephemeral}); 56 | return; 57 | } 58 | 59 | if (!nexus && !user) { 60 | interaction.followUp({ content: 'You must provide a Discord user or Nexus Mods username.', flags: MessageFlags.Ephemeral}); 61 | return; 62 | } 63 | 64 | // If the bot has been pinged. 65 | if (user && user === client.user) { 66 | interaction.followUp({ content: 'That\'s me!', embeds:[await userEmbed(botUser(client), client)], flags: show ? MessageFlags.Ephemeral : undefined }) 67 | .catch(err => console.warn('Failed to send bot info for whois slash command', err)); 68 | return; 69 | } 70 | 71 | try { 72 | const allUsers: NexusUser[] = await getAllUsers().catch(() => []); 73 | let foundUser: NexusUser | undefined; 74 | if (user) { 75 | foundUser = allUsers.find(u => u.d_id === user.id); 76 | } 77 | else if (nexus) { 78 | foundUser = allUsers.find(u => u.name.toLowerCase() === nexus.toLowerCase()); 79 | } 80 | 81 | if (!foundUser) interaction.followUp({content: 'No members found for your query.', ephemeral: true}); 82 | else { 83 | const botUser = new DiscordBotUser(foundUser, logger); 84 | // check if we should return the result. If the found user isn't in the current server, reject the request. 85 | const isAdmin: boolean = (client as ClientExt).config.ownerID?.includes(interaction.user.id); 86 | const isMe: boolean = interaction.user.id === botUser.DiscordId; 87 | const inGuild: boolean = !!interaction.guild //!!foundServers.find(link => link.server_id === interaction.guild?.id); 88 | if (isAdmin || isMe || inGuild) interaction.followUp({ embeds: [await userProfileEmbed(botUser, client)], ephemeral: show }); 89 | else { 90 | logger.info('Whois not authorised', {requester: userData, target: botUser, isAdmin, isMe, inGuild}); 91 | interaction.followUp({ embeds: [ notAllowed(client) ], flags: MessageFlags.Ephemeral }); 92 | }; 93 | } 94 | 95 | } 96 | catch (err) { 97 | interaction.followUp({ content: 'Error looking up users.', flags: MessageFlags.Ephemeral}); 98 | logger.warn('Error looking up users from slash command', err); 99 | return; 100 | } 101 | 102 | 103 | } 104 | 105 | const botUser = (client: Client): NexusUser => { 106 | const d_id: Snowflake = client.user?.id ? client.user?.id.toString() as Snowflake : '' as Snowflake; 107 | const avatar_url = client.user?.avatarURL() || ''; 108 | return { 109 | d_id, 110 | id: 1234042, 111 | name: 'Nexus Mods Discord Bot', 112 | avatar_url, 113 | premium: false, 114 | supporter: false, 115 | lastupdate: new Date() 116 | } 117 | } 118 | 119 | const notAllowed = (client: Client): EmbedBuilder => { 120 | return new EmbedBuilder() 121 | .setTitle('⛔ Profile Unavailable') 122 | .setColor('#ff0000') 123 | .setDescription('The user you are looking for is not a member of this server.') 124 | .setFooter({ text: `Nexus Mods API Link`, iconURL: client.user?.avatarURL() || '' }); 125 | } 126 | 127 | export { discordInteraction }; -------------------------------------------------------------------------------- /src/server/NexusModsOAuth.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import { baseheader, Logger } from '../api/util'; 3 | import { findUser } from '../api/queries/v2-finduser'; 4 | 5 | interface OAuthURL { 6 | url: string; 7 | state: string; 8 | } 9 | 10 | interface OAuthTokens { 11 | access_token: string; 12 | refresh_token: string; 13 | expires_at: number; 14 | token_type?: string; 15 | expires_in?: number; 16 | scope?: string; 17 | } 18 | 19 | interface NexusOAuthTokens extends OAuthTokens { 20 | created_at?: number; 21 | id_token?: string; 22 | } 23 | 24 | type NexusMembershipRoles = 'member' | 'supporter' | 'premium' | 'lifetimepremium' | 'modauthor'; 25 | 26 | interface NexusUserData { 27 | sub: string; 28 | name: string; 29 | email: string; 30 | avatar: string; 31 | group_id: number; 32 | membership_roles: NexusMembershipRoles[]; 33 | } 34 | 35 | export function getOAuthUrl(sharedState: string, logger: Logger): OAuthURL { 36 | 37 | const state = sharedState ?? crypto.randomUUID(); 38 | 39 | const { NEXUS_OAUTH_ID, NEXUS_REDIRECT_URI } = process.env; 40 | if (!NEXUS_OAUTH_ID || !NEXUS_REDIRECT_URI) { 41 | logger.warn('Could not generate Nexus Mods OAUTH URL', { NEXUS_OAUTH_ID, NEXUS_REDIRECT_URI }); 42 | return { url: '/oauth-error', state }; 43 | }; 44 | 45 | const url = new URL('https://users.nexusmods.com/oauth/authorize'); 46 | url.searchParams.set('client_id', NEXUS_OAUTH_ID); 47 | url.searchParams.set('redirect_uri', NEXUS_REDIRECT_URI); 48 | url.searchParams.set('response_type', 'code'); 49 | url.searchParams.set('state', state); 50 | url.searchParams.set('scope', 'openid email profile'); 51 | // url.searchParams.set('approval_prompt', 'auto'); // Skips the auth prompt? 52 | return { state, url: url.toString() }; 53 | } 54 | 55 | export async function getOAuthTokens(code: string): Promise { 56 | 57 | const { NEXUS_OAUTH_ID, NEXUS_OAUTH_SECRET, NEXUS_REDIRECT_URI } = process.env; 58 | if (!NEXUS_OAUTH_ID || !NEXUS_REDIRECT_URI || !NEXUS_OAUTH_SECRET) throw new Error('Cannot get Nexus Mods OAuth Tokens, ENVARs invalid'); 59 | 60 | const url = 'https://users.nexusmods.com/oauth/token'; 61 | const body = new URLSearchParams({ 62 | client_id: NEXUS_OAUTH_ID, 63 | client_secret: NEXUS_OAUTH_SECRET, 64 | grant_type: 'authorization_code', 65 | code, 66 | redirect_uri: NEXUS_REDIRECT_URI, 67 | }); 68 | 69 | const response = await fetch(url, { 70 | body, 71 | method: 'POST', 72 | headers: { 73 | 'Content-Type': 'application/x-www-form-urlencoded', 74 | }, 75 | }); 76 | if (response.ok) { 77 | const data = await response.json(); 78 | data.expires_at = Date.now() + (data.expires_in * 1000); 79 | return data; 80 | } else { 81 | throw new Error(`Error fetching Nexus Mods OAuth tokens: [${response.status}] ${response.statusText}`); 82 | } 83 | } 84 | 85 | export async function getUserData(tokens: NexusOAuthTokens, logger: Logger): Promise { 86 | const url = 'https://users.nexusmods.com/oauth/userinfo'; 87 | const response = await fetch(url, { 88 | headers: { 89 | Authorization: `Bearer ${tokens.access_token}`, 90 | }, 91 | }); 92 | if (response.ok) { 93 | const data = await response.json(); 94 | let modAuthor = false; 95 | try { 96 | const user = await findUser(baseheader, logger,parseInt(data.sub)); 97 | modAuthor = user?.recognizedAuthor ?? false; 98 | } 99 | catch(err) { 100 | logger.warn('Error fetching user data', { error: (err as Error).message, userId: data.sub }, true); 101 | } 102 | if (modAuthor === true) data.membership_roles?.push('modauthor'); 103 | return data; 104 | } else { 105 | throw new Error(`Error fetching Nexus Mods user data: [${response.status}] ${response.statusText}`); 106 | } 107 | } 108 | 109 | export async function getAccessToken(tokens: OAuthTokens): Promise { 110 | const { NEXUS_OAUTH_ID, NEXUS_OAUTH_SECRET } = process.env; 111 | 112 | if (!NEXUS_OAUTH_ID || !NEXUS_OAUTH_SECRET) throw new Error('Error getting Discord access token, ENV VARS are undefined.'); 113 | 114 | // logMessage('CHECKING NEXUS MODS ACCESS TOKENS', { expires: new Date((tokens.expires_at)), timestamp: tokens.expires_at}); 115 | 116 | // Tokens are valid for 6 hours from the point they are issued. 117 | if (Date.now() > tokens.expires_at) { 118 | // logMessage('RENEWING NEXUS MODS ACCESS TOKENS', { expires: new Date((tokens.expires_at)) }); 119 | const url = 'https://users.nexusmods.com/oauth/token'; 120 | const body = new URLSearchParams({ 121 | client_id: NEXUS_OAUTH_ID, 122 | client_secret: NEXUS_OAUTH_SECRET, 123 | grant_type: 'refresh_token', 124 | refresh_token: tokens.refresh_token, 125 | }); 126 | const response = await fetch(url, { 127 | body, 128 | method: 'POST', 129 | headers: { 130 | 'Content-Type': 'application/x-www-form-urlencoded', 131 | }, 132 | }); 133 | if (response.ok) { 134 | const tokens = await response.json(); 135 | tokens.access_token = tokens.access_token; 136 | tokens.expires_at = Date.now() + (tokens.expires_in * 1000); 137 | return tokens; 138 | } else { 139 | const err: any = new Error(`Could not refresh Nexus Mods access token: [${response.status}] ${response.statusText}`); 140 | err.code = response.status; 141 | throw err; 142 | } 143 | } 144 | // logMessage('Tokens are still valid', { expires: new Date((tokens.expires_at)) }); 145 | return tokens; 146 | } 147 | 148 | // Revoke tokens 149 | export async function revoke(tokens: OAuthTokens): Promise { 150 | const { NEXUS_OAUTH_ID, NEXUS_OAUTH_SECRET } = process.env; 151 | 152 | if (!NEXUS_OAUTH_ID || !NEXUS_OAUTH_SECRET) throw new Error('Bot environment variables are not configured properly.'); 153 | 154 | const url = 'https://users.nexusmods.com/oauth/revoke'; 155 | const body = new URLSearchParams({ 156 | client_id: NEXUS_OAUTH_ID, 157 | client_secret: NEXUS_OAUTH_SECRET, 158 | token: tokens.refresh_token, 159 | }); 160 | 161 | const response = await fetch(url, { 162 | body, 163 | method: 'POST', 164 | headers: { 165 | 'Content-Type': 'application/x-www-form-urlencoded', 166 | }, 167 | }); 168 | if (response.ok) { 169 | const data = await response.json(); 170 | data.expires_at = Date.now() + (data.expires_in * 1000); 171 | return data; 172 | } else { 173 | throw new Error(`Error revoking Neuxs Mods OAuth tokens: [${response.status}] ${response.statusText}`); 174 | } 175 | } -------------------------------------------------------------------------------- /src/server/public/css/styles.css: -------------------------------------------------------------------------------- 1 | body{ 2 | background:#2b2d2f; 3 | padding:0; 4 | margin:0; 5 | min-height:100vh; 6 | display:flex; 7 | flex-direction:column 8 | } 9 | header{ 10 | height:56px; 11 | background:#101010; 12 | display:flex; 13 | justify-content:center; 14 | align-items:center; 15 | flex-grow:0 16 | } 17 | main{ 18 | font-family:Montserrat,Helvetica,sans-serif; 19 | text-align:left; 20 | color:#fff; 21 | padding-top:1.25rem; 22 | padding-left:1rem; 23 | padding-right:1rem; 24 | margin-left:auto; 25 | margin-right:auto; 26 | flex-grow:1 27 | } 28 | footer{ 29 | flex-grow:0; 30 | display:flex; 31 | flex-direction:column; 32 | align-items:center; 33 | padding-bottom:2.5rem; 34 | padding-top:4.5rem; 35 | font-family:Roboto,Helvetica,sans-serif; 36 | font-size:.75rem; 37 | color:#aaa 38 | } 39 | .maintenance-page__content{ 40 | max-width:900px 41 | } 42 | .maintenance-page__title{ 43 | font-weight:700; 44 | text-transform:uppercase; 45 | font-size:2rem; 46 | letter-spacing:.8px 47 | } 48 | .maintenance-page__main-image{ 49 | max-width:385px; 50 | width:100% 51 | } 52 | .maintenance-page__subtext{ 53 | font-family:Roboto; 54 | font-size:1.125rem; 55 | line-height:1.5; 56 | font-weight:400; 57 | margin-top:2rem; 58 | margin-bottom:2rem 59 | } 60 | .maintenance-page__subtext a { 61 | color: #d98f40; 62 | } 63 | .maintenance-page__supertext{ 64 | color:#aaa; 65 | font-size:.875rem; 66 | line-height:1.5; 67 | font-family:Roboto; 68 | font-weight:400 69 | } 70 | .maintenance-page__text{ 71 | font-size:.875rem; 72 | line-height:1.5; 73 | font-family:Roboto; 74 | font-weight:400; 75 | margin-top:2rem; 76 | margin-bottom:2rem 77 | } 78 | .maintenance-page__text a { 79 | color: #d98f40; 80 | } 81 | a{ 82 | text-decoration:none 83 | } 84 | .maintenance-page__social-links{ 85 | margin-bottom:1.5rem 86 | } 87 | .maintenance-page__social-links>*+*{ 88 | margin-left:1rem 89 | } 90 | .maintenance-page__social-links img{ 91 | opacity:.6 92 | } 93 | .maintenance-page__social-links a:hover img{ 94 | opacity:1 95 | } 96 | .maintenance-page__centered-content{ 97 | display:flex; 98 | justify-content:center 99 | } 100 | .maintenance-page__discord-button{ 101 | background:0 0; 102 | border:1px solid #d98f40; 103 | border-radius:4px; 104 | color:#fff; 105 | font-family:Montserrat; 106 | font-weight:600; 107 | text-transform:uppercase; 108 | letter-spacing:.7px; 109 | font-size:.875rem; 110 | display:flex; 111 | justify-content:space-between; 112 | align-items:center; 113 | gap:10px; 114 | padding:0 16px; 115 | height:36px 116 | } 117 | .maintenance-page__discord-button:hover{ 118 | background:#3a413f 119 | } 120 | .maintenance-page__discord-button:focus{ 121 | background:#3a413f; 122 | outline:2px solid #388ffa; 123 | outline-offset:2px 124 | } 125 | .maintenance-page__discord-button>img{ 126 | width:1.25rem 127 | } 128 | @media (min-width:768px){ 129 | main{ 130 | padding-top:2.5rem; 131 | padding-left:3rem; 132 | padding-right:3rem 133 | } 134 | .maintenance-page__title{ 135 | font-size:2.5rem; 136 | line-height:3rem; 137 | font-weight:500; 138 | letter-spacing:unset 139 | } 140 | .maintenance-page__main-image{ 141 | max-width:510px 142 | } 143 | .maintenance-page__subtext{ 144 | font-family:Montserrat; 145 | font-size:1.125rem; 146 | line-height:1.375; 147 | font-weight:600 148 | } 149 | .maintenance-page__supertext{ 150 | font-size:1.125rem 151 | } 152 | .maintenance-page__text{ 153 | font-size:1.125rem 154 | } 155 | } 156 | @media (min-width:1000px){ 157 | .maintenance-page__content{ 158 | max-width:1100px 159 | } 160 | .maintenance-page__title{ 161 | font-size:3rem; 162 | line-height:3.5rem; 163 | font-weight:600; 164 | letter-spacing:-1.2px 165 | } 166 | .maintenance-page__main-image{ 167 | max-width:858px 168 | } 169 | } 170 | 171 | /* General Table Styling */ 172 | table { 173 | width: 100%; 174 | border-collapse: collapse; 175 | background-color: #1d1f21; /* Dark background color for the table */ 176 | color: #e4e4e4; /* Light gray text color */ 177 | font-family: Arial, sans-serif; 178 | } 179 | 180 | /* Table Header Styling */ 181 | th { 182 | background-color: #2d2f32; /* Slightly lighter dark background for header */ 183 | color: #55b8e4; /* Nexus Mods accent color - light blue */ 184 | padding: 12px; 185 | text-align: left; 186 | font-weight: bold; 187 | text-transform: uppercase; 188 | } 189 | 190 | /* Table Body Styling */ 191 | td { 192 | background-color: #2d2f32; /* Same as header background for consistency */ 193 | padding: 10px; 194 | border-bottom: 1px solid #444849; /* Slightly darker border for rows */ 195 | } 196 | 197 | /* Hover Effect for Table Rows */ 198 | tr:hover { 199 | background-color: #3a3d41; /* Darker background color when hovering over rows */ 200 | } 201 | 202 | /* Placeholder Styling when No Items */ 203 | .placeholder { 204 | text-align: center; 205 | color: #aaa; /* Light gray color */ 206 | padding: 10px; 207 | background-color: #2d2f32; 208 | font-style: italic; 209 | } 210 | 211 | /* Optional: Add some spacing around the table */ 212 | table { 213 | margin: 20px 0; 214 | } -------------------------------------------------------------------------------- /src/server/public/images/Discord.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/server/public/images/github-mark-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/server/public/images/readme.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/server/public/images/retry.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/server/public/images/standby.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nexus-Mods/discord-bot/5a8d738a484e876deff82d5947f671ccf2d1c29b/src/server/public/images/standby.gif -------------------------------------------------------------------------------- /src/server/public/images/success.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nexus-Mods/discord-bot/5a8d738a484e876deff82d5947f671ccf2d1c29b/src/server/public/images/success.gif -------------------------------------------------------------------------------- /src/server/public/images/unlink.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nexus-Mods/discord-bot/5a8d738a484e876deff82d5947f671ccf2d1c29b/src/server/public/images/unlink.gif -------------------------------------------------------------------------------- /src/server/public/images/unlinkerror.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nexus-Mods/discord-bot/5a8d738a484e876deff82d5947f671ccf2d1c29b/src/server/public/images/unlinkerror.gif -------------------------------------------------------------------------------- /src/server/views/content.ejs: -------------------------------------------------------------------------------- 1 |
2 |

Discord bot is online.

3 |

<%=timestamp%>

4 | 14 | 24 |

Version: <% if (locals?.version) { %> <%=locals.version %><% } %> | Uptime: <% if (locals?.upTime) { %> <%=locals.upTime %><% } %>

25 |
-------------------------------------------------------------------------------- /src/server/views/errormessage.ejs: -------------------------------------------------------------------------------- 1 |
2 |

Authentication Failed

3 |

4 | Drat! And double drat! An error occured while attempting to link your Nexus Mods and Discord accounts. This could be a temporary issue or something caused by your browser setup. 5 |

6 |
7 | Please stand by 8 |
9 |

10 | You can give it another try or report the issue in our Discord Server, including the error details below. 11 |

12 | 18 |

Error details:

<%= error %>

19 |
-------------------------------------------------------------------------------- /src/server/views/footer.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Copyright © 2023 Black Tree Gaming Ltd. All rights reserved.

4 | 5 | -------------------------------------------------------------------------------- /src/server/views/index.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs') %> 2 | <%- include('content.ejs') %> 3 | <%- include('footer.ejs') %> -------------------------------------------------------------------------------- /src/server/views/linkconfirm.ejs: -------------------------------------------------------------------------------- 1 |
2 |

Success!

3 |

4 | @<%=discord%> has been successfully linked to <%=nexus%>. 5 |

6 |
7 | An animated image showing Dutch and Dillon performing an epic handshake in the film Predator 8 |
9 |

Use the "Linked Roles" option in servers with the Nexus Mods Discord bot to claim your roles.

10 | 16 |
-------------------------------------------------------------------------------- /src/server/views/linkerror.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs') %> 2 | <%- include('errormessage.ejs') %> 3 | <%- include('footer.ejs') %> -------------------------------------------------------------------------------- /src/server/views/revoked.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs') %> 2 | <%- include('revokesuccess.ejs') %> 3 | <%- include('footer.ejs') %> -------------------------------------------------------------------------------- /src/server/views/revokesuccess.ejs: -------------------------------------------------------------------------------- 1 |
2 |

Unlink Complete

3 |

4 | Your Discord and Nexus Mods accounts are no longer linked. 5 |

6 |
7 | An animated image of a cowboy toy called Woody saying 'So long partner' the film Toy Story 3 8 |
9 |

All linked roles will be automatically removed from your account.

10 | 16 |
-------------------------------------------------------------------------------- /src/server/views/success.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs') %> 2 | <%- include('linkconfirm.ejs') %> 3 | <%- include('footer.ejs') %> -------------------------------------------------------------------------------- /src/server/views/tracking.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs') %> 2 | <%- include('trackingInfo.ejs') %> 3 | <%- include('footer.ejs') %> -------------------------------------------------------------------------------- /src/server/views/trackingInfo.ejs: -------------------------------------------------------------------------------- 1 |
2 |

Tracking Summary

3 |
4 |
5 | 6 |
7 |
8 | Summary for <%=guild%> 9 |
10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | <% if (subs.length === 0) { %> 24 | 25 | 26 | 27 | <% } else { %> 28 | <% subs.forEach(function(sub) { %> 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | <% }); %> 38 | <% } %> 39 | 40 |
IDTypeChannelNameEntity IDLast Update
No subscriptions
<%= sub.id %><%= sub.type.toUpperCase() %>#<%= sub.channelName %><%= sub.title %><%= sub.entityid %><%= timeAgo(sub.last_update) %>
41 | 42 |
-------------------------------------------------------------------------------- /src/server/views/unlinkerror.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs') %> 2 | <%- include('unlinkerrormessage.ejs') %> 3 | <%- include('footer.ejs') %> -------------------------------------------------------------------------------- /src/server/views/unlinkerrormessage.ejs: -------------------------------------------------------------------------------- 1 |
2 |

Unlinked Failed

3 |

4 | Well, this is unexpected. An error occurred when attempting to remove the link between your accounts. This could be a temporary issue or something caused by your browser setup

5 |
6 | A monkey hitting a computer in frustration 7 |
8 |

9 | You can give it another try or report the issue in our Discord Server, including the error details below. 10 |

11 | 17 |

Error details:

<%= error %>

18 |
-------------------------------------------------------------------------------- /src/shards.ts: -------------------------------------------------------------------------------- 1 | import { ShardingManager } from 'discord.js'; 2 | import dotenv from 'dotenv'; 3 | import * as dbMigrations from './api/migrations'; 4 | dotenv.config(); 5 | 6 | const manager = new ShardingManager('./dist/app.js', { 7 | token: process.env.DISCORD_TOKEN, // Bot token 8 | totalShards: process.env.NODE_ENV === 'testing' ? 2 : 'auto', // Automatically determine the number of shards 9 | }); 10 | 11 | manager.on('shardCreate', (shard) => { 12 | console.log(`[Shard ${shard.id}] Launched shard ${shard.id + 1}/${manager.totalShards}`); 13 | shard.on('death', () => console.log(`[Shard ${shard.id}] Shard ${shard.id} died`, true)); 14 | shard.on('disconnect', () => console.warn(`[Shard ${shard.id}] Shard ${shard.id} disconnected`)); 15 | shard.on('reconnecting', () => console.log(`[Shard ${shard.id}] Shard ${shard.id} reconnecting`)); 16 | }); 17 | 18 | async function start() { 19 | // Run migrations 20 | const version = process.env.npm_package_version; 21 | try { 22 | if (version === '3.13.0') await dbMigrations.migrationDeleteAPIkeyColumn(); 23 | if (version === '3.13.1') await dbMigrations.migrationMoveConfigOptionsToJSON(); 24 | } 25 | catch(err) { 26 | console.error('Failed to run database migrations', err); 27 | } 28 | 29 | manager.spawn(); // Spawn the shards 30 | } 31 | 32 | start(); 33 | -------------------------------------------------------------------------------- /src/types/DiscordTypes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Snowflake, CommandInteraction, Collection, 3 | Client, ContextMenuCommandBuilder, SlashCommandBuilder, AutocompleteInteraction, 4 | } from "discord.js"; 5 | import { NewsFeedManager } from "../feeds/NewsFeedManager"; 6 | import { GameListCache, TipCache } from "./util"; 7 | import { AutoModManager } from "../feeds/AutoModManager"; 8 | import { SubscriptionManger } from "../feeds/SubscriptionManager"; 9 | import { Logger } from "../api/util"; 10 | 11 | interface ClientExt extends Client { 12 | config?: any; 13 | commands?: Collection; 14 | interactions?: Collection; 15 | newsFeed?: NewsFeedManager; 16 | automod?: AutoModManager; 17 | subscriptions?: SubscriptionManger; 18 | updateInteractions?: (force?: boolean) => Promise 19 | gamesList?: GameListCache; 20 | tipCache?: TipCache; 21 | } 22 | 23 | interface DiscordEventInterface { 24 | name: string; 25 | once: boolean; 26 | execute: (client: Client, logger: Logger, ...args: any) => Promise | void; 27 | } 28 | 29 | interface DiscordInteraction { 30 | command: SlashCommandBuilder | ContextMenuCommandBuilder; 31 | action: (client: Client, interact: CommandInteraction, logger: Logger) => Promise; 32 | public: boolean; 33 | guilds?: Snowflake[]; 34 | permissions?: PermissionsExt[]; 35 | // Optional to add aliases 36 | aliases?: string[]; 37 | // Optional for autocomplete commands 38 | autocomplete?: (client: Client, interact: AutocompleteInteraction, logger: Logger) => Promise, 39 | } 40 | 41 | interface PermissionsExt { 42 | guild?: Snowflake; 43 | id: Snowflake; 44 | type: 'USER' | 'ROLE'; 45 | permission: boolean; 46 | } 47 | 48 | export { DiscordEventInterface, DiscordInteraction, ClientExt }; -------------------------------------------------------------------------------- /src/types/ForumWebhookTypes.ts: -------------------------------------------------------------------------------- 1 | export interface ForumTopic { 2 | id: number; 3 | title: string; 4 | forum: { 5 | id: number; 6 | name: string; 7 | path: string; 8 | type: string; 9 | topics: number; 10 | url: string; 11 | parentId: number; 12 | permissions: Record; 13 | club: number; 14 | }; 15 | posts: number; 16 | views: number; 17 | prefix: string | null; 18 | tags: string[]; 19 | firstPost: ForumPost; 20 | lastPost: ForumPost; 21 | bestAnswer: any | null; 22 | locked: boolean; 23 | hidden: boolean; 24 | pinned: boolean; 25 | featured: boolean; 26 | archived: boolean; 27 | poll: any | null; 28 | url: string; 29 | rating: number; 30 | is_future_entry: number; 31 | publish_date: string; 32 | } 33 | 34 | export interface ForumPost { 35 | id: number; 36 | item_id: number; 37 | author: ForumAuthor; 38 | date: string; 39 | content: string; 40 | hidden: boolean; 41 | url: string; 42 | reactions: any[]; 43 | } 44 | 45 | interface ForumAuthor { 46 | id: number; 47 | name: string; 48 | title: string | null; 49 | timeZone: string; 50 | formattedName: string; 51 | primaryGroup: Record; 52 | secondaryGroups: Record[]; 53 | email: string; 54 | joined: string; 55 | registrationIpAddress: string; 56 | warningPoints: number; 57 | reputationPoints: number; 58 | photoUrl: string; 59 | photoUrlIsDefault: boolean; 60 | coverPhotoUrl: string; 61 | profileUrl: string; 62 | validating: boolean; 63 | posts: number; 64 | lastActivity: string; 65 | lastVisit: string; 66 | lastPost: string; 67 | birthday: string | null; 68 | profileViews: number; 69 | customFields: Record; 70 | rank: Record; 71 | achievements_points: number; 72 | allowAdminEmails: boolean; 73 | completed: boolean; 74 | } -------------------------------------------------------------------------------- /src/types/GQLTypes.ts: -------------------------------------------------------------------------------- 1 | import { GuildMember } from "discord.js"; 2 | 3 | type ID = number | string | bigint; 4 | export enum ModStatus { 5 | Moderated = 'under_moderation', 6 | Published = 'published', 7 | Draft = 'not_published', 8 | AwaitingGameApproval = 'publish_with_game', 9 | RemovedByAuthor = 'removed', 10 | RemovedByModerator = 'wastebinned', 11 | Hidden = 'hidden' 12 | } 13 | 14 | 15 | interface Mod { 16 | adult: boolean; 17 | author: string; 18 | // Possibly redundant? 19 | category: string; 20 | createdAt: string; 21 | description: string; 22 | game: Partial; 23 | gameId: number; 24 | id: number; 25 | ipAddress?: string; 26 | modCategory: Partial; 27 | modId: number; 28 | name: string; 29 | pictureUrl: string; 30 | status: ModStatus; 31 | summary: string; 32 | trackingData?: TrackingState; 33 | uid: ID; 34 | updatedAt: string; 35 | uploader: Partial; 36 | version: string; 37 | } 38 | 39 | interface FeedMod extends Mod { 40 | // Used on Gamefeeds where we append the Discord account to the object 41 | authorDiscord?: GuildMember | null; 42 | // Add the latest file update time from the v1 API response. 43 | lastFileUpdate?: number; 44 | } 45 | 46 | interface ModCategory { 47 | date: number; 48 | gameId: number; 49 | id: ID; 50 | name: string; 51 | tags: string; 52 | } 53 | 54 | interface Game { 55 | availableTags: Partial[]; 56 | collectionCount: number; 57 | domainName: string; 58 | id: number; 59 | name: string; 60 | specificTags: Partial[]; 61 | } 62 | 63 | interface User { 64 | avatar: string; 65 | deleted: boolean; 66 | dpOptedIn: boolean; 67 | email: string; 68 | ipAddress: string; 69 | kudos: number; 70 | memberId: number; 71 | name: string; 72 | paypal: string; 73 | posts: number; 74 | recognizedAuthor: boolean; 75 | } 76 | 77 | interface Tag { 78 | adult: boolean; 79 | category: TagCategory; 80 | createdAt: string; 81 | discardedAt: Date; 82 | games: Partial[]; 83 | global: boolean; 84 | id: ID; 85 | name: string; 86 | taggablesCount: number; 87 | updatedAt: string; 88 | } 89 | 90 | interface TagCategory { 91 | createdAt: Date; 92 | discardedAt: Date; 93 | id: ID; 94 | name: string; 95 | tags: Partial[]; 96 | updatedAt: string; 97 | } 98 | 99 | interface TrackingState { 100 | test: number; 101 | } 102 | 103 | /** COLLECTIONS */ 104 | type CollectionStatus = 'listed' | 'unlisted' | 'under_moderation'; 105 | 106 | interface Collection { 107 | adultContent: boolean; 108 | allowUserMedia: boolean; 109 | bugReport: any; 110 | bugReports: any; 111 | category: any; 112 | collectionChangelogs: any[]; 113 | collectionStatus: CollectionStatus; 114 | commentLink: string; 115 | comments: any[]; 116 | contentPreviewLink: string; 117 | createdAt: Date; 118 | currentRevision: any; 119 | description: string; 120 | discardReason: any; 121 | discardedAt: boolean; //??? 122 | downloadLink: string; 123 | draftRevisionNumber: number; 124 | enableDonations: boolean; 125 | endorsements: number; 126 | forumTopic: any; 127 | game: Game; 128 | gameId: number; 129 | headerImage: any; 130 | id: number; 131 | latestPublishedRevision: any; 132 | latestPublishedRevisionRating: any; 133 | listedAt: Date; 134 | manuallyVerifyMedia: boolean; 135 | media: any[]; 136 | metadata: any; 137 | moderations: any[]; 138 | name: string; 139 | overallRating: string; 140 | overallRatingCount: number; 141 | permissions: any[]; 142 | publicRevisions: any[]; 143 | publishedAt: Date; 144 | recentRating: string; 145 | recentRatingCount: number; 146 | revisions: any[]; 147 | slug: string; 148 | summary: string; 149 | tags: any[]; 150 | tileImage: any; 151 | totalDownloads: number; 152 | uniqueDownloads: number; 153 | updatedAt: Date; 154 | user: User; 155 | userId: number; 156 | } 157 | 158 | /** COLLECTIONS SEARCH AND FILTERING */ 159 | 160 | type FilterComparisonOperator = 'EQUALS' | 'NOT_EQUALS' | 'MATCHES' | 'WILDCARD' | 'GT' | 'GTE' | 'LT' | 'LTE'; 161 | export type FilterLogicalOperator = 'AND' | 'OR'; 162 | 163 | export interface BaseSortValue { 164 | direction: 'ASC' | 'DESC' 165 | } 166 | 167 | export interface BaseFilterValue { 168 | value: string; 169 | op: FilterComparisonOperator; 170 | } 171 | 172 | export interface BooleanFilterValue { 173 | value: boolean; 174 | op: FilterComparisonOperator; 175 | } 176 | 177 | export interface IntFilterValue { 178 | value: number; 179 | op: FilterComparisonOperator; 180 | } 181 | 182 | interface CollectionsSort { 183 | relevance?: BaseSortValue; 184 | createdAt?: BaseSortValue; 185 | updatedAt?: BaseSortValue; 186 | endorsements?: BaseSortValue; 187 | downloads?: BaseSortValue; 188 | rating?: BaseSortValue; 189 | } 190 | 191 | interface ICollectionsFilter { 192 | filter?: ICollectionsFilter[]; 193 | op?: FilterLogicalOperator; 194 | userId?: BaseFilterValue; 195 | name?: BaseFilterValue; 196 | collectionRating?: BaseFilterValue; 197 | createdAt?: BaseFilterValue; 198 | updatedAt?: BaseFilterValue; 199 | collectionStatus?: BaseFilterValue; 200 | gameId?: BaseFilterValue; 201 | gameDomain?: BaseFilterValue; 202 | gameName?: BaseFilterValue; 203 | categoryId?: BaseFilterValue; 204 | categoryName?: BaseFilterValue; 205 | gameVersion?: BaseFilterValue; 206 | modUid?: BaseFilterValue; 207 | modName?: BaseFilterValue; 208 | tag?: BaseFilterValue; 209 | adultContent?: BooleanFilterValue; 210 | hasDraftRevision?: BooleanFilterValue; 211 | hasPublishedRevision?: BooleanFilterValue; 212 | generalSearch?: BaseFilterValue; 213 | } 214 | 215 | interface CollectionPage { 216 | nodes?: Collection[]; 217 | nodesAggregations?: any[]; 218 | nodesCount?: number; 219 | nodesFacets?: any[]; 220 | nodesFilter?: string; 221 | nextURL?: string; //URL to browser the results on the website. 222 | } 223 | 224 | export { ICollectionsFilter, CollectionsSort }; -------------------------------------------------------------------------------- /src/types/NexusModsAPIv1.ts: -------------------------------------------------------------------------------- 1 | export interface IChangelogs { 2 | [versionNumber: string]: string[]; 3 | } 4 | 5 | export interface IUpdateEntry { 6 | mod_id: number; 7 | latest_file_update: number; 8 | latest_mod_activity: number; 9 | } 10 | 11 | type EndorsedStatus = 'Undecided' | 'Abstained' | 'Endorsed'; 12 | type ModStatus = 'under_moderation' | 'published' | 'not_published' | 'publish_with_game' | 'removed' | 'wastebinned' | 'hidden'; 13 | export interface IModInfo { 14 | mod_id: number; 15 | game_id: number; 16 | domain_name: string; 17 | category_id: number; 18 | contains_adult_content: boolean; 19 | name?: string; 20 | summary?: string; 21 | description?: string; 22 | version: string; 23 | author: string; 24 | user: IUser; 25 | uploaded_by: string; 26 | uploaded_users_profile_url: string; 27 | status: ModStatus; 28 | available: boolean; 29 | picture_url?: string; 30 | created_timestamp: number; 31 | created_time: string; 32 | updated_timestamp: number; 33 | updated_time: string; 34 | allow_rating: boolean; 35 | endorsement_count: number; 36 | mod_downloads: number; 37 | mod_unique_downloads: number; 38 | endorsement?: { 39 | endorse_status: EndorsedStatus; 40 | timestamp: number; 41 | version: number; 42 | }; 43 | } 44 | 45 | interface IFileInfo { 46 | file_id: number; 47 | category_id: number; 48 | category_name: string; 49 | changelog_html: string; 50 | content_preview_link: string; 51 | name: string; 52 | description: string; 53 | version: string; 54 | size: number; 55 | size_kb: number; 56 | file_name: string; 57 | uploaded_timestamp: number; 58 | uploaded_time: string; 59 | mod_version: string; 60 | external_virus_scan_url: string; 61 | is_primary: boolean; 62 | } 63 | export interface IModFiles { 64 | file_updates: IFileUpdate[]; 65 | files: IFileInfo[]; 66 | } 67 | interface IFileUpdate { 68 | new_file_id: number; 69 | new_file_name: string; 70 | old_file_id: number; 71 | old_file_name: string; 72 | uploaded_time: string; 73 | uploaded_timestamp: number; 74 | } 75 | 76 | interface ICategory { 77 | category_id: number; 78 | name: string; 79 | parent_category: number | false; 80 | } 81 | 82 | export interface IGameListEntry { 83 | id: number; 84 | domain_name: string; 85 | name: string; 86 | forum_url: string; 87 | nexusmods_url: string; 88 | genre: string; 89 | mods: number; 90 | file_count: number; 91 | downloads: number; 92 | approved_date: number; 93 | } 94 | export interface IGameInfo extends IGameListEntry { 95 | categories: ICategory[]; 96 | } 97 | 98 | interface IUser { 99 | member_id: number; 100 | member_group_id: number; 101 | name: string; 102 | } 103 | export interface IValidateKeyResponse { 104 | user_id: number; 105 | key: string; 106 | name: string; 107 | is_premium: boolean; 108 | is_supporter: boolean; 109 | email: string; 110 | profile_url: string; 111 | } -------------------------------------------------------------------------------- /src/types/feeds.ts: -------------------------------------------------------------------------------- 1 | export interface INews { 2 | id: string; 3 | title: string; 4 | summary: string; 5 | newsCategory: { 6 | name: string; 7 | } 8 | date: string; 9 | author: { 10 | name: string; 11 | avatar: string; 12 | } 13 | header: string; 14 | image: string; 15 | } 16 | 17 | export class News implements INews { 18 | id: string; 19 | title: string; 20 | summary: string; 21 | date: string; 22 | author: { name: string; avatar: string; }; 23 | header: string; 24 | image: string; 25 | newsCategory: { name: string; }; 26 | private baseImageUrl: string = 'https://staticdelivery.nexusmods.com/images/News/'; 27 | public publishDate: Date; 28 | public headerUrl: string; 29 | public imageUrl: string; 30 | 31 | constructor(newsItem: INews) { 32 | this.id = newsItem.id; 33 | this.title = newsItem.title; 34 | this.summary = newsItem.summary; 35 | this.date = newsItem.date; 36 | this.author = newsItem.author; 37 | this.header = newsItem.header; 38 | this.image = newsItem.image; 39 | this.publishDate = new Date(newsItem.date) 40 | this.headerUrl = `${this.baseImageUrl}/${newsItem.header}`; 41 | this.imageUrl = `${this.baseImageUrl}/${newsItem.image}`; 42 | this.newsCategory = newsItem.newsCategory; 43 | } 44 | 45 | public url(gameDomain?: string): string { 46 | return `https://nexusmods.com/${gameDomain ? `${gameDomain}/`: ''}news/${this.id}`; 47 | } 48 | 49 | } 50 | 51 | export interface SavedNewsData { 52 | title: string; 53 | date: Date; 54 | id: number; 55 | } -------------------------------------------------------------------------------- /src/types/servers.ts: -------------------------------------------------------------------------------- 1 | import { Snowflake } from 'discord.js'; 2 | 3 | export interface BotServer { 4 | id: Snowflake; 5 | official: boolean; 6 | channel_nexus?: Snowflake; 7 | channel_news?: Snowflake; 8 | role_author?: Snowflake; 9 | game_filter?: string; 10 | server_owner: Snowflake; 11 | } -------------------------------------------------------------------------------- /src/types/users.ts: -------------------------------------------------------------------------------- 1 | import { Snowflake } from 'discord.js'; 2 | 3 | interface NexusUser { 4 | d_id: Snowflake; 5 | id: number; 6 | name: string; 7 | avatar_url?: string; 8 | supporter: boolean; 9 | premium: boolean; 10 | modauthor?: boolean; 11 | lastupdate?: Date; 12 | nexus_access?: string; 13 | nexus_refresh?: string; 14 | nexus_expires?: number; 15 | discord_access?: string; 16 | discord_refresh?: string; 17 | discord_expires?: number; 18 | } 19 | 20 | 21 | export { NexusUser }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", // Use ES modules 5 | "moduleResolution": "node", // Use Node.js module resolution logic 6 | "esModuleInterop": true, // Enable compatibility for ES modules 7 | "allowSyntheticDefaultImports": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | // "noUnusedLocals": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, // Allow importing JSON files 13 | "outDir": "./dist", // Output folder 14 | "baseUrl": "./src", // Base URL for module resolution 15 | "rootDir": "./src", // Root directory of the source files 16 | "paths": { 17 | "*": ["node_modules/*", "src/*"] 18 | } 19 | }, 20 | "include": ["src/**/*"], 21 | "exclude": ["node_modules", "dist"] 22 | } -------------------------------------------------------------------------------- /tsconfig.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./src/discordbot.ts","./src/app.ts","./src/shards.ts","./src/api/discordbotuser.ts","./src/api/automod.ts","./src/api/bot-db.ts","./src/api/dbconnect.ts","./src/api/forumapi.ts","./src/api/migrations.ts","./src/api/moderationwebhooks.ts","./src/api/news.ts","./src/api/server_role_conditions.ts","./src/api/servers.ts","./src/api/subscriptions.ts","./src/api/tips.ts","./src/api/users.ts","./src/api/util.ts","./src/api/queries/all.ts","./src/api/queries/other.ts","./src/api/queries/v1.ts","./src/api/queries/v2-collection.ts","./src/api/queries/v2-collectionrevisions.ts","./src/api/queries/v2-collections.ts","./src/api/queries/v2-collectionsdownloadtotals.ts","./src/api/queries/v2-finduser.ts","./src/api/queries/v2-game.ts","./src/api/queries/v2-ismodauthor.ts","./src/api/queries/v2-latestmods.ts","./src/api/queries/v2-mods.ts","./src/api/queries/v2-modsfiles.ts","./src/api/queries/v2-modsbymodid.ts","./src/api/queries/v2-modsbyuid.ts","./src/api/queries/v2-mycollections.ts","./src/api/queries/v2-news.ts","./src/api/queries/v2-updatedmods.ts","./src/api/queries/v2-users.ts","./src/api/queries/v2.ts","./src/events/error.ts","./src/events/interactioncreate.ts","./src/events/ready.ts","./src/events/readyforaction.ts","./src/events/reconnecting.ts","./src/events/resume.ts","./src/feeds/automodmanager.ts","./src/feeds/newsfeedmanager.ts","./src/feeds/subscriptionmanager.ts","./src/interactions/about.ts","./src/interactions/automod.ts","./src/interactions/claimrole.ts","./src/interactions/commandupdate.ts","./src/interactions/help.ts","./src/interactions/link.ts","./src/interactions/mytoken.ts","./src/interactions/news.ts","./src/interactions/profile.ts","./src/interactions/refresh.ts","./src/interactions/search.ts","./src/interactions/settings.ts","./src/interactions/status.ts","./src/interactions/test.ts","./src/interactions/tips-manager.ts","./src/interactions/tips.ts","./src/interactions/track.ts","./src/interactions/triggerupdate.ts","./src/interactions/unlink.ts","./src/interactions/untrack.ts","./src/interactions/user-profile.ts","./src/interactions/whois.ts","./src/server/discordoauth.ts","./src/server/nexusmodsoauth.ts","./src/server/forumwebhook.ts","./src/server/server.ts","./src/types/discordtypes.ts","./src/types/forumwebhooktypes.ts","./src/types/gqltypes.ts","./src/types/nexusmodsapiv1.ts","./src/types/feeds.ts","./src/types/servers.ts","./src/types/subscriptions.ts","./src/types/users.ts","./src/types/util.ts"],"version":"5.8.2"} --------------------------------------------------------------------------------