Initial version of bot
This commit is contained in:
48
src/app.ts
Normal file
48
src/app.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { Client } from "discord.js";
|
||||
import { deployCommands } from "./deploy-commands";
|
||||
import { commands } from "./cmds";
|
||||
import { config } from "./config";
|
||||
import { generateDiscordMessage, safeParseInput } from "./lib/dice";
|
||||
|
||||
const client = new Client({
|
||||
intents: ["Guilds", "GuildMessages", "DirectMessages", "MessageContent"],
|
||||
});
|
||||
|
||||
client.once('ready', () => {
|
||||
console.log('Successfully logged in as ' + client.user?.tag);
|
||||
});
|
||||
|
||||
client.on('guildCreate', async (guild) => {
|
||||
await deployCommands({ guildId: guild.id });
|
||||
});
|
||||
|
||||
client.on('messageCreate', async (message) => {
|
||||
if (message.author.bot) return;
|
||||
|
||||
const result = safeParseInput(message.content);
|
||||
if (result !== null) {
|
||||
const response = generateDiscordMessage(result);
|
||||
if (response.length > 2000) {
|
||||
await message.reply("stop that.\n-# (The result is too long to display in a single message. Please try a smaller input.)");
|
||||
} else {
|
||||
await message.reply(response);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
client.on('interactionCreate', async (interaction) => {
|
||||
if (!interaction.isCommand()) return;
|
||||
|
||||
const { commandName } = interaction;
|
||||
if (commands[commandName as keyof typeof commands]) {
|
||||
commands[commandName as keyof typeof commands].execute(interaction);
|
||||
}
|
||||
});
|
||||
|
||||
client.login(config.DISCORD_TOKEN).then(async () => {
|
||||
console.log('Logged in successfully!');
|
||||
});
|
||||
|
||||
client.on('error', (error) => {
|
||||
console.error('Error occurred:', error);
|
||||
});
|
||||
7
src/cmds/index.ts
Normal file
7
src/cmds/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import * as ping from './ping.js';
|
||||
import * as roll from './roll.js';
|
||||
|
||||
export const commands = {
|
||||
ping,
|
||||
roll,
|
||||
}
|
||||
9
src/cmds/ping.ts
Normal file
9
src/cmds/ping.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { CommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName("ping")
|
||||
.setDescription("Replies with a keep alive check!");
|
||||
|
||||
export async function execute(interaction: CommandInteraction) {
|
||||
await interaction.reply("I am still alive!");
|
||||
}
|
||||
36
src/cmds/roll.ts
Normal file
36
src/cmds/roll.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { CommandInteraction, SlashCommandBuilder } from "discord.js";
|
||||
import { generateDiscordMessage, safeParseInput } from "../lib/dice";
|
||||
|
||||
export const data = new SlashCommandBuilder()
|
||||
.setName("roll")
|
||||
.addStringOption(option =>
|
||||
option.setName("input")
|
||||
.setDescription("The expression to roll")
|
||||
.setRequired(true))
|
||||
.setDescription("Rolls dice using hallowed arc syntax");
|
||||
|
||||
export async function execute(interaction: CommandInteraction) {
|
||||
if (!interaction.isCommand()) return;
|
||||
|
||||
const { options } = interaction;
|
||||
const input = options.get('input', true)?.value as string;
|
||||
|
||||
let result = safeParseInput(input);
|
||||
if (result === null) {
|
||||
await interaction.reply("Invalid input. Please provide a valid expression.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(result)) {
|
||||
result = [result];
|
||||
}
|
||||
|
||||
const response = generateDiscordMessage(result);
|
||||
|
||||
if (response.length > 2000) {
|
||||
await interaction.reply("stop that.\n-# (The result is too long to display in a single message. Please try a smaller input.)");
|
||||
return;
|
||||
}
|
||||
|
||||
await interaction.reply(response);
|
||||
}
|
||||
14
src/config.ts
Normal file
14
src/config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const { DISCORD_TOKEN, DISCORD_CLIENT_ID } = process.env;
|
||||
|
||||
if (!DISCORD_TOKEN || !DISCORD_CLIENT_ID) {
|
||||
throw new Error('Missing required environment variables: DISCORD_TOKEN or DISCORD_CLIENT_ID');
|
||||
}
|
||||
|
||||
export const config = {
|
||||
DISCORD_TOKEN,
|
||||
DISCORD_CLIENT_ID,
|
||||
};
|
||||
28
src/deploy-commands.ts
Normal file
28
src/deploy-commands.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { REST, Routes } from 'discord.js';
|
||||
import { config } from './config';
|
||||
import { commands } from './cmds/index.js';
|
||||
|
||||
const commandsData = Object.values(commands).map((command) => command.data);
|
||||
|
||||
const rest = new REST({ version: '10' }).setToken(config.DISCORD_TOKEN);
|
||||
|
||||
type DeployCommandsProps = {
|
||||
guildId: string;
|
||||
}
|
||||
|
||||
export async function deployCommands({ guildId }: DeployCommandsProps) {
|
||||
try {
|
||||
console.log("Started refreshing application (/) commands.");
|
||||
|
||||
await rest.put(
|
||||
Routes.applicationGuildCommands(config.DISCORD_CLIENT_ID, guildId),
|
||||
{
|
||||
body: commandsData,
|
||||
}
|
||||
);
|
||||
|
||||
console.log("Successfully reloaded application (/) commands.");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
175
src/lib/dice.ts
Normal file
175
src/lib/dice.ts
Normal file
@ -0,0 +1,175 @@
|
||||
export type DiceType = 'f' | 'r' | 't';
|
||||
|
||||
export interface RollResult {
|
||||
input: string;
|
||||
value: number;
|
||||
rolls: number[]; // Kept dice
|
||||
discarded: number[]; // Discarded dice
|
||||
summary: string; // Formatted string of all dice
|
||||
steps?: string[]; // Arithmetic operations performed
|
||||
}
|
||||
|
||||
export function rollDie(diceType: DiceType): number {
|
||||
const roll = {
|
||||
f: Math.floor(Math.random() * 12) + 1 - 6,
|
||||
r: Math.floor(Math.random() * 18) + 1 - 9,
|
||||
t: Math.floor(Math.random() * 24) + 1 - 12,
|
||||
};
|
||||
return roll[diceType];
|
||||
}
|
||||
|
||||
export function rollDice(count: number, diceType: DiceType): number[] {
|
||||
return Array.from({ length: count }, () => rollDie(diceType));
|
||||
}
|
||||
|
||||
export function getDieRange(diceType: DiceType): { min: number; max: number } {
|
||||
const ranges = {
|
||||
f: { min: -5, max: 6 },
|
||||
r: { min: -8, max: 9 },
|
||||
t: { min: -11, max: 12 },
|
||||
};
|
||||
return ranges[diceType];
|
||||
}
|
||||
|
||||
export function formatSummary(rolls: number[], discarded: number[], diceType: DiceType): string {
|
||||
const { min, max } = getDieRange(diceType);
|
||||
const all = rolls.map(r => ({ value: r, kept: true }))
|
||||
.concat(discarded.map(r => ({ value: r, kept: false })));
|
||||
|
||||
return all
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.map(({ value, kept }) => {
|
||||
const isMinOrMax = value === min || value === max;
|
||||
let formatted = `${value}`;
|
||||
if (isMinOrMax) formatted = `**${formatted}**`;
|
||||
if (!kept) formatted = `~~${formatted}~~`;
|
||||
return formatted;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function parseExpression(input: string): RollResult | RollResult[] {
|
||||
const batchMatch = input.match(/^(\d+)#(.+)$/);
|
||||
if (batchMatch) {
|
||||
const [, batchCountStr, innerExpr] = batchMatch;
|
||||
const batchCount = parseInt(batchCountStr, 10);
|
||||
return Array.from({ length: batchCount }, () => parseSingleExpression(innerExpr));
|
||||
} else {
|
||||
return parseSingleExpression(input);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSingleExpression(expr: string): RollResult {
|
||||
const baseMatch = expr.match(/^(\d+)?([frt])(?:(kh|kl)(\d+))?/);
|
||||
if (!baseMatch) throw new Error(`Invalid expression: ${expr}`);
|
||||
|
||||
const [, countStr, diceType, keepType, keepCountStr] = baseMatch;
|
||||
const diceExprLength = baseMatch[0].length;
|
||||
const arithmeticExpr = expr.slice(diceExprLength);
|
||||
|
||||
const count = parseInt(countStr || '1', 10);
|
||||
const dice = diceType as DiceType;
|
||||
const keepCount = keepCountStr ? parseInt(keepCountStr, 10) : null;
|
||||
|
||||
const allRolls = rollDice(count, dice);
|
||||
let rolls = [...allRolls];
|
||||
let discarded: number[] = [];
|
||||
|
||||
if (keepType && keepCount !== null) {
|
||||
const sorted = [...allRolls].sort((a, b) => keepType === 'kh' ? b - a : a - b);
|
||||
rolls = sorted.slice(0, keepCount);
|
||||
discarded = sorted.slice(keepCount);
|
||||
}
|
||||
|
||||
const steps: string[] = [];
|
||||
let result = rolls.reduce((acc, val) => acc + val, 0);
|
||||
steps.push(`Initial sum: ${result}`);
|
||||
|
||||
const opRegex = /([+\-*/])\s*(-?\d+(?:\.\d+)?)/g;
|
||||
let match;
|
||||
while ((match = opRegex.exec(arithmeticExpr)) !== null) {
|
||||
const [, op, valStr] = match;
|
||||
const val = parseFloat(valStr);
|
||||
const prev = result;
|
||||
switch (op) {
|
||||
case '+': result += val; break;
|
||||
case '-': result -= val; break;
|
||||
case '*': result *= val; break;
|
||||
case '/': result /= val; break;
|
||||
}
|
||||
steps.push(`${prev} ${op} ${val} = ${result}`);
|
||||
}
|
||||
|
||||
return {
|
||||
input: expr.replaceAll('*', '\\*'),
|
||||
value: result,
|
||||
rolls,
|
||||
discarded,
|
||||
summary: formatSummary(rolls, discarded, dice),
|
||||
steps
|
||||
};
|
||||
}
|
||||
|
||||
export function safeParseInput(message: string): RollResult | RollResult[] | null {
|
||||
const trimmed = message.trim();
|
||||
const isSingleWord = !trimmed.includes(' ');
|
||||
|
||||
if (isSingleWord && !trimmed.startsWith('[') && !trimmed.endsWith(']')) {
|
||||
try {
|
||||
return parseExpression(trimmed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isSingleWord && trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
const inside = trimmed.slice(1, -1).trim();
|
||||
try {
|
||||
return parseExpression(inside);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const words = trimmed.split(/\s+/);
|
||||
const expressions = words
|
||||
.filter(word => word.startsWith('[') && word.endsWith(']'))
|
||||
.map(word => word.slice(1, -1).trim());
|
||||
|
||||
if (expressions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results: RollResult[] = [];
|
||||
|
||||
for (const expr of expressions) {
|
||||
try {
|
||||
const result = parseExpression(expr);
|
||||
if (Array.isArray(result)) {
|
||||
results.push(...result);
|
||||
} else {
|
||||
results.push(result);
|
||||
}
|
||||
} catch {
|
||||
// skip invalid
|
||||
}
|
||||
}
|
||||
|
||||
return results.length > 0 ? results : null;
|
||||
}
|
||||
|
||||
export function generateDiscordMessage(result: RollResult | RollResult[]): string {
|
||||
if (!Array.isArray(result)) {
|
||||
result = [result];
|
||||
}
|
||||
|
||||
let response = `\` `;
|
||||
for (const resultset of result) {
|
||||
response += resultset.value + ' ` ⟵ ';
|
||||
response += `[${resultset.summary}] ${resultset.input}\n\` `;
|
||||
}
|
||||
response = response.slice(0, response.length - 3);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user