diff --git a/discordbot.cfg b/discordbot.cfg index bb25a22..4bd2472 100644 --- a/discordbot.cfg +++ b/discordbot.cfg @@ -3,3 +3,6 @@ # - copy this file elsewhere, modify, and execute it from another cfg file; # - copy and modify the individual convars you wish to configure; # - or modify this file, and execute it directly from another cfg file via the command `exec @discordbot/discordbot.cfg`. + +# The bot token to use for Discord API authentication. +set discordbot:botToken "YOUR_BOT_TOKEN_HERE" diff --git a/package-lock.json b/package-lock.json index e16d31a..cd27094 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,11 @@ "requires": true, "packages": { "": { + "dependencies": { + "@discordjs/core": "1.0.0", + "@discordjs/rest": "2.0.0", + "tweetnacl": "^1.0.3" + }, "devDependencies": { "@citizenfx/server": "^2.0.9780-1", "@types/node": "16.9.1", @@ -20,6 +25,143 @@ "dev": true, "license": "ISC" }, + "node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@discordjs/core/-/core-1.0.0.tgz", + "integrity": "sha512-NmCv8Ebm1ieszEEOVUeS9k6kq9lC/O4OpwU3BCfMgrRUuTh/+q61a616Ayciqzao9c1tfdoZ3AgC8mrxxDZ3oQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/rest": "^2.0.0", + "@discordjs/util": "^1.0.0", + "@discordjs/ws": "^1.0.0", + "@sapphire/snowflake": "^3.5.1", + "@vladfrangu/async_event_emitter": "^2.2.2", + "discord-api-types": "^0.37.50" + }, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.0.0.tgz", + "integrity": "sha512-CW9ldfzsRzUbHcS4Oqu5+Moo+yrQ5qQ9groKNxPOzcoq2nuXa/fXOXkuQtQHcTeSVXsC9cmJ56M8gBDBUyLgGA==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^1.5.2", + "@discordjs/util": "^1.0.0", + "@sapphire/async-queue": "^1.5.0", + "@sapphire/snowflake": "^3.5.1", + "@vladfrangu/async_event_emitter": "^2.2.2", + "discord-api-types": "^0.37.50", + "magic-bytes.js": "^1.0.15", + "tslib": "^2.6.1", + "undici": "^5.22.1" + }, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", + "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.1.1.tgz", + "integrity": "sha512-PZ+vLpxGCRtmr2RMkqh8Zp+BenUaJqlS6xhgWKEZcgC/vfHLEzpHtKkB0sl3nZWpwtcKk6YWy+pU3okL2I97FA==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.3.0", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "0.37.83", + "tslib": "^2.6.2", + "ws": "^8.16.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/rest": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.4.0.tgz", + "integrity": "sha512-Xb2irDqNcq+O8F0/k/NaDp7+t091p+acb51iA4bCKfIn+WFWd6HrNvcsSbMMxIR9NjcMZS6NReTKygqiQN+ntw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.1.1", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.3", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "0.37.97", + "magic-bytes.js": "^1.10.0", + "tslib": "^2.6.3", + "undici": "6.19.8" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/rest/node_modules/discord-api-types": { + "version": "0.37.97", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.97.tgz", + "integrity": "sha512-No1BXPcVkyVD4ZVmbNgDKaBoqgeQ+FJpzZ8wqHkfmBnTZig1FcH3iPPersiK1TUIAzgClh2IvOuVUYfcWLQAOA==", + "license": "MIT" + }, + "node_modules/@discordjs/ws/node_modules/discord-api-types": { + "version": "0.37.83", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.83.tgz", + "integrity": "sha512-urGGYeWtWNYMKnYlZnOnDHm8fVRffQs3U0SpE8RHeiuLKb/u92APS8HoQnPTFbnXmY1vVnXjXO4dOxcAn3J+DA==", + "license": "MIT" + }, + "node_modules/@discordjs/ws/node_modules/undici": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz", + "integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/@esbuild/win32-x64": { "version": "0.23.1", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", @@ -37,6 +179,15 @@ "node": ">=18" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -66,13 +217,51 @@ "node": ">=14" } }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.3.tgz", + "integrity": "sha512-x7zadcfJGxFka1Q3f8gCts1F0xMwCKbZweM85xECGI0hBTeIZJGGCrHgLggihBoprlQ/hBmDR5LKfIPqnmHM3w==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@types/node": { "version": "16.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", - "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz", + "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz", + "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/ansi-regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", @@ -151,6 +340,12 @@ "node": ">= 8" } }, + "node_modules/discord-api-types": { + "version": "0.37.100", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.100.tgz", + "integrity": "sha512-a8zvUI0GYYwDtScfRd/TtaNBDTXwP5DiDVX7K5OmE+DRT57gBqKnwtOC5Ol8z0mRW8KQfETIgiB8U0YZ9NXiCA==", + "license": "MIT" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -292,6 +487,12 @@ "node": "20 || >=22" } }, + "node_modules/magic-bytes.js": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.10.0.tgz", + "integrity": "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", @@ -528,6 +729,18 @@ "node": ">=8" } }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + }, "node_modules/typescript": { "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", @@ -542,6 +755,18 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "5.28.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", + "integrity": "sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -655,6 +880,27 @@ "engines": { "node": ">=8" } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 557f97d..dc7b1ae 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,11 @@ "typecheck": "tsc -p server", "build": "npm run typecheck && rimraf dist && node build.js" }, + "dependencies": { + "@discordjs/core": "1.0.0", + "@discordjs/rest": "2.0.0", + "tweetnacl": "^1.0.3" + }, "devDependencies": { "@citizenfx/server": "^2.0.9780-1", "@types/node": "16.9.1", diff --git a/server/http/handler.ts b/server/http/handler.ts new file mode 100644 index 0000000..7554a41 --- /dev/null +++ b/server/http/handler.ts @@ -0,0 +1,39 @@ +import { + APIInteraction, + APIInteractionResponse, + InteractionResponseType, + InteractionType, +} from '@discordjs/core/http-only'; +import { HttpHandlerRequest, HttpHandlerResponse } from './types.js'; +import { verifyDiscordRequest } from './verify.js'; + +async function handleInteraction( + interaction: APIInteraction, +): Promise { + switch (interaction.type) { + case InteractionType.Ping: + return { type: InteractionResponseType.Pong }; + } +} + +export function setupHttpHandler(verifyKey: string) { + const verify = verifyDiscordRequest(verifyKey); + + async function handler( + request: HttpHandlerRequest, + response: HttpHandlerResponse, + ) { + request.setDataHandler(async (body) => { + if (!verify(request, body)) { + response.writeHead(401); + return response.send('invalid request signature'); + } + + const interaction = JSON.parse(body) as APIInteraction; + const result = await handleInteraction(interaction); + result && response.send(JSON.stringify(result)); + }); + } + + SetHttpHandler(handler); +} diff --git a/server/http/types.d.ts b/server/http/types.d.ts new file mode 100644 index 0000000..335b1c1 --- /dev/null +++ b/server/http/types.d.ts @@ -0,0 +1,18 @@ +export interface HttpHandlerRequest { + address: string; + headers: Record; + method: string; + path: string; + setDataHandler(handler: (data: string) => void): void; + setDataHandler( + handler: (data: ArrayBuffer) => void, + binary: 'binary', + ): void; + setCancelHandler(handler: () => void): void; +} + +export interface HttpHandlerResponse { + writeHead(code: number, headers?: Record): void; + write(data: string): void; + send(data?: string): void; +} diff --git a/server/http/verify.ts b/server/http/verify.ts new file mode 100644 index 0000000..679ada4 --- /dev/null +++ b/server/http/verify.ts @@ -0,0 +1,16 @@ +import nacl from 'tweetnacl'; +import { HttpHandlerRequest } from './types.js'; + +export function verifyDiscordRequest(verifyKey: string) { + return (request: HttpHandlerRequest, body: string): boolean => { + const signature = request.headers['X-Signature-Ed25519']; + const timestamp = request.headers['X-Signature-Timestamp']; + if (!signature || !timestamp) return false; + + return nacl.sign.detached.verify( + Buffer.from(timestamp + body), + Buffer.from(signature, 'hex'), + Buffer.from(verifyKey, 'hex'), + ); + }; +} diff --git a/server/index.ts b/server/index.ts index e69de29..5ab4508 100644 --- a/server/index.ts +++ b/server/index.ts @@ -0,0 +1,18 @@ +import { setTimeout } from 'timers/promises'; +import { inspect } from 'util'; +import { setupHttpHandler } from './http/handler.js'; +import { api, botToken } from './utils/env.js'; + +(async () => { + await setTimeout(0); + + if (!botToken) { + console.log( + '^1[ERROR] Please provide a bot token with the ^3discordbot:botToken ^1convar and restart the resource.^7', + ); + return; + } + + const app = await api.oauth2.getCurrentBotApplicationInformation(); + setupHttpHandler(app.verify_key); +})().catch((e) => console.log(inspect(e))); diff --git a/server/tsconfig.json b/server/tsconfig.json index 477996c..8f25171 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -2,8 +2,8 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "../base.tsconfig.json", "compilerOptions": { - "module": "CommonJS", - "moduleResolution": "Node10", + "module": "Node16", + "moduleResolution": "Node16", "types": ["node", "@citizenfx/server"], diff --git a/server/utils/env.ts b/server/utils/env.ts new file mode 100644 index 0000000..c1303e4 --- /dev/null +++ b/server/utils/env.ts @@ -0,0 +1,7 @@ +import { API } from '@discordjs/core/http-only'; +import { REST } from '@discordjs/rest'; + +export const botToken = GetConvar('discordbot:botToken', ''); + +export const rest = new REST({ version: '10' }).setToken(botToken); +export const api = new API(rest);