diff --git a/.gitignore b/.gitignore index 0b64dde..626d0bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,4 @@ -# Ignore everything -* - -# Except these -!.gitignore -!minetestbot.lua -!README.md -!relay.lua -!settings.example -!util.lua +config.json +*-lock.json +node_modules/ +legacy/ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..db9b17d --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,19 @@ +MIT Copyright 2019 GreenDimond + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 71ad892..311d4b1 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,19 @@ # MinetestBot for Discord # +_MinetestBot and it's author(s) are in no way affiliated with the IRC MinetestBot._ -Discordia and Luvit are required to run this bot. -See the [Discordia README](https://github.com/SinisterRectus/Discordia/blob/master/README.md) for instructions on insalling Discordia and Luvit. +Uses NodeJS and `discord.js`. -To run the bot, use `/path/to/luvit/executable/minetestbot.lua`. +To use the bot, run `node .` in the bot directory. -A `settings.lua` file is required for the bot to run. See `settings.example`. +MinetestBot demands a ~~sacrifice~~ `config.json`. See `config.example`. ### Todo (PRs welcome) ### * Vote starter * Serverlist searcher/parser * Metric/US value conversion command -* `minetest.conf.example` search -* Define +* Word definition * Forum search + +Might be missing `;` everywhere. + +This used to be written in Lua, but Luvit is garbage. diff --git a/commands/cdb.js b/commands/cdb.js new file mode 100644 index 0000000..7df784a --- /dev/null +++ b/commands/cdb.js @@ -0,0 +1,70 @@ +const {color} = require("../config.js"); +const request = require("request"); + +module.exports = { + name: "cdb", + aliases: ["contentdb", "mod", "modsearch", "search"], + usage: "[search term]", + description: "Search the ContentDB", + execute: function(message, args) { + if (!args.length) { + const embed = { + url: "https://content.minetest.net/", + title: "**ContentDB**", + description: "Minetest's official content repository.", + color: color, + thumbnail: { + url: "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Minetest-logo.svg/1024px-Minetest-logo.svg.png", + }, + fields: [ + { + name: "Usage:", + value: "``" + } + ], + }; + message.channel.send({embed: embed}); + } else { + const term = args.join(" "); + request({ + url: `https://content.minetest.net/api/packages/?q=${term}&lucky=1`, + json: true, + }, function(err, res, body) { + if (!body.length) { + const embed = { + title: `Could not find any packages related to "${term}".`, + color: color, + }; + message.channel.send({embed: embed}); + } else { + const meta = body[0]; + request({ + url: `https://content.minetest.net/api/packages/${meta.author}/${meta.name}/`, + json: true, + }, function(err, res, pkg) { + let desc = `${pkg.short_description}`; + let info = []; + if (pkg.forums) info.push(`[Forum thread](https://forum.minetest.net/viewtopic.php?t=${pkg.forums})`); + if (pkg.repo) info.push(`[Git Repo](${pkg.repo})`); + const embed = { + url: encodeURI(`https://content.minetest.net/packages/${meta.author}/${meta.name}/`), + title: `**${pkg.title}**`, + description: `By ${pkg.author}`, + color: color, + image: { + url: pkg.thumbnail + }, + fields: [ + { + name: "Description:", + value: `${desc}\n${info.join(" | ")}` + } + ] + }; + message.channel.send({embed: embed}); + }); + } + }); + } + } +}; diff --git a/commands/conf.js b/commands/conf.js new file mode 100644 index 0000000..ac7a312 --- /dev/null +++ b/commands/conf.js @@ -0,0 +1,72 @@ +const {color, version} = require("../config.js") +const minetest_logo = "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Minetest-logo.svg/1024px-Minetest-logo.svg.png"; +const confURL = `https://github.com/minetest/minetest/blob/${version}/minetest.conf.example`; +const rawURL = `https://raw.githubusercontent.com/minetest/minetest/${version}/minetest.conf.example`; +const pageSize = 6; +const pages = require("../pages.js"); + +module.exports = { + name: "conf", + aliases: ["mtconf"], + usage: "", + description: "Search minetest.conf.example", + execute: async function(message, args) { + if (!args.length) { + const embed = { + title: "Minetest Configuration", + thumbnail: { + url: minetest_logo, + }, + color: color, + fields: [ + { + name: "minetest.conf.example (stable)", + value: confURL, + }, + { + name: "minetest.conf.example (unstable)", + value: "https://github.com/minetest/minetest/blob/master/minetest.conf.example" + } + ] + }; + const msg = await message.channel.send({embed: embed}); + pages.addControls(msg, false); + } else { + const term = args.join(" "); + pages.getPage("conf", message, { + url: { + search: rawURL, + display: confURL + }, + page: 1, + pageSize: pageSize, + title: "Minetest Configuration", + thumbnail: minetest_logo, + }, term, async function(embed, results) { + let turn = true; + if (results.length > 100) turn = false; + const msg = await message.channel.send({embed: embed}); + pages.addControls(msg, turn); + }); + } + }, + page: { + execute: function(message, page) { + const oldEmbed = message.embeds[0]; + const term = oldEmbed.description.match(/Results for \[`(.+)`\]/)[1]; + pages.getPage("conf", message, { + url: { + search: rawURL, + display: confURL + }, + page: page, + pageSize: pageSize, + title: "Minetest Configuration", + thumbnail: minetest_logo, + }, term, function(embed) { + embed.footer.icon_url = oldEmbed.footer.iconURL; + message.edit({embed: embed}); + }); + }, + } +}; diff --git a/commands/help.js b/commands/help.js new file mode 100644 index 0000000..4860f03 --- /dev/null +++ b/commands/help.js @@ -0,0 +1,84 @@ +const {prefix} = require("../config.json"); +const {color} = require("../config.js"); + +module.exports = { + name: "help", + description: "List available commands or info about a specific command.", + aliases: ["commands"], + usage: "[command name]", + // cooldown: 5, + execute(message, args) { + const client = message.client; + const {commands} = client; + + const fields = []; + + let append = ""; + + if (!args.length) { + commands.array().forEach((command) => { + let desc = "No description."; + if (command.description) desc = command.description; + + let aliases = ""; + if (command.aliases) aliases = ` | Aliases: ${command.aliases.join(", ")}`; + + fields.push({ + name: `Command: \`${command.name}\``, + value: `${desc}${aliases}` + }) + }) + + append = ` | See ${prefix}help for usage.`; + } else { + const name = args[0].toLowerCase(); + const command = commands.get(name) || commands.find(c => c.aliases && c.aliases.includes(name)); + + if (!command) { + message.channel.send({embed: { + color: color, + title: `Command "${name}" does not exist.`, + timestamp: new Date(), + footer: { + text: `Use ${prefix}help to list all commands.` + }, + }}); + return; + } + + let desc = "No description."; + if (command.description) desc = command.description; + + let aliases = ""; + if (command.aliases) aliases = ` | Aliases: ${command.aliases.join(", ")}`; + + let usage = ""; + if (command.usage) usage = `\nUsage: \`${command.usage}\``; + fields.push({ + name: `Command: \`${command.name}\``, + value: `${desc}${aliases}${usage}` + }) + } + + const embed = { + color: color, + title: `${client.user.username} Commands:`, + thumbnail: { + url: client.user.avatarURL, + }, + fields: fields, + timestamp: new Date(), + footer: { + text: `Prefix: ${prefix}${append}` + }, + }; + + return message.author.send({embed: embed}).then(() => { + if (message.channel.type === "dm") return; + message.reply("I\'ve sent you a DM with all my commands."); + }).catch(error => { + console.error(`Could not send help DM to ${message.author.tag}.\n`, error); + message.reply("help DM couldn't be sent, do you have DMs disabled?"); + }); + }, +}; diff --git a/commands/info.js b/commands/info.js new file mode 100644 index 0000000..6faa326 --- /dev/null +++ b/commands/info.js @@ -0,0 +1,64 @@ +const {color} = require("../config.js"); + +function pluralize(time, period) { + const msg = `${time.toString()} ${period}`; + if (time > 1) { + return `${msg}s, `; + } else if (time == 0) { + return ""; + } + return `${msg}, `; +} + +function duration(ms) { + let sec = (ms / 1000) | 0; + + let min = (sec / 60) | 0; + sec -= min * 60; + + let hrs = (min / 60) | 0; + min -= hrs * 60; + + let day = (hrs / 24) | 0; + hrs -= day * 24; + + let wks = (day / 7) | 0; + day -= wks * 7; + + return `${pluralize(wks, "week")}${pluralize(day, "day")}${pluralize(hrs, "hour")}${pluralize(min, "minute")}${pluralize(sec, "second").slice(0, -2)}.`; +} + +module.exports = { + name: "info", + aliases: ["about"], + description: "Bot info.", + execute(message) { + const client = message.client; + const creator = client.users.get("286032516467654656"); + const embed = { + color: color, + title: `${client.user.username} Info`, + description: "Open-source, JavaScript-powered, Discord bot providing useful Minetest features. Consider [donating](https://www.patreon.com/GreenXenith/).", + thumbnail: { + url: client.user.avatarURL, + }, + fields: [ + { + name: "Sauce", + value: "" + }, + { + name: "Uptime", + value: duration(client.uptime) + }, + ], + timestamp: new Date(), + footer: { + text: `Created by ${creator.tag}`, + icon_url: creator.avatarURL, + }, + }; + + message.channel.send({embed: embed}); + }, +}; diff --git a/commands/lmgtfy.js b/commands/lmgtfy.js new file mode 100644 index 0000000..71bb29d --- /dev/null +++ b/commands/lmgtfy.js @@ -0,0 +1,72 @@ +const {color} = require("../config.js"); + +module.exports = { + name: "lmgtfy", + usage: "[-x] [-g|y|d|b] ", + description: "Let Me Google That For You.", + execute: function(message, args) { + let iie = false; + let engine = "g"; + let footer = ""; + + const valid = ["g", "d", "b", "y"]; + const a = new RegExp(/^\-\w$/); + + while (a.test(args[0])) { + const arg = args.shift().slice(1); + if (valid.includes(arg)) { + engine = arg; + } else if (arg === "x") { + iie = true; + footer = "Internet Explainer"; + } + } + + const engines = { + g: { + icon: "https://cdn4.iconfinder.com/data/icons/new-google-logo-2015/400/new-google-favicon-512.png", + name: "Google", + color: "4081EC", + }, + y: { + icon: "https://cdn1.iconfinder.com/data/icons/smallicons-logotypes/32/yahoo-512.png", + name: "Yahoo", + color: "770094", + }, + b: { + icon: "https://cdn.icon-icons.com/icons2/1195/PNG/512/1490889706-bing_82538.png", + name: "Bing", + color: "ECB726", + }, + d: { + icon: "https://cdn.icon-icons.com/icons2/844/PNG/512/DuckDuckGo_icon-icons.com_67089.png", + name: "DuckDuckGo", + color: "D75531", + }, + }; + + const term = args.join(" "); + let embed = {}; + + if (term === "") { + embed = { + title: "Empty search term.", + color: color + }; + } else { + embed = { + title: `${engines[engine].name} Search:`, + thumbnail: { + url: engines[engine].icon, + }, + description: `[Search for "${term}"](http://lmgtfy.com/?s=${engine}&iie=${iie}&q=${encodeURI(term)}).`, + color: parseInt(engines[engine].color, 16), + footer: { + text: footer + } + }; + } + + message.channel.send({embed: embed}); + } +}; diff --git a/commands/lua_api.js b/commands/lua_api.js new file mode 100644 index 0000000..f956e7d --- /dev/null +++ b/commands/lua_api.js @@ -0,0 +1,81 @@ +const {color, version} = require("../config.js") +const minetest_logo = "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Minetest-logo.svg/1024px-Minetest-logo.svg.png"; +const apiURL = `https://github.com/minetest/minetest/blob/${version}/doc/lua_api.txt`; +const rawURL = `https://raw.githubusercontent.com/minetest/minetest/${version}/doc/lua_api.txt`; +const pageSize = 6; +const pages = require("../pages.js"); + +module.exports = { + name: "lua_api", + aliases: ["api", "rtfm", "docs", "doc"], + usage: "", + description: "Get Lua API links or search the Lua API", + execute: async function(message, args) { + if (!args.length) { + const embed = { + title: "Lua API", + thumbnail: { + url: minetest_logo, + }, + description: "Minetest Lua API Documentation", + color: color, + fields: [ + { + name: `lua_api.txt (stable, ${version})`, + value: `Lua API in a text file (use CTRL+F). Located [here](${apiURL}).` + }, + { + name: "lua_api.txt (unstable)", + value: "Unstable Lua API in a text file (use CTRL+F). Located [here](https://github.com/minetest/minetest/blob/master/doc/lua_api.txt)." + }, + { + name: "Read the Docs Minetest API", + value: "lua_api.txt with very nice formatting. Located [here](http://minetest.gitlab.io/minetest/)." + }, + { + name: "lua_api.txt with proper markdown", + value: "lua_api.txt but looks a little nicer. Located [here](https://rubenwardy.com/minetest_modding_book/lua_api.html)." + }, + ] + }; + const msg = await message.channel.send({embed: embed}); + pages.addControls(msg, false); + } else { + const term = args.join(" "); + pages.getPage("lua_api", message, { + url: { + search: rawURL, + display: apiURL + }, + page: 1, + pageSize: pageSize, + title: "Minetest Lua API", + thumbnail: minetest_logo, + }, term, async function(embed, results) { + let turn = true; + if (results.length > 100) turn = false; + const msg = await message.channel.send({embed: embed}); + pages.addControls(msg, turn); + }); + } + }, + page: { + execute: function(message, page) { + const oldEmbed = message.embeds[0]; + const term = oldEmbed.description.match(/Results for \[`(.+)`\]/)[1]; + pages.getPage("lua_api", message, { + url: { + search: rawURL, + display: apiURL + }, + page: page, + pageSize: pageSize, + title: "Minetest Lua API", + thumbnail: minetest_logo, + }, term, function(embed) { + embed.footer.icon_url = oldEmbed.footer.iconURL; + message.edit({embed: embed}); + }); + }, + } +}; diff --git a/commands/minetest.js b/commands/minetest.js new file mode 100644 index 0000000..587f95d --- /dev/null +++ b/commands/minetest.js @@ -0,0 +1,190 @@ +const {prefix} = require("../config.json"); +const {color, version} = require("../config.js"); +const minetest_logo = "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Minetest-logo.svg/1024px-Minetest-logo.svg.png"; +const commands = { + default: { + title: "Helpful Minetest Commands", + fields: [ + { + name: "Usage:", + value: `\`${prefix}minetest \`` + }, + { + name: "Avaliable commands:", + value: "```\ninstall\ncompile\nabout```" + }, + ] + }, + install: { + default: { + name: "Install Minetest", + url: "https://www.minetest.net/downloads/", + title: "Downloads for Minetest are located here.", + fields: [ + { + name: `Use \`${prefix}minetest install OShere\` for OS-specific instructions.`, + value: "```\nlinux\nwindows\nmac\nandroid\nios```" + }, + ] + }, + linux: { + name: "Install Minetest (Linux)", + icon: "http://www.stickpng.com/assets/images/58480e82cef1014c0b5e4927.png", + title: "The recommended way to install Minetest on Linux is through your package manager.\nNote: the version shipped by default may be out of date.\nIn which case, you can use a PPA (if applicable), or compiling may be a better option.", + fields: [ + { + name: "__For Debian/Ubuntu-based Distributions:__", + value: "Open a terminal and run these 3 commands:\n```sudo add-apt-repository ppa:minetestdevs/stable\nsudo apt update\nsudo apt install minetest```" + }, + { + name: "__For Arch Distributions:__", + value: "Open a terminal and run this command:\n```sudo pacman -S minetest```" + }, + { + name: "Again, this will vary depending on your distribution. ", + value: `**[Google](https://www.google.com/) is your friend.**\n\nWhile slightly more involved, compiling works on any Linux distribution.\nSee \`${prefix}minetest compile linux\` for details.` + }, + ] + }, + windows: { + name: "Install Minetest (Windows)", + icon: "http://pngimg.com/uploads/windows_logos/windows_logos_PNG25.png", + title: "Installing Minetest on Windows is quite simple.", + fields: [ + { + name: "__Download:__", + value: "Visit https://www.minetest.net/downloads/, navigate to the Windows downloads, and download the proper package for your system." + }, + { + name: "__Installation:__", + value: "Extract your Minetest folder to the location of your choice.\n\nThe executable is located in `YOUR-DIR-PATH\\minetest\\bin\\`.\n\nCreate a desktop link to the executable." + }, + ] + }, + mac: { + name: "Install Minetest (MacOS)", + icon: "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bb/OS_X_El_Capitan_logo.svg/1024px-OS_X_El_Capitan_logo.svg.png", + title: "Installing Minetest on MacOS is about as simple as it gets.", + fields: [ + { + name: "__Installation:__", + value: "Open a terminal.\n\nMake sure you have [homebrew](https://brew.sh/) installed!\nRun:\n```brew install minetest```" + }, + ] + }, + android: { + name: "Install Minetest (Android)", + icon: "https://cdn.freebiesupply.com/logos/large/2x/android-logo-png-transparent.png", + title: "Minetest on mobile devices is not the best experience, but if you really must..", + fields: [ + { + name: "__Download:__", + value: "Navigate to the Google Play Store.\n\nSearch for and download the [official Minetest app](https://play.google.com/store/apps/details?id=net.minetest.minetest).\n\nOptionally, you may also download [Rubenwardy's Minetest Mods app](https://play.google.com/store/apps/details?id=com.rubenwardy.minetestmodmanager)." + }, + ] + }, + ios: { + name: "Install Minetest (iOS)", + icon: "https://png.icons8.com/color/1600/ios-logo.png", + title: "Step one: Switch to an Android device!", + fields: [ + { + name: "__Installation:__", + value: "No, seriously. There is no official Minetest app on the app store for iOS devices.\nHowever, there are more than enough \"clones\" full of ads and bugs you could try if you really wanted to." + }, + ] + }, + }, + compile: { + default: { + name: "Compile Minetest", + url: "https://dev.minetest.net/Compiling_Minetest", + title: "Compiling instructions are located here.", + fields: [ + { + name: `Use \`${prefix}minetest compile OShere\` for OS-specific instructions.`, + value: "```\nlinux\nwindows```" + }, + ] + }, + linux: { + name: "Compile Minetest (Linux)", + icon: "http://www.stickpng.com/assets/images/58480e82cef1014c0b5e4927.png", + title: "Compiling on Linux will allow you to view and modify the source code yourself, as well as run multiple Minetest builds.", + fields: [ + { + name: "__Compiling:__", + value: "Open a terminal.\n\nInstall dependencies. Here's an example for Debian-based and Ubuntu-based distributions:\n```apt install build-essential cmake git libirrlicht-dev libbz2-dev libgettextpo-dev libfreetype6-dev libpng12-dev libjpeg8-dev libxxf86vm-dev libgl1-mesa-dev libsqlite3-dev libogg-dev libvorbis-dev libopenal-dev libhiredis-dev libcurl3-dev```\n\nDownload the engine:```git clone https://github.com/minetest/minetest.git cd minetest/```\n\nDownload Minetest Game:\n```cd games/ git clone https://github.com/minetest/minetest_game.git cd ../```\n\nBuild the game (the make command is set to automatically detect the number of CPU threads to use):\n```cmake . -DENABLE_GETTEXT=1 -DENABLE_FREETYPE=1 -DENABLE_LEVELDB=1 -DENABLE_REDIS=1\nmake -j$(grep -c processor /proc/cpuinfo)```\n\nFor more information, see https://dev.minetest.net/Compiling_Minetest#Compiling_on_GNU.2FLinux." + }, + ] + }, + windows: { + name: "Compile Minetest (Windows)", + icon: "http://pngimg.com/uploads/windows_logos/windows_logos_PNG25.png", + title: "Compiling on Windows is not an easy task, and is not going to be covered easily by me.", + fields: [ + { + name: "__Compiling:__", + value: "Please see https://dev.minetest.net/Compiling_Minetest#Compiling_on_Windows for instructions on compiling Minetest on Windows." + }, + ] + }, + }, + about: { + default: { + name: "About Minetest", + icon: minetest_logo, + fields: [ + { + name: "Features", + value: "https://www.minetest.net/#Features" + }, + { + name: "Latest Version", + value: `[${version}](https://www.github.com/minetest/minetest/releases/latest)` + } + ] + }, + } +}; + +module.exports = { + name: "minetest", + aliases: ["mt"], + description: "General Minetest helpers.", + usage: "Use empty command to see options.", + execute(message, args) { + let content = {}; + if (!args[0]) { + content.title = commands.default.title; + content.fields = commands.default.fields; + content.name = "Minetest"; + content.icon = minetest_logo; + } else { + if (!commands[args[0]]) return; + + if (!args[1]) args[1] = "default"; + if (!commands[args[0]][args[1]]) return; + + content.url = commands[args[0]][args[1]].url; + content.title = commands[args[0]][args[1]].title; + content.fields = commands[args[0]][args[1]].fields; + content.name = commands[args[0]][args[1]].name; + content.icon = commands[args[0]][args[1]].icon; + } + + const embed = { + url: content.url || "", + title: content.title, + color: color, + author: { + name: content.name, + icon_url: content.icon + }, + fields: content.fields || [] + }; + + // Send the message + message.channel.send({embed: embed}); + } +} diff --git a/commands/modbook.js b/commands/modbook.js new file mode 100644 index 0000000..763afc6 --- /dev/null +++ b/commands/modbook.js @@ -0,0 +1,139 @@ +const {color} = require("../config.js"); +const request = require("request"); +const jsonURL = "https://rubenwardy.com/minetest_modding_book/sitemap.json"; +const bookURL = "https://rubenwardy.com/minetest_modding_book/en/index.html"; +const rubenAvatar = "https://avatars0.githubusercontent.com/u/2122943?s=460&v=4.png"; + +const pageSize = 6; +const pages = require("../pages.js"); + +function bookPage(message, page, func) { + request({ + url: jsonURL, + json: true, + }, async function(err, res, body) { + const embed = { + title: "Minetest Modding Book", + url: bookURL, + thumbnail: { + url: rubenAvatar, + }, + description: "By Rubenwardy", + color: color, + footer: pages.pageFooter(message, "modbook", page, Math.ceil(body.length / pageSize)), + fields: [], + }; + + let chapters = []; + + // Get chapters + for (let i = 0; i < body.length; i++) { + const c = body[i]; + if (c.chapter_number) { + chapters.push(c) + } + } + + // Populate page + for (let i = (page - 1) * pageSize; i < (page * pageSize); i++) { + const c = chapters[i]; + if (!c) break; + embed.fields.push({ + name: `**Chapter ${c.chapter_number}: ${c.title}**`, + value: `${c.description || ""} [[Open]](${c.loc})` + }); + } + + func(embed); + }); +} + +module.exports = { + name: "modbook", + aliases: ["book", "mb"], + usage: "[search term]", + description: "Search Rubenwardy's Modding Book", + execute: async function(message, args) { + if(!args.length) { + bookPage(message, 1, async function(embed) { + const msg = await message.channel.send({embed: embed}); + pages.addControls(msg); + }); + } else { + const term = args[0].toLowerCase(); + request({ + url: jsonURL, + json: true, + }, async function(err, res, body) { + let chapters = []; + let chapter = 0; + + // Get chapters + for (let i = 0; i < body.length; i++) { + const c = body[i]; + if (c.chapter_number) { + chapters.push(c) + } + } + + // Test for chapter number + if (new RegExp(/^\d+$/).test(term)) { + const ch = parseInt(term.match(/^(\d+)$/)[1]); + if (chapters[ch - 1]) { + chapter = ch; + } + } + + // Search for term + if (chapter === 0) { + let results = []; + for (let i = 0; i < chapters.length; i++) { + const c = chapters[i]; + results.push([i, 0]); + if (c.title.toLowerCase().includes(term)) results[i][1] += 2; + if (c.description && c.description.toLowerCase().includes(term)) results[i][1] += 1; + } + results.sort(function(a, b) {return b[1] - a[1]}); + const top = results[0]; + if (top[1] > 0) { + chapter = top[0] + 1; + } + } + + if (chapter != 0) { + const c = chapters[chapter - 1]; + const embed = { + title: "Minetest Modding Book", + url: bookURL, + thumbnail: { + url: rubenAvatar, + }, + color: color, + fields: [ + { + name: `**Chapter ${c.chapter_number}: ${c.title}**`, + value: `${c.description || ""} [[Open]](${c.loc})` + } + ] + }; + message.channel.send({embed: embed}) + } else { + const embed = { + title: `Could not find any chapter related to "${term}".`, + color: color, + }; + message.channel.send({embed: embed}) + } + }) + } + }, + page: { + execute: function(message, page) { + const oldEmbed = message.embeds[0] + bookPage(message, page, function(embed) { + embed.footer.icon_url = oldEmbed.footer.iconURL; + message.edit({embed: embed}); + }); + } + } +}; diff --git a/commands/ping.js b/commands/ping.js new file mode 100644 index 0000000..0f59958 --- /dev/null +++ b/commands/ping.js @@ -0,0 +1,11 @@ +module.exports = { + name: "ping", + description: "Ping pong.", + execute(message) { + const res = ":ping_pong: Pong."; + message.channel.send(res + "..").then(msg => { + const ping = msg.createdTimestamp - message.createdTimestamp + msg.edit(`${res} ${ping}ms.`); + }) + }, +}; diff --git a/config.js b/config.js new file mode 100644 index 0000000..12590dd --- /dev/null +++ b/config.js @@ -0,0 +1,19 @@ +const config = require("./config.json"); +const request = require("sync-request"); + +let version = ""; + +console.log("Loading configurations..."); +const res = request("GET", "https://api.github.com/repos/minetest/minetest/releases/latest", { + headers: { + "User-Agent": "https://github.com/GreenXenith/minetestbot/ Minetest latest version fetcher" + } +}); +version = JSON.parse(res.getBody('utf8')).tag_name; + +if (!config.color) config.color = "#66601c"; // Configure your bot color or I'll pick an ugly one for you + +module.exports = { + color: parseInt(config.color.replace("#", ""), 16), + version: version, +}; diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..969e14d --- /dev/null +++ b/config.json.example @@ -0,0 +1,5 @@ +{ + "prefix": "!", + "token": "Bot ABCDEFGHIJKLMNOPQRSTUVWXYZ.1234567890", + "color": "#FF0000" +} diff --git a/minetestbot.js b/minetestbot.js new file mode 100644 index 0000000..d705f9c --- /dev/null +++ b/minetestbot.js @@ -0,0 +1,134 @@ +const fs = require("fs"); +const Discord = require("discord.js"); +const {prefix, token} = require('./config.json'); + +// Error if missing configuration +if (!token || !prefix) { + console.log("Error: Missing configurations! See config.json.example."); + return; +} + +const client = new Discord.Client(); +client.commands = new Discord.Collection(); + +client.pages = new Discord.Collection(); +client.pageControls = { + prev: "⬅", + next: "➡", + exit: "🇽" +}; + +// Find term function +client.searchText = function(text, term) { + let results = []; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + if (l.toLowerCase().includes(term.toLowerCase())) results.push([i + 1, l]); + } + return results; +} + +// Load commands +const commandFiles = fs.readdirSync("./commands").filter(file => file.endsWith(".js")); +for (const file of commandFiles) { + let command = require(`./commands/${file}`); + if (typeof(command) === "function") command = command(client); // Pass client if needed + client.commands.set(command.name, command); + + if (command.page) client.pages.set(command.name, command.page); +} + +let mentionString = ""; + +client.once("ready", () => { + console.log(`Logged in as ${client.user.tag}.`); + mentionString = `<@${client.user.id}>` + + client.user.setActivity("no one.", {type: "LISTENING"}); +}); + +client.on("message", async message => { + // Pingsock >:/ + if (message.content === mentionString) { + const pingsock = client.guilds.get("531580497789190145").emojis.find(emoji => emoji.name === "pingsock"); + message.channel.send(`${pingsock}`); + return; + } + + if (message.author.bot) return; + + // Try prefix first, then mentionString (could probably be done better) + let p = prefix; + if (!message.content.startsWith(p)) { + p = `${mentionString} `; + if (!message.content.startsWith(p)) return; + } + + const args = message.content.slice(p.length).trim().split(/ +/g); + const commandName = args.shift().toLowerCase(); + + const command = client.commands.get(commandName) + || client.commands.find(cmd => cmd.aliases && cmd.aliases.includes(commandName)); + + if (!command) return; + + try { + command.execute(message, args, client); + } catch(error) { + console.error(error); + message.channel.send(":warning: Yikes, something broke."); + } +}); + +// Page handler +client.on("messageReactionAdd", (reaction, user) => { + const message = reaction.message; + if (message.author != client.user || user == client.user) return; // Message author must be bot; Reactor must not be bot + + if (!message.embeds.length) return; + const embed = message.embeds[0]; + + if (!embed.footer) return; + if (!(new RegExp(/^Page /).test(embed.footer.text))) return; + const matches = embed.footer.text.match(/^Page (\d+) ?\/ ?(\d+) \| (.+)/); + const commandName = matches[3]; + const command = client.pages.get(commandName) + + if (!command || !reaction.me) return; + + let event = ""; // Unused internally; Up to commands to utilize if needed + for (const [action, name] of Object.entries(client.pageControls)) { + if (reaction.emoji.name === name) { + event = action; + break; + } + } + if (event === "") return; + + let page = parseInt(matches[1]); + const total = parseInt(matches[2]); + + switch(event) { + case "next": + page++; + if (page > total) page = 1; + break; + case "prev": + page--; + if (page < 1) page = total; + break; + case "exit": + const authorID = embed.footer.iconURL.match(/avatars\/(\d+)/)[1]; + if (authorID == user.id || + message.guild.member(user).hasPermission("MANAGE_MESSAGES")) message.delete(); + return; + } + + // (message, current page, total pages, reaction event) + command.execute(message, page, total, event); + reaction.remove(user); +}); + +// Launch +client.login(token); diff --git a/minetestbot.lua b/minetestbot.lua deleted file mode 100644 index bb6c269..0000000 --- a/minetestbot.lua +++ /dev/null @@ -1,1038 +0,0 @@ ---[[ Init ]]-- - --- Get HTTPs API, json string to lua table, url handler, and Discord API -_G.http = require("coro-http") -_G.json = require("json") -_G.url = require("socket.url") -_G.discordia = require("discordia") -_G.client = discordia.Client({ - cacheAllMembers = true, - autoReconnect = true, -}) -_G.irc = require("irc") -discordia.extensions() ---client.cacheAllMembers = true - --- Get utils -dofile("util.lua") - -if mbot.relay.enabled then - dofile("relay.lua") -end - ---[[ Register Commands ]]-- - --- General Minetest command -mbot.register_command("minetest", { - description = "General Minetest helpers.", - usage = "Use empty command to see options.", - aliases = {"mt"}, - func = function(message) - -- Get arguments - local args = message.content:split(" ") - args[1] = args[1]:gsub(mbot.prefix, "") - -- List of info - local commands = { - -- If empty - default = { - title = "Helpful Minetest Commands", - fields = { - { - name = "Usage:", - value = "`"..mbot.prefix.."minetest `" - }, - { - name = "Avaliable commands:", - value = "```\ninstall\ncompile\nabout```" - }, - } - }, - install = { - default = { - name = "Install", - url = "https://www.minetest.net/downloads/", - title = "Downloads for Minetest are located here.", - fields = { - { - name = "Use `"..mbot.prefix.."minetest install OShere` for OS-specific instructions.", - value = "```\nlinux\nwindows\nmac\nandroid\nios```" - }, - } - }, - linux = { - name = "Install (Linux)", - icon = "http://www.stickpng.com/assets/images/58480e82cef1014c0b5e4927.png", - title = "The recommended way to install Minetest on Linux is through your package manager.\nNote: the version shipped by default may be out of date.\nIn which case, you can use a PPA (if applicable), or compiling may be a better option.", - fields = { - { - name = "__For Debian/Ubuntu-based Distributions:__", - value = "Open a terminal and run these 3 commands:\n```sudo add-apt-repository ppa:minetestdevs/stable\nsudo apt update\nsudo apt install minetest```" - }, - { - name = "__For Arch Distributions:__", - value = "Open a terminal and run this command:\n```sudo pacman -S minetest```" - }, - { - name = "Again, this will vary depending on your distribution. ", - value = "**[Google](https://www.google.com/) is your friend.**\n\nWhile slightly more involved, compiling works on any Linux distribution.\nSee `"..mbot.prefix.."minetest compile linux` for details." - }, - } - }, - windows = { - name = "Install (Windows)", - icon = "http://pngimg.com/uploads/windows_logos/windows_logos_PNG25.png", - title = "Installing Minetest on Windows is quite simple.", - fields = { - { - name = "__Download:__", - value = "Visit https://www.minetest.net/downloads/, navigate to the Windows downloads, and download the proper package for your system." - }, - { - name = "__Installation:__", - value = "Extract your Minetest folder to the location of your choice.\n\nThe executable is located in `YOUR-DIR-PATH\\minetest\\bin\\`.\n\nCreate a desktop link to the executable." - }, - } - }, - mac = { - name = "Install (MacOS)", - icon = "https://upload.wikimedia.org/wikipedia/commons/thumb/b/bb/OS_X_El_Capitan_logo.svg/1024px-OS_X_El_Capitan_logo.svg.png", - title = "Installing Minetest on MacOS is about as simple as it gets.", - fields = { - { - name = "__Installation:__", - value = "Open a terminal.\n\nMake sure you have [homebrew](https://brew.sh/) installed!\nRun:\n```brew install minetest```" - }, - } - }, - android = { - name = "Install (Android)", - icon = "https://cdn.freebiesupply.com/logos/large/2x/android-logo-png-transparent.png", - title = "Minetest on mobile devices is not the best experience, but if you really must..", - fields = { - { - name = "__Download:__", - value = "Navigate to the Google Play Store.\n\nSearch for and download the [official Minetest app](https://play.google.com/store/apps/details?id=net.minetest.minetest).\n\nOptionally, you may also download [Rubenwardy's Minetest Mods app](https://play.google.com/store/apps/details?id=com.rubenwardy.minetestmodmanager)." - }, - } - }, - ios = { - name = "Install (iOS)", - icon = "https://png.icons8.com/color/1600/ios-logo.png", - title = "Step one: Switch to an Android device!", - fields = { - { - name = "__Installation:__", - value = "No, seriously. There is no official Minetest app on the app store for iOS devices.\nHowever, there are more than enough \"clones\" full of ads and bugs you could try if you really wanted to." - }, - } - }, - }, - compile = { - default = { - name = "Compile", - url = "https://dev.minetest.net/Compiling_Minetest", - title = "Compiling instructions are located here.", - fields = { - { - name = "Use `"..mbot.prefix.."minetest compile OShere` for OS-specific instructions.", - value = "```\nlinux\nwindows```" - }, - } - }, - linux = { - name = "Compile (Linux)", - icon = "http://www.stickpng.com/assets/images/58480e82cef1014c0b5e4927.png", - title = "Compiling on Linux will allow you to view and modify the source code yourself, as well as run multiple Minetest builds.", - fields = { - { - name = "__Compiling:__", - value = "Open a terminal.\n\nInstall dependencies. Here's an example for Debian-based and Ubuntu-based distributions:\n```apt install build-essential cmake git libirrlicht-dev libbz2-dev libgettextpo-dev libfreetype6-dev libpng12-dev libjpeg8-dev libxxf86vm-dev libgl1-mesa-dev libsqlite3-dev libogg-dev libvorbis-dev libopenal-dev libhiredis-dev libcurl3-dev```\n\nDownload the engine:```git clone https://github.com/minetest/minetest.git cd minetest/```\n\nDownload Minetest Game:\n```cd games/ git clone https://github.com/minetest/minetest_game.git cd ../```\n\nBuild the game (the make command is set to automatically detect the number of CPU threads to use):\n```cmake . -DENABLE_GETTEXT=1 -DENABLE_FREETYPE=1 -DENABLE_LEVELDB=1 -DENABLE_REDIS=1\nmake -j$(grep -c processor /proc/cpuinfo)```\n\nFor more information, see https://dev.minetest.net/Compiling_Minetest#Compiling_on_GNU.2FLinux." - }, - } - }, - windows = { - name = "Compile (Windows)", - icon = "http://pngimg.com/uploads/windows_logos/windows_logos_PNG25.png", - title = "Compiling on Windows is not an easy task, and is not going to be covered easily by me.", - fields = { - { - name = "__Compiling:__", - value = "Please see https://dev.minetest.net/Compiling_Minetest#Compiling_on_Windows for instructions on compiling Minetest on Windows." - }, - } - }, - }, - about = { - default = { - url = "https://www.minetest.net/#Features", - title = "A quick and comprehensive feature list can be found here!", - }, - } - } - -- Content used later in message - local content = {} - -- Minetest logo - local default_icon = "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Minetest-logo.svg/1024px-Minetest-logo.svg.png" - -- Is this a blank command? - if not args[2] then - -- Use default inputs - content.title = commands.default.title - content.fields = commands.default.fields - content.name = "Minetest" - content.icon = default_icon - elseif args[2] then - -- Is the first input valid? - if not commands[args[2]] then - return - end - -- Do we have a third argument or not? - if not args[3] then - content.url = commands[args[2]].default.url - content.title = commands[args[2]].default.title - content.fields = commands[args[2]].default.fields - content.name = commands[args[2]].default.name - content.icon = default_icon - else - -- Is the third argument valid? - if not commands[args[2]][args[3]] then - return - end - content.url = commands[args[2]][args[3]].url - content.title = commands[args[2]][args[3]].title - content.fields = commands[args[2]][args[3]].fields - content.name = commands[args[2]][args[3]].name - content.icon = commands[args[2]][args[3]].icon - end - end - -- Send the message - message.channel:send({ - embed = { - -- Title URL (to instructions) - url = content.url or "", - title = content.title, - color = mbot.color, - -- OS-specific icon - author = { - name = content.name, - icon_url = content.icon - }, - fields = content.fields or {} - } - }) - end, -}) - --- Server rules -mbot.register_command("rules", { - description = "List rules.", - usage = "rules [rule number]", - aliases = {"r"}, - func = function(message) - local msg = message.content - if not message.guild then - message.channel:send({ - content = "This command must be run in a server!" - }) - return - end - -- Make sure we are a staff member - if not message.member:getPermissions():__tostring():find("kickMembers") then - message.channel:send({ - content = "You do not have the permissions to run this command!" - }) - return - end - -- Do we have rules for this server? - local servername = message.guild.name - local server = mbot.servers[servername] - if not server then - return - end - local rules = server.rules - if not rules then - return - end - local args = msg:split(" ") - local content = {} - local title = "" - local rule = args[2] - -- Is this a blank command? - if not rule then - -- Get all the rules and put them in a table - title = "__**Rules:**__" - for _,text in pairs(rules) do - if text[2] == "" then - text[2] = "⠀" - end - content[_] = { - name = "**"..tostring(_)..".** "..text[1], - value = text[2] - } - end - else - -- Is this a valid rule number? - if not mbot.servers[servername].rules[tonumber(rule)] then - return - end - local rule_content = mbot.servers[servername].rules[tonumber(rule)] - if rule_content[2] == "" then - rule_content[2] = "⠀" - end - -- Set the table to one rule - content[tonumber(rule)] = { - name = "**"..rule..".** "..rule_content[1], - value = rule_content[2] - } - end - -- Send the message using the content - message.channel:send({ - embed = { - title = title, - color = mbot.color, - -- Get server icon - author = { - name = servername, - icon_url = message.guild.iconURL - }, - fields = content - } - }) - end, -}) - --- ContentDB search -mbot.register_command("cdb", { - description = "Search the ContentDB", - usage = "cdb ", - aliases = {"mod", "modsearch", "search"}, - func = function(message) - local msg = message.content - -- Get stuff after command - local termidx = msg:find(" ") - -- Is there a search term - if not termidx then - -- Send general info - message.channel:send({ - embed = { - url = "https://content.minetest.net/", - title = "**ContentDB**", - description = "Minetest's official content repository.", - color = mbot.color, - thumbnail = { - url = "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Minetest-logo.svg/1024px-Minetest-logo.svg.png", - }, - fields = { - { - name = "Usage:", - value = "`cdb `" - } - } - } - }) - return - end - -- Get search term - local term = msg:sub(termidx+1) - -- Get webpage with search term - local _, rawbody = http.request( - "GET", - "https://content.minetest.net/api/packages/?q="..term:gsub(" ", "+").."&lucky=1", - {{"content-type", "application/json"}} - ) - -- Turn json string into lua table (awesome!) - rawbody = json.decode(rawbody)[1] - -- Did we get results? - if not rawbody then - -- If not, say so - message.channel:send({ - embed = { - title = "Could not find any packages related to \""..term.."\".", - color = mbot.color, - } - }) - return - end - -- Get the data we need - local author = rawbody.author - local name = rawbody.name - local title = rawbody.title - local desc = rawbody.short_description - local thumb = rawbody.thumbnail - -- Failsafe in case something screwed up - if not name then - message.channel:send({ - embed = { - title = "Could not find any packages related to \""..term.."\".", - color = mbot.color, - } - }) - return - end - -- Get the actual package URL - local url = "https://content.minetest.net/api/packages/"..author.."/"..name.."/" - local _, body = http.request( - "GET", - url, - {{"content-type", "application/json"}} - ) - body = json.decode(body) - -- Get the Forum and Git repo links (and format a string) - local forums = body.forums - local forumthread = "" - if not (forums == "" or forums == nil or forums == "null") then - forumthread = "\n[Forum thread](https://forum.minetest.net/viewtopic.php?t="..forums..")" - end - local repourl = body.repo - local gitrepo = "" - if not (repourl == "" or repourl == nil or repourl == "null") then - if forumthread == "" then - gitrepo = "\n[Git Repo]("..repourl..")" - else - gitrepo = " | [Git Repo]("..repourl..")" - end - end - -- Send the message - message.channel:send({ - embed = { - url = "https://content.minetest.net/packages/"..author.."/"..name.."/", - title = "**"..title.."**", - description = "By "..author, - color = mbot.color, - image = { - url = thumb - }, - fields = { - { - name = "Description:", - value = desc..forumthread..gitrepo - } - } - } - }) - end, -}) - --- Minetest Modding Book search -mbot.register_command("modbook", { - description = "Search the Modding Book", - usage = "modbook [search term]", - aliases = {"book"}, - func = function(message) - local msg = message.content - -- Get the search term - local termidx = msg:find(" ") - -- Get the sitemap - local _, body = http.request( - "GET", - "https://rubenwardy.com/minetest_modding_book/sitemap.json", - {{"content-type", "application/json"}} - ) - -- json string to lua table - local sitemap = json.decode(body) - -- Is it an empty command? - if not termidx then - -- Set table to nicely formatted chapters - local pages = 1 - local results = {} - for _,chapter in pairs(sitemap) do - if chapter.chapter_number then - local desc = "" - if chapter.description then - desc = chapter.description - end - results[#results+1] = { - -- I hate this, I cant use in-line links in the name >:( - name = "**Chapter "..tostring(chapter.chapter_number)..": "..chapter.title.."**", - value = desc.." [[Open]]("..chapter.loc..")" - } - end - end - -- Did we get more than 10 results? - local max = 10 - if #results > max then - pages = math.ceil(#results / max ) - for i = 1,#results do - if i > max then - results[i] = nil - end - end - end - -- Send the message - message.channel:send({ - embed = { - title = "Minetest Modding Book", - url = "https://rubenwardy.com/minetest_modding_book/en/index.html", - thumbnail = { - url = "https://avatars0.githubusercontent.com/u/2122943?s=460&v=4.png", - }, - description = "By Rubenwardy", - color = mbot.color, - footer = { - icon_url = message.author.avatarURL, - text = "Page 1/"..pages.." | modbook" - }, - fields = results - } - }) - return - -- We have a search term - else - -- Get the actual term - local term = msg:sub(termidx+1):lower() - local index, url, title - local desc = "" - -- Is our term a chapter number or title? - if tonumber(term) then - -- If its a number, get all the chapters by index - local chapters = {} - for i,chapter in pairs(sitemap) do - chapters[tostring(chapter.chapter_number)] = i - end - -- Is our term a valid index? - if chapters[term] then - -- If so, set the variables - local chapter = sitemap[chapters[term]] - if chapter.description then - desc = chapter.description - end - index = chapter.chapter_number - url = chapter.loc - title = chapter.title - end - -- Or we have a term - else - -- Get titles, compare to search term - for _,chapter in pairs(sitemap) do - if chapter.title then - if chapter.title:lower():find(term) then - -- If title contains search term, count it as a hit - if chapter.description then - desc = chapter.description - end - index = chapter.chapter_number - url = chapter.loc - title = chapter.title - break - end - end - end - -- Do we have anything yet? - if not url then - for _,chapter in pairs(sitemap) do - -- Let's check the descriptions - if chapter.description then - if chapter.description:lower():find(term) then - -- If description contains search term, count it as a hit - if chapter.description then - desc = chapter.description - end - index = chapter.chapter_number - url = chapter.loc - title = chapter.title - break - end - end - end - end - end - -- Do we have anything to send? - if url then - local chapterstr = "**" - if index then - chapterstr = "**Chapter "..index..": " - end - -- Send the message - message.channel:send({ - embed = { - title = "Minetest Modding Book", - url = "https://rubenwardy.com/minetest_modding_book/en/index.html", - thumbnail = { - url = "https://avatars0.githubusercontent.com/u/2122943?s=460&v=4.png", - }, - color = mbot.color, - fields = { - { - name = chapterstr..title.."**", - value = desc.." [[Open]]("..url..")" - } - } - } - }) - return - end - -- If we havent had a success, throw a fail - message.channel:send({ - embed = { - title = "Could not find chapter \""..term.."\".", - color = mbot.color, - } - }) - end - end, - page = function(page) - local current_page = page.current - local results = {} - local fields = {} - -- Get the sitemap - local _, body = http.request( - "GET", - "https://rubenwardy.com/minetest_modding_book/sitemap.json", - {{"content-type", "application/json"}} - ) - -- json string to lua table - local sitemap = json.decode(body) - for _,chapter in pairs(sitemap) do - if chapter.chapter_number then - local desc = "" - if chapter.description then - desc = chapter.description - end - results[#results+1] = { - -- I hate this, I cant use in-line links in the name >:( - name = "**Chapter "..tostring(chapter.chapter_number)..": "..chapter.title.."**", - value = desc.." [[Open]]("..chapter.loc..")" - } - end - end - local max = 10 - for i = 1,#results do - if i > max*(current_page-1) and i <= max*(current_page) then - fields[#fields+1] = results[i] - end - end - return fields, "fields" - end, -}) - --- RTFM -mbot.register_command("lua_api", { - description = "Get Lua API links", - usage = "lua_api ", - aliases = {"api", "rtfm", "docs", "doc"}, - func = function(message) - local msg = message.content - -- Get the search term - local termidx = msg:find(" ") - -- Do we have a term? - if not termidx then - -- If not, send some links - message.channel:send({ - embed = { - title = "Lua API", - thumbnail = { - url = "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Minetest-logo.svg/1024px-Minetest-logo.svg.png", - }, - description = "Minetest Lua API Documentation", - color = mbot.color, - fields = { - { - name = "lua_api.txt with nice formatting", - value = "lua_api.txt but looks a little nicer. Located [here](https://rubenwardy.com/minetest_modding_book/lua_api.html)." - }, - { - name = "lua_api.txt (stable, "..mbot.stable_version..")", - value = "Lua API in a text file (use CTRL+F). Located [here](https://github.com/minetest/minetest/blob/"..mbot.stable_version.."/doc/lua_api.txt)." - }, - { - name = "lua_api.txt (unstable, "..mbot.unstable_version..")", - value = "Unstable Lua API in a text file (use CTRL+F). Located [here](https://github.com/minetest/minetest/blob/master/doc/lua_api.txt)." - }, - } - } - }) - else - -- Get the actual term - local term = msg:sub(termidx+1) - message.channel:send({ - embed = mbot.searchUrl(message.author, "https://github.com/minetest/minetest/blob/"..mbot.stable_version.."/doc/lua_api.txt", term, { - icon = "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Minetest-logo.svg/1024px-Minetest-logo.svg.png", - title = "Minetest Lua API", - max = 6, - }, "lua_api") - }) - end - end, - page = function(page) - local embed = page.embed - local desc = embed.description - local term = desc:sub(desc:find("`")+1, desc:find("`%]")-1) - local user = mbot.iconUser(embed) - return mbot.searchUrl(user, "https://github.com/minetest/minetest/blob/"..mbot.stable_version.."/doc/lua_api.txt", term, { - icon = "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Minetest-logo.svg/1024px-Minetest-logo.svg.png", - title = "Minetest Lua API", - max = 6, - }, "lua_api", page.current), "embed" - end, -}) - --- GitHub file search -mbot.register_command("githubsearch", { - description = "Search a GitHub file", - usage = "githubsearch ", - aliases = {"ghsearch", "github", "gh"}, - func = function(message) - local msg = message.content - -- Do we have a search term - msg = msg:split(" ", 2) - local url = msg[2] - local term = msg[3] - if not url then - message.channel:send("Empty command!") - return - end - if not term then - message.channel:send("Empty search term!") - return - end - if not url:find("github%.com/") then - message.channel:send("Not a valid GitHub URL!") - return - end - - message.channel:send({ - embed = mbot.searchUrl(message.author, url, term, { - title = "GitHub Search", - max = 6, - }, "githubsearch") - }) - end, - page = function(page) - local embed = page.embed - local desc = embed.description - local term = desc:sub(desc:find("`")+1, desc:find("`%]")-1) - local url = desc:sub(desc:find("%]%(")+2, desc:find("%)")-1) - local user = mbot.iconUser(embed) - return mbot.searchUrl(user, url, term, { - title = "GitHub Search", - max = 6, - }, "githubsearch", page.current), "embed" - end, -}) - --- Let Me Google That For You -mbot.register_command("lmgtfy", { - description = "Let Me Google That For You.", - usage = "lmgtfy [-s|iie] [-g|y|d|b] ", - aliases = {"google", "www"}, - func = function(message) - -- Get message and arguments - local term = message.content:split(" ", 1)[2] - if not term then - message.channel:send("Empty command!") - return - end - local args = term:split(" ", 2) - local mode = 0 - local engine = "g" - for _, arg in ipairs(args) do - -- Did we get a specific search engine? - local en_op = arg:match("^-[gybd]$") - if en_op then - engine = en_op:sub(2) - term = term:gsub(en_op.." ", "", 1) - -- Should we enable the Internet Explainer? - elseif arg:match("^-iie$") then - mode = 1 - term = term:gsub("-iie ", "", 1) - -- Default mode - elseif arg:match("^-s$") then - mode = 0 - term = term:gsub("-s ", "", 1) - end - end - local footer = "" - if mode == 1 then - footer = "Internet Explainer" - end - -- Search engine data - local engines = { - g = { - icon = "https://cdn4.iconfinder.com/data/icons/new-google-logo-2015/400/new-google-favicon-512.png", - name = "Google", - color = "#4081EC", - }, - y = { - icon = "https://cdn1.iconfinder.com/data/icons/smallicons-logotypes/32/yahoo-512.png", - name = "Yahoo", - color = "#770094", - }, - b = { - icon = "https://cdn.icon-icons.com/icons2/1195/PNG/512/1490889706-bing_82538.png", - name = "Bing", - color = "#ECB726", - }, - d = { - icon = "https://cdn.icon-icons.com/icons2/844/PNG/512/DuckDuckGo_icon-icons.com_67089.png", - name = "DuckDuckGo", - color = "#D75531", - }, - } - message.channel:send({ - embed = { - title = engines[engine].name.." Search:", - thumbnail = { - url = engines[engine].icon, - }, - description = "[Search for `"..term.."`](http://lmgtfy.com/?s="..engine.."&iie="..mode.."&q="..url.escape(term)..").", - color = mbot.getColor(engines[engine].color), - footer = { - text = footer - } - } - }) - end, -}) - --- Bot Info -mbot.register_command("info", { - description = "Show MinetestBot info.", - func = function(message) - -- Get uptime - local t = mbot.uptime:getTime():toTable() - local check = {"weeks", "days", "hours", "minutes", "seconds"} - local ustr = "" - -- Format string - for i = 1, 5 do - local v = check[i] - if t[v] == 0 then - t[v] = nil - else - break - end - end - for i = 1, #check do - local v = check[i] - if t[v] then - local n = v - if t[v] == 1 then - n = n:sub(1,-2) - end - ustr = ustr..", "..t[v].." "..n:gsub("^%l", string.upper) - end - end - - local creator = client:getUser("286032516467654656") - - message.channel:send({ - embed = { - title = "MinetestBot Info", - thumbnail = { - url = client.user:getAvatarURL(), - }, - description = "Open-source, Lua-powered Discord bot providing useful Minetest features. Consider [donating](https://www.patreon.com/GreenXenith/).", - fields = { - { - name = "Sauce", - value = "" - }, - { - name = "Uptime", - value = ustr:sub(3) - }, - }, - color = mbot.color, - footer = { - icon_url = creator.avatarURL, - text = "Created by "..creator.tag - } - } - }) - end, -}) - --- Bueller? Bueller? -mbot.register_command("ping", { - description = "Answers with pong.", - func = function(message) - message.channel:send("🏓 Pong!") - end, -}) - ---[[ Message Handling ]]-- - --- Bot loaded -client:on("ready", function() - -- client.user is the path for bot - print("Logged in as ".. client.user.username) -end) - --- On receive message -client:on("messageCreate", function(message) - -- Is this sent by someone other than self? - if message.author.name ~= client.user.name then - if message.content == client.user.mentionString then - -- Send pingsock - message.channel:send("<"..mbot.botEmoji().pingsock..">") - return - end - - -- Turn mention into prefix and split all arguments into a table - local args = message.content:gsub("^"..client.user.mentionString.." ", mbot.prefix):split(" ") - - -- Is it a command? - if not args[1]:find("^"..mbot.prefix) then - return - end - - -- If so, execute it - command = args[1]:sub(2) - if mbot.aliases[command] then - command = mbot.aliases[command] - end - if mbot.commands[command] then - if mbot.commands[command].perms then - for _, perm in pairs(mbot.commands[command].perms) do - if not message.author:hasPermission(perm) then - return - end - end - end - mbot.commands[command].func(message) - end - - -- If we get this far, see if the command is 'help' - if args[1] == mbot.prefix.."help" then - local fields = {} - local helplist = "" - -- Did we specify a command? - if args[2] then - if mbot.aliases[args[2]] then - args[2] = mbot.aliases[args[2]] - end - local cmd = mbot.commands[args[2]] - if cmd and not cmd.secret then - local infostr = "" - -- Do we have aliases? - if cmd.aliases then - infostr = "Aliases:" - -- If so, list them - for _,aliase in pairs(cmd.aliases) do - infostr = infostr.." "..aliase.."," - end - infostr = infostr:sub(1,-2) - end - if cmd.description then - if cmd.aliases then - infostr = cmd.description.." | "..infostr - else - infostr = cmd.description - end - end - local usg = "" - if cmd.usage then - usg = "Usage: `"..cmd.usage.."`" - end - if infostr ~= "" then - infostr = infostr.."\n"..usg - else - infostr = usg - end - if infostr == "" then - infostr = "⠀" - end - fields[1] = { - name = "Command: `"..args[2].."`", - value = infostr - } - else - -- Throw fail - message.channel:send({ - embed = { - title = "Command \""..args[2].."\" does not exist.", - color = mbot.color, - } - }) - return - end - else - -- Get all commands - for cmd, def in pairs(mbot.commands) do - -- Is this a dev command? - if not def.secret then - local infostr = "" - -- Do we have aliases? - if def.aliases then - infostr = "Aliases:" - -- If so, list them - for _,aliase in pairs(def.aliases) do - infostr = infostr.." "..aliase.."," - end - infostr = infostr:sub(1,-2) - end - if def.description then - if def.aliases then - infostr = def.description.." | "..infostr - else - infostr = def.description - end - end - if infostr == "" then - infostr = "⠀" - end - fields[#fields+1] = { - name = "Command: `"..cmd.."`", - value = infostr - } - end - end - helplist = " | See "..mbot.prefix.."help for usage." - end - -- Send the message - message.channel:send({ - embed = { - title = "MinetestBot Commands:", - thumbnail = { - url = client.user:getAvatarURL() - }, - color = mbot.color, - fields = fields, - footer = { - text = "Prefix: ".. mbot.prefix..helplist - }, - } - }) - end - -- Otherwise its my own message - else - local embed = message.embed - -- Do we have an embed and footer with page count? - if embed then - if embed.footer then - local text = embed.footer.text:gsub(" |", ""):split(" ") - -- Is this a scrolling message and can we work with it? - if text[1] == "Page" and text[3] and mbot.commands[text[3]] then - -- Are there enough pages to bother adding turners? - local page_total = text[2]:match("%d*$") - if tonumber(page_total) ~= 1 then - message:addReaction("⬅") - message:addReaction("➡") - end - message:addReaction("❌") - end - end - end - end -end) - -client:on("reactionAdd", mbot.pageTurn) - -client:on("reactionAddUncached", function(channel, messageId, hash, userId) - local message = channel:getMessage(messageId) - if message then - local reaction = message.reactions:get(hash) - if reaction then - return mbot.pageTurn(reaction, userId) - end - end -end) - --- Run the bot :D -client:run(botSettings.token) - -client:setGame({ - name = "no one.", - url = "https://www.minetest.net/", - type = 2, -}) - -mbot.uptime = discordia.Stopwatch() diff --git a/package.json b/package.json new file mode 100644 index 0000000..f81f4ce --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "minetestbot", + "version": "2.0.0", + "description": "Discord bot with useful Minetest commands.", + "main": "minetestbot.js", + "dependencies": { + "discord-js": "^11.5.1", + "request": "^2.88.1", + "sync-request": "^6.1.0" + }, + "author": "GreenXenith", + "license": "MIT" +} diff --git a/pages.js b/pages.js new file mode 100644 index 0000000..7f79d4e --- /dev/null +++ b/pages.js @@ -0,0 +1,74 @@ +const request = require("request"); +const {color} = require("./config.js"); + +module.exports = { + searchText: function(text, term) { + let results = []; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + if (l.toLowerCase().includes(term.toLowerCase())) results.push([i + 1, l]); + } + return results; + }, + pageFooter: function(message, command, page, total) { + return { + icon_url: message.author.avatarURL, + text: `Page ${page} / ${total} | ${command}` + }; + }, + getPage: function(command, message, config, term, func) { + request(`${config.url.search}`, async function (err, res, body) { + if (err || res.statusCode != 200) { + message.channel.send(":warning: Something went wrong."); + return; + } + + let embed = {}; + const fields = []; + const results = await module.exports.searchText(body, term); + + if (results.length > 100) { + embed = { + title: "Error: Result overflow!", + description: `Got ${results.length} results. Search [the page](${config.url.display}) manually instead.`, + color: color + }; + } else { + for (let i = (config.page - 1) * config.pageSize; i < (config.page * config.pageSize); i++) { + const res = results[i]; + if (!res) break; + fields.push({ + name: `Line ${res[0]}:`, + value: `[\`\`\`\n${res[1]}\n\`\`\`](${config.url.display}#L${res[0]})` + }) + } + + embed = { + title: config.title, + thumbnail: { + url: config.thumbnail + }, + description: `Results for [\`${term}\`](${config.url.display}):`, + color: color, + footer: module.exports.pageFooter(message, command, config.page, Math.ceil(results.length / config.pageSize)), + fields: fields + }; + } + + func(embed, results); + }) + }, + addControls: async function(message, turn, exit) { + try { + const controls = message.client.pageControls; + if (turn != false) { + await message.react(controls.prev); + await message.react(controls.next); + } + if (exit != false) await message.react(controls.exit); + } catch (error) { + console.error(`Failed to add page controls. Error: ${error}`); + } + } +} diff --git a/relay.lua b/relay.lua deleted file mode 100644 index adc0d4a..0000000 --- a/relay.lua +++ /dev/null @@ -1,326 +0,0 @@ --- Settings -local relay = mbot.relay -local default_avatar = "https://i.imgur.com/qa6QJZl.png" - -if not relay.irc_channel or not relay.minetest_user or not relay.discord_channel or not relay.webhook then - print("IRC Relay configured incorrectly! Aborting.") - client:quit() - return -end - --- Mainly for code blocks. Might extend later. -local function simplifyMarkdown(input) - return input:gsub("\n", " "):gsub(" ", "") -end - --- Get user avatar if they exist -local function cloneAvatar(name) - local avatar - local channel = client:getChannel(relay.discord_channel).guild - channel.members:forEach(function(m) - if m.user.username == name then - avatar = m.user.avatarURL - end - end) - return avatar -end - --- Find @mentions and replace them -local function userMention(msg) - local channel = client:getChannel(relay.discord_channel).guild - channel.members:forEach(function(m) - msg = msg:gsub("@"..m.user.username.."#?%d?%d?%d?%d?", m.user.mentionString) - msg = msg:gsub("@"..m.user.username:lower().."#?%d?%d?%d?%d?", m.user.mentionString) - if m.nickname then - msg = msg:gsub("@"..m.nickname.."#?%d?%d?%d?%d?", m.user.mentionString) - msg = msg:gsub("@"..m.nickname:lower().."#?%d?%d?%d?%d?", m.user.mentionString) - end - end) - return msg -end - --- Find #channels and replace them -local function channelMention(msg) - local channel = client:getChannel(relay.discord_channel).guild - channel.textChannels:forEach(function(c) - msg = msg:gsub("#"..c.name, c.mentionString) - end) - return msg -end - --- Convert IRC to Discord markdown -local function discordFormat(str) - -- Get occurences - local function find(input, search) - local ct = 0 - for char in string.gmatch(input, ".") do if char == search then ct = ct + 1 end end - return ct - end - - -- Italic, bold, and underline chars - local pat = "["..string.char(0x1D)..string.char(0x02)..string.char(0x1F).."]" - - -- Formats - local formatChars = { - italic = {string.char(0x1D), "_"}, - bold = {string.char(0x02), "**"}, - underline = {string.char(0x1F), "__"}, - } - - -- Find pairs - str = str:gsub(pat.."+.-"..pat.."+", function(section) - local wrap = section:sub(section:match("^"..pat.."+"):len()+1,-section:match(pat.."+$"):len()-1) - for _, chars in pairs(formatChars) do - local found = find(section, chars[1]) - if found and found > 1 then - wrap = chars[2]..wrap..chars[2] - end - end - if wrap == "" then - wrap = section - end - return wrap - end) - - -- Find unmatched (should only be 0 or 1) - str = str:gsub(pat.."+.-$", function(section) - local wrap = section:sub(section:match("^"..pat.."+"):len()+1) - for _, chars in pairs(formatChars) do - local found = find(section, chars[1]) - if found and found == 1 then - wrap = chars[2]..wrap..chars[2] - end - end - return wrap - end) - return str -end - --- Combine above -local function smartParse(msg) - msg = userMention(msg) - msg = channelMention(msg) - return discordFormat(msg) -end - --- Send webhook message -local function hook(payload) - -- Content - local send = { - username = payload.name, - avatar_url = payload.avatar, - content = payload.msg or "ERROR: Missing message content" - } - - -- Send to webhook - coroutine.wrap(function() - http.request("POST", relay.webhook, {{"Content-Type", "application/json"}}, json.encode(send)) - end)() -end - --- Create relay user -local c = irc:new(relay.server or "irc.freenode.net", "Discord", {auto_join={relay.irc_channel}}) - --- Connect -c:connect() - --- Log/info -c:on("connect", function() - print("Discord-IRC relay connected.") - hook({msg = "Relay connected.", avatar = default_avatar}) -end) - -c:on("disconnect", function(reason) - hook({msg = "Relay disconnected.", avatar = default_avatar}) - print(string.format("Disconnected: %s", reason)) -end) - --- Send IRC chat to Discord -c:on("message", function(from, to, msg) - local avatar = cloneAvatar(from) - if relay.minetest_user and from == relay.minetest_user then - avatar = nil - end - hook({name = from, msg = smartParse(msg), avatar = avatar}) -end) - -c:on("action", function(from, to, msg) - local avatar = cloneAvatar(from) - hook({name = from, msg = "*"..smartParse(msg).."*", plain = true, avatar = avatar}) -end) - --- Send Discord chat to IRC -client:on("messageCreate", function(message) - -- Dont send relay messages; stay in desegnated channel - if message.member and message.channel.id == relay.discord_channel then - -- Get nickname, if any - local member = message.member or message.author - -- Send commands from Discord to IRC for server to catch - if message.content:lower():match("^"..relay.minetest_user:lower()..",") then - c:say(relay.irc_channel, "Command sent by "..member.name..":") - c:say(relay.irc_channel, simplifyMarkdown(message.content)) - else - c:say(relay.irc_channel, "<"..member.name.."> "..simplifyMarkdown(message.content)) - end - end -end) - ---[[c:on("notice", function(from, to, msg) - from = from or c.server - print(string.format("-%s:%s- %s", from, to, msg)) -end)]] - --- IRC actions -c:on("ijoin", function(channel, whojoined) - print(string.format("Joined channel: %s", channel)) - - channel:on("join", function(whojoined) - hook({msg = string.format("_%s_ has joined the channel", whojoined), avatar = default_avatar}) - end) - - channel:on("part", function(who, reason) - hook({msg = string.format("_%s_ has left the channel", who)..(reason and " ("..reason..")" or ""), avatar = default_avatar}) - end) - - channel:on("kick", function(who, by, reason) - hook({msg = string.format("_%s_ has been kicked from the channel by %s", who, by)..(reason and " ("..reason..")" or ""), avatar = default_avatar}) - end) - - channel:on("quit", function(who, reason) - hook({msg = string.format("_%s_ has quit", who)..(reason and " ("..reason..")" or ""), avatar = default_avatar}) - end) - - channel:on("kill", function(who) - hook({msg = string.format("_%s_ has been forcibly terminated by the server", who), avatar = default_avatar}) - end) - - channel:on("+mode", function(mode, setby, param) - if setby == nil then return end - hook({msg = string.format("_%s_ sets mode: %s%s", - channel, - setby, - "+"..mode.flag, - (param and " "..param or "") - ), avatar = default_avatar}) - end) - - channel:on("-mode", function(mode, setby, param) - if setby == nil then return end - hook({msg = string.format("_%s_ sets mode: %s%s", - channel, - setby, - "-"..mode.flag, - (param and " "..param or "") - ), avatar = default_avatar}) - end) -end) - -c:on("ipart", function(channel, reason) - print("Left channel") -end) - -c:on("ikick", function(channel, kickedby, reason) - print(string.format("Kicked from channel by %s (%s)", kickedby, reason)) -end) - -c:on("names", function(channel) - local users = "" - for _, user in pairs(channel.users) do - users = users .. tostring(user) .. ", " - end - hook({msg = "Users in channel: "..users:sub(1,-3), avatar = default_avatar}) -end) - -if relay.dm_enabled == false then - return -end - --- Response handler -local relayQueue = {} -local loggedIn - --- Relay interaction -mbot.register_command("irc", { - description = "Interact with IRC relay", - func = function(message) - -- Only work in DM (to protect passwords when logging in) - if message.channel.type ~= 1 then - message.channel:send("This can only be used in a private DM!") - return - end - -- Logout current login to prevent abuse if not the same user - if loggedIn and loggedIn ~= message.channel.id then - -- Handle logout response message - relayQueue[#relayQueue+1] = loggedIn - c:say(relay.minetest_user, "logout") - loggedIn = nil - end - -- Get message parts - local send = message.content - local args = send:split(" ", 2) - local cmd = args[2] - if not cmd then - message.channel:send("Empty command!") - return - end - -- Handle server PMs - if cmd:match("^@") then - -- Add Discord sender - args[3] = "<"..message.author.tag.."> "..args[3] - end - -- Create message - send = args[2].." "..args[3] - -- Handle response message and send - relayQueue[#relayQueue+1] = message.channel.id - c:say(relay.minetest_user, send) - end, -}) - -c:on("pm", function (from, msg) - -- Is the message a PM from a Minetest user - if msg:match("^<[%w_-]-> ") then - -- Get message parts - local params = msg:split(" ", 2) - local from = params[1]:sub(2,-2) - local to = params[2] - local send = params[3] - local dm - local channel = client:getChannel(relay.discord_channel).guild - -- Find user - channel.members:forEach(function(m) - if to:match("#%d%d%d%d$") then - if m.user.tag == to then - dm = m.user - end - else - if m.user.username == to then - dm = m.user - end - end - end) - -- If the user exists, send the messsage to them - if dm then - coroutine.wrap(function() - dm:getPrivateChannel():send("<"..from.."@"..relay.minetest_user.."> "..smartParse(send)) - end)() - -- Otherwise deny message; use dummy queue to handle PM response - else - relayQueue[#relayQueue+1] = "dummy" - c:say(relay.minetest_user, "@"..from.." User '"..to.."' is not on Discord!") - end - -- Is this an IRC response - else - coroutine.wrap(function() - -- Send to next channel in queue if not dummy - if relayQueue[1] and relayQueue[1] ~= "dummy" then - -- Handle user logins - if msg:match("^You are now logged in as ") then - loggedIn = relayQueue[1] - end - client:getChannel(relayQueue[1]):send("<"..from.."> "..smartParse(msg)) - end - -- Increment queue - table.remove(relayQueue, 1) - end)() - end -end) diff --git a/settings.example b/settings.example deleted file mode 100644 index 92e8ada..0000000 --- a/settings.example +++ /dev/null @@ -1,27 +0,0 @@ --- Bot settings -botSettings = { - token = "Bot ABCDEFG", -- Bot Token - prefix = "~", -- Command Prefix - color = "#026610", -- Main embed color (hexidecimal or rgb {r=255,g=255,b=255} table) -} - --- Per-server settings -botSettings.servers = { - ["My Awesome Discord Server"] = { - rules = { - {"Rule number one", "Subtext"}, - {"Rule number two", ""}, - }, - bot_channel = "<#1234567890>", -- ID for bot command channel (currently unused) - }, -} - --- Discord-IRC Relay -botSettings.relay = { - enabled = false, - dm_enabled = true, -- Whether or not users can communicate with the server through Discord (default: true) - irc_channel = "#my-irc-channel", - minetest_user = "MyServerUser", -- Name of user IRC mod uses to connect (found in minetest.conf) - discord_channel = "1234567890", -- ID for IRC to relay with - webhook = "https://discordapp.com/api/webhooks/1234567890/abcdefghijklmnopqrstuvwxyz1234567890", -- Webhook URL -} diff --git a/util.lua b/util.lua deleted file mode 100644 index b95077a..0000000 --- a/util.lua +++ /dev/null @@ -1,281 +0,0 @@ -_G.mbot = {} - -dofile("settings.lua") - -function mbot.dbg(msg) - discordia.Logger(4, tostring(os.date())):log(4, tostring(msg)) -end - -if not botSettings then - print("Bot configured incorrectly! Aborting.") - client:quit() - return -end - -for type, settings in pairs(botSettings) do - mbot[type] = settings -end - -mbot.stable_version = "5.0.1" -mbot.unstable_version = "5.1.0-dev" - -mbot.commands = {} -mbot.aliases = {} - --- Get custom emotes -function mbot.botEmoji() - local list = {} - local emoteServer = client:getGuild("531580497789190145") - for i in pairs(emoteServer.emojis) do - local emoji = emoteServer:getEmoji(i) - list[emoji.name] = ":"..emoji.name..":"..i - end - return list -end - --- Split function -function string:split(delimiter, max) - local result = {} - for match in (self..delimiter):gmatch("(.-)"..delimiter) do - if max and #result == max then - result[max+1] = self - break - end - table.insert(result, match) - self = self:sub(match:len()+2) - end - return result -end - --- Get RGB int -function mbot.getColor(r, g, b) - if type(r) == "string" then - r = r:gsub("^#", "") - if not r:find("^%x%x%x%x%x%x$") then - return - end - r, g, b = tonumber("0x"..r:sub(1,2)), tonumber("0x"..r:sub(3,4)), tonumber("0x"..r:sub(5,6)) - end - return 256 * 256 * r + 256 * g + b -end - --- Get main bot color -if type(botSettings.color) == "table" then - mbot.color = mbot.getColor(botSettings.color.r, botSettings.color.g, botSettings.color.b) -else - mbot.color = mbot.getColor(botSettings.color) -end - ---[[ URL Handling ]]-- -local function readUrl(url) - local _, body = http.request("GET", url) - local lines = {} - function adjust(s) - if s:sub(-1)~="\n" then s=s.."\n" end - return s:gmatch("(.-)\n") - end - for line in adjust(body) do - lines[#lines+1] = line - end - return lines -end - -function mbot.searchUrl(user, url, term, def, id, page) - -- Init and defaults - local pages = 1 - local results = {} - local resultMax = def.max or 10 - local resultIcon = def.icon or "https://magentys.io/wp-content/uploads/2017/04/github-logo-1.png" --github logo - local resultTitle = def.title or "Search Results" - - if not id or type(id) ~= "string" then - return - end - - if not page then - page = 1 - end - - -- Adjust URL - local cutoff = url:find("%.com/") - url = url:sub(cutoff+5):gsub("#%w+", "") - - local githubUrl = "https://github.com/"..url - local rawUrl = "https://raw.githubusercontent.com/"..url:gsub("/blob", "", 1) - - -- Read the API - for num, line in pairs(readUrl(rawUrl)) do - -- Add a field with the line number and a preview (link) - if line:lower():find(term:lower(), 1, true) or line:lower():find(term:lower():gsub(" ", "_"), 1, true) then - results[#results+1] = { - name = "Line "..tostring(num)..":", - value = "[```\n"..line:gsub("[%[%]]", "").."\n```]("..githubUrl.."#L"..num..")" - } - end - end - - local fields = {} - - -- Did we get anything? - if #results == 0 then - local embed = { - title = resultTitle, - description = "No results!", - color = mbot.color - } - return embed - end - - -- Did we get more than max results? - if #results > resultMax then - -- Did we get way too many? - if #results > 100 then - local embed = { - title = "Error: Result overflow!", - description = "Got "..#results.." results. Search [the URL]("..githubUrl..") manually instead.", - color = mbot.color - } - return embed - end - pages = math.ceil(#results / resultMax ) - for i = 1, #results do - if i > resultMax*(page-1) and i <= resultMax*(page) then - fields[#fields+1] = results[i] - end - end - else - fields = table.deepcopy(results) - end - - local embed = { - title = resultTitle, - thumbnail = { - url = resultIcon, - }, - description = "Results for [`"..term.."`]("..githubUrl.."):", - color = mbot.color, - footer = { - icon_url = user.avatarURL, - text = "Page "..page.."/"..pages.." | "..id - }, - fields = fields, - } - - return embed -end - -function mbot.pageTurn(reaction, userId) - local message = reaction.message - local reactor = client:getUser(userId) - local embed = message.embed - local sender = message.author.name - -- Was this a bot message, was it a normal user reacting, and does it have a footer to read? - if sender == client.user.name and reactor.name ~= client.user.name and embed and embed.footer then - local invoker = mbot.iconUser(embed) - if embed.provider then - mbot.dbg(embed.provider) - end - local text = embed.footer.text:gsub(" |", ""):split(" ") - -- Is this worth doing something with - if text[1] ~= "Page" then - return - end - -- Clean up extras - message:removeReaction(reaction, userId) - -- Only 3 valid interaction emotes - if reaction.emojiName == "⬅" or reaction.emojiName == "➡" or reaction.emojiName == "❌" then - -- No ID to work with - if not mbot.commands[text[3]] or not mbot.commands[text[3]].page then - return - end - - -- Remove search - if reaction.emojiName == "❌" then - if (invoker and reactor.name == invoker.name) or message.guild:getMember(reactor.id):hasPermission("manageMessages") then - message:delete() - end - return - end - - -- Get total and current - local page_total = tonumber(text[2]:match("%d+$")) - -- This only has 1 page, dont do anything - if page_total == 1 then - return - end - local current_page = tonumber(text[2]:match("^%d+")) - if reaction.emojiName == "➡" then - -- Loop around - if current_page == page_total then - current_page = 1 - -- Or go forward - else - current_page = current_page + 1 - end - else - -- Loop around - if current_page == 1 then - current_page = page_total - -- Or go backward - else - current_page = current_page - 1 - end - end - local input, type = mbot.commands[text[3]].page({ - current = current_page, - embed = embed, - }) - -- Edit the message - if type == "fields" then - message:setEmbed({ - title = embed.title or nil, - thumbnail = embed.thumbnail or nil, - description = embed.description or nil, - color = mbot.color, - footer = { - text = "Page "..current_page.."/"..page_total.." | "..text[3] - }, - fields = input, - }) - else - message:setEmbed(input) - end - end - end -end - ---[[ Other ]]-- -function mbot.iconUser(embed) - if not embed.footer.icon_url then - return - end - return client:getUser(embed.footer.icon_url:match("avatars/%d+"):sub(9)) -end - ---[[ Command Registration ]]-- -function mbot.register_command(name, def) - -- Not valid - if not def.func then - return - end - if def.description and type(def.description) ~= "string" then - def.description = nil - end - if def.aliases and type(def.aliases) ~= "table" then - def.aliases = {} - end - mbot.commands[name] = { - func = def.func, - description = def.description, - usage = def.usage, - aliases = def.aliases, - page = def.page, - secret = def.secret, - perms = def.perms, - } - if def.aliases then - for _,alias in pairs(def.aliases) do - mbot.aliases[alias] = name - end - end -end