commit b55ff5829cc65da927fbdbe595f8aa9c820078a7 Author: Jens Bech-Sørensen Date: Sat May 3 12:03:31 2025 +0200 Initial commit diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4ab91c1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,22 @@ +{ + "name": "Bun Development Container", + "image": "ghcr.io/nhaef/devcontainer-bun:latest", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": true, + "installOhMyZsh": true, + "configureZshAsDefaultShell": true + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-vscode.cpptools", + "ms-python.python", + "esbenp.prettier-vscode" + ] + } + }, + "remoteUser": "vscode", + "postCreateCommand": "sudo chown -R vscode:vscode /workspaces/DiscordBots && sudo chown -R vscode:vscode /workspaces/DiscordBots/node_modules || true" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87e5610 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +**/*.trace +**/*.zip +**/*.tar.gz +**/*.tgz +**/*.log +package-lock.json +**/*.bun \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8cd355e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM oven/bun AS build + +WORKDIR /app + +COPY package.json package.json +COPY bun.lock bun.lock + +RUN bun install + +COPY ./src ./src + +ENV NODE_ENV=production + +RUN bun build \ + --compile \ + --minify-whitespace \ + --minify-syntax \ + --target bun \ + --outfile server \ + ./src/index.ts + +FROM gcr.io/distroless/base + +WORKDIR /app + +COPY --from=build /app/server server + +ENV NODE_ENV=production + +CMD ["./server"] + +EXPOSE 3000 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e38d297 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +# Makefile + +BW_PROJECT_NAME=BoredGods.dev +BW_PROJECT_ID=9d72d613-8d1f-4455-8676-b2c10167aa94 +BW_DOMAIN=https://vault.bitwarden.eu + +run: + @if ! command -v bws >/dev/null 2>&1; then \ + echo "Error: bws is not installed."; \ + exit 1; \ + fi + + @if [ -z "$$BWS_ACCESS_TOKEN" ]; then \ + echo "Error: BWS_ACCESS_TOKEN environment variable is not set."; \ + exit 1; \ + fi + + @bws config server-base $(BW_DOMAIN) + @echo "Set EU connection $(BW_DOMAIN)" + + @echo "Starting app with project ${BW_PROJECT_NAME}" + @bws run --project-id $(BW_PROJECT_ID) -- bun src/index.ts diff --git a/a b/a new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json new file mode 100644 index 0000000..7ae8bb3 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "app", + "version": "1.0.50", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "bun run --watch src/index.ts" + }, + "dependencies": { + "discord.js": "^14.18.0", + "elysia": "^1.2.25" + }, + "devDependencies": { + "bun-types": "^1.2.9" + }, + "module": "src/index.js" +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..93a49bf --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,264 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + discord.js: + specifier: ^14.18.0 + version: 14.18.0 + elysia: + specifier: ^1.2.25 + version: 1.2.25(@sinclair/typebox@0.34.33) + devDependencies: + bun-types: + specifier: ^1.2.9 + version: 1.2.9 + +packages: + + '@discordjs/builders@1.10.1': + resolution: {integrity: sha512-OWo1fY4ztL1/M/DUyRPShB4d/EzVfuUvPTRRHRIt/YxBrUYSz0a+JicD5F5zHFoNs2oTuWavxCOVFV1UljHTng==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@1.5.3': + resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@2.1.1': + resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} + engines: {node: '>=18'} + + '@discordjs/formatters@0.6.0': + resolution: {integrity: sha512-YIruKw4UILt/ivO4uISmrGq2GdMY6EkoTtD0oS0GvkJFRZbTSdPhzYiUILbJ/QslsvC9H9nTgGgnarnIl4jMfw==} + engines: {node: '>=16.11.0'} + + '@discordjs/rest@2.4.3': + resolution: {integrity: sha512-+SO4RKvWsM+y8uFHgYQrcTl/3+cY02uQOH7/7bKbVZsTfrfpoE62o5p+mmV+s7FVhTX82/kQUGGbu4YlV60RtA==} + engines: {node: '>=18'} + + '@discordjs/util@1.1.1': + resolution: {integrity: sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==} + engines: {node: '>=18'} + + '@discordjs/ws@1.2.1': + resolution: {integrity: sha512-PBvenhZG56a6tMWF/f4P6f4GxZKJTBG95n7aiGSPTnodmz4N5g60t79rSIAq7ywMbv8A4jFtexMruH+oe51aQQ==} + engines: {node: '>=16.11.0'} + + '@sapphire/async-queue@1.5.5': + resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@sapphire/shapeshift@4.0.0': + resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} + engines: {node: '>=v16'} + + '@sapphire/snowflake@3.5.3': + resolution: {integrity: sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@sinclair/typebox@0.34.33': + resolution: {integrity: sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g==} + + '@types/node@22.14.1': + resolution: {integrity: sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vladfrangu/async_event_emitter@2.4.6': + resolution: {integrity: sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + bun-types@1.2.9: + resolution: {integrity: sha512-dk/kOEfQbajENN/D6FyiSgOKEuUi9PWfqKQJEgwKrCMWbjS/S6tEXp178mWvWAcUSYm9ArDlWHZKO3T/4cLXiw==} + + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + + discord-api-types@0.37.120: + resolution: {integrity: sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==} + + discord.js@14.18.0: + resolution: {integrity: sha512-SvU5kVUvwunQhN2/+0t55QW/1EHfB1lp0TtLZUSXVHDmyHTrdOj5LRKdR0zLcybaA15F+NtdWuWmGOX9lE+CAw==} + engines: {node: '>=18'} + + elysia@1.2.25: + resolution: {integrity: sha512-WsdQpORJvb4uszzeqYT0lg97knw1iBW1NTzJ1Jm57tiHg+DfAotlWXYbjmvQ039ssV0fYELDHinLLoUazZkEHg==} + peerDependencies: + '@sinclair/typebox': '>= 0.34.0' + openapi-types: '>= 12.0.0' + typescript: '>= 5.0.0' + peerDependenciesMeta: + openapi-types: + optional: true + typescript: + optional: true + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + magic-bytes.js@1.10.0: + resolution: {integrity: sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==} + + memoirist@0.3.0: + resolution: {integrity: sha512-wR+4chMgVPq+T6OOsk40u9Wlpw1Pjx66NMNiYxCQQ4EUJ7jDs3D9kTCeKdBOkvAiqXlHLVJlvYL01PvIJ1MPNg==} + + ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@6.21.1: + resolution: {integrity: sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==} + engines: {node: '>=18.17'} + + ws@8.18.1: + resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==} + 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 + +snapshots: + + '@discordjs/builders@1.10.1': + dependencies: + '@discordjs/formatters': 0.6.0 + '@discordjs/util': 1.1.1 + '@sapphire/shapeshift': 4.0.0 + discord-api-types: 0.37.120 + fast-deep-equal: 3.1.3 + ts-mixer: 6.0.4 + tslib: 2.8.1 + + '@discordjs/collection@1.5.3': {} + + '@discordjs/collection@2.1.1': {} + + '@discordjs/formatters@0.6.0': + dependencies: + discord-api-types: 0.37.120 + + '@discordjs/rest@2.4.3': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/util': 1.1.1 + '@sapphire/async-queue': 1.5.5 + '@sapphire/snowflake': 3.5.3 + '@vladfrangu/async_event_emitter': 2.4.6 + discord-api-types: 0.37.120 + magic-bytes.js: 1.10.0 + tslib: 2.8.1 + undici: 6.21.1 + + '@discordjs/util@1.1.1': {} + + '@discordjs/ws@1.2.1': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/rest': 2.4.3 + '@discordjs/util': 1.1.1 + '@sapphire/async-queue': 1.5.5 + '@types/ws': 8.18.1 + '@vladfrangu/async_event_emitter': 2.4.6 + discord-api-types: 0.37.120 + tslib: 2.8.1 + ws: 8.18.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@sapphire/async-queue@1.5.5': {} + + '@sapphire/shapeshift@4.0.0': + dependencies: + fast-deep-equal: 3.1.3 + lodash: 4.17.21 + + '@sapphire/snowflake@3.5.3': {} + + '@sinclair/typebox@0.34.33': {} + + '@types/node@22.14.1': + dependencies: + undici-types: 6.21.0 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.14.1 + + '@vladfrangu/async_event_emitter@2.4.6': {} + + bun-types@1.2.9: + dependencies: + '@types/node': 22.14.1 + '@types/ws': 8.18.1 + + cookie@1.0.2: {} + + discord-api-types@0.37.120: {} + + discord.js@14.18.0: + dependencies: + '@discordjs/builders': 1.10.1 + '@discordjs/collection': 1.5.3 + '@discordjs/formatters': 0.6.0 + '@discordjs/rest': 2.4.3 + '@discordjs/util': 1.1.1 + '@discordjs/ws': 1.2.1 + '@sapphire/snowflake': 3.5.3 + discord-api-types: 0.37.120 + fast-deep-equal: 3.1.3 + lodash.snakecase: 4.1.1 + tslib: 2.8.1 + undici: 6.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + elysia@1.2.25(@sinclair/typebox@0.34.33): + dependencies: + '@sinclair/typebox': 0.34.33 + cookie: 1.0.2 + memoirist: 0.3.0 + + fast-deep-equal@3.1.3: {} + + lodash.snakecase@4.1.1: {} + + lodash@4.17.21: {} + + magic-bytes.js@1.10.0: {} + + memoirist@0.3.0: {} + + ts-mixer@6.0.4: {} + + tslib@2.8.1: {} + + undici-types@6.21.0: {} + + undici@6.21.1: {} + + ws@8.18.1: {} diff --git a/src/boredBot/commands.ts b/src/boredBot/commands.ts new file mode 100644 index 0000000..456fc10 --- /dev/null +++ b/src/boredBot/commands.ts @@ -0,0 +1,17 @@ +import { SlashCommandBuilder } from "discord.js"; +import { Command } from "../discordBot/types"; + +export const boredBotCommands = [ + new SlashCommandBuilder() + .setName(Command.Roll) + .setDescription("Roll dice (e.g. 'd20', '6d12-4', '2d8 + 1d6+4')") + .addStringOption((option) => + option + .setName("input") + .setDescription("The dice you want to roll") + .setRequired(true), + ), + new SlashCommandBuilder() + .setName(Command.Help) + .setDescription("Displays a list of available commands."), +]; diff --git a/src/boredBot/index.ts b/src/boredBot/index.ts new file mode 100644 index 0000000..156d9b7 --- /dev/null +++ b/src/boredBot/index.ts @@ -0,0 +1,123 @@ +import type { SlashCommandOptionsOnlyBuilder } from "discord.js"; +import { DiceRoller } from "../diceRoller"; +import type { DiceParseResult } from "../diceRoller/types"; +import { parseDiceUserInput } from "../diceRoller/utils/parseDiceUserInput"; +import { DiscordBot } from "../discordBot"; +import { Command, MessageContent } from "../discordBot/types"; + +export class BoredBot extends DiscordBot { + private static instance: BoredBot; + + private constructor( + token: string, + applicationId: string, + commands: SlashCommandOptionsOnlyBuilder[], + ) { + super(token, applicationId, commands); + this.useCommand(); + } + + public static async getInstance( + token: string, + applicationId: string, + commands: SlashCommandOptionsOnlyBuilder[], + ): Promise { + if (!BoredBot.instance) { + BoredBot.instance = new BoredBot(token, applicationId, commands); + await BoredBot.instance.initialize("Bored Bot"); + } + return BoredBot.instance; + } + + private async useCommand() { + this.client.on("interactionCreate", async (interaction) => { + if (!interaction.isChatInputCommand()) return; + + const command = interaction.commandName as Command; + const userName = interaction.user.displayName; + const userInput = interaction.options.getString(MessageContent.Input); + + if (!userInput) throw new Error("Expected input to be defined"); + + console.log("got command"); + + switch (command) { + case Command.Roll: { + await interaction.reply( + await this.handleRollCommand(userInput, userName), + ); + } + } + }); + } + + private async handleRollCommand( + inputStringFromUser: string, + username: string, + ): Promise { + let parsedInputResult: DiceParseResult = { dices: [], mod: 0 }; + + try { + parsedInputResult = parseDiceUserInput(inputStringFromUser); + } catch (error) { + if (error instanceof Error) return error.message; + } + + const roller = new DiceRoller(username); + const resultsMessages: string[] = []; + + if (parsedInputResult.dices.length === 1) { + const singleDie = parsedInputResult.dices[0]; + const { rollResult, message } = await roller.roll(singleDie); + + const mod = parsedInputResult.mod; + const finalResult = rollResult + mod; + + let singleLine = `(${singleDie.toString()}) => ${rollResult}`; + if (mod !== 0) { + const sign = mod > 0 ? "+" : "-"; + singleLine += ` ${sign}${Math.abs(mod)} = ${finalResult}`; + } + + if (message) { + singleLine += `\n${message}`; + } + + resultsMessages.push(singleLine); + } else { + resultsMessages.push( + `You rolled ${parsedInputResult.dices.length} dice!`, + ); + + let sum = 0; + let rollCount = 1; + + for (const dieInput of parsedInputResult.dices) { + const { rollResult, message } = await roller.roll(dieInput); + sum += rollResult; + + const prefix = `Roll #${rollCount}: `; + const suffix = message ? ` **${message}**` : ""; + + resultsMessages.push( + `${prefix}(d${dieInput.toString()}) => ${rollResult}${suffix}`, + ); + rollCount++; + } + + const mod = parsedInputResult.mod; + const finalResult = sum + mod; + if (mod !== 0) { + const sign = mod > 0 ? "+" : "-"; + + resultsMessages.push( + `Result: ${sum} ${sign} ${Math.abs(mod)} = ${finalResult}`, + ); + } else { + resultsMessages.push(`**Final result: ${sum}**`); + } + } + + return resultsMessages.join("\n"); + } +} diff --git a/src/diceRoller/index.ts b/src/diceRoller/index.ts new file mode 100644 index 0000000..0176f49 --- /dev/null +++ b/src/diceRoller/index.ts @@ -0,0 +1,53 @@ +import { Critical, Dice } from "./types"; + +export class DiceRoller { + private username: string; + constructor(user: string) { + this.username = user; + } + + public async roll( + dice: (typeof Dice)[keyof typeof Dice], + ): Promise<{ rollResult: number; message?: string }> { + if (!Object.values(Dice).includes(dice)) + throw new Error(`Invalid dice type: ${dice}`); + + const { outcome: result, crit } = this.getSingleDiceRollOutcome(dice); + + if (crit.failure || crit.success) { + return { + rollResult: result, + message: crit.success + ? await this.handleCritical(Critical.Success) + : await this.handleCritical(Critical.Fail), + }; + } + return { rollResult: result }; + } + + private getSingleDiceRollOutcome(dice: (typeof Dice)[keyof typeof Dice]): { + outcome: number; + crit: { failure: boolean; success: boolean }; + } { + const roll = Math.floor(Math.random() * dice) + 1; + + switch (dice) { + case Dice.Twenty: + return { + outcome: roll, + crit: { failure: roll === 1, success: roll === 20 }, + }; + default: + return { outcome: roll, crit: { failure: false, success: false } }; + } + } + + private async handleCritical(critical: Critical): Promise { + switch (critical) { + case Critical.Fail: + return "Critical FAIL!"; + case Critical.Success: + return "Critical SUCCESS!"; + } + } +} diff --git a/src/diceRoller/types.ts b/src/diceRoller/types.ts new file mode 100644 index 0000000..9d8419a --- /dev/null +++ b/src/diceRoller/types.ts @@ -0,0 +1,18 @@ +export const Dice = { + Four: 4, + Six: 6, + Ten: 10, + Twelve: 12, + Twenty: 20, + Hundrer: 100, +}; + +export enum Critical { + Fail = "fail", + Success = "success", +} + +export type DiceParseResult = { + dices: number[]; + mod: number; +}; diff --git a/src/diceRoller/utils/handleRollCommand.ts b/src/diceRoller/utils/handleRollCommand.ts new file mode 100644 index 0000000..8637c4a --- /dev/null +++ b/src/diceRoller/utils/handleRollCommand.ts @@ -0,0 +1,71 @@ +import { DiceRoller } from "../discord/dice"; +import { parseDiceUserInput } from "./parseDiceUserInput"; +import type { DiceParseResult } from "../types"; + +export async function handleRollCommand( + inputStringFromUser: string, + username: string, +): Promise { + let parsedInputResult: DiceParseResult = { dices: [], mod: 0 }; + + try { + parsedInputResult = parseDiceUserInput(inputStringFromUser); + } catch (error) { + if (error instanceof Error) return error.message; + } + + const roller = new DiceRoller(username); + const resultsMessages: string[] = []; + + if (parsedInputResult.dices.length === 1) { + const singleDie = parsedInputResult.dices[0]; + const { rollResult, message } = await roller.roll(singleDie); + + const mod = parsedInputResult.mod; + const finalResult = rollResult + mod; + + let singleLine = `(${singleDie.toString()}) => ${rollResult}`; + if (mod !== 0) { + const sign = mod > 0 ? "+" : "-"; + singleLine += ` ${sign}${Math.abs(mod)} = ${finalResult}`; + } + + if (message) { + singleLine += `\n${message}`; + } + + resultsMessages.push(singleLine); + } else { + resultsMessages.push(`You rolled ${parsedInputResult.dices.length} dice!`); + + let sum = 0; + let rollCount = 1; + + for (const dieInput of parsedInputResult.dices) { + const { rollResult, message } = await roller.roll(dieInput); + sum += rollResult; + + const prefix = `Roll #${rollCount}: `; + const suffix = message ? ` **${message}**` : ""; + + resultsMessages.push( + `${prefix}(d${dieInput.toString()}) => ${rollResult}${suffix}`, + ); + rollCount++; + } + + const mod = parsedInputResult.mod; + const finalResult = sum + mod; + if (mod !== 0) { + const sign = mod > 0 ? "+" : "-"; + + resultsMessages.push( + `Result: ${sum} ${sign} ${Math.abs(mod)} = ${finalResult}`, + ); + } else { + resultsMessages.push(`**Final result: ${sum}**`); + } + } + + return resultsMessages.join("\n"); +} diff --git a/src/diceRoller/utils/parseDiceUserInput.ts b/src/diceRoller/utils/parseDiceUserInput.ts new file mode 100644 index 0000000..e810f03 --- /dev/null +++ b/src/diceRoller/utils/parseDiceUserInput.ts @@ -0,0 +1,69 @@ +import { Dice } from "../types"; + +const ALLOWED_DICE_SIDES = new Set(Object.values(Dice)); + +export function parseDiceUserInput(input: string): DiceParseResult { + if (typeof input !== "string" || !input.trim()) { + throw new Error("Input must be a non-empty string."); + } + + let clean = input.toLowerCase().trim(); + clean = clean.replace(/\b(roll|dice|die)\b/g, ""); + clean = clean.replace(/\s+/g, ""); + + if (/^\d+$/.test(clean)) { + const sides = Number.parseInt(clean, 10); + if (!ALLOWED_DICE_SIDES.has(sides)) { + throw new Error( + `Allowed dice sides are ${[...ALLOWED_DICE_SIDES].join(", ")}. Received: ${sides}`, + ); + } + return { dices: [sides], mod: 0 }; + } + + const tokenRegex = /(\d*d\d+|[+\-]\d+)/g; + const tokens = clean.match(tokenRegex); + + if (!tokens) { + throw new Error( + "Unable to parse any dice or modifiers. Examples: '3d6+5', 'd20-2', '2d8+1d6+4'.", + ); + } + + const dices: number[] = []; + let mod = 0; + + for (const token of tokens) { + if (token.includes("d")) { + const [countPart, sidesPart] = token.split("d"); + + let diceCount = countPart ? Number.parseInt(countPart, 10) : 1; + if (Number.isNaN(diceCount) || diceCount < 1) { + diceCount = 1; + } + + const sides = Number.parseInt(sidesPart, 10); + if (Number.isNaN(sides) || sides <= 0) { + throw new Error(`Invalid number of sides: "${sidesPart}"`); + } + + if (!ALLOWED_DICE_SIDES.has(sides)) { + throw new Error( + `Allowed dice sides are ${[...ALLOWED_DICE_SIDES].join(", ")}. Received: ${sides}`, + ); + } + + for (let i = 0; i < diceCount; i++) { + dices.push(sides); + } + } else { + const parsedMod = Number.parseInt(token, 10); + if (Number.isNaN(parsedMod)) { + throw new Error(`Invalid modifier: "${token}"`); + } + mod += parsedMod; + } + } + + return { dices, mod }; +} diff --git a/src/discordBot/index.ts b/src/discordBot/index.ts new file mode 100644 index 0000000..23e0b2d --- /dev/null +++ b/src/discordBot/index.ts @@ -0,0 +1,65 @@ +import { + Client, + GatewayIntentBits, + REST, + Routes, + type SlashCommandOptionsOnlyBuilder, +} from "discord.js"; + +export abstract class DiscordBot { + private token: string; + private applicationId: string; + protected client: Client; + private commands: SlashCommandOptionsOnlyBuilder[]; + + protected constructor( + token: string, + applicationId: string, + commands: SlashCommandOptionsOnlyBuilder[], + ) { + this.client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], + }); + this.token = token; + this.applicationId = applicationId; + this.commands = commands; + } + + private async login(): Promise { + if (!this.token) { + throw new Error("No token provided when attempting to log in bot"); + } + try { + await this.client.login(this.token); + } catch (error) { + throw new Error(`Bot failed to log in: ${error}`); + } + } + + private async registerCommands(): Promise { + try { + await new REST({ version: "10" }) + .setToken(this.token) + .put(Routes.applicationCommands(this.applicationId), { + body: this.commands, + }); + } catch (error) { + throw new Error(`Failed to register slash commands: ${error}`); + } + } + + protected async initialize(botName: string): Promise { + console.log(`Initializing bot '${botName}'...`); + try { + await this.login(); + await this.registerCommands(); + console.log(`${botName} is ready!`); + } catch (error) { + console.log("Initialization error:", error); + } + } +} diff --git a/src/discordBot/types.ts b/src/discordBot/types.ts new file mode 100644 index 0000000..7062087 --- /dev/null +++ b/src/discordBot/types.ts @@ -0,0 +1,8 @@ +export enum Command { + Roll = "roll", + Help = "help", +} + +export enum MessageContent { + Input = "input", +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ceaf642 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,10 @@ +// import { Elysia } from "elysia"; +import { BoredBot } from "./boredBot"; +import { boredBotCommands } from "./boredBot/commands"; + +// new Elysia().get("/", () => "Hello Elysia").listen(3000); +if (!process.env.DISCORD_BOT_TOKEN || !process.env.APPLICATION_ID) { + throw new Error("DISCORD_BOT_TOKEN and APPLICATION_ID must be defined in the environment variables."); +} + +await BoredBot.getInstance(process.env.DISCORD_BOT_TOKEN, process.env.APPLICATION_ID, boredBotCommands); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ca2350 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,103 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ES2022", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +} diff --git a/wps/compose.yml b/wps/compose.yml new file mode 100644 index 0000000..1d0cf1f --- /dev/null +++ b/wps/compose.yml @@ -0,0 +1,43 @@ +services: + bookstack: + image: lscr.io/linuxserver/bookstack:latest + container_name: bookstack + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/Oslo + - APP_URL=$APP_URL + - APP_KEY=$APP_KEY + - DB_HOST=mariadb + - DB_PORT=3306 + - DB_DATABASE=bookstack + - DB_USERNAME=bookstack + - DB_PASSWORD=$DB_PASSWORD + volumes: + - ~/bookstack/bookstack_app_data:/config + ports: + - 6875:80 + restart: always + networks: + - bookstack_network + + mariadb: + image: lscr.io/linuxserver/mariadb:latest + container_name: mariadb + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/Oslo + - MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD + - MYSQL_DATABASE=bookstack + - MYSQL_USER=bookstack + - MYSQL_PASSWORD=$DB_PASSWORD + volumes: + - ~/bookstack/bookstack_db_data:/config + restart: always + networks: + - bookstack_network + +networks: + bookstack_network: + driver: bridge \ No newline at end of file diff --git a/wps/todo.md b/wps/todo.md new file mode 100644 index 0000000..80cb4aa --- /dev/null +++ b/wps/todo.md @@ -0,0 +1,5 @@ +- import stack from prod +- reverse proxy +- point domain provider to hostmanet +- ??? +- Profit \ No newline at end of file