Migrate to JavaScript

master
GreenDimond 2019-10-26 13:45:21 -07:00
parent aa2cfb9d22
commit e58391eefd
21 changed files with 1060 additions and 1688 deletions

14
.gitignore vendored
View File

@ -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/

19
LICENSE.txt Normal file
View File

@ -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.

View File

@ -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.

70
commands/cdb.js Normal file
View File

@ -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: "`<search term>`"
}
],
};
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});
});
}
});
}
}
};

72
commands/conf.js Normal file
View File

@ -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: "<search term>",
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});
});
},
}
};

84
commands/help.js Normal file
View File

@ -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 <command> 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?");
});
},
};

64
commands/info.js Normal file
View File

@ -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: "<https://github.com/GreenXenith/minetestbot>"
},
{
name: "Uptime",
value: duration(client.uptime)
},
],
timestamp: new Date(),
footer: {
text: `Created by ${creator.tag}`,
icon_url: creator.avatarURL,
},
};
message.channel.send({embed: embed});
},
};

72
commands/lmgtfy.js Normal file
View File

@ -0,0 +1,72 @@
const {color} = require("../config.js");
module.exports = {
name: "lmgtfy",
usage: "[-x] [-g|y|d|b] <search term>",
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});
}
};

81
commands/lua_api.js Normal file
View File

@ -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: "<search term>",
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});
});
},
}
};

190
commands/minetest.js Normal file
View File

@ -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 <command>\``
},
{
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});
}
}

139
commands/modbook.js Normal file
View File

@ -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});
});
}
}
};

11
commands/ping.js Normal file
View File

@ -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.`);
})
},
};

19
config.js Normal file
View File

@ -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,
};

5
config.json.example Normal file
View File

@ -0,0 +1,5 @@
{
"prefix": "!",
"token": "Bot ABCDEFGHIJKLMNOPQRSTUVWXYZ.1234567890",
"color": "#FF0000"
}

134
minetestbot.js Normal file
View File

@ -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);

File diff suppressed because it is too large Load Diff

13
package.json Normal file
View File

@ -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"
}

74
pages.js Normal file
View File

@ -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}`);
}
}
}

326
relay.lua
View File

@ -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)

View File

@ -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
}

281
util.lua
View File

@ -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