diff --git a/pom.xml b/pom.xml
index 1322027..ec97a9f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,190 +1,184 @@
4.0.0BotBot1.0-SNAPSHOTdespbotA discord bot1.81.8srcorg.apache.maven.pluginsmaven-shade-plugin3.2.1log4j:**org/apache/log4j/**commons-logging:commons-loggingorg/apache/commons/logging/**org.slf4j:******:sqlite-jdbc****me.despawningbone.discordbot.DiscordBottruepackageshadeorg.awaitilityawaitility3.0.0org.slf4jslf4j-log4j121.8.0-alpha2log4japache-log4j-extras1.2.17commons-loggingcommons-logging1.2
+
- com.sedmelluq
+ com.github.devoxinlavaplayer
- 1.3.77
+ 1.9.1se.michaelthelin.spotifyspotify-web-api-java6.0.0commons-iocommons-io2.5org.apache.commonscommons-lang33.6
-
-
- commons-io
- commons-io
- 2.5
- org.apache.httpcomponentshttpcore4.4.6org.jsonjson20170516net.objecthunterexp4j0.4.8
- net.dv8tion
+ com.github.discord-jdaJDA
- 4.3.0_322
+ v5.2.1org.reflectionsreflections0.9.11org.apache.commonscommons-math33.6.1org.knowm.xchartxchart3.6.4org.xerialsqlite-jdbc3.25.2com.zaxxerHikariCP3.3.1org.jsoupjsoup1.13.1
- dv8tion
- m2-dv8tion
- https://m2.dv8tion.net/releases
+ jitpack.io
+ https://jitpack.io
diff --git a/src/me/despawningbone/discordbot/DiscordBot.java b/src/me/despawningbone/discordbot/DiscordBot.java
index 93fda2c..da8d41e 100644
--- a/src/me/despawningbone/discordbot/DiscordBot.java
+++ b/src/me/despawningbone/discordbot/DiscordBot.java
@@ -1,328 +1,322 @@
package me.despawningbone.discordbot;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.Set;
import java.util.Stack;
import java.util.StringJoiner;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import javax.net.ssl.HttpsURLConnection;
-import javax.security.auth.login.LoginException;
import org.apache.commons.lang3.StringUtils;
import org.apache.log4j.PropertyConfigurator;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
//import com.sedmelluq.discord.lavaplayer.jdaudp.NativeAudioSendFactory;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import me.despawningbone.discordbot.command.Command;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Activity;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.requests.GatewayIntent;
public class DiscordBot {
//TODO move to appropriate locations
public static List ModID = new ArrayList();
public static List logExcemptID = new ArrayList();
//TODO synchronize below or change to concurrent //but why? its thread safe since its read only
public static HashMap commands = new HashMap();
public static HashMap aliases = new HashMap(); //TODO temporary solution
public static TreeMap> catCmds = new TreeMap>(); //order base on categories?
public static ConcurrentHashMap lastMusicCmd = new ConcurrentHashMap(); //TODO store in guild configs //nah its fine
public static final String prefix = "!desp "; //DONE allow guild change prefix?
public static JDA mainJDA = null;
public static String BotID;
public static String OwnerID;
public static Properties tokens = new Properties();
static final Logger logger = LoggerFactory.getLogger(DiscordBot.class); //package private
public static HikariDataSource db;
//DONE SQLite integration; check if program termination will screw up the connection
//TODO SHARDING
//DONE on shutdown alert those playing music or await? //alerted
public static void main(String[] args) {
PropertyConfigurator.configure(System.getProperty("user.dir") + File.separator + "log4j.properties");
shuffleCipherSuites();
//init tokens file
try(FileInputStream in = new FileInputStream(new File(System.getProperty("user.dir") + File.separator + "tokens.properties"))){
tokens.load(in);
} catch (IOException e) {
e.printStackTrace();
return;
}
initCmds();
initDB();
//login
- try {
- JDA jda = JDABuilder.create(tokens.getProperty("bot"), GatewayIntent.getIntents(GatewayIntent.ALL_INTENTS))
- //.setAudioSendFactory(new NativeAudioSendFactory()) //segfaults frequently somehow, disabling
- .addEventListeners(new me.despawningbone.discordbot.EventListener()).build();
- jda.getPresence().setActivity(Activity.watching("Ping me for info!"));
- // for user bots, see
- // https://discordapp.com/developers/docs/topics/gateway#update-status
- // and
- // https://gist.github.com/MrPowerGamerBR/b8acccb9951b0d78a5115024e3ee0d03
-
- //find owner id from bot owner
- BotID = jda.getSelfUser().getId();
- OwnerID = jda.retrieveApplicationInfo().complete().getOwner().getId();
- } catch (LoginException e) {
- e.printStackTrace();
- return;
- }
+ JDA jda = JDABuilder.create(tokens.getProperty("bot"), GatewayIntent.getIntents(GatewayIntent.ALL_INTENTS))
+ //.setAudioSendFactory(new NativeAudioSendFactory()) //segfaults frequently somehow, disabling
+ .addEventListeners(new me.despawningbone.discordbot.EventListener()).build();
+ jda.getPresence().setActivity(Activity.watching("Ping me for info!"));
+ // for user bots, see
+ // https://discordapp.com/developers/docs/topics/gateway#update-status
+ // and
+ // https://gist.github.com/MrPowerGamerBR/b8acccb9951b0d78a5115024e3ee0d03
+
+ //find owner id from bot owner
+ BotID = jda.getSelfUser().getId();
+ OwnerID = jda.retrieveApplicationInfo().complete().getOwner().getId();
//initiate logs //handled by log4j?
File directory = new File(System.getProperty("user.dir") + File.separator + "logs" + File.separator);
if (!directory.exists()) {
directory.mkdir();
}
//DEPRECATED //or is it? make unmodifiable instead
for(String id : tokens.getProperty("botmod").split(",")) {
ModID.add(id);
}
ModID.add(OwnerID);
//TODO put into sql and make guild setting to opt out
logExcemptID.add(BotID);
}
private static void initDB() { //init guild settings when do !desp settings for the first time? //dont really need coz im doing upsert for all values anyways
HikariConfig dbConf = new HikariConfig();
dbConf.setJdbcUrl("jdbc:sqlite:data.db");
dbConf.setIdleTimeout(45000);
dbConf.setMaxLifetime(60000);
dbConf.setMaximumPoolSize(10);
//dbConf.setMaximumPoolSize(25); //for deployment in server
db = new HikariDataSource(dbConf);
try (Connection con = db.getConnection()){
Statement s = con.createStatement();
s.execute("CREATE TABLE IF NOT EXISTS settings"
+ "(id INTEGER PRIMARY KEY," //performance problem using text; integer can handle long anyways
+ "prefix TEXT DEFAULT '!desp ',"
+ "premium INTEGER DEFAULT 0,"
+ "mchannel TEXT," //allow people to set this manually? By default, use last music cmd place
+ "locale TEXT DEFAULT 'EN'," //or int? //user specific or guild specific, or user override guild?
+ "shortcuts TEXT," //use another method?
+ "votepct INTEGER DEFAULT 50,"
+ "looplimit INTEGER DEFAULT 1,"
+ "volume INTEGER DEFAULT 100," //premium?, is default actually 100?
+ "helpdm INTEGER DEFAULT 0);"); //send help to dm or not, excluding cmd help(?)
//add logexempt?
//init perms table
PreparedStatement pragma = con.prepareStatement("SELECT name FROM pragma_table_info('perms_' || ?);"); //NOTE: sqlite does not support changing default values, beware when adding new commands
for(Entry> entry : catCmds.entrySet()) {
pragma.setString(1, entry.getKey().toLowerCase());
ResultSet rs = pragma.executeQuery();
//traverse cmd tree to obtain node names
boolean isEmpty = true;
HashMap> nodes = new HashMap<>();
Stack tree = new Stack<>();
for(Command cmd : entry.getValue()) tree.push(cmd); //populate stack coz there is no root node
while(!tree.empty()) {
Command cmd = tree.pop();
String name = cmd.getName();
for(Command iterate = cmd.getParent(); iterate != null; iterate = iterate.getParent()) {
name = iterate.getName() + "." + name;
}
nodes.put(name, cmd.getDefaultPerms());
for(String sub : cmd.getSubCommandNames()) {
tree.push(cmd.getSubCommand(sub));
}
}
while(rs.next()) {
isEmpty = false;
nodes.remove(rs.getString("name")); //remove already added node names
}
if(isEmpty) {
String create = "CREATE TABLE perms_" + entry.getKey().toLowerCase() + " (id INTEGER PRIMARY KEY, _GLOBAL_ TEXT DEFAULT '0\n', "; //perms for a category are always default no restraints
StringJoiner join = new StringJoiner(", ");
for(Entry> node : nodes.entrySet()) {
join.add("\"" + node.getKey() + "\" TEXT DEFAULT '" + ((0L << 32) | (Permission.getRaw(node.getValue()) & 0xffffffffL)) + "\n'"); //initialize with no permission deny override, permissions allow //needs to be text so that channel overrides delimiter can be stored
}
create += join.toString() + ");";
s.execute(create);
//DONE? create table
} else {
if(!nodes.isEmpty()) { //which means there is new subCmd/cmd
for(Entry> node : nodes.entrySet()) {
s.execute("ALTER TABLE perms_" + entry.getKey().toLowerCase() + " ADD COLUMN \"" + node.getKey() + "\" TEXT DEFAULT '" + ((0L << 32) | (Permission.getRaw(node.getValue()) & 0xffffffffL)) + "\n';"); //not able to prep statement coz its column name
s.close();
}
}
}
}
pragma.close();
//init users table
//DONE TEST generate the nodes, retrieve default from fields? //what about additional commands added, use ALTER TABLE ADD COLUMN DEFAULT? I dont need to sort it according to category at all, i am not and will not be bulk printing permissions anywhere //actually i might be coz of list edited perms
//fit subnodes into sub tables, split by category so its more balanced (category as table name)?
//fields are gonna be sth like :| etc?
//DONE store the permissions with long merged; <32bit = deny, >32bit = allow
s.execute("CREATE TABLE IF NOT EXISTS users"
+ "(id INTEGER PRIMARY KEY,"
+ "reports TEXT DEFAULT '',"
+ "game TEXT);"); //disposable, i can change it anytime
//add botmod field? if so, i can deprecate the hard coded list, but it would require querying to this table on perm checks //i need to query it to check if the user is banned anyways //subcmd need to query again, so i didnt do it; besides the modlist is gonna be too small to be significant anyways
//add user specific locale? might be confusing
s.close();
} catch (SQLException e1) {
e1.printStackTrace();
}
}
private static void initCmds() {
Reflections reflections = new Reflections("me.despawningbone.discordbot.command");
Set> classes = reflections.getSubTypesOf(Command.class);
for (Class extends Command> s : classes) {
try {
//skip abstract classes
if (Modifier.isAbstract(s.getModifiers())) {
continue;
}
String pkName = s.getPackage().getName();
String cat = StringUtils.capitalize(pkName.substring(pkName.lastIndexOf(".") + 1));
Command c = s.getConstructor().newInstance(); //DONE use constructor to put name and cat instead
//dont skip disabled commands - they should still show up but disabled
//init name and cat
try {
Field nameF = Command.class.getDeclaredField("name");
nameF.setAccessible(true);
if(nameF.get(c) == null) nameF.set(c, s.getSimpleName().toLowerCase()); //allow overrides
Field catF = Command.class.getDeclaredField("cat");
catF.setAccessible(true);
catF.set(c, cat);
} catch (NoSuchFieldException e) { //should never throw
e.printStackTrace();
}
//check disable whole command category? //no non-perm-wise category wide disabling support
List cmds = catCmds.get(cat);
if(cmds == null) {
cmds = new ArrayList(Arrays.asList(c));
} else {
cmds.add(c);
}
catCmds.put(cat, cmds);
commands.put(c.getName(), c);
List l = c.getAliases();
if(l != null) {
for(int i = 0; i < l.size(); i++) {
aliases.put(l.get(i), c);
}
}
} catch (ReflectiveOperationException e) {
e.printStackTrace();
}
}
//sort each category alphabetically
for(Entry> entry : catCmds.entrySet()) {
Collections.sort(entry.getValue(), (a, b) -> a.getName().compareTo(b.getName()));
}
}
//shuffles SSL cipher suites for the default SSL socket factory to avoid fingerprinting used by some sites (e.g. cloudflare-backed sites)
//current implementation is to disable renegotiation and shuffle, since it seems like at least cloudflare flags renegotiation
//none of the endpoints in this bot should require mutual authentication, and it seems like major browsers also doesnt include it so should be fine
//sun.security.ssl.SSLSocketFactoryImpl dependent
private static void shuffleCipherSuites() {
try {
//obtain the SSLContext in use
Field contextField = HttpsURLConnection.getDefaultSSLSocketFactory().getClass().getDeclaredField("context");
contextField.setAccessible(true);
Object context = contextField.get(HttpsURLConnection.getDefaultSSLSocketFactory());
//find clientDefaultCipherSuites/clientDefaultCipherSuiteList, which is ultimately obtained by getDefaultCipherSuites(false)/getDefaultCipherSuiteList(false) called by SSLSocketFactoryImpl
//recursively find getDefaultCipherSuiteList due to unknown class hierachy
Class> contextClass = context.getClass();
Field clientDefaultCipherSuitesField = null;
do {
for(Field field : contextClass.getDeclaredFields()) {
if(field.getName().contains("clientDefaultCipherSuite")) {
clientDefaultCipherSuitesField = field;
break;
}
}
contextClass = contextClass.getSuperclass();
} while (clientDefaultCipherSuitesField == null);
clientDefaultCipherSuitesField.setAccessible(true);
Object cipherSuitesObj = clientDefaultCipherSuitesField.get(context);
//obtain the actual list depending on what type it is
Collection> cipherSuiteList;
if(cipherSuitesObj.getClass().getSimpleName().equals("CipherSuiteList")) {
Arrays.asList(cipherSuitesObj.getClass().getDeclaredFields()).forEach(f -> System.out.println(f.getName()));
Field cipherSuiteCollection = cipherSuitesObj.getClass().getDeclaredField("cipherSuites");
cipherSuiteCollection.setAccessible(true);
//CipherSuiteList can contain any type of collections (ArrayList, TreeSet, etc)
cipherSuiteList = (Collection>) cipherSuiteCollection.get(cipherSuitesObj);
//reset name list so it can regenerate after shuffling
Field suiteNamesField = cipherSuitesObj.getClass().getDeclaredField("suiteNames");
suiteNamesField.setAccessible(true);
suiteNamesField.set(cipherSuitesObj, null);
} else {
cipherSuiteList = (Collection>) cipherSuitesObj;
}
//remove renegotiation and shuffle
cipherSuiteList.removeAll(cipherSuiteList.stream().filter(o -> o.toString().contains("TLS_EMPTY_RENEGOTIATION_INFO_SCSV")).collect(Collectors.toList()));
if(cipherSuiteList instanceof List>)
Collections.shuffle((List>) cipherSuiteList); //only shuffle if it's a list - sets are unordered already
} catch (Throwable e) { //methodhandle.invoke throws throwable as checked exception
logger.warn("Cannot shuffle SSL cipher suites - certain commands might not work due to SSL fingerprinting.", e);
}
}
}
diff --git a/src/me/despawningbone/discordbot/EventListener.java b/src/me/despawningbone/discordbot/EventListener.java
index ce68702..9f84331 100644
--- a/src/me/despawningbone/discordbot/EventListener.java
+++ b/src/me/despawningbone/discordbot/EventListener.java
@@ -1,314 +1,289 @@
package me.despawningbone.discordbot;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.exception.ExceptionUtils;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import me.despawningbone.discordbot.command.music.AudioTrackHandler;
import me.despawningbone.discordbot.command.music.GuildMusicManager;
import me.despawningbone.discordbot.command.music.Music;
import me.despawningbone.discordbot.utils.MiscUtils;
import net.dv8tion.jda.api.EmbedBuilder;
-import net.dv8tion.jda.api.MessageBuilder;
+import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
+import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.GuildVoiceState;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
-import net.dv8tion.jda.api.entities.VoiceChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
+import net.dv8tion.jda.api.entities.emoji.Emoji;
import net.dv8tion.jda.api.entities.Message.Attachment;
-import net.dv8tion.jda.api.events.guild.voice.GuildVoiceJoinEvent;
-import net.dv8tion.jda.api.events.guild.voice.GuildVoiceLeaveEvent;
-import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMoveEvent;
-import net.dv8tion.jda.api.events.message.guild.GuildMessageReceivedEvent;
-import net.dv8tion.jda.api.events.message.guild.GuildMessageUpdateEvent;
+import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent;
+import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
+import net.dv8tion.jda.api.events.message.MessageUpdateEvent;
import net.dv8tion.jda.api.events.user.UserActivityStartEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
public class EventListener extends ListenerAdapter {
@Override
- public void onGuildMessageReceived(GuildMessageReceivedEvent event) {
+ public void onMessageReceived(MessageReceivedEvent event) {
+ //do not support DMs for now
+ if(!event.isFromGuild()) return;
+
DiscordBot.mainJDA = event.getJDA(); // refresh it? //no need, everything's getJDA() is the same jda basis but audioplayer's still using it; need to migrate to store in guildmusicmanager
User author = event.getAuthor();
- TextChannel channel = event.getChannel();
+ TextChannel channel = event.getChannel().asTextChannel();
Message msg = event.getMessage();
//DONE use log4j
//logging
if (!DiscordBot.logExcemptID.contains(author.getId())) {
String guildinfo = "[" + event.getGuild().getName() + " #" + channel.getName() + "]";
String payload = "[INFO] " + guildinfo + System.lineSeparator() + " " + msg + System.lineSeparator() + " Full msg: " + msg.getContentDisplay();
List att = event.getMessage().getAttachments();
if (!att.isEmpty()) {
payload += System.lineSeparator() + " Attachments:";
for (int i = 0; i < att.size(); i++) {
payload += System.lineSeparator() + " " + att.get(i).getUrl();
}
}
DiscordBot.logger.trace(payload); //TODO log embeds and add user id too?
}
//parse cmd
CommandResult result = null;
try (Connection con = DiscordBot.db.getConnection(); Statement s = con.createStatement()){
//check prefix
String prefix = MiscUtils.getPrefix(s, event.getGuild().getId());
if(msg.getContentDisplay().toLowerCase().startsWith(prefix.toLowerCase())) {
//preprocess args
String msgStripped = msg.getContentDisplay().substring(prefix.length()).replaceAll("\\s\\s+", " "); //merges space
String[] args = msgStripped.split(" "); // base on command length?
//get cmd from args
Command cmd = DiscordBot.commands.get(args[0].toLowerCase());
cmd = cmd == null ? DiscordBot.aliases.get(args[0].toLowerCase()) : cmd;
if (cmd != null) {
//check if banned
ResultSet uRs = s.executeQuery("SELECT reports FROM users WHERE id = " + author.getId() + ";");
if(uRs.next()) { //checks null at the same time
if(uRs.getString(1).split("\n").length >= 5) {
channel.sendMessage("You are banned from using the bot.").queue();
DiscordBot.logger.info("[WARN] " + author.getName() + " (" + author.getId() + ") tried to execute " + msg.getContentDisplay() + " but was banned.");
return;
}
}
uRs.close();
//check perms
long perm = MiscUtils.getActivePerms(s, channel, cmd);
String perms = cmd.hasSubCommand() ? null : MiscUtils.getMissingPerms(perm, cmd.getRequiredBotUserLevel(), event.getMember(), channel); //pass it to the subcommand handler to handle instead
if(cmd.isDisabled()) perms = "DISABLED"; //override if disabled by code
if(perms == null || event.getAuthor().getId().equals(DiscordBot.OwnerID)) { //owner overrides perms for convenience
//execute async
cmd.executeAsync(channel, author, msg, Arrays.copyOfRange(args, 1, args.length), r -> { //catch all exceptions? //should have actually
DiscordBot.logger.info("[" + r.getResultType() + "] " + author.getName() + " (" + author.getId() + ") executed "
+ msg.getContentDisplay() + (r.getRemarks() == null ? "." : ". (" + r.getRemarks() + ")")); //logging has to be before sendMessage, or else if no permission it will just quit
if(r.getMessage() != null) channel.sendMessage(r.getMessage()).queue();
}); //dont know if async will screw anything up //wont, TODO log date and which server executed the command also?
return;
} else if(perms.equals("DISABLED")) {
author.openPrivateChannel().queue(c -> //notify user whats wrong if possible
c.sendMessage("Sorry, but the command `" + msg.getContentDisplay() + "` is disabled in the channel `#" + channel.getName() + "`.").queue());
- msg.addReaction("❎").queue(); //instead of sending messages, react instead to avoid clutter (which is what most ppl disable a bot in a channel for)
+ msg.addReaction(Emoji.fromUnicode("❎")).queue(); //instead of sending messages, react instead to avoid clutter (which is what most ppl disable a bot in a channel for)
result = new CommandResult(CommandResultType.DISABLED);
} else {
result = new CommandResult(CommandResultType.NOPERMS, perms);
channel.sendMessage(result.getMessage()).queue();
}
} else {
result = new CommandResult(CommandResultType.FAILURE, "Invalid command");
//do more stuff?
}
//non prefix "command" - greetings with tagging
} else if(msg.getContentRaw().matches("<@!?" + DiscordBot.BotID + ">")) {
result = greet(channel, author, prefix);
}
} catch (SQLException e) {
result = new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
//log non async results
if(result != null) DiscordBot.logger.info("[" + result.getResultType() + "] " + author.getName() + " (" + author.getId() + ") executed "
+ msg.getContentDisplay() + (result.getRemarks() == null ? "." : ". (" + result.getRemarks() + ")"));
}
//init easter eggs file
private Properties greetEasterEggs = new Properties();
{
try(FileInputStream in = new FileInputStream(new File(System.getProperty("user.dir") + File.separator + "eastereggs.properties"))){
greetEasterEggs.load(in);
} catch (IOException e) {
e.printStackTrace();
}
}
private CommandResult greet(TextChannel channel, User author, String prefix) {
- MessageBuilder smsg = new MessageBuilder();
+ MessageCreateBuilder smsg = new MessageCreateBuilder();
//main greet
String nick = channel.getGuild().getMemberById(author.getId()).getNickname();
if (nick != null) {
- smsg.append("Yo " + nick + "!\n");
+ smsg.addContent("Yo " + nick + "!\n");
} else {
- smsg.append("Yo " + author.getName() + "!\n");
+ smsg.addContent("Yo " + author.getName() + "!\n");
}
//easter eggs
String easterEgg = greetEasterEggs.getProperty(author.getId());
if(easterEgg != null) {
- smsg.append(easterEgg.replace("\\n", "\n") + "\n");
+ smsg.addContent(easterEgg.replace("\\n", "\n") + "\n");
}
//bot info
EmbedBuilder eb = new EmbedBuilder();
eb.setColor(0x051153); //TODO version info based on git commits
eb.appendDescription("This bot is running **despbot v1.5.0**, Shard `" + DiscordBot.mainJDA.getShardInfo().getShardString() + "`. ([invite me!](https://discordapp.com/oauth2/authorize?&client_id=" + DiscordBot.BotID + "&scope=bot&permissions=0))\n");
eb.appendDescription("Connected guilds: `" + DiscordBot.mainJDA.getGuildCache().size() + (DiscordBot.mainJDA.getShardManager() == null ? "" : "/" + DiscordBot.mainJDA.getShardManager().getShards().stream().mapToLong(jda -> jda.getGuildCache().size()).sum()) + "`; ");
eb.appendDescription("Total members (cached): `" + DiscordBot.mainJDA.getUserCache().size() + (DiscordBot.mainJDA.getShardManager() == null ? "" : "/" + DiscordBot.mainJDA.getShardManager().getShards().stream().mapToLong(jda -> jda.getUserCache().size()).sum()) + "`\n");
eb.appendDescription("DM `" + DiscordBot.mainJDA.getUserById(DiscordBot.OwnerID).getAsTag() + "` if you have any questions!\n");
eb.appendDescription("To get a list of commands, do `" + prefix + "help`.");
smsg.setEmbeds(eb.build());
- Message fmsg = smsg.build();
+ MessageCreateData fmsg = smsg.build();
channel.sendMessage(fmsg).queue();
return new CommandResult(CommandResultType.SUCCESS, null);
}
@Override
- public void onGuildMessageUpdate(GuildMessageUpdateEvent event) { //log edits
+ public void onMessageUpdate(MessageUpdateEvent event) { //log edits
+ //do not support DMs for now
+ if(!event.isFromGuild()) return;
+
Message msg = event.getMessage();
User author = event.getAuthor();
- TextChannel channel = event.getChannel();
+ TextChannel channel = event.getChannel().asTextChannel();
if (!DiscordBot.logExcemptID.contains(author.getId())) {
String guildinfo = "[" + event.getGuild().getName() + " #" + channel.getName() + "]";
DiscordBot.logger.trace("[EDIT] " + guildinfo + System.lineSeparator() + " " + msg + System.lineSeparator() + " Full edited msg: " + msg.getContentDisplay());
}
}
@Override
public void onUserActivityStart(UserActivityStartEvent event) { //store presence for checking osu pp
Activity osu = event.getNewActivity();
if (osu != null && osu.getName().equals("osu!") && osu.isRich() //if need to include other games and details, just remove this if clause and change sGame below
&& osu.asRichPresence().getDetails() != null) {
String toolTip = osu.asRichPresence().getLargeImage().getText();
String sGame = (toolTip.lastIndexOf(" (") != -1 ? toolTip.substring(0, toolTip.lastIndexOf(" (")) : toolTip) + "||" + osu.asRichPresence().getDetails();
//update db
try (Connection con = DiscordBot.db.getConnection()) { //DONE do i need to close the statement?
PreparedStatement s = con.prepareStatement("INSERT INTO users(id, game) VALUES (" + event.getUser().getId() + ", ?) ON CONFLICT(id) DO UPDATE SET game = ? WHERE game <> ?;" ); //prevent blank updates
s.setString(1, sGame); s.setString(2, sGame); s.setString(3, sGame);
s.execute();
} catch (SQLException e) { //FIXED if i make this not osu only, be aware of SQL injections through sGame (probably being paranoid tho)
e.printStackTrace();
}
}
}
//TODO only update when not paused?
//TODO update on member deafen?
private void waitActivity(Guild guild) {
AudioTrackHandler ap = ((Music) DiscordBot.commands.get("music")).getAudioTrackHandler();
ap.getGuildMusicManager(guild).player.setPaused(true);
ap.getGuildMusicManager(guild).clearQueueCleanup = ap.ex.schedule(() -> {
ap.stopAndClearQueue(guild);
DiscordBot.lastMusicCmd.get(guild.getId()).sendMessage("The queue has been cleared.").queue();
ap.getGuildMusicManager(guild).clearQueueCleanup = null;
}, 1, TimeUnit.MINUTES);
}
private String updateActivity(Guild guild, VoiceChannel vc) {
AudioTrackHandler ap = ((Music) DiscordBot.commands.get("music")).getAudioTrackHandler();
GuildMusicManager mm = ap.getGuildMusicManager(guild);
if(mm.clearQueueCleanup != null) {
mm.player.setPaused(false);
mm.clearQueueCleanup.cancel(true);
mm.clearQueueCleanup = null;
}
String type = mm.scheduler.loop; //mm cannot be null
if (type != null
&& vc.getMembers().size() > 2
&& vc.getMembers().contains(guild.getMemberById(DiscordBot.OwnerID))) {
ap.toggleLoopQueue(guild, type);
return type;
}
return null;
}
@Override
- public void onGuildVoiceLeave(GuildVoiceLeaveEvent event) { // theoretically i dont need to check if lastMusicCmd has the entry or not, as it must have one to trigger this
+ public void onGuildVoiceUpdate(GuildVoiceUpdateEvent event) { // theoretically i dont need to check if lastMusicCmd has the entry or not, as it must have one to trigger this
GuildVoiceState vs = event.getGuild().getMemberById(DiscordBot.BotID).getVoiceState();
+ String id = event.getGuild().getId();
+ TextChannel channel = DiscordBot.lastMusicCmd.get(id);
if(!event.getMember().getUser().getId().equals(DiscordBot.BotID)) {
- if (vs.inVoiceChannel()) {
+ if(event.getChannelJoined() != null) {
+ if (vs.getChannel().equals(event.getChannelJoined())) {
+ String type = updateActivity(event.getGuild(), event.getChannelJoined().asVoiceChannel());
+ if(type != null) channel.sendMessage((type.equals("loop") ? "Looping" : "Autoplay") + " mode disabled due to new users joining the music channel.").queue();
+ }
+ } else if(event.getChannelLeft() != null) {
if (vs.getChannel().equals(event.getChannelLeft())
&& event.getChannelLeft().getMembers().size() < 2) {
- TextChannel channel = DiscordBot.lastMusicCmd.get(event.getGuild().getId());
channel.sendMessage(
"All users have left the music channel, the player is now paused.\nThe queue will be cleared in 1 minute if there is no activity.")
.queue();
waitActivity(event.getGuild());
}
}
- } else { //got kicked
- AudioTrackHandler ap = ((Music) DiscordBot.commands.get("music")).getAudioTrackHandler();
- if(ap != null) { //if not destroyed
- ap.stopAndClearQueue(event.getGuild()); //can double fire on normal end; shouldnt be too big of a problem
- }
- }
- }
-
- @Override
- public void onGuildVoiceMove(GuildVoiceMoveEvent event) { // theoretically i dont need to check if lastMusicCmd has the entry or not, as it must have one to trigger this
- GuildVoiceState vs = event.getGuild().getMemberById(DiscordBot.BotID).getVoiceState();
- if (vs.inVoiceChannel()) {
- String id = event.getGuild().getId();
- TextChannel channel = DiscordBot.lastMusicCmd.get(id);
- if (!event.getMember().getUser().getId().equals(DiscordBot.BotID)) {
- if (vs.getChannel().equals(event.getChannelLeft())) {
- if (!event.getMember().getUser().getId().equals(DiscordBot.BotID)
- && event.getChannelLeft().getMembers().size() < 2) {
-
- channel.sendMessage(
- "All users have left the music channel, the player is now paused.\nThe queue will be cleared in 1 minute if there is no activity.")
- .queue();
- waitActivity(event.getGuild());
- }
- } else if (vs.getChannel().equals(event.getChannelJoined())) {
- String type = updateActivity(event.getGuild(), event.getChannelJoined());
- if(type != null) channel.sendMessage((type.equals("loop") ? "Looping" : "Autoplay") + " mode disabled due to new users joining the music channel.").queue();
- }
- } else { //moved bot to empty channel
+ } else {
+ if(event.getChannelJoined() != null && event.getChannelLeft() != null) { //got moved
+ //moved bot to empty channel
if(event.getChannelJoined().getMembers().size() < 2) {
channel.sendMessage(
"The bot has been moved to an empty channel, the player is now paused.\nThe queue will be cleared in 1 minute if there is no activity.")
.queue();
waitActivity(event.getGuild());
} else { //moved bot to channel with ppl
event.getGuild().getAudioManager().openAudioConnection(event.getChannelJoined()); //seems to need explicit reconnect on bot move, it gets stuck on attempting to reconnect otherwise; probably would be fixed soon but hot patch for now
- String type = updateActivity(event.getGuild(), event.getChannelJoined());
+ String type = updateActivity(event.getGuild(), event.getChannelJoined().asVoiceChannel());
if(type != null) channel.sendMessage((type.equals("loop") ? "Looping" : "Autoplay") + " mode disabled due to new users joining the music channel.").queue();
}
- }
- }
- }
-
- @Override
- public void onGuildVoiceJoin(GuildVoiceJoinEvent event) {
- GuildVoiceState vs = event.getGuild().getMemberById(DiscordBot.BotID).getVoiceState();
- String id = event.getGuild().getId();
- TextChannel channel = DiscordBot.lastMusicCmd.get(id);
- if(channel != null) {
- if (vs.inVoiceChannel() && !event.getMember().getUser().getId().equals(DiscordBot.BotID)) {
- if (vs.getChannel().equals(event.getChannelJoined())) {
- String type = updateActivity(event.getGuild(), event.getChannelJoined());
- if(type != null) channel.sendMessage((type.equals("loop") ? "Looping" : "Autoplay") + " mode disabled due to new users joining the music channel.").queue();
+ } else if(event.getChannelLeft() != null) { //got kicked
+ AudioTrackHandler ap = ((Music) DiscordBot.commands.get("music")).getAudioTrackHandler();
+ if(ap != null) { //if not destroyed
+ ap.stopAndClearQueue(event.getGuild()); //can double fire on normal end; shouldnt be too big of a problem
}
-
}
-
+ //we dont care about the bot joining a channel
}
}
}
diff --git a/src/me/despawningbone/discordbot/command/Command.java b/src/me/despawningbone/discordbot/command/Command.java
index 5c3ba94..0539e21 100644
--- a/src/me/despawningbone/discordbot/command/Command.java
+++ b/src/me/despawningbone/discordbot/command/Command.java
@@ -1,214 +1,215 @@
package me.despawningbone.discordbot.command;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
//import java.time.OffsetDateTime;
//import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import org.apache.commons.lang3.exception.ExceptionUtils;
import me.despawningbone.discordbot.DiscordBot;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import me.despawningbone.discordbot.utils.MiscUtils;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
+import net.dv8tion.jda.api.entities.emoji.Emoji;
import net.dv8tion.jda.api.entities.User;
public class Command { //no need to be abstract anymore //make it final to ensure thread safety?
protected String name;
protected String desc;
protected String usage;
protected List alias; //fixed-size in this case is negligible, as it will not be edited later on anyways
protected List remarks = null;
protected boolean isDisabled;
protected EnumSet perms = EnumSet.noneOf(Permission.class); //so that not configured commands would have a default of no perms needed, instead of null //WILL NOT ALLOW DENY OVERRIDE FOR NOW
protected int botUserLevel; //0 = everyone, 1 = BotMod, 2 = BotOwner; negative value = either BotUser or required perm
protected List examples;
protected ExecuteImpl impl = null;
private Command parent = null;
private String cat; //not used; remove? //actually gets used now
private LinkedHashMap subCmds = new LinkedHashMap<>(); //preserve order
public HashMap subCmdAliases = new HashMap(); //TODO temporary solution
//DONE add examples; what about subcmds?
//for overriding
protected Command() {}
//only used by nested commands, since all explicitly declared commands override execute(); hot creation of subcommands are possible, but permission setting via !desp settings is not supported
public Command(String name, List aliases, Command.ExecuteImpl exeFunc, String usage, List examples, String desc, List remarks, EnumSet defaultPerms, int botUserLevel) {
this.name = name;
this.alias = aliases;
this.impl = exeFunc;
this.usage = usage;
this.examples = examples;
this.desc = desc;
this.remarks = remarks;
this.perms = defaultPerms;
this.botUserLevel = botUserLevel;
}
public interface ExecuteImpl { //DONE make this a seperate class to store the ever increasing params? if so, protected constructor since it doesnt need to be instantiated elsewhere other than registerSubCommand here //implemented nested commands instead
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args);
}
private void setParent(Command parent) { //private so only register function can access to avoid accidental use
this.parent = parent;
}
//for anonymous inner classes if needed
public void registerSubCommand(Command subCmd) {
subCmd.setParent(this); //make parent only set on register since subcmds with wrong/no parents can result in finnicky bugs
subCmds.put(subCmd.getName(), subCmd);
if(subCmd.getAliases() != null) subCmd.getAliases().forEach(a -> subCmdAliases.put(a, subCmd));
}
public void registerSubCommand(String name, List aliases, Command.ExecuteImpl exe, String usage, List examples, String desc, List remarks) { //subcmd has its own examples? //DONE subcmd permissions and botuserlevel //probably not needed, as its gonna be binded to guild instead of cmds soon enough
registerSubCommand(name, aliases, exe, usage, examples, desc, remarks, EnumSet.noneOf(Permission.class), 0);
}
public void registerSubCommand(String name, List aliases, Command.ExecuteImpl exe, String usage, List examples, String desc, List remarks, EnumSet defaultPerms, int botUserLevel) {
Command subCmd = new Command(name, aliases, exe, usage, examples, desc, remarks, defaultPerms, botUserLevel);
registerSubCommand(subCmd);
}
public boolean hasSubCommand() {
return !subCmds.isEmpty();
}
public Set getSubCommandNames() {
return subCmds.keySet();
}
public Command getSubCommand(String name) {
Command subCmd = subCmds.get(name);
return subCmd == null ? subCmdAliases.get(name) : subCmd;
}
public Command getParent() {
return parent;
}
public enum BotUserLevel { //DONE? implement this DONE test this
DEFAULT,
BOT_MOD,
BOT_OWNER
}
public String getCategory() { //is null if it is a subcommand
return cat;
}
public String getName() {
return name;
}
public String getDesc() {
return desc;
}
public String getUsage() {
return usage;
}
public List getAliases() {
return alias;
}
public List getRemarks() {
return remarks;
}
public EnumSet getDefaultPerms() { //DONE make this channel dependent?
return perms;
}
public int getRequiredBotUserLevel() {
return botUserLevel;
}
public List getExamples() {
return examples;
}
public boolean isDisabled() { //FIXED think of what to do with disabledGuild; it makes the command class not thread safe; store in DB instead, and be channel based?
return isDisabled;
}
//TODO deprecate use of @Override execute, use Command.ExecuteImpl instead - this allows merging of perm handlers in EventListener and here
//even when multiple thread references to one specific command, it should be thread safe because it doesnt modify anything in the execute stage; and even the hashmap is somewhat immutable as it will never be changed after finished loading
//i dont think i need to even volatile that hashmap
//the only thing i might need to do to make it thread safe is to make the hashmap a concurrenthashmap
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { //if you want a main command to work with sub commands, just define impl along with subcmds instead of overriding this method
OffsetDateTime timesent = OffsetDateTime.now();
try(Connection con = DiscordBot.db.getConnection(); Statement s = con.createStatement()) { //not the best, but i cant share connection through threads
String prefix = MiscUtils.getPrefix(s, channel.getGuild().getId());
if(hasSubCommand()) {
if(args.length > 0) {
Command subCmd = getSubCommand(args[0].toLowerCase());
if(subCmd != null) {
int botUserLevel = subCmd.getRequiredBotUserLevel(); Command temp = subCmd;
while(temp.getParent() != null && botUserLevel == 0) {
temp = temp.getParent();
botUserLevel = temp.getRequiredBotUserLevel(); //if not set get parent's
}
String perms = subCmd.hasSubCommand() ? null : MiscUtils.getMissingPerms(MiscUtils.getActivePerms(s, channel, subCmd), botUserLevel, channel.getGuild().getMember(author), channel); //yet again pass to handler
if(subCmd.isDisabled()) perms = "DISABLED"; //override if disabled by code
if(perms == null || author.getId().equals(DiscordBot.OwnerID)) { //owner overrides perms for convenience
OffsetDateTime timeReceived = OffsetDateTime.now();
long ms = Math.abs(timesent.until(timeReceived, ChronoUnit.MILLIS));
System.out.println("subcmd parse Time taken: " + ms + "ms");
return subCmd.execute(channel, author, msg, Arrays.copyOfRange(args, 1, args.length));
} else if(perms.equals("DISABLED")) {
author.openPrivateChannel().queue(c -> //notify user whats wrong if possible
c.sendMessage("Sorry, but the command `" + msg.getContentDisplay() + "` is disabled in the channel `#" + channel.getName() + "`.").queue());
- msg.addReaction("❎").queue();
+ msg.addReaction(Emoji.fromUnicode("❎")).queue();
return new CommandResult(CommandResultType.DISABLED);
} else {
return new CommandResult(CommandResultType.NOPERMS, perms);
}
} else {
if(impl == null) return new CommandResult(CommandResultType.INVALIDARGS, "Unknown sub command! Check `" + prefix + "help " + this.getName() + "` for more info.");
}
} else { //only run if no execute definition, if not let it override default messages
if(impl == null) return new CommandResult(CommandResultType.INVALIDARGS, "Please specify a subcommand. You can view them with `" + prefix + "help " + this.name + "`.");
}
}
return impl.execute(channel, author, msg, args); //fallback if no subcommand found; useful for defining custom messages or default command for a set of subcommands
} catch (SQLException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
}
public void executeAsync(TextChannel channel, User author, Message msg, String[] args, Consumer success) {
OffsetDateTime timesent = OffsetDateTime.now();
CompletableFuture.supplyAsync(() -> execute(channel, author, msg, args))
.exceptionally(ex -> new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex.getCause()))) //getCause should always work as ex is a CompletionException
.thenAccept(success).thenAccept(r -> { //DONE thenAccept parses the CommandResult
OffsetDateTime timeReceived = OffsetDateTime.now();
long ms = Math.abs(timesent.until(timeReceived, ChronoUnit.MILLIS));
System.out.println("Time taken: " + ms + "ms");
});
}
}
diff --git a/src/me/despawningbone/discordbot/command/CommandResult.java b/src/me/despawningbone/discordbot/command/CommandResult.java
index f5b580c..0612fee 100644
--- a/src/me/despawningbone/discordbot/command/CommandResult.java
+++ b/src/me/despawningbone/discordbot/command/CommandResult.java
@@ -1,77 +1,77 @@
package me.despawningbone.discordbot.command;
-import net.dv8tion.jda.api.MessageBuilder;
-import net.dv8tion.jda.api.entities.Message;
+import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
+import net.dv8tion.jda.api.utils.messages.MessageCreateData;
public class CommandResult {
//for logging
private CommandResultType t;
private String r;
public enum CommandResultType { //TODO add a noargs to specify please enter something to search for?
INTERRUPTED,
INVALIDARGS, //print help?
NORESULT,
NOPERMS,
TOOLONG,
FAILURE, //generic failure
ERROR,
SUCCESS,
DISABLED
}
public CommandResult(CommandResultType type, String remarks) { //TODO overload with CommandResult(CommandResultType, Exception)?
t = type;
r = remarks;
}
public CommandResult(CommandResultType type) {
t = type;
r = null;
}
public CommandResultType getResultType() {
return t;
}
public String getRemarks() {
return t == CommandResultType.NORESULT || t == CommandResultType.NOPERMS || t == CommandResultType.TOOLONG ? null : r;
}
//make a getLogLevel() for log4j integration later?
- public Message getMessage() {
- MessageBuilder mb = new MessageBuilder();
+ public MessageCreateData getMessage() {
+ MessageCreateBuilder mb = new MessageCreateBuilder();
switch(t) {
case DISABLED:
case SUCCESS: //already sent message
return null;
case INTERRUPTED: //both are errors
case ERROR:
String[] splits = r.split("\n");
String line = "Unknown Source";
for(String split : splits) {
if(split.matches("(?s).*command\\..*") && split.trim().startsWith("at")) {
line = split.replaceFirst(".*command\\..*\\((.*?)\\).*", "$1");
break;
}
}
- mb.append("Something went wrong at `" + line + "`: `" + splits[0].replaceAll(":.*", "") + "`");
+ mb.addContent("Something went wrong at `" + line + "`: `" + splits[0].replaceAll(":.*", "") + "`");
break;
case NOPERMS:
- mb.append("You need the `" + r + "` permission(s) for this channel to execute that command.");
+ mb.addContent("You need the `" + r + "` permission(s) for this channel to execute that command.");
break;
case NORESULT:
- mb.append("Unfortunately there was no results" + (r != null ? " for " + r : "") + " :cry:");
+ mb.addContent("Unfortunately there was no results" + (r != null ? " for " + r : "") + " :cry:");
break;
case TOOLONG:
- mb.append("The description was too long discord can't stand it :joy:");
+ mb.addContent("The description was too long discord can't stand it :joy:");
break;
default: //TODO add "For help, do" for INVALIDARGS?
- mb.append(r);
+ mb.addContent(r);
}
return mb.build();
}
}
diff --git a/src/me/despawningbone/discordbot/command/admin/BotBan.java b/src/me/despawningbone/discordbot/command/admin/BotBan.java
index d9f29a8..8fb4697 100644
--- a/src/me/despawningbone/discordbot/command/admin/BotBan.java
+++ b/src/me/despawningbone/discordbot/command/admin/BotBan.java
@@ -1,76 +1,76 @@
package me.despawningbone.discordbot.command.admin;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Arrays;
import org.apache.commons.lang3.exception.ExceptionUtils;
import me.despawningbone.discordbot.DiscordBot;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
public class BotBan extends Command {
public BotBan() {
this.alias = Arrays.asList("bban");
this.desc = "Ban a user from the bot";
this.usage = "";
this.examples = Arrays.asList(DiscordBot.OwnerID);
this.botUserLevel = BotUserLevel.BOT_MOD.ordinal();
}
@Override
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) {
if (args.length < 1) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a user ID.");
} else {
String SID = args[0];
Member b;
try {
b = channel.getGuild().getMemberById(SID);
} catch (NumberFormatException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid user ID.");
}
if (b != null) {
if (DiscordBot.ModID.contains(SID) || SID.equals(DiscordBot.BotID)) {
return new CommandResult(CommandResultType.FAILURE, "That user cannot be banned.");
} else {
try (Connection con = DiscordBot.db.getConnection(); Statement s = con.createStatement()) {
String reports = "";
ResultSet uRs = s.executeQuery("SELECT reports FROM users WHERE id = " + SID + ";");
if(uRs.next()) {
reports = uRs.getString(1);
if(reports.split("\n").length >= 5) {
return new CommandResult(CommandResultType.FAILURE, "The user is already banned from the bot.");
}
}
PreparedStatement set = con.prepareStatement("INSERT INTO users(id, reports) VALUES (?, \"0\n0\n0\n0\n0\n\") ON CONFLICT(id) DO UPDATE SET reports = \"0\n0\n0\n0\n0\n\"");
set.setString(1, SID);
if(set.executeUpdate() != 0) {
channel.sendMessage("You have successfully banned <@!" + SID + "> from the bot.").queue();
return new CommandResult(CommandResultType.SUCCESS);
} else {
throw new IllegalStateException("This should never happen");
}
} catch (SQLException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
}
} else {
return new CommandResult(CommandResultType.FAILURE, "This user ID is invalid.");
}
}
}
}
diff --git a/src/me/despawningbone/discordbot/command/admin/BotUnban.java b/src/me/despawningbone/discordbot/command/admin/BotUnban.java
index 4786ed9..d2a13b6 100644
--- a/src/me/despawningbone/discordbot/command/admin/BotUnban.java
+++ b/src/me/despawningbone/discordbot/command/admin/BotUnban.java
@@ -1,64 +1,64 @@
package me.despawningbone.discordbot.command.admin;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Arrays;
import org.apache.commons.lang3.exception.ExceptionUtils;
import me.despawningbone.discordbot.DiscordBot;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
public class BotUnban extends Command {
public BotUnban() {
this.alias = Arrays.asList("bunban");
this.desc = "Unban a user from the bot";
this.usage = "";
this.botUserLevel = BotUserLevel.BOT_MOD.ordinal();
this.examples = Arrays.asList(DiscordBot.OwnerID);
}
@Override //DONE rewrite BotBan/BotUnban to use SQLite
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) {
if (args.length < 1) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a user ID.");
} else {
String SID = args[0];
Member t = channel.getGuild().getMemberById(SID);
if (t != null) {
try (Connection con = DiscordBot.db.getConnection(); Statement s = con.createStatement()) {
String reports = "";
ResultSet uRs = s.executeQuery("SELECT reports FROM users WHERE id = " + SID + ";");
if(uRs.next()) {
reports = uRs.getString(1);
if(reports.split("\n").length >= 5) {
//can actually just UPDATE since it must have an entry
PreparedStatement set = con.prepareStatement("INSERT INTO users(id, reports) VALUES (?, \"\") ON CONFLICT(id) DO UPDATE SET reports = \"\"");
set.setString(1, SID);
if(set.executeUpdate() != 0) {
channel.sendMessage("You unbanned <@!" + SID + ">.").queue();
return new CommandResult(CommandResultType.SUCCESS);
} else {
throw new IllegalStateException("This should never happen");
}
}
}
return new CommandResult(CommandResultType.FAILURE, "This user is not banned.");
} catch (SQLException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
} else {
return new CommandResult(CommandResultType.FAILURE, "This user ID is invalid.");
}
}
}
}
diff --git a/src/me/despawningbone/discordbot/command/admin/Purge.java b/src/me/despawningbone/discordbot/command/admin/Purge.java
index 60ea209..6daee86 100644
--- a/src/me/despawningbone/discordbot/command/admin/Purge.java
+++ b/src/me/despawningbone/discordbot/command/admin/Purge.java
@@ -1,53 +1,53 @@
package me.despawningbone.discordbot.command.admin;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.stream.Collectors;
import me.despawningbone.discordbot.DiscordBot;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
public class Purge extends Command {
public Purge() {
this.desc = "Clear the bot's message";
this.usage = "";
this.remarks = Arrays.asList("Note: Count must be in between 1 to 100, and it includes the messages in between.");
this.botUserLevel = -BotUserLevel.BOT_MOD.ordinal();
this.examples = Arrays.asList("20");
this.perms = EnumSet.of(Permission.MESSAGE_MANAGE);
}
@Override //TODO rewrite purge to purge until message deleted meet the count instead
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) {
int n = 0;
try {
n = Integer.parseInt(args[0]);
} catch (NumberFormatException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a correct number.");
} catch (ArrayIndexOutOfBoundsException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a number.");
}
if (n > 100) {
return new CommandResult(CommandResultType.INVALIDARGS, "The number entered is too large.");
}
channel.getHistory().retrievePast(n).queue(history -> {
List msgs = history.stream()
.filter(hmsg -> hmsg.getAuthor().getId().equals(DiscordBot.BotID)).collect(Collectors.toList());
if(msgs.size() > 1 && channel.getGuild().getSelfMember().hasPermission(Permission.MESSAGE_MANAGE)) channel.deleteMessages(msgs).queue();
else msgs.forEach(m -> channel.deleteMessageById(m.getId()).queue());
//if no result ignore
});
return new CommandResult(CommandResultType.SUCCESS);
}
}
diff --git a/src/me/despawningbone/discordbot/command/admin/Settings.java b/src/me/despawningbone/discordbot/command/admin/Settings.java
index 826be52..26c8be7 100644
--- a/src/me/despawningbone/discordbot/command/admin/Settings.java
+++ b/src/me/despawningbone/discordbot/command/admin/Settings.java
@@ -1,252 +1,252 @@
package me.despawningbone.discordbot.command.admin;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.ArrayList;
import java.util.stream.Collectors;
import org.apache.commons.lang3.exception.ExceptionUtils;
import me.despawningbone.discordbot.DiscordBot;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import me.despawningbone.discordbot.utils.MiscUtils;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.Permission;
public class Settings extends Command {
public static final int DISABLED = 0x80000; //even if i use 0x80000000 and it overflows it should still work //DONE 0x200 is a new permission; JDA 3 will probably not recognize it so i have to circumvent over it manually //changed to 0x80000
private static final List disableExempt = Arrays.asList("admin", "admin.settings", "admin.settings.perms");
public Settings() {
this.alias = Arrays.asList("setting", "set");
this.desc = "Change the settings of the bot for this guild!";
this.usage = "";
this.botUserLevel = -BotUserLevel.BOT_OWNER.ordinal();
this.perms = EnumSet.of(Permission.MANAGE_SERVER);
//PATCHED? SQL INJECTION PRONE (eg node: games/**/WHERE1--.",*," - returns numberformatexception; games/**/WHERE1--.osu",*,"osu returns N/A; can even drop tables)
registerSubCommand("perms", Arrays.asList("permissions", "perm", "permission"), (channel, user, msg, words) -> { //DONE disable commands; use negative? //TODO list all edited permission nodes, clear command?
int opType = 3;
try {
if(words[0].equalsIgnoreCase("list")) {
if(words.length != 2) throw new ArrayIndexOutOfBoundsException(); //invalid argument count
opType = 0;
} else if(words[0].equalsIgnoreCase("wipe") || words[0].equalsIgnoreCase("wipeall")){
opType = 1;
} else {
if(words.length != 3) throw new ArrayIndexOutOfBoundsException(); //invalid argument count
opType = words[0].equalsIgnoreCase("guild") ? 2 : 3;
}
} catch(ArrayIndexOutOfBoundsException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid argument count specified.");
}
if(!words[1].matches("[a-zA-Z0-9.]+")) return new CommandResult(CommandResultType.INVALIDARGS, "Invalid node specified."); //prevent SQL injection; might be restricting though, but then again all command names are alphanumeric
try(Connection con = DiscordBot.db.getConnection(); Statement s = con.createStatement()){
String node = words[1].contains(".") ? words[1].substring(words[1].indexOf(".") + 1).toLowerCase() : "_GLOBAL_";
String cat = node.equals("_GLOBAL_") ? words[1].toLowerCase() : words[1].substring(0, words[1].indexOf(".")).toLowerCase();
ResultSet rs = null;
if(opType == 3) try {
if(channel.getGuild().getTextChannelById(words[0]) == null) throw new NumberFormatException();
} catch(NumberFormatException e) {
if(words[0].startsWith("#")) {
- if(!msg.getMentionedChannels().isEmpty()) {
- words[0] = msg.getMentionedChannels().get(0).getId(); //assuming its to order from left to right; but tbh if they try to trick the command with 2 channels it wont work anyways with the range check and node check
+ if(!msg.getMentions().getChannels().isEmpty()) {
+ words[0] = msg.getMentions().getChannels().get(0).getId(); //assuming its to order from left to right; but tbh if they try to trick the command with 2 channels it wont work anyways with the range check and node check
}
} else {
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid channel specified.");
}
}
try {
rs = s.executeQuery("SELECT \"" + node + "\" FROM perms_" + cat + " WHERE id = " + channel.getGuild().getId() + ";"); //hopefully this will be pretty fast
} catch(SQLException x) {
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid node specified.");
}
EnumSet def = null;
if(node.contains(".")) { //path must be valid coz or else SQL wouldve thrown an exception
String[] split = node.split("\\.");
Command subCmd = DiscordBot.commands.get(split[0]);
for(int i = 1; i < split.length; i++) {
subCmd = subCmd.getSubCommand(split[i]);
}
def = subCmd.getDefaultPerms();
} else {
def = DiscordBot.commands.containsKey(node) ? DiscordBot.commands.get(node).getDefaultPerms() : EnumSet.noneOf(Permission.class);
}
if(opType == 0) { //list perms
EmbedBuilder eb = new EmbedBuilder();
eb.setTitle("Permissions for node: " + words[1]);
eb.appendDescription("*Default: " + (def.isEmpty() ? "N/A" : def.stream().map(p -> p.name()).collect(Collectors.joining(", "))) + "*\n\n");
if(rs.next()) {
String sOrig = rs.getString(1);
if(sOrig.equals(node)) return new CommandResult(CommandResultType.INVALIDARGS, "Invalid node specified."); //SQL returns the actual string if the table doesnt have the column
for(String line : sOrig.split("\n")) {
String[] split = line.split(":");
int index = 0;
if(split.length < 2) { //channels would have colons
eb.appendDescription("**Global permissions:** \n");
} else {
eb.appendDescription("<#" + split[0] + ">:\n"); //TODO low priority: old permissions set for deleted channels will still be there
index = 1;
}
long orig = Long.parseLong(split[index]);
if(orig != 0) {
long deny = (int) (orig >> 32), allow = (int) orig;
if(MiscUtils.hasDisabled(deny)) eb.appendDescription("**-DISABLED**\n");
for(Permission p : Permission.getPermissions(deny)) eb.appendDescription("-" + p.name() + "\n");
if(MiscUtils.hasDisabled(allow)) eb.appendDescription("**DISABLED**\n");
for(Permission p : Permission.getPermissions(allow)) eb.appendDescription(p.name() + "\n");
} else {
eb.appendDescription("N/A\n");
}
eb.appendDescription("\n");
}
} else { //edit perms
eb.appendDescription("Global permissions: \n");
if(!def.isEmpty()) for(Permission p : def) eb.appendDescription(p.name() + "\n");
else eb.appendDescription("N/A\n");
}
eb.setFooter("To see the permissions in effect, do the help command in an affected channel!", null);
channel.sendMessageEmbeds(eb.build()).queue();
return new CommandResult(CommandResultType.SUCCESS);
} else if(opType == 1) { //wipe
String actualNode = words[1].toLowerCase();
if(words[0].equalsIgnoreCase("wipeall")) {
s.execute("DELETE FROM perms_" + cat + " WHERE id = " + channel.getGuild().getId() + ";");
actualNode = cat;
} else {
s.execute("UPDATE perms_" + cat + " SET \"" + node + "\" = '" + ((0L << 32) | (Permission.getRaw(def) & 0xffffffffL)) + "\n'");
}
channel.sendMessage("Successfully reset `" + actualNode + "` to default permissions.").queue();
return new CommandResult(CommandResultType.SUCCESS);
} else {
boolean allows = words[2].indexOf("-") == -1;
Permission perm = null;
try {
String p = allows ? words[2].toUpperCase() : words[2].substring(1).toUpperCase();
if(!p.equals("DISABLED")) perm = Permission.valueOf(p);
if(perm == null && disableExempt.contains(words[1].toLowerCase())) return new CommandResult(CommandResultType.INVALIDARGS, "You cannot disable this node."); //temporary patch
} catch(IllegalArgumentException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid permission specified.");
}
String[] lines = {""};
String type = "";
if(rs.next()) { //considerations: rs.next(); guild vs channel; allows; add or remove;
lines = rs.getString(1).split("\n");
if(lines[0].equals(node)) return new CommandResult(CommandResultType.INVALIDARGS, "Invalid node specified."); //SQL returns the actual string if the table doesnt have the column
int i = 0;
long orig = 0;
if(opType == 2) {
orig = Long.parseLong(lines[0]);
} else {
try {
for(i = 0; i <= lines.length; i++) {
if(lines[i].startsWith(words[0] + ":")) {
orig = Long.parseLong(lines[i].split(":")[1]);
break;
}
}
} catch(ArrayIndexOutOfBoundsException e) {
lines = Arrays.copyOf(lines, lines.length + 1); //to extend it; should be the same method as using arraylist
//orig = Permission.getRaw(def); //use default //actually should already have imposed global default if rs has next; adding default to this will only give channel the global perm, which makes no sense
}
}
String[] split = imposePerms(words[0].equalsIgnoreCase("guild") ? null : words[0], orig, perm, allows);
type = split[0]; lines[i] = split[1]; //at this stage theres only 2 options: guild and a valid channel id
} else {
if(opType == 2) {
String[] split = imposePerms(null, Permission.getRaw(def), perm, allows); //initiate guild by imposing perm to default; since allow is in the lower 32 bit, i can just put def as the base
type = split[0]; lines[0] = split[1];
} else {
lines[0] = String.valueOf(Permission.getRaw(def)); //initiate guild with the default permission of the command
lines = Arrays.copyOf(lines, lines.length + 1);
String[] split = imposePerms(words[0], 0, perm, allows); //impose with perm base = 0
type = split[0]; lines[1] = split[1];
}
}
String fin = String.join("\n", lines) + "\n"; //last line must have \n
s.execute("INSERT INTO perms_" + cat + "(id, \"" + node + "\") VALUES (" + channel.getGuild().getId() + ", '" + fin + "') ON CONFLICT(id) DO UPDATE SET \"" + node + "\" = '" + fin + "';"); //no need blank update check, because it will never be the same
channel.sendMessage("Successfully " + type + " " + (words[0].equalsIgnoreCase("guild") ? "guild-wide" : "channel-specific (<#" + words[0] + ">)") + " permission `" + (allows ? "" : "-") + (perm == null ? "DISABLED" : perm.name()) + "` for `" + words[1].toLowerCase() + "`.").queue();
return new CommandResult(CommandResultType.SUCCESS);
}
} catch(SQLException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
}, " [-][permission]", Arrays.asList("list admin", "list games.osu.pp", "wipeall admin", "wipe games.osu", "419464253574086656 games MESSAGE_EMBED_LINKS", "guild games.osu -MESSAGE_EMBED_LINKS", "#general music VOICE_CONNECT", "guild admin ADMINISTRATOR"),
"Edit default perms required for commands for this guild!", Arrays.asList(
"Adding a `-` sign before a permission overrides the parent permission set,",
"Where `cat(guild > channel) > cmd(guild > channel) > subcmd(guild > channel)` (parent > child, where subcmd channel is of highest priority).",
"*For example, specifying `admin.settings.prefix` with `-MANAGE_SERVER` will override the requirement of `admin.settings`, and allow those without `MANAGE_SERVER` to use `admin.settings.prefix`*.\n",
"The permission nodes are as such: `[.cmd][.subcmd]`.",
"For permission names, please refer to https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/core/Permission.html,",
"Along with a special permission `DISABLED` for disabling the node.",
"Specifying the permission twice will remove it.\n",
"Use `wipe` if you want to reset the permissions for the node, and `wipeall` for resetting all perms in the whole category.",
"`list` and `wipe`/`wipeall` only accepts the first 2 parameters."), EnumSet.of(Permission.ADMINISTRATOR), -BotUserLevel.BOT_OWNER.ordinal());
registerSubCommand("prefix", Arrays.asList("pre"), (channel, user, msg, words) -> { //dont allow prefixes with markdown for now
String prefix;
if(words.length < 1) {
prefix = DiscordBot.prefix;
channel.sendMessage("No prefix entered. Using default prefix...").queue();
} else {
prefix = msg.getContentStripped().substring(msg.getContentDisplay().lastIndexOf(String.join(" ", words))).replaceAll("\\\\", ""); //if prefix/shortcut contains markdown it breaks
}
if(prefix.length() > 30) return new CommandResult(CommandResultType.INVALIDARGS, "Please do not enter prefixes that are too long."); //arbitrarily set; can change anytime
try (Connection con = DiscordBot.db.getConnection()) {
PreparedStatement s = con.prepareStatement("INSERT INTO settings(id, prefix) VALUES (" + channel.getGuild().getId() + ", ?) ON CONFLICT(id) DO UPDATE SET prefix = ? WHERE prefix <> ?");
s.setString(1, prefix); s.setString(2, prefix); s.setString(3, prefix); //using prep statement to prevent injection
int update = s.executeUpdate();
s.close();
if(update != 0) { //should ever only be 1 or 0
channel.sendMessage("Successfully changed prefix to `" + prefix + "`. Do " + prefix + "help to see the new command syntaxes.").queue();
} else {
return new CommandResult(CommandResultType.INVALIDARGS, "You are setting the same prefix!");
}
return new CommandResult(CommandResultType.SUCCESS);
} catch(SQLException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
}, "[new prefix]", Arrays.asList("`!desp `", "!", "-"),
"Change the prefix for this guild!", Arrays.asList("*You can also include spaces with the use of `code block`s.*", "Leave blank to use the default (`" + DiscordBot.prefix + "`)."));
}
private String[] imposePerms(String cId, long base, Permission perm, boolean allows) {
long allow = (int) base;
long deny = base >> 32;
String type; long newRaw = allows ? allow : deny;
if(perm == null) { //only disabled is null; since Permission doesnt have DISABLED mapped, i have to do it manually
type = MiscUtils.hasDisabled(newRaw) ? "removed" : "added";
if(type.equals("removed")) {
newRaw &= ~DISABLED;
} else {
newRaw |= DISABLED;
}
} else {
List perms = new ArrayList<>(Permission.getPermissions(newRaw));
if(perms.contains(perm)) {
perms.remove(perm);
type = "removed";
} else {
perms.add(perm);
type = "added";
}
newRaw = Permission.getRaw(perms) | (MiscUtils.hasDisabled(newRaw) ? DISABLED : 0); //add back as perms remove it
}
if(allows) allow = newRaw; else deny = newRaw;
return new String[]{type, (cId == null ? "" : cId + ":") + ((deny << 32) | (allow & 0xffffffffL))};
}
}
diff --git a/src/me/despawningbone/discordbot/command/admin/Shutdown.java b/src/me/despawningbone/discordbot/command/admin/Shutdown.java
index dd5bebe..0165842 100644
--- a/src/me/despawningbone/discordbot/command/admin/Shutdown.java
+++ b/src/me/despawningbone/discordbot/command/admin/Shutdown.java
@@ -1,32 +1,32 @@
package me.despawningbone.discordbot.command.admin;
import me.despawningbone.discordbot.DiscordBot;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import me.despawningbone.discordbot.command.music.AudioTrackHandler;
import me.despawningbone.discordbot.command.music.Music;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
public class Shutdown extends Command {
public Shutdown() {
this.desc = "Shut down the bot";
this.usage = "";
this.botUserLevel = BotUserLevel.BOT_OWNER.ordinal();
}
@Override
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) {
channel.sendMessage("Bye!").queue();
System.out.println("\nShutting down the bot...\n");
//shut down audio system gracefully first if exists
AudioTrackHandler ap = ((Music) DiscordBot.commands.get("music")).getAudioTrackHandler();
if(ap != null) ap.shutdown();
new Thread(() -> DiscordBot.mainJDA.shutdown()).start(); //new thread needed to actually shutdown correctly since or else its stuck waiting for the event to finish
return new CommandResult(CommandResultType.SUCCESS);
}
}
diff --git a/src/me/despawningbone/discordbot/command/admin/UserPurge.java b/src/me/despawningbone/discordbot/command/admin/UserPurge.java
index 9f68923..292e0a5 100644
--- a/src/me/despawningbone/discordbot/command/admin/UserPurge.java
+++ b/src/me/despawningbone/discordbot/command/admin/UserPurge.java
@@ -1,75 +1,75 @@
package me.despawningbone.discordbot.command.admin;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.stream.Collectors;
import me.despawningbone.discordbot.DiscordBot;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
public class UserPurge extends Command {
public UserPurge() {
this.alias = Arrays.asList("upurge");
this.desc = "Clear a specific user's messages";
this.usage = "";
this.remarks = Arrays.asList("Note: Count must be in between 1 to 100, and it includes the messages in between.");
this.examples = Arrays.asList(DiscordBot.OwnerID);
//this.botUserLevel = -1; //dont give so much power to botmods
this.perms = EnumSet.of(Permission.MESSAGE_MANAGE);
}
@Override
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) {
Member Bot = channel.getGuild().getMemberById(DiscordBot.BotID);
if (Bot.hasPermission(Permission.MESSAGE_MANAGE)) {
int n = 0;
try {
n = Integer.parseInt(args[1]);
} catch (NumberFormatException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a correct number.");
} catch (ArrayIndexOutOfBoundsException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a number.");
}
if (n > 100) {
return new CommandResult(CommandResultType.INVALIDARGS, "The number entered is too large.");
}
String SID = null;
if (args.length < 1) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please tag a user or enter their ID.");
} else {
- List u = msg.getMentionedUsers();
+ List u = msg.getMentions().getUsers();
if (!u.isEmpty()) {
SID = u.get(0).getId();
} else {
SID = args[0];
Member m = msg.getGuild().getMemberById(SID);
if (m == null) {
return new CommandResult(CommandResultType.INVALIDARGS, "The user ID is not valid.");
}
}
}
msg.delete().queue();
final String purgeID = SID;
channel.getHistory().retrievePast(n).queue(history -> {
List msgs = history.stream()
.filter(hmsg -> hmsg.getAuthor().getId().equals(purgeID)).collect(Collectors.toList());
if(msgs.size() > 1) channel.deleteMessages(msgs).queue();
else if(msgs.size() == 1) channel.deleteMessageById(msgs.get(0).getId());
//if no result ignore
});
return new CommandResult(CommandResultType.SUCCESS);
} else {
return new CommandResult(CommandResultType.FAILURE, "The Bot does not have enough permissions to perform that.");
}
}
}
diff --git a/src/me/despawningbone/discordbot/command/admin/UserReport.java b/src/me/despawningbone/discordbot/command/admin/UserReport.java
index e6f0d39..6baea99 100644
--- a/src/me/despawningbone/discordbot/command/admin/UserReport.java
+++ b/src/me/despawningbone/discordbot/command/admin/UserReport.java
@@ -1,99 +1,99 @@
package me.despawningbone.discordbot.command.admin;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Arrays;
import java.util.List;
import org.apache.commons.lang3.exception.ExceptionUtils;
import me.despawningbone.discordbot.DiscordBot;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
public class UserReport extends Command {
public UserReport() {
this.alias = Arrays.asList("ureport");
this.desc = "Report a user if they are abusing the bot!";
this.usage = "";
this.remarks = Arrays.asList("Note: 5 global reports will result in a ban from the bot.");
this.examples = Arrays.asList("311086271642599424", "despawningbone");
}
@Override
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) {
if (args.length < 1) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please specify which user you want to report.");
} else {
String SID = null;
boolean success = true;
String pname = String.join(" ", args);
long ID = 0;
try {
ID = Long.parseLong(pname);
} catch (NumberFormatException e) {
success = false;
// System.out.println(pname); //debug
List pm = channel.getGuild().getMembersByEffectiveName(pname, true);
if (pm.size() <= 0) {
return new CommandResult(CommandResultType.FAILURE, "There is no such user.");
} else if (pm.size() > 1) {
return new CommandResult(CommandResultType.FAILURE, "Theres more than 1 user with the same name. Please use !desp ID to get the ID of the user you want to report.");
} else {
SID = pm.get(0).getUser().getId();
ID = Long.parseLong(SID);
}
}
if (success == true) {
SID = Long.toString(ID);
}
if (author.getId().equals(SID)) {
return new CommandResult(CommandResultType.FAILURE, "You cannot report yourself you dumbo :stuck_out_tongue:");
} else if (DiscordBot.ModID.contains(SID) || SID.equals(DiscordBot.BotID)) {
return new CommandResult(CommandResultType.FAILURE, "That user cannot be reported.");
}
try (Connection con = DiscordBot.db.getConnection(); Statement s = con.createStatement()) {
String reports = "";
ResultSet uRs = s.executeQuery("SELECT reports FROM users WHERE id = " + SID + ";");
if(uRs.next()) {
reports = uRs.getString(1);
if(reports.contains(author.getId())) {
return new CommandResult(CommandResultType.FAILURE, "You cannot report a user more than once."); //make it so that its toggling the reports instead?
} else if(reports.split("\n").length < 5){
reports += author.getId() + "\n";
} else {
return new CommandResult(CommandResultType.FAILURE, "The user has already been banned!");
}
} else {
reports = author.getId() + "\n";
}
PreparedStatement set = con.prepareStatement("INSERT INTO users(id, reports) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET reports = ?" );
set.setString(1, SID);
set.setString(2, reports); set.setString(3, reports);
int update = set.executeUpdate();
if(update != 0) {
int times = reports.split("\n").length;
channel.sendMessage("You have successfully reported <@!" + ID + ">.\nThe user has been reported for "
+ times + "/5 times.\n").queue();
if(times >= 5) {
channel.sendMessage("The user <@!" + ID + "> is now banned from using the bot.").queue();
}
return new CommandResult(CommandResultType.SUCCESS);
} else {
throw new IllegalArgumentException("This should never happen");
}
} catch (SQLException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
}
}
}
diff --git a/src/me/despawningbone/discordbot/command/anime/Anime.java b/src/me/despawningbone/discordbot/command/anime/Anime.java
index 01ed3aa..93fccaa 100644
--- a/src/me/despawningbone/discordbot/command/anime/Anime.java
+++ b/src/me/despawningbone/discordbot/command/anime/Anime.java
@@ -1,103 +1,103 @@
package me.despawningbone.discordbot.command.anime;
import java.awt.Color;
import java.io.IOException;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;
import me.despawningbone.discordbot.DiscordBot;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import me.despawningbone.discordbot.utils.MiscUtils;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
public class Anime extends Command {
private static byte[] encodedMalAuth = Base64.encodeBase64((DiscordBot.tokens.getProperty("mal")).getBytes(StandardCharsets.UTF_8));
public static String malAuth = "Basic " + new String(encodedMalAuth);
public Anime() {
this.desc = "Find information for any anime!";
this.usage = " [| index]";
this.examples = Arrays.asList("clockwork planet", "chuunibyou | 2");
}
@Override
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) {
if(args.length < 1) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please enter an anime title.");
}
channel.sendTyping().queue();
String[] split = String.join(" ", args).split(" \\|");
String search = split[0]; int index = 0;
try {
if(split.length > 1) {
index = Integer.parseInt(split[1].trim()) - 1;
if(index < 1 || index > 10) throw new NumberFormatException();
}
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
channel.sendMessage("Invalid index inputted. Defaulting to first result...").queue();
}
JSONObject anime, info;
try {
JSONObject main = new JSONObject(new JSONTokener(new URL("https://api.jikan.moe/v3/search/anime?q=" + URLEncoder.encode(search, "UTF-8")).openStream()));
anime = main.getJSONArray("results").getJSONObject(index);
info = new JSONObject(new JSONTokener(new URL("https://api.jikan.moe/v3/anime/" + anime.getInt("mal_id")).openStream()));
} catch (IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
} catch (JSONException e) {
e.printStackTrace();
return new CommandResult(CommandResultType.INVALIDARGS, "There are not enough results for your specified index!");
}
EmbedBuilder em = new EmbedBuilder();
em.setTitle("Anime info for " + anime.getString("title") + " (" + anime.getString("type") + ")", anime.getString("url"));
em.setThumbnail(anime.getString("image_url"));
em.setDescription("JP: " + info.getString("title_japanese") + "\n\n" + (info.isNull("synopsis") ? "No synopsis information has been added to this title." : info.getString("synopsis")));
em.addField("Episodes", (anime.getInt("episodes") == 0 ? "Unknown" : String.valueOf(anime.getInt("episodes"))), true);
em.addField("Rating", anime.getDouble("score") == 0.0 ? "N/A" : String.valueOf(anime.getDouble("score")), true);
em.addField("Airing status", info.getString("status"), true);
String[] aired = info.getJSONObject("aired").getString("string").split("to");
em.addField("Premier season", info.isNull("premiered") ? "Unknown" : info.getString("premiered"), true);
em.addField("Start date", aired[0], true);
em.addField("End date", aired.length > 1 ? aired[1] : aired[0], true);
ArrayList studios = new ArrayList();
for(Object genre : info.getJSONArray("studios")) studios.add("[" + ((JSONObject) genre).getString("name") + "](" + ((JSONObject) genre).getString("url") + ")");
String source = info.getJSONObject("related").has("Adaptation") ?
"[" + info.getString("source") + "](" + info.getJSONObject("related").getJSONArray("Adaptation").getJSONObject(0).getString("url") + ")" : info.getString("source");
if(!source.contains("[Light novel]") && !source.contains("anga]")) source = source.replaceFirst("\\[(.*?)\\](.*)", "$1 ([Adaptation]$2)"); //something couldve adapted it if its original instead
em.addField("Studios", String.join(", ", studios), true);
em.addField("PG rating", info.getString("rating"), true);
em.addField("Source", source, true);
ArrayList genres = new ArrayList();
for(Object genre : info.getJSONArray("genres")) genres.add(((JSONObject) genre).getString("name"));
em.addField("Genre", String.join(", ", genres), false);
em.setFooter(MiscUtils.ordinal(index + 1) + " result - Rank #" + (info.isNull("rank") ? "N/A" : info.getInt("rank")) + ", Popularity #" + (info.isNull("popularity") ? "N/A" : info.getInt("popularity")) + " | MyAnimeList.net", "https://cdn.myanimelist.net/img/sp/icon/apple-touch-icon-256.png");
em.setColor(new Color(46, 81, 162));
channel.sendMessageEmbeds(em.build()).queue();
return new CommandResult(CommandResultType.SUCCESS);
}
}
diff --git a/src/me/despawningbone/discordbot/command/anime/AnimePic.java b/src/me/despawningbone/discordbot/command/anime/AnimePic.java
index 97ed007..aa83411 100644
--- a/src/me/despawningbone/discordbot/command/anime/AnimePic.java
+++ b/src/me/despawningbone/discordbot/command/anime/AnimePic.java
@@ -1,108 +1,108 @@
package me.despawningbone.discordbot.command.anime;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.concurrent.ThreadLocalRandom;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.jsoup.Connection;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import me.despawningbone.discordbot.utils.MiscUtils;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
public class AnimePic extends Command {
public AnimePic() {
this.desc = "Get anime pics!";
this.usage = "[search words] [-f] [| index]";
this.remarks = Arrays.asList("Leave the search words blank for a random pic!", "If you have not specified the index, it will be a random result from the search.", "Include `-f` if you want to load the full picture!", "However, full pictures takes a while to load in.");
this.alias = Arrays.asList("apic");
this.examples = Arrays.asList("ryuZU -f", "neptune | 2");
}
//TODO migrate to another more popular image board?
@Override
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) {
Connection con = null;
channel.sendTyping().queue();
//full res option
boolean full = false;
ArrayList amend = new ArrayList<>(Arrays.asList(args));
if(amend.contains("-f")) {
amend.remove("-f");
full = true;
}
//random, use most popular last 3 months as order - should return better results
if(amend.size() < 1) {
con = Jsoup.connect("https://www.zerochan.net/?s=fav&t=2p=" + (ThreadLocalRandom.current().nextInt(1000) + 1));
}
String stripped = String.join(" ", amend);
String search; int index;
if(stripped.contains("|")) {
String[] split = stripped.split(" \\|");
search = split[0];
try {
index = Integer.parseInt(split[1].trim()) - 1;
} catch (NumberFormatException | ArrayIndexOutOfBoundsException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid index inputted");
}
} else {
search = stripped;
index = -1;
}
try {
if(con == null) con = Jsoup.connect("https://www.zerochan.net/search?q=" + URLEncoder.encode(search, "UTF-8")).ignoreHttpErrors(true).followRedirects(true);
Document document = con.get();
Element href = document.body();
Elements resList = null;
try {
resList = href.select("ul[id=\"thumbs2\"]").get(0).select("li");
if (resList.size() == 0) throw new IndexOutOfBoundsException();
if (resList.size() < index) throw new IndexOutOfBoundsException();
} catch (IndexOutOfBoundsException e) {
if(!href.select("ul[id=\"children\"]").isEmpty()) return new CommandResult(CommandResultType.FAILURE, "You need to specify your search words more!");
else return new CommandResult(CommandResultType.NORESULT);
}
Element a = resList.get(index == -1 ? ThreadLocalRandom.current().nextInt(resList.size()) : index).select("a").first();
if(a.hasAttr("rel")) {
return new CommandResult(CommandResultType.FAILURE, "This picture is not public!");
}
EmbedBuilder eb = new EmbedBuilder();
eb.setTitle((index == -1 ? "Random" : MiscUtils.ordinal(index + 1)) + (search.isEmpty() ? " anime pic" : " pic for " + search), a.absUrl("href"));
//fetch img - seems like discord shrinks the full image anyways nowadays, is -f really useful anymore lol
String imgName = a.nextElementSibling().child(0).attr("href").replace("+", "."); //spaces are represented as dots in the cdn for some reason
String imgPrefix = a.attr("href").substring(1); //remove prefix /
String img = full ?
"https://static.zerochan.net" + imgName + ".full." + imgPrefix + ".jpg":
"https://s1.zerochan.net" + imgName + ".600." + imgPrefix + ".jpg";
eb.setImage(img);
eb.setFooter(full ? "The image might need a while to load in." : "Include -f if you want the full resolution!", null);
channel.sendMessageEmbeds(eb.build()).queue();
return new CommandResult(CommandResultType.SUCCESS);
} catch (IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
}
}
diff --git a/src/me/despawningbone/discordbot/command/anime/Sauce.java b/src/me/despawningbone/discordbot/command/anime/Sauce.java
index 0fecefc..d18b5d7 100644
--- a/src/me/despawningbone/discordbot/command/anime/Sauce.java
+++ b/src/me/despawningbone/discordbot/command/anime/Sauce.java
@@ -1,257 +1,257 @@
package me.despawningbone.discordbot.command.anime;
import java.awt.Color;
import java.io.IOException;
import java.net.ConnectException;
import java.net.MalformedURLException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
public class Sauce extends Command{
public Sauce() {
this.desc = "Get the source of an anime pic!";
this.usage = "[-d] [imgurl]";
this.alias = Arrays.asList("source", "saucenao", "iqdb");
this.remarks = Arrays.asList("The URL should be a direct link to an image.", "You can also upload an image as an attachment while calling this command instead of using a url.",
" * Specify the `-d` parameter to do a depth search!", " * It is useful for cropped images and edited images, but makes the search much longer,", " * and can sometimes result in a related image instead of the actual source.");
}
@Override
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { //allow people to not input url to check sauce of the most recent image like u/2dgt3d?
List amend = new ArrayList(Arrays.asList(args));
int depth = amend.indexOf("-d");
if(depth != -1) amend.subList(depth, depth + 1).clear();
//get url or use attachment if no url found
String url = null;
try {
if(amend.size() < 1) {
if(msg.getAttachments().size() > 0) url = msg.getAttachments().get(0).getUrl();
else throw new MalformedURLException();
} else {
url = amend.get(0).trim();
new URL(url);
}
} catch(MalformedURLException e) {
return new CommandResult(CommandResultType.FAILURE, "Please enter a valid URL!");
}
try {
channel.sendTyping().queueAfter(20, TimeUnit.MILLISECONDS);
if(depth != -1) {
channel.sendMessage("Performing depth search for the picture... (this can take up to 20 seconds)").queue();
String[] urls = yandexSearch(url);
//System.out.println("urls" + Arrays.asList(urls));
url = urls[2] == null ? urls[0] : urls[1] + ";" + urls[2]; //TODO deprecate this wack system
//sync
//List collect = Arrays.asList(tempSearchSauce(urls[0]),tempSearchSauce(urls[1]));
//async - only throw exception if both errored
CompletableFuture first = CompletableFuture.supplyAsync(() -> {try {return getSauce(urls[0]);} catch (IOException e){e.printStackTrace(); return null;}});
CompletableFuture ratio = CompletableFuture.supplyAsync(() -> {try {return getSauce(urls[1]);} catch (IOException e){e.printStackTrace(); return null;}});
List collect = CompletableFuture.allOf(first, ratio)
.thenApply(future -> Arrays.asList(first.join(), ratio.join())).get();
//checking at whencomplete wouldnt help - the array joining wouldve errored as a whole and returned null regardless; must use individual try catches
//return highest similarity if obtained using closest ratio, else return first found (other size then similar)
channel.sendMessageEmbeds(collect.stream().filter(e -> e != null)
.sorted((a, b) -> urls[2] == null ? 1 : Double.compare(
Double.parseDouble(b.build().getFooter().getText().replaceAll(".*?([.0-9]*)%.*", "$1")),
Double.parseDouble(a.build().getFooter().getText().replaceAll(".*?([.0-9]*)%.*", "$1"))))
.findFirst().get().build()).queue();
} else {
//direct search
EmbedBuilder eb = getSauce(url);
channel.sendMessageEmbeds(eb.build()).queue();
}
} catch (IOException | InterruptedException | ExecutionException e) {
Throwable t = e instanceof ExecutionException ? e.getCause() : e;
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(t));
} catch (NullPointerException | NoSuchElementException e) {
//give possible related images embed info
if(e instanceof NoSuchElementException) {
EmbedBuilder eb = new EmbedBuilder();
String[] urls = url.split(";");
eb.setThumbnail(urls[0]);
eb.setTitle("Possibly related image", urls[0]);
eb.setDescription("Maybe [this](" + urls[urls.length > 1 ? 1 : 0] + ") will help in your search for the delicious marinara?");
channel.sendMessageEmbeds(eb.build()).queueAfter(20, TimeUnit.MILLISECONDS);
}
return new CommandResult(CommandResultType.NORESULT);
}
return new CommandResult(CommandResultType.SUCCESS);
}
//well known image boards with workable source images that yandex might scrape
private List imageBoards = Arrays.asList("danbooru.donmai.us", "safebooru.org", "gelbooru.com", "zerochan.net", "tbib.org", "pixiv.kurocore.com", "pixiv.net", "chan.sankakucomplex.com", "sankakucomplex.com", "e-shuushuu.net");
//TODO internal cooldown of ~5s when command cooldown system is done - too many requests results in captcha
//returns String[] {, , }
private String[] yandexSearch(String url) throws IOException {
Element yandex = Jsoup.connect("https://yandex.com/images/search?url=" + URLEncoder.encode(url, "UTF-8") + "&rpt=imageview").get().body();
//other sites div class new style is slowly rolling out, support both names
String osItem, osSnippet, osPreview, osMeta, osSimilar;
if(!yandex.select(".other-sites__item").isEmpty()) { //old ver
osItem = ".other-sites__item";
osSnippet = ".other-sites__snippet-site-link";
osPreview = ".other-sites__preview-link";
osMeta = ".other-sites__meta";
osSimilar = ".cbir-similar__thumb .cbir-similar__image";
} else { //usually means new
//System.out.println("Sauce: new version of yandex found");
//System.out.println(yandex);
osItem = ".CbirSites-Item";
osSnippet = ".CbirSites-ItemDomain";
osPreview = ".CbirSites-ItemThumb .Thumb";
osMeta = ".CbirSites-ItemThumb .Thumb-Mark";
osSimilar = ".CbirSimilar-Thumb .Thumb-Image";
}
//System.out.println(yandex.select(osItem).stream()
// .filter(o -> imageBoards.contains(o.select(osSnippet).text().toLowerCase())).collect(Collectors.toList()));
//check if image boards are found, if so return the first one found and discard similar results since imageboards are as standard as we can get
//it is possible but unlikely that we would be grabbing the wrong image's source in the lower sections even though there are good sources (that are not imageboards) at the top due to this
String boardRes = yandex.select(osItem).stream()
.filter(o -> imageBoards.contains(o.select(osSnippet).text().toLowerCase()))
.map(o -> o.select(osPreview).attr("href")).findFirst().orElse(null);
if(boardRes != null) {
if(boardRes.startsWith("https://embed.pixiv.net")) { //pixiv images arent full images, need some post processing - using kurocore's database instead
String illustId = boardRes.substring(boardRes.lastIndexOf("illust_id=") + 10);
boardRes = "https://img.kurocore.com/thumbnail/pi/"
+ String.format("%03d", Integer.parseInt(illustId.substring(0, illustId.length() - 6)))
+ "/" + illustId.substring(illustId.length() - 6, illustId.length() - 3)
+ "/" + illustId + "_0.jpg";
}
return new String[] {null, boardRes, null};
}
//else use other options
//gets second image coz usually the first one is just a sharper identical image
Element simThumb = yandex.select(osSimilar).get(1);
String similar = "https:" + (osSimilar.contains(".CbirSimilar") ? simThumb.attr("style").replaceAll(".*url\\((.*?)\\).*", "$1") : simThumb.attr("src")); //use yandex's thumbnails since the actual source might be broken already
//use other sizes if found - second most accurate result (unless theres edits that uses the image in it (memes for example), so still retain similar results)
if(yandex.select(".CbirOtherSizes-Wrapper").size() > 0) { //merge the sort algorithms? //sorting with ratio seems to perform bad for most crops so dont sort anymore
String otherSize = yandex.select(".Tags-Item").first().attr("href");
return new String[] {similar, otherSize, null};
}
//last resort: sort by closest ratio (only first 12 results since typically they deviate from original a ton after 12 results)
String[] size = new String[]{yandex.selectFirst(".CbirPreview-Placeholder").attr("width"), yandex.selectFirst(".CbirPreview-Placeholder").attr("height")};
//fixed ratio - works better with normal dimensions (eg anime scenes), sucks at everything else
//double ratio = Double.parseDouble(size[0]) / Double.parseDouble(size[1]) >= 1 ? 16.0/9 : 9/16.0;
//dynamic ratio - works really well only if the crop has the same ratio as the actual image itself
double ratio = Double.parseDouble(size[0]) / Double.parseDouble(size[1]);
Element eSite = yandex.select(osItem).stream().limit(12).sorted((a, b) -> {
String[] aA = a.select(osMeta).first().text().split("×"), bA = b.select(osMeta).first().text().split("×");
return Double.compare(Math.abs(Double.parseDouble(aA[0]) / Double.parseDouble(aA[1]) - ratio), Math.abs(Double.parseDouble(bA[0]) / Double.parseDouble(bA[1]) - ratio));}).findFirst().get();
return new String[] {similar,
eSite.select(osPreview).first().attr("href"),
eSite.select(osSnippet).first().attr("href")};
//anime checking usually cant be done with depth search since not every scene is screen capped online
}
private EmbedBuilder getSauce(String url) throws IOException {
if(url == null) return null;
EmbedBuilder eb = new EmbedBuilder();
eb.setColor(new Color(29, 29, 29));
try { //search iqdb first for the tags; results usually more organized and formatted
Element iqdb = Jsoup.connect("https://iqdb.org/?url=" + URLEncoder.encode(url, "UTF-8") + "&service[]=1&service[]=2&service[]=3&service[]=4&service[]=5&service[]=11&service[]=13").post().body(); //services excluding eshuushuu since it has no tags and will fallback to saucenao anyways
//post instead of get due to the recent incident making iqdb and saucenao block imgur
if(iqdb.select(".err").size() > 0 && iqdb.select(".err").html().contains("HTTP")) //iqdb errors, most likely broken link so dont fallback
throw new IOException(iqdb.selectFirst(".err").ownText().split("\\.")[0]);
Elements tb = iqdb.select("th:contains(Best match)").get(0).parent().siblingElements();
Element img = tb.get(0).selectFirst("img");
eb.setThumbnail("https://iqdb.org/" + img.attr("src"));
eb.setTitle("Source: " + tb.get(1).selectFirst("td").ownText(), (img.parent().attr("href").contains("http") ? "" : "https:") + img.parent().attr("href"));
String tags = img.attr("alt").split("Tags:")[1];
eb.setDescription(tags.contains(",") ? tags.replaceAll(",", "\n") : tags.replaceAll(" ", "\n").replaceAll("\\_", " "));
eb.setFooter(tb.get(2).select("td").eachText().get(0) + " | " + tb.get(3).select("td").text(), null);
} catch (IndexOutOfBoundsException | SocketTimeoutException | ConnectException e) {
try { //fallback to saucenao, usually pixiv source instead of image boards; also falls back if its anime scenes
Element saucenao = Jsoup.connect("https://saucenao.com/search.php").requestBody("url=" + URLEncoder.encode(url, "UTF-8")).post().body(); //same reason as iqdb post
Element result = saucenao.selectFirst(".resulttable");
if(result == null) return null; //no results
if(result.parent().attr("class").equals("result hidden")) return null; //all low similarity results, ignore
eb.setThumbnail((result.selectFirst("img").attr("src").contains("http") ? "" : "https:") + result.selectFirst("img").attr("src").replaceAll(" ", "%20"));
try { //normal pixiv/deviantart handling
Element source = result.selectFirst("strong:contains(ID:)").nextElementSibling();
eb.setAuthor(result.select(".resulttitle strong").text());
eb.setTitle("Source: " + source.previousElementSibling().text().replaceAll("ID:", "#") + source.text(), source.attr("href"));
Element member = result.select(".linkify").get(2);
eb.setDescription("Author: [" + member.text() + "](" + member.attr("href") + ")");
} catch (NullPointerException e1) { //weird saucenao card formatting (eg episode info)
//change line break tags to \n, and use first line as title
result.select("br").after("\\n");
String[] title = result.selectFirst(".resulttitle") == null ? new String[]{"No title"} : result.selectFirst(".resulttitle").wholeText().replaceAll("\\\\n", "\n").split("\n", 2); //there can be no titles, like 4chan sources
eb.setTitle(title[0], result.selectFirst(".resultmiscinfo a") == null ? null : result.selectFirst(".resultmiscinfo a").attr("href"));
if(title.length > 1) eb.appendDescription(title[1] + "\n");
eb.appendDescription(result.selectFirst(".resultcontentcolumn").wholeText().replaceAll("\\\\n", "\n"));
}
String similarity = result.select(".resultsimilarityinfo").text();
//additional layer above saucenao's low similarity check: discard lower than 65% similarity
if(Double.parseDouble(similarity.substring(0, similarity.length() - 1)) < 65)
return null;
eb.setFooter(similarity + " similarity", null);
} catch (IndexOutOfBoundsException e1) {
return null;
}
}
return eb;
}
}
diff --git a/src/me/despawningbone/discordbot/command/anime/Waifu.java b/src/me/despawningbone/discordbot/command/anime/Waifu.java
index 029f7d9..7bbdff6 100644
--- a/src/me/despawningbone/discordbot/command/anime/Waifu.java
+++ b/src/me/despawningbone/discordbot/command/anime/Waifu.java
@@ -1,186 +1,186 @@
package me.despawningbone.discordbot.command.anime;
import java.awt.Color;
import java.io.IOException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;
import org.jsoup.Connection.Response;
import org.jsoup.Jsoup;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
public class Waifu extends Command {
public Waifu() {
this.desc = "Find information about your waifu/husbando!"; //, or leave it blank for a random one!";
this.usage = " [| index]";
this.examples = Arrays.asList("neptune", "ryuZU");
this.alias = Arrays.asList("husbando");
}
@Override
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) {
channel.sendTyping().queue();
String[] stripped = String.join(" ", args).split("\\|");
if(args.length < 1) return new CommandResult(CommandResultType.FAILURE, "Please enter something to search for!");
JSONObject main, props;
try {
//get required cookies
//Mozilla/4.0 actually bypasses the need for CSRF, but breaks /api/search/all since it requires X-Requested-With (/browse?name= still works but doesn't handle multiple types at once)
Response con = Jsoup.connect("https://mywaifulist.moe/").userAgent("Mozilla/5.0").execute();
String res = Jsoup.connect("https://mywaifulist.moe/api/search/all") //advancedsearch is no longer a thing
.userAgent("Mozilla/5.0")
.header("X-Requested-With", "XMLHttpRequest")
.header("X-XSRF-TOKEN", URLDecoder.decode(con.cookie("XSRF-TOKEN"), "UTF-8")) //base64 might be encoded - if it is, then laravel will return 419 page expired
.header("Content-Type", "application/json") //needed or the endpoint can't parse the query json
.header("Referer", "https://mywaifulist.moe/") //needed to avoid internal server error for some reason
.cookies(con.cookies())
.ignoreContentType(true)
.requestBody("{\"query\":\"" + stripped[0].trim() + "\"}").post().text();
//check index
int index = 0;
JSONArray jarr = new JSONArray(new JSONTokener(res));
try {
//System.out.println(arr.length());
if(stripped.length > 1) index = Integer.parseInt(stripped[1].trim()) - 1;
if(jarr.length() <= index && index != 0) throw new NumberFormatException(); //if set index is out of bounds
} catch (NumberFormatException e) {
channel.sendMessage("Invalid index inputted. Defaulting to first result.").queue();
channel.sendTyping().queue();
index = 0;
}
//sort search results for fetching with specified index
Pattern wholeWord = Pattern.compile("(?i).*\\b" + stripped[0].trim() + "\\b.*");
List arr = new ArrayList<>();
for(Object obj : jarr) arr.add((JSONObject) obj);
arr = arr.stream().filter(o -> !o.isNull("entity_type") && (o.getString("entity_type").equalsIgnoreCase("waifu") || o.getString("entity_type").equalsIgnoreCase("husbando"))) //filter only characters
.sorted((a, b) -> Integer.compare( //combine likes and trash to get popularity - sort by popularity since the search result sucks ass
(b.has("likes") ? b.getInt("likes") : 0) + (b.has("trash") ? b.getInt("trash") : 0),
(a.has("likes") ? a.getInt("likes") : 0) + (a.has("trash") ? a.getInt("trash") : 0)))
.sorted((a, b) -> wholeWord.matcher(a.getString("name")).matches() && !wholeWord.matcher(b.getString("name")).matches() ? -1 : 0) //move whole word matches up only if last one was not matched
.collect(Collectors.toList());
//fetch
props = new JSONObject(new JSONTokener(Jsoup.connect("https://mywaifulist.moe/waifu/" + arr.get(index).getString("slug"))
.userAgent("Mozilla/4.0")
.followRedirects(true)
.get().selectFirst("#app").attr("data-page"))).getJSONObject("props");
main = props.getJSONObject("waifu");
} catch (IndexOutOfBoundsException | JSONException e) {
e.printStackTrace();
return new CommandResult(CommandResultType.NORESULT);
} catch (IOException e) {
if(e.toString().contains("Status=422")) return new CommandResult(CommandResultType.NORESULT); //search scope too broad(?)
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
EmbedBuilder em = new EmbedBuilder();
em.setColor(new Color(44, 62, 80));
em.setTitle((main.getBoolean("husbando") ? "Husbando" : "Waifu") + " info of " + main.getString("name"), "https://mywaifulist.moe/waifu/" + main.getString("slug"));
//series
JSONObject series = main.getJSONArray("appearances").getJSONObject(0);
em.setAuthor("Series: " + series.getString("name"), "https://mywaifulist.moe/series/" + series.getString("slug"));
if(!series.isNull("display_picture") && !series.getString("display_picture").isEmpty())
em.setThumbnail(series.getString("display_picture"));
try {
em.setDescription(main.getString("description").substring(0, Math.min(main.getString("description").length(), 2040)) + (main.getString("description").length() > 2040 ? "..." : ""));
} catch (IllegalArgumentException e) {
return new CommandResult(CommandResultType.TOOLONG);
}
em.setImage(main.getString("display_picture").replaceAll("\\\\", ""));
//series appearance + series description
ArrayList appearances = new ArrayList<>();
int totalName = 0;
for(Object obj : main.getJSONArray("appearances")) {
JSONObject jobj = ((JSONObject) obj);
appearances.add(jobj);
totalName += jobj.getString("name").length() + jobj.getString("slug").length() + 43; //magic number for giving enough leeway
}
final int avg = (1024 - totalName) / appearances.size(); //get max description average length
em.addField("Appearances", appearances.stream().map(jobj -> "[" + jobj.getString("name") + "](https://mywaifulist.moe/series/" + jobj.getString("slug")
+ (!jobj.isNull("description") ? (" \"" + jobj.getString("description").substring(0, Math.min(jobj.getString("description").length(), avg)).replaceAll("\"", "”")) //trim desc to max length, replacing double quotes since it will interfere with markdown
+ (jobj.getString("description").length() > avg ? "..." : "") + "\")" : ")")) //append ... if its not finished (only if desc is non null will desc be printed)
.collect(Collectors.joining(", ")), false);
//aliases
if(!main.isNull("original_name") && !main.getString("original_name").isEmpty())
em.addField("Also known as", main.getString("original_name")
+ (main.isNull("romaji_name") || main.getString("romaji_name").isEmpty() ? ""
: ", " + main.getString("romaji_name")), false);
em.addBlankField(false);
//optionally existing info
if (!main.isNull("origin"))
em.addField("Origin", main.getString("origin"), true);
if (!main.isNull("height"))
em.addField("Height", String.valueOf(main.getDouble("height")), true);
if (!main.isNull("weight"))
em.addField("Weight", String.valueOf(main.getDouble("weight")), true);
if (!main.isNull("bust"))
em.addField("Bust", String.valueOf(main.getDouble("bust")), true);
if (!main.isNull("hip"))
em.addField("Hip", String.valueOf(main.getDouble("hip")), true);
if (!main.isNull("waist"))
em.addField("Waist", String.valueOf(main.getDouble("waist")), true);
if (!main.isNull("blood_type"))
em.addField("Blood type", main.getString("blood_type"), true);
if (!main.isNull("birthday_day") && main.getInt("birthday_day") != 0 && !main.isNull("birthday_month") && !main.getString("birthday_month").isEmpty())
em.addField("Birthday", main.getString("birthday_month") + " " + String.valueOf(main.getInt("birthday_day"))
+ (main.isNull("birthday_year") || main.getString("birthday_year").isEmpty() ? "" : ", " + main.getString("birthday_year")), true);
//only add blank on 5 fields for formatting
if (em.getFields().size() == 5) {
em.addBlankField(true);
}
//tags is back! (for some entries at least)
String tags = props.getJSONArray("tags").toList().stream().map(o -> o.toString().replaceAll("\\{name=", "").replaceAll(", id=.*\\}", "")).collect(Collectors.joining(", "));
if(!tags.isEmpty()) em.addField("Tags", tags, false);
//popularity stats
if(em.getFields().size() > 2) em.addBlankField(false);
em.addField("Likes", main.getInt("likes") + " (#" + (main.isNull("like_rank") ? "N/A" : main.getInt("like_rank")) + ")", true);
em.addField("Popularity rank", "#" + (main.isNull("popularity_rank") ? "N/A" : main.getInt("popularity_rank")), true);
em.addField("Trash", main.getInt("trash") + " (#" + (main.isNull("trash_rank") ? "N/A" : main.getInt("trash_rank")) + ")", true);
//creator name no longer exists
em.setFooter("Created at " + DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneId.of("Z")).format(Instant.parse(main.getString("created_at"))) + " | MyWaifuList.moe", null);
channel.sendMessageEmbeds(em.build()).queue();
return new CommandResult(CommandResultType.SUCCESS);
}
}
\ No newline at end of file
diff --git a/src/me/despawningbone/discordbot/command/games/Osu.java b/src/me/despawningbone/discordbot/command/games/Osu.java
index 5526fbc..5b43761 100644
--- a/src/me/despawningbone/discordbot/command/games/Osu.java
+++ b/src/me/despawningbone/discordbot/command/games/Osu.java
@@ -1,1159 +1,1159 @@
package me.despawningbone.discordbot.command.games;
import java.awt.Color;
import java.io.BufferedReader;
-import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.text.DateFormat;
import java.text.DecimalFormat;
import java.text.SimpleDateFormat;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import javax.xml.bind.DatatypeConverter;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;
import org.knowm.xchart.BitmapEncoder;
import org.knowm.xchart.XYChart;
import org.knowm.xchart.XYChartBuilder;
import org.knowm.xchart.BitmapEncoder.BitmapFormat;
import org.knowm.xchart.XYSeries.XYSeriesRenderStyle;
import org.knowm.xchart.style.Styler.LegendPosition;
import me.despawningbone.discordbot.DiscordBot;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import me.despawningbone.discordbot.command.games.Koohii.Accuracy;
import me.despawningbone.discordbot.command.games.Koohii.DiffCalc;
import me.despawningbone.discordbot.command.games.Koohii.Map;
import me.despawningbone.discordbot.command.games.Koohii.PPv2;
import me.despawningbone.discordbot.command.games.Koohii.PlayParameters;
import me.despawningbone.discordbot.command.games.Koohii.MapStats;
import me.despawningbone.discordbot.command.games.Koohii.TaikoPP;
import me.despawningbone.discordbot.utils.MiscUtils;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.User;
+import net.dv8tion.jda.api.utils.FileUpload;
public class Osu extends Command {
private static final String osuAPI = DiscordBot.tokens.getProperty("osu");
private static final DecimalFormat df = new DecimalFormat("#.##");
private static final String[] modes = new String[] {"osu!", "osu!taiko", "osu!catch", "osu!mania"};
public Osu() { //TODO add back the todos to respective sub command, automate desc for subcmds, add back typing; CLOSE ALL STREAMS
this.desc = "All the info you need with osu!";
this.usage = "";
//TODO add a command to parse replays?
registerSubCommand("pp", Arrays.asList("map"), (channel, user, msg, words) -> {
List amend = new ArrayList(Arrays.asList(words));
int wParamIndex = amend.indexOf("-w");
//parse weight param and uid; doesnt work if -w is immediately followed by pp params like 100x etc
String uid = null;
boolean weight = wParamIndex != -1;
if(weight) {
try {
int wParamLength = 1;
uid = amend.get(wParamIndex + 1);
if(!uid.contains("osu.ppy.sh/u") && (uid.startsWith("http") && uid.contains("://"))) { //url thats not user url means its most likely a beatmap, aka no username param
uid = getPlayer(user);
} else {
wParamLength = 2; //has username
}
amend.subList(wParamIndex, wParamIndex + wParamLength).clear(); //remove
} catch (IndexOutOfBoundsException e) {
uid = getPlayer(user);
}
}
String initmap;
try {
initmap = amend.get(0);
} catch (IndexOutOfBoundsException e) {
initmap = "null"; //dud
}
CompletableFuture msgId = new CompletableFuture<>();
//check if no map input, use discord rich presence
if (!initmap.startsWith("https://") && !initmap.startsWith("http://")) {
//get map name from status
String details = null;
try (Connection con = DiscordBot.db.getConnection()) {
ResultSet rs = con.createStatement().executeQuery("SELECT game FROM users WHERE id = " + user.getId() + ";");
if(rs.next()) {
details = rs.getString(1).substring(rs.getString(1).indexOf("||"));
}
rs.close();
} catch (SQLException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
channel.sendMessage("Trying to retrieve map from discord status...").queue(m -> msgId.complete(m.getId())); //DONE custom status masks presence now, will not be as effective; any way to get manually? //JDA 4 API change fixed this
channel.sendTyping().queue();
//parse map name and search
if (details != null) { //TODO if name is sth like `Feryquitous - (S).0ngs//---::compilation.[TQR-f3] [-[//mission:#FC.0011-excindell.defer.abferibus]-]` it breaks (but reasonable break tbh); also breaks if difficulty has " in it
try {
String title = URLEncoder.encode(details.substring(details.indexOf(" - ") + 3, details.lastIndexOf("[")).trim(), "UTF-8");
String diff = URLEncoder.encode(details.substring(details.lastIndexOf("[") + 1, details.lastIndexOf("]")).trim(), "UTF-8");
String url = "https://osusearch.com/query/?title=" + title + "&diff_name=" + diff + "&query_order=play_count&offset=0";
URLConnection stream = new URL(url).openConnection();
stream.addRequestProperty("User-Agent", "Mozilla/4.0");
JSONTokener tokener = new JSONTokener(stream.getInputStream());
initmap = "https://osu.ppy.sh/beatmaps/" + new JSONObject(tokener).getJSONArray("beatmaps").getJSONObject(0).getInt("beatmap_id");
} catch (IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
} catch (JSONException e) {
e.printStackTrace();
System.out.println(details);
return new CommandResult(CommandResultType.NORESULT); //returns if osusearch isnt updated fast enough
}
} else {
return new CommandResult(CommandResultType.FAILURE, "There is no account of your rich presence, therefore I cannot get the beatmap from your status.");
}
}
//if still dud aka no presence nor url
if(initmap.equals("null")) { //shouldnt throw at all
return new CommandResult(CommandResultType.FAILURE, "You haven't played any maps I can recognize yet!");
}
//parse beatmap
Map beatmap = null;
try {
beatmap = new Koohii.Parser()
.map(new BufferedReader(new InputStreamReader(getMap(initmap), "UTF-8")));
} catch (IOException e1) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1));
} catch (UnsupportedOperationException e1) {
return new CommandResult(CommandResultType.FAILURE, "This gamemode is not yet supported.");
} catch (IllegalArgumentException e1) {
return new CommandResult(CommandResultType.INVALIDARGS, e1.getMessage());
}
if (beatmap.title.isEmpty()) {
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid beatmap.");
}
//parse pp params
double dacc = 100;
int combo = beatmap.max_combo(), mods = 0, miss = 0;
try {
for (int i = 0; i < amend.size(); i++) {
String param = amend.get(i);
if (param.startsWith("+")) {
mods = Koohii.mods_from_str(param.substring(1).toUpperCase());
//invalid mods just get ignored
} else if (param.toLowerCase().endsWith("m")) {
miss = Integer.parseInt(param.substring(0, param.length() - 1));
if(miss < 0 || miss > beatmap.objects.size())
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid miss count specified.");
} else if (param.endsWith("%")) {
dacc = Double.parseDouble(param.substring(0, param.length() - 1));
if(dacc < 0)
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid accuracy specified.");
} else if (param.toLowerCase().endsWith("x")) {
combo = Integer.parseInt(param.substring(0, param.length() - 1));
if(combo < 0 || combo > beatmap.max_combo())
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid combo specified.");
}
}
} catch (NumberFormatException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid params specified.");
}
//compute pp
Accuracy acc = null;
JSONObject jbm = null;
try {
acc = new Accuracy(dacc, miss, beatmap);
jbm = computePP(beatmap, initmap.substring(initmap.lastIndexOf("/") + 1).split("&")[0],
mods, acc.n50, acc.n100, acc.n300, miss, combo);
} catch (IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
} catch (IllegalArgumentException e) {
return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage());
}
//build embed
EmbedBuilder eb = new EmbedBuilder();
if(jbm.has("source") && !jbm.getString("source").isEmpty()) eb.setTitle("Source: " + jbm.getString("source"));
eb.setDescription("Mode: " + modes[beatmap.mode]);
eb.setAuthor("PP information for " + (beatmap.title_unicode.isEmpty() ? beatmap.title : beatmap.title_unicode),
initmap, "https://b.ppy.sh/thumb/" + jbm.getString("beatmapset_id") + ".jpg");
eb.addField("Artist", (beatmap.artist_unicode.isEmpty() ? beatmap.artist : beatmap.artist_unicode), true);
eb.addField("Created by", beatmap.creator, true);
String totaldur = MiscUtils.convertMillis(TimeUnit.SECONDS.toMillis((int)(jbm.getInt("total_length") / jbm.getDouble("speed"))));
String draindur = MiscUtils.convertMillis(TimeUnit.SECONDS.toMillis((int)(jbm.getInt("hit_length") / jbm.getDouble("speed"))));
eb.addField("Duration", totaldur + " | Drain " + draindur, true);
eb.addField("Difficulty", beatmap.version, true);
eb.addField("Mods", (mods == 0 ? "None" : Koohii.mods_str(mods)), true);
eb.addField("BPM", df.format(jbm.getDouble("bpm") * jbm.getDouble("speed")), true);
eb.addField("Accuracy", df.format(jbm.getDouble("accVal") * 100) + "%", true);
eb.addField("Combo", String.valueOf(combo) + "x", true);
eb.addField("Misses", String.valueOf(miss), true);
eb.addField("300", String.valueOf(acc.n300), true);
eb.addField("100", String.valueOf(acc.n100), true);
eb.addField("50", String.valueOf(acc.n50), true);
eb.addField("PP", df.format(jbm.getDouble("ppVal")), true);
if(weight) {
try {
JSONObject res = getPlayerData("get_user_best", uid, beatmap.mode, user);
if(res.has("result")) {
String gain = df.format(weightForPP(jbm.getDouble("ppVal"), jbm.getString("beatmap_id"), res.getJSONArray("result")));
eb.addField("Actual pp gain for " + uid, (gain.equals("-1") ? "User has a better score than this" : gain + "pp"), true);
} else {
channel.sendMessage("Unknown user `" + res.getString("search") + "` specified for pp checking, ignoring...").queue();
}
} catch (IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
}
eb.setFooter("Stars: " + df.format(jbm.getDouble("starsVal")) + " | CS: " + df.format(jbm.getDouble("cs")) + " | HP: " + df.format(jbm.getDouble("hp"))
+ " | AR: " + df.format(jbm.getDouble("ar")) + " | OD: " + df.format(jbm.getDouble("od")), null);
eb.setColor(new Color(239, 109, 167));
//remove retrieve msg if sent
if(msgId.isDone()) {
msgId.thenAccept(m -> channel.deleteMessageById(m).queue());
}
channel.sendMessageEmbeds(eb.build()).queue();
return new CommandResult(CommandResultType.SUCCESS);
}, "[beatmap URL] [+|%|x|m] [-w [username]]", Arrays.asList("-w", "+DT", "https://osu.ppy.sh/b/1817768 93.43% -w", "https://osu.ppy.sh/beatmaps/1154509 +HDDT 96.05% 147x 2m -w despawningbone"),
"Check PP information about that beatmap!", Arrays.asList(
" * Available parameters: +[mod alias], [accuracy]%, [combo]x, [misses]m",
" * specify the `-w` parameter with a username (or none for your own) to check how many actual pp the play will get them!",
" * You can also not input the URL, if you are currently playing osu! and discord sensed it."));
registerSubCommand("weight", Arrays.asList("w"), (channel, user, msg, words) -> {
List params = new ArrayList<>(Arrays.asList(words));
int modeId = getMode(params);
final double precision = 1e-4, iterations = 500;
//get user
String uid;
int index = params.size() < 2 ? 0 : 1;
try {
uid = params.get(index - 1);
if(index == 0 || (!uid.contains("osu.ppy.sh/u") && (uid.startsWith("http") && uid.contains("://")))) {
uid = getPlayer(user);
}
} catch (IndexOutOfBoundsException e) {
uid = getPlayer(user);
}
//get user top
JSONArray arr = null;
try {
JSONObject res = getPlayerData("get_user_best", uid.trim(), modeId, user);
if(!res.has("result"))
return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`.");
arr = res.getJSONArray("result");
} catch (IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
double limit = arr.getJSONObject(0).getDouble("pp") + 100; //fricc those who wanna get pp over 100pp more than they can lmao
//perform bisection search
try {
double targetPP = params.size() > index ? Double.parseDouble(params.get(index)) : 1;
int i = 0; double minPP = 0, maxPP = limit, mid = 0;
while(i < iterations && Math.abs(maxPP - minPP) > precision) {
mid = (maxPP + minPP) / 2;
double temp = weightForPP(mid, null, arr);
if(temp > targetPP) {
maxPP = mid;
} else {
minPP = mid;
}
i++;
}
if(!df.format(mid).equals(df.format(limit))) {
channel.sendMessage("For " + uid + " to actually gain " + df.format(targetPP) + "pp in " + modes[modeId] + ", they have to play a map worth approximately **" + df.format(mid) + "pp** raw.").queue();
return new CommandResult(CommandResultType.SUCCESS);
} else {
return new CommandResult(CommandResultType.INVALIDARGS, "You can't really achieve such large pp jumps without people thinking you are hacking :P");
}
} catch (NumberFormatException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid target pp specified.");
}
}, "[username] [wished pp gain]", Arrays.asList("", "10", "despawningbone 20"),
"Estimate how much raw pp a map needs to have in order to gain you pp!",
Arrays.asList(" * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard)."));
//DONE NEED EXTREME TIDYING UP
//DONE merge parts of the code with getRecent() and getPP();
registerSubCommand("recent", Arrays.asList("r"), (channel, user, msg, words) -> {
List params = new ArrayList<>(Arrays.asList(words));
int modeId = getMode(params);
if(modeId > 1) modeId = 0; //ignore unsupported modes and default to standard
boolean passOnly = params.removeAll(Collections.singleton("-p")); //no need space, as spaced ones are split (if a player has a name with spaced -p it might be a problem)
//parse params
String[] split = String.join(" ", params).split("\\|");
String search = split[0].trim();
int nrecent = 0;
try {
nrecent = Integer.parseInt(split[1].trim()) - 1;
} catch (NumberFormatException e) {
//print to channel?
} catch (ArrayIndexOutOfBoundsException e) {
; //nothing special about this
}
if(nrecent > 50) {
return new CommandResult(CommandResultType.FAILURE, "Only the 50 most recent plays can be recalled!");
}
//fetch recent plays
JSONArray array;
String name;
try {
JSONObject res = getPlayerData("get_user_recent", search, modeId, user);
if(!res.has("result"))
return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "` or the player has not been playing in the last 24h.");
array = res.getJSONArray("result");
if(array.length() == 0)
return new CommandResult(CommandResultType.FAILURE, "You have no recent plays in this 24h!");
//set name according to supported formats
name = res.getBoolean("isId") ? //isId might return true on cases inputted as https://osu.ppy.sh/users/despawningbone for example, which would make the fetching redundant but still works
getPlayerData("get_user", search, 0, user).getJSONArray("result").getJSONObject(0).getString("username"):
res.getString("search");
} catch (IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
//get which play to return
JSONObject mostRecent = null;
if(passOnly) {
int times = 0;
for(int i = 0; i < array.length(); i++) {
if(!array.getJSONObject(i).getString("rank").equals("F")) {
times++;
if(times - 1 >= nrecent) {
mostRecent = array.getJSONObject(i);
nrecent = i;
break;
}
}
}
if(mostRecent == null) {
return new CommandResult(CommandResultType.INVALIDARGS, "You did not have this much passed plays in the recent 50 plays!");
}
} else {
try {
mostRecent = array.getJSONObject(nrecent);
} catch (JSONException e) {
return new CommandResult(CommandResultType.FAILURE, "You only played " + array.length() + " times recently!");
}
}
String bid = mostRecent.getString("beatmap_id");
//get consecutive tries
int i;
for(i = nrecent + 1; i < array.length(); i++) {
if(!array.getJSONObject(i).getString("beatmap_id").equals(bid)) {
break;
}
}
//if we reached the end of the list, there might be more that we missed since we can only fetch 50 recent plays
String more = "";
if(i == array.length()) {
more = " (or more)";
}
//parse map of the recent play and compute pp
int n50 = mostRecent.getInt("count50"), n100 = mostRecent.getInt("count100"), n300 = mostRecent.getInt("count300"),
miss = mostRecent.getInt("countmiss"), combo = mostRecent.getInt("maxcombo"), mods = mostRecent.getInt("enabled_mods");
Map beatmap = null;
JSONObject jbm = null;
try {
beatmap = new Koohii.Parser()
.map(new BufferedReader(new InputStreamReader(getMap(bid), "UTF-8")));
jbm = computePP(beatmap, bid, mods, n50, n100, n300, miss, combo);
} catch (IOException e1) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1));
} catch (UnsupportedOperationException e1) {
return new CommandResult(CommandResultType.FAILURE, "This gamemode is not yet supported.");
} catch (IllegalArgumentException e1) {
return new CommandResult(CommandResultType.INVALIDARGS, e1.getMessage());
}
//build embed
String modStr = Koohii.mods_str(mods);
EmbedBuilder eb = new EmbedBuilder();
eb.setColor(new Color(0, 0, 0));
eb.setTitle((beatmap.artist_unicode.isEmpty() ? beatmap.artist : beatmap.artist_unicode)
+ " - " + (beatmap.title_unicode.isEmpty() ? beatmap.title : beatmap.title_unicode) + " [" + beatmap.version + "]"
+ (modStr.isEmpty() ? "" : " +" + modStr) + " (" + df.format(jbm.getDouble("starsVal")) + "*)"
, "https://osu.ppy.sh/beatmaps/" + bid);
eb.setAuthor(MiscUtils.ordinal(nrecent + 1) + " most recent " + modes[modeId] + " play by " + name,
"https://osu.ppy.sh/users/" + mostRecent.getString("user_id"), "https://a.ppy.sh/" + mostRecent.getString("user_id"));
eb.setDescription(MiscUtils.ordinal(i - nrecent) + more + " consecutive try\n"
+ "Ranking: " + mostRecent.getString("rank").replaceAll("H", "+").replaceAll("X", "SS")
+ (mostRecent.getString("rank").equals("F") ? " (Estimated completion percentage: " + df.format((n50 + n100 + n300 + miss) * 100.0 / beatmap.objects.size()) + "%)" : ""));
eb.setThumbnail("https://b.ppy.sh/thumb/" + jbm.getString("beatmapset_id") + ".jpg");
eb.addField("Score", String.valueOf(mostRecent.getInt("score")), true);
eb.addField("Accuracy", df.format(jbm.getDouble("accVal") * 100) + " (" + n300 + "/" + n100 + "/" + n50 + "/" + miss + ")", true);
eb.addField("Combo", (mostRecent.getInt("perfect") == 0 ? combo + "x / " + beatmap.max_combo() + "x" : "FC (" + combo + "x)"), true);
eb.addField("PP", df.format(jbm.getDouble("ppVal")) + "pp / " + df.format(jbm.getDouble("ppMax")) + "pp", true);
eb.setFooter("Score submitted", null);
eb.setTimestamp(OffsetDateTime.parse(mostRecent.getString("date").replace(" ", "T") + "+00:00"));
channel.sendMessageEmbeds(eb.build()).queue();
return new CommandResult(CommandResultType.SUCCESS);
}, "[-p] [username] [| index]", Arrays.asList("", "-p | 2", "-p despawningbone"),
"Check a user's recent play!", Arrays.asList(
" * Specifying the `-p` parameter will only search for plays that did not fail.",
" * Supports `-t` for taiko (Defaults to standard)."));
//DONE tidy up first part, merge with getTopPlays()?
registerSubCommand("ppchart", Arrays.asList("ppc"), (channel, user, msg, words) -> {
if(words.length < 30) {
List params = new ArrayList<>(Arrays.asList(words));
int modeId = getMode(params);
if(params.size() < 1) {
params.add(getPlayer(user));
}
//get all usernames
String[] split = String.join(" ", params).split("\\|");
String concName = String.join(" | ", split);
//init chart
XYChart chart = new XYChartBuilder().width(800).height(600).title("Top PP plays for " + concName + " (" + modes[modeId] + ")").yAxisTitle("PP").xAxisTitle("Plays (100 = top)").build();
chart.getStyler().setDefaultSeriesRenderStyle(XYSeriesRenderStyle.Scatter);
chart.getStyler().setLegendPosition(LegendPosition.InsideSE);
chart.getStyler().setPlotContentSize(0.91);
chart.getStyler().setMarkerSize(7);
chart.getStyler().setChartTitlePadding(12);
chart.getStyler().setChartPadding(12);
chart.getStyler().setChartBackgroundColor(new Color(200, 220, 230));
//plot each user's top plays onto chart
for(String name : split) {
List pps = new ArrayList<>();
try {
JSONObject res = getPlayerData("get_user_best", name.trim(), modeId, user);
if(!res.has("result"))
return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`.");
JSONArray array = res.getJSONArray("result");
for(int i = 0; i < array.length(); i++) {
JSONObject obj = array.getJSONObject(i);
pps.add(obj.getDouble("pp"));
}
} catch (IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
Collections.reverse(pps); //so it orders from low to high left to right
chart.addSeries(name.trim() + "'s PP", pps);
}
//write to png
ByteArrayOutputStream os = new ByteArrayOutputStream();
try {
BitmapEncoder.saveBitmap(chart, os, BitmapFormat.PNG);
} catch (IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
- channel.sendFile(new ByteArrayInputStream(os.toByteArray()), "ppchart.png").queue();
+ channel.sendFiles(FileUpload.fromData(os.toByteArray(), "ppchart.png")).queue();
return new CommandResult(CommandResultType.SUCCESS);
} else {
return new CommandResult(CommandResultType.INVALIDARGS, "Please do not include so many players at once.");
}
}, "[mode] [username] [| usernames]...", Arrays.asList("", "-t", "FlyingTuna | Rafis | despawningbone"),
"Get a graph with the users' top plays!", Arrays.asList(
" * You can specify up to 30 players at once, seperated by `|`.\n"
+ " * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard)."));
//TODO reaction based paging?
registerSubCommand("set", Arrays.asList("s"), (channel, user, msg, words) -> {
int page = 1;
if (words.length < 1) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please enter search words or an URL.");
}
String id = "", set = "";
try {
new URL(words[0]);
} catch (MalformedURLException e) {
//if not url, search
try {
String[] split = String.join(" ", words).split("\\|");
String search = URLEncoder.encode(split[0].trim(), "UTF-8");
try {
if(split.length > 1) page = Integer.parseInt(split[1].trim());
} catch (NumberFormatException e1) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a valid page number.");
}
String url = "https://osusearch.com/query/?title=" + search + "&query_order=play_count&offset=0";
URLConnection stream = new URL(url).openConnection();
stream.addRequestProperty("User-Agent", "Mozilla/4.0");
JSONTokener tokener = new JSONTokener(stream.getInputStream());
id = String.valueOf(new JSONObject(tokener).getJSONArray("beatmaps").getJSONObject(0).getInt("beatmapset_id"));
set = "https://osu.ppy.sh/beatmapset/" + id;
} catch (IOException e1) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1));
} catch (JSONException e1) {
return new CommandResult(CommandResultType.NORESULT);
}
}
//empty id means words[0] was a valid url - accept and parse
if(id.equals("")) {
set = words[0];
id = set.substring(set.lastIndexOf("/") + 1);
if (set.contains("/beatmapsets/")) {
id = set.substring(set.lastIndexOf("/beatmapsets/"));
id = id.substring(id.lastIndexOf("/") + 1);
} else { //old urls
if (set.contains("/b/") || set.contains("#")) {
return new CommandResult(CommandResultType.INVALIDARGS, "This is a specific beatmap, not a beatmap set.");
}
id = id.split("&")[0];
}
}
//get beatmap set info
InputStream stream = null;
try {
stream = new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&s=" + id).openStream();
} catch (IOException ex) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex));
}
JSONArray arr = new JSONArray(new JSONTokener(stream));
//convert it to a list of object for streaming
List obj = new ArrayList();
for (int i = 0; i < arr.length(); i++) {
obj.add(arr.getJSONObject(i));
}
//group by mode sorted by difficulty and flatten back to a list
List fobj = obj.stream().sorted(Comparator.comparing(e -> e.getDouble("difficultyrating")))
.collect(Collectors.groupingBy(e -> e.getInt("mode"))).values().stream().flatMap(List::stream)
.collect(Collectors.toList());
EmbedBuilder em = new EmbedBuilder();
JSONObject fo; //use first object's metadata for universal info like author
try {
fo = fobj.get(0);
} catch (IndexOutOfBoundsException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid beatmap URL.");
}
if(page > Math.ceil((double) fobj.size() / 3)) { //each page is 3 entries
return new CommandResult(CommandResultType.INVALIDARGS, "The beatmap set does not have this many difficulties!");
}
//build embed page heading
em.setAuthor("Beatmap set information of " + fo.getString("title"), set,
"https://b.ppy.sh/thumb/" + id + ".jpg");
em.setDescription("Author: " + fo.getString("artist"));
em.addField("BPM", fo.getString("bpm"), true);
em.addField("Creator", fo.getString("creator"), true);
em.addField("Approved/Ranked", (fo.getInt("approved") >= 1 ? "Yes" : "No"), true);
em.addBlankField(false);
//build each entry
int n = ((page - 1) * 3);
for (int i = n; (n + 3 <= fobj.size() ? i < n + 3 : i < fobj.size()); i++) {
JSONObject json = fobj.get(i);
em.addField("Difficulty", "[" + json.getString("version") + "](https://osu.ppy.sh/b/" + json.getString("beatmap_id") + ")" + " ([Preview](https://jmir.xyz/osu/#" + json.getString("beatmap_id") + "))", false); //bloodcat is no longer a thing but someone hosted a copy for previewing here
em.addField("Max combo", json.get("max_combo").toString().replaceAll("null", "N/A"), true);
em.addField("Stars", df.format(json.getDouble("difficultyrating")), true);
em.addField("Mode", modes[json.getInt("mode")], true);
}
//build footer and send
em.setFooter("Page: " + String.valueOf(page) + "/" + String.valueOf((int) Math.ceil((double) fobj.size() / 3)), null);
channel.sendMessageEmbeds(em.build()).queue();
return new CommandResult(CommandResultType.SUCCESS);
}, " [| page]\n", Arrays.asList("big black", "high free spirits | 2"),
"Check info about a beatmap set!", Arrays.asList(
" * Useful to get the specific difficulty URL for !desp osu pp."));
//TODO add userset for setting username to db so getPlayer wont be wrong?
registerSubCommand("user", Arrays.asList("u"), (channel, user, msg, words) -> {
List params = new ArrayList<>(Arrays.asList(words));
int modeId = getMode(params);
String search = String.join(" ", params);
//fetch user data
JSONObject usr;
try {
JSONObject res = getPlayerData("get_user", search, modeId, user);
if(!res.has("result"))
return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`.");
usr = res.getJSONArray("result").getJSONObject(0);
} catch (IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
String id = usr.getString("user_id"); //no matter if its url or not user_id is still gonna be the most accurate one
//build embed
EmbedBuilder em = new EmbedBuilder();
em.setAuthor("User info of " + usr.getString("username") + " (" + modes[modeId] + ")", "https://osu.ppy.sh/users/" + id);
//check if pfp exists, if not fallback to blank
String img = "https://a.ppy.sh/" + id;
try {
new URL(img).openStream();
} catch (IOException e) {
img = "https://s.ppy.sh/images/blank.jpg";
}
em.setThumbnail(img);
//if user doesnt have pp, there wont be any data pretty much - also might be a result of inactivity (?)
if(usr.isNull("pp_raw"))
return new CommandResult(CommandResultType.FAILURE, "This user has not played any map in " + modes[modeId] + " before.");
em.appendDescription("Joined " + usr.getString("join_date"));
em.addField("PP", usr.getDouble("pp_raw") == 0 ? "No Recent Plays" : usr.getString("pp_raw"), true);
em.addField("Accuracy", df.format(usr.getDouble("accuracy")), true);
em.addField("Rank", (usr.getInt("pp_rank") == 0 ? "N/A" : "#" + usr.getString("pp_rank")) +
" | " + MiscUtils.countryNameToUnicode(usr.getString("country")) + " #" + usr.getString("pp_country_rank"), true);
em.addField("Play count", usr.getString("playcount") +
" (" + df.format(Long.parseLong(usr.getString("total_seconds_played")) / 3600.0) + "h)", true);
em.addField("Total score", usr.getString("total_score"), true);
em.addField("Ranked score", usr.getString("ranked_score") +
" (" + df.format((usr.getDouble("ranked_score") / usr.getDouble("total_score")) * 100) + "%)", true);
em.addField("Level", df.format(usr.getDouble("level")), true);
em.addField("S+", usr.getString("count_rank_sh"), true);
em.addField("SS+", usr.getString("count_rank_ssh"), true);
em.addField("A", usr.getString("count_rank_a"), true);
em.addField("S", usr.getString("count_rank_s"), true);
em.addField("SS", usr.getString("count_rank_ss"), true);
em.setColor(new Color(66, 134, 244));
em.setFooter("300: " + usr.getString("count300") + " | 100: " + usr.getString("count100") + " | 50: " + usr.getString("count50"), null); //TODO display percentage?
channel.sendMessageEmbeds(em.build()).queue();
return new CommandResult(CommandResultType.SUCCESS);
}, "[gamemode] [user URL|keyword]", Arrays.asList("", "despawningbone", "-t despawningbone"),
"Check info about a user!", Arrays.asList(
" * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard)."));
//DONE api demanding; need to add cooldown //nvm i changed it to use XHR now
//parts mergeable with getRecent()
registerSubCommand("topplays", Arrays.asList("top", "t"), (channel, user, msg, words) -> {
List params = new ArrayList<>(Arrays.asList(words));
int modeId = getMode(params), page;
//get page number
String[] split = String.join(" ", params).split("\\|");
String search = split[0];
try {
page = Integer.parseInt(split[1].trim());
if(page > 10) {
return new CommandResult(CommandResultType.INVALIDARGS, "You can only request your top 100 plays!");
}
} catch (NumberFormatException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Invalid page number!");
} catch (ArrayIndexOutOfBoundsException e) {
page = 1; //its normal for people to not input index
}
//get user id then fetch top plays from XHR req
JSONArray array;
String name, id;
try {
JSONObject res = getPlayerData("get_user", search, modeId, user);
if(!res.has("result"))
return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`.");
JSONObject usr = res.getJSONArray("result").getJSONObject(0);
name = usr.getString("username");
id = usr.getString("user_id");
//top plays internal api use different names for modes than listed
String[] m = new String[]{"osu", "taiko", "fruits", "mania"};
InputStream stream = new URL("https://osu.ppy.sh/users/" + id + "/scores/best"
+ "?mode=" + m[modeId] + "&offset=" + (page - 1) * 10 + "&limit=10").openStream();
if (stream.available() < 4) {
return new CommandResult(CommandResultType.FAILURE, "This player does not have this many plays in this gamemode!");
}
array = new JSONArray(new JSONTokener(stream));
} catch (IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
//build embed
EmbedBuilder eb = new EmbedBuilder();
eb.setAuthor(name + "'s top plays (" + modes[modeId] + ")", "https://osu.ppy.sh/users/" + id, "https://a.ppy.sh/" + id);
DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
for(int i = 0; i < array.length(); i++) {
JSONObject obj = array.getJSONObject(i);
String mods = obj.getJSONArray("mods").join("").replaceAll("\"", "");
JSONObject stats = obj.getJSONObject("statistics");
JSONObject bInfo = obj.getJSONObject("beatmap");
JSONObject bsInfo = obj.getJSONObject("beatmapset");
int bId = bInfo.getInt("id");
String info = "**" + obj.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + "**"
+ (!mods.isEmpty() ? " **" + mods + "** " : " ")
+ df.format(bInfo.getDouble("difficulty_rating")) + "*"
+ " ([map link](https://osu.ppy.sh/beatmaps/" + bId + "))"
+ "\nScore: " + obj.getInt("score")
+ "\n**" + (obj.getDouble("pp") + "pp** | Weighted " + df.format(obj.getJSONObject("weight").getDouble("pp")) + "pp (" + df.format(obj.getJSONObject("weight").getDouble("percentage")) + "%)"
+ "\n" + df.format(obj.getDouble("accuracy") * 100) + "% " + (obj.getBoolean("perfect") ? "FC " + obj.getInt("max_combo") + "x" : obj.getInt("max_combo") + "x") + " (" + stats.getInt("count_300") + "/" + stats.getInt("count_100") + "/" + stats.getInt("count_50") + "/" + stats.getInt("count_miss") + ")")
+ "\nPlayed on " + format.format(DatatypeConverter.parseDateTime(obj.getString("created_at")).getTime());
eb.addField(((page - 1) * 10 + (i + 1)) + ". " + bsInfo.getString("artist") + " - " + bsInfo.getString("title") + " [" + bInfo.getString("version") + "]", info, false); //no unicode unfortunately*/
}
eb.setFooter("Page: " + page, null);
eb.setColor(new Color(5, 255, 162));
channel.sendMessageEmbeds(eb.build()).queue();
return new CommandResult(CommandResultType.SUCCESS);
}, "[username] [|page]", Arrays.asList("", "| 2", "despawningbone", "-m despawningbone"),
"Get the top plays of a user!", Arrays.asList(
" * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard)."));
registerSubCommand("compare", Arrays.asList("c", "comp"), (channel, user, msg, words) -> {
String map = "", name = "", img = "", mode = "";
//get which card to compare with, starting from newest to oldest
int index = 1;
String[] param = String.join(" ", words).split("\\|");
try {
if(param.length > 1) index = Integer.parseInt(param[1].trim());
} catch(NumberFormatException e) {
channel.sendMessage("Invalid index specified. Defaulting to the most recent scorecard...").queue();
}
//iterate through recent 100 messsages
for(Message card : channel.getHistory().retrievePast(100).complete()) {
List embeds = card.getEmbeds();
if(embeds.size() > 0) {
MessageEmbed embed = embeds.get(0);
if(embed.getAuthor() == null) continue;
String author = embed.getAuthor().getName();
//our own cards
if(card.getAuthor().getId().equals(DiscordBot.BotID)) {
//recent
if(author.contains("most recent osu!")) {
map = embed.getUrl();
name = embed.getTitle().lastIndexOf("+") == -1 ? embed.getTitle().substring(0, embed.getTitle().lastIndexOf("(")) : embed.getTitle().substring(0, embed.getTitle().lastIndexOf("+"));
img = embed.getThumbnail().getUrl();
mode = author.split("most recent osu!")[1].split(" play")[0];
//pp
} else if(author.startsWith("PP information for")) {
map = embed.getAuthor().getUrl();
name = author.split("PP information for ")[1] + " [" +embed.getFields().get(3).getValue() + "]";
img = embed.getAuthor().getIconUrl();
mode = embed.getDescription().replace("Mode: osu!", "");
}
//owobot support
} else if(card.getAuthor().getId().equals("289066747443675143")) {
//new score tracker
if(author.startsWith("New") && author.contains("in osu!")) {
String markdown = embed.getDescription().substring(7, embed.getDescription().indexOf("\n")).trim();
map = markdown.substring(markdown.lastIndexOf("(") + 1, markdown.length() - 2);
name = markdown.substring(0, markdown.indexOf("__**]"));
img = embed.getThumbnail().getUrl();
mode = author.substring(author.lastIndexOf("in osu! "), author.length()).trim();
//>rs
} else if(card.getContentDisplay().contains("Recent ")) {
map = embed.getAuthor().getUrl();
name = author.substring(0, author.lastIndexOf("+"));
img = embed.getThumbnail().getUrl();
mode = card.getContentDisplay().contains("Mania") ? "mania" : card.getContentDisplay().split("Recent ")[1].split(" ")[0]; //ye fucking blame owobot for being so inconsistent
//map url response
} else if(card.getContentDisplay().contains("map(s).")) {
map = embed.getAuthor().getUrl();
map = map.substring(0, map.length() - (map.endsWith("/") ? 1 : 0)); //fucking hell the url actually changes according to how ppl trigger it
name = author.split(" – ")[1];
String[] split = embed.getFields().get(0).getName().split("__", 3);
name = name.substring(0, name.lastIndexOf(" by ")) + " [" + split[1] + "]";
img = "https://b.ppy.sh/thumb/" + embed.getDescription().split("\\[map\\]\\(https:\\/\\/osu\\.ppy\\.sh\\/d\\/", 2)[1].split("\\)", 2)[0] + ".jpg";
mode = split[2].isEmpty() ? "std" : split[2].substring(2, split[2].lastIndexOf("]") + 1);
}
}
//if card hit check index and decrement + reset if neded
if(index > 1 && !map.isEmpty()) {
index--;
map = "";
}
//short circuit if found
if(!map.isEmpty()) break;
}
}
//yet another set of mode names so we have to parse
int modeId = 0;
if(mode.equalsIgnoreCase("taiko")) modeId = 1;
else if(mode.equalsIgnoreCase("catch") || mode.equals("ctb")) modeId = 2;
else if(mode.equalsIgnoreCase("mania")) modeId = 3;
//if still doesnt exist after iteration, fail
if(map.isEmpty()) return new CommandResult(CommandResultType.FAILURE, "There are no recent map/score cards to compare with in the past 100 messages!");
//if name or id is provided in the command use it else fallback to requester
String uid = param[0].isEmpty() ? getPlayer(user) : param[0].replaceAll(" ", "%20");
//get scores to compare
try {
URL url = new URL("https://osu.ppy.sh/api/get_scores?k=" + osuAPI + "&b=" + map.substring(map.lastIndexOf("/") + 1) + "&u=" + uid + "&m=" + modeId);
JSONArray arr = new JSONArray(new JSONTokener(url.openStream()));
//build embed
EmbedBuilder eb = new EmbedBuilder();
eb.setTitle("Top plays for " + arr.getJSONObject(0).getString("username") + " on " + name);
eb.setColor(new Color(237, 154, 70));
eb.setThumbnail(img);
//iterate through existing scores for this beatmap and add it to the embed
for(int i = 0; i < arr.length(); i++) {
JSONObject score = arr.getJSONObject(i);
String mods = Koohii.mods_str(score.getInt("enabled_mods"));
Accuracy acc = new Accuracy(score.getInt("count300"), score.getInt("count100"), score.getInt("count50"), score.getInt("countmiss"));
String info = "**`" + (mods.isEmpty() ? "No mods" : mods) + "` Score:\n"
+ score.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + " " + (score.isNull("pp") ? "No " : score.getDouble("pp")) + "pp** | Score **" + score.getInt("score") + "**\n"
+ df.format(acc.value(modeId) * 100) + "% " + (score.getInt("perfect") == 1 ? "FC " + score.getInt("maxcombo") + "x" : score.getInt("maxcombo") + "x") + " (" + acc.n300 + "/" + acc.n100 + "/" + acc.n50 + "/" + acc.nmisses + ")\n"
+ "Played on " + score.getString("date") + (score.getInt("replay_available") == 1 ? " (Replay available)" : "");
eb.appendDescription(info + "\n\n");
}
channel.sendMessageEmbeds(eb.build()).queue();
return new CommandResult(CommandResultType.SUCCESS);
} catch (IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
} catch (JSONException e) {
e.printStackTrace();
return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + uid + "` or the player has no scores on this map.");
}
}, "[username] [| index]", null, "Compare your score with the most recent map/score card in the channel!", Arrays.asList(" * Specify the index to skip to the nth recent score card in the channel."));
}
private static double weightForPP(double pp, String ibid, JSONArray userBest) {
double net = 0, change = -1;
List pps = new ArrayList<>();
//get new top pp list, replacing old score if needed
for(int i = 0; i < userBest.length(); i++) { //something like a treemap might work more elegantly, but i need to add pp to the list anyways so its fine i guess
JSONObject obj = userBest.getJSONObject(i);
double v = obj.getDouble("pp");
String bid = obj.getString("beatmap_id");
if(bid.equals(ibid)) {
if(v >= pp) {
return -1;
} else {
change = v;
}
}
if(v <= pp && !pps.contains(pp)) { //insert on first smaller occurence
pps.add(pp);
}
pps.add(v);
}
//doesnt even get on top list - no pp gain
if(pps.indexOf(pp) == -1) {
return 0;
}
//calculate net weighted gain
if(change == -1) {
double last = pps.size() > 100 ? pps.remove(100) * (Math.pow(0.95, 99)): 0;
for(int i = pps.indexOf(pp); i < pps.size(); i++) {
double c = pps.get(i);
double w = c*(Math.pow(0.95, i));
if(i == pps.indexOf(pp)) {
net += w;
} else {
net += c*(Math.pow(0.95, i - 1)) * (0.95 - 1);
}
}
net -= last; //because the last one is completely not counted, not just shifted to a lower value
} else {
int old = pps.indexOf(change) - 1;
pps.remove(change);
for(int i = pps.indexOf(pp); i <= old; i++) {
double c = pps.get(i);
double w = c*(Math.pow(0.95, i));
if(c == pp) {
net += w - change*(Math.pow(0.95, old));
} else {
net += w * (0.95 - 1);
}
}
}
return net;
}
//note: stripped ids are considered usernames - if you are sure that its an id (eg result from api) then wrap it in an url
private static JSONObject getPlayerData(String api, String input, int modeId, User requester) throws IOException {
//check if user url provided, if not assume username
String id = "";
boolean noturl = false;
try {
new URL(input);
id = input.substring(input.lastIndexOf("/") + 1);
} catch (MalformedURLException e) {
noturl = true;
id = input;
}
//if empty fallback to own
if(id.isEmpty()) {
id = getPlayer(requester);
}
//set params and fetch
String addparam = "";
if (noturl) { //search as usernames
addparam = "&type=string";
}
if (modeId != 0) { //change mode
addparam += ("&m=" + modeId);
}
//limit either gets ignored or truncated to highest - is fine if i leave it as 100 (highest of any apis i use)
JSONArray a = new JSONArray(new JSONTokener(new URL("https://osu.ppy.sh/api/" + api + "?k=" + osuAPI + "&u=" + URLEncoder.encode(id, "UTF-8") + addparam + "&limit=100").openStream()));
//always return the val we did the search with, and only result if it exists
JSONObject wrap = new JSONObject("{\"search\": \"" + id + "\", \"isId\": " + !noturl + "}");
if (a.length() > 0) {
wrap.put("result", a);
}
return wrap;
}
//TODO find a way to parallelize map fetching and meta fetching if possible?
private static JSONObject computePP(Map beatmap, String bid, int mods, int n50, int n100, int n300, int miss, int combo) throws IllegalArgumentException, IOException {
//set osu-wide beatmap params (non mode specific)
PlayParameters p = new Koohii.PlayParameters();
p.beatmap = beatmap;
p.n50 = n50;
p.n100 = n100;
p.n300 = n300;
p.nmiss = miss;
p.nobjects = p.n300 + p.n100 + p.n50 + p.nmiss;
p.mods = mods;
if (combo > 0) {
p.combo = combo;
} else {
p.combo = p.beatmap.max_combo();
}
//get beatmap metadata
String addparam = "";
if(p.beatmap.mode == 1) { //these mods alter metadata that taiko needs
if((mods & Koohii.MODS_HT) != 0) addparam = "&mods=256";
if((mods & (Koohii.MODS_DT | Koohii.MODS_NC)) != 0) if(addparam.isEmpty()) addparam = "&mods=64";
else addparam = "";
}
URL url = new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&b=" + bid + addparam);
JSONObject jbm = new JSONArray(new JSONTokener(url.openStream())).getJSONObject(0);
//finally calc pp and put it in metadata
MapStats stats = new MapStats(p.beatmap);
switch(p.beatmap.mode) {
case 0: //osu
//std specific params - calc diff and acc
DiffCalc stars = new Koohii.DiffCalc().calc(p.beatmap, mods);
p.speed_stars = stars.speed;
p.aim_stars = stars.aim;
PPv2 oPP = new PPv2(p);
jbm.put("ppVal", oPP.total);
jbm.put("accVal", oPP.computed_accuracy.value(0)); //use computed
jbm.put("starsVal", stars.total);
jbm.put("ppMax", constructPP(stars.aim, stars.speed, p.beatmap, mods).total);
Koohii.mods_apply(mods, stats, 1|2|4|8);
break;
case 1: //taiko
//taiko specific params - get diff from metadata since theres no calculating mechanism
p.speed_stars = jbm.getDouble("difficultyrating");
TaikoPP tPP = new TaikoPP(p);
jbm.put("ppVal", tPP.pp);
jbm.put("accVal", tPP.acc);
jbm.put("starsVal", p.speed_stars);
jbm.put("ppMax", new TaikoPP(p.speed_stars, p.max_combo, mods, 1, 0, p.beatmap).pp);
tPP.taiko_mods_apply(mods, stats);
break;
default:
throw new UnsupportedOperationException("This gamemode is not yet supported.");
}
//mods_apply updates the values, so we cant use beatmap.cs etc and we gotta give back via jbm
jbm.put("cs", stats.cs);
jbm.put("hp", stats.hp);
jbm.put("ar", stats.ar);
jbm.put("od", stats.od);
jbm.put("speed", stats.speed);
return jbm;
}
private static PPv2 constructPP(double aim_stars, double speed_stars, Map b, int mods) {
PlayParameters p = new PlayParameters();
p.aim_stars = aim_stars;
p.speed_stars = speed_stars;
p.beatmap = b;
p.nobjects = b.objects.size();
p.mods = mods;
return new PPv2(p);
}
private static String getPlayer(User user) { //say im getting from presence/name?
try (Connection con = DiscordBot.db.getConnection()){
ResultSet rs = con.createStatement().executeQuery("SELECT game FROM users WHERE id = " + user.getId() + ";");
String player = rs.next() ? player = rs.getString(1).substring(0, rs.getString(1).indexOf("||")) : user.getName();
rs.close();
return player;
} catch (SQLException e) {
return user.getName();
}
}
private static int getMode(List params) {
int modeId = 0;
params.replaceAll(String::toLowerCase); //all searching should be case insensitive anyways, is fine if we change the entire thing
//pass by "ref" so it removes from the list;
//iterates over all mode params and remove, giving priority according to descending id and defaulting to standard (0)
if(params.removeAll(Collections.singleton("-t")))
modeId = 1;
if(params.removeAll(Collections.singleton("-c")))
modeId = 2;
if(params.removeAll(Collections.singleton("-m")))
modeId = 3;
return modeId;
}
private static InputStream getMap(String origURL) throws IOException {
//get beatmap id
String id = origURL.substring(origURL.lastIndexOf("/") + 1);
if (origURL.contains("/beatmapsets/")) { //new urls with this format must include # to specify beatmap, or else its a set
if (!origURL.contains("#")) throw new IllegalArgumentException("Please specify a difficulty, instead of giving a set URL.");
} else { //either /beatmap/ or old url /b/ /s/, latter of which we dont want
if (origURL.contains("/s/")) throw new IllegalArgumentException("Please specify a difficulty, instead of giving a set URL.");
id = id.split("&")[0]; //remove things like &m=0, which is no need
}
//fetch map
URLConnection mapURL;
try {
mapURL = new URL("https://osu.ppy.sh/osu/" + id).openConnection(); //no longer has any IOExceptions, always return 200 no matter what
mapURL.setRequestProperty("User-Agent", "Mozilla/4.0");
InputStream stream = mapURL.getInputStream();
if (stream.available() < 4) throw new IOException(); //to fallback
return stream;
} catch (IOException e) { //try fallback regardless of exception reason
//fallback to bloodcat successor - much more convoluted and slower to fetch a single map now
//get map info
mapURL = new URL("https://api.chimu.moe/v1/map/" + id).openConnection();
mapURL.setRequestProperty("User-Agent", "Mozilla/4.0");
JSONObject mapData = new JSONObject(new JSONTokener(mapURL.getInputStream())).getJSONObject("data");
if(mapData.length() == 0) throw new IOException(e); //invalid beatmap
String mapName = mapData.getString("OsuFile").replaceAll("[\\\\/:*?\"<>|]", ""); //apparently the name returned isnt windows safe, so sanitize
//fetch beatmapset
mapURL = new URL("https://chimu.moe" + mapData.getString("DownloadPath")).openConnection();
mapURL.setRequestProperty("User-Agent", "Mozilla/4.0");
ZipInputStream set = new ZipInputStream(mapURL.getInputStream());
//iterate to get correct map
ZipEntry map = null;
while((map = set.getNextEntry()) != null) //assume always have the file specified in map info
if(map.getName().equals(mapName))
return set; //found at input stream current pointer
throw new IOException(e); //not catching this, if even this doesnt work just throw something went wrong.
}
}
}
diff --git a/src/me/despawningbone/discordbot/command/info/Calculator.java b/src/me/despawningbone/discordbot/command/info/Calculator.java
index e685355..d92bb42 100644
--- a/src/me/despawningbone/discordbot/command/info/Calculator.java
+++ b/src/me/despawningbone/discordbot/command/info/Calculator.java
@@ -1,230 +1,230 @@
package me.despawningbone.discordbot.command.info;
import java.awt.Color;
import java.awt.Graphics;
import java.awt.image.BufferedImage;
-import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.EmptyStackException;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import javax.imageio.ImageIO;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.commons.math3.special.Gamma;
import org.json.JSONObject;
import org.json.JSONTokener;
import com.neovisionaries.ws.client.WebSocket;
import com.neovisionaries.ws.client.WebSocketAdapter;
import com.neovisionaries.ws.client.WebSocketException;
import com.neovisionaries.ws.client.WebSocketFactory;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
+import net.dv8tion.jda.api.utils.FileUpload;
import net.dv8tion.jda.api.entities.User;
import net.objecthunter.exp4j.Expression;
import net.objecthunter.exp4j.ExpressionBuilder;
import net.objecthunter.exp4j.function.Function;
import net.objecthunter.exp4j.operator.Operator;
public class Calculator extends Command {
public Calculator() {
this.alias = Arrays.asList("calc");
this.desc = "Do some calculations!";
this.usage = "[-w] ";
this.remarks = Arrays.asList("This calculator is using exp4j.",
"You can get the built in operators and functions at:", "http://projects.congrace.de/exp4j/",
"`!` - factorials", "`logx(base, num)` - logarithm (base x)", "are supported too.",
"\nYou can also query [Wolfram Alpha](https://www.wolframalpha.com/) using the `-w` switch.");
this.examples = Arrays.asList("3*4-2", "4 * (sin(3 - 5)) + 5!", "log(e) + logx(10, 100)", " -w laurent series log(x) about x=0");
}
static final double planckConstant = 6.62607004 * Math.pow(10, -34), eulerMascheroni = 0.57721566490153286060651209008240243104215933593992;
static final Function logb = new Function("logx", 2) {
@Override
public double apply(double... args) {
return Math.log(args[1]) / Math.log(args[0]);
}
};
static final Function digamma = new Function("digamma", 1) {
@Override
public double apply(double... args) {
return Gamma.digamma(args[0]);
}
};
static final Operator factorial = new Operator("!", 2, true, Operator.PRECEDENCE_POWER + 1) {
@Override
public double apply(double... args) {
final long arg = (long) args[0];
if ((double) arg != args[0]) {
throw new IllegalArgumentException("Operand for factorial has to be an integer");
}
if (arg < 0) {
throw new IllegalArgumentException("The operand of the factorial can not " + "be " + "less than zero");
}
double result = 1;
for (int i = 1; i <= arg; i++) {
result *= i;
}
return result;
}
};
@Override
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) {
List params = new ArrayList<>(Arrays.asList(args));
if(params.removeAll(Collections.singleton("-w"))) {
return queryWolfram(String.join(" ", params), channel);
} else {
if (args.length < 1) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please enter an operation.");
}
String splitted = String.join(" ", args);
//easter eggs
if (msg.getContentDisplay().toLowerCase().contains("the meaning of life, the universe, and everything")) { // easter egg
channel.sendMessage("The answer is: `42`").queue();
return new CommandResult(CommandResultType.SUCCESS, "Executed easter egg");
}
if (splitted.equals("9+10") || splitted.equals("9 + 10")) { // easter egg
channel.sendMessage("The answer is: `21`\n\n *i am smart ;)*").queue();
return new CommandResult(CommandResultType.SUCCESS, "Executed easter egg");
}
if (splitted.equals("666") || splitted.equals("333")) { // easter egg
channel.sendMessage("No you don't").queue();
return new CommandResult(CommandResultType.SUCCESS, "Executed easter egg");
}
//patch for factorial formatting
String operation = String.join("", args);
if (operation.contains("!")) {
int index = operation.indexOf("!");
while (index >= 0) { // indexOf returns -1 if no match found
operation = new StringBuilder(operation).insert(index + 1, "(1)").toString();
index = operation.indexOf("!", index + 1);
}
}
// DecimalFormat format = new DecimalFormat();
// format.setDecimalSeparatorAlwaysShown(false);
// System.out.println(operation);
String ans = null;
try {
Expression e = new ExpressionBuilder(operation).variable("h").variable("γ").function(digamma).function(logb).operator(factorial).build()
.setVariable("h", planckConstant).setVariable("γ", eulerMascheroni);
ans = Double.toString(e.evaluate());
// String ans = format.format(e.evaluate());
} catch (EmptyStackException e1) {
return new CommandResult(CommandResultType.INVALIDARGS, "You have imbalanced parentheses.");
} catch (ArithmeticException e1) {
return new CommandResult(CommandResultType.INVALIDARGS, "You cannot divide by zero.");
} catch (IllegalArgumentException e1) {
return new CommandResult(CommandResultType.FAILURE, "An error has occured: " + e1.getMessage());
}
if (ans.equals("NaN")) {
ans = "Undefined";
}
channel.sendMessage("The answer is: `" + ans + "`").queue();
return new CommandResult(CommandResultType.SUCCESS);
}
}
public CommandResult queryWolfram(String operation, TextChannel channel) {
channel.sendTyping().queue();
try {
CompletableFuture result = new CompletableFuture<>();
WebSocket socket = new WebSocketFactory().createSocket("wss://www.wolframalpha.com/n/v1/api/fetcher/results");
ArrayList pos = new ArrayList<>(); //prevent duplicate
socket.addListener(new WebSocketAdapter() {
@Override
public void onTextMessage(WebSocket websocket, String message) {
try {
//System.out.println(message);
JSONObject resp = new JSONObject(new JSONTokener(message));
switch(resp.getString("type")) {
case "pods":
for(Object obj : resp.getJSONArray("pods")) { //pods might have multiple values
JSONObject pod = (JSONObject) obj;
if(pod.getBoolean("error")) continue; //skip errors
if(!pos.contains(pod.getInt("position"))) { //check dupe
//build big image for each pods
ArrayList images = new ArrayList<>();
int width = 0, height = 0;
if(!pod.has("subpods")) continue; //ignore empty pods that doesnt have image, usually fixed somewhere else
for(Object subobj : pod.getJSONArray("subpods")) {
JSONObject subpodImg = ((JSONObject) subobj).getJSONObject("img");
images.add(ImageIO.read(new URL(subpodImg.getString("src"))));
if(subpodImg.getInt("width") > width) width = subpodImg.getInt("width"); //get widest image and use it as final width
height += subpodImg.getInt("height"); //add all images
}
//create final image
BufferedImage podImg = new BufferedImage(width + 20, height + 20, BufferedImage.TYPE_INT_RGB); //padding
Graphics g = podImg.getGraphics();
g.setColor(new Color(255, 255, 255)); //fill as white first
g.fillRect(0, 0, width + 20, height + 20);
int y = 10;
for(BufferedImage img : images) {
g.drawImage(img, 10, y, null);
y += img.getHeight();
}
//send each pod as an individual message
ByteArrayOutputStream os = new ByteArrayOutputStream();
ImageIO.write(podImg, "png", os);
channel.sendMessage(pod.getString("title") + ":")
- .addFile(new ByteArrayInputStream(os.toByteArray()), "result.png").queue();
+ .addFiles(FileUpload.fromData(os.toByteArray(), "result.png")).queue();
pos.add(pod.getInt("position")); //update to prevent dupes
}
}
break;
case "futureTopic":
result.complete(new CommandResult(CommandResultType.FAILURE, "This query was identified under the topic of `" + resp.getJSONObject("futureTopic").getString("topic") + "`; Development of this topic is under investigation..."));
break;
case "didyoumean":
result.complete(new CommandResult(CommandResultType.INVALIDARGS, "No good results found :cry:\nDid you mean `" + resp.getJSONArray("didyoumean").getJSONObject(0).getString("val") + "`?"));
break;
case "noResult":
result.complete(new CommandResult(CommandResultType.NORESULT));
break;
case "queryComplete": //might provide no output for certain queries, but theres no practical way to detect with this listener rn
websocket.disconnect();
result.complete(new CommandResult(CommandResultType.SUCCESS)); //if its preceded by anything it wouldnt update; thats how complete() works
break;
}
} catch(Exception e) {
result.complete(new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)));
}
}
});
//System.out.println("{\"type\":\"init\",\"lang\":\"en\",\"exp\":" + System.currentTimeMillis() + ",\"displayDebuggingInfo\":false,\"messages\":[{\"type\":\"newQuery\",\"locationId\":\"hipuj\",\"language\":\"en\",\"displayDebuggingInfo\":false,\"yellowIsError\":false,\"input\":\"" + operation + "\",\"assumption\":[],\"file\":null}],\"input\":\"" + operation + "\",\"assumption\":[],\"file\":null}");
socket.connect();
socket.sendText("{\"type\":\"init\",\"lang\":\"en\",\"exp\":" + System.currentTimeMillis() + ",\"displayDebuggingInfo\":false,\"messages\":[{\"type\":\"newQuery\",\"locationId\":\"hipuj\",\"language\":\"en\",\"displayDebuggingInfo\":false,\"yellowIsError\":false,\"input\":\"" + operation + "\",\"assumption\":[],\"file\":null}],\"input\":\"" + operation + "\",\"assumption\":[],\"file\":null}");
return result.get();
} catch (IOException | WebSocketException | InterruptedException | ExecutionException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
}
}
diff --git a/src/me/despawningbone/discordbot/command/info/CityInfo.java b/src/me/despawningbone/discordbot/command/info/CityInfo.java
index db8ba2e..3f66324 100644
--- a/src/me/despawningbone/discordbot/command/info/CityInfo.java
+++ b/src/me/despawningbone/discordbot/command/info/CityInfo.java
@@ -1,149 +1,149 @@
package me.despawningbone.discordbot.command.info;
import java.awt.Color;
import java.io.IOException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;
import org.jsoup.Connection.Response;
import org.jsoup.Jsoup;
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import me.despawningbone.discordbot.utils.MiscUtils;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.MessageEmbed;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.exceptions.InsufficientPermissionException;
public class CityInfo extends Command {
public CityInfo() {
this.alias = Arrays.asList("ci", "weather");
this.desc = "Search for info about a city!"; //"Search for info about the city the address is in!";
this.usage = "";
this.examples = Arrays.asList("hong kong", "tokyo"); //"HK", "akihabara");
}
NumberFormat formatter = new DecimalFormat("#0.00");
@Override
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) {
if(args.length < 1) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please input a city name."); //or a address.");
} else {
channel.sendTyping().queue();
String sword = String.join(" ", args);
try {
boolean hasWeather = true;
JSONObject info = null;
String wQualifiedName = "", woeid = "", lng = "", lat = "", region = "", countryShort = ""; TimeZone timezone = null;
try {
//www.yahoo.com changed its endpoint - there's no longer a AJAX API for weather info, and the search autocomplete is basically just a prefix search which is way inferior; so we use ca.news.yahoo.com instead
URLConnection sCon = new URL("https://ca.news.yahoo.com/_td/api/resource/WeatherSearch;text=" + URLEncoder.encode(sword, "UTF-8") + "?returnMeta=true").openConnection();
sCon.addRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:73.0) Gecko/20100101 Firefox/73.0");
JSONObject wsearch = new JSONObject(new JSONTokener(sCon.getInputStream())).getJSONArray("data").getJSONObject(0);
woeid = String.valueOf(wsearch.getInt("woeid")); //yahoo scrape
lat = String.valueOf(wsearch.getDouble("lat"));
lng = String.valueOf(wsearch.getDouble("lon"));
wQualifiedName = wsearch.getString("qualifiedName");
countryShort = wQualifiedName.substring(wQualifiedName.lastIndexOf(",") + 1); //the display name from yahoo is not always consistent with Java's Locale display name, so we extract from qualified name instead
region = wQualifiedName.split(",")[wQualifiedName.split(",").length - 2]; //get second highest level, highest should always be country code
timezone = TimeZone.getTimeZone(new JSONObject(new JSONTokener(new URL("https://api.internal.teleport.org/api/locations/" + lat + "," + lng + "/?embed=location:nearest-cities/location:nearest-city/city:timezone").openStream())).getJSONObject("_embedded").getJSONArray("location:nearest-cities").getJSONObject(0).getJSONObject("_embedded").getJSONObject("location:nearest-city").getJSONObject("_embedded").getJSONObject("city:timezone").getString("iana_name"));
//can use metaweather, but not accurate enough
//can also broaden the scope for yahoo scrape for it to work better
//JSONObject wsearch = new JSONObject(new JSONTokener(new URL("https://api.flickr.com/services/rest/?method=flickr.places.findByLatLon&api_key=bdaafeafab62267931d920dda27a4f90&lat=" + lat + "&lon=" + lng + "&format=json&nojsoncallback=1").openStream())).getJSONObject("places").getJSONArray("place").getJSONObject(0); //gonna use flickr find instead
//get cookies and crumb for WeatherService
Response mainCon = Jsoup.connect("https://ca.news.yahoo.com/weather").execute();
String mainPage = mainCon.body();
int mainData = mainPage.indexOf("root.App.main = ");
String crumb = new JSONObject(mainPage.substring(mainData + 16, mainPage.indexOf(";\n", mainData))).getJSONObject("context").getJSONObject("dispatcher").getJSONObject("stores").getJSONObject("WeatherStore").getString("crumb");
info = new JSONObject(new JSONTokener(
Jsoup.connect("https://ca.news.yahoo.com/_td/api/resource/WeatherService;crumb=" + URLEncoder.encode(crumb, "UTF-8") + ";woeids=[" + woeid + "]?lang=en-US&returnMeta=true")
.cookies(mainCon.cookies())
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:73.0) Gecko/20100101 Firefox/73.0")
.ignoreContentType(true)
.execute().bodyStream()
)).getJSONObject("data").getJSONArray("weathers").getJSONObject(0);
hasWeather = info.getJSONObject("observation").getJSONObject("temperature").length() != 0;
} catch(IOException e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
} catch(JSONException e) {
e.printStackTrace();
return new CommandResult(CommandResultType.NORESULT);
//hasWeather = false;
}
Date date = new Date();
//System.out.println(info);
EmbedBuilder embedmsg = new EmbedBuilder();
embedmsg.setAuthor("Info for " + wQualifiedName, null, null);
embedmsg.setColor(new Color(100, 0, 255));
embedmsg.addField("Country", MiscUtils.countryNameToUnicode(countryShort), true);
embedmsg.addField("Region", region, true);
embedmsg.addField("Current time", OffsetDateTime.now(timezone.toZoneId()).format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ssa").withLocale(Locale.ENGLISH)).trim(), true);
long hours = (timezone.getOffset(date.getTime())/1000/60/60);
embedmsg.addField("Timezone" , timezone.getDisplayName(timezone.inDaylightTime(date), TimeZone.LONG, Locale.ENGLISH) + " (UTC" + (Math.signum(hours) == 1 || Math.signum(hours) == 0 ? "+" + hours : hours) + ")" + "\u1160", true); //FLICKR DED
String footer = "Weather info not available";
if (hasWeather) { //use another api if weather info not available?
JSONObject obs = info.getJSONObject("observation"); JSONObject temp = obs.getJSONObject("temperature"); JSONObject forecast = info.getJSONObject("forecasts").getJSONArray("daily").getJSONObject(0);
if(temp.has("now")) {
embedmsg.addField("Temperature", fToC(temp.getInt("now")) + "°C (↑" + fToC(temp.getInt("high")) + "°C | ↓" + fToC(temp.getInt("low")) + "°C)", true);
embedmsg.addField("Humidity", obs.getInt("humidity") + "% (Chance of rain: " + forecast.getInt("precipitationProbability") + "%)", true);
embedmsg.addField("Visibility", miToKm(obs.getDouble("visibility")) + "km (" + obs.getString("conditionDescription") + ")", true);
embedmsg.addField("Atmospheric pressure", formatter.format(obs.getDouble("barometricPressure") / 0.029530) + "millibars", true);
embedmsg.addField("Wind speed", miToKm(obs.getDouble("windSpeed")) + "km/h", true);
embedmsg.addField("Wind direction", obs.getInt("windDirection") + "° (" + obs.getString("windDirectionCode") + ")", true);
embedmsg.addField("Feels Like", fToC(temp.getInt("feelsLike")) + "°C", true);
embedmsg.addField("UV index", obs.getInt("uvIndex") + " (" + obs.getString("uvDescription") + ")", true);
embedmsg.addField("Sunrise", MiscUtils.convertMillis(info.getJSONObject("sunAndMoon").getLong("sunrise") * 1000).substring(0, 5), true);
embedmsg.addField("Sunset", MiscUtils.convertMillis(info.getJSONObject("sunAndMoon").getLong("sunset") * 1000).substring(0, 5), true);
String imgUrl = info.getJSONArray("photos").getJSONObject(0).getJSONArray("resolutions").getJSONObject(0).getString("url"); //seems to have dead urls, how fix
embedmsg.setThumbnail(imgUrl.split(":\\/\\/").length > 2 ? "https://" + imgUrl.split(":\\/\\/")[2] : imgUrl);
footer = "Weather info last updated: " + OffsetDateTime.parse(obs.getJSONObject("observationTime").getString("timestamp")).format(DateTimeFormatter.RFC_1123_DATE_TIME)
.replace("GMT", timezone.getDisplayName(timezone.inDaylightTime(date), TimeZone.SHORT, Locale.ENGLISH)); //+ " | " + wQualifiedName;
//add weather provider to footer?
}
}
embedmsg.addField("Latitude", lat, true);
embedmsg.addField("Longitude", lng, true);
embedmsg.setFooter(footer, null);
try {
MessageEmbed fmsg = embedmsg.build();
channel.sendMessageEmbeds(fmsg).queue();
} catch(InsufficientPermissionException e2) {
return new CommandResult(CommandResultType.FAILURE, "Unfortunately, the bot is missing the permission `MESSAGE_EMBED_LINKS` which is required for this command to work.");
}
return new CommandResult(CommandResultType.SUCCESS);
} catch (Exception e) {
return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e));
}
}
}
private String fToC(int f) { //fucking no metric ree
return formatter.format((f-32)*5.0/9);
}
private String miToKm(double mile) {
return formatter.format(mile*1.609344);
}
}
diff --git a/src/me/despawningbone/discordbot/command/info/CurrencyConvert.java b/src/me/despawningbone/discordbot/command/info/CurrencyConvert.java
index b2a6e52..39169b6 100644
--- a/src/me/despawningbone/discordbot/command/info/CurrencyConvert.java
+++ b/src/me/despawningbone/discordbot/command/info/CurrencyConvert.java
@@ -1,82 +1,82 @@
package me.despawningbone.discordbot.command.info;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.text.DecimalFormat;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.json.JSONException;
import org.json.JSONObject;
import org.json.JSONTokener;
/*import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;*/
import me.despawningbone.discordbot.command.Command;
import me.despawningbone.discordbot.command.CommandResult;
import me.despawningbone.discordbot.command.CommandResult.CommandResultType;
import net.dv8tion.jda.api.entities.Message;
-import net.dv8tion.jda.api.entities.TextChannel;
+import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.User;
public class CurrencyConvert extends Command {
public CurrencyConvert() {
this.alias = Arrays.asList("currency", "cc");
this.desc = "convert a currency to another!";
this.usage = " [amount]";
this.remarks = Arrays.asList("The currency units use the format of ISO 4217. For the list, see below:", "https://en.wikipedia.org/wiki/ISO_4217");
this.examples = Arrays.asList("HKD USD", "EUR SGD 100");
}
@Override
public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) {
String fromc = null;
String toc = null;
String num = null;
try {
fromc = args[0].toUpperCase();
toc = args[1].toUpperCase();
} catch (ArrayIndexOutOfBoundsException e) {
return new CommandResult(CommandResultType.INVALIDARGS, "Please enter 2 currencies.");
}
try { //there is a lot more info, use them too?
InputStream stream = new URL("https://query1.finance.yahoo.com/v8/finance/chart/"+ fromc + toc + "=X?range=6h&includePrePost=false&interval=1m").openStream();
JSONObject main = new JSONObject(new JSONTokener(stream)).getJSONObject("chart").getJSONArray("result").getJSONObject(0);
double rate = 0;
try {
List