diff --git a/.classpath b/.classpath new file mode 100644 index 0000000..149cb3c --- /dev/null +++ b/.classpath @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13f2d81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +bin/ +target/ +logs/ +.metadata/ +tokens.properties +testAnimePicCache.bin +data.db +hs_err_pid*.log diff --git a/.project b/.project new file mode 100644 index 0000000..b4a837b --- /dev/null +++ b/.project @@ -0,0 +1,23 @@ + + + Bot + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.m2e.core.maven2Nature + org.eclipse.jdt.core.javanature + + diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 0000000..a65ef9a --- /dev/null +++ b/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,108 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.annotation.inheritNullAnnotations=disabled +org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore +org.eclipse.jdt.core.compiler.annotation.nonnull=org.eclipse.jdt.annotation.NonNull +org.eclipse.jdt.core.compiler.annotation.nonnull.secondary= +org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.eclipse.jdt.annotation.NonNullByDefault +org.eclipse.jdt.core.compiler.annotation.nonnullbydefault.secondary= +org.eclipse.jdt.core.compiler.annotation.nullable=org.eclipse.jdt.annotation.Nullable +org.eclipse.jdt.core.compiler.annotation.nullable.secondary= +org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled +org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled +org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8 +org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve +org.eclipse.jdt.core.compiler.compliance=1.8 +org.eclipse.jdt.core.compiler.debug.lineNumber=generate +org.eclipse.jdt.core.compiler.debug.localVariable=generate +org.eclipse.jdt.core.compiler.debug.sourceFile=generate +org.eclipse.jdt.core.compiler.problem.annotationSuperInterface=warning +org.eclipse.jdt.core.compiler.problem.assertIdentifier=error +org.eclipse.jdt.core.compiler.problem.autoboxing=ignore +org.eclipse.jdt.core.compiler.problem.comparingIdentical=warning +org.eclipse.jdt.core.compiler.problem.deadCode=warning +org.eclipse.jdt.core.compiler.problem.deprecation=warning +org.eclipse.jdt.core.compiler.problem.deprecationInDeprecatedCode=disabled +org.eclipse.jdt.core.compiler.problem.deprecationWhenOverridingDeprecatedMethod=disabled +org.eclipse.jdt.core.compiler.problem.discouragedReference=warning +org.eclipse.jdt.core.compiler.problem.emptyStatement=ignore +org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled +org.eclipse.jdt.core.compiler.problem.enumIdentifier=error +org.eclipse.jdt.core.compiler.problem.explicitlyClosedAutoCloseable=ignore +org.eclipse.jdt.core.compiler.problem.fallthroughCase=ignore +org.eclipse.jdt.core.compiler.problem.fatalOptionalError=disabled +org.eclipse.jdt.core.compiler.problem.fieldHiding=ignore +org.eclipse.jdt.core.compiler.problem.finalParameterBound=warning +org.eclipse.jdt.core.compiler.problem.finallyBlockNotCompletingNormally=warning +org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning +org.eclipse.jdt.core.compiler.problem.hiddenCatchBlock=warning +org.eclipse.jdt.core.compiler.problem.includeNullInfoFromAsserts=disabled +org.eclipse.jdt.core.compiler.problem.incompatibleNonInheritedInterfaceMethod=warning +org.eclipse.jdt.core.compiler.problem.incompleteEnumSwitch=warning +org.eclipse.jdt.core.compiler.problem.indirectStaticAccess=ignore +org.eclipse.jdt.core.compiler.problem.localVariableHiding=ignore +org.eclipse.jdt.core.compiler.problem.methodWithConstructorName=warning +org.eclipse.jdt.core.compiler.problem.missingDefaultCase=ignore +org.eclipse.jdt.core.compiler.problem.missingDeprecatedAnnotation=ignore +org.eclipse.jdt.core.compiler.problem.missingEnumCaseDespiteDefault=disabled +org.eclipse.jdt.core.compiler.problem.missingHashCodeMethod=ignore +org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotation=ignore +org.eclipse.jdt.core.compiler.problem.missingOverrideAnnotationForInterfaceMethodImplementation=enabled +org.eclipse.jdt.core.compiler.problem.missingSerialVersion=warning +org.eclipse.jdt.core.compiler.problem.missingSynchronizedOnInheritedMethod=ignore +org.eclipse.jdt.core.compiler.problem.noEffectAssignment=warning +org.eclipse.jdt.core.compiler.problem.noImplicitStringConversion=warning +org.eclipse.jdt.core.compiler.problem.nonExternalizedStringLiteral=ignore +org.eclipse.jdt.core.compiler.problem.nonnullParameterAnnotationDropped=warning +org.eclipse.jdt.core.compiler.problem.nonnullTypeVariableFromLegacyInvocation=warning +org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=error +org.eclipse.jdt.core.compiler.problem.nullReference=warning +org.eclipse.jdt.core.compiler.problem.nullSpecViolation=error +org.eclipse.jdt.core.compiler.problem.nullUncheckedConversion=warning +org.eclipse.jdt.core.compiler.problem.overridingPackageDefaultMethod=warning +org.eclipse.jdt.core.compiler.problem.parameterAssignment=ignore +org.eclipse.jdt.core.compiler.problem.pessimisticNullAnalysisForFreeTypeVariables=warning +org.eclipse.jdt.core.compiler.problem.possibleAccidentalBooleanAssignment=ignore +org.eclipse.jdt.core.compiler.problem.potentialNullReference=ignore +org.eclipse.jdt.core.compiler.problem.potentiallyUnclosedCloseable=ignore +org.eclipse.jdt.core.compiler.problem.rawTypeReference=warning +org.eclipse.jdt.core.compiler.problem.redundantNullAnnotation=warning +org.eclipse.jdt.core.compiler.problem.redundantNullCheck=ignore +org.eclipse.jdt.core.compiler.problem.redundantSpecificationOfTypeArguments=ignore +org.eclipse.jdt.core.compiler.problem.redundantSuperinterface=ignore +org.eclipse.jdt.core.compiler.problem.reportMethodCanBePotentiallyStatic=ignore +org.eclipse.jdt.core.compiler.problem.reportMethodCanBeStatic=ignore +org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore +org.eclipse.jdt.core.compiler.problem.specialParameterHidingField=disabled +org.eclipse.jdt.core.compiler.problem.staticAccessReceiver=warning +org.eclipse.jdt.core.compiler.problem.suppressOptionalErrors=disabled +org.eclipse.jdt.core.compiler.problem.suppressWarnings=enabled +org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=disabled +org.eclipse.jdt.core.compiler.problem.syntheticAccessEmulation=ignore +org.eclipse.jdt.core.compiler.problem.typeParameterHiding=warning +org.eclipse.jdt.core.compiler.problem.unavoidableGenericTypeProblems=enabled +org.eclipse.jdt.core.compiler.problem.uncheckedTypeOperation=warning +org.eclipse.jdt.core.compiler.problem.unclosedCloseable=warning +org.eclipse.jdt.core.compiler.problem.undocumentedEmptyBlock=ignore +org.eclipse.jdt.core.compiler.problem.unhandledWarningToken=warning +org.eclipse.jdt.core.compiler.problem.unnecessaryElse=ignore +org.eclipse.jdt.core.compiler.problem.unnecessaryTypeCheck=ignore +org.eclipse.jdt.core.compiler.problem.unqualifiedFieldAccess=ignore +org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownException=ignore +org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionExemptExceptionAndThrowable=enabled +org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionIncludeDocCommentReference=enabled +org.eclipse.jdt.core.compiler.problem.unusedDeclaredThrownExceptionWhenOverriding=disabled +org.eclipse.jdt.core.compiler.problem.unusedExceptionParameter=ignore +org.eclipse.jdt.core.compiler.problem.unusedImport=warning +org.eclipse.jdt.core.compiler.problem.unusedLabel=warning +org.eclipse.jdt.core.compiler.problem.unusedLocal=warning +org.eclipse.jdt.core.compiler.problem.unusedObjectAllocation=ignore +org.eclipse.jdt.core.compiler.problem.unusedParameter=ignore +org.eclipse.jdt.core.compiler.problem.unusedParameterIncludeDocCommentReference=enabled +org.eclipse.jdt.core.compiler.problem.unusedParameterWhenImplementingAbstract=disabled +org.eclipse.jdt.core.compiler.problem.unusedParameterWhenOverridingConcrete=disabled +org.eclipse.jdt.core.compiler.problem.unusedPrivateMember=warning +org.eclipse.jdt.core.compiler.problem.unusedTypeParameter=ignore +org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning +org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning +org.eclipse.jdt.core.compiler.release=disabled +org.eclipse.jdt.core.compiler.source=1.8 diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 0000000..f897a7f --- /dev/null +++ b/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/log4j.properties b/log4j.properties new file mode 100644 index 0000000..5268ab6 --- /dev/null +++ b/log4j.properties @@ -0,0 +1,46 @@ +log4j.rootLogger=Debug, httpfile +log4j.logger.net.dv8tion.jda=Debug, stdout, jdafile +log4j.logger.me.despawningbone.discordbot.DiscordBot=trace, botstd, botfile + +# log4j.additivity.me.despawningbone.discordbot.DiscordBot=false #as rootLogger is set to debug, traces wont be logged and i dont mind logging command results + +log4j.appender.httpfile=org.apache.log4j.RollingFileAppender +log4j.appender.httpfile.File=target/logs/httpClient.log +log4j.appender.httpfile.MaxFileSize=20MB +log4j.appender.httpfile.MaxBackupIndex=2 +log4j.appender.httpfile.layout=org.apache.log4j.PatternLayout +log4j.appender.httpfile.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n + +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.Target=System.out +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=[%d{HH:mm:ss}] [%p] [%c{1}] %m%n +log4j.appender.stdout.Threshold=INFO + +log4j.appender.botstd=org.apache.log4j.ConsoleAppender +log4j.appender.botstd.Target=System.out +log4j.appender.botstd.layout=org.apache.log4j.PatternLayout +log4j.appender.botstd.layout.ConversionPattern=[%d{HH:mm:ss}] %m%n +log4j.appender.botstd.Threshold=INFO + +log4j.appender.botfile=org.apache.log4j.rolling.RollingFileAppender +log4j.appender.botfile.RollingPolicy=org.apache.log4j.rolling.TimeBasedRollingPolicy +log4j.appender.botfile.RollingPolicy.FileNamePattern=logs/%d{yyyy-MM-dd} session.log +log4j.appender.botfile.layout=org.apache.log4j.PatternLayout +log4j.appender.botfile.layout.ConversionPattern=[%d{HH:mm:ss}] %m%n%n +log4j.appender.botfile.filter.01=org.apache.log4j.varia.LevelMatchFilter +log4j.appender.botfile.filter.01.LevelToMatch=INFO +log4j.appender.botfile.filter.01.AcceptOnMatch=false + +log4j.appender.errout=org.apache.log4j.ConsoleAppender +log4j.appender.errout.Target=System.out +log4j.appender.errout.layout=org.apache.log4j.PatternLayout +log4j.appender.errout.layout.ConversionPattern=[%d{HH:mm:ss}] [%p] [%c{1}] %m%n +log4j.appender.errout.Threshold=ERROR + +log4j.appender.jdafile=org.apache.log4j.RollingFileAppender +log4j.appender.jdafile.File=target/logs/JDADebug.log +log4j.appender.jdafile.MaxFileSize=10MB +log4j.appender.jdafile.MaxBackupIndex=1 +log4j.appender.jdafile.layout=org.apache.log4j.PatternLayout +log4j.appender.jdafile.layout.ConversionPattern=[%d{HH:mm:ss}] [%-5p] [%c{1}] %m%n \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..252adec --- /dev/null +++ b/pom.xml @@ -0,0 +1,161 @@ + + 4.0.0 + Bot + Bot + 1.0-SNAPSHOT + despbot + A discord bot + + src + + + maven-compiler-plugin + 3.5.1 + + 1.8 + 1.8 + + + + + + + org.awaitility + awaitility + 3.0.0 + + + org.slf4j + slf4j-api + 1.8.0-alpha2 + + + org.slf4j + slf4j-log4j12 + 1.8.0-alpha2 + + + + log4j + apache-log4j-extras + 1.2.17 + + + + commons-logging + commons-logging + 1.2 + + + com.sedmelluq + lavaplayer + 1.3.50 + + + com.sedmelluq + jda-nas + 1.1.0 + + + se.michaelthelin.spotify + spotify-web-api-java + 6.0.0 + + + + commons-io + commons-io + 2.5 + + + com.vdurmont + emoji-java + 3.3.0 + + + + net.java.dev.jna + jna + 4.5.0 + + + + org.apache.commons + commons-lang3 + 3.6 + + + + commons-io + commons-io + 2.5 + + + + org.apache.httpcomponents + httpcore + 4.4.6 + + + + org.json + json + 20170516 + + + net.objecthunter + exp4j + 0.4.8 + + + net.dv8tion + JDA + 4.1.1_155 + + + + org.seleniumhq.selenium + selenium-java + 3.11.0 + + + org.reflections + reflections + 0.9.11 + + + + org.apache.commons + commons-math3 + 3.6.1 + + + + org.knowm.xchart + xchart + 3.6.4 + + + + org.xerial + sqlite-jdbc + 3.25.2 + + + + com.zaxxer + HikariCP + 3.3.1 + + + + + central + bintray + https://jcenter.bintray.com + + + + + + diff --git a/src/me/despawningbone/discordbot/DiscordBot.java b/src/me/despawningbone/discordbot/DiscordBot.java new file mode 100644 index 0000000..b7f2370 --- /dev/null +++ b/src/me/despawningbone/discordbot/DiscordBot.java @@ -0,0 +1,292 @@ +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.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 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.requests.GatewayIntent; + +public class DiscordBot { + // TODO move to appropriate locations + + //public static List BannedID = new ArrayList(); //DONE store in DB + 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 HashMap guildMemberPresence = new HashMap(); + + public static final String prefix = "!desp "; //DONE allow guild change prefix? + + public static JDA mainJDA = null; + + public static final String BotID = "311086271642599424"; + public static final String OwnerID = "237881229876133888"; + + public static Properties tokens = new Properties(); + + static final Logger logger = LoggerFactory.getLogger(DiscordBot.class); //package private + + public static HikariDataSource db; + + /*public static FirefoxDriver driver;*/ + + // 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) { + // initiate Selenium + /*System.setProperty("webdriver.gecko.driver", + System.getProperty("user.dir") + File.separator + "geckodriver.exe"); + FirefoxOptions options = new FirefoxOptions(); + options.setHeadless(true); + driver = new FirefoxDriver(options);*/ + + PropertyConfigurator.configure(System.getProperty("user.dir") + File.separator + "log4j.properties"); + + 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 + } catch (LoginException e) { + e.printStackTrace(); + return; + } + + // initiate logs //handled by log4j? + File directory = new File(System.getProperty("user.dir") + File.separator + "logs" + File.separator); + if (!directory.exists()) { + directory.mkdir(); + } + + //initiate test anime pics cache for repost check + /*try { + FileInputStream in = new FileInputStream(System.getProperty("user.dir") + File.separator + "testAnimePicCache.bin"); + ObjectInputStream ois = new ObjectInputStream(in); + EventListener.testAnimePicCache = (Multimap) ois.readObject(); + ois.close(); + } catch (IOException | ClassNotFoundException e) { + e.printStackTrace(); + } + + Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> { //no need to be a global variable since i dont need to access it + try { + FileOutputStream out = new FileOutputStream(System.getProperty("user.dir") + File.separator + "testAnimePicCache.bin"); + ObjectOutputStream oos = new ObjectOutputStream(out); + oos.writeObject(EventListener.testAnimePicCache); + oos.flush(); + oos.close(); + } catch (IOException e) { + e.printStackTrace(); + } + }, 1, 5, TimeUnit.MINUTES);*/ + + // DEPRECATED //or is it? make unmodifiable instead + ModID.add("165403578133905408"); + ModID.add("187714189672841216"); + ModID.add("204587986724192257"); // hyper + ModID.add("194112515859283968"); // bond + ModID.add("214270819793108993"); // emma + ModID.add("209883960522702848"); // M4 + ModID.add("237546287388426242"); // haoiscoll + ModID.add("264612287048843264"); // kanade + ModID.add("254509414600409088"); // shii + ModID.add(OwnerID); + 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? + 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(); + 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(); + //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 s : classes) { + try { + if (Modifier.isAbstract(s.getModifiers())) { + continue; + } + String pkName = s.getPackage().getName(); + String cat = StringUtils.capitalize(pkName.substring(pkName.lastIndexOf(".") + 1)); + //if(cat.equals("Osu")) cat = "osu!"; + Command c = s.getConstructor().newInstance(); //DONE use constructor to put name and cat instead + + if (c.isDisabled()) { + continue; + } + try { //init name and cat + 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? + List cmds = catCmds.get(cat); + if(cmds == null) { + cmds = new ArrayList(Arrays.asList(c)); + } else { + cmds.add(c); + } + catCmds.put(cat, cmds); + // set command category? + 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(); + } + } + for(Entry> entry : catCmds.entrySet()) { + Collections.sort(entry.getValue(), (a, b) -> a.getName().compareTo(b.getName())); + } + } +} diff --git a/src/me/despawningbone/discordbot/EventListener.java b/src/me/despawningbone/discordbot/EventListener.java new file mode 100644 index 0000000..b4e0a3a --- /dev/null +++ b/src/me/despawningbone/discordbot/EventListener.java @@ -0,0 +1,445 @@ +package me.despawningbone.discordbot; + +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.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Element; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; +import com.google.common.collect.Multimaps; + +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.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.User; +import net.dv8tion.jda.api.entities.VoiceChannel; +import net.dv8tion.jda.api.entities.Message.Attachment; +import net.dv8tion.jda.api.entities.MessageEmbed; +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.user.UserActivityStartEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; + +public class EventListener extends ListenerAdapter { + + public static Multimap testAnimePicCache = ArrayListMultimap.create(); + + @Override + public void onGuildMessageReceived(GuildMessageReceivedEvent event) { + 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 + //String[] words = event.getMessage().getContentDisplay().split(" "); + User author = event.getAuthor(); + TextChannel channel = event.getChannel(); + Message msg = event.getMessage(); + + //checkAnimePics(event); + + /*if(author.getId().equals("411350223625912323")) { + channel.sendMessage("| ||\n|| |_").queue(); + }*/ + //if(event.getMessage().getAttachments().stream().anyMatch(p -> p.getUrl().equals("https://cdn.discordapp.com/attachments/419464220304867348/432108442036207617/TinyPlainAnemoneshrimp-size_restricted.gif"))) event.getMessage().delete().queue(); + if(event.getMessage().getAttachments().stream().anyMatch(p -> p.getUrl().contains("DeepFryer_20191122_160935.jpg"))) event.getMessage().delete().queue(); + //if(event.getGuild().getId().equals("398140934035996687")) channel.getGuild().getRolesByName("Actual robutt role, this one has perms don't fuck with it", true).get(0).getPermissions().forEach(p -> System.out.println(p)); + + //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? + } + + /*if (String.join("", words).equalsIgnoreCase("!despacito")) { + channel.sendMessage("https://www.youtube.com/watch?v=W3GrSMYbkBE").queue(); + return; + }*/ + CommandResult result = null; + + try (Connection con = DiscordBot.db.getConnection(); Statement s = con.createStatement()){ + String prefix = MiscUtils.getPrefix(s, event.getGuild().getId()); + if(msg.getContentDisplay().toLowerCase().startsWith(prefix.toLowerCase())) { + String msgStripped = msg.getContentDisplay().substring(prefix.length()).replaceAll("\\s\\s+", " "); //merges space + String[] args = msgStripped.split(" "); // base on command length? + Command cmd = DiscordBot.commands.get(args[0].toLowerCase()); + cmd = cmd == null ? DiscordBot.aliases.get(args[0].toLowerCase()) : cmd; + if (cmd != null) { + 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(); + long perm = MiscUtils.getActivePerms(s, channel, cmd); + //result = cmd.execute(channel, author, msg, args); + String perms = cmd.hasSubCommand() ? null : MiscUtils.getMissingPerms(perm, cmd.getRequiredBotUserLevel(), event.getMember(), channel); //pass it to the subcommand handler to handle instead + if(perms == null || event.getAuthor().getId().equals(DiscordBot.OwnerID)) { //owner overrides perms for convenience + /*if(cmd.isDisabled(channel.getGuild().getId())) { + channel.sendMessage("This command is disabled!").queue(); + result = new CommandResult(CommandResultType.FAILURE, "Disabled command"); + } else {*/ + 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") || cmd.isDisabled()) { + msg.addReaction("โŽ").queue(); + 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? + } + } else if(msg.getContentRaw().matches("<@!?" + DiscordBot.BotID + ">")) result = greet(channel, author, prefix); + } catch (SQLException e) { + result = new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + if(result != null) DiscordBot.logger.info("[" + result.getResultType() + "] " + author.getName() + " (" + author.getId() + ") executed " + + msg.getContentDisplay() + (result.getRemarks() == null ? "." : ". (" + result.getRemarks() + ")")); + } + + @SuppressWarnings("unused") + private void checkAnimePics(GuildMessageReceivedEvent event) { + //TESTING + CompletableFuture.runAsync(() -> { + User author = event.getAuthor(); + TextChannel channel = event.getChannel(); + Message msg = event.getMessage(); + List pics = new ArrayList<>(); + if(channel.getId().equals("615396635383562272") && !DiscordBot.BotID.equals(author.getId())) { + for(Attachment att : msg.getAttachments()) { + if(att.isImage()) pics.add(att.getUrl()); + } + /*if(msg.getContentDisplay().contains("http")) { + for(String s : msg.getContentDisplay().split("http")) { + try { + new URL("http" + s); + pics.add("http" + s); + } catch(MalformedURLException e) { + ; + } + } + }*/ + try { + Thread.sleep(1500); + msg = channel.retrieveMessageById(msg.getId()).complete(); + } catch (InterruptedException e2) { + e2.printStackTrace(); + } + for(MessageEmbed em : msg.getEmbeds()) { + //System.out.println(em.toJSONObject()); + if(em.getThumbnail() != null) { + System.out.println("thumb"); + if(em.getSiteProvider() != null && em.getSiteProvider().getName().equals("pixiv")) { //pixiv doesnt show whole pic in embed + pics.add(em.getUrl()); + } else { + pics.add(em.getThumbnail().getUrl()); + } + } else if(em.getImage() != null && !author.isBot()) { + System.out.println("img"); + pics.add(em.getImage().getUrl()); + } else { + System.out.println("url"); + pics.add(em.getUrl()); + } + + } + //System.out.println(pics); + //System.out.println(testAnimePicCache); + for(String pic : pics) { //handle first since these mustnt be the same urls, therefore cache saucenao sauce instead + String origPic = pic; + if(!testAnimePicCache.containsValue(pic)) { + Element saucenao; + try { + saucenao = Jsoup.connect("https://saucenao.com/search.php?url=" + pic).get().body(); + Element result = saucenao.selectFirst(".resulttable"); + try { //normal pixiv/deviantart handling + if(result.parent().attr("class").equals("result hidden")) throw new NullPointerException(); + Element source = result.selectFirst("strong:contains(ID:)").nextElementSibling(); + pic = source.attr("href"); //will not run if the cache has the value || if the link specified aint a pic + } catch (NullPointerException e1) { //weird saucenao card formatting (eg episode info), or no result; we dont handle these + ; + } + } catch (IOException e) { + e.printStackTrace(); + } + } + System.out.println("next" + testAnimePicCache); + if(testAnimePicCache.containsValue(pic)) { + try { + Multimap temp = ArrayListMultimap.create(); + System.out.println("temp"); + Message m = channel.retrieveMessageById(Multimaps.invertFrom(testAnimePicCache, temp).get(pic).toArray(new String[1])[0]).complete(); + EmbedBuilder eb = new EmbedBuilder(); + eb.setTitle("Repost detected!"); + eb.setDescription("[This image](" + origPic + ")" + (origPic.equals(pic) ? "" : "([source](" + pic + "))") + " is a repost of [this message](" + m.getJumpUrl() + ") by `" + m.getAuthor().getName() + "#" + m.getAuthor().getDiscriminator() + "` at **" + m.getTimeCreated().format(DateTimeFormatter.RFC_1123_DATE_TIME) + "**.\n"); + eb.setThumbnail(origPic); + channel.sendMessage(eb.build()).queue(); + continue; + } catch(NullPointerException e) { //if the message is deleted + e.printStackTrace(); + } + } else { + System.out.println("put"); + testAnimePicCache.put(msg.getId(), pic); + } + + } + } + }).whenComplete((r, t) -> {if(t != null) t.printStackTrace();}); + } + + private CommandResult greet(TextChannel channel, User author, String prefix) { + MessageBuilder smsg = new MessageBuilder(); + String nick = channel.getGuild().getMemberById(author.getId()).getNickname(); + if (nick != null) { + smsg.append("Yo " + nick + "!\n"); + } else { + smsg.append("Yo " + author.getName() + "!\n"); + } + if (author.getId().equals("237881229876133888")) { // easter eggs + smsg.append("How art thou, my creator?"); + } + if (author.getId().equals("179824176847257600")) { + smsg.append("Still stalking as usual, huh? :smirk:"); + smsg.append("\n\"go fix your potatoes\" - pugger"); + } + if (author.getId().equals("165403578133905408")) { + smsg.append( + "You're probably still dead :D (your status), either that or you are playing with your doodle :P"); + } + if (author.getId().equals("187714189672841216")) { + smsg.append("Hello, fearsome coder named Morgan :wink:"); + } + if (author.getId().equals("201768560345743360")) { + smsg.append("Still need help to save you from the school? :D"); + } + if (author.getId().equals("257660112703979521")) { + smsg.append("I like how you only uses me for `!desp roll 1`. :P"); + } + if (author.getId().equals("272712701988569090")) { + smsg.append("This guy is generous :smile:"); + } + if (author.getId().equals("203861130995695616")) { + smsg.append("He asked my owner to change it so here it is :smile:"); + } + if (author.getId().equals("206038522971422721")) { + smsg.append("Appearently he loves !desp idiot :face_palm:"); + } + if (author.getId().equals("218377806994866176")) { + smsg.append("Your potatoes seems better than Dank's :smirk:"); + } + if (author.getId().equals("139316803582033920")) { + smsg.append("That ironic name tho :stuck_out_tongue:"); + } + if (author.getId().equals("237058431272484864")) { + smsg.append(":b:oi"); + } + if (author.getId().equals("338258756762730496")) { + smsg.append("**a e s t h e t i c**"); + } + 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=311086271642599424&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 `despawningbone#4078` if you have any questions!\n"); + eb.appendDescription("To get a list of commands, do `" + prefix + "help`."); + smsg.setEmbed(eb.build()); + Message fmsg = smsg.build(); + + channel.sendMessage(fmsg).queue(); + return new CommandResult(CommandResultType.SUCCESS, null); + } + + @Override + public void onGuildMessageUpdate(GuildMessageUpdateEvent event) { + //System.out.println("edit"); //debug + Message msg = event.getMessage(); + User author = event.getAuthor(); + TextChannel channel = event.getChannel(); + 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(); + //DiscordBot.guildMemberPresence.put(event.getUser().getId(), game); //so that game update to nothing wont be logged + /*System.out.println(event.getGuild().getName()); + System.out.println(event.getUser().getName()); + System.out.println(game.getName()); + System.out.println(game.getUrl()); + System.out.println(game.getType()); + System.out.println(game.isRich() ? game.asRichPresence().getDetails() : "no rich"); + System.out.println(game.isRich() ? game.asRichPresence().getState() : "no rich");*/ + //OffsetDateTime timesent = OffsetDateTime.now(); + //System.out.println(toolTip); + String sGame = (toolTip.lastIndexOf(" (") != -1 ? toolTip.substring(0, toolTip.lastIndexOf(" (")) : toolTip) + "||" + osu.asRichPresence().getDetails(); + //sGame = sGame.replaceAll("'", "''"); + /*OffsetDateTime timeReceived = OffsetDateTime.now(); + long ms = Math.abs(timesent.until(timeReceived, ChronoUnit.MILLIS)); + System.out.println("Time taken: " + ms + "ms");*/ + 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(); + //con.createStatement().execute("INSERT INTO users(id, game) VALUES (" + event.getUser().getId() + ", '" + sGame + "') ON CONFLICT(id) DO UPDATE SET game = '" + sGame + "' WHERE game <> '" + sGame + "';" ); //prevent blank updates + } catch (SQLException e) { //FIXED if i make this not osu only, be aware of SQL injections through sGame (probably being paranoid tho) + e.printStackTrace(); + } + /*timeReceived = OffsetDateTime.now(); + ms = Math.abs(timesent.until(timeReceived, ChronoUnit.MILLIS)); + System.out.println("Time taken: " + ms + "ms");*/ + } + } + + //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) { + ap.getGuildMusicManager(guild).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 + GuildVoiceState vs = event.getGuild().getMemberById(DiscordBot.BotID).getVoiceState(); + if (vs.inVoiceChannel()) { + if(!event.getMember().getUser().getId().equals(DiscordBot.BotID)) { + 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 + System.out.println("Got kicked"); + ((Music) DiscordBot.commands.get("music")).getAudioTrackHandler().stopAndClearQueue(event.getGuild()); + } + } + } + + @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 + 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 + 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(); + } + } + } + } + + @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(); + } + + } + + } + } +} diff --git a/src/me/despawningbone/discordbot/command/Command.java b/src/me/despawningbone/discordbot/command/Command.java new file mode 100644 index 0000000..6486a53 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/Command.java @@ -0,0 +1,205 @@ +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.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; + 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() + public Command(String name, List aliases, Command.ExecuteImpl exeFunc, String usage, List examples, String desc, List remarks, EnumSet defaultPerms, int botUserLevel, Command parent) { + 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; + this.parent = parent; + } + + 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); + } + + //for anonymous inner classes if needed + public void registerSubCommand(Command subCmd) { + 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, this); + subCmds.put(name, subCmd); + if(aliases != null) aliases.forEach(a -> subCmdAliases.put(a, 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; + } + + //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 super() this and then write the main command stuff + 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(perms == null) { + 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") || this.isDisabled) { + msg.addReaction("โŽ").queue(); + return new CommandResult(CommandResultType.DISABLED); + } else { + return new CommandResult(CommandResultType.NOPERMS, perms); + } + + } else { + return new CommandResult(CommandResultType.INVALIDARGS, "Unknown sub command! Check `" + prefix + "help " + this.getName() + "` for more info."); + } + } else { + return new CommandResult(CommandResultType.INVALIDARGS, "Please specify a subcommand. You can view them with `" + prefix + "help " + this.name + "`."); + } + } else { + return impl.execute(channel, author, msg, args); //if no sub commands + } + } 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 new file mode 100644 index 0000000..f5b580c --- /dev/null +++ b/src/me/despawningbone/discordbot/command/CommandResult.java @@ -0,0 +1,77 @@ +package me.despawningbone.discordbot.command; + +import net.dv8tion.jda.api.MessageBuilder; +import net.dv8tion.jda.api.entities.Message; + +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(); + 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(":.*", "") + "`"); + break; + case NOPERMS: + mb.append("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:"); + break; + case TOOLONG: + mb.append("The description was too long discord can't stand it :joy:"); + break; + default: //TODO add "For help, do" for INVALIDARGS? + mb.append(r); + } + return mb.build(); + } + +} diff --git a/src/me/despawningbone/discordbot/command/admin/BotBan.java b/src/me/despawningbone/discordbot/command/admin/BotBan.java new file mode 100644 index 0000000..c9349f4 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/admin/BotBan.java @@ -0,0 +1,118 @@ +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.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 { + //boolean alrBanned = false; + 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 IllegalArgumentException("This should never happen"); + } + } catch (SQLException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + + /*for (int i = 0; i < DiscordBot.BannedID.size(); i++) { + if (DiscordBot.BannedID.get(i).equals(SID)) { + alrBanned = true; + break; + } + } + if (!alrBanned) { + boolean inList = false; + for (int i = 0; i < DiscordBot.playerreportlist.size(); i++) { + if (DiscordBot.playerreportlist.get(i).equals(SID)) { + inList = true; + DiscordBot.playerreportlist.set(i, SID + " " + "5"); + break; + } + } + if (!inList) { + DiscordBot.playerreportlist.add(SID + " " + "5"); + } + DiscordBot.BannedID.add(SID); + Path fp = Paths.get(System.getProperty("user.dir"), "PlayerReports.txt"); + try { + List fileContent = new ArrayList<>( + Files.readAllLines(fp, StandardCharsets.UTF_8)); + + for (int f = 0; f < fileContent.size(); f++) { + if (fileContent.get(f).startsWith(SID)) { + fileContent.set(f, SID + " " + "5"); + inList = true; + break; + } + } + if (!inList) { + fileContent.add(SID + " " + "5"); + } + Files.write(fp, fileContent, StandardCharsets.UTF_8); + } catch (IOException e) { + e.printStackTrace(); + } + channel.sendMessage("You have successfully banned <@!" + SID + "> from the bot.").queue(); + return new CommandResult(CommandResultType.SUCCESS); + } else { + return new CommandResult(CommandResultType.FAILURE, "The user is already banned from the bot."); + }*/ + } + } 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 new file mode 100644 index 0000000..091b99c --- /dev/null +++ b/src/me/despawningbone/discordbot/command/admin/BotUnban.java @@ -0,0 +1,97 @@ +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.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 { + //boolean unbanned = false; + 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); + } + } + } + return new CommandResult(CommandResultType.FAILURE, "This user is not banned."); + } catch (SQLException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + /*for (int i = 0; i < DiscordBot.BannedID.size(); i++) { + if (DiscordBot.BannedID.get(i).equals(SID)) { + unbanned = true; + DiscordBot.BannedID.remove(i); + break; + } + } + if (unbanned == true) { + for (int i = 0; i < DiscordBot.playerreportlist.size(); i++) { + if (DiscordBot.playerreportlist.get(i).startsWith(SID)) { + DiscordBot.playerreportlist.remove(i); + break; + } + } + Path fp = Paths.get(System.getProperty("user.dir"), "PlayerReports.txt"); + try { + List fileContent = new ArrayList<>(Files.readAllLines(fp, StandardCharsets.UTF_8)); + for (int f = 0; f < fileContent.size(); f++) { + if (fileContent.get(f).startsWith(SID)) { + fileContent.remove(f); + break; + } + } + Files.write(fp, fileContent, StandardCharsets.UTF_8); + } catch (IOException e) { + e.printStackTrace(); + } + } + if (unbanned == true) { + channel.sendMessage("You unbanned <@!" + SID + ">.").queue(); + String name = channel.getGuild().getMemberById(SID).getEffectiveName(); + return new CommandResult(CommandResultType.SUCCESS, "User: " + name); + } + return new CommandResult(CommandResultType.FAILURE, "This user is not banned.");*/ + } 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 new file mode 100644 index 0000000..51ed140 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/admin/Purge.java @@ -0,0 +1,51 @@ +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.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 new file mode 100644 index 0000000..0821bf6 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/admin/Settings.java @@ -0,0 +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 + } + } 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.sendMessage(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 new file mode 100644 index 0000000..dbbc116 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/admin/Shutdown.java @@ -0,0 +1,35 @@ +package me.despawningbone.discordbot.command.admin; + +import java.util.concurrent.TimeUnit; + +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.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"); + AudioTrackHandler ap = ((Music) DiscordBot.commands.get("music")).getAudioTrackHandler(); + if(ap != null) { + ap.ex.schedule(() -> DiscordBot.mainJDA.shutdown(), 500, TimeUnit.MILLISECONDS); //delay needed to actually shutdown correctly; borrowing the scheduler lmao + ap.shutdown(); + } else { + DiscordBot.mainJDA.shutdown(); + } + 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 new file mode 100644 index 0000000..9f68923 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/admin/UserPurge.java @@ -0,0 +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.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(); + 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 new file mode 100644 index 0000000..d36f349 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/admin/UserReport.java @@ -0,0 +1,183 @@ +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.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(DiscordBot.OwnerID, "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)); + } + + /*for (int i = 0; i < DiscordBot.reportedlist.size(); i++) { + String[] randrp = DiscordBot.reportedlist.get(i).split(" "); + if (randrp[0].contains(author.getId()) && randrp[1].contains(SID)) { + return new CommandResult(CommandResultType.FAILURE, "You cannot report a user more than once."); + } + } + boolean banned = false; + boolean contains = false; + for (int i = 0; i < DiscordBot.playerreportlist.size(); i++) { + if (DiscordBot.playerreportlist.get(i).contains(SID)) { + contains = true; + } + } + if (contains == false) { + DiscordBot.playerreportlist.add(SID + " " + "1"); + times = 1; + FileWriter fileWriter = null; + BufferedWriter bufferedWriter; + try { + fileWriter = new FileWriter(DiscordBot.pfile, true); + } catch (IOException e1) { + e1.printStackTrace(); + } + try { + bufferedWriter = new BufferedWriter(fileWriter); + bufferedWriter.write(SID + " " + "1" + "\r\n"); + bufferedWriter.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } else { + // System.out.println("Got record");//debug + for (int i = 0; i < DiscordBot.playerreportlist.size(); i++) { + String[] pandt = DiscordBot.playerreportlist.get(i).split(" "); + if (pandt[0].contains(SID)) { + times = Long.parseLong(pandt[1]); + // System.out.println(times); //debug + if (times < 5) { + times = times + (long) 1; + // System.out.println(times); //debug + if (times == 5) { + DiscordBot.BannedID.add(Long.toString(ID)); + banned = true; + } + DiscordBot.playerreportlist.set(i, SID + " " + Long.toString(times)); + Path fp = Paths.get(System.getProperty("user.dir"), "PlayerReports.txt"); + try { + List fileContent = new ArrayList<>( + Files.readAllLines(fp, StandardCharsets.UTF_8)); + + for (int f = 0; f < fileContent.size(); f++) { + if (fileContent.get(f).startsWith(SID)) { + fileContent.set(f, SID + " " + Long.toString(times)); + break; + } + } + + Files.write(fp, fileContent, StandardCharsets.UTF_8); + } catch (IOException e) { + e.printStackTrace(); + } + + break; + } else { + if (!DiscordBot.BannedID.contains(String.valueOf(ID))) { + DiscordBot.BannedID.add(Long.toString(ID)); + } + return new CommandResult(CommandResultType.FAILURE, "The user <@!" + ID + + "> had enough reports therefore he is banned from using this bot."); + } + } + } + } + DiscordBot.reportedlist.add(author.getId() + " " + SID); + channel.sendMessage("You have successfully reported <@!" + ID + ">.\nThe user has been reported for " + + times + "/5 times.\n").queue(); + String addinfo = null; + if (banned == true) { + channel.sendMessage("The user <@!" + ID + "> is now banned from using the bot.").queue(); + addinfo = channel.getGuild().getMemberById(SID).getEffectiveName() + " (" + SID + + ") is banned from the bot."; + } + return new CommandResult(CommandResultType.SUCCESS, addinfo);*/ + } + } +} diff --git a/src/me/despawningbone/discordbot/command/anime/Anime.java b/src/me/despawningbone/discordbot/command/anime/Anime.java new file mode 100644 index 0000000..0c8e876 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/anime/Anime.java @@ -0,0 +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.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.sendMessage(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 new file mode 100644 index 0000000..bfbb2f7 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/anime/AnimePic.java @@ -0,0 +1,93 @@ +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.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"); + } + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { + Connection con = null; + channel.sendTyping().queue(); + if(args.length < 1) { + con = Jsoup.connect("https://www.zerochan.net/?p=" + (ThreadLocalRandom.current().nextInt(1000) + 1)); + } + boolean full = false; + ArrayList amend = new ArrayList<>(Arrays.asList(args)); + if(amend.contains("-f")) { + amend.remove("-f"); + full = true; + } + 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(); + //System.out.println(a.parent().html()); + eb.setTitle((index == -1 ? "Random" : MiscUtils.ordinal(index + 1)) + (search.isEmpty() ? " anime pic" : " pic for " + search), a.absUrl("href")); + String img = full ? (a.siblingElements().first().children().size() < 2 ? a.select("img").first().absUrl("src").replace(".240.", ".full.") : a.siblingElements().first().children().last().absUrl("href")) : a.select("img").first().absUrl("src").replace(".240.", ".600."); + //System.out.println(img); + eb.setImage(img); + eb.setFooter(full ? "The image might need a while to load in." : "Include -f if you want the full resolution!", null); + //eb.setImage("https://static.zerochan.net/Clannad%3A.After.Story.full.2275059.jpg"); + channel.sendMessage(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 new file mode 100644 index 0000000..f29106c --- /dev/null +++ b/src/me/despawningbone/discordbot/command/anime/Sauce.java @@ -0,0 +1,166 @@ +package me.despawningbone.discordbot.command.anime; + +import java.awt.Color; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.URLDecoder; +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.CompletionException; +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.User; + +public class Sauce extends Command{ + + public Sauce() { + this.desc = "Get the source of an anime pic!"; + this.usage = ""; + this.alias = Arrays.asList("source", "saucenao", "iqdb"); + } + + @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 temp = amend.indexOf("-d"); + if(temp != -1) amend.subList(temp, temp + 1).clear(); + 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!"); + } + System.out.println(url); + try { + channel.sendTyping().queueAfter(20, TimeUnit.MILLISECONDS); + if(temp != -1) { + channel.sendMessage("Performing depth search for the picture... (this can take up to 20 seconds)").queue(); + String[] urls = yandexSearch(url); + System.out.println(Arrays.asList(urls)); + url = urls[2] == null ? urls[0] : urls[1] + ";" + urls[2]; + //sync + //List collect = Arrays.asList(tempSearchSauce(urls[0]),tempSearchSauce(urls[1])); + //async + CompletableFuture first = CompletableFuture.supplyAsync(() -> {try {return tempSearchSauce(urls[0]);} catch (IOException e){e.printStackTrace(); return null;}}); + CompletableFuture ratio = CompletableFuture.supplyAsync(() -> {try {return tempSearchSauce(urls[1]);} catch (IOException e){e.printStackTrace(); return null;}}); + List collect = CompletableFuture.allOf(first, ratio) + .thenApply(future -> Arrays.asList(first.join(), ratio.join())) + .whenComplete((s, t) -> {if(!(ratio.join() != null && first.join() != null) && t != null) throw new CompletionException(t.getCause());}).get(); + + channel.sendMessage(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").replace("%", "")), Double.parseDouble(a.build().getFooter().getText().replaceAll(".*?([.0-9]*%).*", "$1").replace("%", "")))).findFirst().get().build()).queue(); + } else { + EmbedBuilder eb = tempSearchSauce(url); + channel.sendMessage(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) { + 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.sendMessage(eb.build()).queueAfter(20, TimeUnit.MILLISECONDS); + } + return new CommandResult(CommandResultType.NORESULT); + } + return new CommandResult(CommandResultType.SUCCESS); + } + + 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(); + String[] size = new String[]{yandex.selectFirst(".CbirPreview-Placeholder").attr("width"), yandex.selectFirst(".CbirPreview-Placeholder").attr("height")}; + //double ratio = Double.parseDouble(size[0]) / Double.parseDouble(size[1]) >= 1 ? 16.0/9 : 9/16.0; + double ratio = Double.parseDouble(size[0]) / Double.parseDouble(size[1]); + System.out.println(yandex.select(".other-sites__thumb")); + System.out.println(size[0] + " " + size[1] + " " + ratio); + String other, site = null, similar = URLDecoder.decode(yandex.select(".cbir-similar__thumb .cbir-similar__image").get(yandex.select(".cbir-other-sizes__item").size() > 0 ? 1 : 0).parent().attr("href").split("img_url=")[1].split("&")[0], "UTF-8"); + if(yandex.select(".cbir-other-sizes__list").size() > 0) { //TODO merge the sort algorithms? + /*other = yandex.select(".cbir-other-sizes__list").first().select("a").stream().sorted((a, b) -> { //selects largest possible section of the other sizes + String[] aA = a.select(".cbir-other-sizes__resolution").first().text().split("ร—"), bA = b.select(".cbir-other-sizes__resolution").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().attr("href");*/ + other = yandex.select(".cbir-other-sizes__list a").first().attr("href"); //sorting with ratio seems to perform bad for most crops so dont sort anymore + } else { + yandex.select(".other-sites__item").stream().limit(12).sorted((a, b) -> { + String[] aA = a.select(".other-sites__meta").first().text().split("ร—"), bA = b.select(".other-sites__meta").first().text().split("ร—"); + System.out.println(Math.abs(Double.parseDouble(aA[0]) / Double.parseDouble(aA[1]) - ratio) + ", " + Math.abs(Double.parseDouble(bA[0]) / Double.parseDouble(bA[1]) - ratio)); + 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));}).forEach(s -> System.out.println(s)); + Element eSite = yandex.select(".other-sites__item").stream().limit(12).sorted((a, b) -> { + String[] aA = a.select(".other-sites__meta").first().text().split("ร—"), bA = b.select(".other-sites__meta").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(); + other = eSite.select(".other-sites__preview-link").first().attr("href"); + site = Jsoup.connect(eSite.select(".other-sites__snippet-site-link").first().attr("href")).get().html().split(";URL='")[1].split("'")[0]; + } + + return new String[]{similar, other, site}; + //gets second image coz usually the first one is just a sharper identical image, then get the largest image that has the image inside or select an image closest to 16:9 for anime checking //closest to given ratio now, anime checking usually cant be done with depth search anyways since not every scene is screen capped online + } + + private EmbedBuilder tempSearchSauce(String url) throws IOException { + 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=" + url + "&service[]=1&service[]=2&service[]=3&service[]=4&service[]=5&service[]=11&service[]=13").get().body(); //services excluding eshuushuu since it has no tags and will fallback to saucenao anyways + if(iqdb.select(".err").size() > 0 && iqdb.select(".err").html().contains("HTTP")) 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 e) { //fallback to saucenao, usually pixiv source instead of image boards + try { + Element saucenao = Jsoup.connect("https://saucenao.com/search.php?url=" + url).get().body(); + Element result = saucenao.selectFirst(".resulttable"); + if(result == null) return null; + if(result.parent().attr("class").equals("result hidden")) return null; + 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) + 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")); + } + eb.setFooter(result.select(".resultsimilarityinfo").text() + " 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 new file mode 100644 index 0000000..9a4c79f --- /dev/null +++ b/src/me/despawningbone/discordbot/command/anime/Waifu.java @@ -0,0 +1,190 @@ +package me.despawningbone.discordbot.command.anime; + +import java.awt.Color; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.json.JSONArray; +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.User; + +public class Waifu extends Command { + public Waifu() { + this.desc = "Find information about your waifu!"; //, or leave it blank for a random one!"; + this.usage = " [| index]"; + this.examples = Arrays.asList("neptune", "ryuZU"); + } + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { + channel.sendTyping().queue(); + String[] stripped = String.join(" ", args).split("\\|"); + /* , url = ""; + if (args.length < 1) { + url = "https://mywaifulist.moe/random/"; + } else { + url = "https://mywaifulist.moe/waifu/" + stripped.replaceAll(" ", "-"); + } + JSONObject main = getWaifuJSON(url); + if (main == null) { + 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) { + channel.sendMessage("Invalid index inputted. Defaulting to first result.").queue(); + channel.sendTyping().queue(); + index = 0; + } + } else { + search = stripped; + index = 0; + } + try { + url = GoogleSearch + .search(URLEncoder.encode("site:https://mywaifulist.moe/waifu " + search, "UTF-8"), 20) + .get(index).getValue(); + // System.out.println(url); //debug + } catch (IOException | IndexOutOfBoundsException e) { + if (e instanceof IndexOutOfBoundsException) { + return new CommandResult(CommandResultType.NORESULT); + } else { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + } + main = getWaifuJSON(url); + }*/ + + if(args.length < 1) return new CommandResult(CommandResultType.FAILURE, "Please enter something to search for!"); + JSONObject main; + try { + Response con = Jsoup.connect("https://mywaifulist.moe/dash/").execute(); + String cookie = String.join(";", con.header("Set-Cookie").split("; expires.*?,.*?,")).split("; expires")[0]; + String csrf = con.parse().selectFirst("meta[name=\"csrf-token\"]").attr("content"); + String res = Jsoup.connect("https://mywaifulist.moe/api/waifu/search") + .userAgent("Mozilla/4.0") + .header("Cookie", cookie) + .header("X-CSRF-Token", csrf) + .header("X-Requested-With", "XMLHttpRequest") + .followRedirects(true) + .requestBody("{\"query\":\"" + stripped[0].trim() + "\"}").post().text(); + 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(); + } catch (NumberFormatException e) { + channel.sendMessage("Invalid index inputted. Defaulting to first result.").queue(); + channel.sendTyping().queue(); + index = 0; + } + List arr = new ArrayList<>(); + for(Object obj : jarr) arr.add((JSONObject) obj); + arr = arr.stream().filter(o -> !o.getString("type").equalsIgnoreCase("series")).sorted((a, b) -> Integer.compare((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))).collect(Collectors.toList()); //sort by popularity since the search result sucks ass + main = new JSONObject(new JSONTokener(Jsoup.connect("https://mywaifulist.moe/api/waifu/" + arr.get(index).getInt("id")) + .userAgent("Mozilla/4.0") + .header("Cookie", cookie) + .header("X-CSRF-Token", csrf) + .header("X-Requested-With", "XMLHttpRequest") + .followRedirects(true).ignoreContentType(true).get().text())).getJSONObject("data"); + System.out.println(main); + } catch (IndexOutOfBoundsException e) { + //e.printStackTrace(); + return new CommandResult(CommandResultType.NORESULT); + } catch (IOException e) { + 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")); + + JSONObject series = main.getJSONObject("series"); + em.setAuthor("Series: " + series.getString("name"), "https://mywaifulist.moe/series/" + series.getString("slug")); + if(!main.getJSONObject("series").getString("display_picture").isEmpty()) em.setThumbnail(main.getJSONObject("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("\\\\", "")); + + 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; + } + final int avg = (1024 - totalName) / appearances.size(); + System.out.println(appearances.stream().map(jobj -> "[" + jobj.getString("name") + "](https://mywaifulist.moe/series/" + jobj.getString("slug") + " \"" + jobj.getString("description").substring(0, Math.min(jobj.getString("description").length(), avg)) + (jobj.getString("description").length() > avg ? "..." : "").replaceAll("\"", "โ€") + "\")").collect(Collectors.joining(", ")).length()); + em.addField("Appearances", appearances.stream().map(jobj -> "[" + jobj.getString("name") + "](https://mywaifulist.moe/series/" + jobj.getString("slug") + " \"" + jobj.getString("description").substring(0, Math.min(jobj.getString("description").length(), avg)) + (jobj.getString("description").length() > avg ? "..." : "").replaceAll("\"", "โ€") + "\")").collect(Collectors.joining(", ")), false); + + if(!main.getString("original_name").isEmpty()) em.addField("Also known as", main.getString("original_name") + (main.isNull("romaji_name") ? "" : ", " + main.getString("romaji_name")), false); + + em.addBlankField(false); + + if (!main.getString("origin").equals("")) { + em.addField("Origin", main.getString("origin"), true); + } + if (main.getDouble("height") != 0.00) { + em.addField("Height", String.valueOf(main.getDouble("height")), true); + } + if (main.getDouble("weight") != 0.00) { + em.addField("Weight", String.valueOf(main.getDouble("weight")), true); + } + if (main.getDouble("bust") != 0.00) { + em.addField("Bust", String.valueOf(main.getDouble("bust")), true); + } + if (main.getDouble("hip") != 0.00) { + em.addField("Hip", String.valueOf(main.getDouble("hip")), true); + } + if (main.getDouble("waist") != 0.00) { + em.addField("Waist", String.valueOf(main.getDouble("waist")), true); + } + if (!main.getString("blood_type").isEmpty()) { + em.addField("Blood type", main.getString("blood_type"), true); + } + if (!main.isNull("birthday_day") && !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); + } + + if (em.getFields().size() == 5) { + em.addBlankField(true); + } + + String tags = main.getJSONArray("tags").toList().stream().map(o -> o.toString().replaceAll("\\{name=", "").replaceAll(", id=.*\\}", "")).collect(Collectors.joining(", ")); + if(!tags.isEmpty()) em.addField("Tags", tags, false); + + if(em.getFields().size() > 2) em.addBlankField(false); + em.addField("Likes", main.getInt("likes") + " (#" + main.getInt("like_rank") + ")", true); + em.addField("Popularity rank", "#" + main.getInt("popularity_rank"), true); + em.addField("Trash", main.getInt("trash") + " (#" + main.getInt("trash_rank") + ")", true); + + em.setFooter("Created by " + main.getJSONObject("creator").getString("name") + /*", Last updated: " + + main.getString("updated_at") +*/ " | MyWaifuList.moe", null); + channel.sendMessage(em.build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } +} diff --git a/src/me/despawningbone/discordbot/command/games/Koohii.java b/src/me/despawningbone/discordbot/command/games/Koohii.java new file mode 100644 index 0000000..78acb94 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/games/Koohii.java @@ -0,0 +1,1874 @@ +package me.despawningbone.discordbot.command.games; + +import java.util.ArrayList; +import java.util.Collections; +import java.io.BufferedReader; +import java.io.IOException; + +/** +* pure java implementation of github.com/Francesco149/oppai-ng . +* +*
+* this is meant to be a single file library that's as portable and
+* easy to set up as possible for java projects that need
+* pp/difficulty calculation.
+*
+* when running the test suite, speed is roughly equivalent to the C
+* implementation, but peak memory usage is almost 80 times higher.
+* if you are on a system with limited resources or you don't want
+* to spend time installing and setting up java, you can use the C
+* implementation which doesn't depend on any third party software.
+* -----------------------------------------------------------------
+* usage:
+* put Koohii.java in your project's folder
+* -----------------------------------------------------------------
+* import java.io.BufferedReader;
+* import java.io.InputStreamReader;
+*
+* class Example {
+*
+* public static void main(String[] args) throws java.io.IOException
+* {
+*     BufferedReader stdin =
+*         new BufferedReader(new InputStreamReader(System.in)
+*     );
+*
+*     Koohii.Map beatmap = new Koohii.Parser().map(stdin);
+*     Koohii.DiffCalc stars = new Koohii.DiffCalc().calc(beatmap);
+*     System.out.printf("%s stars\n", stars.total);
+*
+*     Koohii.PPv2 pp = Koohii.PPv2(
+*         stars.aim, stars.speed, beatmap
+*     );
+*
+*     System.out.printf("%s pp\n", pp.total);
+* }
+*
+* }
+* -----------------------------------------------------------------
+* javac Example.java
+* cat /path/to/file.osu | java Example
+* -----------------------------------------------------------------
+* this is free and unencumbered software released into the
+* public domain.
+*
+* refer to the attached UNLICENSE or http://unlicense.org/
+* 
+* +* @author Franc[e]sco (lolisamurai@tfwno.gf) +*/ +public final class Koohii { + +private Koohii() {} + +public final int VERSION_MAJOR = 1; +public final int VERSION_MINOR = 2; +public final int VERSION_PATCH = 0; + +/** prints a message to stderr. */ +public static +void info(String fmt, Object... args) { + System.err.printf(fmt, args); +} + +/* ------------------------------------------------------------- */ +/* math */ + +/** 2D vector with double values */ +public static class Vector2 +{ + public double x = 0.0, y = 0.0; + + public Vector2() {} + public Vector2(Vector2 other) { this(other.x, other.y); } + public Vector2(double x, double y) { this.x = x; this.y = y; } + + public String toString() { + return String.format("(%s, %s)", x, y); + } + + /** + * this -= other . + * @return this + */ + public Vector2 sub(Vector2 other) + { + x -= other.x; y -= other.y; + return this; + } + + /** + * this *= value . + * @return this + */ + public Vector2 mul(double value) + { + x *= value; y *= value; + return this; + } + + /** length (magnitude) of the vector. */ + public double len() { return Math.sqrt(x * x + y * y); } + + /** dot product between two vectors, correlates with the angle */ + public double dot(Vector2 other) { return x * other.x + y * other.y; } +} + +/* ------------------------------------------------------------- */ +/* beatmap utils */ + +public static final int MODE_STD = 0; +public static final int MODE_TK = 1; + +public static class Circle +{ + public Vector2 pos = new Vector2(); + public String toString() { return pos.toString(); } +} + +public static class Slider +{ + public Vector2 pos = new Vector2(); + + /** distance travelled by one repetition. */ + public double distance = 0.0; + + /** 1 = no repeats. */ + public int repetitions = 1; + + public String toString() + { + return String.format( + "{ pos=%s, distance=%s, repetitions=%d }", + pos, distance, repetitions + ); + } +} + +public static final int OBJ_CIRCLE = 1<<0; +public static final int OBJ_SLIDER = 1<<1; +public static final int OBJ_SPINNER = 1<<3; + +/** strain index for speed */ +public final static int DIFF_SPEED = 0; + +/** strain index for aim */ +public final static int DIFF_AIM = 1; + +public static class HitObject +{ + /** start time in milliseconds. */ + public double time = 0.0; + public int type = OBJ_CIRCLE; + + /** an instance of Circle or Slider or null. */ + public Object data = null; + public Vector2 normpos = new Vector2(); + public double angle = 0.0; + public final double[] strains = new double[] { 0.0, 0.0 }; + public boolean is_single = false; + public double delta_time = 0.0; + public double d_distance = 0.0; + + /** string representation of the type bitmask. */ + public String typestr() + { + StringBuilder res = new StringBuilder(); + + if ((type & OBJ_CIRCLE) != 0) res.append("circle | "); + if ((type & OBJ_SLIDER) != 0) res.append("slider | "); + if ((type & OBJ_SPINNER) != 0) res.append("spinner | "); + + String result = res.toString(); + return result.substring(0, result.length() - 3); + } + + public String toString() + { + return String.format( + "{ time=%s, type=%s, data=%s, normpos=%s, " + + "strains=[ %s, %s ], is_single=%s }", + time, typestr(), data, normpos, strains[0], strains[1], + is_single + ); + } +} + +public static class Timing +{ + /** start time in milliseconds. */ + public double time = 0.0; + public double ms_per_beat = -100.0; + + /** if false, ms_per_beat is -100 * bpm_multiplier. */ + public boolean change = false; +} + +/** +* the bare minimum beatmap data for difficulty calculation. +* +* this object can be reused for multiple beatmaps without +* re-allocation by simply calling reset() +*/ +public static class Map +{ + public int format_version; + public int mode; + public String title, title_unicode; + public String artist, artist_unicode; + + /** mapper name. */ + public String creator; + + /** difficulty name. */ + public String version; + + public int ncircles, nsliders, nspinners; + public float hp, cs, od, ar; + public float sv, tick_rate; + + public final ArrayList objects = + new ArrayList(512); + + public final ArrayList tpoints = + new ArrayList(32); + + public Map() { reset(); } + + /** clears the instance so that it can be reused. */ + public void reset() + { + title = title_unicode = + artist = artist_unicode = + creator = + version = ""; + + ncircles = nsliders = nspinners = 0; + hp = cs = od = ar = 5.0f; + sv = tick_rate = 1.0f; + + objects.clear(); tpoints.clear(); + } + + public String toString() + { + StringBuilder sb = new StringBuilder(); + + for (HitObject obj : objects) + { + sb.append(obj); + sb.append(", "); + } + + String objs_str = sb.toString(); + + sb.setLength(0); + + for (Timing t : tpoints) + { + sb.append(t); + sb.append(", "); + } + + String timing_str = sb.toString(); + + return String.format( + "beatmap { mode=%d, title=%s, title_unicode=%s, " + + "artist=%s, artist_unicode=%s, creator=%s, " + + "version=%s, ncircles=%d, nsliders=%d, nspinners=%d," + + " hp=%s, cs=%s, od=%s, ar=%s, sv=%s, tick_rate=%s, " + + "tpoints=[ %s ], objects=[ %s ] }", + mode, title, title_unicode, artist, artist_unicode, + creator, version, ncircles, nsliders, nspinners, hp, + cs, od, ar, sv, tick_rate, timing_str, objs_str + ); + } + + public int max_combo() + { + int res = 0; + int tindex = -1; + double tnext = Double.NEGATIVE_INFINITY; + double px_per_beat = 0.0; + + for (HitObject obj : objects) + { + if ((obj.type & OBJ_SLIDER) == 0) + { + /* non-sliders add 1 combo */ + ++res; + continue; + } + + /* keep track of the current timing point without + looping through all of them for every object */ + while (obj.time >= tnext) + { + ++tindex; + + if (tpoints.size() > tindex + 1) { + tnext = tpoints.get(tindex + 1).time; + } else { + tnext = Double.POSITIVE_INFINITY; + } + + Timing t = tpoints.get(tindex); + + double sv_multiplier = 1.0; + + if (!t.change && t.ms_per_beat < 0) { + sv_multiplier = -100.0 / t.ms_per_beat; + } + + px_per_beat = sv * 100.0 * sv_multiplier; + if (format_version < 8) { + px_per_beat /= sv_multiplier; + } + } + + /* slider, we need to calculate slider ticks */ + Slider sl = (Slider)obj.data; + + double num_beats = + (sl.distance * sl.repetitions) / px_per_beat; + + int ticks = (int) + Math.ceil( + (num_beats - 0.1) / sl.repetitions * tick_rate + ); + + --ticks; + ticks *= sl.repetitions; + ticks += sl.repetitions + 1; + + res += Math.max(0, ticks); + } + + return res; + } +} + +/* ------------------------------------------------------------- */ +/* beatmap parser */ + +/* note: I just let parser throw built-in exceptions instead of +error checking stuff because it's as good as making my own +exception since you can check lastline/lastpos when you catch */ + +public static class Parser +{ + /** last line touched. */ + public String lastline; + + /** last line number touched. */ + public int nline; + + /** last token touched. */ + public String lastpos; + + /** true if the parsing completed successfully. */ + public boolean done; + + /** + * the parsed beatmap will be stored in this object. + * willl persist throughout reset() calls and will be reused by + * subsequent parse calls until changed. + * @see Parser#reset + */ + public Map beatmap = null; + + private String section; /* current section */ + private boolean ar_found = false; + + public Parser() { reset(); } + + private void reset() + { + lastline = lastpos = section = ""; + nline = 0; + done = false; + if (beatmap != null) { + beatmap.reset(); + } + } + + public String toString() + { + return String.format( + "in line %d\n%s\n> %s", nline, lastline, lastpos + ); + } + + private void warn(String fmt, Object... args) + { + info("W: "); + info(fmt, args); + info("\n%s\n", this); + } + + /** + * trims v, sets lastpos to it and returns trimmed v. + * should be used to access any string that can make the parser + * fail + */ + private String setlastpos(String v) + { + v = v.trim(); + lastpos = v; + return v; + } + + private String[] property() + { + String[] split = lastline.split(":", 2); + split[0] = setlastpos(split[0]); + if (split.length > 1) { + split[1] = setlastpos(split[1]); + } + /* why does java have such inconsistent naming? ArrayList + length is .size(), normal array length is .length, string + length is .length(). why do I have to look up documentation + for stuff that should have the same interface? */ + return split; + } + + private void metadata() + { + String[] p = property(); + + if (p[0].equals("Title")) { + beatmap.title = p[1]; + } + else if (p[0].equals("TitleUnicode")) { + beatmap.title_unicode = p[1]; + } + else if (p[0].equals("Artist")) { + beatmap.artist = p[1]; + } + else if (p[0].equals("ArtistUnicode")) { + beatmap.artist_unicode = p[1]; + } + else if (p[0].equals("Creator")) { + beatmap.creator = p[1]; + } + else if (p[0].equals("Version")) { + beatmap.version = p[1]; + } + } + + private void general() + { + String[] p = property(); + + if (p[0].equals("Mode")) + { + beatmap.mode = Integer.parseInt(setlastpos(p[1])); + + if (beatmap.mode != MODE_STD && beatmap.mode != MODE_TK) + { + throw new UnsupportedOperationException( + "this gamemode is not yet supported" + ); + } + } + } + + private void difficulty() + { + String[] p = property(); + + /* what's up with the redundant Float.parseFloat ?_? */ + if (p[0].equals("CircleSize")) { + beatmap.cs = Float.parseFloat(setlastpos(p[1])); + } + else if (p[0].equals("OverallDifficulty")) { + beatmap.od = Float.parseFloat(setlastpos(p[1])); + } + else if (p[0].equals("ApproachRate")) { + beatmap.ar = Float.parseFloat(setlastpos(p[1])); + ar_found = true; + } + else if (p[0].equals("HPDrainRate")) { + beatmap.hp = Float.parseFloat(setlastpos(p[1])); + } + else if (p[0].equals("SliderMultiplier")) { + beatmap.sv = Float.parseFloat(setlastpos(p[1])); + } + else if (p[0].equals("SliderTickRate")) { + beatmap.tick_rate = Float.parseFloat(setlastpos(p[1])); + } + } + + private void timing() + { + String[] s = lastline.split(","); + + if (s.length > 8) { + warn("timing point with trailing values"); + } + + Timing t = new Timing(); + t.time = Double.parseDouble(setlastpos(s[0])); + t.ms_per_beat = Double.parseDouble(setlastpos(s[1])); + + if (s.length >= 7) { + t.change = !s[6].trim().equals("0"); + } + + beatmap.tpoints.add(t); + } + + private void objects() + { + String[] s = lastline.split(","); + + if (s.length > 11) { + warn("object with trailing values"); + } + + HitObject obj = new HitObject(); + obj.time = Double.parseDouble(setlastpos(s[2])); + obj.type = Integer.parseInt(setlastpos(s[3])); + + if ((obj.type & OBJ_CIRCLE) != 0) + { + ++beatmap.ncircles; + Circle c = new Circle(); + c.pos.x = Double.parseDouble(setlastpos(s[0])); + c.pos.y = Double.parseDouble(setlastpos(s[1])); + obj.data = c; + } + + else if ((obj.type & OBJ_SPINNER) != 0) { + ++beatmap.nspinners; + } + + else if ((obj.type & OBJ_SLIDER) != 0) + { + ++beatmap.nsliders; + Slider sli = new Slider(); + sli.pos.x = Double.parseDouble(setlastpos(s[0])); + sli.pos.y = Double.parseDouble(setlastpos(s[1])); + sli.repetitions = Integer.parseInt(setlastpos(s[6])); + sli.distance = Double.parseDouble(setlastpos(s[7])); + obj.data = sli; + } + + beatmap.objects.add(obj); + } + + /** + * calls reset() on beatmap and parses a osu file into it. + * if beatmap is null, it will be initialized to a new Map + * @return this.beatmap + * @throws IOException + */ + public Map map(BufferedReader reader) throws IOException + { + String line = null; + + if (beatmap == null) { + beatmap = new Map(); + } + + reset(); + + while ((line = reader.readLine()) != null) + { + lastline = line; + ++nline; + + /* comments (according to lazer) */ + if (line.startsWith(" ") || line.startsWith("_")) { + continue; + } + + line = lastline = line.trim(); + if (line.length() <= 0) { + continue; + } + + /* c++ style comments */ + if (line.startsWith("//")) { + continue; + } + + /* [SectionName] */ + if (line.startsWith("[")) { + section = line.substring(1, line.length() - 1); + continue; + } + + try + { + if (section.equals("Metadata")) + metadata(); + else if (section.equals("General")) + general(); + else if (section.equals("Difficulty")) + difficulty(); + else if (section.equals("TimingPoints")) + timing(); + else if (section.equals("HitObjects")) + objects(); + else { + int fmt_index = line.indexOf("file format v"); + if (fmt_index < 0) { + continue; + } + + beatmap.format_version = Integer.parseInt( + line.substring(fmt_index + 13) + ); + } + } + + catch (NumberFormatException e) { + warn("ignoring line with bad number"); + } catch (ArrayIndexOutOfBoundsException e) { + warn("ignoring malformed line"); + } + } + + if (!ar_found) { + beatmap.ar = beatmap.od; + } + + done = true; + return beatmap; + } + + /** + * sets beatmap and returns map(reader) + * @return this.beatmap + * @throws IOException + */ + public Map map(BufferedReader reader, Map beatmap) + throws IOException + { + this.beatmap = beatmap; + return map(reader); + } +} + +/* ------------------------------------------------------------- */ +/* mods utils */ + +public static final int MODS_NOMOD = 0; + +public static final int MODS_NF = 1<<0; +public static final int MODS_EZ = 1<<1; +public static final int MODS_TOUCH_DEVICE = 1<<2; +public static final int MODS_TD = MODS_TOUCH_DEVICE; +public static final int MODS_HD = 1<<3; +public static final int MODS_HR = 1<<4; +public static final int MODS_DT = 1<<6; +public static final int MODS_HT = 1<<8; +public static final int MODS_NC = 1<<9; +public static final int MODS_FL = 1<<10; +public static final int MODS_SO = 1<<12; +public static final int MODS_SD = 1<<5; +public static final int MODS_PF = 1<<14; + +public static final int MODS_SPEED_CHANGING = + MODS_DT | MODS_HT | MODS_NC; + +public static final int MODS_MAP_CHANGING = + MODS_HR | MODS_EZ | MODS_SPEED_CHANGING; + +/** @return a string representation of the mods, such as HDDT */ +public static +String mods_str(int mods) +{ + StringBuilder sb = new StringBuilder(); + + if ((mods & MODS_NF) != 0) { + sb.append("NF"); + } + + if ((mods & MODS_EZ) != 0) { + sb.append("EZ"); + } + + if ((mods & MODS_TOUCH_DEVICE) != 0) { + sb.append("TD"); + } + + if ((mods & MODS_HD) != 0) { + sb.append("HD"); + } + + if ((mods & MODS_HR) != 0) { + sb.append("HR"); + } + + if ((mods & MODS_NC) != 0) { + sb.append("NC"); + } else if ((mods & MODS_DT) != 0) { + sb.append("DT"); + } + + if ((mods & MODS_HT) != 0) { + sb.append("HT"); + } + + if ((mods & MODS_FL) != 0) { + sb.append("FL"); + } + + if ((mods & MODS_SO) != 0) { + sb.append("SO"); + } + + if ((mods & MODS_PF) != 0) { + sb.append("PF"); + } else if ((mods & MODS_SD) != 0) { + sb.append("SD"); + } + + return sb.toString(); +} + +/** @return mod bitmask from the string representation */ +public static +int mods_from_str(String str) +{ + int mask = 0; + + while (str.length() > 0) + { + if (str.startsWith("NF")) mask |= MODS_NF; + else if (str.startsWith("EZ")) mask |= MODS_EZ; + else if (str.startsWith("TD")) mask |= MODS_TOUCH_DEVICE; + else if (str.startsWith("HD")) mask |= MODS_HD; + else if (str.startsWith("HR")) mask |= MODS_HR; + else if (str.startsWith("DT")) mask |= MODS_DT; + else if (str.startsWith("HT")) mask |= MODS_HT; + else if (str.startsWith("NC")) mask |= MODS_NC; + else if (str.startsWith("FL")) mask |= MODS_FL; + else if (str.startsWith("SO")) mask |= MODS_SO; + else { + str = str.substring(1); + continue; + } + str = str.substring(2); + } + + return mask; +} + +/** +* beatmap stats with mods applied. +* should be populated with the base beatmap stats and passed to +* mods_apply which will modify the stats for the given mods +*/ +public static class MapStats +{ + float ar, od, cs, hp; + + public MapStats() { + + } + + public MapStats(Map map) { + ar = map.ar; + od = map.od; + cs = map.cs; + hp = map.hp; + } + + /** + * speed multiplier / music rate. + * this doesn't need to be initialized before calling mods_apply + */ + float speed = 1.0f; +} + +private static final double OD0_MS = 80; +private static final double OD10_MS = 20; +private static final double AR0_MS = 1800.0; +private static final double AR5_MS = 1200.0; +private static final double AR10_MS = 450.0; + +private static final double OD_MS_STEP = (OD0_MS - OD10_MS) / 10.0; +private static final double AR_MS_STEP1 = (AR0_MS - AR5_MS) / 5.0; +private static final double AR_MS_STEP2 = (AR5_MS - AR10_MS) / 5.0; + +private static final int APPLY_AR = 1<<0; +private static final int APPLY_OD = 1<<1; +private static final int APPLY_CS = 1<<2; +private static final int APPLY_HP = 1<<3; + +/** +* applies mods to mapstats. +* +*
+* Koohii.MapStats mapstats = new Koohii.MapStats();
+* mapstats.ar = 9;
+* Koohii.mods_apply(Koohii.MODS_DT, mapstats, Koohii.APPLY_AR);
+* // mapstats.ar is now 10.33, mapstats.speed is 1.5
+* 
+* +* @param mapstats the base beatmap stats +* @param flags bitmask that specifies which stats to modify. only +* the stats specified here need to be initialized in +* mapstats. +* @return mapstats +* @see MapStats +*/ +public static +MapStats mods_apply(int mods, MapStats mapstats, int flags) +{ + if ((mods & MODS_MAP_CHANGING) == 0) { + return mapstats; + } + + if ((mods & (MODS_DT | MODS_NC)) != 0) { + mapstats.speed = 1.5f; + } + + if ((mods & MODS_HT) != 0) { + mapstats.speed *= 0.75f; + } + + float od_ar_hp_multiplier = 1.0f; + + if ((mods & MODS_HR) != 0) { + od_ar_hp_multiplier = 1.4f; + } + + if ((mods & MODS_EZ) != 0) { + od_ar_hp_multiplier *= 0.5f; + } + + if ((flags & APPLY_AR) != 0) + { + mapstats.ar *= od_ar_hp_multiplier; + + /* convert AR into milliseconds window */ + double arms = mapstats.ar < 5.0f ? + AR0_MS - AR_MS_STEP1 * mapstats.ar + : AR5_MS - AR_MS_STEP2 * (mapstats.ar - 5.0f); + + /* stats must be capped to 0-10 before HT/DT which brings + them to a range of -4.42->11.08 for OD and -5->11 for AR */ + arms = Math.min(AR0_MS, Math.max(AR10_MS, arms)); + arms /= mapstats.speed; + + mapstats.ar = (float)( + arms > AR5_MS ? + (AR0_MS - arms) / AR_MS_STEP1 + : 5.0 + (AR5_MS - arms) / AR_MS_STEP2 + ); + } + + if ((flags & APPLY_OD) != 0) + { + mapstats.od *= od_ar_hp_multiplier; + double odms = OD0_MS - Math.ceil(OD_MS_STEP * mapstats.od); + odms = Math.min(OD0_MS, Math.max(OD10_MS, odms)); + odms /= mapstats.speed; + mapstats.od = (float)((OD0_MS - odms) / OD_MS_STEP); + } + + if ((flags & APPLY_CS) != 0) + { + if ((mods & MODS_HR) != 0) { + mapstats.cs *= 1.3f; + } + + if ((mods & MODS_EZ) != 0) { + mapstats.cs *= 0.5f; + } + + mapstats.cs = Math.min(10.0f, mapstats.cs); + } + + if ((flags & APPLY_HP) != 0) + { + mapstats.hp = + Math.min(10.0f, mapstats.hp * od_ar_hp_multiplier); + } + + return mapstats; +} + +/* ------------------------------------------------------------- */ +/* difficulty calculator */ + +/** +* arbitrary thresholds to determine when a stream is spaced +* enough that it becomes hard to alternate. +*/ +private final static double SINGLE_SPACING = 125.0; + +/** strain decay per interval. */ +private final static double[] DECAY_BASE = { 0.3, 0.15 }; + +/** balances speed and aim. */ +private final static double[] WEIGHT_SCALING = { 1400.0, 26.25 }; + +/** +* max strains are weighted from highest to lowest, this is how +* much the weight decays. +*/ +private final static double DECAY_WEIGHT = 0.9; + +/** +* strains are calculated by analyzing the map in chunks and taking +* the peak strains in each chunk. this is the length of a strain +* interval in milliseconds +*/ +private final static double STRAIN_STEP = 400.0; + +/** non-normalized diameter where the small circle buff starts. */ +private final static double CIRCLESIZE_BUFF_THRESHOLD = 30.0; + +/** global stars multiplier. */ +private final static double STAR_SCALING_FACTOR = 0.0675; + +/** in osu! pixels */ +private final static double PLAYFIELD_WIDTH = 512.0, + PLAYFIELD_HEIGHT = 384.0; + +private final static Vector2 PLAYFIELD_CENTER = new Vector2( + PLAYFIELD_WIDTH / 2.0, PLAYFIELD_HEIGHT / 2.0 +); + +/** +* 50% of the difference between aim and speed is added to total +* star rating to compensate for aim/speed only maps +*/ +private final static double EXTREME_SCALING_FACTOR = 0.5; + +private final static double MIN_SPEED_BONUS = 75.0; +private final static double MAX_SPEED_BONUS = 45.0; +private final static double ANGLE_BONUS_SCALE = 90.0; +private final static double AIM_TIMING_THRESHOLD = 107; +private final static double SPEED_ANGLE_BONUS_BEGIN = 5 * Math.PI / 6; +private final static double AIM_ANGLE_BONUS_BEGIN = Math.PI / 3; + +private static +double d_spacing_weight(int type, double distance, double delta_time, + double prev_distance, double prev_delta_time, double angle) +{ + double strain_time = Math.max(delta_time, 50.0); + double prev_strain_time = Math.max(prev_delta_time, 50.0); + double angle_bonus; + switch (type) + { + case DIFF_AIM: { + double result = 0.0; + if (!Double.isNaN(angle) && angle > AIM_ANGLE_BONUS_BEGIN) { + angle_bonus = Math.sqrt( + Math.max(prev_distance - ANGLE_BONUS_SCALE, 0.0) * + Math.pow(Math.sin(angle - AIM_ANGLE_BONUS_BEGIN), 2.0) * + Math.max(distance - ANGLE_BONUS_SCALE, 0.0) + ); + result = ( + 1.5 * Math.pow(Math.max(0.0, angle_bonus), 0.99) / + Math.max(AIM_TIMING_THRESHOLD, prev_strain_time) + ); + } + double weighted_distance = Math.pow(distance, 0.99); + return Math.max(result + + weighted_distance / + Math.max(AIM_TIMING_THRESHOLD, strain_time), + weighted_distance / strain_time); + } + + case DIFF_SPEED: { + distance = Math.min(distance, SINGLE_SPACING); + delta_time = Math.max(delta_time, MAX_SPEED_BONUS); + double speed_bonus = 1.0; + if (delta_time < MIN_SPEED_BONUS) { + speed_bonus += + Math.pow((MIN_SPEED_BONUS - delta_time) / 40.0, 2); + } + angle_bonus = 1.0; + if (!Double.isNaN(angle) && angle < SPEED_ANGLE_BONUS_BEGIN) { + double s = Math.sin(1.5 * (SPEED_ANGLE_BONUS_BEGIN - angle)); + angle_bonus += Math.pow(s, 2) / 3.57; + if (angle < Math.PI / 2.0) { + angle_bonus = 1.28; + if (distance < ANGLE_BONUS_SCALE && angle < Math.PI / 4.0) { + angle_bonus += (1.0 - angle_bonus) * + Math.min((ANGLE_BONUS_SCALE - distance) / 10.0, 1.0); + } + } else if (distance < ANGLE_BONUS_SCALE) { + angle_bonus += (1.0 - angle_bonus) * + Math.min((ANGLE_BONUS_SCALE - distance) / 10.0, 1.0) * + Math.sin((Math.PI / 2.0 - angle) * 4.0 / Math.PI); + } + } + return ( + (1 + (speed_bonus - 1) * 0.75) * angle_bonus * + (0.95 + speed_bonus * Math.pow(distance / SINGLE_SPACING, 3.5)) + ) / strain_time; + } + } + + throw new UnsupportedOperationException( + "this difficulty type does not exist" + ); +} + +/** +* calculates the strain for one difficulty type and stores it in +* obj. this assumes that normpos is already computed. +* this also sets is_single if type is DIFF_SPEED +*/ +private static +void d_strain(int type, HitObject obj, HitObject prev, + double speed_mul) +{ + double value = 0.0; + double time_elapsed = (obj.time - prev.time) / speed_mul; + double decay = + Math.pow(DECAY_BASE[type], time_elapsed / 1000.0); + + obj.delta_time = time_elapsed; + + /* this implementation doesn't account for sliders */ + if ((obj.type & (OBJ_SLIDER | OBJ_CIRCLE)) != 0) + { + double distance = + new Vector2(obj.normpos).sub(prev.normpos).len(); + obj.d_distance = distance; + + if (type == DIFF_SPEED) { + obj.is_single = distance > SINGLE_SPACING; + } + + value = d_spacing_weight(type, distance, time_elapsed, + prev.d_distance, prev.delta_time, obj.angle); + value *= WEIGHT_SCALING[type]; + } + + obj.strains[type] = prev.strains[type] * decay + value; +} + +/** +* difficulty calculator, can be reused in subsequent calc() calls. +*/ +public static class DiffCalc +{ + /** star rating. */ + public double total; + + /** aim stars. */ + public double aim; + + /** aim difficulty (used to calc length bonus) */ + public double aim_difficulty; + + /** aim length bonus (unused at the moment) */ + public double aim_length_bonus; + + /** speed stars. */ + public double speed; + + /** speed difficulty (used to calc length bonus) */ + public double speed_difficulty; + + /** speed length bonus (unused at the moment) */ + public double speed_length_bonus; + + /** + * number of notes that are considered singletaps by the + * difficulty calculator. + */ + public int nsingles; + + /** + * number of taps slower or equal to the singletap threshold + * value. + */ + public int nsingles_threshold; + + /** + * the beatmap we want to calculate the difficulty for. + * must be set or passed to calc() explicitly. + * persists across calc() calls unless it's changed or explicity + * passed to calc() + * @see DiffCalc#calc(Koohii.Map, int, double) + * @see DiffCalc#calc(Koohii.Map, int) + * @see DiffCalc#calc(Koohii.Map) + */ + public Map beatmap = null; + + private double speed_mul; + private final ArrayList strains = + new ArrayList(512); + + public DiffCalc() { reset(); } + + /** sets up the instance for re-use by resetting fields. */ + private void reset() + { + total = aim = speed = 0.0; + nsingles = nsingles_threshold = 0; + speed_mul = 1.0; + } + + public String toString() + { + return String.format("%s stars (%s aim, %s speed)", + total, aim, speed); + } + + private static double length_bonus(double stars, double difficulty) { + return ( + 0.32 + 0.5 * + (Math.log10(difficulty + stars) - Math.log10(stars)) + ); + } + + private class DiffValues + { + public double difficulty, total; + + public DiffValues(double difficulty, double total) { + this.difficulty = difficulty; + this.total = total; + } + }; + + private DiffValues calc_individual(int type) + { + strains.clear(); + + double strain_step = STRAIN_STEP * speed_mul; + double interval_end = strain_step; + double max_strain = 0.0; + + /* calculate all strains */ + for (int i = 0; i < beatmap.objects.size(); ++i) + { + HitObject obj = beatmap.objects.get(i); + HitObject prev = i > 0 ? + beatmap.objects.get(i - 1) : null; + + if (prev != null) { + d_strain(type, obj, prev, speed_mul); + } + + while (obj.time > interval_end) + { + /* add max strain for this interval */ + strains.add(max_strain); + + if (prev != null) + { + /* decay last object's strains until the next + interval and use that as the initial max + strain */ + double decay = Math.pow(DECAY_BASE[type], + (interval_end - prev.time) / 1000.0); + + max_strain = prev.strains[type] * decay; + } else { + max_strain = 0.0; + } + + interval_end += strain_step; + } + + max_strain = Math.max(max_strain, obj.strains[type]); + } + + /* weigh the top strains sorted from highest to lowest */ + double weight = 1.0; + double total = 0.0; + double difficulty = 0.0; + + Collections.sort(strains, Collections.reverseOrder()); + + for (Double strain : strains) + { + total += Math.pow(strain, 1.2); + difficulty += strain * weight; + weight *= DECAY_WEIGHT; + } + + return new DiffValues(difficulty, total); + } + + /** + * default value for singletap_threshold. + * @see DiffCalc#calc + */ + public final static double DEFAULT_SINGLETAP_THRESHOLD = 125.0; + + /** + * calculates beatmap difficulty and stores it in total, aim, + * speed, nsingles, nsingles_speed fields. + * @param singletap_threshold the smallest milliseconds interval + * that will be considered singletappable. for example, + * 125ms is 240 1/2 singletaps ((60000 / 240) / 2) + * @return self + */ + public DiffCalc calc(int mods, double singletap_threshold) + { + reset(); + + MapStats mapstats = new MapStats(); + mapstats.cs = beatmap.cs; + mods_apply(mods, mapstats, APPLY_CS); + speed_mul = mapstats.speed; + + double radius = (PLAYFIELD_WIDTH / 16.0) * + (1.0 - 0.7 * (mapstats.cs - 5.0) / 5.0); + + /* positions are normalized on circle radius so that we can + calc as if everything was the same circlesize */ + double scaling_factor = 52.0 / radius; + + if (radius < CIRCLESIZE_BUFF_THRESHOLD) + { + scaling_factor *= 1.0 + + Math.min(CIRCLESIZE_BUFF_THRESHOLD - radius, 5.0) / 50.0; + } + + Vector2 normalized_center = + new Vector2(PLAYFIELD_CENTER).mul(scaling_factor); + + HitObject prev1 = null; + HitObject prev2 = null; + int i = 0; + + /* calculate normalized positions */ + for (HitObject obj : beatmap.objects) + { + if ((obj.type & OBJ_SPINNER) != 0) { + obj.normpos = new Vector2(normalized_center); + } + + else + { + Vector2 pos; + + if ((obj.type & OBJ_SLIDER) != 0) { + pos = ((Slider)obj.data).pos; + } + + else if ((obj.type & OBJ_CIRCLE) != 0) { + pos = ((Circle)obj.data).pos; + } + + else + { + info( + "W: unknown object type %08X\n", + obj.type + ); + pos = new Vector2(); + } + + obj.normpos = new Vector2(pos).mul(scaling_factor); + } + + if (i >= 2) { + Vector2 v1 = new Vector2(prev2.normpos).sub(prev1.normpos); + Vector2 v2 = new Vector2(obj.normpos).sub(prev1.normpos); + double dot = v1.dot(v2); + double det = v1.x * v2.y - v1.y * v2.x; + obj.angle = Math.abs(Math.atan2(det, dot)); + } else { + obj.angle = Double.NaN; + } + + prev2 = prev1; + prev1 = obj; + ++i; + } + + /* speed and aim stars */ + + DiffValues aimvals = calc_individual(DIFF_AIM); + aim = aimvals.difficulty; + aim_difficulty = aimvals.total; + aim_length_bonus = length_bonus(aim, aim_difficulty); + + DiffValues speedvals = calc_individual(DIFF_SPEED); + speed = speedvals.difficulty; + speed_difficulty = speedvals.total; + speed_length_bonus = length_bonus(speed, speed_difficulty); + + aim = Math.sqrt(aim) * STAR_SCALING_FACTOR; + speed = Math.sqrt(speed) * STAR_SCALING_FACTOR; + if ((mods & MODS_TOUCH_DEVICE) != 0) { + aim = Math.pow(aim, 0.8); + } + + /* total stars */ + total = aim + speed + + Math.abs(speed - aim) * EXTREME_SCALING_FACTOR; + + /* singletap stats */ + for (i = 1; i < beatmap.objects.size(); ++i) + { + HitObject prev = beatmap.objects.get(i - 1); + HitObject obj = beatmap.objects.get(i); + + if (obj.is_single) { + ++nsingles; + } + + if ((obj.type & (OBJ_CIRCLE | OBJ_SLIDER)) == 0) { + continue; + } + + double interval = (obj.time - prev.time) / speed_mul; + + if (interval >= singletap_threshold) { + ++nsingles_threshold; + } + } + + return this; + } + + /** + * @return calc(mods, DEFAULT_SINGLETAP_THRESHOLD) + * @see DiffCalc#calc(int, double) + * @see DiffCalc#DEFAULT_SINGLETAP_THRESHOLD + */ + public DiffCalc calc(int mods) { + return calc(mods, DEFAULT_SINGLETAP_THRESHOLD); + } + + /** + * @return calc(MODS_NOMOD, DEFAULT_SINGLETAP_THRESHOLD) + * @see DiffCalc#calc(int, double) + * @see DiffCalc#DEFAULT_SINGLETAP_THRESHOLD + */ + public DiffCalc calc() { + return calc(MODS_NOMOD, DEFAULT_SINGLETAP_THRESHOLD); + } + + /** + * sets beatmap field and calls + * calc(mods, singletap_threshold). + * @see DiffCalc#calc(int, double) + */ + public DiffCalc calc(Map beatmap, int mods, + double singletap_threshold) + { + this.beatmap = beatmap; + return calc(mods, singletap_threshold); + } + + /** + * sets beatmap field and calls + * calc(mods, DEFAULT_SINGLETAP_THRESHOLD). + * @see DiffCalc#calc(int, double) + * @see DiffCalc#DEFAULT_SINGLETAP_THRESHOLD + */ + public DiffCalc calc(Map beatmap, int mods) { + return calc(beatmap, mods, DEFAULT_SINGLETAP_THRESHOLD); + } + + /** + * sets beatmap field and calls + * calc(MODS_NOMOD, DEFAULT_SINGLETAP_THRESHOLD). + * @see DiffCalc#calc(int, double) + * @see DiffCalc#DEFAULT_SINGLETAP_THRESHOLD + */ + public DiffCalc calc(Map beatmap) + { + return calc(beatmap, MODS_NOMOD, + DEFAULT_SINGLETAP_THRESHOLD); + } +} + +/* ------------------------------------------------------------- */ +/* acc calc */ + +public static class Accuracy +{ + public int n300 = 0, n100 = 0, n50 = 0, nmisses = 0; + + public Accuracy() {} + + /** + * @param n300 the number of 300s, if -1 it will be calculated + * from the object count in Accuracy#value(int). + */ + public Accuracy(int n300, int n100, int n50, int nmisses) + { + this.n300 = n300; + this.n100 = n100; + this.n50 = n50; + this.nmisses = nmisses; + } + + /** + * calls Accuracy(-1, n100, n50, nmisses) . + * @see Accuracy#Accuracy(int, int, int, int) + */ + public Accuracy(int n100, int n50, int nmisses) { + this(-1, n100, n50, nmisses); + } + + /** + * calls Accuracy(-1, n100, n50, 0) . + * @see Accuracy#Accuracy(int, int, int, int) + */ + public Accuracy(int n100, int n50) { + this(-1, n100, n50, 0); + } + + /** + * calls Accuracy(-1, n100, 0, 0) . + * @see Accuracy#Accuracy(int, int, int, int) + */ + public Accuracy(int n100) { + this(-1, n100, 0, 0); + } + + /** + * rounds to the closest amount of 300s, 100s, 50s for a given + * accuracy percentage. + * @param nobjects the total number of hits (n300 + n100 + n50 + + * nmisses) + */ + public Accuracy(double acc_percent, int nobjects, int nmisses) + { + nmisses = Math.min(nobjects, nmisses); + int max300 = nobjects - nmisses; + + double maxacc = + new Accuracy(max300, 0, 0, nmisses).value() * 100.0; + + acc_percent = Math.max(0.0, Math.min(maxacc, acc_percent)); + + /* just some black magic maths from wolfram alpha */ + n100 = (int) + Math.round( + -3.0 * + ((acc_percent * 0.01 - 1.0) * nobjects + nmisses) * + 0.5 + ); + + if (n100 > max300) + { + /* acc lower than all 100s, use 50s */ + n100 = 0; + + n50 = (int) + Math.round( + -6.0 * + ((acc_percent * 0.01 - 1.0) * nobjects + + nmisses) * 0.5 + ); + + n50 = Math.min(max300, n50); + } + + n300 = nobjects - n100 - n50 - nmisses; + } + + /** + * @param nobjects the total number of hits (n300 + n100 + n50 + + * nmiss). if -1, n300 must have been set and + * will be used to deduce this value. + * @return the accuracy value (0.0-1.0) + */ + public double value(int nobjects) + { + if (nobjects < 0 && n300 < 0) + { + throw new IllegalArgumentException( + "either nobjects or n300 must be specified" + ); + } + + int n300_ = n300 > 0 ? n300 : + nobjects - n100 - n50 - nmisses; + + if (nobjects < 0) { + nobjects = n300_ + n100 + n50 + nmisses; + } + + double res = (n50 * 50.0 + n100 * 100.0 + n300_ * 300.0) / + (nobjects * 300.0); + + return Math.max(0, Math.min(res, 1.0)); + } + + /** + * calls value(-1) . + * @see Accuracy#value(int) + */ + public double value() { + return value(-1); + } +} + +/* ------------------------------------------------------------- */ +/* pp calc */ + +/* base pp value for stars, used internally by ppv2 */ +private static +double pp_base(double stars) +{ + return Math.pow(5.0 * Math.max(1.0, stars / 0.0675) - 4.0, 3.0) + / 100000.0; +} + +/** +* parameters to be passed to PPv2. +* aim_stars, speed_stars, max_combo, nsliders, ncircles, nobjects, +* base_ar, base_od are required. +* @see PPv2#PPv2(Koohii.PPv2Parameters) +*/ +public static class PPv2Parameters +{ + /** + * if not null, max_combo, nsliders, ncircles, nobjects, + * base_ar, base_od will be obtained from this beatmap. + */ + public Map beatmap = null; + + public double aim_stars = 0.0; + public double speed_stars = 0.0; + public int max_combo = 0; + public int nsliders = 0, ncircles = 0, nobjects = 0; + + /** the base AR (before applying mods). */ + public float base_ar = 5.0f; + + /** the base OD (before applying mods). */ + public float base_od = 5.0f; + + /** gamemode. */ + public int mode = MODE_STD; + + /** the mods bitmask, same as osu! api, see MODS_* constants */ + public int mods = MODS_NOMOD; + + /** + * the maximum combo achieved, if -1 it will default to + * max_combo - nmiss . + */ + public int combo = -1; + + /** + * number of 300s, if -1 it will default to + * nobjects - n100 - n50 - nmiss . + */ + public int n300 = -1; + public int n100 = 0, n50 = 0, nmiss = 0; + + /** scorev1 (1) or scorev2 (2). */ + public int score_version = 1; +} + +public static class PPv2 +{ + public double total, aim, speed, acc; + public Accuracy computed_accuracy; + public double real_acc; + + /** + * calculates ppv2, results are stored in total, aim, speed, + * acc, acc_percent. + * @see PPv2Parameters + */ + private PPv2(double aim_stars, double speed_stars, + int max_combo, int nsliders, int ncircles, int nobjects, + float base_ar, float base_od, int mode, int mods, + int combo, int n300, int n100, int n50, int nmiss, + int score_version, Map beatmap) + { + if (beatmap != null) + { + mode = beatmap.mode; + base_ar = beatmap.ar; + base_od = beatmap.od; + max_combo = beatmap.max_combo(); + nsliders = beatmap.nsliders; + ncircles = beatmap.ncircles; + if(nobjects == 0) { + nobjects = beatmap.objects.size(); + } + } + + if (mode != MODE_STD) + { + throw new UnsupportedOperationException( + "this gamemode is not yet supported" + ); + } + + if (max_combo <= 0) + { + info("W: max_combo <= 0, changing to 1\n"); + max_combo = 1; + } + + if (combo < 0) { + combo = max_combo - nmiss; + } + + if (n300 < 0) { + n300 = nobjects - n100 - n50 - nmiss; + } + + /* accuracy -------------------------------------------- */ + computed_accuracy = new Accuracy(n300, n100, n50, nmiss); + double accuracy = computed_accuracy.value(nobjects); + real_acc = accuracy; + + switch (score_version) + { + case 1: + /* scorev1 ignores sliders since they are free 300s + and for some reason also ignores spinners */ + int nspinners = nobjects - nsliders - ncircles; + + try { + real_acc = new Accuracy(n300 - nsliders - nspinners, + n100, n50, nmiss).value(); + } catch (IllegalArgumentException e) { + info(" Invalid values, using computed accuracy...\n"); + }; + + real_acc = Math.max(0.0, real_acc); + break; + + case 2: + ncircles = nobjects; + break; + + default: + throw new UnsupportedOperationException( + String.format("unsupported scorev%d", + score_version) + ); + } + + /* global values --------------------------------------- */ + double nobjects_over_2k = nobjects / 2000.0; + + double length_bonus = 0.95 + 0.4 * + Math.min(1.0, nobjects_over_2k); + + if (nobjects > 2000) { + length_bonus += Math.log10(nobjects_over_2k) * 0.5; + } + + double miss_penality = Math.pow(0.97, nmiss); + double combo_break = Math.pow(combo, 0.8) / + Math.pow(max_combo, 0.8); + + /* calculate stats with mods */ + MapStats mapstats = new MapStats(); + mapstats.ar = base_ar; + mapstats.od = base_od; + mods_apply(mods, mapstats, APPLY_AR | APPLY_OD); + + /* ar bonus -------------------------------------------- */ + double ar_bonus = 1.0; + + if (mapstats.ar > 10.33) { + ar_bonus += 0.3 * (mapstats.ar - 10.33); + } + + else if (mapstats.ar < 8.0) { + ar_bonus += 0.01 * (8.0 - mapstats.ar); + } + + /* aim pp ---------------------------------------------- */ + aim = pp_base(aim_stars); + aim *= length_bonus; + aim *= miss_penality; + aim *= combo_break; + aim *= ar_bonus; + + double hd_bonus = 1.0; + if ((mods & MODS_HD) != 0) { + hd_bonus *= 1.0 + 0.04 * (12.0 - mapstats.ar); + } + aim *= hd_bonus; + + if ((mods & MODS_FL) != 0) { + double fl_bonus = 1.0 + 0.35 * Math.min(1.0, nobjects / 200.0); + if (nobjects > 200) { + fl_bonus += 0.3 * Math.min(1.0, (nobjects - 200) / 300.0); + } + if (nobjects > 500) { + fl_bonus += (nobjects - 500) / 1200.0; + } + aim *= fl_bonus; + } + + double acc_bonus = 0.5 + accuracy / 2.0; + double od_squared = mapstats.od * mapstats.od; + double od_bonus = 0.98 + od_squared / 2500.0; + + aim *= acc_bonus; + aim *= od_bonus; + + /* speed pp -------------------------------------------- */ + speed = pp_base(speed_stars); + speed *= length_bonus; + speed *= miss_penality; + speed *= combo_break; + if (mapstats.ar > 10.33) { + speed *= ar_bonus; + } + speed *= hd_bonus; + + /* similar to aim acc and od bonus */ + speed *= 0.02 + accuracy; + speed *= 0.96 + od_squared / 1600.0; + + /* acc pp ---------------------------------------------- */ + acc = Math.pow(1.52163, mapstats.od) * + Math.pow(real_acc, 24.0) * 2.83; + + acc *= Math.min(1.15, Math.pow(ncircles / 1000.0, 0.3)); + + if ((mods & MODS_HD) != 0) { + acc *= 1.08; + } + + if ((mods & MODS_FL) != 0) { + acc *= 1.02; + } + + /* total pp -------------------------------------------- */ + double final_multiplier = 1.12; + + if ((mods & MODS_NF) != 0) { + final_multiplier *= 0.90; + } + + if ((mods & MODS_SO) != 0) { + final_multiplier *= 0.95; + } + + total = Math.pow( + Math.pow(aim, 1.1) + Math.pow(speed, 1.1) + + Math.pow(acc, 1.1), + 1.0 / 1.1 + ) * final_multiplier; + } + + /** @see PPv2Parameters */ + public PPv2(PPv2Parameters p) + { + this(p.aim_stars, p.speed_stars, p.max_combo, p.nsliders, + p.ncircles, p.nobjects, p.base_ar, p.base_od, p.mode, + p.mods, p.combo, p.n300, p.n100, p.n50, p.nmiss, + p.score_version, p.beatmap); + } + + /** + * simplest possible call, calculates ppv2 for SS scorev1. + * @see PPv2#PPv2(Koohii.PPv2Parameters) + */ + public PPv2(double aim_stars, double speed_stars, Map b) + { + this(aim_stars, speed_stars, -1, b.nsliders, b.ncircles, + b.objects.size(), b.ar, b.od, b.mode, MODS_NOMOD, -1, + -1, 0, 0, 0, 1, b); + } +} + +public static class TaikoPP { + + public Map map; + public double stars; + public int totalHits; + public int misses; + public int combo; + public int mods; + public double acc; + public double computedAcc; + public double rawod; + public double strain; + public double pp; + + public TaikoPP(double stars, double acc, PPv2Parameters p) { + this(stars, p.beatmap.ncircles + p.beatmap.nsliders, p.mods, acc, p.nmiss, p.beatmap); + if(p.combo == p.max_combo) p.combo = this.totalHits; + p.nobjects = p.max_combo = this.totalHits; + p.n50 = 0; + p.n100 = (int) Math.round((1 - ((double) misses/p.nobjects) - acc/100) * 2 * p.nobjects); + if(p.n100 < 0) { + p.n100 = 0; + this.acc = (p.nobjects - p.nmiss) / (double) (p.nobjects); + } + p.n300 = p.nobjects - p.n100 - p.nmiss; + } + + public TaikoPP(double stars, PPv2Parameters p) { //DO NOT USE THIS WITH DIFFCALC + this(stars, p.beatmap.ncircles + p.beatmap.nsliders, p.mods, (p.n100 * 0.5 + p.n300) / (double) (p.nmiss + p.n100 + p.n300), p.nmiss, p.beatmap); + } + + public TaikoPP(double stars, int combo, int mods, double acc, int misses, Map beatmap) { + this.map = beatmap; + this.stars = stars; + this.totalHits = beatmap.ncircles + beatmap.nsliders; + this.misses = misses; + this.combo = combo; + this.mods = mods; + this.acc = acc; + this.rawod = beatmap.od; + if(this.acc > 1) { + this.acc /= 100; + } + this.strain = computeStrain(this.combo, this.misses, this.acc); + this.computedAcc = computeAcc(this.acc); + this.pp = computeTotal(this.strain, this.computedAcc); + } + + private double computeTotal(double strain, double acc) { + double multiplier = 1.1; + if ((mods & MODS_NF) != 0) multiplier *= 0.9; + if ((mods & MODS_HD) != 0) multiplier *= 1.1; + return Math.pow(Math.pow(strain, 1.1) + Math.pow(acc, 1.1), 1.0 / 1.1) * multiplier; + } + + private double computeStrain(int combo, int misses, double acc) { + double strainValue = Math.pow(5 * Math.max(1, this.stars / 0.0075) - 4, 2) / 100000; + double lengthBonus = 1 + 0.1 * Math.min(1, (this.totalHits / 1500.0)); + strainValue *= lengthBonus * Math.pow(0.985, misses); + + if(combo > 0) strainValue *= Math.min(Math.pow(this.totalHits, 0.5) / Math.pow(combo, 0.5), 1); + if ((mods & MODS_HD) != 0) strainValue *= 1.025; + if ((mods & MODS_FL) != 0) strainValue *= (1.05 * lengthBonus); + + strainValue *= acc; + return strainValue; + } + + private double computeAcc(double acc) { + double hitWindow300 = hitWindow(); + if(hitWindow300 <= 0) return -1; + return Math.pow(150 / hitWindow300, 1.1) * Math.pow(acc, 15) * 22 * Math.min(Math.pow(this.totalHits / 1500.0, 0.3), 1.15); + } + + private double hitWindow() { + double od = scaleOD(); + double max = 20, min = 50; + double result = Math.floor(min + (max - min) * od / 10) - 0.5; + if ((mods & MODS_HT) != 0) result /= 0.75; + if ((mods & MODS_DT) != 0) result /= 1.5; + return Math.round(result * 100) / 100.0; + } + + private double scaleOD() { + double od = this.rawod; + if ((mods & MODS_EZ) != 0) od /= 2; + if ((mods & MODS_HR) != 0) od *= 1.4; + od = Math.max(Math.min(od, 10), 0); + return od; + } + +} + +} /* public final class Koohii */ diff --git a/src/me/despawningbone/discordbot/command/games/Osu.java b/src/me/despawningbone/discordbot/command/games/Osu.java new file mode 100644 index 0000000..8780662 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/games/Osu.java @@ -0,0 +1,1174 @@ +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.HashMap; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +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.PPv2Parameters; +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; + +public class Osu extends Command { + + private final static String osuAPI = DiscordBot.tokens.getProperty("osu"); + + private static DecimalFormat df = new DecimalFormat("#.##"); + + private final static HashMap modes = new HashMap<>(); + static { + modes.put(0, "osu!"); + modes.put(1, "osu!taiko"); + modes.put(2, "osu!catch"); + modes.put(3, "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) -> { + //OffsetDateTime timesent = OffsetDateTime.now(); + List amend = new ArrayList(Arrays.asList(words)); + int temp = amend.indexOf("-w"); + String uid = null; + boolean weight = temp != -1; + if(weight) { + try { + uid = amend.get(temp + 1); + if(!uid.contains("osu.ppy.sh/u") && (uid.startsWith("http") && uid.contains("://"))) { + uid = getPlayer(user); + } + amend.subList(temp, temp + 1).clear(); + } catch (IndexOutOfBoundsException e) { + uid = getPlayer(user); + } + } + String initmap; + try { + initmap = amend.get(0); + } catch (IndexOutOfBoundsException e) { + initmap = "null"; + } + List mid = new ArrayList(); + if (!initmap.startsWith("https://") && !initmap.startsWith("http://")) { //check if no map input, use discord rich presence + 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 -> mid.add(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(); + /*OffsetDateTime timeReceived = OffsetDateTime.now(); + long ms = Math.abs(timesent.until(timeReceived, ChronoUnit.MILLIS)); + System.out.println("Time taken: " + ms + "ms");*/ + 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) + //if(game.getName().equals("osu!") && game.isRich()) { + 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 + } + + /*SELENIUM DEPRECATED*/ + + /*String url = "http://osusearch.com/search/?title=" + title + "&diff_name=" + diff + + "&query_order=play_count"; + System.out.println(url); + //MsgListener.driver.manage().timeouts().implicitlyWait(2000, TimeUnit.MILLISECONDS); + DiscordBot.driver.get(url); + WebDriverWait wait = new WebDriverWait(DiscordBot.driver, 10); + wait.until(ExpectedConditions + .elementToBeClickable(By.cssSelector("div[class~=beatmap-list]"))); + Document document = Jsoup.parse(DiscordBot.driver.getPageSource()); + //System.out.println(document.toString()); + initmap = document.body() + .select("div[class~=beatmap-list]") + .first() + .child(0) + .select("div.truncate.beatmap-title a").get(0).attr("href");*/ + } else { + return new CommandResult(CommandResultType.FAILURE, "There is no account of your rich presence, therefore I cannot get the beatmap from your status."); + } + /* + * } else { throw new + * IllegalArgumentException("Rich presence is not an instance of osu!, therefore I cannot get the beatmap from your status." + * ); } + */ + } + if(initmap.equals("null")) { //shouldnt throw at all + return new CommandResult(CommandResultType.FAILURE, "You haven't played any maps I can recognize yet!"); + } + List params = Arrays.asList(words); + double dacc = 100; + int combo = -1, mods = 0, miss = 0; + PPv2Parameters p = new Koohii.PPv2Parameters(); + try { + p.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 (p.beatmap.title.isEmpty()) { + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid beatmap."); + } + try { + for (int i = 0; i < params.size(); i++) { + String param = params.get(i); + if (param.startsWith("+")) { + mods = Koohii.mods_from_str(param.substring(1).toUpperCase()); + } else if (param.toLowerCase().endsWith("m")) { + miss = Integer.parseInt(param.substring(0, param.length() - 1)); + } else if (param.endsWith("%")) { + dacc = Double.parseDouble(param.substring(0, param.length() - 1)); + } else if (param.toLowerCase().endsWith("x")) { + combo = Integer.parseInt(param.substring(0, param.length() - 1)); + } + } + } catch (NumberFormatException e) { + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid value inputted."); + } + DiffCalc stars = new Koohii.DiffCalc().calc(p.beatmap, mods); + Accuracy acc = new Accuracy(dacc, p.beatmap.objects.size(), miss); + p.n50 = acc.n50; + p.n100 = acc.n100; + p.n300 = acc.n300; + p.nmiss = miss; + p.aim_stars = stars.aim; + p.speed_stars = stars.speed; + p.mods = mods; + if (combo > 0) { + p.combo = combo; + } else { + p.combo = p.beatmap.max_combo(); + } + EmbedBuilder eb = new EmbedBuilder(); + JSONTokener result = null; + InputStream stream = null; + URL url = null; + try { + String addparam = ""; + if(p.beatmap.mode == 1) { + if((mods & 1<<8) != 0) addparam = "&mods=256"; + if((mods & 1<<6) != 0) if(addparam.isEmpty()) addparam = "&mods=64"; + else addparam = ""; + } + url = new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&b=" + initmap.substring(initmap.lastIndexOf("/") + 1).split("&")[0] + addparam); + stream = url.openStream(); + } catch (IOException ex) { + ex.printStackTrace(); + } + result = new JSONTokener(stream); + JSONObject jbm = new JSONArray(result).getJSONObject(0); + String setid = jbm.getString("beatmapset_id"); + double accVal, ppVal, starsVal; + MapStats stats = new MapStats(p.beatmap); + try { + PPv2 pp = new PPv2(p); + ppVal = pp.total; + accVal = acc.value(); //somehow real_acc aint correct, pretty sure i screwed sth up + starsVal = stars.total; + Koohii.mods_apply(mods, stats, 1|2|4|8); + } catch (UnsupportedOperationException e) { //should always only be taiko + starsVal = jbm.getDouble("difficultyrating"); + TaikoPP pp = new TaikoPP(starsVal, dacc, p); + ppVal = pp.pp; + accVal = pp.acc; + Koohii.mods_apply(mods, stats, 0); //want nothing but speed to be changed + } + String totaldur = MiscUtils.convertMillis(TimeUnit.SECONDS.toMillis((int)(jbm.getInt("total_length") / stats.speed))); + String draindur = MiscUtils.convertMillis(TimeUnit.SECONDS.toMillis((int)(jbm.getInt("hit_length") / stats.speed))); + if(jbm.has("source") && !jbm.getString("source").isEmpty()) eb.setTitle("Source: " + jbm.getString("source")); + eb.setDescription("Mode: " + modes.get(p.beatmap.mode)); + eb.setAuthor("PP information for " + (p.beatmap.title_unicode.isEmpty() ? p.beatmap.title : p.beatmap.title_unicode), + initmap, "https://b.ppy.sh/thumb/" + setid + ".jpg"); + eb.addField("Artist", (p.beatmap.artist_unicode.isEmpty() ? p.beatmap.artist : p.beatmap.artist_unicode), + true); + eb.addField("Created by", p.beatmap.creator, true); + eb.addField("Duration", totaldur + " | Drain " + draindur, true); + eb.addField("Difficulty", p.beatmap.version, true); + eb.addField("Mods", (p.mods == 0 ? "None" : Koohii.mods_str(p.mods)), true); + eb.addField("BPM", df.format(jbm.getDouble("bpm") * stats.speed), true); + eb.addField("Accuracy", df.format(accVal * 100), true); + eb.addField("Combo", String.valueOf(p.combo), true); + eb.addField("Misses", String.valueOf(p.nmiss), true); + eb.addField("300", String.valueOf(p.n300), true); + eb.addField("100", String.valueOf(p.n100), true); + eb.addField("50", String.valueOf(p.n50), true); + eb.addField("PP", df.format(ppVal), true); + if(weight) { + try { + String gain = df.format(weightForPP(ppVal, jbm.getString("beatmap_id"), getUserBest(uid, p.beatmap.mode))); + eb.addField("Actual pp gain for " + uid, (gain.equals("-1") ? "User has a better score than this" : gain + "pp"), true); + } catch (IllegalArgumentException e1) { + return new CommandResult(CommandResultType.INVALIDARGS, e1.getMessage()); + } + } + eb.setFooter("Stars: " + df.format(starsVal) + " | CS: " + df.format(stats.cs) + " | HP: " + df.format(stats.hp) + + " | AR: " + df.format(stats.ar) + " | OD: " + df.format(stats.od), null); + //e.setColor(new Color(248, 124, 248)); + eb.setColor(new Color(239, 109, 167)); + if(!mid.isEmpty()) { + channel.deleteMessageById(mid.get(0)).queue(); + } + //channel.sendMessage(e.build()).queue(); + channel.sendMessage(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) -> { + //System.out.println(weightForPP(95.91, null, null)); + //String debugMax = "", debugMin = "", debugData = "", debugAll = ""; + List params = new ArrayList<>(Arrays.asList(words)); + int modeId = getMode(params); + final double precision = 1e-4, iterations = 500; + 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); + } + JSONArray arr = null; + try { + arr = getUserBest(uid, modeId); + } catch (IllegalArgumentException e) { + return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); + } + double maxPP = arr.getJSONObject(0).getDouble("pp") + 100; //fricc those who wanna get pp over 100pp more than they can lmao + try { + //double basePP = new JSONArray(new JSONTokener(new URL("https://osu.ppy.sh/api/get_user?k=" + osuAPI + "&u=" + uid).openStream())).getJSONObject(0).getDouble("pp_raw"); + double targetPP = params.size() > index ? Double.parseDouble(params.get(index)) : 1; + int i = 0; double minPP = 0, mid = 0; + /*for(i = 0; i < 600; i++) { + //debugData += weightForPP(i, null, arr) + "\n"; + debugData += i + " " + weightForPP(i, null, arr) + "\n"; + }*/ + //System.out.println(debugData); + i = 0; + while(i < iterations && Math.abs(maxPP - minPP) > precision) { + mid = (maxPP + minPP) / 2; + double temp = weightForPP(mid, null, arr); + //System.out.println(i + ", " + mid + ", " + temp); + if(temp > targetPP) { + maxPP = mid; + } else { + minPP = mid; + } + i++; + //System.out.println(maxPP + " " + minPP); + //debugAll += maxPP + " " + minPP + "\n"; + //debugMax += maxPP + ""; + //debugMin += minPP + " "; + } + //System.out.println(debugAll); + //System.out.println(debugMax); + //System.out.println(debugMin); + if(!df.format(mid).equals(df.format(arr.getJSONObject(0).getDouble("pp") + 100))) { + channel.sendMessage("For " + uid + " to actually gain " + df.format(targetPP) + "pp in " + modes.get(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."); + } + //return ""; + }, "[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).")); + + + //TODO NEED EXTREME TIDYING UP + //TODO 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; + 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) + String[] split = String.join(" ", params).split("\\|"); + String search = split[0]; + 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!"); + } + String id = ""; + boolean noturl = false; + try { + new URL(search); + id = search.substring(search.lastIndexOf("/") + 1); + } catch (MalformedURLException e) { + noturl = true; + id = search; + } + if(id.isEmpty()) { + id = getPlayer(user); + } + JSONTokener result = null; + InputStream stream = null; + URL url = null; + try { + String addparam = ""; + if (noturl) { + addparam = "&type=string"; + } + if (modeId != 0) { + addparam += ("&m=" + modeId); + } + url = new URL( + "https://osu.ppy.sh/api/get_user_recent?k=" + osuAPI + "&u=" + URLEncoder.encode(id, "UTF-8") + addparam + "&limit=50"); + stream = url.openStream(); + if (stream.available() < 4) { + return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + id + "` or the player has not been playing in the last 24h."); + } + } catch (IOException ex) { + ex.printStackTrace(); + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex)); + } + result = new JSONTokener(stream); + JSONArray array = new JSONArray(result); + //System.out.println(array); + if(array.length() > 0) { + 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 beatmap = mostRecent.getString("beatmap_id"); + String name; + if (noturl) { + name = id; + id = mostRecent.getString("user_id"); + } else { + try { + Long.parseLong(id); + //get more info from here? + name = new JSONObject(new JSONTokener(new URL("https://osu.ppy.sh/api/get_user?k=" + osuAPI + "&u=" + id).openStream())).getString("username"); + } catch (NumberFormatException e) { + name = id; + } catch (IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + } + int i; + String more = ""; + for(i = nrecent + 1; i < array.length(); i++) { + if(!array.getJSONObject(i).getString("beatmap_id").equals(beatmap)) { + break; + } + if(i == array.length() - 1) { + more = " (or more)"; + } + } + if(i == array.length()) { + more = " (or more)"; + } + PPv2Parameters p = new Koohii.PPv2Parameters(); + try { + p.beatmap = new Koohii.Parser() + .map(new BufferedReader(new InputStreamReader(getMap(beatmap), "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()); + } + int mods = mostRecent.getInt("enabled_mods"); + if (p.beatmap.title.isEmpty()) { + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid beatmap."); + } + DiffCalc stars = new Koohii.DiffCalc().calc(p.beatmap, mods); + p.n50 = mostRecent.getInt("count50"); + p.n100 = mostRecent.getInt("count100"); + p.n300 = mostRecent.getInt("count300"); + p.nmiss = mostRecent.getInt("countmiss"); + //System.out.println(p.n300); + p.nobjects = p.n300 + p.n100 + p.n50 + p.nmiss; + //System.out.println(p.nobjects); + p.aim_stars = stars.aim; + p.speed_stars = stars.speed; + p.mods = mods; + p.combo = mostRecent.getInt("maxcombo"); + EmbedBuilder eb = new EmbedBuilder(); + //System.out.println(mods); + String modStr = Koohii.mods_str(mods); + eb.setColor(new Color(0, 0, 0)); + double ppVal, ppMax, starVal, accVal; + JSONObject obj; + try { + String addparam = ""; + if(p.beatmap.mode == 1) { + if((mods & 1<<8) != 0) addparam = "&mods=256"; + if((mods & 1<<6) != 0) if(addparam.isEmpty()) addparam = "&mods=64"; + else addparam = ""; + } + obj = new JSONArray(new JSONTokener(new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&b=" + mostRecent.getInt("beatmap_id") + addparam).openStream())).getJSONObject(0); + } catch (JSONException | IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + try { + PPv2 pp = new PPv2(p); + starVal = stars.total; + accVal = pp.computed_accuracy.value(); + ppVal = pp.total; + ppMax = constructPP(stars.aim, stars.speed, p.beatmap, mods).total; + } catch (UnsupportedOperationException e) { + starVal = obj.getDouble("difficultyrating"); + TaikoPP pp = new TaikoPP(starVal, p); + ppVal = pp.pp; + accVal = pp.acc; + ppMax = new TaikoPP(starVal, p.max_combo, mods, 1, 0, p.beatmap).pp; + } + eb.setTitle((p.beatmap.artist_unicode.isEmpty() ? p.beatmap.artist : p.beatmap.artist_unicode) + " - " + (p.beatmap.title_unicode.isEmpty() ? p.beatmap.title : p.beatmap.title_unicode) + " [" + p.beatmap.version + "]" + (modStr.isEmpty() ? "" : " +" + modStr) + " (" + df.format(starVal) + "*)", "https://osu.ppy.sh/beatmaps/" + beatmap); + eb.setAuthor(MiscUtils.ordinal(nrecent + 1) + " most recent " + modes.get(modeId) + " play by " + name, "https://osu.ppy.sh/users/" + id, "https://a.ppy.sh/" + id); + eb.setDescription(MiscUtils.ordinal(i - nrecent) + more + " consecutive try\nRanking: " + mostRecent.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + (mostRecent.getString("rank").equals("F") ? " (Estimated completion percentage: " + df.format(p.nobjects * 100.0 / p.beatmap.objects.size()) + "%)" : "")); + eb.setThumbnail("https://b.ppy.sh/thumb/" + obj.getString("beatmapset_id") + ".jpg"); + eb.addField("Score", String.valueOf(mostRecent.getInt("score")), true); + eb.addField("Accuracy", df.format(accVal * 100) + " (" + p.n300 + "/" + p.n100 + "/" + p.n50 + "/" + p.nmiss + ")", true); + eb.addField("Combo", (mostRecent.getInt("perfect") == 0 ? p.combo + "x / " + p.beatmap.max_combo() + "x" : "FC (" + p.combo + "x)"), true); + eb.addField("PP", df.format(ppVal) + "pp / " + df.format(ppMax) + "pp", true); + eb.setFooter("Score submitted", null); + eb.setTimestamp(OffsetDateTime.parse(mostRecent.getString("date").replace(" ", "T") + "+00:00")); + channel.sendMessage(eb.build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } else { + return new CommandResult(CommandResultType.FAILURE, "You have no recent plays in this 24h!"); + } + }, "[-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).")); + + + //TODO 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)); + } + String[] split = String.join(" ", params).split("\\|"); + String concName = String.join(" | ", split); + XYChart chart = new XYChartBuilder().width(800).height(600).title("Top PP plays for " + concName + " (" + modes.get(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().setAxisTitlePadding(24); + chart.getStyler().setChartTitlePadding(12); + chart.getStyler().setChartPadding(12); + chart.getStyler().setChartBackgroundColor(new Color(200, 220, 230)); + //ThreadLocalRandom ran = ThreadLocalRandom.current(); + for(String name : split) { + List pps = new ArrayList<>(); + try { + JSONArray array = getUserBest(name.trim(), modeId); + for(int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + pps.add(obj.getDouble("pp")); + } + } catch (IllegalArgumentException e) { + return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); + } + Collections.reverse(pps); + /*XYSeries series =*/ chart.addSeries(name.trim() + "'s PP", pps); + //use series.setXYSeriesRenderStyle() for average line + //series.setMarkerColor(new Color(ran.nextInt(1, 255), ran.nextInt(1, 255), ran.nextInt(1, 255))); + } + 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(); + 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("", "taiko", "FlyingTuna | Rafis | despawningbone"), + "Get a graph with the users' top plays!", Arrays.asList( + " * You can specify up to 30 players at once, seperated by `|`." + + " * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard).")); + + + 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."); + }/* else if (words.length > 2) { + try { + page = Integer.parseInt(words[2]); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Please enter a valid page number."); + } + }*/ + String id = "", set = ""; + try { + new URL(words[0]); + } catch (MalformedURLException e) { + //throw new IllgalArgumentException("Invalid URL."); //NOFIX: for some reason, discord seems to continue the typing after this has been sent //its because of queue() + 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; + 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 | JSONException e1) { + if(e1 instanceof JSONException) { + return new CommandResult(CommandResultType.NORESULT); + } else { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1)); + } + } + } + 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 { + if (set.contains("/b/") || set.contains("#")) { + return new CommandResult(CommandResultType.INVALIDARGS, "This is a specific beatmap, not a beatmap set."); + } + id = id.split("&")[0]; + } + } + JSONTokener result = null; + InputStream stream = null; + URL url = null; + try { + url = new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&s=" + id); + stream = url.openStream(); + } catch (IOException ex) { + ex.printStackTrace(); + } + result = new JSONTokener(stream); + JSONArray arr = new JSONArray(result); + List obj = new ArrayList(); + for (int i = 0; i < arr.length(); i++) { + obj.add(arr.getJSONObject(i)); + } + 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; + try { + fo = fobj.get(0); + } catch (IndexOutOfBoundsException e) { + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid beatmap URL."); + } + if(page > Math.ceil((double) fobj.size() / 3)) { + return new CommandResult(CommandResultType.INVALIDARGS, "The beatmap set does not have this many difficulties!"); + } + 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); + int n = ((page - 1) * 3); + //String desc = ""; + for (int i = n; (n + 3 <= fobj.size() ? i < n + 3 : i < fobj.size()); i++) { + JSONObject json = fobj.get(i); + String mode = modes.get(json.getInt("mode")); + /*desc += "**[" + json.getString("version") + "](https://osu.ppy.sh/b/" + json.getString("beatmap_id") + ")** (" + mode + ")\n"; + desc += df.format(json.getDouble("difficultyrating")) + "* " + (json.get("max_combo").toString() + "*").replaceAll("null*", "N/A") + "";*/ + em.addField("Difficulty", "[" + json.getString("version") + "](https://osu.ppy.sh/b/" + json.getString("beatmap_id") + ")" + " ([Preview](http://bloodcat.com/osu/preview.html#" + json.getString("beatmap_id") + "))", false); + /*em.addField("URL", "https://osu.ppy.sh/b/" + json.getString("beatmap_id"), true); + em.addBlankField(true);*/ + 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", mode, true); + } + em.setFooter("Page: " + String.valueOf(page) + "/" + String.valueOf((int) Math.ceil((double) fobj.size() / 3)), + null); + channel.sendMessage(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); + String id = ""; + boolean noturl = false; + try { + new URL(search); + id = search.substring(search.lastIndexOf("/") + 1); + } catch (MalformedURLException e) { + noturl = true; + id = search; + } + if(id.isEmpty()) { + id = getPlayer(user); + } + JSONTokener result = null; + InputStream stream = null; + URL url = null; + try { + String addparam = ""; + if (noturl) { + addparam = "&type=string"; + } + if (modeId != 0) { + addparam += ("&m=" + modeId); + } + url = new URL( + "https://osu.ppy.sh/api/get_user?k=" + osuAPI + "&u=" + URLEncoder.encode(id, "UTF-8") + addparam); + stream = url.openStream(); + if (stream.available() < 4) { + return new CommandResult(CommandResultType.INVALIDARGS, "Unknown user `" + id + "`."); + } + } catch (IOException ex) { + ex.printStackTrace(); + } + result = new JSONTokener(stream); + JSONObject usr = new JSONArray(result).getJSONObject(0); + + id = usr.getString("user_id"); //no matter if its url or not user_id is still gonna be the most accurate one + + EmbedBuilder em = new EmbedBuilder(); + em.setAuthor("User info of " + usr.getString("username") + " (" + modes.get(modeId) + ")", "https://osu.ppy.sh/users/" + id); + 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); + try { + 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); + } catch (JSONException e) { + return new CommandResult(CommandResultType.FAILURE, "This user has not played any map in " + modes.get(modeId) + " before."); + } + StringBuffer unibuff = new StringBuffer(); + char[] ch = usr.getString("country").toLowerCase().toCharArray(); + for (char c : ch) { + int temp = (int) c; + int temp_integer = 96; //for lower case + if (temp <= 122 & temp >= 97) + unibuff.append(Character.toChars(127461 + (temp - temp_integer))); + } + em.addField("Rank", (usr.getInt("pp_rank") == 0 ? "N/A" : "#" + usr.getString("pp_rank")) + " | Country #" + usr.getString("pp_country_rank"), true); + em.addField("Country", unibuff.toString(), true); + em.addField("Level", df.format(usr.getDouble("level")), true); + em.addField("Play count", usr.getString("playcount"), true); + em.addField("SS+", usr.getString("count_rank_ssh"), true); + em.addField("SS", usr.getString("count_rank_ss"), true); + double total = usr.getDouble("total_score"); + em.addField("Total score", String.valueOf((long) total), true); + em.addField("S+", usr.getString("count_rank_sh"), true); + em.addField("S", usr.getString("count_rank_s"), true); + double ranked = usr.getDouble("ranked_score"); + em.addField("Ranked score", String.valueOf((long) ranked) + " (" + df.format((ranked / total) * 100) + "%)", 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.sendMessage(em.build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); + }, "[gamemode] [user URL|keyword]", Arrays.asList("", "despawningbone", "taiko 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; + 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 + } + String id = ""; + boolean noturl = false; + try { + new URL(search); + id = search.substring(search.lastIndexOf("/") + 1); + } catch (MalformedURLException e) { + noturl = true; + id = search; + } + if(id.isEmpty()) { + id = getPlayer(user); + } + JSONTokener result = null; + InputStream stream = null; + String name, addparam = ""; + try { + if (noturl) { + addparam = "&type=string"; + } + JSONObject userObj = new JSONArray(new JSONTokener(new URL("https://osu.ppy.sh/api/get_user?k=" + osuAPI + "&u=" + id + addparam).openStream())).getJSONObject(0); + name = userObj.getString("username"); + id = userObj.getString("user_id"); + URLConnection con = new URL("https://osu.ppy.sh/users/" + id + "/scores/best?mode=" + (modeId == 0 ? "osu" : modeId == 1 ? "taiko" : modeId == 2 ? "fruits" : modeId == 3 ? "mania" : "osu") + "&offset=" + (page - 1) * 10 + "&limit=10").openConnection(); + stream = con.getInputStream(); + if (stream.available() < 4) { + return new CommandResult(CommandResultType.FAILURE, "This player does not have this many plays in this gamemode!"); + } + } catch (IOException ex) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex)); + } catch (JSONException ex) { + return new CommandResult(CommandResultType.INVALIDARGS, "Unknown user `" + id + "`."); + } + result = new JSONTokener(stream); + JSONArray array = new JSONArray(result); + EmbedBuilder eb = new EmbedBuilder(); + eb.setAuthor(name + "'s top plays (" + modes.get(modeId) + ")", "https://osu.ppy.sh/users/" + id, "https://a.ppy.sh/" + id); + DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + try { + for(int i = 0; i < array.length(); i++) { + JSONObject obj = array.getJSONObject(i); + String mods = obj.getJSONArray("mods").join("").replaceAll("\"", ""); + /*bInfo = new JSONArray(new JSONTokener(new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&b=" + bId).openStream())).getJSONObject(0); + String info = "**" + obj.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + "** " + + df.format(bInfo.getDouble("difficultyrating")) + "*" + + (!mods.isEmpty() ? " **" + mods + "** " : " ") + + " ([link](https://osu.ppy.sh/beatmap/" + bId + "))" + + "\n" + (obj.getDouble("pp") + "pp (" + obj.getInt("count300") + "/" + obj.getInt("count100") + "/" + obj.getInt("count50") + "/" + obj.getInt("countmiss") + ")") + + " " + (obj.getInt("perfect") == 1 ? "FC " + obj.getInt("maxcombo") + "x" : obj.getInt("maxcombo") + (!bInfo.isNull("max_combo") ? "/" + bInfo.getInt("max_combo") + "x" : "x")) + + "\nPlayed on " + obj.getString("date"); + eb.addField((i + 1) + ". " + bInfo.getString("artist") + " - " + bInfo.getString("title") + " [" + bInfo.getString("version") + "]", info, false); //no unicode unfortunately*/ + 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)); + } catch (JSONException e1) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1)); + } + channel.sendMessage(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("search", Arrays.asList("s"), (channel, user, msg, words) -> { //need to deprecate google search //no longer works at all + String search = String.join(" ", Arrays.asList(words)); + List> url = new ArrayList<>(); + try { + url = GoogleSearch + .search(URLEncoder.encode("site:https://osu.ppy.sh/ " + search, "UTF-8"), 10); + } catch (IOException e1) { + e1.printStackTrace(); + } + if (!url.isEmpty()) { + String list = ""; + Stream> stream = url.stream(); + if (words[0].equals("mapsearch")) { + stream = stream.filter(n -> n.getValue().contains("/b/") || n.getValue().contains("/s/") + || n.getValue().contains("/beatmapsets/")); + } else if (words[0].equals("usersearch")) { + stream = stream.filter(n -> n.getValue().contains("/u/") || n.getValue().contains("/users/")); + } + list = "Search results for " + search + ":\n\n"; + list += stream + .map(e -> (e.getKey() + "\n" + e.getValue() + "\n").replaceAll("", "").replaceAll("", "")) + .collect(Collectors.joining("\n")); + if (!list.isEmpty()) { + channel.sendMessage(list).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } else { + return new CommandResult(CommandResultType.NORESULT); + } + } else { + return new CommandResult(CommandResultType.NORESULT); + } + }, "search|usersearch|mapsearch [keywords]", Arrays.asList("usersearch cookiezi", "search big black"), + "Search the osu website!", null);*/ + + registerSubCommand("compare", Arrays.asList("c", "comp"), (channel, user, msg, words) -> { + String map = "", name = "", img = "", mode = ""; + 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(); + } + 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(); + if(card.getAuthor().getId().equals(DiscordBot.BotID)) { + 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]; + } 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!", ""); + } + } else if(card.getAuthor().getId().equals("289066747443675143")) { //owobot support + 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(); + } else if(card.getContentDisplay().contains("Most Recent ")) { + map = embed.getAuthor().getUrl(); + name = author.substring(0, author.lastIndexOf("+")); + img = embed.getThumbnail().getUrl(); + mode = card.getContentDisplay().contains("Mania") ? "mania" : card.getContentDisplay().split("Most Recent ")[1].split(" ")[0]; //ye fucking blame owobot for being so inconsistent + } 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(index > 1) { + index--; + map = ""; + } + if(!map.isEmpty()) break; + } + } + 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(map.isEmpty()) return new CommandResult(CommandResultType.FAILURE, "There are no recent map/score cards to compare with in the past 100 messages!"); + String uid = param[0].isEmpty() ? getPlayer(user) : param[0].replaceAll(" ", "%20"); + 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())); + 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); + 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() * 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.sendMessage(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) { + //String[] dataset = test.split(" "); + //List datas = Arrays.asList(dataset).stream().map(s -> Double.parseDouble(s)).collect(Collectors.toList()); + + double net = 0, change = -1; + List pps = new ArrayList<>(); + 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 + //for(int i = 0; i < datas.size(); i++) { + JSONObject obj = userBest.getJSONObject(i); + double v = obj.getDouble("pp"); + //double v = datas.get(i); + //System.out.println("raw" + v); + String bid = obj.getString("beatmap_id"); + if(bid.equals(ibid)) { + if(v >= pp) { + return -1; + } else { + change = v; + } + } + if(v <= pp && !pps.contains(pp)) { + pps.add(pp); + } + pps.add(v); + } + if(pps.indexOf(pp) == -1) { + return 0; + } + //System.out.println(pps.indexOf(pp)); + 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)); + //System.out.println("pp" + w); + if(i == pps.indexOf(pp)) { + net += w; + } else { + net += c*(Math.pow(0.95, i - 1)) * (0.95 - 1); + } + //System.out.println("t1" + net); + } + //System.out.println("last" + last); + 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; + } + + private static JSONArray getUserBest(String id, int mode) { + boolean noturl = false; + try { + new URL(id); + id = id.substring(id.lastIndexOf("/") + 1); + } catch (MalformedURLException e) { + noturl = true; + } + JSONTokener result = null; + InputStream stream = null; + URL url = null; + try { + String addparam = ""; + if (noturl) { + addparam = "&type=string"; + } + //System.out.println("https://osu.ppy.sh/api/get_user_best?k=" + osuAPI + "&m=" + mode + "&u=" + URLEncoder.encode(id, "UTF-8") + addparam + "&limit=100"); + url = new URL( + "https://osu.ppy.sh/api/get_user_best?k=" + osuAPI + "&m=" + mode + "&u=" + URLEncoder.encode(id, "UTF-8") + addparam + "&limit=100"); + stream = url.openStream(); + if (stream.available() < 4) { + throw new IllegalArgumentException("Unknown player `" + id + "`."); + } + } catch (IOException ex) { + ex.printStackTrace(); + } + result = new JSONTokener(stream); + return new JSONArray(result); + } + + private static PPv2 constructPP(double aim_stars, double speed_stars, Map b, int mods) { + PPv2Parameters p = new PPv2Parameters(); + 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; String mode = ""; + params.replaceAll(String::toLowerCase); + if(params.contains("-t")) { + mode = "-t"; + modeId = 1; + } + if(params.contains("-c")) { + mode = "-c"; + modeId = 2; + } + if(params.contains("-m")) { + mode = "-m"; + modeId = 3; + } + params.removeAll(Collections.singleton(mode)); //pass by ref so it removes from the list + return modeId; + } + + private static InputStream getMap(String origURL) throws IOException { + String id = origURL.substring(origURL.lastIndexOf("/") + 1); + if (origURL.contains("/beatmapsets/")) { + if (!origURL.contains("#")) throw new IllegalArgumentException("Please specify a difficulty, instead of giving a set URL."); + } else { + 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 + } + URLConnection mapURL; + try { + mapURL = new URL("https://osu.ppy.sh/osu/" + id).openConnection(); + mapURL.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:57.0)"); + } catch (IOException e) { + //e.printStackTrace(); + throw new IllegalArgumentException("Invalid beatmap URL."); + } + try { + return mapURL.getInputStream(); + } catch (IOException e) { + if(e.getMessage().contains("503")) { //most likely cloudflare, unless they change the request method which is very unlikely + mapURL = new URL("https://bloodcat.com/osu/b/" + id).openConnection(); + mapURL.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:57.0)"); + return mapURL.getInputStream(); //not catching this, if even this doesnt work just throw something went wrong. + } else { + throw e; + } + } + } + +} diff --git a/src/me/despawningbone/discordbot/command/info/Calculator.java b/src/me/despawningbone/discordbot/command/info/Calculator.java new file mode 100644 index 0000000..9f1ec83 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/info/Calculator.java @@ -0,0 +1,115 @@ +package me.despawningbone.discordbot.command.info; + +import java.text.DecimalFormat; +import java.util.Arrays; +import java.util.EmptyStackException; + +import org.apache.commons.math3.special.Gamma; + +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.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 = ""; + 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."); + this.examples = Arrays.asList("3*4-2", "4 * (sin(3 - 5)) + 5!", "log(e) + logx(10, 100)"); + } + + Function logb = new Function("logx", 2) { + @Override + public double apply(double... args) { + return Math.log(args[1]) / Math.log(args[0]); + } + }; + + Function digamma = new Function("digamma", 1) { + @Override + public double apply(double... args) { + return Gamma.digamma(args[0]); + } + }; + + 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) { + if (args.length < 1) { + return new CommandResult(CommandResultType.INVALIDARGS, "Please enter an operation."); + } + String splitted = String.join(" ", args); + 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 (args[0].equals("9+10") || args[0].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 (args[0].equals("666") || splitted.equals("333")) { // easter egg + channel.sendMessage("No you don't").queue(); + return new CommandResult(CommandResultType.SUCCESS, "Executed easter egg"); + } else { + 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); + Expression e = null; + String ans = null; + double planckConstant = 6.62607004 * Math.pow(10, -34); + double eulerMascheroni = 0.57721566490153286060651209008240243104215933593992; + try { + 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); + } + } +} diff --git a/src/me/despawningbone/discordbot/command/info/CityInfo.java b/src/me/despawningbone/discordbot/command/info/CityInfo.java new file mode 100644 index 0000000..de57330 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/info/CityInfo.java @@ -0,0 +1,278 @@ +package me.despawningbone.discordbot.command.info; + +import java.awt.Color; +import java.io.IOException; +import java.net.URL; +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.HashMap; +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 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.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 = "
"; + for (String country : Locale.getISOCountries()) { + Locale locale = new Locale("en", country); + countryCodes.put(locale.getDisplayCountry(Locale.ENGLISH), locale.getCountry()); + } + this.examples = Arrays.asList("hong kong", "tokyo"); //"HK", "akihabara"); + } + + HashMap countryCodes = new HashMap<>(); + + NumberFormat formatter = new DecimalFormat("#0.00"); + + //private final String flickrAPI = DiscordBot.tokens.getProperty("flickr"); + + @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 { + /*JSONTokener georesult = null; + InputStream geostream = null; + String search = URLEncoder.encode(sword, "UTF-8"); + URL geocode = null; + try { + geocode = new URL("https://maps.googleapis.com/maps/api/geocode/json?address=" + search + "&key=" + AudioPlayer.GAPI + "&language=en"); + geostream = geocode.openStream(); + } catch (IOException e) { + e.printStackTrace(); + } + georesult = new JSONTokener(geostream); + JSONObject geomain = new JSONObject(georesult); + JSONArray resultList = geomain.getJSONArray("results"); + if(resultList.isNull(0)) { + channel.sendMessage("Unfortunately there is no results :cry:").queue(); + return new CommandResult(CommandResultType.NORESULT); + } + JSONObject firstResult = resultList.getJSONObject(0); + JSONObject loc = firstResult.getJSONObject("geometry").getJSONObject("location"); + //String formattedAddr = firstResult.getString("formatted_address"); + JSONArray addrComponents = firstResult.getJSONArray("address_components"); + String formattedAddr = "", addr = firstResult.getString("formatted_address"); + String countryShort = null; String region = null; String locality = null; String country = null; String colarea = null; + boolean stop = false; + for(int i = 0; i < addrComponents.length(); i++) { + JSONObject component = addrComponents.getJSONObject(i); + String compname = component.getString("long_name"); + if(!stop) { + if(i == addrComponents.length() - 1) { + formattedAddr += compname; + } else { + formattedAddr += compname + ", "; + } + } + List types = component.getJSONArray("types").toList(); + if(types.contains("country")) { + countryShort = component.getString("short_name"); + country = compname; + if(i == 0) { + channel.sendMessage("You need to specify which part of the country you want to get the info from.").queue(); + return new CommandResult(CommandResultType.FAILURE, "Address is a country"); + } + } else if(types.contains("continent")) { + if(i == 0) { + channel.sendMessage("You need to specify which part of the continent you want to get the info from.").queue(); + return new CommandResult(CommandResultType.FAILURE, "Address is a continent"); + } + } else if(types.contains("postal_code")) { + if(i == 0) { + formattedAddr = addr; + stop = true; + } + } else if(types.contains("administrative_area_level_1")) { + region = compname; + } else if(types.contains("locality")) { + locality = compname; + } else if(types.contains("colloquial_area")) { + colarea = compname; + } else if(types.contains("natural_feature") && addrComponents.length() == 1) { + channel.sendMessage("Search civilized locations please :joy:").queue(); + return new CommandResult(CommandResultType.FAILURE, "Address is natural"); + } + } + if(region == null) { + if(stop) { + region = country; + } else { + if(locality.equals("Singapore")) { + formattedAddr = addr; + } + region = colarea; + if(locality != null) region = locality; + } + } + double lat = loc.getDouble("lat"); + double lng = loc.getDouble("lng"); + JSONTokener timeresult = null; + InputStream timestream = null; + URL timezone = null; + Timestamp timestamp = new Timestamp(System.currentTimeMillis()); + long sec = Math.round(timestamp.getTime() / 1000.0); + try { //can deprecate this since the new weather scrape has local time, but there wont be a name for the timezone anymore + timezone = new URL("https://maps.googleapis.com/maps/api/timezone/json?location=" + lat + "," + lng + "×tamp=" + sec + "&key=" + AudioPlayer.GAPI + "&language=en"); + timestream = timezone.openStream(); + } catch (IOException e) { + e.printStackTrace(); + } + timeresult = new JSONTokener(timestream); + JSONObject timemain = new JSONObject(timeresult); + String timeZoneName = timemain.getString("timeZoneName"); + int rawOffset = timemain.getInt("rawOffset"); + int dstOffset = timemain.getInt("dstOffset"); + ZonedDateTime zone = ZonedDateTime.now(ZoneOffset.ofTotalSeconds(rawOffset + dstOffset)); + int hours = (rawOffset + dstOffset) / 60 / 60;*/ + + boolean hasWeather = true; + JSONObject info = null; + String wQualifiedName = "", woeid = "", lng = "", lat = "", region = "", countryShort = ""; TimeZone timezone = null; + /*try { + URLConnection con = new URL("https://api.flickr.com/services/rest/?method=flickr.places.find&api_key=" + flickrAPI + "&query=" + URLEncoder.encode(sword, "UTF-8") + "&format=json&nojsoncallback=1").openConnection(); + con.setRequestProperty("Accept-Language", "en"); + JSONArray warray = new JSONObject(new JSONTokener(con.getInputStream())).getJSONObject("places").getJSONArray("place"); + int index; + for(index = 0; index < warray.length(); index++) { + if(warray.getJSONObject(index).has("timezone")) { + break; + } + } + JSONObject wsearch = warray.getJSONObject(index); + woeid = wsearch.getString("woeid"); //flickr api, using generated api key (will it expire?) //highest accuracy so far + //wQualifiedName = wsearch.getString("_content"); //too short + ArrayList pSplit = new ArrayList<>(Arrays.asList(URLDecoder.decode(wsearch.getString("place_url"), "UTF-8").substring(1).split("/"))); + if(!pSplit.get(pSplit.size() - 1).equals(wsearch.getString("woe_name"))) { + pSplit.add(wsearch.getString("woe_name")); + } + Collections.reverse(pSplit); + wQualifiedName = String.join(", ", pSplit); + timezone = TimeZone.getTimeZone(wsearch.getString("timezone")); + lat = wsearch.getString("latitude"); + lng = wsearch.getString("longitude"); + String[] rSplit = wQualifiedName.split(", "); + region = rSplit.length > 1 ? rSplit[rSplit.length - 2] : rSplit[0]; + countryShort = countryCodes.get(rSplit[rSplit.length - 1].trim()); + } catch(IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } catch (JSONException e) { + //e.printStackTrace(); + return new CommandResult(CommandResultType.NORESULT); + }*/ //FLICKR DED + try { + JSONObject wsearch = new JSONObject(new JSONTokener(new URL("https://www.yahoo.com/news/_tdnews/api/resource/WeatherSearch;text=" + URLEncoder.encode(sword, "UTF-8") + "?returnMeta=true").openStream())).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 = countryCodes.get(wsearch.getString("country")); + region = wsearch.getString("city"); + timezone = TimeZone.getTimeZone(new JSONObject(new JSONTokener(new URL("https://api.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 + info = new JSONObject(new JSONTokener(new URL("https://www.yahoo.com/news/_tdnews/api/resource/WeatherService;woeids=[" + woeid + "]?lang=en-US&returnMeta=true").openStream())).getJSONObject("data").getJSONArray("weathers").getJSONObject(0); + } catch(IOException e) { + e.printStackTrace(); + } catch(JSONException e) { + e.printStackTrace(); + return new CommandResult(CommandResultType.NORESULT); + //hasWeather = false; + } + /*String ftimezone = timeZoneName + " (UTC" + (Math.signum(hours) == 1 || Math.signum(hours) == 0 ? "+" + hours : hours) + ")"; + if(ftimezone.length() < 34) { + ftimezone += String.join("", Collections.nCopies(34 - ftimezone.length(), " ")); + }*/ + StringBuffer unibuff = new StringBuffer(); + if(countryShort != null) { + char[] ch = countryShort.toLowerCase().toCharArray(); + for(char c : ch) { + int temp = (int)c; + int temp_integer = 96; //for lower case + if(temp<=122 & temp>=97) unibuff.append(Character.toChars(127461 + (temp-temp_integer))); + } + } else { + unibuff.append("N/A"); + } + 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.setFooter("Weather info last updated: " + info.getString("lastBuildDate") , null); + embedmsg.addField("Country", unibuff.toString(), 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"); + 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: " + obs.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.sendMessage(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 new file mode 100644 index 0000000..60c3686 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/info/CurrencyConvert.java @@ -0,0 +1,121 @@ +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.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 close = main.getJSONObject("indicators").getJSONArray("quote").getJSONObject(0).getJSONArray("close").toList(); + close.removeAll(Collections.singleton(null)); + rate = (double) close.get(close.size() - 1); + } catch (JSONException e) { + rate = main.getJSONObject("meta").getDouble("previousClose"); + //rate += new JSONObject(new JSONTokener(new URL("https://query1.finance.yahoo.com/v1/finance/lookup?formatted=true&lang=en-US®ion=US&query=" + fromc + toc + "=x&type=all&count=25&start=0&corsDomain=finance.yahoo.com").openStream())) + //.getJSONObject("finance").getJSONArray("result").getJSONObject(0).getJSONArray("documents").getJSONObject(0).getJSONObject("regularMarketChange").getDouble("raw"); + } + if(rate == 1) { + //throw new NullPointerException("Invalid currency conversion!"); + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid currency conversion!"); + } + try { + num = args[2]; + } catch (ArrayIndexOutOfBoundsException e) { + num = "1"; + } + double f = rate * Double.parseDouble(num); + channel.sendMessage(num + " " + fromc.toUpperCase() + " = " + new DecimalFormat("#.####").format(f) + " " + toc.toUpperCase()).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } catch (IOException e) { + if(e instanceof FileNotFoundException) { + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid currency. See the help for the list of available currencies."); + } else { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + } catch (NumberFormatException e) { + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid amount."); + } + + /*SCRAPE DEPRECATED*/ + + /*try { // again, not pretty good to scrape but thats the only way to get the ever updating yahoo finance data + Document doc = Jsoup.connect("https://hk.finance.yahoo.com/quote/" + fromc + toc + "=X").get(); + // System.out.println(doc); + try { + String rate = doc.select("span[data-reactid=\"35\"]").first().text().replace(",", ""); + // String cc = doc.select("div[data-reactid=\"6\"] h1").first().text().split(" ")[0].replace("/", ""); + // System.out.println(cc); + if (Double.parseDouble(rate) == 1 //!cc.equals(fromc + toc) ) { + throw new NullPointerException(); + } + boolean nonum = false; + try { + num = args[2]; + } catch (ArrayIndexOutOfBoundsException e) { + nonum = true; + num = "1"; + } + String finalrate; + if (!nonum) { + double tempn = Double.parseDouble(num); + double tempr = Double.parseDouble(rate); + finalrate = String.valueOf(tempn * tempr); + } else { + finalrate = rate; + } + channel.sendMessage(num + " " + fromc.toUpperCase() + " = " + finalrate + " " + toc.toUpperCase()) + .queue(); + return new CommandResult(CommandResultType.SUCCESS, null); + } catch (NullPointerException | NumberFormatException e) { + channel.sendMessage("Invalid currency.").queue(); + return new CommandResult(CommandResultType.INVALIDARGS, null); + } + } catch (IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + }*/ + } + +} diff --git a/src/me/despawningbone/discordbot/command/info/Fandom.java b/src/me/despawningbone/discordbot/command/info/Fandom.java new file mode 100644 index 0000000..4f59d3b --- /dev/null +++ b/src/me/despawningbone/discordbot/command/info/Fandom.java @@ -0,0 +1,153 @@ +package me.despawningbone.discordbot.command.info; + +import java.awt.Color; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.time.Instant; +import java.util.Arrays; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +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.User; + +public class Fandom extends Command { + public Fandom() { + this.desc = "Search across all the fan-made wikis!"; + this.alias = Arrays.asList("wikia"); + this.usage = ": [| index]"; + this.examples = Arrays.asList("zelda: gate of time", "clockwork planet: ryuZU", "angel beats: kanade"); + } + + @Override //query for details using future too? since i already have to make 2 queries, making 3 in parallel wont make it much slower; the only concern is rate limit //already doing sequential 3 queries, aint too slow so its fine + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { + channel.sendTyping().queue(); + //OffsetDateTime timesent = OffsetDateTime.now(); + + /*for(TextChannel c : channel.getGuild().getTextChannels()) { wtf is this + try { + System.out.println(c); + Message m = c.getMessageById("607977540690378753").complete(); + if(m != null) channel.sendMessage(m.toString()).queue(); + } catch(ErrorResponseException | InsufficientPermissionException e) { + e.printStackTrace(); + continue; + } + + }*/ + + if (args.length < 1) { + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid input, check help for more info."); + } + try { + String[] init = String.join(" ", args).split(":"); + if(init.length < 2) { + return new CommandResult(CommandResultType.INVALIDARGS, "Please use a colon to seperate the wiki and the search query!"); + } + String[] split = init[1].split(" \\| "); + String sword = split[0]; + int num = 0; + if (split.length > 1) { + try { + num = Integer.parseInt(split[1]) - 1; + if(num < 0) throw new NumberFormatException(); + } catch (NumberFormatException e) { + channel.sendMessage("Invalid index inputted. Defaulting to the first result...").queue(); + num = 0; + } + } + JSONTokener result = null; + InputStream stream = null; + String search = URLEncoder.encode(sword.trim(), "UTF-8"); + HttpURLConnection url = null; + String wiki = init[0].replaceAll("[^\\p{L}\\p{N} ]+", "").replaceAll(" ", "-").toLowerCase(); + //boolean old = false; + try { + /*url = (HttpURLConnection) new URL("https://" + wiki + ".fandom.com/api/v1/Search/List?query=" + search + "&limit=25&minArticleQuality=10&batch=1&namespaces=0%2C14").openConnection(); + stream = url.getInputStream(); + if(url.getResponseCode() == 302) { //wikia is deprecated, for the few weeks of deprecation period only + old = true; + url = (HttpURLConnection) new URL("https://" + init[0].replaceAll("[^\\p{L}\\p{N} ]+", "").replaceAll(" ", "-").toLowerCase() + ".wikia.com/api/v1/Search/List?query=" + search + "&limit=25&minArticleQuality=10&batch=1&namespaces=0%2C14").openConnection(); + stream = url.getInputStream(); + if(url.getResponseCode() == 302) { + channel.sendMessage("Unknown wiki name!").queue(); + return new CommandResult(CommandResultType.NORESULT); + } + }*/ + //i can use this instead of the above code since wikia redirects with 301 with no exceptions; when everything finishes moving, i might have to change it to fandom though + System.out.println("https://" + wiki + ".wikia.com/api/v1/Search/List?query=" + search + "&limit=25&minArticleQuality=10&batch=1&namespaces=0%2C14"); + url = (HttpURLConnection) new URL("https://" + wiki + ".wikia.com/api/v1/Search/List?query=" + search + "&limit=25&minArticleQuality=10&batch=1&namespaces=0%2C14").openConnection(); + stream = url.getInputStream(); + if(url.getURL().toString().startsWith("https://community.fandom.com/")) { + return new CommandResult(CommandResultType.FAILURE, "Unknown wiki name!"); + } + } catch (IOException e) { + if(url.getResponseCode() == 404) { + return new CommandResult(CommandResultType.NORESULT, "it in the " + init[0] + " wiki"); + } + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + JSONObject item; + try { + result = new JSONTokener(stream); + JSONArray main = new JSONObject(result).getJSONArray("items"); + item = main.getJSONObject(num); + } catch(JSONException e) { + return new CommandResult(CommandResultType.NORESULT, "it in the " + init[0] + " wiki"); + } + // System.out.println(i); + //JSONArray array = new JSONObject(new JSONTokener(new URL("https://" + wiki + (old ? ".wikia" : ".fandom") + ".com/api/v1/Articles/AsSimpleJson?id=" + item.getInt("id")).openStream())).getJSONArray("sections").getJSONObject(0).getJSONArray("content"); + JSONArray array = new JSONObject(new JSONTokener(new URL("https://" + wiki + ".wikia.com/api/v1/Articles/AsSimpleJson?id=" + item.getInt("id")).openStream())).getJSONArray("sections").getJSONObject(0).getJSONArray("content"); + String desc = ""; + for(int i = 0; i < array.length(); i++) { + desc = array.getJSONObject(i).getString("text"); + if(desc.contains("may refer to")) { + JSONArray list = array.getJSONObject(i + 1).getJSONArray("elements"); + for(int j = 0; j < list.length(); j++) { + desc += "\n- " + list.getJSONObject(j).getString("text"); + } + } + if(!desc.trim().replaceAll("(^\\h*)|(\\h*$)","").isEmpty()) { + break; + } + } + JSONObject details = new JSONObject(new JSONTokener(new URL("https://" + wiki + ".wikia.com/api/v1/Articles/Details?ids=" + item.getInt("id")).openStream())); + JSONObject info = details.getJSONObject("items").getJSONObject(String.valueOf(item.getInt("id"))); //TODO make async + EmbedBuilder eb = new EmbedBuilder(); + eb.setTitle(info.getString("title"), item.getString("url")); + eb.setAuthor(StringUtils.capitalize(init[0]) + " wiki", details.getString("basepath")); + eb.setDescription(desc); + if(info.has("comments")) eb.addField("Comments", String.valueOf(info.getInt("comments")), false); + if(!info.isNull("thumbnail")) eb.setThumbnail(info.getString("thumbnail")); + eb.setFooter("Last edited by " + info.getJSONObject("revision").getString("user"), null); + eb.setTimestamp(Instant.ofEpochSecond(Long.parseLong(info.getJSONObject("revision").getString("timestamp")))); + eb.setColor(new Color(0, 42, 50)); + channel.sendMessage(eb.build()).queue(); + /*MessageBuilder smsg = new MessageBuilder(); + smsg.append("**" + item.getString("title") + "**\n\n"); + smsg.append(desc + "\n"); + smsg.append("<" + item.getString("url") + ">"); + OffsetDateTime timeReceived = OffsetDateTime.now(); + long ms = Math.abs(timesent.until(timeReceived, ChronoUnit.MILLIS)); + smsg.append("\n" + ms + "ms"); + Message fmsg = smsg.build(); + channel.sendMessage(fmsg).queue();*/ + return new CommandResult(CommandResultType.SUCCESS); //add searched result name? + } catch (Exception e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + } +} diff --git a/src/me/despawningbone/discordbot/command/info/Graph.java b/src/me/despawningbone/discordbot/command/info/Graph.java new file mode 100644 index 0000000..42db96f --- /dev/null +++ b/src/me/despawningbone/discordbot/command/info/Graph.java @@ -0,0 +1,113 @@ +package me.despawningbone.discordbot.command.info; + +import java.awt.Color; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EmptyStackException; +import java.util.List; + +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 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.User; +import net.objecthunter.exp4j.Expression; +import net.objecthunter.exp4j.ExpressionBuilder; +import net.objecthunter.exp4j.function.Function; + +public class Graph extends Command { + public Graph() { + this.desc = "Plot a graph with a mathematical equation!"; + this.usage = ""; + this.remarks = Arrays.asList("Please use \"x\" for the variables."); + this.isDisabled = true; + this.examples = Arrays.asList("1+x/(x+100)"); + } + + Function logb = new Function("logx", 2) { + @Override + public double apply(double... args) { + return Math.log(args[1]) / Math.log(args[0]); + } + }; + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { + double low = -10; double high = 10; boolean hasRange = false; + if(args[0].contains("..")) { + try { + String[] range = args[0].split("\\.\\."); + low = Double.parseDouble(range[0]); + high = Double.parseDouble(range[1]); + if(low < -200 || high > 200) { + return new CommandResult(CommandResultType.INVALIDARGS, "Range specified is too large."); + } + hasRange = true; + } catch (ArrayIndexOutOfBoundsException e) { + e.printStackTrace(); //should never trigger + } catch (IllegalStateException e) { + if(!e.getMessage().equals("No match found")) { //= not include range + e.printStackTrace(); + } + } catch (NumberFormatException e) { + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid range."); + } + } + if(hasRange) args[0] = ""; + String eqn = String.join(" ", args); + XYChart chart = new XYChartBuilder().width(800).height(600).title("Graph for " + eqn).build(); + chart.getStyler().setDefaultSeriesRenderStyle(XYSeriesRenderStyle.Line); + chart.getStyler().setLegendVisible(false); + chart.getStyler().setPlotContentSize(0.91); + chart.getStyler().setMarkerSize(0); + //chart.getStyler().setAxisTitlePadding(24); + chart.getStyler().setChartTitlePadding(12); + chart.getStyler().setChartPadding(12); + chart.getStyler().setChartBackgroundColor(new Color(200, 220, 230)); + List xSeries = new ArrayList<>(); + List ySeries = new ArrayList<>(); + try { + Expression e = new ExpressionBuilder(eqn).function(logb).variables("x").build(); + for(double i = low; i <= high; i+=0.01) { //tan graphs looks wrong + if(i != 0) { + double val = e.setVariable("x", i).evaluate(); + if(val == Double.POSITIVE_INFINITY) { + val = 1e100; + } else if(val == Double.NEGATIVE_INFINITY) { + val = 1e-100; + } + if(/*val > low && val < high &&*/ !Double.isNaN(val) && val != Double.POSITIVE_INFINITY && val != Double.NEGATIVE_INFINITY) { + ySeries.add(val); + xSeries.add(i); + } + } + } + chart.addSeries("data", xSeries, ySeries); + // 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()); + } + ByteArrayOutputStream os = new ByteArrayOutputStream(); + try { + BitmapEncoder.saveBitmap(chart, os, BitmapFormat.PNG); + } catch (IOException e1) { + e1.printStackTrace(); + } + channel.sendFile(new ByteArrayInputStream(os.toByteArray()), "graph.png").queue(); + return new CommandResult(CommandResultType.SUCCESS); + } +} diff --git a/src/me/despawningbone/discordbot/command/info/Help.java b/src/me/despawningbone/discordbot/command/info/Help.java new file mode 100644 index 0000000..44d21ee --- /dev/null +++ b/src/me/despawningbone/discordbot/command/info/Help.java @@ -0,0 +1,108 @@ +package me.despawningbone.discordbot.command.info; + +import java.awt.Color; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Arrays; +import java.util.List; +import java.util.Map.Entry; + +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.entities.Message; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; + +public class Help extends Command { + public Help() { + this.desc = "Shows command usage and relevant details."; + this.usage = "[commandname]"; + this.remarks = Arrays.asList("Specify a command name to see details about it!"); + } + + //private MessageEmbed embed = null; + + //TODO invoke this on INVALIDARGS + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { //add command rank required? add category? + try(Connection con = DiscordBot.db.getConnection(); Statement s = con.createStatement()) { + String prefix = MiscUtils.getPrefix(s, channel.getGuild().getId()); + if (args.length < 1) { + EmbedBuilder em = new EmbedBuilder(); + em.setAuthor("despbot's Commands", null, + channel.getJDA().getSelfUser().getAvatarUrl()); + for (Entry> entry : DiscordBot.catCmds.entrySet()) { + String content = ""; + List cmds = entry.getValue(); + for (Command cmd : cmds) { + String cmdInfo = prefix + cmd.getName() + " " + cmd.getUsage() + " - " + cmd.getDesc() + "\n"; + //if(cmd.isDisabled(channel.getGuild().getId())) content += "*" + cmdInfo.substring(0, cmdInfo.length() - 2) + "*\n"; //TODO migrate to SQLite //seems to have too much overhead + /*else*/ content += cmdInfo; + } + em.addField(entry.getKey(), content, false); + em.addBlankField(false); + } + em.getFields().remove(em.getFields().size() - 1); + em.setFooter("Do " + prefix + "help [cmdname] for more information about the command!", + null); + em.setColor(new Color(10,182,246)); + channel.sendMessage(em.build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } else { + Command cmd = DiscordBot.commands.get(args[0]); + if (cmd == null) { + cmd = DiscordBot.aliases.get(args[0]); + if (cmd == null) { + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid command!"); + } + } + String missing; + String name = cmd.getName(); String desc = ""; + EmbedBuilder em = new EmbedBuilder(); //DONE show permission required + + for(int i = 1; i < args.length; i++) { + cmd = cmd.getSubCommand(args[i]); + if(cmd == null) return new CommandResult(CommandResultType.INVALIDARGS, "Invalid sub command! Check `" + prefix + "help " + String.join(" ", Arrays.copyOfRange(args, 0, i)) + "` for more info."); + name += " " + cmd.getName(); + } + + /*em.setDescription(cmd.getDesc()); + em.addField("Usage", DiscordBot.prefix + " " + cmd.getName() + " " + cmd.getUsage() + (cmd.getRemarks() == null ? "" : "\n\n" + String.join("\n", cmd.getRemarks())), false); + if(cmd.getAliases() != null) em.addField("Alias(es)", String.join(", ", cmd.getAliases()), false);*/ + desc = (cmd.getAliases() != null ? "*Alias(es): " + String.join(", ", cmd.getAliases()) + "*\n\n**" : "**") + + cmd.getDesc() + "**\n" + "Usage: `" + prefix + name + (cmd.getUsage() == null || cmd.getUsage().isEmpty() ? "" : " " + cmd.getUsage()) + + (cmd.getRemarks() == null ? "`" : "`\n\n" + String.join("\n", cmd.getRemarks()).replaceAll(DiscordBot.prefix, prefix)); //TODO temporary fix for !desp roll + if(cmd.hasSubCommand()) { //TODO italic? + desc += "\n\nDo `" + prefix + "help " + cmd.getName() + " ` for more information!\n\nAvailable sub commands:\n\n"; + for(String subCmd : cmd.getSubCommandNames()) { + desc += prefix + cmd.getName() + " " + subCmd + " - " + cmd.getSubCommand(subCmd).getDesc().split("\n")[0] + "\n"; + } + } + String eg = ""; + if(cmd.getExamples() != null) { + for(String example : cmd.getExamples()) eg += prefix + name + " " + example + "\n"; + em.addField("Examples", eg, true); + } + missing = MiscUtils.getRequiredPerms(MiscUtils.getActivePerms(s, channel, cmd), cmd.getRequiredBotUserLevel()); + + boolean disabled = (missing != null && missing.equals("DISABLED")) || cmd.isDisabled(); + if(missing != null && !disabled) em.addField("Required permissions", missing.replaceAll("OR ", "OR \n"), true); //DONE make it print regardless of member? + em.setDescription(desc); + em.setTitle("Information for " + prefix + name + (disabled ? " (disabled)" : "") + ":"); + em.setFooter("<> - required parameters, [] - optional parameters", null); + em.setColor(new Color(4, 246, 254)); + channel.sendMessage(em.build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } + } catch (SQLException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + } +} diff --git a/src/me/despawningbone/discordbot/command/info/Ping.java b/src/me/despawningbone/discordbot/command/info/Ping.java new file mode 100644 index 0000000..8df2ab6 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/info/Ping.java @@ -0,0 +1,37 @@ +package me.despawningbone.discordbot.command.info; + +import java.time.OffsetDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; + +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.User; + +public class Ping extends Command { + public Ping() { + this.desc = "Check the round trip latency between the server and the bot!"; + this.usage = "[debug]"; + this.remarks = Arrays.asList("Putting the debug parameter will output the WebSocket latency too."); + } + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { //DONE USE RESTACTION INSTEAD + OffsetDateTime timesent = OffsetDateTime.now(); + if (args.length > 0 && args[0].equals("debug")) { + channel.sendMessage(":ping_pong: Pong! Response latency: ").queue(m -> { + long ms = Math.abs(timesent.until(OffsetDateTime.now(), ChronoUnit.MILLIS)); + // System.out.println(String.valueOf(ms)); + channel.editMessageById(m.getId(), ":ping_pong: Pong! Response latency: " + ms + "ms" + (ms < 250 ? "\nNot bad! :D" : ms > 500 ? "\nWow. God damn Internet issues!" : "")).queue(); + channel.sendMessage("WebSocket response latency: " + channel.getJDA().getGatewayPing() + "ms").queue(); + }); + } else { + channel.getJDA().getRestPing().queue(ms -> channel.sendMessage(":ping_pong: Pong! Response latency: " + ms + "ms" + (ms < 250 ? "\nNot bad! :D" : ms > 500 ? "\nWow. God damn Internet issues!" : "")).queue()); + } + + return new CommandResult(CommandResultType.SUCCESS); + } +} diff --git a/src/me/despawningbone/discordbot/command/info/Translate.java b/src/me/despawningbone/discordbot/command/info/Translate.java new file mode 100644 index 0000000..2e03c05 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/info/Translate.java @@ -0,0 +1,131 @@ +package me.despawningbone.discordbot.command.info; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +import org.jsoup.Jsoup; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.json.JSONObject; +import org.json.JSONTokener; +import org.jsoup.Connection; +import org.jsoup.Connection.Method; +import org.jsoup.Connection.Response; +import org.jsoup.nodes.Document; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; + +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.User; + +public class Translate extends Command { + public Translate() { + this.desc = "Translate some sentences!"; + this.usage = "[-fromlang] [-tolang]"; + this.remarks = Arrays.asList("Supported Languages: `en, ja, zh, de, es, fr, it, ru, pl, pt, nl`"); + this.examples = Arrays.asList("-ja ใ‚ใชใŸใ‚‚ๆ–‡ๅŒ–ไบบใ ใจๆ€ใ„ใพใ™", "-es despacito"); + this.alias = Arrays.asList("tl"); + /*try { + refresh(); + Response resp = Jsoup.connect("https://s.deepl.com/web/stats?request_type=jsonrpc").userAgent(agent) + .requestBody("{\"jsonrpc\":\"2.0\",\"method\":\"WebAppPushStatistics\",\"params\":{\"value\":{\"instanceId\":\"" + cVars.getString("uid") + "\",\"event\":\"web/pageview\",\"url\":\"https://www.deepl.com/en/translator\",\"data\":{\"gaBlocked\":false,\"referrer\":\"https://www.google.com/\"}}},\"id\":" + id++ + "}").method(Method.POST).execute(); + cookie = resp.cookies().get("LMTBID"); + //System.out.println(cookie); + } catch(IOException e) { + e.printStackTrace(); + }*/ + executor.scheduleAtFixedRate(() -> refresh(), 0, 450, TimeUnit.SECONDS); + } + + private int id = (ThreadLocalRandom.current().nextInt(1000) + 1) * 100000 + 1; + private String cookie = null, agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:73.0) Gecko/20100101 Firefox/73.0"; + //private JSONObject cVars; //old ver only + //private boolean update = true; + + final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactoryBuilder().setDaemon(true).setNameFormat("tl-scheduler-%d").build()); + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { + //if(cookie == null) return new CommandResult(CommandResultType.FAILURE, "The translation service is unavailable right now!"); //disable of cookie not found + + try { + String langF = "auto", langT = "EN", query = String.join(" ", args).replaceAll("\n", " ").replace("-", ""); //since it hates new lines and hyphens apparently + if(Arrays.asList("-en", "-ja", "-zh", "-de", "-es", "-fr", "-it", "-ru", "-pl", "-pt", "-nl").contains(args[0].toLowerCase())) { + langF = args[0].substring(1).toUpperCase(); + query = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); + } + if(Arrays.asList("-en", "-ja", "-zh", "-de", "-es", "-fr", "-it", "-ru", "-pl", "-pt", "-nl").contains(args[args.length - 1].toLowerCase())) { + langT = args[args.length - 1].substring(1).toUpperCase(); + query = query.substring(0, query.length() - 3); + } + if((args.length < 5 ? query.length() > 40 : args.length > 20)) return new CommandResult(CommandResultType.INVALIDARGS, "Please type a shorter phrase to translate!"); + //System.out.println(query); + //System.out.println(id); + //System.out.println("{\"jsonrpc\":\"2.0\",\"method\": \"LMT_handle_jobs\",\"params\":{\"jobs\":[{\"kind\":\"default\",\"raw_en_sentence\":\"" + query + "\",\"raw_en_context_before\":[],\"raw_en_context_after\":[],\"preferred_num_beams\":4,\"quality\":\"fast\"}]" + // + ",\"lang\":{\"user_preferred_langs\":[\"JA\"],\"source_lang_user_selected\":\"auto\",\"target_lang\":\"" + langT + "\"},\"priority\":-1,\"timestamp\":" + OffsetDateTime.now().toEpochSecond() + "000},\"id\":" + id + "}"); + + /*if(update) { + refresh(); + update = false; + executor.schedule(() -> update = true, 300, TimeUnit.SECONDS); + }*/ + + Connection con = Jsoup.connect("https://www2.deepl.com/jsonrpc").userAgent(agent) + .requestBody("{\"jsonrpc\":\"2.0\",\"method\": \"LMT_handle_jobs\",\"params\":{\"jobs\":[{\"kind\":\"default\",\"raw_en_sentence\":\"" + query + "\",\"raw_en_context_before\":[],\"raw_en_context_after\":[],\"preferred_num_beams\":4" + ((args.length < 2 ? query.length() > 5 : args.length > 5) ? "" : ",\"quality\":\"fast\"") + "}],\"commonJobParams\":{}" + + ",\"lang\":{\"user_preferred_langs\":[\"EN\", \"JA\"],\"source_lang_user_selected\":\"" + langF + "\",\"target_lang\":\"" + langT + "\"},\"priority\":-1,\"timestamp\":" + OffsetDateTime.now().toEpochSecond() + "000},\"id\":" + id++ + "}"); + if(cookie != null) con.cookie("LMTBID", cookie); + Response resp = con.method(Method.POST).execute(); + if(resp.hasCookie("LMTBID")) cookie = resp.cookie("LMTBID"); + Document doc = resp.parse(); + //System.out.println(doc.text()); + JSONObject main = new JSONObject(new JSONTokener(doc.text())).getJSONObject("result"); + EmbedBuilder eb = new EmbedBuilder(); + eb.setColor(0x0f2b46); + eb.setTitle("Translation: " + (langF.equals("auto") ? main.getString("source_lang") + "(detected)" : langF) + " -> " + main.getString("target_lang")); + eb.addField("Original", query, true); + String tl = ""; + for(Object obj : main.getJSONArray("translations").getJSONObject(0).getJSONArray("beams")) { + String p = ((JSONObject) obj).getString("postprocessed_sentence"); + if(tl.isEmpty()) tl = p + "\n"; + else tl += "*" + p + "*\n"; + + } + eb.addField("Translated", tl, true); + eb.setFooter("Translated by DeepL", "https://egress.storeden.net/gallery/5a2577c9ffe48e7e4b418464"); + eb.setTimestamp(OffsetDateTime.now()); + channel.sendMessage(eb.build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } catch(IOException e) { + String ex = ExceptionUtils.getStackTrace(e); + /*if(ex.contains("Status=429")) { + refresh(); + return execute(channel, author, msg, args); + } else { + return new CommandResult(CommandResultType.ERROR, ex); + }*/ + return new CommandResult(CommandResultType.ERROR, ex); + } + } + + private void refresh() { + //System.out.println(update); + //System.out.println("{\"jsonrpc\":\"2.0\",\"method\":\"getClientState\",\"params\":{\"v\":\"20180814\"" + (cVars == null ? "" : "," + cVars.toString()) + "},\"id\":" + id++ + "}"); + Connection con = Jsoup.connect("https://www.deepl.com/PHP/backend/clientState.php?request_type=jsonrpc&il=EN").userAgent(agent) + .requestBody("{\"jsonrpc\":\"2.0\",\"method\":\"getClientState\",\"params\":{\"v\":\"20180814\"" + /*(cVars == null ? "" : ",\"clientVars\":" + cVars.toString()) + */"},\"id\":" + id++ + "}"); + if(cookie != null) con.cookie("LMTBID", cookie); + + //JSONObject json = new JSONObject(new JSONTokener(con.post().text())); + //System.out.println(json); + //if(cVars == null) cVars = json.getJSONObject("result").getJSONObject("clientVars"); + + } +} diff --git a/src/me/despawningbone/discordbot/command/info/UrbanDictionary.java b/src/me/despawningbone/discordbot/command/info/UrbanDictionary.java new file mode 100644 index 0000000..a2f3062 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/info/UrbanDictionary.java @@ -0,0 +1,181 @@ +package me.despawningbone.discordbot.command.info; + +import java.awt.Color; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.math.BigInteger; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import org.apache.commons.lang3.StringEscapeUtils; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +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.MessageBuilder; +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.User; +import net.dv8tion.jda.api.exceptions.PermissionException; + +@SuppressWarnings("deprecation") +public class UrbanDictionary extends Command { + public UrbanDictionary() { + this.desc = "Ask the best dictionary in the world!"; + this.alias = Arrays.asList("urban", "ud", "u"); + this.usage = " [|index]"; + this.examples = Arrays.asList("life", "life | 2"); + } + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { + if (args.length < 1) { + return new CommandResult(CommandResultType.INVALIDARGS, "You can't search nothing you dumbo :stuck_out_tongue:"); + } + String search; + String content = String.join(" ", args); + JSONTokener result = null; + // System.out.println(content); //debug + boolean nonum = false; + boolean error = false; + int num = 0; + try { + try { + num = Integer.valueOf(content.split(" \\| ")[1]); + } catch (NumberFormatException e) { + error = true; + } + if (num < 1 || error) { + channel.sendMessage("Invalid number entered. The bot will default to the top definition.").queue(); + num = 0; + } else { + num = num - 1; + } + } catch (ArrayIndexOutOfBoundsException e) { + nonum = true; + } + String ss = null; + if (nonum) { + ss = content; + } else { + ss = content.split(" \\| ", 2)[0]; + } + try { + search = URLEncoder.encode(ss, "UTF-8"); + try { + InputStream input = new URL("http://api.urbandictionary.com/v0/define?term=" + search).openStream(); + result = new JSONTokener(new InputStreamReader(input, "UTF-8")); + + } catch (MalformedURLException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } catch (UnsupportedEncodingException e) { + e.printStackTrace(); + } + if(result == null) return new CommandResult(CommandResultType.FAILURE, "The service is not available right now!"); + JSONObject mainobj = new JSONObject(result); + // System.out.println(mainobj.toString()); //debug + JSONArray choice = mainobj.getJSONArray("list"); + if (choice.length() < 1) { + return new CommandResult(CommandResultType.NORESULT); + } + // System.out.println(choice.toString()); //debug + List list = new ArrayList(); + for (int i = 0; i < choice.length(); i++) { + list.add(choice.getJSONObject(i)); + } + Collections.sort(list, new Comparator() { + + public int compare(JSONObject a, JSONObject b) { + int valA = 0; + int valB = 0; + valA = a.getInt("thumbs_up") - a.getInt("thumbs_down"); + valB = b.getInt("thumbs_up") - b.getInt("thumbs_down"); + return Integer.compare(valB, valA); + } + }); + + JSONArray sortedchoice = new JSONArray(list); + JSONObject r; + try { + r = (JSONObject) sortedchoice.get(num); + } catch (JSONException e) { + channel.sendMessage("The number you've entered is too large. The bot will default to the top definition.") + .queue(); + num = 0; + r = (JSONObject) sortedchoice.get(num); + } + // JSONObject r = (JSONObject) choice.get(num); + String def = StringEscapeUtils.unescapeJava(r.getString("definition")); + String tlink = StringEscapeUtils.unescapeJava(r.getString("permalink")); + String eg = StringEscapeUtils.unescapeJava(r.getString("example")); + String word = StringEscapeUtils.unescapeJava(r.getString("word")); + String a = StringEscapeUtils.unescapeJava(r.getString("author")); + BigInteger tu = r.getBigInteger("thumbs_up"); + BigInteger td = r.getBigInteger("thumbs_down"); + // String link = tlink.split("/", 4)[0] + "/" + tlink.split("/", 4)[1] + + // "/" + tlink.split("/", 4)[2] + "/"; + EmbedBuilder e = new EmbedBuilder(); + if (num == 0) { + e.setAuthor(word + " (Top definition)\n\n", tlink, null); + } else { + e.setAuthor(word + " (" + MiscUtils.ordinal(num + 1) + " definition)\n", tlink, null); + } + // e.setThumbnail("https://is4-ssl.mzstatic.com/image/thumb/Purple71/v4/71/5a/45/715a450b-b0af-0912-c0a6-208fd92b559b/source/256x256bb.jpg"); + e.setColor(new Color(0, 0, 255)); + try { + e.setDescription(def); + //e.addField("\nDefinition", def, false); + if (!eg.isEmpty()) { + e.addField("Example:", "```" + eg + "```", false); + } + e.addField("๐Ÿ‘ ", String.valueOf(tu), true); + e.addField("๐Ÿ‘Ž", String.valueOf(td), true); + e.setFooter("Author: " + a, null); + MessageEmbed em = e.build(); + channel.sendMessage(em).queue(); + } catch (PermissionException | IllegalArgumentException e1) { //this shouldnt happen anymore, since description limit is the same as a normal message + MessageBuilder smsg = new MessageBuilder(); + if (num == 0) { + smsg.append("**" + word + "** (Top definition)\n\n"); + } else { + smsg.append("**" + word + "** (" + MiscUtils.ordinal(num + 1) + " definition)\n\n"); + } + smsg.append(def); + if (!eg.isEmpty()) { + smsg.append("```\n" + eg + "```\n"); + } else { + smsg.append("\n\n"); + } + smsg.append("<" + tlink + ">\n\n"); + smsg.append("Author: `" + a + "`\n"); + smsg.append("๐Ÿ‘ " + tu + " ๐Ÿ‘Ž " + td); + Message fmsg = null; + try { + fmsg = smsg.build(); + } catch (IllegalStateException e2) { + return new CommandResult(CommandResultType.TOOLONG); + } + channel.sendMessage(fmsg).queue(); + } + return new CommandResult(CommandResultType.SUCCESS); + } + +} diff --git a/src/me/despawningbone/discordbot/command/info/UserInfo.java b/src/me/despawningbone/discordbot/command/info/UserInfo.java new file mode 100644 index 0000000..1e40dbe --- /dev/null +++ b/src/me/despawningbone/discordbot/command/info/UserInfo.java @@ -0,0 +1,93 @@ +package me.despawningbone.discordbot.command.info; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; + +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.Activity.ActivityType; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.Role; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; + +public class UserInfo extends Command { + public UserInfo() { + this.desc = "Get a user's information"; + this.usage = "[-a] [usertag/username]"; + this.alias = Arrays.asList("ui", "user"); + this.remarks = Arrays.asList("Leave blank for your own user info!", "", "* Specify the `-a` parameter to get a larger profile picture instead of a thumbnail."); + } + + //DONE merge with ID.java? + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { + List userList = msg.getMentionedUsers(); + User user = null; + List params = new ArrayList<>(Arrays.asList(args)); + boolean bigPfp = params.removeAll(Collections.singleton("-a")); //pass by ref so it removes from the list + if (params.size() < 1) { + user = author; + } else { + if (userList.isEmpty()) { + String pname = String.join(" ", params); + List pm = channel.getGuild().getMembersByEffectiveName(pname, true); + if (pm.size() == 0) pm = channel.getGuild().getMembersByName(pname, true); + if (pm.size() == 0) { + return new CommandResult(CommandResultType.FAILURE, "There is no such user."); + } + if (pm.size() > 1) { + return new CommandResult(CommandResultType.FAILURE, "Theres more than 1 user with the same name. Please use tags instead.\n"); + } + user = pm.get(0).getUser(); + } else { + if (userList.size() > 1) { + return new CommandResult(CommandResultType.INVALIDARGS, "Please only enter one user."); + } + user = userList.get(0); + } + } + Member member = channel.getGuild().getMember(user); + EmbedBuilder eb = new EmbedBuilder(); + eb.setTitle(user.getName() + "#" + user.getDiscriminator() + "'s " + (user.isBot() ? "bot" : "user") + " info"); + eb.addField("Tag", "<@!" + user.getId() + ">", true); + eb.addField("ID", user.getId(), true); + eb.addField("Nickname", member.getNickname() == null ? "N/A" : member.getNickname(), true); + member.getActivities().forEach(game -> { + String playing = game.getName(); + if(game.getType().equals(ActivityType.CUSTOM_STATUS)) playing = (game.getEmoji() == null ? "" : game.getEmoji().getAsMention()) + " " + (game.getName().equals("Custom Status") ? "" : game.getName()); + if(game.isRich()) { + if(game.asRichPresence().getState() != null) playing += " - " + game.asRichPresence().getState(); + if(game.asRichPresence().getTimestamps() != null) playing += "\n (Elapsed: " + MiscUtils.convertMillis(game.asRichPresence().getTimestamps().getElapsedTime(ChronoUnit.MILLIS)) + ")"; + } + eb.addField(game.getType().name().equals("DEFAULT") ? "Playing" : StringUtils.capitalize(game.getType().name().toLowerCase().replaceAll("_", " ")), playing, true); + }); + if(member.getActivities().size() == 0) eb.addField("Playing", "N/A", true); //add back N/A + eb.addField("In voice", member.getVoiceState().inVoiceChannel() ? (member.getVoiceState().getChannel().getName() + (member.getVoiceState().isMuted() ? " (Muted)" : "")) : "N/A", true); + eb.addField("Creation date", user.getTimeCreated().format(DateTimeFormatter.RFC_1123_DATE_TIME), true); + eb.addField("Join date", member.getTimeJoined().format(DateTimeFormatter.RFC_1123_DATE_TIME), true); + ArrayList roleList = new ArrayList<>(); + for(Role role : member.getRoles()) { + roleList.add(role.getName()); + } + eb.addField("Roles (" + roleList.size() + ")", roleList.isEmpty() ? "N/A" : String.join(", ", roleList), false); + eb.setColor(member.getColor()); + eb.setFooter(StringUtils.capitalize(member.getOnlineStatus().name().toLowerCase().replaceAll("_", " ")), null); + eb.setTimestamp(OffsetDateTime.now()); + if(user.getAvatarUrl() != null) + if(bigPfp) eb.setImage(user.getAvatarUrl() + "?size=2048"); else eb.setThumbnail(user.getAvatarUrl() + "?size=1024"); + channel.sendMessage(eb.build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } +} diff --git a/src/me/despawningbone/discordbot/command/info/Wikipedia.java b/src/me/despawningbone/discordbot/command/info/Wikipedia.java new file mode 100644 index 0000000..fa8b6e9 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/info/Wikipedia.java @@ -0,0 +1,88 @@ +package me.despawningbone.discordbot.command.info; + +import java.io.IOException; +import java.net.URL; +import java.net.URLEncoder; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.Locale; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +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.User; + +public class Wikipedia extends Command { + public Wikipedia() { + this.desc = "Ask the almighty Wikipedia!"; + this.alias = Arrays.asList("wiki"); + this.usage = "[-lang] [| index]"; + this.remarks = Arrays.asList("You can search a specific language's Wikipedia with the parameter, as long as there is a valid subdomain for that language,", "e.g. `en.wikipedia.org` or `ja.wikipedia.org`."); + this.examples = Arrays.asList("C++", "ping | 4", "-ja ็ง‹่‘‰ๅŽŸ"); + } + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { //DONE make use of XHR instead? + if (args.length < 1) { + return new CommandResult(CommandResultType.INVALIDARGS, "Search something :joy:"); + } + try { + String lang = "en"; + if(args.length > 1 && args[0].startsWith("-")) { + lang = args[0].substring(1); + args = Arrays.copyOfRange(args, 1, args.length); + } + + String[] split = String.join(" ", args).split(" \\|"); + String sword = 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 e) { + channel.sendMessage("Invalid index inputted. Defaulting to first result...").queue(); + } + + String search = URLEncoder.encode(sword, "UTF-8"); + + String title; + + try { + JSONObject s = new JSONObject(new JSONTokener(new URL("https://" + lang + ".wikipedia.org/w/api.php?action=query&format=json&list=search&srsearch=" + search).openStream())); + title = URLEncoder.encode(s.getJSONObject("query").getJSONArray("search").getJSONObject(index).getString("title"), "UTF-8").replaceAll("\\+", "%20"); + } catch(IOException e) { //usually caused by wrong language wiki + return new CommandResult(CommandResultType.INVALIDARGS, "Unknown wiki language version specified."); + } catch(JSONException e) { + return new CommandResult(CommandResultType.INVALIDARGS, "There are not enough results for your specified index!"); + } + + JSONObject result = new JSONObject(new JSONTokener(new URL("https://" + lang + ".wikipedia.org/api/rest_v1/page/summary/" + title).openStream())); + + //TODO do something with type = disambiguition? + + EmbedBuilder eb = new EmbedBuilder(); + + eb.setAuthor(new Locale(result.getString("lang")).getDisplayName(Locale.ENGLISH) + " Wikipedia"); //DONE add support for other languages? + if(result.has("thumbnail")) eb.setThumbnail(result.getJSONObject("thumbnail").getString("source")); + eb.setTitle(result.getString("displaytitle").replaceAll("", "*"), result.getJSONObject("content_urls").getJSONObject("desktop").getString("page")); + eb.setDescription(result.getString("extract")); + eb.setFooter("Last revision id " + result.getString("revision"), "https://www.wikipedia.org/portal/wikipedia.org/assets/img/Wikipedia-logo-v2.png"); + eb.setTimestamp(OffsetDateTime.parse(result.getString("timestamp"))); + + channel.sendMessage(eb.build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); //add searched result name? + } catch (Exception e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + } +} diff --git a/src/me/despawningbone/discordbot/command/misc/BaseConvert.java b/src/me/despawningbone/discordbot/command/misc/BaseConvert.java new file mode 100644 index 0000000..800f6e0 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/misc/BaseConvert.java @@ -0,0 +1,113 @@ +package me.despawningbone.discordbot.command.misc; + +import java.util.Arrays; + +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.entities.Message; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; + +public class BaseConvert extends Command { + public BaseConvert() { + this.alias = Arrays.asList("bc"); + this.desc = "convert integers of a base to another base!"; + this.usage = "[frombase] "; + this.examples = Arrays.asList("2 100", "16 8 FF"); + } + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { //base 1 is weird, fix? + String x = "0"; + long base = 10; + long frombase = 10; + + try { + if (args.length < 3) { + try { + base = Long.parseLong(args[0]); + x = args[1]; + } catch (ArrayIndexOutOfBoundsException e) { + return new CommandResult(CommandResultType.INVALIDARGS, "Please enter" + (args.length < 1 ? "something :joy:" : "a value to convert.")); + } catch (NumberFormatException e) { + base = MiscUtils.nameToBase(args[0]); + } + } else { + try { frombase = Long.parseLong(args[0]); } catch (NumberFormatException e) { frombase = MiscUtils.nameToBase(args[0]); } + try { base = Long.parseLong(args[1]); } catch (NumberFormatException e) { base = MiscUtils.nameToBase(args[1]); } + x = args[2]; + } + if (base > 30 || frombase > 30) { + return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a base smaller than 30."); + } + try { + channel.sendMessage("Base" + MiscUtils.longToSubscript(frombase) + ": `" + x.toUpperCase() + "`\n" + "Base" + + MiscUtils.longToSubscript(base) + ": `" + + Long.toString(Long.parseLong(x, (int) frombase), (int) base).toUpperCase() + "`").queue(); + return new CommandResult(CommandResultType.SUCCESS); + } catch (NumberFormatException e) { + return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a valid value within the base range specified."); + } + } catch (IllegalArgumentException e) { + return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); + } + + /*DEPRECATED OLD CODE*/ + + /*try { + if (args.length < 2) { + try { + base = Long.parseLong(msg.getContentDisplay().split(" ")[2]); + } catch (ArrayIndexOutOfBoundsException e) { + channel.sendMessage("Please enter something :joy:").queue(); + return new CommandResult(CommandResultType.INVALIDARGS, null); + } + } else { + frombase = Long.parseLong(msg.getContentDisplay().split(" ")[2]); + base = Long.parseLong(msg.getContentDisplay().split(" ")[3]); + } + if (base > 30 || frombase > 30) { + channel.sendMessage("Please enter a base smaller than 30.").queue(); + return new CommandResult(CommandResultType.INVALIDARGS, null); + } + } catch (NumberFormatException e) { + String sbase = msg.getContentDisplay().split(" ")[2]; + try { + if (msg.getContentDisplay().split(" ").length > 4) { + frombase = MiscUtils.nameToBase(sbase); + base = MiscUtils.nameToBase(msg.getContentDisplay().split(" ")[3]); + } else { + base = MiscUtils.nameToBase(sbase); + } + } catch (NullPointerException | IllegalArgumentException e1) { + if (e1 instanceof IllegalArgumentException) { + channel.sendMessage(e1.getMessage()).queue(); + return new CommandResult(CommandResultType.INVALIDARGS, null); + } + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1)); + } + } + if (msg.getContentDisplay().split(" ").length < 5) { + try { + x = msg.getContentDisplay().split(" ", 4)[3]; + } catch (ArrayIndexOutOfBoundsException e) { + channel.sendMessage("Please enter a value to convert.").queue(); + return new CommandResult(CommandResultType.INVALIDARGS, null); + } + } else { + x = msg.getContentDisplay().split(" ", 5)[4]; + } + try { + channel.sendMessage("Base" + MiscUtils.longToSubscript(frombase) + ": `" + x + "`\n" + "Base" + + MiscUtils.longToSubscript(base) + ": `" + + Long.toString(Long.parseLong(x, (int) frombase), (int) base).toUpperCase() + "`").queue(); + return new CommandResult(CommandResultType.SUCCESS, null); + } catch (NumberFormatException e) { + channel.sendMessage("Please enter a valid positive integer within the base range specified.").queue(); + return new CommandResult(CommandResultType.INVALIDARGS, null); + }*/ + } + +} diff --git a/src/me/despawningbone/discordbot/command/misc/Choose.java b/src/me/despawningbone/discordbot/command/misc/Choose.java new file mode 100644 index 0000000..a881b6c --- /dev/null +++ b/src/me/despawningbone/discordbot/command/misc/Choose.java @@ -0,0 +1,45 @@ +package me.despawningbone.discordbot.command.misc; + +import java.util.Arrays; +import java.util.Random; + +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.User; + +public class Choose extends Command { + public Choose() { + this.desc = "Let me choose the best for you!"; + this.usage = " | [...]"; + this.examples = Arrays.asList("eggs | ham | sausage | tomato"); + } + + @Override //tags might get echoed if someone did "markdown injection" lmao //nvm fixed + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { + if (args.length < 1) { + return new CommandResult(CommandResultType.INVALIDARGS, "Please enter something for me to choose from lol"); + } + //String stripped = String.join(" ", args); + String stripped = msg.getContentStripped().replaceAll("\\s\\s+", " ").split(" ", 3)[2]; + if (stripped.contains("|")) { + String[] split = stripped.split(" *\\| *"); + Random randomno = new Random(); + //System.out.println(split.length); + if (split.length < 2 || (split.length == 2 && (split[0].isEmpty() || split[1].isEmpty()))) { + return new CommandResult(CommandResultType.INVALIDARGS, "At least put 2 choices for me to choose from lol"); + } else if (Arrays.asList(split).contains("")) { + return new CommandResult(CommandResultType.INVALIDARGS, "Please do not input empty choices."); + } + int ran = randomno.nextInt(split.length); + channel.sendMessage("<@!" + author.getId() + ">: If I were you, I would have chosen `" + split[ran] + + "` :stuck_out_tongue:").queue(); + return new CommandResult(CommandResultType.SUCCESS); + } else { + return new CommandResult(CommandResultType.INVALIDARGS, "At least put 2 choices for me to choose from lol"); + } + } + +} diff --git a/src/me/despawningbone/discordbot/command/misc/EightBall.java b/src/me/despawningbone/discordbot/command/misc/EightBall.java new file mode 100644 index 0000000..8e1d9d9 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/misc/EightBall.java @@ -0,0 +1,61 @@ +package me.despawningbone.discordbot.command.misc; + +import java.util.Random; + +import me.despawningbone.discordbot.command.Command; +import me.despawningbone.discordbot.command.CommandResult; +import me.despawningbone.discordbot.command.CommandResult.CommandResultType; +import net.dv8tion.jda.api.MessageBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; + +public class EightBall extends Command { + public EightBall() { + this.name = "8ball"; //override name + this.desc = "Ask the almighty 8ball!"; + this.usage = ""; + } + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { + Random randomno = new Random(); + MessageBuilder ans = new MessageBuilder(); + if (args.length < 1) { + return new CommandResult(CommandResultType.INVALIDARGS, "Well, what do you even want to ask?"); + } else { + ans.append("The 8ball says:\n"); + int ran = randomno.nextInt(9) + 1; + if (ran == 1) { + ans.append("`Seems true.`"); + } + if (ran == 2) { + ans.append("`Oops. Don't count on that.`"); + } + if (ran == 3) { + ans.append("`What did you say again?`"); + } + if (ran == 4) { + ans.append("`Sure.`"); + } + if (ran == 5) { + ans.append("`Nope. Just no.`"); + } + if (ran == 6) { + ans.append("`Ask your mom.`"); + } + if (ran == 7) { + ans.append("`Aint gonna answer ;)`"); + } + if (ran == 8) { + ans.append("`I approve.`"); + } + if (ran == 9) { + ans.append("`Chance of speaking the truth: 0.001%.`"); + } + } + Message fans = ans.build(); + channel.sendMessage(fans).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } +} diff --git a/src/me/despawningbone/discordbot/command/misc/Roll.java b/src/me/despawningbone/discordbot/command/misc/Roll.java new file mode 100644 index 0000000..550a9e6 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/misc/Roll.java @@ -0,0 +1,99 @@ +package me.despawningbone.discordbot.command.misc; + +import java.util.Arrays; +import java.util.Random; + +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.MessageBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; + +public class Roll extends Command { + public Roll() { + this.desc = "Roll a dice!"; + this.usage = "[int][-int]"; + this.remarks = Arrays.asList("Possible parameter combinations are as follows:\n" + + DiscordBot.prefix + "roll - this will return a value within normal dice range.\n" + + DiscordBot.prefix + "roll [intbound]\n - this will return a value from 0 to the bound.\n" + + DiscordBot.prefix + "roll [intmin]-[intmax] - this will return a value within the range.\n"); + this.examples = Arrays.asList("", "100", "100-1000"); + } + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { + Random randomno = new Random(); + MessageBuilder smsg = new MessageBuilder(); + int num = 6; + int range = 0; + boolean no = false; + if (args.length > 0) { + String p1 = args[0]; + int n = 0; + try { + n = Integer.parseInt(p1); + } catch (NumberFormatException e) { + if (p1.equals("yourself")) { // easter egg + smsg.append("*rolls*"); + no = true; + n = 1; + } else if (p1.contains("-")) { + try { + range = Integer.parseInt(p1.split("-")[0]); + n = Integer.parseInt(p1.split("-")[1]); + } catch (NumberFormatException e1) { + smsg.append("Please enter a correct range.\n"); + no = true; + } + } else { + smsg.append("Please enter a correct number.\n"); + no = true; + n = 1; + } + } + if (n > 100000000) { + smsg.append("The number entered is too large.\n"); + no = true; + } + if (n < 1 && !no) { + smsg.append("No number smaller than 1, please.\n"); + no = true; + } + if (!no) { + num = n; + if (num < range) { + int buffer = num; + num = range; + range = buffer; + } + int dice = randomno.nextInt((num - range) + 1) + range; + String ID = author.getId(); + smsg.append("<@!" + ID + ">" + " ,Your roll is: "); + smsg.append(String.valueOf("`" + dice + "`" + "!")); + Message out = smsg.build(); + channel.sendMessage(out).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } else { + Message out = smsg.build(); + channel.sendMessage(out).queue(); + return new CommandResult(CommandResultType.FAILURE, smsg.toString()); + } + } else { + int dice = 0; + if (range == 0) { + dice = randomno.nextInt(num) + 1; + } else { + dice = randomno.nextInt((num - range) + 1) + range; + } + String ID = author.getId(); + smsg.append("<@!" + ID + ">" + " ,Your roll is: "); + smsg.append(String.valueOf("`" + dice + "`" + "!")); + Message out = smsg.build(); + channel.sendMessage(out).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } + } +} diff --git a/src/me/despawningbone/discordbot/command/misc/Say.java b/src/me/despawningbone/discordbot/command/misc/Say.java new file mode 100644 index 0000000..0a1f71d --- /dev/null +++ b/src/me/despawningbone/discordbot/command/misc/Say.java @@ -0,0 +1,94 @@ +package me.despawningbone.discordbot.command.misc; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; + +import me.despawningbone.discordbot.command.Command; +import me.despawningbone.discordbot.command.CommandResult; +import me.despawningbone.discordbot.command.CommandResult.CommandResultType; +import net.dv8tion.jda.api.MessageBuilder; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.Message.MentionType; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; + +public class Say extends Command { //DONE rewrite to say who echoed it, or even disable it for now //reenabled it with MESSAGE_MANAGE perms; not my fault if server owners decided to allow people to (ab)use it lmao + + List FilterWords = new ArrayList( + Arrays.asList("fuck", "shit", "cunt", "pussy", "penis", "whore", "rape", "faggot", "nigga"/*, "gay"*/)); //this is 2020 we cant disallow people saying gay LOL + List BotPrefix = new ArrayList(Arrays.asList("!", ".", "++", "_", "-", "?")); + + public Say() { + this.desc = "Make the bot say something!"; + this.usage = ""; + this.perms = EnumSet.of(Permission.MESSAGE_MANAGE); + } + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { + MessageBuilder smsg = new MessageBuilder(); + String[] spmsg = msg.getContentRaw().split(" ", 3); + if (args.length < 1) { + return new CommandResult(CommandResultType.INVALIDARGS, "Say something -.-"); + } + boolean noappend = false; + int fcount = 0; + int ecount = 0; + if (msg.getContentDisplay().toLowerCase().contains("pen is")) { + ecount = 1; + } + if(!msg.getMentions(MentionType.values()).isEmpty() || msg.getContentStripped().contains("@everyone")) { + return new CommandResult(CommandResultType.FAILURE, "Please don't echo tags."); + } + + for (int t = 0; t < FilterWords.size(); t++) { + String trimmedmsg = msg.getContentDisplay().replaceAll("\\s", ""); + if (trimmedmsg.toLowerCase().contains(FilterWords.get(t))) { + fcount++; + } + } + // System.out.println(fcount); + if (fcount - ecount != 0) { + return new CommandResult(CommandResultType.FAILURE, "You are not permitted to do so."); + } + for (int c = 0; c < BotPrefix.size(); c++) { + if (spmsg[2].startsWith(BotPrefix.get(c))) { + smsg.append("\\" + spmsg[2]); + noappend = true; + break; + } + } + if (!noappend) { + smsg.append(spmsg[2]); + } + String content = smsg.getStringBuilder().toString(); + MessageBuilder fbmsg = new MessageBuilder(); + if (content.toLowerCase().contains("https://") || content.toLowerCase().contains("https://")) { + String[] wsplit; + String[] tmpsplit; + if (content.toLowerCase().contains("https://")) { + wsplit = content.split("https://"); + fbmsg.append(wsplit[0]); + tmpsplit = wsplit[1].split(" "); + fbmsg.append(" "); + } else { + wsplit = content.split("http://"); + fbmsg.append(wsplit[0]); + tmpsplit = wsplit[1].split(" "); + fbmsg.append(" "); + } + for (int i = 0; i < tmpsplit.length - 1; i++) { + fbmsg.append(tmpsplit[i + 1] + " "); + } + } else { + fbmsg.append(content); + } + //fbmsg.append("\n- " + author.getName() + "#" + author.getDiscriminator()); hey they have message manage perms whatever + Message fmsg = fbmsg.build(); + channel.sendMessage(fmsg).queue(); + return new CommandResult(CommandResultType.SUCCESS, "Message echoed: " + fbmsg.getStringBuilder().toString()); + } +} diff --git a/src/me/despawningbone/discordbot/command/misc/Spoiler.java b/src/me/despawningbone/discordbot/command/misc/Spoiler.java new file mode 100644 index 0000000..9aa85be --- /dev/null +++ b/src/me/despawningbone/discordbot/command/misc/Spoiler.java @@ -0,0 +1,84 @@ +package me.despawningbone.discordbot.command.misc; + +import java.awt.Color; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Arrays; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +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; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; + +public class Spoiler extends Command { //allow people to redirect the spoiler box to another channel? + + public Spoiler() { + this.desc = "Turns your message into a hover-only spoiler box!"; + this.usage = "[title:] "; + this.examples = Arrays.asList("mario: peach get rescued"); + this.isDisabled = true; + } + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { //foul word filter? + if(channel.getGuild().getSelfMember().hasPermission(Permission.MESSAGE_MANAGE)) { + if (args.length < 1) { + return new CommandResult(CommandResultType.INVALIDARGS, "Say something -.-"); + } + channel.deleteMessageById(msg.getId()).queue(); + EmbedBuilder eb = new EmbedBuilder(); + String[] lineSplit = msg.getContentDisplay().split(":", 2); + String message; + if(lineSplit.length < 2) { + message = String.join(" ", args); + } else { + message = lineSplit[1]; + try { + eb.setTitle(lineSplit[0].split(" ", 3)[2]); + } catch (IllegalArgumentException e) { + channel.sendMessage(e.getMessage()).queue(m -> MiscUtils.delayDeleteMessage(m, 5000)); + return new CommandResult(CommandResultType.INVALIDARGS); + } + } + eb.setAuthor(author.getName() + "#" + author.getDiscriminator()); + //eb.setDescription("[Hover me for the spoiler!](https://discordapp.com/channels/" + channel.getGuild().getId() + "/" + channel.getId() + " \"" + lineSplit[1] + "\")"); + try { + eb.setDescription("[Hover me for the spoiler!](https://hastebin.com/" + pasteToHastebin(message) + ".txt \"" + message + "\")"); + } catch (JSONException | IOException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + eb.setColor(new Color(255, 255, 0)); + eb.setFooter("Click on the link if you cannot see the hover, or are on Discord mobile!", null); + channel.sendMessage(eb.build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } else { + return new CommandResult(CommandResultType.FAILURE, "The bot has no permission to delete messages, and making spoiler box with the actual spoilers still visible seems pointless, ain't it? :P"); + } + } + + private String pasteToHastebin(String message) throws IOException { + URL url = new URL("https://hastebin.com/documents"); + HttpURLConnection httpCon = (HttpURLConnection) url.openConnection(); + httpCon.setDoOutput(true); + httpCon.setRequestMethod("POST"); + httpCon.addRequestProperty("User-Agent", "Mozilla/4.0"); + httpCon.setRequestProperty("Content-Type", "text/plain; charset=UTF-8"); + OutputStreamWriter out = new OutputStreamWriter(httpCon.getOutputStream()); + out.write(message); + out.close(); + //System.out.print(httpCon.getResponseMessage()); + return new JSONObject(new JSONTokener(httpCon.getInputStream())).getString("key"); + } +} diff --git a/src/me/despawningbone/discordbot/command/music/AudioPlayerSendHandler.java b/src/me/despawningbone/discordbot/command/music/AudioPlayerSendHandler.java new file mode 100644 index 0000000..2266cab --- /dev/null +++ b/src/me/despawningbone/discordbot/command/music/AudioPlayerSendHandler.java @@ -0,0 +1,40 @@ +package me.despawningbone.discordbot.command.music; + +import java.nio.ByteBuffer; + +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame; +import net.dv8tion.jda.api.audio.AudioSendHandler; + +/** + * This is a wrapper around AudioPlayer which makes it behave as an AudioSendHandler for JDA. As JDA calls canProvide + * before every call to provide20MsAudio(), we pull the frame in canProvide() and use the frame we already pulled in + * provide20MsAudio(). + */ +public class AudioPlayerSendHandler implements AudioSendHandler { + private final AudioPlayer audioPlayer; + private AudioFrame lastFrame; + + /** + * @param audioPlayer Audio player to wrap. + */ + public AudioPlayerSendHandler(AudioPlayer audioPlayer) { + this.audioPlayer = audioPlayer; + } + + @Override + public boolean canProvide() { + lastFrame = audioPlayer.provide(); + return lastFrame != null; + } + + @Override + public ByteBuffer provide20MsAudio() { + return ByteBuffer.wrap(lastFrame.getData()); + } + + @Override + public boolean isOpus() { + return true; + } +} \ No newline at end of file diff --git a/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java b/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java new file mode 100644 index 0000000..63c4c83 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java @@ -0,0 +1,463 @@ +package me.despawningbone.discordbot.command.music; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.MalformedURLException; +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.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionTimeoutException; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers; +import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; +import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.wrapper.spotify.SpotifyApi; + +import me.despawningbone.discordbot.DiscordBot; +import me.despawningbone.discordbot.command.CommandResult; +import me.despawningbone.discordbot.command.CommandResult.CommandResultType; +import me.despawningbone.discordbot.utils.MiscUtils; +import net.dv8tion.jda.api.MessageBuilder; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.VoiceChannel; +import net.dv8tion.jda.api.managers.AudioManager; +import net.dv8tion.jda.internal.utils.PermissionUtil; + +public class AudioTrackHandler { + + private ConcurrentHashMap musicManagers; + private AudioPlayerManager playerManager; + + final String GAPI = DiscordBot.tokens.getProperty("google"); //package final + + public ScheduledExecutorService ex = Executors.newScheduledThreadPool(2, new ThreadFactoryBuilder().setDaemon(true).setNameFormat("audio-scheduler-%d").build()); + + static class TrackData { + private String url; + private String uDis; + private String fDur; + + private List votes = new ArrayList<>(); + + public String getUrl() { + return url; + } + + public String getUserWithDiscriminator() { + return uDis; + } + + public String getFormattedDuration() { + return fDur; + } + + public int voteSkip(User user, int req) { + if (votes.contains(user.getId())) + throw new UnsupportedOperationException("You have already voted!"); + votes.add(user.getId()); + if (votes.size() < req) { + return votes.size(); + } else { + votes.clear(); + return -1; + } + } + + public TrackData(String url, User user, long durMillis) { + this.url = url; + this.uDis = user.getName() + "#" + user.getDiscriminator(); + this.fDur = MiscUtils.convertMillis(durMillis); + } + + //overload for autoplay + public TrackData(String url, String user, long durMillis) { + this.url = url; + this.uDis = user; + this.fDur = MiscUtils.convertMillis(durMillis); + } + } + + public AudioTrackHandler() { + this.musicManagers = new ConcurrentHashMap<>(); + this.playerManager = new DefaultAudioPlayerManager(); + playerManager.setFrameBufferDuration(1000); + playerManager.getConfiguration().setFilterHotSwapEnabled(true); + + try { //register spotify source manager + playerManager.registerSourceManager(new SpotifyAudioSourceManager(new SpotifyApi.Builder().setClientId(DiscordBot.tokens.getProperty("spotifyid")).setClientSecret(DiscordBot.tokens.getProperty("spotifysecret")).build(), + playerManager, this)); + } catch (Exception e) { + e.printStackTrace(); + } + + AudioSourceManagers.registerRemoteSources(playerManager); + AudioSourceManagers.registerLocalSource(playerManager); + } + + public GuildMusicManager getGuildMusicManager(Guild guild) { + String guildId = guild.getId(); + GuildMusicManager musicManager; + + musicManager = musicManagers.get(guildId); + if (musicManager == null) { + musicManager = new GuildMusicManager(guild, this, playerManager); + musicManagers.put(guildId, musicManager); + } + guild.getAudioManager().setSendingHandler(musicManager.getSendHandler()); + + return musicManager; + } + + public CompletableFuture searchAndPlay(String search, String type, Member user, TextChannel channel) throws NoSuchElementException { + CompletableFuture resFuture = new CompletableFuture<>(); + String url = search; + try { + new URL(url); + } catch(MalformedURLException e) { + url = fetchUrlFromSearch(search, type); + } + if (url == null) + throw new NoSuchElementException(); + + load(user, url, (n, l) -> { + if (l.size() > 100) { + resFuture.complete(new CommandResult(CommandResultType.INVALIDARGS, "Cannot queue in a playlist of more than 100 tracks.")); + return; + } + + if(!user.getVoiceState().inVoiceChannel()) { + resFuture.complete(new CommandResult(CommandResultType.FAILURE, "You are not in a voice channel.")); + return; + } + if((channel.getGuild().getAudioManager().isConnected() && !channel.getGuild().getAudioManager().getConnectedChannel().getId().equals(user.getVoiceState().getChannel().getId()))) { + resFuture.complete(new CommandResult(CommandResultType.FAILURE, "You are not in the same channel as the bot.")); + return; + } + + if (l.size() > 10 || l.stream().anyMatch(t -> t.getInfo().isStream)) { + GuildMusicManager mm = getGuildMusicManager(user.getGuild()); + int req = (int) Math.ceil(user.getVoiceState().getChannel().getMembers().stream().filter(mem -> !mem.getUser().isBot()).count() / 2.0); + if(req > 1 && !DiscordBot.ModID.contains(user.getId())) { + if(mm.pending != null) { + resFuture.complete(new CommandResult(CommandResultType.FAILURE, "There is already a pending playlist or livestream.")); + return; + } + mm.vote(user.getUser(), "tracks", req); //self vote; should never return -1 (success) coz req > 1 + channel.sendMessage("Due to the total duration of your requested tracks, it has been added to pending. It will be automatically removed if it has not been approved by the users in the channel for longer than 1 minute.\n" + "Others in the channel should use `!desp music approve` to vote.").queue(); + mm.pending = l; + mm.pendingCleanup = ex.schedule(() -> { + mm.clearVotes("tracks"); + mm.pending = null; + mm.pendingCleanup = null; + channel.sendMessage(user.getUser().getName() + "'s" + (l.size() > 1 ? " playlist " : " livestream ") + "request has timed out.").queue(); + }, 1, TimeUnit.MINUTES); + resFuture.complete(new CommandResult(CommandResultType.SUCCESS, "Pending approval")); + return; + } + } + + try { + if(!l.isEmpty()) { + int startIndex = queueTracks(l, user) + 1; + if (l.size() == 1){ + channel.sendMessage("Adding `" + l.get(0).getInfo().title + "` (" + (l.get(0).getDuration() == Long.MAX_VALUE ? "N/A" : l.get(0).getUserData(TrackData.class).getFormattedDuration()) + ") to the queue. [`" + startIndex + "`]").queue(); + } else if (l.size() > 1) { + channel.sendMessage("Adding playlist `" + n + "` to the queue, queue now has a total of `" + (startIndex + l.size() - 1) + "` tracks.").queue(); + channel.sendMessage((startIndex == 1 ? "Playing `" : "First track: `") + l.get(0).getInfo().title + "` (" + l.get(0).getUserData(TrackData.class).getFormattedDuration() + ") [`" + startIndex + "`].").queue(); + } + resFuture.complete(new CommandResult(CommandResultType.SUCCESS)); + } else { + resFuture.complete(new CommandResult(CommandResultType.NORESULT)); + } + } catch (UnsupportedOperationException e) { + resFuture.complete(new CommandResult(CommandResultType.FAILURE, e.getMessage())); + } + + }, (ex) -> resFuture.complete(ex.getStackTrace()[0].getMethodName().equals("readPlaylistName") ? new CommandResult(CommandResultType.FAILURE, "Cannot read the playlist specified. Is it private?") : new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex)))); + return resFuture; + } + + private String fetchUrlFromSearch(String search, String type) { + if (type.startsWith("youtube")) { + type = type.substring(8, type.length()); + if (type.equals("video")) { + return "ytsearch:" + search; + } else { //only use yt api when playlist (and autoplay) due to strict rate limit + try { //NOADD check if theres a way to search for playlists and vids at the same time //using different commands now + InputStream input = new URL("https://www.googleapis.com/youtube/v3/search?part=snippet%20&q=" + URLEncoder.encode(search, "UTF-8") + "%20&type=" + type + "%20&key=" + GAPI).openStream(); + JSONObject result = new JSONObject(new JSONTokener(new InputStreamReader(input, "UTF-8"))).getJSONArray("items").getJSONObject(0); + JSONObject id = result.getJSONObject("id"); + String vidID = id.getString(type + "Id"); //can switch to pure playlist impl tbh i aint even using this api for normal vids + return "https://www.youtube.com/" + (type.equals("video") ? "watch?v=" : "playlist?list=") + vidID; + } catch (IOException e) { + e.printStackTrace(); + return null; + } catch (JSONException e) { + return null; + } + } + } else if (type.equals("soundcloud")) { + return "scsearch:" + search; + } + throw new UnsupportedOperationException("This provider is not implemented yet!"); + } + + //package private + void load(Member user, String url, BiConsumer> resultHandler, Consumer exceptionally) { + playerManager.loadItemOrdered(getGuildMusicManager(user.getGuild()), url, new AudioLoadResultHandler() { + @Override + public void trackLoaded(AudioTrack track) { + try { + track.setUserData(new TrackData(track.getInfo().uri, user.getUser(), track.getDuration())); + resultHandler.accept(null, Arrays.asList(track)); + } catch(Exception e) { + exceptionally.accept(e); //so i dont lose my sanity over silenced errors + } + } + + @Override + public void playlistLoaded(AudioPlaylist playlist) { + try { + if(playlist.getTracks().size() == 0) { //somehow its possible; do the same as noResult() + if(url.startsWith("ytsearch:")) load(user, url.replaceFirst("yt", "sc"), resultHandler, exceptionally); //searches in soundcloud, since apparently yt pretty frequently returns no result + else resultHandler.accept(null, new ArrayList<>()); + return; + } + + List tracks = playlist.isSearchResult() ? playlist.getTracks().subList(0, 1) : playlist.getTracks(); + + String plId = ""; + if (!playlist.isSearchResult()) + if(url.contains("://soundcloud.com") || url.contains("://www.youtube.com")) + plId = url.contains("://soundcloud.com") ? "?in=" + url.split("soundcloud.com/")[1] : "&list=" + url.split("list=")[1].split("&")[0]; + + for (AudioTrack track : tracks) //TODO tell users that we skipped some tracks? + if(track != null) track.setUserData(new TrackData(track.getInfo().uri + plId, user.getUser(), track.getDuration())); + + if (playlist.getSelectedTrack() != null) + tracks.add(0, tracks.remove(tracks.indexOf(playlist.getSelectedTrack()))); //shift selected track to first track + resultHandler.accept(playlist.getName(), tracks.stream().filter(t -> t != null).collect(Collectors.toList())); //only get first result if search + } catch(Exception e) { + exceptionally.accept(e); //so i dont lose my sanity over silenced errors + } + } + + @Override + public void noMatches() { + if(url.startsWith("ytsearch:")) load(user, url.replaceFirst("yt", "sc"), resultHandler, exceptionally); //searches in soundcloud, since apparently yt pretty frequently returns no result + else resultHandler.accept(null, new ArrayList<>()); + } + + @Override + public void loadFailed(FriendlyException exception) { + if(exception.getMessage().contains("Unknown file format.") && url.contains("open.spotify.com")) { + resultHandler.accept(null, new ArrayList<>()); //TODO TEMPORARY FIX + } else { + exceptionally.accept(exception.getCause() != null ? exception.getCause() : exception); + } + } + }); + } + + public int queueTracks(List tracks, Member user) throws UnsupportedOperationException { + Guild guild = user.getGuild(); + GuildMusicManager musicManager = getGuildMusicManager(guild); + int startIndex = musicManager.scheduler.getQueueSize() + (musicManager.player.getPlayingTrack() != null ? 1 : 0); + if (user.getVoiceState().inVoiceChannel()) { + if (!guild.getAudioManager().isConnected() && !guild.getAudioManager().isAttemptingToConnect()) { + VoiceChannel voice = user.getVoiceState().getChannel(); + if (PermissionUtil.checkPermission(voice, guild.getSelfMember(), Permission.VOICE_CONNECT, Permission.VOICE_SPEAK)) { + guild.getAudioManager().openAudioConnection(voice); //already checked permissions so no need to try catch + } else { + throw new UnsupportedOperationException("The bot cannot play music in that channel."); + } + } + } else { + throw new UnsupportedOperationException("You are currently not in a voice channel."); + } + + try { + if (!guild.getAudioManager().isConnected()) { + Awaitility.await().atMost(3, TimeUnit.SECONDS).until(() -> guild.getAudioManager().isConnected()); + } + } catch (ConditionTimeoutException e) { + throw new UnsupportedOperationException("Error while connecting to voice channel: The connection timed out."); + } + + if (guild.getAudioManager().getConnectedChannel().equals(user.getVoiceState().getChannel())) { + for (AudioTrack track : tracks) + musicManager.scheduler.queue(track); //nulls should already be handled; if it aint its my fault lmao + } else { + throw new UnsupportedOperationException("You are currently not in the same channel as the bot."); + } + + return startIndex; //if it successfully returned it means that nothing failed + } + + public String skipTrack(Guild guild) { + GuildMusicManager musicManager = getGuildMusicManager(guild); + musicManager.scheduler.nextTrack(); + musicManager.player.setPaused(false); //implicit resume + try { + return musicManager.player.getPlayingTrack().getInfo().title; + } catch (NullPointerException e) { + musicManager.scheduler.loop = null; + return null; + } + } + + public String setTrackPosition(Guild guild, long hour, long min, long sec) throws IllegalArgumentException { + GuildMusicManager mm = getGuildMusicManager(guild); + Long millis = TimeUnit.HOURS.toMillis(hour) + TimeUnit.MINUTES.toMillis(min) + TimeUnit.SECONDS.toMillis(sec); + AudioTrack track = mm.player.getPlayingTrack(); + if (track.getDuration() > millis && !track.getInfo().isStream) { + track.setPosition(millis); + return MiscUtils.convertMillis(track.getPosition()); + } else if (track.getInfo().isStream) { + throw new IllegalArgumentException("You cannot set the track time in a stream!"); + } else { + throw new IllegalArgumentException("You cannot set the track time over the track duration."); + } + } + + //not zero-based + public MessageBuilder getTrackInfo(Guild guild, int index) { //TODO add views, etc by storing them when getting with lavaplayer? + GuildMusicManager mm = getGuildMusicManager(guild); + if(index - 1 > mm.scheduler.getQueueSize() || index < 1) return null; + AudioTrack track = index == 1 ? mm.player.getPlayingTrack() : mm.scheduler.findTracks(index - 1, 1).get(0); + TrackData data = track.getUserData(TrackData.class); + MessageBuilder smsg = new MessageBuilder(); + String fpos = MiscUtils.convertMillis(track.getPosition()); + String fdur = data.getFormattedDuration(); + smsg.append((index == 1 ? "Current" : MiscUtils.ordinal(index)) + " track: `" + track.getInfo().title + "` (" + fpos + "/" + (track.getDuration() == Long.MAX_VALUE ? "???" : fdur) + ")\n"); + smsg.append("Author: " + track.getInfo().author + "\n"); + smsg.append("Requested by: `" + data.getUserWithDiscriminator() + "`\n"); + String timeTag = ""; + if(data.getUrl().startsWith("https://www.youtube.com")) timeTag = "&="; + else if(data.getUrl().startsWith("https://soundcloud.com")) timeTag = "#="; + smsg.append("URL: " + data.getUrl() + (timeTag.isEmpty() ? "" : timeTag + TimeUnit.MILLISECONDS.toSeconds(track.getPosition()))); + return smsg; + } + + //TODO migrate to embed? + public MessageBuilder queueCheck(Guild guild, int page) throws IllegalArgumentException { + GuildMusicManager mm = getGuildMusicManager(guild); + AudioTrack playing = mm.player.getPlayingTrack(); + if(playing == null) return null; + + MessageBuilder smsg = new MessageBuilder(); + + List tracks = mm.scheduler.findTracks(1, Integer.MAX_VALUE).stream().filter(a -> a != null).collect(Collectors.toList()); //get all tracks in queue + tracks.add(0, playing); + + int maxPage = (int) Math.ceil(tracks.size() / 10f); + if(page > maxPage) throw new IllegalArgumentException("There is no such page."); + smsg.append("The current queue (page " + page + "/" + maxPage + "): \n"); + if (mm.scheduler.loop != null) { + smsg.append("There is a total of `" + tracks.size() + "` tracks " + (mm.scheduler.loop.equals("loop") ? "looping" : "in autoplay") + ".\n\n"); + } else { + long millis = 0; + for(AudioTrack track : tracks) + millis += track.getDuration(); + + smsg.append("There is a total of `" + tracks.size() + "` tracks queued" + ((millis == Long.MAX_VALUE || millis < 0) ? ".\n\n" : ", with a total duration of `" + MiscUtils.convertMillis(millis - playing.getPosition()) + "`.\n\n")); + } + + int times = (page - 1) * 10; + for (AudioTrack track : tracks.subList((page - 1) * 10, Math.min(tracks.size(), page * 10))) { + times++; + TrackData data = track.getUserData(TrackData.class); + smsg.append("[" + times + "]: `" + track.getInfo().title + "` (" + (track.getDuration() == Long.MAX_VALUE ? "N/A" : data.getFormattedDuration()) + ") requested by `" + data.getUserWithDiscriminator() + "`\n"); + } + + if(maxPage > page) smsg.append("\nDo `!desp music queue " + (page + 1) + "` to see the next page."); + return smsg; + } + + public boolean togglePause(Guild guild, boolean pause) throws IllegalStateException { + GuildMusicManager mm = getGuildMusicManager(guild); + if (mm.player.isPaused() == !pause) { + mm.player.setPaused(pause); + return mm.player.isPaused(); + } else { + throw new IllegalStateException("The player is already " + (mm.player.isPaused() ? "paused!" : "unpaused!")); + } + } + + public boolean toggleLoopQueue(Guild guild, String type) { + type = type.toLowerCase(); + GuildMusicManager mm = getGuildMusicManager(guild); + if (mm.scheduler.loop == null || !mm.scheduler.loop.equals(type)) { + mm.scheduler.loop = type; + if (type != null && type.equals("autoplay") && mm.scheduler.getQueueSize() < 1) { + mm.scheduler.queueAutoplay(mm.player.getPlayingTrack()); + } + return true; + } else { //remove autoplay queued track when disabling autoplay? + if (mm.scheduler.loop.equals("autoplay") && mm.scheduler.getQueueSize() == 1 && mm.scheduler.findTracks(1, 1).get(0).getUserData(TrackData.class).uDis.equals("Autoplay")) { //including autoplay, theres only 2 tracks; only remove tracks that is autoplayed + mm.scheduler.removeTrack(1); + } + mm.scheduler.loop = null; + return false; + } + } + + public void stopAndClearQueue(Guild guild) { + GuildMusicManager mm = getGuildMusicManager(guild); + mm.pending = null; + mm.pendingCleanup = null; + mm.clearQueueCleanup = null; + mm.scheduler.loop = null; + mm.scheduler.clearSchedulerQueue(); + mm.clearAllVotes(); + mm.player.stopTrack(); + mm.player.setPaused(false); + guild.getAudioManager().closeAudioConnection(); + + } + + public void shutdown() { + musicManagers.forEach((s, mm) -> { + //System.out.println(DiscordBot.mainJDA.getGuildById(s).getName()); + mm.player.destroy(); + mm.scheduler.clearSchedulerQueue(); + AudioManager man = DiscordBot.mainJDA.getGuildById(s).getAudioManager(); + if(man.isConnected()) { + man.closeAudioConnection(); + DiscordBot.lastMusicCmd.get(s).sendMessage("The music bot is going into maintenance and it will now disconnect. Sorry for the inconvenience.").queue(); + } + + }); + musicManagers = null; //so further operations wont be possible even if i forgot to set this instance to null + playerManager.shutdown(); + ex.shutdown(); + } + +} diff --git a/src/me/despawningbone/discordbot/command/music/GuildMusicManager.java b/src/me/despawningbone/discordbot/command/music/GuildMusicManager.java new file mode 100644 index 0000000..cf68db6 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/music/GuildMusicManager.java @@ -0,0 +1,115 @@ +package me.despawningbone.discordbot.command.music; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.ScheduledFuture; + +import com.sedmelluq.discord.lavaplayer.filter.equalizer.EqualizerFactory; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; + +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.User; + +/** + * Holder for both the player and a track scheduler for one guild. + */ +public class GuildMusicManager { + /** + * Audio player for the guild. + */ + public final AudioPlayer player; + /** + * Track scheduler for the player. + */ + public final TrackScheduler scheduler; + + private HashMap> votes = new HashMap<>(); + public ScheduledFuture clearQueueCleanup = null; + public ScheduledFuture pendingCleanup = null; + public List pending = null; + private EqualizerFactory equalizer = new EqualizerFactory(); + + public int vote(User user, String type, int req) throws UnsupportedOperationException{ + List ids = votes.get(type); + if(ids == null) ids = new ArrayList<>(); + if(ids.contains(user.getId())) throw new UnsupportedOperationException("You have already voted!"); + ids.add(user.getId()); + if(ids.size() < req) { + votes.put(type, ids); //actually shouldnt need to put + return ids.size(); + } else { + votes.remove(type); + + switch(type) { //type specific cleanup stuff + case "tracks": + if(pendingCleanup != null) { + pending = null; + pendingCleanup.cancel(true); + pendingCleanup = null; + } + break; + } + + return -1; + } + + } + + public void clearVotes(String type) { + votes.remove(type); + } + + public void clearAllVotes() { + votes.clear(); + } + + public void setGain(int fromBand, Float... values) throws IllegalArgumentException { + for(int i = fromBand; i < values.length; i++) { + if(values[i] > 0.25) throw new IllegalArgumentException("A band's value is out of range."); + equalizer.setGain(i, values[i]); + } + } + + public Float[] getCurrentGain() { + Float[] vals = new Float[15]; + for(int i = 0; i < 15; i++) { + vals[i] = equalizer.getGain(i); + } + return vals; + } + + public Float[] setPresetGain(String name) throws IllegalArgumentException { + Float[] preset; + switch(name) { + case "bassboost": preset = new Float[]{ 0.08f, 0.06f, 0.05f, 0f, -0.03f, -0.05f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f, -0.07f }; break; + case "default": preset = new Float[]{ 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f }; break; + case "rock": preset = new Float[]{ 0.07f, 0.05f, 0.05f, -0.01f, -0.02f, -0.02f, -0.04f, -0.04f, 0.01f, 0.01f, 0.03f, 0.05f, 0.07f, 0.08f, 0.08f }; break; + default: throw new IllegalArgumentException("Unknown preset."); + } + for(int i = 0; i < preset.length; i++) { + equalizer.setGain(i, preset[i]); + } + return preset; + } + + /** + * Creates a player and a track scheduler. + * @param manager Audio player manager to use for creating the player. + */ + public GuildMusicManager(Guild guild, AudioTrackHandler handler, AudioPlayerManager manager) { + player = manager.createPlayer(); + scheduler = new TrackScheduler(guild, handler, player); + player.addListener(scheduler); + player.setFilterFactory(equalizer); + } + + /** + * @return Wrapper around AudioPlayer to use it as an AudioSendHandler. + */ + public AudioPlayerSendHandler getSendHandler() { + return new AudioPlayerSendHandler(player); + } +} \ No newline at end of file diff --git a/src/me/despawningbone/discordbot/command/music/Music.java b/src/me/despawningbone/discordbot/command/music/Music.java new file mode 100644 index 0000000..6492a88 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/music/Music.java @@ -0,0 +1,678 @@ +package me.despawningbone.discordbot.command.music; + +import java.awt.Color; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.apache.commons.lang3.StringEscapeUtils; +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.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.safety.Whitelist; +import org.jsoup.select.Elements; + +import com.google.common.base.Splitter; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; + +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.TrackData; +import net.dv8tion.jda.api.EmbedBuilder; +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.MessageEmbed; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.VoiceChannel; +import net.dv8tion.jda.api.managers.AudioManager; + +@SuppressWarnings("deprecation") +public class Music extends Command { + + private AudioTrackHandler handler; + + public Music() { + this.alias = Arrays.asList("m"); + this.desc = "The music sub-bot!"; + this.usage = ""; + + this.handler = new AudioTrackHandler(); + + //DONE merge p, pl and ps? + registerSubCommand("play", Arrays.asList("p"), (c, u, m, a) -> { + List params = new ArrayList<>(Arrays.asList(a)); + String type = params.removeAll(Collections.singleton("-s")) ? "soundcloud" : "youtube " + (params.removeAll(Collections.singleton("-l")) ? "playlist" : "video"); + if(a.length < 1) return new CommandResult(CommandResultType.INVALIDARGS, "Please enter something to search or an URL to play."); + try { + return handler.searchAndPlay(String.join(" ", params), type, c.getGuild().getMember(u), c).get(); + } catch (NoSuchElementException e) { + return new CommandResult(CommandResultType.NORESULT); + } catch (InterruptedException | ExecutionException e) { + return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); + } + }, "[-s/-l] ", Arrays.asList("despacito", "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "-l tpz sega", "-s asymmetry reol"), "Queue some music to play!", Arrays.asList( + " * this also supports all opus streams and common audio format as long as you provide the url.", + " * you can specify `-s` when searching to search soundcloud instead of youtube,", + " * or `-l` to search for youtube playlists instead.")); + + //TODO merge? i dont think it can be though + registerSubCommand("autoplay", Arrays.asList("ap"), (c, u, m, a) -> { + VoiceChannel vc = c.getGuild().getAudioManager().getConnectedChannel(); + if (vc.getMembers().size() <= 2 + || DiscordBot.ModID.contains(u.getId())) { + c.sendMessage("Autoplay mode toggled " + + (handler.toggleLoopQueue(c.getGuild(), "autoplay") ? "on.\nIt will end once someone joins the music channel." : "off.")) + .queue(); + return new CommandResult(CommandResultType.SUCCESS); + } else { + return new CommandResult(CommandResultType.INVALIDARGS, "You are currently not alone in the music channel, therefore this feature is disabled."); + } + }, "", null, "Auto-queues related track of the last track when you are alone!", + Arrays.asList(" Note: it will autoplay indefinitely until you toggle it again or someone joins.")); + + registerSubCommand("loop", Arrays.asList("l", "loopqueue"), (c, u, m, a) -> { + VoiceChannel vc = c.getGuild().getAudioManager().getConnectedChannel(); + if (vc.getMembers().size() <= 2 + || DiscordBot.ModID.contains(u.getId())) { + c.sendMessage("Looping mode toggled " + + (handler.toggleLoopQueue(c.getGuild(), "loop") ? "on.\nIt will end once someone joins the music channel." : "off.")) + .queue(); + return new CommandResult(CommandResultType.SUCCESS); + } else { + return new CommandResult(CommandResultType.INVALIDARGS, "You are currently not alone in the music channel, therefore this feature is disabled."); + } + }, "", null, "Loop the queue indefinitely if you are alone!", + Arrays.asList(" Note: skipping tracks will remove it from the loop too.")); + + + registerSubCommand("approve", Arrays.asList("a"), (c, u, m, a) -> { + GuildMusicManager mm = handler.getGuildMusicManager(c.getGuild()); + if (mm.pending == null) { + return new CommandResult(CommandResultType.FAILURE, "There is currently no pending playlists/livestreams."); + } + Member requester = c.getGuild().getMemberByTag(mm.pending.get(0).getUserData(TrackData.class).getUserWithDiscriminator()); + VoiceChannel vc = c.getGuild().getAudioManager().getConnectedChannel(); + if(vc == null) vc = requester.getVoiceState().getChannel(); //fallback to requester, usually due to bot not joined yet (first track needs approval) + if(vc == null) vc = c.getGuild().getMember(u).getVoiceState().getChannel(); //fallback to approver in case requester left voice channel + try { + int req = (int) Math.ceil(vc.getMembers().stream().filter(mem -> !mem.getUser().isBot()).count() / 2.0); + List tracks = mm.pending; //have to place before vote or else it gets wiped + int votes = mm.vote(u, "tracks", req); + if (votes == -1) { //automatically cleaned up in guildmusicmanager already + c.sendMessage("Pending playlist/livestream approved. Queuing...").queue(); + handler.queueTracks(tracks, requester); + } else { + c.sendMessage("You voted for the pending playlist! (" + votes + "/"+ req + ")").queue(); + } + return new CommandResult(CommandResultType.SUCCESS); + } catch(UnsupportedOperationException e) { + return new CommandResult(CommandResultType.FAILURE, e.getMessage()); + } + }, "", null, "Approve the pending playlist or livestream.", null); + + + registerSubCommand("skip", Arrays.asList("s"), (c, u, m, a) -> { + TrackData track = handler.getGuildMusicManager(c.getGuild()).player.getPlayingTrack().getUserData(TrackData.class); + if(!u.getAsTag().equals(track.getUserWithDiscriminator())) { + VoiceChannel vc = c.getGuild().getAudioManager().getConnectedChannel(); + int req = (int) Math.ceil(vc.getMembers().stream().filter(mem -> !mem.getUser().isBot()).count() / 2.0); + try { + int votes = track.voteSkip(u, req); + if (votes != -1) { + c.sendMessage("You voted to skip the current track! (" + votes + "/" + req + ")").queue(); + return new CommandResult(CommandResultType.SUCCESS); + } + } catch (UnsupportedOperationException e) { + return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); + } + } + //else skips track if no need votes or vote passed + String next = handler.skipTrack(c.getGuild()); + if(next != null) { + c.sendMessage("Skipped to the next track: `" + next + "`.").queue(); + } else { + c.sendMessage("No tracks found after this one. Stopping the player...").queue(); + } + return new CommandResult(CommandResultType.SUCCESS); + }, "", null, "Vote to skip the currently playing track.", + Arrays.asList(" * this is equivalent to `forceskip` if you are the one who requested the track.")); + //TODO merge? i dont think i can either though + registerSubCommand("forceskip", Arrays.asList("fs"), (c, u, m, a) -> { + String next = handler.skipTrack(c.getGuild()); + if(next != null) { + c.sendMessage("Skipped to the next track: `" + next + "`.").queue(); + } else { + c.sendMessage("No tracks found after this one. Stopping the player...").queue(); + } + return new CommandResult(CommandResultType.SUCCESS); + }, "", null, "Skip the currently playing track without voting", null, + EnumSet.of(Permission.VOICE_MOVE_OTHERS), -BotUserLevel.BOT_MOD.ordinal()); + + + registerSubCommand("info", Arrays.asList("i", "trackinfo", "track", "nowplaying", "np", "current", "c"), (c, u, m, a) -> { + try { + c.sendMessage(handler.getTrackInfo(c.getGuild(), a.length > 0 ? Integer.parseInt(a[0]) : 1).build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } catch(NumberFormatException | NullPointerException e) { + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid track index specified."); + } + }, "[index]", Arrays.asList("", "2"), "See the info about a track in the queue.", + Arrays.asList(" * If you did not specify an index, it will return info about the current track playing.")); + + + registerSubCommand("queue", Arrays.asList("q", "page", "list"), (c, u, m, a) -> { + try { + c.sendMessage(handler.queueCheck(c.getGuild(), a.length > 0 ? Integer.parseInt(a[0]) : 1).build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); + } catch(NumberFormatException e) { + return new CommandResult(CommandResultType.INVALIDARGS, "Invalid page index specified."); + } catch(IllegalArgumentException e) { + return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); + } + }, "[page]", Arrays.asList("", "2"), "See the queue of tracks to play.", + Arrays.asList(" * If you did not specify an page number, it will return the first page.")); + + //TODO allow pausing when alone? unpause when people join; or vote system? + //TODO yet another merge lmao but this time idk coz rythm has them as seperate commands + registerSubCommand("pause", Arrays.asList("pa"), (c, u, m, a) -> { + try { + handler.togglePause(c.getGuild(), true); + c.sendMessage("Successfully paused the player.").queue(); + return new CommandResult(CommandResultType.SUCCESS); + } catch(IllegalStateException e) { + return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); + } + }, "", null, "Pauses the music player.", null, EnumSet.of(Permission.VOICE_MOVE_OTHERS), BotUserLevel.DEFAULT.ordinal()); + + registerSubCommand("resume", Arrays.asList("re", "unpause"), (c, u, m, a) -> { + try { + handler.togglePause(c.getGuild(), false); + c.sendMessage("Successfully resumed the player.").queue(); + return new CommandResult(CommandResultType.SUCCESS); + } catch(IllegalStateException e) { + return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); + } + }, "", null, "Resumes the music player.", null, EnumSet.of(Permission.VOICE_MOVE_OTHERS), BotUserLevel.DEFAULT.ordinal()); + + + registerSubCommand("setposition", Arrays.asList("set", "setpos", "seek"), (c, u, m, a) -> { + try { + String[] s = a[0].split(":"); + int index = a.length > 2 ? 1 : 0; + String t = handler.setTrackPosition(c.getGuild(), index == 1 ? Long.parseLong(s[0]) : 0 , Long.parseLong(s[index]), Long.parseLong(s[index + 1])); + c.sendMessage("Successfully set the timestamp to `" + t + "`.").queue(); + return new CommandResult(CommandResultType.SUCCESS); + } catch (IndexOutOfBoundsException | NumberFormatException e) { + e.printStackTrace(); + return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a valid timestamp with colons."); + } catch (IllegalArgumentException e) { + return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); + } + }, "[hr:]", Arrays.asList("4:52:21", "2:00"), "Set the playing position in the current track!", + Arrays.asList(" * the person who requested the current track can always set the position, regardless of perms."), + EnumSet.of(Permission.VOICE_MOVE_OTHERS), -BotUserLevel.BOT_MOD.ordinal()); + + + //TODO merge with skip? + registerSubCommand("removetrack", Arrays.asList("rt", "r", "skiptrack", "st"), (c, u, m, a) -> { + if(a.length > 0) { + try { + int num = Integer.parseInt(a[0]); + if(num == 1) return new CommandResult(CommandResultType.INVALIDARGS, "You cannot remove the current track from the queue with this command. Use !desp music skip instead."); + if(num < 1) return new CommandResult(CommandResultType.INVALIDARGS, "The index you entered is invalid."); + AudioTrack removed = handler.getGuildMusicManager(c.getGuild()).scheduler.removeTrack(num - 1); + if(removed != null) { + c.sendMessage("Removed track `" + removed.getInfo().title + "` requested by `" + removed.getUserData(TrackData.class).getUserWithDiscriminator() + "`.").queue(); + return new CommandResult(CommandResultType.SUCCESS); + } else { + return new CommandResult(CommandResultType.INVALIDARGS, "The track does not exist."); + } + } catch(NumberFormatException e) { + return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a valid index number."); + } + } else { + return new CommandResult(CommandResultType.INVALIDARGS, "Please enter an index."); + } + + }, "", null, "Remove a track at the specified index of the queue.", + Arrays.asList(" * the person who requested the track which is to be removed can always execute the command, regardless of perms."), + EnumSet.of(Permission.VOICE_MOVE_OTHERS), -BotUserLevel.BOT_MOD.ordinal()); + + + registerSubCommand("move", Arrays.asList("m", "movetrack"), (c, u, m, a) -> { + try { + if(a[0].equals("1") || a[1].equals("1")) return new CommandResult(CommandResultType.INVALIDARGS, "You cannot move the currently playing track."); + AudioTrack track = handler.getGuildMusicManager(c.getGuild()).scheduler.moveTrack(Integer.parseInt(a[0]) - 2, Integer.parseInt(a[1]) - 2); + c.sendMessage("Successfully moved `" + track.getInfo().title + "` to position `" + a[1] + "`.").queue(); + return new CommandResult(CommandResultType.SUCCESS); + } catch(IndexOutOfBoundsException | NumberFormatException | NullPointerException e) { + return new CommandResult(CommandResultType.INVALIDARGS, "Please valid indices."); + } + }, " ", Arrays.asList("2 3"), "Move a track to the specified index.", Arrays.asList(" * you can also run this command regardless of perms if you are alone with the bot.") + , EnumSet.of(Permission.VOICE_MOVE_OTHERS), -BotUserLevel.BOT_MOD.ordinal()); + + + //TODO make vote system for this + registerSubCommand("shuffle", null, (c, u, m, a) -> { + VoiceChannel vc = c.getGuild().getAudioManager().getConnectedChannel(); + if (vc.getMembers().size() <= 2 + || DiscordBot.ModID.contains(u.getId())) { + handler.getGuildMusicManager(c.getGuild()).scheduler.shuffleQueue(); + c.sendMessage("Successfully shuffled the queue.").queue(); + return new CommandResult(CommandResultType.SUCCESS); + } else { + return new CommandResult(CommandResultType.INVALIDARGS, "You are currently not alone in the music channel, therefore this feature is disabled."); + } + }, "", null, "Shuffle the tracks in the queue when you are alone!", null); + + + //TODO make vote system for this + registerSubCommand("clear", Arrays.asList("disconnect", "dc", "stop", "clearqueue"), (c, u, m, a) -> { + handler.stopAndClearQueue(c.getGuild()); + c.sendMessage("The queue has been cleared.").queue(); + return new CommandResult(CommandResultType.SUCCESS); + }, "", null, "Clear the queue and stop the player.", Arrays.asList(" * you can also run this command regardless of perms if you are alone with the bot.") + , EnumSet.of(Permission.VOICE_MUTE_OTHERS, Permission.VOICE_MOVE_OTHERS), -BotUserLevel.BOT_MOD.ordinal()); + + registerSubCommand("lyrics", Arrays.asList("ly"), (c, u, m, a) -> { + c.sendTyping().queue(); + try { + String search = a.length > 0 ? String.join(" ", a) : handler.getGuildMusicManager(c.getGuild()).player.getPlayingTrack().getInfo().title + .split("ft.")[0].replaceAll("\\(.*?\\)", "") + .replaceAll("\\[.*?\\]", "") + .replaceAll("\\ใ€.*?\\ใ€‘", "") + .replaceAll("-", "").trim(); + getLyrics(search).forEach(em -> c.sendMessage(em).queue()); + return new CommandResult(CommandResultType.SUCCESS); + } catch(IllegalArgumentException | UnsupportedOperationException e) { + String erMsg = e.getMessage(); + //String rMsg = null; + if(e instanceof UnsupportedOperationException) { + String[] erSplit = erMsg.split(" \\| "); + erMsg = erSplit[0]; + //rMsg = "Lyrics too long; " + erSplit[1]; + } + return new CommandResult(CommandResultType.FAILURE, erMsg); + } catch(NullPointerException e) { + return new CommandResult(CommandResultType.INVALIDARGS, "There is nothing playing currently! Please specify a song title to search the lyrics up."); + } + }, "[search words]", Arrays.asList(""), "Search some lyrics up!", Arrays.asList(" * if you did not specify any search words, the bot will try to fetch the lyrics of the current track playing, if any.")); + + + //TODO equalizer persistence? + //make this premium? + registerSubCommand("equalizer", Arrays.asList("eq"), (c, u, m, a) -> { + GuildMusicManager mm = handler.getGuildMusicManager(c.getGuild()); + Float[] vals; + if(a.length == 0) { + vals = mm.getCurrentGain(); + } else { + try { + try { + if(a.length != 15) throw new NumberFormatException(); + vals = new Float[15]; + for(int i = 0; i < a.length; i++) { + vals[i] = Float.parseFloat(a[i]); + } + mm.setGain(0, vals); + } catch(NumberFormatException e) { + vals = mm.setPresetGain(a[0]); + } + } catch(IllegalArgumentException e2) { + return new CommandResult(CommandResultType.INVALIDARGS, e2.getMessage()); + } + } + //formatting + DecimalFormat df = new DecimalFormat("0.00"); + df.setPositivePrefix("+"); + EmbedBuilder eb = new EmbedBuilder(); + eb.setTitle("Current equalizer graph"); + eb.appendDescription("```\n"); + for(double line = 0.25; line >= -0.25; line -= 0.05) { + eb.appendDescription(df.format(line) + " "); + for(int band = 0; band < 15; band++) { + if(Math.abs(0.05 * Math.round(vals[band] / 0.05) - line) < 1E-7) eb.appendDescription("๐Ÿ”˜"); + else eb.appendDescription("โ€†โ˜ "); + } + eb.appendDescription("\n"); + } + eb.appendDescription("```"); + eb.addField("Actual values", Arrays.asList(vals).stream().map(f -> df.format(f)).collect(Collectors.joining(" ")), false); + c.sendMessage(eb.build()).queue(); + return new CommandResult(CommandResultType.SUCCESS); + }, "[bandvalues/presetname]", Arrays.asList("", "0.09 0.07 0.07 0.01 0 0 -0.02 -0.02 0.03 0.03 0.05 0.07 0.09 0.1 0.1", "bassboost"), "Sets the equalizer for the music player in this guild!", + Arrays.asList("Accepts 15 bands with values ranging from -0.25 to 0.25, where -0.25 is muted and 0.25 is double volume.", "* presets include: `bassboost`, `default`, `rock`.", "Input nothing to return the current settings.", "", + "Note: you might experience some audio cracking for band values >0.1, since amplifying volumes remotely does not work well.", "It is recommended to use values from -0.25 to 0.1, and turning discord volume up instead.") + , EnumSet.of(Permission.VOICE_MUTE_OTHERS, Permission.VOICE_MOVE_OTHERS), -BotUserLevel.BOT_MOD.ordinal()); + + + registerSubCommand("shutdown", null, (c, u, m, a) -> { + handler.shutdown(); + handler = null; + c.sendMessage("Successfully destroyed the music player instance.").queue(); + return new CommandResult(CommandResultType.SUCCESS); + }, "", null, "Shuts down the music player sub-bot.", null, EnumSet.noneOf(Permission.class), BotUserLevel.BOT_OWNER.ordinal()); + + registerSubCommand("startup", Arrays.asList("start"), (c, u, m, a) -> { + if(handler != null) return new CommandResult(CommandResultType.FAILURE, "An instance is already running. Please shut it down first."); + else handler = new AudioTrackHandler(); + c.sendMessage("Successfully created the music player instance.").queue(); + return new CommandResult(CommandResultType.SUCCESS); + }, "", null, "Boots up the music player sub-bot.", null, EnumSet.noneOf(Permission.class), BotUserLevel.BOT_OWNER.ordinal()); + + registerSubCommand("restart", Arrays.asList("reboot"), (c, u, m, a) -> { + handler.shutdown(); + handler = new AudioTrackHandler(); + c.sendMessage("Successfully rebooted the music player instance.").queue(); + return new CommandResult(CommandResultType.SUCCESS); + }, "", null, "Reboots the music player sub-bot.", null, EnumSet.noneOf(Permission.class), BotUserLevel.BOT_OWNER.ordinal()); + } + + public AudioTrackHandler getAudioTrackHandler() { + return handler; + } + + //TODO make a skipuntil command that requires voting? //prob not + //TODO make clear accessible on vote? + + @Override + public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { + + DiscordBot.lastMusicCmd.put(channel.getGuild().getId(), channel); + Command sub = args.length > 0 ? getSubCommand(args[0].toLowerCase()) : null; + + if(sub != null) { //only run if not null, else hand to normal sub command handler + //if handler is null it means the music player is shut down + if(!sub.getName().equals("startup")) { + if(handler == null) return new CommandResult(CommandResultType.FAILURE, "The music bot is in maintenance right now!"); + + //pre check to block out all music commands if the player is not running + List exception = Arrays.asList("play", "approve", "shutdown", "lyrics", "equalizer"); + GuildMusicManager mm = handler.getGuildMusicManager(channel.getGuild()); + if(mm.player.getPlayingTrack() == null && + !exception.contains(sub.getName())) + return new CommandResult(CommandResultType.INVALIDARGS, "There are no tracks playing currently."); + else { + //else block out all users that are not in the same voice channel + exception = Arrays.asList("lyrics", "queue", "info"); + AudioManager man = channel.getGuild().getAudioManager(); + if(man.isConnected() && !man.getConnectedChannel().getMembers().contains(channel.getGuild().getMember(author)) + && !(exception.contains(sub.getName()) || DiscordBot.ModID.contains(author.getId()))) { + return new CommandResult(CommandResultType.INVALIDARGS, "You are not in the same channel as the bot."); + } + } + + //perms overriding for queuer + if(Arrays.asList("forceskip", "skip", "setposition").contains(sub.getName())) { + if(author.getAsTag().equals(mm.player.getPlayingTrack().getUserData(TrackData.class).getUserWithDiscriminator())) + return sub.execute(channel, author, msg, Arrays.copyOfRange(args, 1, args.length)); + } else if(sub.getName().equals("removetrack")) { + try { + if(author.getAsTag().equals(mm.scheduler.findTracks(Integer.parseInt(args[1]) - 1, 1).get(0).getUserData(TrackData.class).getUserWithDiscriminator())) + return sub.execute(channel, author, msg, Arrays.copyOfRange(args, 1, args.length)); + } catch(Exception ignored) { + ignored.printStackTrace(); + } + } + + //perms overriding for when alone + if(Arrays.asList("move", "clear").contains(sub.getName())) { //should always be connected to vc + if(channel.getGuild().getAudioManager().getConnectedChannel().getMembers().size() == 2) { //alone; no need to check if the other member is requester, since its checked before + return sub.execute(channel, author, msg, Arrays.copyOfRange(args, 1, args.length)); + } + } + } + } + + CommandResult res = super.execute(channel, author, msg, args); //pass to subcommand handler for perms checking + if(res.getResultType() == CommandResultType.NOPERMS && Arrays.asList("forceskip", "skip", "setposition", "removetrack").contains(sub.getName())) { //add back info to command result + res = new CommandResult(CommandResultType.INVALIDARGS, res.getMessage().getContentRaw().split(" to execute")[0] + ", or have requested the track to execute this command."); + } + return res; + + } + + private final String geniusAuth = DiscordBot.tokens.getProperty("genius"); + + private List whitelist = Arrays.asList("https://genius.com/Noma-jpn-brain-power-annotated"); + + //private ExecutorService executor = Executors.newCachedThreadPool(); //unfortunately i need to finish the api search to get the url to start the html scrape, which means i cannot use future for multithread here + + public List getLyrics(String search) { //DONE dont splice words between embeds; make whole sentence to be spliced instead + //System.out.println(search); + List em = new ArrayList(); + try { + URLConnection searchCon = new URL("https://api.genius.com/search?access_token=" + geniusAuth + "&q=" + URLEncoder.encode(search, "UTF-8").replaceAll("\\+", "%20")).openConnection(); + //URLConnection searchCon = new URL("https://api.genius.com/search?q=eden%20-%20end%20credits%20%28feat.%20leah%20kelly%29").openConnection(); + searchCon.addRequestProperty("User-Agent", "Mozilla/4.0"); + //searchCon.addRequestProperty("Authorization", "Bearer " + geniusAuth); + InputStream searchStream = searchCon.getInputStream(); + JSONTokener searchResult = new JSONTokener(searchStream); + JSONArray list = new JSONObject(searchResult).getJSONObject("response").getJSONArray("hits"); + JSONObject main = null; + for(int i = 0; i < list.length(); i++) { + JSONObject check = list.getJSONObject(i).getJSONObject("result"); + if(!check.getString("url").endsWith("lyrics") && !whitelist.contains(check.getString("url"))) { + System.out.println(" " + check.getString("url")); + continue; + } + main = check; + break; + } + if(main == null) { + throw new IllegalArgumentException("There were no results unfortunately :cry:"); + } + searchStream.close(); + String url = main.getString("url"); + Element href; + try { + Document document = Jsoup.connect(url).userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:75.0) Gecko/20100101 Firefox/75.0").get(); + href = document.body(); + } catch (IOException e1) { + e1.printStackTrace(); + throw new IllegalArgumentException("Something went wrong."); + } + Element el = href.selectFirst("div[class=\"lyrics\"]"); + String s; + if(el == null) { + el = href.selectFirst("div[class*=\"Lyrics__Root\"]"); //second version of the page + el.select("div[class*=\"Ad__Container\"]").remove(); + el.select("a, span, i, div[class*=\"Lyrics__Container\"]").unwrap(); + s = el.html().replaceAll("\\\\n", "\n"); + } else { + el.select("br").append("\\n"); + el.select("p").prepend("\\n\\n"); + s = el.html().replaceAll("\\\\n", "\n"); + } + String lyrics = Jsoup.clean(s, "", Whitelist.none(), new Document.OutputSettings().prettyPrint(false)); + List lyList = new ArrayList(Splitter.fixedLength(2000).splitToList( + lyrics.trim().replaceAll("&", "&").replaceAll(" ", " ") + .replaceAll("\n ", "\n"))); //retain this formatting? it looks more clean yet more jumbled at the same time + if(lyList.size() > 5) { + throw new UnsupportedOperationException("The lyrics is too long for a normal song xD | URL: " + url); + } + EmbedBuilder eb = new EmbedBuilder(); + String auImg = main.getJSONObject("primary_artist").getString("header_image_url"); + eb.setAuthor(main.getString("full_title"), url, auImg.contains("https://assets.genius.com/images/default_avatar_300.png") ? null : auImg); + String alImg = main.getString("song_art_image_thumbnail_url"); + eb.setThumbnail(alImg.contains("https://assets.genius.com/images/default_cover_image.png") ? null : alImg); + formatLyrics(eb, "**Lyrics**\n\n " + (lyList.size() > 0 ? lyList.get(0).trim() : "N/A"), lyList, 0); + eb.setColor(new Color(255, 255, 100)); + if(lyList.size() > 1) { + em.add(eb.build()); + for(int i = 1; i < lyList.size(); i++) { + EmbedBuilder loopEm = new EmbedBuilder(); + loopEm.setColor(new Color(255, 255, 100)); + formatLyrics(loopEm, lyList.get(i), lyList, i); + if(i == lyList.size() - 1) { + setFinalLyricsEmbed(loopEm, main, href); + } + em.add(loopEm.build()); + } + } else { + setFinalLyricsEmbed(eb, main, href); + em = Arrays.asList(eb.build()); + } + //System.out.println(lyrics); + } catch (JSONException | IOException | ArrayIndexOutOfBoundsException e) { + if(e instanceof JSONException || e instanceof ArrayIndexOutOfBoundsException) { + e.printStackTrace(); + throw new IllegalArgumentException("There were no results unfortunately :cry:"); + } else { + e.printStackTrace(); + throw new IllegalArgumentException("Something went wrong."); + } + } + return em; + } + + private void formatLyrics(EmbedBuilder eb, String segment, List lyList, int i) { + if(segment.length() < 2000) { + eb.setDescription(segment.trim()); + return; + } + String includeSeg = segment.length() > 2000 ? segment.substring(0, 2000) : segment; + int index = includeSeg.lastIndexOf("\n"); + //System.out.println(segment); + //System.out.println(segment.substring(0, index)); + eb.setDescription(segment.substring(0, index)); + String move = segment.substring(includeSeg.lastIndexOf("\n")); + try { + if(!move.isEmpty()) lyList.set(i + 1, move + lyList.get(i + 1)); + } catch (IndexOutOfBoundsException e) { + lyList.add(move); + } + } + + private void setFinalLyricsEmbed(EmbedBuilder eb, JSONObject main, Element href) { //change the format for the supplementary info in artists and albums from italic to sth else? + //System.out.println("test"); + + try { + String name = main.getJSONObject("primary_artist").getString("name"); + //name = "[" + name + "](https://genius.com/artists/" + URLEncoder.encode(name, "UTF-8").replaceAll("\\+", "%20") + ")"; + /*StringBuilder sb = new StringBuilder(); + + data.getJSONArray("artists").forEach(obj -> { + String n = obj.toString(); + if(!n.equals(name)) { + sb.append(obj.toString() + " "); + } + }); + String others = sb.toString();*/ + try { + String others = href.select("script:containsData(_sf_async_config)").html().split("_sf_async_config.authors = '")[1].split("';")[0].replace(",", ", "); + eb.addField("Artists", new StringBuffer(others).insert(others.indexOf(name) + name.length() + 2, "\n*").toString() + "*", true); + } catch (IndexOutOfBoundsException e) { + eb.addField("Artist", name, true); + } + //eb.addField("Artist" + (others.isEmpty() ? "" : "s"), name + (others.isEmpty() ? "" : ",\n*" + others.trim().replaceAll(" ", ", ") + "*"), true); //add link to it? + } catch (JSONException e) { + e.printStackTrace(); + eb.addField("Artist", "Unknown", true); + }/* catch (UnsupportedEncodingException e1) { + e1.printStackTrace(); + }*/ + + if(!href.select("div[class=\"lyrics\"]").isEmpty()) { + Elements buffer = href.select("span:contains(Album) ~ span[class=\"metadata_unit-info\"] a"); + if(buffer.size() > 0) { + String album = buffer.get(0).ownText(); + StringBuilder sb = new StringBuilder(); + JSONObject data = new JSONObject(href.select("script[type=\"application/ld+json\"]").get(0).html()); + data.getJSONArray("inAlbum").forEach(obj -> { + String n = ((JSONObject) obj).getString("name"); + if(!n.equals(album)) { + sb.append("*" + n.trim() + "*\n"); + } + }); + String others = sb.toString(); + eb.addField("Album" + (others.isEmpty() ? "" : "s"), album + (others.isEmpty() ? "" : "\n" + others), true); //add link? + } + + buffer = href.select("span:contains(Release Date) ~ span[class*=\"metadata_unit-info\"]"); + if(buffer.size() > 0) eb.addField("Release Date", buffer.get(0).ownText(), true); + + try { + String sbuff = URLDecoder.decode(href.select("img[src*=\"page-genres=\"]").first().absUrl("src").split("page-genres=")[1].split("&page-type")[0].replaceAll("\\+", " ").split("&")[0], "UTF-8"); + if(!sbuff.isEmpty()) { + eb.addField("Tags", sbuff.trim().replaceAll(",", ", "), false); + } + } catch (UnsupportedEncodingException | NullPointerException e) { + e.printStackTrace(); + } + + buffer = href.select("div[class=\"annotation_label\"] ~ div[class=\"rich_text_formatting\"]"); + if(buffer.size() > 0) { + String bgInfo = buffer.get(0).text(); + if(!bgInfo.trim().isEmpty()) { + if(bgInfo.length() > 1024) { + bgInfo = bgInfo.substring(0, 1021) + "..."; + } + eb.addField("Background info", bgInfo, false); + } + } + } else { //second version -- actually has a lot more info, use? + System.out.println("Second ver"); + + String temp = href.select("script:containsData(__PRELOADED_STATE__)").html().split("JSON.parse\\(\'", 2)[1].split("\'\\);\n", 2)[0]; + JSONObject preload = new JSONObject(StringEscapeUtils.unescapeJson(temp)); + JSONObject song = preload.getJSONObject("entities").getJSONObject("songs").getJSONObject(Integer.toString(preload.getJSONObject("songPage").getInt("song"))); + + StringBuilder sb = new StringBuilder(); + try { + String album = IntStream.range(0, song.getJSONArray("trackingData").length()).mapToObj(i -> song.getJSONArray("trackingData").getJSONObject(i)).filter(obj -> obj.getString("key").equals("Primary Album")).findFirst().get().getString("value").trim(); + song.getJSONArray("albums").forEach(obj -> { + String n = ((JSONObject) obj).getString("name"); + if(!n.trim().equals(album)) { + sb.append("*" + n.trim() + "*\n"); + } + }); + String others = sb.toString(); + eb.addField("Album" + (others.isEmpty() ? "" : "s"), album + (others.isEmpty() ? "" : "\n" + others), true); //add link? + } catch (JSONException ignored) { + //no albums; value is null + } + + eb.addField("Release Date", song.getString("releaseDateForDisplay"), true); + + ArrayList tags = new ArrayList<>(); + for(int i = 0; i < song.getJSONArray("tags").length(); i++) { + song.getJSONArray("tags").getJSONObject(i).getString("name"); + } + if(!tags.isEmpty()) eb.addField("Background info", String.join(", ", tags), false); + + String bgInfo = song.getJSONObject("description").getString("markdown"); + if(bgInfo.length() > 1024) { + bgInfo = bgInfo.substring(0, 1021) + "..."; + } + if(!bgInfo.equals("?")) eb.addField("Background info", bgInfo, false); //apparently its ? for empty descs lol + } + + eb.setFooter((main.getJSONObject("stats").has("pageviews") ? NumberFormat.getIntegerInstance().format(main.getJSONObject("stats").getInt("pageviews")) + " views | " : "") + "Powered by Genius", + "https://yt3.ggpht.com/a/AATXAJzPOKLs0x9W_yNpTUPvwg-zeSnJaxqzf2CU0g=s900-c-k-c0xffffffff-no-rj-mo"); + } +} diff --git a/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java b/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java new file mode 100644 index 0000000..8fac900 --- /dev/null +++ b/src/me/despawningbone/discordbot/command/music/SpotifyAudioSourceManager.java @@ -0,0 +1,322 @@ +package me.despawningbone.discordbot.command.music; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; +import org.apache.hc.core5.http.ParseException; +import org.apache.http.NameValuePair; +import org.apache.http.client.utils.URLEncodedUtils; +import org.awaitility.Awaitility; + +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager; +import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioSourceManager; +import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioItem; +import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; +import com.sedmelluq.discord.lavaplayer.track.AudioReference; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; +import com.sedmelluq.discord.lavaplayer.track.BasicAudioPlaylist; +import com.wrapper.spotify.SpotifyApi; +import com.wrapper.spotify.enums.ModelObjectType; +import com.wrapper.spotify.exceptions.SpotifyWebApiException; +import com.wrapper.spotify.exceptions.detailed.NotFoundException; +import com.wrapper.spotify.model_objects.credentials.ClientCredentials; +import com.wrapper.spotify.model_objects.specification.Album; +import com.wrapper.spotify.model_objects.specification.Paging; +import com.wrapper.spotify.model_objects.specification.Playlist; +import com.wrapper.spotify.model_objects.specification.PlaylistTrack; +import com.wrapper.spotify.model_objects.specification.Track; +import com.wrapper.spotify.model_objects.specification.TrackSimplified; +import com.wrapper.spotify.requests.data.albums.GetAlbumsTracksRequest; +import com.wrapper.spotify.requests.data.playlists.GetPlaylistsItemsRequest; + + +/** + * Code mostly modified from https://github.com/lijamez/tonbot-plugin-music/ + * @author lijamez @ github + * + */ + +public class SpotifyAudioSourceManager implements AudioSourceManager { + + private static final String SPOTIFY_DOMAIN = "open.spotify.com"; + private static final int EXPECTED_PATH_COMPONENTS = 2; + + private SpotifyApi spotifyApi; + private YoutubeAudioSourceManager manager; + + public SpotifyAudioSourceManager(SpotifyApi spotifyApi, AudioPlayerManager parent, AudioTrackHandler handler) throws Exception { + this.spotifyApi = spotifyApi; + handler.ex.submit(() -> { + Awaitility.await().until(() -> parent.source(YoutubeAudioSourceManager.class) != null); //needed to ensure its loaded + this.manager = parent.source(YoutubeAudioSourceManager.class); + }); + refreshSpotifyApi(handler); + } + + private void refreshSpotifyApi(AudioTrackHandler handler) throws Exception { + ClientCredentials cred = spotifyApi.clientCredentials().build().execute(); + spotifyApi.setAccessToken(cred.getAccessToken()); + handler.ex.schedule(() -> { + try { + refreshSpotifyApi(handler); + } catch (Exception e) { + e.printStackTrace(); //DONE disable the source manager? + spotifyApi = null; + } + }, cred.getExpiresIn(), TimeUnit.SECONDS); + } + + @Override + public String getSourceName() { + return "Spotify Playlist"; + } + + @Override + public AudioItem loadItem(DefaultAudioPlayerManager manager, AudioReference reference) { + + if(spotifyApi == null) return null; //disabled due to broken api + + try { + URL url = new URL(reference.identifier); + + if (!StringUtils.equals(url.getHost(), SPOTIFY_DOMAIN)) { + return null; + } + + AudioItem audioItem = null; + audioItem = handleAsPlaylist(url, manager); + + if (audioItem == null) { + audioItem = handleAsTrack(url, manager); + } + + return audioItem; + + } catch (MalformedURLException e) { + return null; + } + } + + private AudioTrack handleAsTrack(URL url, DefaultAudioPlayerManager man) { + Path path = Paths.get(url.getPath()); + + if (path.getNameCount() < 2) { + return null; + } + + if (!StringUtils.equals(path.getName(0).toString(), "track")) { + return null; + } + + String trackId = path.getName(1).toString(); + + TrackSimplified track; + try { + Track t = spotifyApi.getTrack(trackId).build().execute(); + track = new TrackSimplified.Builder().setArtists(t.getArtists()).setName(t.getName()).setDurationMs(t.getDurationMs()).build(); + } catch (IOException | SpotifyWebApiException | ParseException e) { + throw new IllegalStateException("Unable to fetch track from Spotify API.", e); + } + + return getAudioTracks(Arrays.asList(track), man).get(0); + } + + private BasicAudioPlaylist handleAsPlaylist(URL url, DefaultAudioPlayerManager man) { + String playlistKey; + try { + playlistKey = extractPlaylistId(url); + } catch (IllegalArgumentException e) { + return null; + } + + String name; + List tracks; + + try { + Playlist playlist = spotifyApi.getPlaylist(playlistKey) + .build().execute(); + name = playlist.getName(); + tracks = getAllPlaylistTracks(playlist).stream().map(pt -> pt.getTrack()) + .filter(pt -> pt.getType() == ModelObjectType.TRACK).map(pt -> { + Track t = (Track) pt; //it doesnt have any methods to translate Track to TrackSimplified, so i had to do this; it only uses 3 params anyways + return new TrackSimplified.Builder().setArtists(t.getArtists()).setName(t.getName()).setDurationMs(t.getDurationMs()).build(); + }).collect(Collectors.toList()); + } catch (IOException | SpotifyWebApiException | ParseException e) { + if(e instanceof NotFoundException) { //try searching as album + try { + Album album = spotifyApi.getAlbum(playlistKey).build().execute(); + name = album.getName(); + tracks = getAllAlbumTracks(album); + } catch (ParseException | SpotifyWebApiException | IOException e1) { + throw new IllegalStateException("Unable to fetch playlist from Spotify API.", e1); + } + + } else { + throw new IllegalStateException("Unable to fetch playlist from Spotify API.", e); + } + } + + + + List audioTracks = getAudioTracks(tracks, man); + + return new BasicAudioPlaylist(name, audioTracks, null, false); + } + + private List getAllPlaylistTracks(Playlist playlist) { + List playlistTracks = new ArrayList<>(); + + Paging currentPage = playlist.getTracks(); + + do { + playlistTracks.addAll(Arrays.asList(currentPage.getItems())); + + if (currentPage.getNext() == null) { + currentPage = null; + } else { + + try { + URI nextPageUri = new URI(currentPage.getNext()); + List queryPairs = URLEncodedUtils.parse(nextPageUri, StandardCharsets.UTF_8); + + GetPlaylistsItemsRequest.Builder b = spotifyApi.getPlaylistsItems(playlist.getId()); + for (NameValuePair queryPair : queryPairs) { + b = b.setBodyParameter(queryPair.getName(), queryPair.getValue()); + } + + currentPage = b.build().execute(); + } catch (IOException | SpotifyWebApiException | ParseException e) { + throw new IllegalStateException("Unable to query Spotify for playlist tracks.", e); + } catch (URISyntaxException e) { + throw new IllegalStateException("Spotify returned an invalid 'next page' URI.", e); + } + } + } while (currentPage != null); + + return playlistTracks; + } + + private List getAllAlbumTracks(Album album) { + List albumTracks = new ArrayList<>(); + + Paging currentPage = album.getTracks(); + + do { + albumTracks.addAll(Arrays.asList(currentPage.getItems())); + + if (currentPage.getNext() == null) { + currentPage = null; + } else { + + try { + URI nextPageUri = new URI(currentPage.getNext()); + List queryPairs = URLEncodedUtils.parse(nextPageUri, StandardCharsets.UTF_8); + + GetAlbumsTracksRequest.Builder b = spotifyApi.getAlbumsTracks(album.getId()); + for (NameValuePair queryPair : queryPairs) { + b = b.setBodyParameter(queryPair.getName(), queryPair.getValue()); + } + + currentPage = b.build().execute(); + } catch (IOException | SpotifyWebApiException | ParseException e) { + throw new IllegalStateException("Unable to query Spotify for album tracks.", e); + } catch (URISyntaxException e) { + throw new IllegalStateException("Spotify returned an invalid 'next page' URI.", e); + } + } + } while (currentPage != null); + + return albumTracks; + } + + private String extractPlaylistId(URL url) { + Path path = Paths.get(url.getPath()); + if (path.getNameCount() < EXPECTED_PATH_COMPONENTS) { + throw new IllegalArgumentException("Not enough path components."); + } + + if (!Arrays.asList("playlist", "album").contains(path.getName(0).toString())) { + throw new IllegalArgumentException("URL doesn't appear to be a playlist."); + } + + String playlistId = path.getName(1).toString(); + if (StringUtils.isBlank(playlistId)) { + throw new IllegalArgumentException("Playlist ID is blank."); + } + + return playlistId; + } + + @Override + public boolean isTrackEncodable(AudioTrack track) { + return false; + } + + @Override + public void encodeTrack(AudioTrack track, DataOutput output) throws IOException { + throw new UnsupportedOperationException("encodeTrack is unsupported."); + } + + @Override + public AudioTrack decodeTrack(AudioTrackInfo trackInfo, DataInput input) throws IOException { + throw new UnsupportedOperationException("decodeTrack is unsupported."); + } + + @Override + public void shutdown() { + + } + + private List getAudioTracks(List tracks, DefaultAudioPlayerManager manager) { + return tracks.parallelStream().map(track -> { //parallelStream made a world of difference in loading times lmao + String artist = track.getArtists().length < 1 ? "" : track.getArtists()[0].getName(); + AudioItem item = this.manager.loadItem(manager, new AudioReference("ytsearch:" + artist + " " + track.getName(), null)); + if (item instanceof AudioPlaylist) { + AudioPlaylist audioPlaylist = (AudioPlaylist) item; + + // The number of matches is limited to reduce the chances of matching against + // less than optimal results. + // The best match is the one that has the smallest track duration delta. + YoutubeAudioTrack bestMatch = audioPlaylist.getTracks().stream().limit(3) + .map(t -> (YoutubeAudioTrack) t).min((o1, o2) -> { + long o1TimeDelta = Math.abs(o1.getDuration() - track.getDurationMs()); + long o2TimeDelta = Math.abs(o2.getDuration() - track.getDurationMs()); + + return (int) (o1TimeDelta - o2TimeDelta); + }).orElse(null); + + return bestMatch; + } else if (item instanceof YoutubeAudioTrack) { + return (YoutubeAudioTrack) item; + } else if (item instanceof AudioReference) { //no results; retry once more + System.out.println("Spotify Source Manager: Retry needed for " + track.getName()); + item = this.manager.loadItem(manager, new AudioReference("ytsearch:" + artist + " " + track.getName(), null)); + + if(item instanceof AudioPlaylist) item = ((AudioPlaylist) item).getTracks().get(0); //cba doing the best match lmao + else if(!(item instanceof YoutubeAudioTrack)) return null; //if not playlist and track return null again + + return (YoutubeAudioTrack) item; + } else { + throw new IllegalArgumentException("Unknown AudioItem"); //should never throw + } + }).collect(Collectors.toList()); + } +} diff --git a/src/me/despawningbone/discordbot/command/music/TrackScheduler.java b/src/me/despawningbone/discordbot/command/music/TrackScheduler.java new file mode 100644 index 0000000..603d3fd --- /dev/null +++ b/src/me/despawningbone/discordbot/command/music/TrackScheduler.java @@ -0,0 +1,200 @@ +package me.despawningbone.discordbot.command.music; + +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; + +import me.despawningbone.discordbot.DiscordBot; +import me.despawningbone.discordbot.command.music.AudioTrackHandler.TrackData; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +import org.json.JSONObject; +import org.json.JSONTokener; + +/** + * This class schedules tracks for the audio player. It contains the queue of + * tracks. + */ +public class TrackScheduler extends AudioEventAdapter { + private final AudioPlayer player; + private final BlockingQueue queue; + private AudioTrackHandler ap; + private Guild guild; + + public String loop = null; //way too lazy to use getter setters lmao + + /** + * @param player + * The audio player this scheduler uses + */ + public TrackScheduler(Guild parent, AudioTrackHandler handler, AudioPlayer player) { + this.player = player; + this.queue = new LinkedBlockingQueue<>(); + this.ap = handler; + this.guild = parent; + } + + /** + * Add the next track to queue or play right away if nothing is in the + * queue. + * + * @param track + * The track to play or add to queue. + */ + public void queue(AudioTrack track) { + // Calling startTrack with the noInterrupt set to true will start the track only if nothing is currently playing. If + // something is playing, it returns false and does nothing. In that case the player was already playing so this + // track goes to the queue instead. + if (!player.startTrack(track, true)) { + queue.offer(track); + } else if (loop != null && loop.equals("autoplay")) { //i dont think this is needed as people need to play something before autoplay can be toggled anyways + queueAutoplay(track); + } + } + + /** + * Start the next track, stopping the current one if it is playing. + */ + public void nextTrack() { //DONE rewrite to not include q.remove here so that stuff like interrupted wont break the queue? + // Start the next track, regardless of if something is already playing or not. In case queue was empty, we are + // giving null to startTrack, which is a valid argument and will simply stop the player. + AudioTrack track = queue.poll(); + player.startTrack(track, false); + if(track == null) { + //System.out.println("finished"); //debug + loop = null; + delayCloseConnection(player); //required because if not it will throw InterruptedException + } + } //seems to be called internally somehow; even when mayStartNext is false (REPLACED, STOPPED etc) this still fires + //NVM ITS CALLED FROM AudioTrackHandler.skipTrack() LOL + + public void queueAutoplay(AudioTrack track) { //check duplicate please, some can get into a dead loop like cosMo@ๆšด่ตฐP - WalpurgisNacht and Ice - ็ตถ //well randoming it works + ap.ex.submit(() -> { //async so it can free up the event + try { + //System.out.println("autoplay"); + InputStream input = new URL("https://www.googleapis.com/youtube/v3/search?part=snippet%20&relatedToVideoId=" + track.getIdentifier() + "&type=video%20&key=" + ap.GAPI).openStream(); + JSONTokener result = new JSONTokener(new InputStreamReader(input, "UTF-8")); + Member temp; + try { + temp = guild.getMemberByTag(track.getUserData(TrackData.class).getUserWithDiscriminator()); + } catch (IllegalArgumentException e) { //track was autoplay queued before this + temp = guild.getMemberById(DiscordBot.BotID); //fallback + } + final Member user = temp; + int t = ThreadLocalRandom.current().nextInt(2); + String url = "https://youtube.com/watch?v=" + new JSONObject(result).getJSONArray("items").getJSONObject(t).getJSONObject("id").getString("videoId"); + ap.load(user, url, (n, l) -> { + AudioTrack auto = l.get(0); + auto.setUserData(new TrackData(auto.getInfo().uri, "Autoplay", auto.getDuration())); + ap.queueTracks(l.subList(0, 1), user); + }, ex -> { + ex.printStackTrace(); + DiscordBot.lastMusicCmd.get(guild.getId()).sendMessage("Something went wrong when loading next autoplay track: " + ex.getMessage()).queue(); + loop = null; + }); + } catch (Exception e) { + e.printStackTrace(); + DiscordBot.lastMusicCmd.get(guild.getId()).sendMessage("Something went wrong when loading next autoplay track. Is the last track a youtube video?").queue(); + loop = null; + } + }); + } + + public List findTracks(int startIndex, int range) { + AudioTrack[] a = queue.toArray(new AudioTrack[queue.size()]); + int i = startIndex - 1 + range < 0 ? Integer.MAX_VALUE : startIndex - 1 + range; //prevent overflow + AudioTrack[] t = Arrays.copyOfRange(a, startIndex - 1, Math.min(queue.size() + 1, i)); //accounts for first track player thats not in queue + + return Arrays.asList(t); + } + + public AudioTrack removeTrack(int num) { + Iterator i = queue.iterator(); + num = num - 1; + + for (int times = 0; i.hasNext(); times++) { + AudioTrack removed = i.next(); + if (num == times) { + i.remove(); + return removed; + } + } + + return null; + } + + public AudioTrack moveTrack(int from, int to) { + List q = new ArrayList<>(Arrays.asList(queue.toArray(new AudioTrack[queue.size()]))); + AudioTrack track = q.remove(from); + q.add(to, track); + synchronized (queue) { //obtain lock and operate before releasing or else queue might not be complete + queue.clear(); + for(AudioTrack t : q) queue.offer(t); + } + return track; + } + + public void shuffleQueue() { + List q = Arrays.asList(queue.toArray(new AudioTrack[queue.size()])); + Collections.shuffle(q); + synchronized (queue) { //obtain lock and operate before releasing or else queue might not be complete + queue.clear(); + for(AudioTrack t : q) queue.offer(t); + } + } + + public void clearSchedulerQueue() { + queue.clear(); + } + + public int getQueueSize() { + return queue.size(); + } + + + @Override + public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason) { + // Only start the next track if the end reason is suitable for it (FINISHED or LOAD_FAILED or REPLACED) + //System.out.println(endReason); //debug + + boolean mayStartNext = endReason.mayStartNext; + + if (mayStartNext) { + //doesnt queue clone if skipped + if (loop != null && loop.equals("loop")) { //so what the hecc if loop is null and i do loop.equals("Loop") it freezes the thread + AudioTrack clone = track.makeClone(); + TrackData origData = clone.getUserData(TrackData.class); + clone.setUserData(new TrackData(origData.getUrl(), origData.getUserWithDiscriminator(), clone.getDuration())); //wipe votes + queue.offer(clone); + } + + nextTrack(); + } + + if(mayStartNext || endReason == AudioTrackEndReason.REPLACED) { //queues new if skipped too + if (loop != null && loop.equals("autoplay") && queue.size() < 1) { + queueAutoplay(player.getPlayingTrack()); + } + } + } + + private void delayCloseConnection(AudioPlayer player) { + ap.ex.schedule(() -> guild.getAudioManager().closeAudioConnection(), 1, TimeUnit.MILLISECONDS); + } + +} \ No newline at end of file diff --git a/src/me/despawningbone/discordbot/utils/GoogleSearch.java b/src/me/despawningbone/discordbot/utils/GoogleSearch.java new file mode 100644 index 0000000..8d365dc --- /dev/null +++ b/src/me/despawningbone/discordbot/utils/GoogleSearch.java @@ -0,0 +1,79 @@ +package me.despawningbone.discordbot.utils; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map.Entry; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringEscapeUtils; + +@SuppressWarnings("deprecation") +public class GoogleSearch { + + public static List> search(String search, int num) throws IOException { + String query = "https://www.google.com/search?q=" + search + "&num=" + num; + //String query = "https://www.google.com/search?q=site:https://osu.ppy.sh/+searchbigblack&num=100&gbv=1&sei=u4V8Wo2GIczfvASZtoaQCQ"; + String page = getSearchContent(query); + return parseLinks(page); + } + + /** + * The method will return the search page result in a {@link String} object + * + * @param path + * the google search query + * @return the content as {@link String} object + * @throws Exception + */ + public static String getSearchContent(String path) throws IOException { + final String agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:57.0)"; + //final String agent = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"; + URL url = new URL(path); + final URLConnection connection = url.openConnection(); + /** + * User-Agent is mandatory otherwise Google will return HTTP response + * code: 403 + */ + connection.setRequestProperty("User-Agent", agent); + final InputStream stream = connection.getInputStream(); + return IOUtils.toString(stream, "UTF-8"); + } + + /** + * Parse all links + * + * @param html + * the page + * @return the list with all URLSs + * @throws Exception + */ + public static List> parseLinks(String html) { + List> result = new ArrayList>(); + //System.out.println(html); + String pattern1 = "

"; + String pattern3 = "

"; + Pattern p = Pattern.compile(Pattern.quote(pattern1) + "(.*?)" + Pattern.quote(pattern2) + "(.*?)" + Pattern.quote(pattern3)); + //result = new ArrayList(Arrays.asList(html.split(" "))).stream().filter(p.asPredicate()).collect(Collectors.toList()); + + Matcher m = p.matcher(html); + + while (m.find()) { + String section = m.group(0).trim(); + String url = "", title = ""; + url = section.substring(section.indexOf("/url?q=") + 7); + url = StringEscapeUtils.unescapeXml(url.substring(0, url.indexOf("&"))); + title = section.substring(section.lastIndexOf("\">") + 2); + title = StringEscapeUtils.unescapeXml(title.substring(0, title.lastIndexOf(""))); + result.add(new AbstractMap.SimpleEntry(title, url)); + } + return result; + } +} diff --git a/src/me/despawningbone/discordbot/utils/MiscUtils.java b/src/me/despawningbone/discordbot/utils/MiscUtils.java new file mode 100644 index 0000000..c05fd18 --- /dev/null +++ b/src/me/despawningbone/discordbot/utils/MiscUtils.java @@ -0,0 +1,225 @@ +package me.despawningbone.discordbot.utils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import me.despawningbone.discordbot.DiscordBot; +import me.despawningbone.discordbot.command.Command; +import me.despawningbone.discordbot.command.Command.BotUserLevel; +import me.despawningbone.discordbot.command.admin.Settings; +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; + +public class MiscUtils { + + public static String convertMillis(long m) { + String c = null; + if (TimeUnit.MILLISECONDS.toHours(m) == 0) { + c = String.format("%02d:%02d", TimeUnit.MILLISECONDS.toMinutes(m) % TimeUnit.HOURS.toMinutes(1), + TimeUnit.MILLISECONDS.toSeconds(m) % TimeUnit.MINUTES.toSeconds(1)); + } else { + c = String.format("%02d:%02d:%02d", TimeUnit.MILLISECONDS.toHours(m), + TimeUnit.MILLISECONDS.toMinutes(m) % TimeUnit.HOURS.toMinutes(1), + TimeUnit.MILLISECONDS.toSeconds(m) % TimeUnit.MINUTES.toSeconds(1)); + } + return c; + } + + public static String appendZeroToMinute(String time) { + if (time.split(":")[1].split(" ")[0].length() != 2) { + time = new StringBuilder(time).insert(time.indexOf(":") + 1, "0").toString(); + } + return time; + } + + public static String ordinal(int i) { + String suffix; + int lastTwoDigits = i % 100; + int lastDigit = lastTwoDigits % 10; + switch (lastDigit) { + case 1: + suffix = "st"; + break; + + case 2: + suffix = "nd"; + break; + + case 3: + suffix = "rd"; + break; + + default: + suffix = "th"; + break; + } + if (11 <= lastTwoDigits && lastTwoDigits <= 13) { + suffix = "th"; + } + return i + suffix; + } + + public static void delayDeleteMessage(Message msg, long millis) { + new Thread() { + public void run() { + try { + Thread.sleep(millis); + msg.delete().queue(); + } catch (InterruptedException v) { + v.printStackTrace(); + ; + } + } + }.start(); + } + + public static Long nameToBase(String sbase) throws IllegalArgumentException { + long base = 10; + if (sbase.equals("hex") || sbase.equals("hexadecimal")) { + base = 16; + } else if (sbase.equals("oct") || sbase.equals("octal")) { + base = 8; + } else if (sbase.equals("bin") || sbase.equals("binary")) { + base = 2; + } else if (sbase.equals("dec") || sbase.equals("decimal") || sbase.equals("denary")) { + base = 10; + } else if (sbase.equals("duodecimal") || sbase.equals("dozenal")) { + base = 12; + } else if (sbase.equals("vigesimal")) { + base = 20; + } else { + throw new IllegalArgumentException("Please enter a valid base."); + } + return base; + } + + public static String longToSubscript(long i) { + StringBuilder sb = new StringBuilder(); + for (char ch : String.valueOf(i).toCharArray()) { + sb.append((char) ('\u2080' + (ch - '0'))); + } + return sb.toString(); + } + + public static long calcPermOverrides(long base, long override) { + long oDeny = (int) (override >> 32); + long oAllow = (int) override; + base &= ~oDeny; + base |= oAllow; + return base; + } + + public static String getMissingPerms(long rawPerms, int botUserLevel, Member member, TextChannel channel) { + if(hasDisabled(rawPerms)) return "DISABLED"; //dont return but append? + EnumSet perms = Permission.getPermissions(rawPerms); + boolean metBotUserLevel = Math.abs(botUserLevel) > 1 ? DiscordBot.OwnerID.equals(member.getUser().getId()) : Math.abs(botUserLevel) > 0 ? DiscordBot.ModID.contains(member.getUser().getId()) : true; + boolean metPerms = member.hasPermission(channel, perms); + if(rawPerms == 0 && botUserLevel < 0) botUserLevel = Math.abs(botUserLevel); //fall back in case misconfiguration + if(botUserLevel > 0 && !metBotUserLevel) return BotUserLevel.values()[botUserLevel].name(); //level > 0 = the command is specifically for BotMod+ + else if(botUserLevel == 0 && !metPerms) return perms.stream().map(p -> p.name()).collect(Collectors.joining(", ")); //not specified for BotMod+, check perms + else if(botUserLevel < 0 && !(metBotUserLevel || metPerms)) return BotUserLevel.values()[Math.abs(botUserLevel)].name() + " OR " + perms.stream().map(p -> p.name()).collect(Collectors.joining(", ")); //since BotMod+ overrides perms, either they have perm or BotMod+ suffices + else return null; + } + + public static String getRequiredPerms(long rawPerms, int botUserLevel) { + if(hasDisabled(rawPerms)) return "DISABLED"; + EnumSet perms = Permission.getPermissions(rawPerms); + if(botUserLevel > 0) return BotUserLevel.values()[botUserLevel].name(); //level > 0 = the command is specifically for BotMod+ + else if(botUserLevel == 0 && !perms.isEmpty()) return perms.stream().map(p -> p.name()).collect(Collectors.joining(", ")); //not specified for BotMod+, check perms + else if(botUserLevel < 0) return BotUserLevel.values()[Math.abs(botUserLevel)].name() + (!perms.isEmpty() ? " OR " + perms.stream().map(p -> p.name()).collect(Collectors.joining(", ")) : ""); //since BotMod+ overrides perms, either they have perm or BotMod+ suffices + else return null; + } + + public static boolean hasDisabled(long rawPerms) { + return (rawPerms & Settings.DISABLED) == Settings.DISABLED; + } + + //TODO move these to a new SQLUtils class? + public static String getPrefix(Statement s, String guildId) throws SQLException { + ResultSet gRs = s.executeQuery("SELECT prefix FROM settings WHERE id = " + guildId + ";"); + String prefix = "!desp "; + if(gRs.next()) prefix = gRs.getString(1); + gRs.close(); + return prefix; + } + + public static long getActivePerms(Statement s, TextChannel channel, Command cmd) throws SQLException { + long perm = 0; + ArrayList defs = new ArrayList<>(); + ArrayList components = new ArrayList<>(); + int c = 2; + + components.add(cmd.getName()); + defs.add(Permission.getRaw(cmd.getDefaultPerms())); + while(cmd.getParent() != null) { + cmd = cmd.getParent(); + components.add(0, cmd.getName()); + defs.add(Permission.getRaw(cmd.getDefaultPerms())); + c++; + } + String nodes = "\"" + components.get(0) + "\""; + for(int i = 2; i <= components.size(); i++) { + nodes += ", \"" + String.join(".", components.subList(0, i)) + "\""; + } + + //System.out.println(nodes); + ResultSet pRs = s.executeQuery("SELECT _GLOBAL_, " + nodes + " FROM perms_" + cmd.getCategory().toLowerCase() + " WHERE id = " + channel.getGuild().getId() + ";"); + if(pRs.next()) { //parse and calc overrides from db settings + for(int i = 1; i <= c; i++) { + //System.out.println(pRs.getString(i)); + perm = getPermOverrides(pRs.getString(i), perm, channel.getId()); //cat(guild) < cmd(guild < channel) < subcmd(guild < channel) ... + } + } else { //directly calc overrides for defaults + Collections.reverse(defs); + for(long def : defs) perm = calcPermOverrides(perm, def); + } + + pRs.close(); + return perm; + } + + private static Long getPermOverrides(String parse, long base, String cId) { + long gPerms = 0, cPerms = 0; + if(parse != null) { + String g = parse.substring(0, parse.indexOf("\n")); + gPerms = Long.parseLong(g); //gPerms should always be present + if(parse.indexOf(cId + ":") != -1) { + String cut = parse.substring(parse.indexOf(cId + ":") + cId.length() + 1); + cPerms = Long.parseLong(cut.substring(0, cut.indexOf("\n"))); + } + } + return calcPermOverrides(calcPermOverrides(base, gPerms), cPerms); + } + + + public static void copy(final InputStream in, final OutputStream out) { + byte[] buffer = new byte[1024]; + int count; + + try { + while ((count = in.read(buffer)) != -1) { + out.write(buffer, 0, count); + } + } catch (IOException e) { + e.printStackTrace(); + } + + // Flush out stream, to write any remaining buffered data + try { + out.flush(); + } catch (IOException e) { + e.printStackTrace(); + } + } +}