diff --git a/client.js b/client.js deleted file mode 100644 index e6120b2..0000000 --- a/client.js +++ /dev/null @@ -1,65 +0,0 @@ -import readline from 'node:readline/promises' -import { stdin, stdout } from 'node:process' -import WebSocket from 'ws' -import { error } from 'node:console' - -let lastMessage = '' - -const writePrompt = () => { - stdout.write('> ') - if(lastMessage) - cli.write(lastMessage) -} - -const send = object => new Promise((resolve, reject) => { - ws.send(JSON.stringify(object), error => { - if(error) - reject(error) - }) -}) - -const handleMessage = async content => { - lastMessage = content - - send({ - type: "message", - content: content - }) - .catch(error => { - console.log(error) - writePrompt() - }) -} - -const printMessage = messageBuf => { - let indent = ' '.repeat(2) - let message = messageBuf.toString('utf-8') - - message = indent + message.replaceAll('\n', '\n' + indent) + '\n' - stdout.write(message) - writePrompt() -} - -const start = () => { - send({ - type: 'login', - name: 'dev', - password: 'dev' - }) - cli.on('line', handleMessage) -} - -// -// Hooks -// - -const ws = new WebSocket('ws://localhost:8080') - -const cli = readline.createInterface({ - input: stdin, - output: stdout -}) - -ws.on('error', console.error) -ws.on('open', start) -ws.on('message', printMessage) diff --git a/package.json b/package.json index fd892ad..8a2aec7 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "dependencies": { "classic-level": "^1.3.0", "discord.js": "^14.11.0", - "dotenv": "^16.0.3", - "ws": "^8.13.0" + "dotenv": "^16.0.3" } } diff --git a/src/constants.js b/src/constants.js index 54eae9f..abbd7a9 100644 --- a/src/constants.js +++ b/src/constants.js @@ -4,41 +4,6 @@ const constants = { descriptionRegex: /\s*((\d*-\d*)|(\d+))?([^;\n]+)/g, macroNameRegex: /^[a-z0-9]+$/, - events: { - login: 'login' - }, - - schemas: { - events: { - login: { - 'name': 'string', - 'password': 'string' - } - } - }, - - errors: { - invalidPacket: error => ({ - type: 'error', - id: 0, - message: 'Invalid packet: ' + error - }), - invalidReference: error => ({ - type: 'error', - id: 0, - message: 'Invalid packet reference: ' + error - }), - badLogin: () => ({ - type: 'error', - id: 10, - message: 'There is no client with that name, or the password does not match.' - }) - }, - - clients: new Map([ - [ 'dev', 'dev' ] - ]), - commands: { about: { name: 'about', @@ -87,7 +52,6 @@ const constants = { iconUrl: 'https://github.com/Dakedres/dicedicedice/raw/main/assets/eater-transparent.png', - errorMessage: error => `\ Something went wrong trying to execute that command. \`\`\`fix diff --git a/src/index.js b/src/index.js index badce92..7089b4a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,108 +1,42 @@ -import constants from './constants.js' -import { WebSocketServer } from 'ws' -import { EventEmitter } from 'node:events' +import { Client, GatewayIntentBits, Partials, REST, Routes } from 'discord.js'; +import * as dotenv from 'dotenv' +import constants from './constants.js'; +import { ClassicLevel } from 'classic-level'; -let connections = new Map() +dotenv.config() -// -// Login & events -// +const replies = new Map() +const commands = new Map() +const db = new ClassicLevel('./db') +const macroCache = new Map() + +const parseRollInt = (value, defaultValue) => + value ? parseInt(value) : defaultValue -const handleConnection = ws => { - let client +const parseOptionRoll = expression => { + let match = constants.optionRollRegex.exec(expression.trim()) - ws.on('error', console.error) - - ws.on('message', data => { - let event - - try { - event = JSON.parse(data.toString('utf-8') ) - } catch(err) { - sendToWebsocket(ws, constants.errors.invalidPacket(err) ) - return - } - - if(typeof event !== 'object' || Array.isArray(event) ) { - sendToWebsocket(ws, constants.errors.invalidPacket('Event is not an object') ) - return - } - - console.log(event) - - if(client) { - if(event.reference && typeof event.reference == 'object') - sendToWebsocket(ws, constants.errors.invalidReference('Reference cannot be an object') ) - - event.client = client - handleEvent(event) - } else if(event.type === constants.events.login) { - client = handleLogin(event, ws) - } - }) -} - -const handleLogin = (event, ws) => { - if(constants.clients.get(event.name) !== event.password) { - replyToWebsocket(ws, event, constants.errors.badLogin() ) - return + let [ + count, + modeSize, + mode, + size, + operationModifier, + operation, + modifier + ] = match + .slice(1) + + return { + count: parseRollInt(count), + mode, + size: parseRollInt(size), + operation, + modifier: parseRollInt(modifier), + descriptionConditions: pullDescription(expression, match) } - - console.log('worked?') - - connections.set(event.name, ws) - replyToWebsocket(ws, event, { type: 'success' }) - return event.name } -const sendToWebsocket = (ws, event) => - ws.send(JSON.stringify(event) ) - -const replyToWebsocket = (ws, toEvent, withEvent) => { - let event = { - ...withEvent, - reference: toEvent.reference - } - - console.log(connections) - - return sendToWebsocket(ws, event) -} - -const handleEvent = event => { - if(typeof event.type != 'string') { - reply(event, constants.errors.invalidPacket("No event type.") ) - } - - bot.emit(event.type, event) -} - -const reply = (toEvent, withEvent) => - replyToWebsocket(connections.get(toEvent.client), toEvent, withEvent) - -// -// Command handling -// - -const handleMessage = message => { - console.log(message) - - let dice = parseRoll(message.content) - - const respond = content => reply(message, { - type: 'message', - content - }) - - if(dice) - return rollDice(dice, respond) -} - -// -// Rolls -// Most of this is pulled straight from the original -// Discord version atm. - const parseRoll = expression => { let match = constants.rollRegex.exec(expression.trim()) @@ -128,9 +62,6 @@ const parseRoll = expression => { } } -const parseRollInt = (value, defaultValue) => - value ? parseInt(value) : defaultValue - const pullDescription = (expression, match) => { if(match[0].length == expression.length) return @@ -138,6 +69,50 @@ const pullDescription = (expression, match) => { return parseDescription(expression.slice(match[0].length)) } +const parseDescription = description => { + let conditions = [] + let match + + while((match = constants.descriptionRegex.exec(description)) !== null) { + let range + let [ + rangeExp, + valueExp, + content + ] = match.slice(2) + + if(rangeExp) { + let split = rangeExp.split('-') + + range = { + lower: parseRollInt(split[0], -Infinity), + upper: parseRollInt(split[1], Infinity) + } + } else if(valueExp) { + range = { + upper: valueExp, + lower: valueExp + } + } + + conditions.push({ + range, + content: content.trim() + }) + } + + return conditions +} + +const handleMessage = (message, respond) => { + let dice = parseRoll(message.content) + + if(dice == undefined) + return // No dice + + rollDice(dice, respond) +} + const rollDice = (dice, respond) => { if(dice.size > 255) { respond('That die is way too big... .-.') @@ -222,16 +197,362 @@ const rollDice = (dice, respond) => { respond(response) } -// -// Hooks -// +const saveReply = (message, reply) => { + replies.set(message.id, { + id: reply.id, + timestamp: Date.now() + }) +} -const bot = new EventEmitter() +const messageCycle = async message => { + handleMessage(message, async content => { + saveReply(message, await message.reply(content) ) + }) +} -bot.on('message', handleMessage) +const rehandleMessage = async (message, reply) => { + handleMessage(message, async content => { + saveReply(message, await reply.edit(content) ) + }) +} -const server = new WebSocketServer({ - port: 8080 +const pruneReplies = () => { + for(let [ id, entry ] of replies.entries()) { + let age = Date.now() - entry.timestamp + + if(age > 1000 * 60 * 3) { + replies.delete(id) + } + } +} + +const interactionRespond = (interaction, content) => { + let reply = { content, ephemeral: true } + + if(interaction.replied || interaction.deferred) { + return interaction.followUp(reply) + } else { + return interaction.reply(reply) + } +} + +const handleError = (interaction) => (error) => + interactionRespond(interaction, constants.errorMessage(error) ) + .catch(reportingError => console.error('Could not display error message:\n ', reportingError) ) + + +const addCommand = (data, callback) => { + commands.set(data.name, { + data, + execute: callback + }) +} + +const addSubcommands = (data, subcommandCallbacks) => + addCommand(data, interaction => { + return subcommandCallbacks[interaction.options.getSubcommand()](interaction) + }) + +const openMacros = guildId => + db.sublevel(guildId).sublevel('macros') + +const reloadMacros = async guildId => { + let commands = [] + let macros = openMacros(guildId) + let cacheEntry = {} + + for await (let [ name, dice ] of macros.iterator() ) { + cacheEntry[name] = dice + + commands.push({ + name, + description: elipsify("Roll " + dice.replaceAll('\n', ';'), 100), + options: [ + { + name: "options", + description: "Dice, modifiers, or descriptions to apply over the macro", + type: 3 + } + ] + }) + } + + macroCache.set(guildId, cacheEntry) + + await rest.put( + Routes.applicationGuildCommands(process.env.DISCORD_ID, guildId), + { body: commands } + ) + .catch(err => console.error('Failed to reload macros:', err) ) +} + +const elipsify = (string, maxLength) => + string.length > maxLength ? string.slice(0, maxLength - 3) + '...' : string + +const pruneDB = async () => { + let validIds = [] + + for await(let key of db.keys()) { + let [ guildId ] = key.split('!').slice(1) + + if(validIds.includes(guildId)) + continue + + if(client.guilds.cache.has(guildId)) { + validIds.push(guildId) + } else { + await db.del(key) + } + } + + return validIds +} + + +addCommand( + constants.commands.about, + async interaction => { + let embed = { + title: 'dicedicedice', + thumbnail: { + url: constants.iconUrl + }, + description: constants.aboutMessage(client.guilds.cache.size) + } + + await interaction.reply({ + embeds: [ embed ], + ephemeral: true + }) + } +) + +const openResponses = (interaction, ephemeral) => async content => + interaction.reply({ content, ephemeral }) + +addSubcommands({ + name: 'macro', + description: "Manage macros", + 'dm_permission': false, + options: [ + { + name: 'add', + description: "Define a dice macro", + type: 1, // Sub command + options: [ + { + name: "name", + description: "Name of the macro", + type: 3, // String + required: true + }, + { + name: "dice", + description: "The dice expression to save as a macro", + type: 3, // String + required: true + } + ] + }, + { + name: 'remove', + description: "Remove a macro", + type: 1, // Sub command + options: [ + { + name: "name", + description: "Name of the macro", + type: 3, // String + required: true, + autocomplete: true, + getAutocomplete: interaction => { + let macros = macroCache.get(interaction.guild.id) + + return macros ? Object.keys(macros) : [] + } + } + ] + } + ] +}, { + add: async interaction => { + let respond = openResponses(interaction, true) + let name = interaction.options.get('name').value.toLowerCase() + + if(!constants.macroNameRegex.test(name)) + return respond("Please provide a macro name that consists of only alphanumeric characters.") + + if(commands.has(name)) + return respond("Uhh,, I think that macro name is already taken by my own commands, sorry.") + + let macros = macroCache.get(interaction.guild.id) + + if(macros && !macros[name] && Object.keys(macros).length >= 100) + return respond("I can't keep track of that many macros,, ;-;") + + let dice = interaction.options.get('dice').value + + if(!constants.rollRegex.test(dice) ) + return respond("Please provide a valid roll expression.") + + await interaction.deferReply({ ephemeral: true }) + + await Promise.all([ + openMacros(interaction.guild.id).put(name, dice), + reloadMacros(interaction.guild.id) + ]) + interaction.followUp(`Macro added! Try \`/${name}\`! You might need to switch to a different server and back or reopen Discord in order for it to recognize the new command.`) + }, + remove: async interaction => { + let name = interaction.options.get('name').value.toLowerCase() + let macros = macroCache.get(interaction.guild.id) + let respond = openResponses(interaction, true) + + if(!macros) + return respond('There aren\'t even any macros in this guild!') + + let dice = macros && macroCache.get(interaction.guild.id)[name] + + if(!dice) + return respond("There isn't a macro with that name .-.") + + await interaction.deferReply({ ephemeral: true }) + await Promise.all([ + openMacros(interaction.guild.id).del(name), + reloadMacros(interaction.guild.id) + ]) + + await interaction.followUp(`Removed \`${name}\`, its dice expression was: \`\`\`${dice}\`\`\``) + } }) -server.on('connection', handleConnection) \ No newline at end of file + + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages + ], + partials: [ + Partials.Channel + ] +}) + +const safeSubscribe = (event, callback) => { + client.on(event, (...args) => { + return callback(...args) + .catch(err => console.error(err)) + }) +} + +const rest = new REST().setToken(process.env.DISCORD_TOKEN) + +safeSubscribe('ready', async () => { + console.log("Logged in!") + + let guildIds = await pruneDB() + + for(let guildId of guildIds) + await reloadMacros(guildId) + + console.log("Ready") +}) + +safeSubscribe('messageCreate', messageCycle) + +safeSubscribe('messageUpdate', async (oldMessage, newMessage) => { + if(replies.has(newMessage.id) ) { + let { id } = replies.get(newMessage.id) + + newMessage.channel.messages.fetch(id) + .then(reply => rehandleMessage(newMessage, reply) ) + .catch(err => messageCycle(newMessage) ) + } else { + messageCycle(newMessage) + } +}) + +const handleCommand = async interaction => { + if(commands.has(interaction.commandName) ) { + commands.get(interaction.commandName).execute(interaction) + .catch(handleError(interaction)) + return + } + + await interaction.deferReply() + let roll = macroCache.get(interaction.guild.id)[interaction.commandName] + + if(roll) { + let dice = parseRoll(roll) + let options = interaction.options.get('options') + + if(options) { + let optionDice = parseOptionRoll(options.value) + + for(let [ key, value ] of Object.entries(optionDice)) { + if(value) + dice[key] = Array.isArray(value) ? value.concat(dice[key]) : value + } + } + + rollDice(dice, content => interaction.followUp(content) ) + } +} + +const findOption = (options, name) => + options.find(option => option.name == name) + +const handleAutocomplete = async interaction => { + if(commands.has(interaction.commandName) ) { + let { data } = commands.get(interaction.commandName) + let subcommand = interaction.options.getSubcommand() + let focusedOption = interaction.options.getFocused(true) + + if(subcommand !== undefined) { + data = findOption(data.options, subcommand) + } + + let option = findOption(data.options, focusedOption.name) + + if(!option) { + console.error('Could not find option: ' + focusedOption) + return + } + + let filtered = option + .getAutocomplete(interaction) + .filter(choice => choice.startsWith(focusedOption.value) ) + .map(choice => ({ name: choice, value: choice }) ) + + await interaction.respond(filtered) + } +} + +safeSubscribe('interactionCreate', interaction => { + if(interaction.isChatInputCommand()) { + return handleCommand(interaction) + } else if(interaction.isAutocomplete()) { + return handleAutocomplete(interaction) + .catch(console.error) + } +}) + + + +;(async () => { + await rest.put( + Routes.applicationCommands(process.env.DISCORD_ID), + { + body: [ ...commands.values() ] + .map(command => command.data ) + } + ) + .catch(err => console.error('Command registration failed: ', err) ) + + await client.login(process.env.DISCORD_TOKEN) + .catch(err => console.error('Login failed: ', err) ) + + setInterval(pruneReplies, 1000 * 60) +})()