From dbfd68ee8a1bf31e1336f954d2eed0f5b551a318 Mon Sep 17 00:00:00 2001 From: Declan Chidlow Date: Fri, 13 Feb 2026 18:08:04 +0800 Subject: [PATCH] Push v1 of bot --- .env.example | 8 ++ .gitignore | 16 ++++ Dockerfile | 8 ++ README.md | 22 ++--- bun.lock | 39 ++++++++ compose.yml.example | 6 ++ main.js | 214 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 20 +++++ 8 files changed, 320 insertions(+), 13 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 bun.lock create mode 100644 compose.yml.example create mode 100644 main.js create mode 100644 package.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0d30756 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Copy this file to `.env.example` before editing + +# TODO: Base URL of the Stoat API to connect to. +# Defaults to https://api.stoat.chat/0.8 +STOAT_API_URL= + +# Your bot account's token. +BOT_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fb48dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# IDE / editor specific ignores +.idea/ +.vscode/ + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# JavaScript +node_modules + +# Docker +compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db0ae7d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM oven/bun:latest + +WORKDIR /app +COPY package.json bun.lockb* ./ +RUN bun install --frozen-lockfile +COPY . . + +CMD ["bun", "run", "start"] diff --git a/README.md b/README.md index e0315ab..b5f3968 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,16 @@

- TemplateRepo + AutoDice - [![Stars](https://img.shields.io/github/stars/DeclanChidlow/TemplateRepo?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/TemplateRepo/stargazers) - [![Forks](https://img.shields.io/github/forks/DeclanChidlow/TemplateRepo?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/TemplateRepo/network/members) - [![Pull Requests](https://img.shields.io/github/issues-pr/DeclanChidlow/TemplateRepo?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/TemplateRepo/pulls) - [![Issues](https://img.shields.io/github/issues/DeclanChidlow/TemplateRepo?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/TemplateRepo/issues) - [![Contributors](https://img.shields.io/github/contributors/DeclanChidlow/TemplateRepo?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/TemplateRepo/graphs/contributors) - [![Licence](https://img.shields.io/github/license/DeclanChidlow/TemplateRepo?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/TemplateRepo/blob/main/LICENCE) + [![Stars](https://img.shields.io/github/stars/DeclanChidlow/AutoDice?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoDice/stargazers) + [![Forks](https://img.shields.io/github/forks/DeclanChidlow/AutoDice?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoDice/network/members) + [![Pull Requests](https://img.shields.io/github/issues-pr/DeclanChidlow/AutoDice?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoDice/pulls) + [![Issues](https://img.shields.io/github/issues/DeclanChidlow/AutoDice?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoDice/issues) + [![Contributors](https://img.shields.io/github/contributors/DeclanChidlow/AutoDice?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoDice/graphs/contributors) + [![Licence](https://img.shields.io/github/license/DeclanChidlow/AutoDice?style=flat-square&logoColor=white)](https://github.com/DeclanChidlow/AutoDice/blob/main/LICENCE)

-Brief description of repository +Dice bot for Stoat

-Larger description of repository. - -## Badge Setup - -Search and replace all instances of "DeclanChidlow/TemplateRepo" with your required details. In Neovim this can be done with `:%s/DeclanChidlow/your_username/g` then `:%s/TemplateRepo/your_repo_name/g`. +Dice bot perfect for role-playing, and especially tabletap RPGs (TTRPGs) such as Dungeons & Dragons (D&D). diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..5c0ea36 --- /dev/null +++ b/bun.lock @@ -0,0 +1,39 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "autodice", + "dependencies": { + "stoat.js": "^7.3.6", + }, + }, + }, + "packages": { + "@solid-primitives/map": ["@solid-primitives/map@0.7.2", "", { "dependencies": { "@solid-primitives/trigger": "^1.2.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-sXK/rS68B4oq3XXNyLrzVhLtT1pnimmMUahd2FqhtYUuyQsCfnW058ptO1s+lWc2k8F/3zQSNVkZ2ifJjlcNbQ=="], + + "@solid-primitives/set": ["@solid-primitives/set@0.7.2", "", { "dependencies": { "@solid-primitives/trigger": "^1.2.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-E4UzC1cQtPWicnbK9ulG0G27d8802DFi4OSC6HZm+yyQOVAb0ebkfJq9FKSYpFxE+gb6M2lM6Zh4ulXRna35CA=="], + + "@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-IWoptVc0SWYgmpBPpCMehS5b07+tpFcvw15tOQ3QbXedSYn6KP8zCjPkHNzMxcOvOicTneleeZDP7lqmz+PQ6g=="], + + "@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="], + + "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "json-with-bigint": ["json-with-bigint@3.5.3", "", {}, "sha512-QObKu6nxy7NsxqR0VK4rkXnsNr5L9ElJaGEg+ucJ6J7/suoKZ0n+p76cu9aCqowytxEbwYNzvrMerfMkXneF5A=="], + + "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], + + "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], + + "solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="], + + "stoat-api": ["stoat-api@0.8.9-4", "", { "dependencies": { "json-with-bigint": "^3.4.4" } }, "sha512-6N4kfE1x+j/XYVaBuvYqzbWxjgqbxSarvNjMv8GEcBaqbkiyPzUwsCj12NJsNNp4uyZmsqoUYUj2jIq018uqiA=="], + + "stoat.js": ["stoat.js@7.3.6", "", { "dependencies": { "@solid-primitives/map": "^0.7.1", "@solid-primitives/set": "^0.7.1", "@vladfrangu/async_event_emitter": "^2.4.6", "json-with-bigint": "^3.4.4", "solid-js": "^1.9.6", "stoat-api": "0.8.9-4", "ulid": "^2.4.0" } }, "sha512-q2+00tjuaGHVSBA5Sg8vBDp73k3yZtsfDyS8OUqYgHYT2uL8x5RZHsrViuloPwHHuKNldzQjib/VdQMISZIfOg=="], + + "ulid": ["ulid@2.4.0", "", { "bin": { "ulid": "bin/cli.js" } }, "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg=="], + } +} diff --git a/compose.yml.example b/compose.yml.example new file mode 100644 index 0000000..a26f610 --- /dev/null +++ b/compose.yml.example @@ -0,0 +1,6 @@ +services: + bot: + build: . + container_name: autodice-bot + restart: always + env_file: .env diff --git a/main.js b/main.js new file mode 100644 index 0000000..a61e623 --- /dev/null +++ b/main.js @@ -0,0 +1,214 @@ +const { Client } = require("stoat.js"); + +class DiceBot { + constructor() { + this.client = new Client(); + this.setupEvents(); + } + + setupEvents() { + this.client.on("connect", () => { + console.log(`AutoDice connected as ${this.client.user?.username}`); + }); + + this.client.on("error", (err) => { + console.error("The client encountered an error:", err); + }); + + this.client.on("messageCreate", async (message) => { + await this.handleMessage(message); + }); + } + + async start() { + await this.client.loginBot(process.env.BOT_TOKEN); + } + + async getDmChannel(userId) { + try { + let user = this.client.users.get(userId) || (await this.client.users.fetch(userId)); + + const existing = Array.from(this.client.channels.values()).find((c) => c.type === "DirectMessage" && c.recipient?.id === user.id); + + if (existing) return existing; + return await user.openDM(); + } catch (error) { + console.error("Direct Message Delivery Error:", error); + return null; + } + } + + async handleMessage(message) { + if (message.author?.bot || !message.content) return; + + let content = message.content.trim(); + const botMention = `<@${this.client.user?.id}>`; + + if (!content.startsWith(botMention)) return; + + content = content.slice(botMention.length).trim(); + if (!content) return; + + const args = content.split(/\s+/); + const trigger = args[0].toLowerCase(); + + if (trigger === "help") { + return await this.sendHelp(message); + } + + if (trigger === "support") { + return await this.sendSupport(message); + } + + let isDMRequest = false; + if (["dm", "secret", "priv", "private"].includes(trigger)) { + isDMRequest = true; + args.shift(); + } + + const formula = args.join(" "); + if (!this.isDiceString(formula)) return; + + await this.processRoll(message, formula, isDMRequest); + } + + async processRoll(message, input, isPrivate) { + const repeatMatch = input.match(/^(\d+)\s+(.*d.*)/i); + let output = ""; + + if (repeatMatch) { + const count = Math.min(parseInt(repeatMatch[1]), 20); + const formula = repeatMatch[2]; + output += `**Repeating ${count} times:** \`${formula}\`\n`; + for (let i = 0; i < count; i++) { + const res = this.calculateDice(formula); + output += `${i + 1}. **${res.total}** \t${res.breakdown}\n`; + } + } else { + const res = this.calculateDice(input); + output = `🎲 **${res.total}**\n${res.breakdown}`; + } + + if (isPrivate) { + const dmChannel = await this.getDmChannel(message.authorId); + if (dmChannel) { + await dmChannel.sendMessage(output); + await message.reply("✅ Sent to DMs."); + } else { + await message.reply("❌ Unable to send a DM."); + } + } else { + await message.reply(output); + } + } + + isDiceString(str) { + return /[0-9d]/.test(str) && /^[0-9d+\-*/().<>krlsha\s!]+$/i.test(str); + } + + calculateDice(expression) { + const diceRegex = /(\d+)?d(\d+)([a-z!<>=\d]*)/gi; + const rolls = []; + + const evalString = expression.replace(diceRegex, (match, countStr, sidesStr, mods) => { + const count = Math.min(parseInt(countStr || "1"), 100); + const sides = Math.min(parseInt(sidesStr), 1000); + const isExploding = mods && mods.includes("!"); + + let results = []; + + for (let i = 0; i < count; i++) { + let roll = Math.floor(Math.random() * sides) + 1; + results.push({ val: roll, kept: true, exploded: false }); + + let explosions = 0; + while (isExploding && roll === sides && sides > 1 && explosions < 10) { + roll = Math.floor(Math.random() * sides) + 1; + results.push({ val: roll, kept: true, exploded: true }); + explosions++; + } + } + + if (mods) results = this.applyModifiers(results, mods, sides); + + const sum = results.filter((r) => r.kept).reduce((a, b) => a + b.val, 0); + + const formatted = results + .map((r) => { + let display = r.val; + if (r.exploded) display = `${r.val}💥`; + return r.kept ? `**${display}**` : `~~${display}~~`; + }) + .join(", "); + + rolls.push(`${match} (${formatted})`); + return `(${sum})`; + }); + + let total = 0; + try { + total = new Function(`return ${evalString.replace(/[^0-9+\-*/().\s]/g, "")}`)(); + if (!Number.isInteger(total)) total = Number(total).toFixed(2); + } catch (e) { + total = "Error"; + } + + return { total, breakdown: rolls.join(" + ") }; + } + + applyModifiers(results, modStr, sides) { + // Reroll + const rerollMatch = modStr.match(/r([<>=]?)(\d+)/); + if (rerollMatch) { + const op = rerollMatch[1] || "="; + const target = parseInt(rerollMatch[2]); + results.forEach((d) => { + const trigger = (op === "=" && d.val === target) || (op === "<" && d.val < target) || (op === ">" && d.val > target); + if (trigger) d.val = Math.floor(Math.random() * sides) + 1; + }); + } + + // Keep/Drop + const keepMatch = modStr.match(/k([hl])(\d+)/); + if (keepMatch) { + const type = keepMatch[1]; + const num = parseInt(keepMatch[2]); + const sorted = [...results].sort((a, b) => b.val - a.val); + const toKeep = type === "h" ? sorted.slice(0, num) : sorted.slice(-num); + results.forEach((d) => { + if (!toKeep.includes(d)) d.kept = false; + }); + } + + // Sort + if (modStr.includes("sd")) results.sort((a, b) => b.val - a.val); + else if (["s", "sa"].some((opt) => modStr.includes(opt))) { + results.sort((a, b) => a.val - b.val); + } + return results; + } + + async sendHelp(message) { + await message.reply(`## AutoDice Help + + Visit [the website]() for more usage information and [the AutoMod server](https://stt.gg/automod) for help. + + - **Basic Rolls:** Use \`@AutoDice [number]d[sides]\`. (eg \`@AutoDice 10d6\`). + - **Math:** Supports \`+\`, \`-\`, \`*\`, \`/\`. Add modifiers like \`1d20 + 5\`. + - **Sorting:** Suffix \`sa\` (ascending) or \`sd\` (descending). (eg \`10d6sd\`). + - **Keep/Drop:** Use \`kh[n]\` to keep highest or \`kl[n]\` to keep lowest. (eg \`2d20kh1\` for Advantage). + - **Rerolls:** Use \`r<[n]\` or \`r>[n]\` to automatically reroll specific values. (eg \`1d10r<3\`). + - **Exploding Dice:** Suffix \`!\` to reroll and add any maximum results (eg \`4d6!\`). + - **Successes:** Suffix \`>[n]\` to count how many dice hit a target (eg \`5d10>7\`). + - **Private Rolls:** Prefix with \`private\` to receive results in your DMs. + `); + } + + async sendSupport(message) { + await message.reply( + `## Support AutoDice\n AutoDice is developed and provided free of charge, but your financial support is greatly appreciated to allow sustainable development and to cover costs. You can support me via https://vale.rocks/support. Thank you so very much!`, + ); + } +} + +new DiceBot().start(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..0bfbdab --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "autodice", + "version": "1.0.0", + "description": "A dice bot for Stoat.", + "main": "main.js", + "scripts": { + "start": "bun main.js", + "dev": "bun main.js" + }, + "dependencies": { + "stoat.js": "^7.3.6" + }, + "keywords": [ + "stoat", + "dice", + "roleplay", + "ttrpg", + "bot" + ] +} -- 2.51.2