Compare commits

...

25 Commits
v1.0 ... master

Author SHA1 Message Date
c43d648279 Native PvN implementation 2025-04-01 23:01:39 -05:00
2f71f2b11c Updating ?help for 1.2 release 2025-02-15 13:55:05 -06:00
c71789e949 Added Pirate vs. Ninja commands 2024-11-14 19:07:33 -06:00
384dcc6416 Added ?trace command 2024-11-14 18:48:27 -06:00
d3801b48e0 Added new aliases 2024-11-10 22:19:47 -06:00
978f6bc932 Rewrote ytlookup to fix edge case issues 2024-11-10 13:14:17 -06:00
81523028ae Added ytlookup function 2024-11-09 14:22:29 -06:00
08d023076b Fixed reconnection starting duplicate timers 2024-10-29 18:47:25 -05:00
729d6b41be Added MOTD exclusion system 2024-10-27 16:05:44 -05:00
7162eb7d93 Minor cleanup 2024-10-27 11:23:12 -05:00
1ec9c93bda Added support for MOTDs matching only the date 2024-10-19 17:43:47 -05:00
7be7970194 Complete rewrite of the MOTD and Save System loops 2024-10-18 23:49:35 -05:00
8da2c94dcd Improved comments 2024-10-18 15:05:47 -05:00
3e36b3839b Added Readme and Changelog 2024-10-18 10:04:39 -05:00
be2d5661d4 Moved general purpose functions to IzzComLib 2024-10-16 18:55:09 -05:00
193f7f2bf8 Improved fallback for database commands 2024-10-16 16:18:03 -05:00
ea5c2da9ef Splitting data files from main repo 2024-10-16 14:18:38 -05:00
22342d954f Added 8ball and optional startup message 2023-09-04 22:04:07 -05:00
a36c40daf6 Changed Help to an embed, secured Echobox 2023-09-04 19:01:14 -05:00
4c4013612e Added command args and gaming commands 2023-09-04 18:44:46 -05:00
2035710bf7 Fixed Echobox loading mistake 2023-09-03 00:14:46 -05:00
27dd8cadc9 Added Echobox 2023-09-03 00:00:11 -05:00
382ae94168 Added save queue system 2023-09-02 18:54:33 -05:00
d8cbaa4e16 Connection change in HxDiscord update #5e30606 2023-09-02 17:04:48 -05:00
ad064a7d2c Exact date support, @everyone, new aliases 2023-09-02 13:53:28 -05:00
8 changed files with 818 additions and 134 deletions

5
.gitignore vendored
View File

@ -1,5 +1,8 @@
# ignore all files in export dir
export/
modules/
releases/
# ignore bot settings file
settings.json
settings.json
echobox-db.json

58
CHANGELOG.md Normal file
View File

@ -0,0 +1,58 @@
# Changelog
## Release Version 1.2
- Added [Pirate vs. Ninja](https://majcher.com/project/pvn/pvn.cgi) commands: `?pirate`, `?ninja`, `?pvn`, `?nvp`
- Added `?trace` debug tool
- New aliases: `?fineart`, `?artishard`, `?indeed`, `?florida`, `?stop`
- Added YouTube search command
- Fixed reconnection starting duplicate timers
- Added system to prevent MOTD from chosing the same daily result consecutively
- Added support for MOTDs matching only the date
- General purpose functions have been split off into IzzComLib
- Improved fallbacks for file-reading commands
- Data files for the official instance have been split into a separate repo
## Release Version 1.1
- Echobox
- Checks whether the message is already in the database
- Makes sure it doesn't say what was just added
- Prevents adding `@everyone` or other mentions to the echobox
- Changed shutdown message to reaction
- Updated MOTD to support exact dates
- Optional startup message supplied by program args
- New aliases
- `?illuminati`
- `?communism`
- `?tufac` lol
- `?mrkrabs` (ack ack ack ack ack)
- `?coolsville`
- `?cube`
- `?opinions`
- Respond to `@everyone` with the Caesar pic
- Gaming functions:
- `?dice` - Rolls a 6-sided die (default) or takes input on size
- `?coin` - Flips a coin (default) or takes input on number of coins to flip
- `?8ball` - Standard "fortune telling"
- Fixed issue if user doesn't have a nickname set
- Added new file saving queue system
- Updated `?help` with new features, using Embed Builder for nicer-looking display
## Release Version 1.0 (Minimum Viable Product)
- Save / Load settings from file
- Git repo that excludes settings file and export / reference folders
- Safe shutdown of bot
- Dev Mode (OQM ignores everything outside of the testing server)
- A way to set up which channels get posted to
- Command processor
- System for WIP commands that only work in testing server
- `?help` command which lists full bot functionality
- `?chk` (ack)
- `?devmode` toggle
- `?hug` image database
- `?yes` database
- `?no` database
- `?slap` command from mIRC
- `?angery`
- `?subway`
- MOTD framework and image database
- Automatic *"ICE"* response

View File

@ -1,23 +1,34 @@
import hxdiscord.DiscordClient;
import hxdiscord.types.*;
import hxdiscord.endpoints.Endpoints;
import haxe.MainLoop;
import htmlparser.HtmlDocument;
import haxe.Timer;
import haxe.Json;
import sys.io.File;
import sys.FileSystem;
import BigInt;
import izzcomlib.IzzComLib.*;
using StringTools;
class Onequestionmark {
// Initialize vars
// Initialize vars
static var Bot:DiscordClient;
static var settings:Dynamic;
static var botInfo:Dynamic;
static var iceRegex:EReg = ~/\bicc?ed?\b/i;
static var hugDB:Array<String>;
static var motdDB:haxe.DynamicAccess<Dynamic> = {};
static var yesnoDB:Dynamic;
static var echoboxDB:haxe.DynamicAccess<Dynamic> = {};
static function main() {
static var motdTimer:Timer;
static var saveTimer:Timer;
static var saveQueue:Array<String> = [];
static var timerInit:Bool = false;
static function main() {
Sys.println('[${timestamp()}] Starting onequestionmark-chat');
@ -35,7 +46,7 @@ class Onequestionmark {
hugDB = Json.parse(File.getContent("hug-db.json"));
} else {
Sys.println('[${timestamp()}] Did not find hug-db.json, skipping');
hugDB = ["https://cdn.discordapp.com/attachments/270113422232911883/445026464623099907/brohugbump.gif"]; // Fallback
hugDB = []; // Fallback
}
if (FileSystem.exists("motd-db.json")) {
@ -43,7 +54,7 @@ class Onequestionmark {
motdDB = Json.parse(File.getContent("motd-db.json"));
} else {
Sys.println('[${timestamp()}] Did not find motd-db.json, skipping');
motdDB = Json.parse('{"sunday": [],"monday": [],"tuesday": [],"wednesday": [],"thursday": [],"friday": [],"saturday": []}'); // Fallback
motdDB = Json.parse('{}'); // Fallback
}
if (FileSystem.exists("yesno-db.json")) {
@ -54,28 +65,63 @@ class Onequestionmark {
yesnoDB = {"yes": [],"no": []}; // Fallback
}
if (FileSystem.exists("echobox-db.json")) {
Sys.println('[${timestamp()}] Found echobox-db.json, loading');
echoboxDB = Json.parse(File.getContent("echobox-db.json"));
} else {
Sys.println('[${timestamp()}] Did not find echobox-db.json, generating');
File.saveContent("echobox-db.json", Json.stringify(echoboxDB, "\t"));
}
if (!FileSystem.exists("export")) {
FileSystem.createDirectory("export");
}
saveSystem();
// Start the bot
Bot = new DiscordClient(settings.token, [3276799], settings.debug);
Bot.onReady = onReady;
Bot.onMessageCreate = onMessageCreate;
Bot = new DiscordClient(settings.token, [3276799], settings.debug);
Bot.onReady = onReady;
Bot.onMessageCreate = onMessageCreate;
Bot.connect();
}
/**
* The `onReady()` function is run upon a successful `Bot.connect()`
*/
public static function onReady() {
Sys.sleep(1);
Sys.println('[${timestamp()}] Bot is online');
// Join message from program args
if (Sys.args().length > 0) {
Endpoints.sendMessage(settings.devchannel, {content:Sys.args().join(" ")}, null, false);
}
botInfo = Endpoints.getCurrentUser();
MainLoop.add(motd);
}
if (timerInit == false) {
timerInit = true;
startMotd();
}
}
public static function onReady() {
Sys.println('[${timestamp()}] Bot is online');
//Endpoints.sendMessage(settings.devchannel, {content:"onequestionmark-chat alpha test is now running"}, null, false);
}
public static function onMessageCreate(m:Message) {
/**
* The `onMessageCreate()` event provides the bulk of onequestionmark's functionality.
* This is what triggers when a new Discord message is received, and contains the bot's command processor.
*/
public static function onMessageCreate(m:Message) {
// DevMode check
if ((!settings.devmode) || (m.guild_id == settings.devserver)) {
if ((!settings.devmode) || (m.guild_id == settings.devserver)) {
var msg = m.content;
var sender:String;
if (m.getMember().nick == null) {sender = m.author.global_name;} else {sender = m.getMember().nick;}
// Command processor
if (msg.charAt(0) == "?") {
var command = "";
var args:Array<String> = [];
if (msg.indexOf(" ") != -1) {
// Separate command from rest of message, if necessary
@ -87,11 +133,17 @@ class Onequestionmark {
msg = "";
}
// Separate arguments from command
if (command.indexOf(".") != -1) {
args = command.substring(command.indexOf(".")+1).toLowerCase().split(".");
command = command.substring(0, command.indexOf(".")).toLowerCase();
}
switch (command) {
// Commands that require authorization
case "quit":
if (m.author.id == settings.botowner) {
m.reply({content:"Shutdown command received from botowner, exiting."}, false);
m.react('');
Sys.println('[${timestamp()}] Shutdown command received from botowner, exiting');
shutdown();
}
@ -107,14 +159,29 @@ class Onequestionmark {
}
case "motd":
if (m.author.id == settings.botowner) {
if (!settings.motd.channels.contains(m.channel_id)) {
settings.motd.channels.push(m.channel_id);
m.reply({content:'MOTD has been enabled for <#${m.channel_id}>'}, false);
if (args.length != 0) {
if (args[0] == "force") {
m.reply({content:'${getMotd(true)}'}, false);
}
} else {
settings.motd.channels.remove(m.channel_id);
m.reply({content:'MOTD has been disabled for <#${m.channel_id}>'}, false);
if (!settings.motd.channels.contains(m.channel_id)) {
settings.motd.channels.push(m.channel_id);
m.reply({content:'MOTD has been enabled for <#${m.channel_id}>'}, false);
} else {
settings.motd.channels.remove(m.channel_id);
m.reply({content:'MOTD has been disabled for <#${m.channel_id}>'}, false);
}
saveQueue.push("settings");
}
saveSettings();
}
case "trace":
if (m.author.id == settings.botowner) {
if (m.referenced_message == null) {
Sys.println('[${timestamp()}] trace: ${Json.stringify(getMessage(m.channel_id, m.id))}');
} else {
Sys.println('[${timestamp()}] trace: ${Json.stringify(getMessage(m.referenced_message.channel_id, m.referenced_message.id))}');
}
m.react('');
}
// System for WIP commands that only work in testing server
case "wipcommand":
@ -123,156 +190,595 @@ class Onequestionmark {
}
// Basic response commands
case "help":
m.reply({content:'
**onequestionmark bot commands**\n`?chk`: ack\n`?slap <target>`: The classic mIRC troutslap.\n`?hug <target (optional)>`: Posts randomized hug image.\n`?yes` and `?no`: Posts randomized yes/no.\n`?angery`\n`?subway`\n`?motd`: Enables MOTD for current channel (requires auth)\n`?devmode`: Toggles Dev Mode (requires auth)\n`?quit`: Shutdown command (requires auth)\n**Non-command bot functions:**\n*MOTD*: Bot will post a randomized *Meme of the Day* in enabled channels.\n*Icce*: Bot provides users with ice cuboids.\n*Meteor*: Bot reacts to falling rocks in the chat.'}, false);
m.reply({embeds: [
{
"color": 13733022,
"url": "https://discord.com",
"author": {
"name": "onequestionmark help",
"url": "https://discord.com",
"icon_url": "https://cdn.discordapp.com/avatars/1071524991084015756/d623b45d33e8119599b29c5cf5ed532e.png"
},
"thumbnail": {
"url": "https://cdn.discordapp.com/emojis/1071560243051511808.png"
},
"fields": [
{
"name": "**Bot commands:**",
"value": "`?help`: Posts this help document. :book:\n`?chk`: Replies with \"ack\". :speaking_head:\n`?slap <target>`: The classic mIRC troutslap. :fish:\n`?hug <target (optional)>`: Posts randomized hug image. :people_hugging:\n`?yes`: Posts randomized \"yes\" image or video. :thumbsup:\n`?no`: Posts randomized \"no\" image or video. :no_entry_sign:\n`?mrkrabs`: Similar to `?chk`. :crab:\n`?tufac`: The official Tufac Theme Song. :busts_in_silhouette:\n`?coin <count (optional)>`: Flips between 1 and 100 coins. :coin:\n`?dice.<arg> <num>`: Rolls <arg>-sided die <num> times :game_die:\n`?8ball`: Answers your yes or no questions. :8ball:\n`?echobox`: Posts randomized quote from the quote database. :loudspeaker:\n`?echobox <quote>`: Adds provided quote to the database. :writing_hand:\n`?youtube <query>`: Return first result of a YouTube search. :cinema:",
"inline": false
},
{
"name": "**Pirate vs. Ninja:**",
"value": "`?pirate <name>`: Look up stats for a pirate. :pirate_flag:\n`?ninja <name>`: Look up stats for a ninja. :ninja:\n`?pvn <pirate> | <ninja>`: Let them fight! :crossed_swords:\n`?nvp <ninja> | <pirate>`: Same as above but reversed order.",
"inline": false
},
{
"name": "**Basic Response Aliases:**",
"value": "`?angery`: *I taste a vegetal.* :rage:\n`?subway`: Arin's infamous Subway rant. :sandwich:\n`?illuminati`: Always watching. :eye:\n`?cube`: *The Cube*\n`?coolsville`: Population: Us :sunglasses:\n`?communism`: The old 3D rotating gif.\n`?opinions`: Here they come. :scream:\n`?fineart`: The great masterpiece. :art:\n`?artishard`: Art *is* hard. :corn:\n`?florida`: :carpentry_saw::rabbit:\n`?stop`: :stop_sign: :smiley:\n`?indeed`",
"inline": false
},
{
"name": "**Auth-Requiring Commands:**",
"value": "`?motd`: Enables MOTD for current channel.\n`?devmode`: Toggles Dev Mode.\n`?trace`: Debugging tool.\n`?quit`: Shutdown command.",
"inline": true
},
{
"name": "**Non-command bot functions:**",
"value": "**MOTD**: *Meme of the Day* is posted in enabled channels.\n**Icce**: Bot provides users with ice cuboids. :ice_cube:",
"inline": true
}
]
}
]});
case "chk":
m.reply({content:'<@${m.author.id}>: ack'}, false);
case "mrkrabs":
m.reply({content:'<@${m.author.id}>: ack ack ack ack ack'}, false);
case "slap":
if (msg.length != 0) {m.reply({content:'*onequestionmark slaps ${msg} around a bit with a large trout*'}, false);}
case "tufac":
m.reply({content:"*Not Teh Face, but better,*\n*Tufac to the letter!*\n*Always two faces, never one,*\n*Tufac has you on the run!*"}, false);
case "youtube":
if (msg.length > 0) {ytlookup(m,msg);}
case "ytdebug":
if (FileSystem.exists("export/ytlookup_result.txt")) {
var debugRename = 'export/${datestamp()}_${StringTools.lpad(Std.string(Date.now().getHours()), "0", 2)}-${StringTools.lpad(Std.string(Date.now().getMinutes()), "0", 2)}-${StringTools.lpad(Std.string(Date.now().getSeconds()), "0", 2)}_ytdebug.txt';
FileSystem.rename("export/ytlookup_result.txt", debugRename);
Sys.println('[${timestamp()}] ytdebug: Created debug file ${debugRename}');
m.react('');
} else {
Sys.println('[${timestamp()}] ytdebug: No result to debug');
m.reply({content:"ytdebug: No result to debug"}, false);
}
case "pirate":
if (msg.length > 0) {pvn("p",msg,m);}
case "ninja":
if (msg.length > 0) {pvn("n",msg,m);}
case "pvn":
if (msg.length > 0) {pvn("pvn",msg,m);}
case "nvp":
if (msg.length > 0) {pvn("nvp",msg,m);}
// Gaming functions
case "coin":
if ((msg.length == 0) || (Std.parseInt(msg) == null) || (Std.parseInt(msg) == 1)) {
var coin = randInt(0,1);
if (coin == 1) {
m.reply({content:'${sender} flipped a coin and got heads.'}, false);
} else {
m.reply({content:'${sender} flipped a coin and got tails.'}, false);
}
} else {
if ((Std.parseInt(msg) > 1) && (Std.parseInt(msg) < 101)) {
var heads:Int = 0;
for (i in 1...(Std.parseInt(msg)+1)) {
heads += randInt(0,1);
}
m.reply({content:'${sender} flipped ${Std.parseInt(msg)} coins.\n**Heads:** ${heads}\n**Tails:** ${Std.parseInt(msg)-heads}'}, false);
} else {
m.reply({content:'Please enter a value between 1 and 100.'}, false);
}
}
case "dice":
var sides:Int = 6;
// Set number of sides
if (args.length != 0) {
if (Std.parseInt(args[0]) != null) {
sides = Std.parseInt(args[0]);
}
}
// Validate sides
if (sides == 1) {
m.reply({content:'${sender} rolled a ball or something, idk.'}, false);
} else if (sides == 2) {
m.reply({content:'${sender} tried to roll a 2-sided die (this is called a coin btw).'}, false);
} else if ((sides < 1) || (sides > 100)) {
m.reply({content:'Please enter a value between 1 and 100.'}, false);
} else {
// Roll a single die
if ((msg.length == 0) || (Std.parseInt(msg) == null) || (Std.parseInt(msg) == 1)) {
m.reply({content:'${sender} rolled a ${sides}-sided die and got ${randInt(1,sides)}.'}, false);
} else {
// Roll multiple dice
if ((Std.parseInt(msg) > 1) && (Std.parseInt(msg) < 101)) {
var results:Array<Int> = [];
for (i in 1...(Std.parseInt(msg)+1)) {
results.push(randInt(1,sides));
}
// Sort results high-to-low
results.sort((a, b) -> b - a);
m.reply({content:'${sender} rolled a ${sides}-sided die ${Std.parseInt(msg)} times.\n**Results:** ${results.toString()}'}, false);
} else {
m.reply({content:'Please enter a value between 1 and 100.'}, false);
}
}
}
case "8ball":
var response:Array<String> = ["It is certain.","It is decidedly so.","Without a doubt.","Yes definitely.","You may rely on it.","As I see it, yes.","Most likely.","Outlook good.","Yes.","Signs point to yes.","Reply hazy, try again.","Ask again later.","Better not tell you now.","Cannot predict now.","Concentrate and ask again.","Don't count on it.","My reply is no.","My sources say no.","Outlook not so good.","Very doubtful."];
m.reply({content:'🎱 ${response[randInt(0,19)]} 🎱'}, false);
// Basic aliases
case "angery":
m.reply({content:"https://cdn.discordapp.com/attachments/1071547517847732305/1079518504413311108/angery.jpg"}, false);
case "subway":
m.reply({content:"https://www.youtube.com/watch?v=y3VRXVvr6XU"}, false);
case "illuminati":
m.reply({content:"https://cdn.discordapp.com/attachments/1071547517847732305/1147204253039984671/illuminati.gif"}, false);
case "cube":
m.reply({content:"https://cdn.discordapp.com/attachments/270113422232911883/502690458779123722/the_cube.jpg"}, false);
case "coolsville":
m.reply({content:"https://cdn.discordapp.com/attachments/1071547517847732305/1147583765212835921/coolsville.gif"}, false);
case "communism":
m.reply({content:"https://cdn.discordapp.com/attachments/1071547517847732305/1147590229960691742/communism.gif"}, false);
case "opinions":
m.reply({content:"https://cdn.discordapp.com/attachments/1071547517847732305/1147983375701921892/opinions.jpg"}, false);
case "fineart":
m.reply({content:"https://cdn.discordapp.com/attachments/270113422232911883/423323629116325898/unknown.png"}, false);
case "artishard":
m.reply({content:"https://cdn.discordapp.com/attachments/270113422232911883/356638565834424330/tumblr_nrxhzoF0KM1t75ioqo2_250.jpg"}, false);
case "indeed":
m.reply({content:"https://cdn.discordapp.com/attachments/1071547517847732305/1305252892491649145/indeed.gif"}, false);
case "florida":
m.reply({content:"https://cdn.discordapp.com/attachments/1071547517847732305/1305253566348529696/florida.gif"}, false);
case "stop":
m.reply({content:"https://cdn.discordapp.com/attachments/1071547517847732305/1305253985036275712/stop.jpg"}, false);
// Image database commands
case "hug":
m.reply({content:'🫂 *hugs ${msg}*\n${hugDB[randInt(0, hugDB.length-1)]}'}, false);
if (hugDB.length > 0) {
m.reply({content:'🫂 *hugs ${msg}*\n${hugDB[randInt(0, hugDB.length-1)]}'}, false);
}
case "yes":
var yes = yesnoDB.yes;
m.reply({content:'${yes[randInt(0, yes.length-1)]}'}, false);
if (yes.length > 0) {
m.reply({content:'${yes[randInt(0, yes.length-1)]}'}, false);
}
case "no":
var no = yesnoDB.no;
m.reply({content:'${no[randInt(0, no.length-1)]}'}, false);
if (no.length > 0) {
m.reply({content:'${no[randInt(0, no.length-1)]}'}, false);
}
// Echobox
case "echobox":
// Create Echobox array for the server if missing
if (!echoboxDB.exists('server${m.guild_id}')) {
echoboxDB.set('server${m.guild_id}', []);
}
var echobox:Array<String> = echoboxDB.get('server${m.guild_id}');
var quote:String = "";
// Echobox response
if ((!m.mention_everyone) && (m.mentions.length == 0) && (m.mention_roles.length == 0)) {
if (echobox.length > 0) {
var ebchoice = randInt(0, echobox.length-1);
quote = '**Echobox, quote #${ebchoice+1}:** ${echobox[ebchoice]}';
} else {
quote = '**Echobox:** There are no quotes in this server\'s Echobox yet.';
}
// Add quote to Echobox
if (msg.length != 0) {
if (!echobox.contains(msg)) {
echobox.push(msg);
echoboxDB.set('server${m.guild_id}', echobox);
saveQueue.push("echobox");
m.reply({content:quote}, false);
} else {
m.reply({content:'**Echobox:** This quote is already in the database.'}, false);
}
} else {
m.reply({content:quote}, false);
}
} else {
m.reply({content:'**Echobox:** You cannot add mentions to the Echobox.'}, false);
}
}
}
// Non-command responses
if ((msg.charAt(0) == ".") && (msg.length == 1)) {m.reply({content:"omg a meteor"}, true);}
if ((iceRegex.match(msg)) && (m.author.id != botInfo.id)) {m.reply({content:"Did some carbon-based lifeform just say **I C E**?"});}
if ((m.mention_everyone == true) && (msg.charAt(0) != "?")) {m.reply({content:"https://cdn.discordapp.com/attachments/1071547517847732305/1147598637241741343/at_everyone.jpg"}, true);}
}
}
// Time-related functions
public static function timestamp() {
return '${StringTools.lpad(Std.string(Date.now().getHours()), "0", 2)}:${StringTools.lpad(Std.string(Date.now().getMinutes()), "0", 2)}';
}
public static function datestamp() {
return '${StringTools.lpad(Std.string(Date.now().getMonth()+1), "0", 2)}-${StringTools.lpad(Std.string(Date.now().getDate()), "0", 2)}';
}
// Clean print day of week
public static function checkDay() {
var day = "";
switch (Date.now().getDay()+1) {
case 1:
day = "sun";
case 2:
day = "mon";
case 3:
day = "tues";
case 4:
day = "wed";
case 5:
day = "thurs";
case 6:
day = "fri";
case 7:
day = "sat";
/**
* This function starts the "Meme of the Day" system.
* A temporary timer is set that counts down to the next MOTD event, at which point the main timer is started on a 24 hour loop.
*/
public static function startMotd() {
var now = Date.now();
var nextPost = new Date(now.getFullYear(), now.getMonth(), now.getDate(), settings.motd.time, 0, 0);
if (now.getHours() >= settings.motd.time) {
if (settings.motd.date != datestamp()) {
settings.motd.date = datestamp();
postMotd();
saveQueue.push("settings");
}
nextPost = DateTools.delta(nextPost, 86400000); // Add one day in milliseconds
}
return day;
}
// Clean print month
public static function checkMonth() {
var month = "";
switch (Date.now().getMonth()+1) {
case 1:
month = "jan";
case 2:
month = "feb";
case 3:
month = "mar";
case 4:
month = "apr";
case 5:
month = "may";
case 6:
month = "jun";
case 7:
month = "jul";
case 8:
month = "aug";
case 9:
month = "sep";
case 10:
month = "oct";
case 11:
month = "nov";
case 12:
month = "dec";
}
return month;
}
// Calculate delay for timers
var delay = Std.int(nextPost.getTime() - now.getTime());
//Get current day of month, padded
public static function checkDate() {
return StringTools.lpad(Std.string(Date.now().getDate()), "0", 2);
}
public static function monthdate() {
return '${checkMonth()}${checkDate()}';
}
public static function weekdate() {
return '${checkDay()}${checkDate()}';
}
public static function motd() {
if (settings.motd.date != datestamp()) {
Timer.delay(function(){
settings.motd.date = datestamp();
settings.motd.posted = false;
postMotd();
saveQueue.push("settings");
// Start the looped MOTD timer
motdTimer = new Timer(86400000);
motdTimer.run = function() {
settings.motd.date = datestamp();
postMotd();
saveQueue.push("settings");
}
}, delay);
}
/**
* Sends the MOTD to the channels specified in the bot settings.
* May just move this code into the places it's needed instead of keeping this function.
*/
public static function postMotd() {
var msg = getMotd(true);
var channels:Array<String> = settings.motd.channels; // Because it won't let me do this directly
if (channels.length > 0) {for (i in channels) {if (msg.length > 0) {Endpoints.sendMessage(i, {content:msg}, null, false);}}}
}
/**
* This function checks the MOTD database to find the entry most relevant to the current date and returns the appropriate data.
*
* @param update Whether the result should be stored to exclude from the next run.
*/
public static function getMotd(update:Bool = false) {
var msg = "";
var day:Array<String> = [];
var special = true;
// Check for exact date
if (motdDB.exists('${datestamp()}')) {
day = motdDB.get('${datestamp()}');
}
// Else, check for special date
else if (motdDB.exists('${printMonth().substr(0,3)}${printDate()}')) {
day = motdDB.get('${printMonth().substr(0,3)}${printDate()}');
}
// Else, check for special day/date combo
else if (motdDB.exists('${printDay().substr(0,3)}${printDate()}')) {
day = motdDB.get('${printDay().substr(0,3)}${printDate()}');
}
// Else, check for just the date
else if (motdDB.exists('${printDate()}')) {
day = motdDB.get('${printDate()}');
}
// Otherwise, post daily meme
else if (motdDB.exists(printDay().substr(0,3))) {
day = motdDB.get(printDay().substr(0,3));
special = false;
}
if (day.length > 0) {
if (update && !special && (day.length > 2)) {
var today = Date.now().getDay();
var previous:Array<Int> = settings.motd.previous;
var choice = randIntExclude(0, day.length-1,[previous[today]]);
previous[today] = choice;
settings.motd.previous = previous;
msg = day[choice];
} else {
msg = day[randInt(0, day.length-1)];
}
}
return msg;
}
/**
* This function performs the *Pirate vs Ninja* stat calculations and replies with the appropriate results.
* @param choice `p`, `n`, `pvn`, or `nvp`.
* @param query The name of the character. For dual-character choices, separate names with a pipe.
*/
public static function pvn(choice:String,query:String,m:Message) {
if (choice.length > 1) {
var splitQuery:Array<String> = [];
if (query.contains(" | ")) {
splitQuery = query.split(" | ");
} else {
splitQuery.push(query);
splitQuery.push(query);
}
if (choice == "pvn") {
var pirate = getPirate(splitQuery[0]);
var ninja = getNinja(splitQuery[1]);
printPirate(pirate,m);
printNinja(ninja,m);
if (pirate.total > ninja.total) {
m.reply({content:'## ${pirate.rank} ${pirate.name} wins!'}, false);
} else if (pirate.total < ninja.total) {
m.reply({content:'## ${ninja.rank} ${ninja.name} wins!'}, false);
} else {
m.reply({content:'## It\'s a TIE!'}, false);
}
} else if (choice == "nvp") {
var ninja = getNinja(splitQuery[0]);
var pirate = getPirate(splitQuery[1]);
printNinja(ninja,m);
printPirate(pirate,m);
if (pirate.total > ninja.total) {
m.reply({content:'## ${pirate.rank} ${pirate.name} wins!'}, false);
} else if (pirate.total < ninja.total) {
m.reply({content:'## ${ninja.rank} ${ninja.name} wins!'}, false);
} else {
m.reply({content:'## It\'s a TIE!'}, false);
}
}
} else {
if (choice == "p") {printPirate(getPirate(query),m);}
if (choice == "n") {printNinja(getNinja(query),m);}
}
}
public static function getPirate(name) {
var pirate:Dynamic = {};
pirate.name = name;
var pweapons = ['Cutlass', 'Pistol', 'Broken Bottle', 'Cannon', 'Attack Parrot', 'Hook Hand', 'Belaying Pin', 'Dagger', 'Grappling Hook', 'A Mean Streak A Mile Wide'];
pirate.swash = (hashString(name, "swashbuckling", 20) + 1);
pirate.drunk = (hashString(name, 'drunkenness', 20) + 1);
pirate.booty = (hashString(name, 'booty', 20) + 1);
var weapons:Array<String> = [];
var wnum = (hashString(name, "pweapons", 5) - 1);
while (wnum > 0) {
var wsel = pweapons[hashString(name, '$wnum pweapon', pweapons.length - 1)];
if (!weapons.contains(wsel)) {weapons.push(wsel);}
wnum--;
}
pirate.weapons = "";
for (i in 0...weapons.length) {
if (Std.string(pirate.weapons).length != 0) {pirate.weapons += ", ";}
pirate.weapons = pirate.weapons + weapons[i];
}
pirate.total = pirate.swash + pirate.drunk + pirate.booty + weapons.length;
pirate.rank = "Cabin Boy";
if (pirate.total > 15) {pirate.rank = 'Pirate';}
if (pirate.total > 30) {pirate.rank = 'Dread Pirate';}
if (pirate.total > 45) {pirate.rank = 'Captain';}
if (pirate.total >= 60) {pirate.rank = 'Pirate King';}
return(pirate);
}
public static function getNinja(name) {
var ninja:Dynamic = {};
ninja.name = name;
var nweapons = ['Shuriken', 'Katana', 'Poison Dart', 'Death Touch', 'Ninja-To', 'Smoke Bomb', 'Thousand Blossom Finger', 'A Pointy Stick', 'Jo Stick', 'Nunchaku'];
ninja.sneak = (hashString(name, "sneakiness", 20) + 1);
ninja.peejs = (hashString(name, 'pajamas', 20) + 1);
ninja.point = (hashString(name, 'pointy things', 20) + 1);
var weapons:Array<String> = [];
var wnum = (hashString(name, "nweapons", 5) - 1);
while (wnum > 0) {
var wsel = nweapons[hashString(name, '$wnum nweapon', nweapons.length - 1)];
if (!weapons.contains(wsel)) {weapons.push(wsel);}
wnum--;
}
if ((settings.motd.posted == false) && (Date.now().getHours() >= settings.motd.time)) {
settings.motd.posted = true;
saveSettings();
var msg = "";
var day:Array<String> = [];
// Check for special date
if (motdDB.exists(monthdate())) {
day = motdDB.get(monthdate());
}
// Else, check for special day/date combo
else if (motdDB.exists(weekdate())) {
day = motdDB.get(weekdate());
}
// Otherwise, post daily meme
else if (motdDB.exists(checkDay())) {
day = motdDB.get(checkDay());
}
if (day.length > 0) {msg = day[randInt(0, day.length-1)];}
var channels:Array<String> = settings.motd.channels; // Because it won't let me do this directly
if (channels.length > 0) {for (i in channels) {if (msg.length > 0) {Endpoints.sendMessage(i, {content:msg}, null, false);}}}
ninja.weapons = "";
for (i in 0...weapons.length) {
if (Std.string(ninja.weapons).length != 0) {ninja.weapons += ", ";}
ninja.weapons = ninja.weapons + weapons[i];
}
ninja.total = ninja.sneak + ninja.peejs + ninja.point + weapons.length;
ninja.rank = "Apprentice";
if (ninja.total > 15) {ninja.rank = 'Ninja';}
if (ninja.total > 30) {ninja.rank = 'Deadly Ninja';}
if (ninja.total > 45) {ninja.rank = 'Ninja Master';}
if (ninja.total >= 60) {ninja.rank = 'Grandmaster';}
return(ninja);
}
// General functions
public static function randInt(x, y) {
// Return a random integer between x and y, both inclusive
return Std.random((y+1)-x)+x;
public static function printPirate(pirate, m:Message) {
m.reply({content:'## ${pirate.rank} ${pirate.name}\nSwashbuckling: ${pirate.swash}\nDrunkenness: ${pirate.drunk}\nBooty: ${pirate.booty}\nWeapons: ${pirate.weapons}\n**Total: ${pirate.total}**'}, false);
}
public static function printNinja(ninja, m:Message) {
m.reply({content:'## ${ninja.rank} ${ninja.name}\nSneakiness: ${ninja.sneak}\nPajamas: ${ninja.peejs}\nPointy Things: ${ninja.point}\nWeapons: ${ninja.weapons}\n**Total: ${ninja.total}**'}, false);
}
/**
* Marc Majcher's string hasher from *Pirate vs Ninja!*
* @param input The input string.
* @param seed An identifying salt to make strings come out differently for different, but same-sized lists.
* @param num The number of buckets you wish to hash into.
*/
public static function hashString(input:String, seed:String, num:BigInt) {
var count:BigInt = 0;
var total:BigInt = 0;
var xorResult = "";
// Pad strings until they're equal in length
input = StringTools.rpad(input, String.fromCharCode(0), seed.length);
seed = StringTools.rpad(seed, String.fromCharCode(0), input.length);
// XOR the input and seed
for (i in 0...(input.length)) {
xorResult = xorResult + String.fromCharCode(input.charCodeAt(i) ^ seed.charCodeAt(i));
}
// Calculate stats
for (i in 0...xorResult.length) {
total += (xorResult.charCodeAt(i):BigInt) * ((128:BigInt).pow(count));
count++;
}
return(((total * (total + (523:BigInt))) % num).toInt());
}
/**
* This function performs a YouTube search and replies with the first result.
* @param m The message data.
* @param query The string to search.
*/
public static function ytlookup(m:Message,query:String) {
var req = new haxe.Http('https://www.youtube.com/results?search_query=${query.urlEncode()}');
Sys.println('[${timestamp()}] ytlookup: URL - https://www.youtube.com/results?search_query=${query.urlEncode()}');
req.onData = function (request) {
File.saveContent("export/ytlookup_result.txt", request);
var data = new HtmlDocument(request);
var search = data.find("script"); // YouTube obfuscates everything into JS garbage so we have to check every <script>
var gotcha = "";
var result = "Error: Unable to parse YouTube search result.";
for (i in search) {
if (i.toString().contains("\"videoRenderer\":{\"videoId\":\"") || i.toString().contains("\"reelWatchEndpoint\":{\"videoId\":\"")) {
gotcha = i.toString();
if ((i.toString().contains("\"reelWatchEndpoint\":{\"videoId\":\"")) && (gotcha.indexOf("\"reelWatchEndpoint\":{\"videoId\":\"") < gotcha.indexOf("\"videoRenderer\":{\"videoId\":\""))) {
// Top result is a Short
result = "https://youtu.be/" + gotcha.substring(gotcha.indexOf("\"reelWatchEndpoint\":{\"videoId\":\"")+32, gotcha.indexOf("\"", gotcha.indexOf("\"reelWatchEndpoint\":{\"videoId\":\"")+32));
Sys.println('[${timestamp()}] ytlookup: Result - $result (Short)');
break;
} else {
// Top result is a normal video
result = "https://youtu.be/" + gotcha.substring(gotcha.indexOf("\"videoRenderer\":{\"videoId\":\"")+28, gotcha.indexOf("\"", gotcha.indexOf("\"videoRenderer\":{\"videoId\":\"")+28));
Sys.println('[${timestamp()}] ytlookup: Result - $result (Normal)');
break;
}
}
}
m.reply({content:result}, false);
m.reply({content:"-# YouTube Lookup is still in beta. If the result seems inaccurate, please use the command `?ytdebug` so the developer can review the data that was received."}, false);
}
req.onError = function (error) {
m.reply({content:'Error in YouTube Lookup: $error'}, false);
Sys.println('[${timestamp()}] ytlookup: Error - $error, request was $query');
}
req.request();
}
/**
* Retrieves a specific message in the channel and returns it as a JSON object.
* @param channel_id Numerical ID of the channel.
* @param m_id Numerical ID of the message.
*/
public static function getMessage(channel_id:String, m_id:String) {
var req:hxdiscord.utils.Http = new hxdiscord.utils.Http("https://discord.com/api/v"+hxdiscord.gateway.Gateway.API_VERSION+"/channels/"+channel_id+"/messages/"+m_id);
req.addHeader("User-Agent", "hxdiscord (https://github.com/FurretDev/hxdiscord)");
req.addHeader("Authorization", DiscordClient.authHeader);
req.setMethod("GET");
var msg:Dynamic = null;
req.onData = function(data:String) {
msg = haxe.Json.parse(data);
}
req.onError = function(error) {
Sys.println('[${timestamp()}] getMessage: Error - $error');
Sys.println('[${timestamp()}] getMessage: ${req.responseData}');
}
req.send();
return msg;
}
/**
* The `saveSystem()` function handles all filesystem writes. When the bot needs to save a file, it pushes data to the `saveQueue` array.
* The save system checks the queue regularly and operates on the first entry provided.
* This throttles filesystem access, to prevent multiple commands from writing to the drive at the same time.
*/
public static function saveSystem() {
Timer.delay(function() {
saveTimer = new Timer(60*1000);
saveTimer.run = function() {
if (saveQueue.length > 0) { // See if there's anything in the queue
switch (saveQueue.shift()) {
case "settings":
saveSettings();
Sys.println('[${timestamp()}] Saved settings.json');
case "echobox":
saveEchoboxDB();
Sys.println('[${timestamp()}] Saved echobox-db.json');
}
}
}
}, (60-Date.now().getSeconds())*1000);
}
/**
* Writes the modified `settings.json` from memory back to the drive.
*/
public static function saveSettings() {
File.saveContent("settings.json", Json.stringify(settings, "\t"));
}
/**
* Writes the modified `echobox-db.json` from memory back to the drive.
*/
public static function saveEchoboxDB() {
File.saveContent("echobox-db.json", Json.stringify(echoboxDB, "\t"));
}
/**
* Performs a clean shutdown of the bot, after saving relevant files to the disk.
*/
public static function shutdown() {
saveSettings();
saveEchoboxDB();
Sys.exit(0);
}
}

63
README.md Normal file
View File

@ -0,0 +1,63 @@
# onequestionmark-chat
**onequestionmark** is a Discord bot written in Haxe.
## Name origin
Following the established naming scheme of Maralis and Derxwna's fivequestionmarks and Kuschelyagi's [tenquestionmarks](https://forge.ds8.zone/elsanctum/tenquestionmarks), onequestionmark is named after the glitch Pokémon known only as ["?"](https://bulbapedia.bulbagarden.net/wiki/%3F_(glitch_Pokémon)) (which amusingly has *two* question marks as its sprite).
"onequestionmark" is never capitalized.
## Configuration
A `settings.json` file is required to run the bot. An example is provided below:
```
{
"motd": {
"date": "",
"time": 8,
"channels": [],
"previous": [0,0,0,0,0,0,0]
},
"debug": false,
"devmode": false,
"devchannel": "000000000000000000",
"botowner": "000000000000000000",
"devserver": "000000000000000000",
"token": "fhqwhgadshgnsdhjsdbkhsdabkfabkveybvf"
}
```
- onequestionmark will handle most of the `motd` values itself.
- `date` and `previous` are modified by the bot during normal operations.
- `time` determines the hour at which the MOTD is posted (24-hour notation, local time).
- Using the `?motd` command adds the current channel to the `channels` array.
- `debug` determines whether HxDiscord's console debug messages are enabled.
- `devmode` restricts the bot's responses to the specified dev server.
- `devchannel`, `devserver`, and `botowner` must be filled with the appropriate numerical values. These values can be seen easily in Discord's URLs.
- `token` is your Discord bot app token. Don't share it.
An `echobox-db.json` file will also be generated by onequestionmark upon starting if it fails to find one.
The `motd-db.json`, `yesno-db.json`, and `hug-db.json` files provided in this repo are examples that will need to be replaced with correct data. Any of these three files can also be removed. If onequestionmark cannot find one of these files at startup, the bot will simply disable the corresponding function.
`settings.json` and `echobox-db.json` are the only files modified by the bot during operation. As they can contain sensitive data, they are excluded from Git tracking.
## Building from source
### Requirements
- [Haxe Programming Language](https://haxe.org/)
- [HxDiscord](https://github.com/furretpaws/hxdiscord) by FurretPaws
- [IzzComLib](https://forge.ds8.zone/Izwzyzx/IzzComLib) by Izwzyzx
Install the libraries [from their git repositories](https://lib.haxe.org/documentation/using-haxelib/#git).
### Compiling
Navigate to the project folder in a terminal and run `haxe hxbuild.hxml`. This will generate `onequestionmark.n` in the export folder.
The provided `hxbuild.hxml` file is set up to immediately run the bot after compiling.
## Running
`neko onequestionmark.n`
onequestionmark is designed and tested to run in [NekoVM](https://nekovm.org/download/) (included with Haxe). Other Haxe targets not guaranteed to work (see HxDiscord library's compatibility).
`onequestionmark.n` should be placed in the same directory as its JSON files.
It is preferable to stop the bot via its own `?quit` command in Discord, so it can save its settings file before exiting.

4
hug-db.json Normal file
View File

@ -0,0 +1,4 @@
[
"Insert \"hug\" image URLs here.",
"The script will randomly choose between multiple entries."
]

View File

@ -1,5 +1,8 @@
-m Onequestionmark
-L hxdiscord
-L IzzComLib
-L HtmlParser
-L littleBigInt
# hxdiscord uses deprecated functions
-D no-deprecation-warnings
--neko export/onequestionmark.n

37
motd-db.json Normal file
View File

@ -0,0 +1,37 @@
{
"Sun": [
"Sunday Event",
"For any day, the script will randomly choose between multiple entries, if provided."
],
"Mon": [
"Monday Event"
],
"Tue": [
"Tuesday Event"
],
"Wed": [
"Wednesday Event"
],
"Thu": [
"Thursday Event"
],
"Fri": [
"Friday Event"
],
"Sat": [
"Saturday Event"
],
"01": [
"Example event to be triggered on the 1st of any month."
],
"Mon01": [
"Example event to be triggered on any Monday that is also the 1st."
],
"Jan01": [
"Example event to be triggered on January 1st."
],
"2000-01-01": [
"Example event to be triggered specifically on January 1st, 2000. (YYYY-MM-DD)",
"Entries that are more specific take precedence over less-specific entries."
]
}

10
yesno-db.json Normal file
View File

@ -0,0 +1,10 @@
{
"yes": [
"Insert \"Yes\" image URLs here.",
"The script will randomly choose between multiple entries."
],
"no": [
"Insert \"No\" image URLs here.",
"The script will randomly choose between multiple entries."
]
}