diff --git a/pom.xml b/pom.xml index dcde20b..fdf2957 100644 --- a/pom.xml +++ b/pom.xml @@ -1,171 +1,184 @@ 4.0.0 Bot Bot 1.0-SNAPSHOT despbot A discord bot 1.8 1.8 src - maven-compiler-plugin - 3.5.1 + org.apache.maven.plugins + maven-shade-plugin + 3.2.1 - 1.8 - 1.8 + + + log4j:** + + org/apache/log4j/** + + + + org.slf4j:** + + ** + + + + **:sqlite-jdbc** + + ** + + + + + + me.despawningbone.discordbot.DiscordBot + + + true + + + package + + shade + + + 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.77 - - 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 + 4.3.0_322 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 org.jsoup jsoup 1.13.1 dv8tion m2-dv8tion https://m2.dv8tion.net/releases diff --git a/src/me/despawningbone/discordbot/EventListener.java b/src/me/despawningbone/discordbot/EventListener.java index 1fa64eb..ce68702 100644 --- a/src/me/despawningbone/discordbot/EventListener.java +++ b/src/me/despawningbone/discordbot/EventListener.java @@ -1,314 +1,314 @@ package me.despawningbone.discordbot; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.Arrays; import java.util.List; import java.util.Properties; import java.util.concurrent.TimeUnit; import org.apache.commons.lang3.exception.ExceptionUtils; import me.despawningbone.discordbot.command.Command; import me.despawningbone.discordbot.command.CommandResult; import me.despawningbone.discordbot.command.CommandResult.CommandResultType; import me.despawningbone.discordbot.command.music.AudioTrackHandler; import me.despawningbone.discordbot.command.music.GuildMusicManager; import me.despawningbone.discordbot.command.music.Music; import me.despawningbone.discordbot.utils.MiscUtils; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.MessageBuilder; import net.dv8tion.jda.api.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.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 { @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 User author = event.getAuthor(); TextChannel channel = event.getChannel(); Message msg = event.getMessage(); //DONE use log4j //logging if (!DiscordBot.logExcemptID.contains(author.getId())) { String guildinfo = "[" + event.getGuild().getName() + " #" + channel.getName() + "]"; String payload = "[INFO] " + guildinfo + System.lineSeparator() + " " + msg + System.lineSeparator() + " Full msg: " + msg.getContentDisplay(); List att = event.getMessage().getAttachments(); if (!att.isEmpty()) { payload += System.lineSeparator() + " Attachments:"; for (int i = 0; i < att.size(); i++) { payload += System.lineSeparator() + " " + att.get(i).getUrl(); } } DiscordBot.logger.trace(payload); //TODO log embeds and add user id too? } //parse cmd CommandResult result = null; try (Connection con = DiscordBot.db.getConnection(); Statement s = con.createStatement()){ //check prefix String prefix = MiscUtils.getPrefix(s, event.getGuild().getId()); if(msg.getContentDisplay().toLowerCase().startsWith(prefix.toLowerCase())) { //preprocess args String msgStripped = msg.getContentDisplay().substring(prefix.length()).replaceAll("\\s\\s+", " "); //merges space String[] args = msgStripped.split(" "); // base on command length? //get cmd from args Command cmd = DiscordBot.commands.get(args[0].toLowerCase()); cmd = cmd == null ? DiscordBot.aliases.get(args[0].toLowerCase()) : cmd; if (cmd != null) { //check if banned ResultSet uRs = s.executeQuery("SELECT reports FROM users WHERE id = " + author.getId() + ";"); if(uRs.next()) { //checks null at the same time if(uRs.getString(1).split("\n").length >= 5) { channel.sendMessage("You are banned from using the bot.").queue(); DiscordBot.logger.info("[WARN] " + author.getName() + " (" + author.getId() + ") tried to execute " + msg.getContentDisplay() + " but was banned."); return; } } uRs.close(); //check perms long perm = MiscUtils.getActivePerms(s, channel, cmd); String perms = cmd.hasSubCommand() ? null : MiscUtils.getMissingPerms(perm, cmd.getRequiredBotUserLevel(), event.getMember(), channel); //pass it to the subcommand handler to handle instead if(cmd.isDisabled()) perms = "DISABLED"; //override if disabled by code if(perms == null || event.getAuthor().getId().equals(DiscordBot.OwnerID)) { //owner overrides perms for convenience //execute async cmd.executeAsync(channel, author, msg, Arrays.copyOfRange(args, 1, args.length), r -> { //catch all exceptions? //should have actually DiscordBot.logger.info("[" + r.getResultType() + "] " + author.getName() + " (" + author.getId() + ") executed " + msg.getContentDisplay() + (r.getRemarks() == null ? "." : ". (" + r.getRemarks() + ")")); //logging has to be before sendMessage, or else if no permission it will just quit if(r.getMessage() != null) channel.sendMessage(r.getMessage()).queue(); }); //dont know if async will screw anything up //wont, TODO log date and which server executed the command also? return; } else if(perms.equals("DISABLED")) { author.openPrivateChannel().queue(c -> //notify user whats wrong if possible c.sendMessage("Sorry, but the command `" + msg.getContentDisplay() + "` is disabled in the channel `#" + channel.getName() + "`.").queue()); msg.addReaction("❎").queue(); //instead of sending messages, react instead to avoid clutter (which is what most ppl disable a bot in a channel for) result = new CommandResult(CommandResultType.DISABLED); } else { result = new CommandResult(CommandResultType.NOPERMS, perms); channel.sendMessage(result.getMessage()).queue(); } } else { result = new CommandResult(CommandResultType.FAILURE, "Invalid command"); //do more stuff? } //non prefix "command" - greetings with tagging } else if(msg.getContentRaw().matches("<@!?" + DiscordBot.BotID + ">")) { result = greet(channel, author, prefix); } } catch (SQLException e) { result = new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } //log non async results if(result != null) DiscordBot.logger.info("[" + result.getResultType() + "] " + author.getName() + " (" + author.getId() + ") executed " + msg.getContentDisplay() + (result.getRemarks() == null ? "." : ". (" + result.getRemarks() + ")")); } //init easter eggs file private Properties greetEasterEggs = new Properties(); { try(FileInputStream in = new FileInputStream(new File(System.getProperty("user.dir") + File.separator + "eastereggs.properties"))){ greetEasterEggs.load(in); } catch (IOException e) { e.printStackTrace(); } } private CommandResult greet(TextChannel channel, User author, String prefix) { MessageBuilder smsg = new MessageBuilder(); //main greet String nick = channel.getGuild().getMemberById(author.getId()).getNickname(); if (nick != null) { smsg.append("Yo " + nick + "!\n"); } else { smsg.append("Yo " + author.getName() + "!\n"); } //easter eggs String easterEgg = greetEasterEggs.getProperty(author.getId()); if(easterEgg != null) { smsg.append(easterEgg.replace("\\n", "\n") + "\n"); } //bot info EmbedBuilder eb = new EmbedBuilder(); eb.setColor(0x051153); //TODO version info based on git commits eb.appendDescription("This bot is running **despbot v1.5.0**, Shard `" + DiscordBot.mainJDA.getShardInfo().getShardString() + "`. ([invite me!](https://discordapp.com/oauth2/authorize?&client_id=" + DiscordBot.BotID + "&scope=bot&permissions=0))\n"); eb.appendDescription("Connected guilds: `" + DiscordBot.mainJDA.getGuildCache().size() + (DiscordBot.mainJDA.getShardManager() == null ? "" : "/" + DiscordBot.mainJDA.getShardManager().getShards().stream().mapToLong(jda -> jda.getGuildCache().size()).sum()) + "`; "); eb.appendDescription("Total members (cached): `" + DiscordBot.mainJDA.getUserCache().size() + (DiscordBot.mainJDA.getShardManager() == null ? "" : "/" + DiscordBot.mainJDA.getShardManager().getShards().stream().mapToLong(jda -> jda.getUserCache().size()).sum()) + "`\n"); eb.appendDescription("DM `" + DiscordBot.mainJDA.getUserById(DiscordBot.OwnerID).getAsTag() + "` if you have any questions!\n"); eb.appendDescription("To get a list of commands, do `" + prefix + "help`."); - smsg.setEmbed(eb.build()); + smsg.setEmbeds(eb.build()); Message fmsg = smsg.build(); channel.sendMessage(fmsg).queue(); return new CommandResult(CommandResultType.SUCCESS, null); } @Override public void onGuildMessageUpdate(GuildMessageUpdateEvent event) { //log edits 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(); String sGame = (toolTip.lastIndexOf(" (") != -1 ? toolTip.substring(0, toolTip.lastIndexOf(" (")) : toolTip) + "||" + osu.asRichPresence().getDetails(); //update db try (Connection con = DiscordBot.db.getConnection()) { //DONE do i need to close the statement? PreparedStatement s = con.prepareStatement("INSERT INTO users(id, game) VALUES (" + event.getUser().getId() + ", ?) ON CONFLICT(id) DO UPDATE SET game = ? WHERE game <> ?;" ); //prevent blank updates s.setString(1, sGame); s.setString(2, sGame); s.setString(3, sGame); s.execute(); } catch (SQLException e) { //FIXED if i make this not osu only, be aware of SQL injections through sGame (probably being paranoid tho) e.printStackTrace(); } } } //TODO only update when not paused? //TODO update on member deafen? private void waitActivity(Guild guild) { AudioTrackHandler ap = ((Music) DiscordBot.commands.get("music")).getAudioTrackHandler(); ap.getGuildMusicManager(guild).player.setPaused(true); ap.getGuildMusicManager(guild).clearQueueCleanup = ap.ex.schedule(() -> { ap.stopAndClearQueue(guild); DiscordBot.lastMusicCmd.get(guild.getId()).sendMessage("The queue has been cleared.").queue(); ap.getGuildMusicManager(guild).clearQueueCleanup = null; }, 1, TimeUnit.MINUTES); } private String updateActivity(Guild guild, VoiceChannel vc) { AudioTrackHandler ap = ((Music) DiscordBot.commands.get("music")).getAudioTrackHandler(); GuildMusicManager mm = ap.getGuildMusicManager(guild); if(mm.clearQueueCleanup != null) { mm.player.setPaused(false); mm.clearQueueCleanup.cancel(true); mm.clearQueueCleanup = null; } String type = mm.scheduler.loop; //mm cannot be null if (type != null && vc.getMembers().size() > 2 && vc.getMembers().contains(guild.getMemberById(DiscordBot.OwnerID))) { ap.toggleLoopQueue(guild, type); return type; } return null; } @Override public void onGuildVoiceLeave(GuildVoiceLeaveEvent event) { // theoretically i dont need to check if lastMusicCmd has the entry or not, as it must have one to trigger this GuildVoiceState vs = event.getGuild().getMemberById(DiscordBot.BotID).getVoiceState(); if(!event.getMember().getUser().getId().equals(DiscordBot.BotID)) { if (vs.inVoiceChannel()) { if (vs.getChannel().equals(event.getChannelLeft()) && event.getChannelLeft().getMembers().size() < 2) { TextChannel channel = DiscordBot.lastMusicCmd.get(event.getGuild().getId()); channel.sendMessage( "All users have left the music channel, the player is now paused.\nThe queue will be cleared in 1 minute if there is no activity.") .queue(); waitActivity(event.getGuild()); } } } else { //got kicked AudioTrackHandler ap = ((Music) DiscordBot.commands.get("music")).getAudioTrackHandler(); if(ap != null) { //if not destroyed ap.stopAndClearQueue(event.getGuild()); //can double fire on normal end; shouldnt be too big of a problem } } } @Override public void onGuildVoiceMove(GuildVoiceMoveEvent event) { // theoretically i dont need to check if lastMusicCmd has the entry or not, as it must have one to trigger this GuildVoiceState vs = event.getGuild().getMemberById(DiscordBot.BotID).getVoiceState(); if (vs.inVoiceChannel()) { String id = event.getGuild().getId(); TextChannel channel = DiscordBot.lastMusicCmd.get(id); if (!event.getMember().getUser().getId().equals(DiscordBot.BotID)) { if (vs.getChannel().equals(event.getChannelLeft())) { if (!event.getMember().getUser().getId().equals(DiscordBot.BotID) && event.getChannelLeft().getMembers().size() < 2) { channel.sendMessage( "All users have left the music channel, the player is now paused.\nThe queue will be cleared in 1 minute if there is no activity.") .queue(); waitActivity(event.getGuild()); } } else if (vs.getChannel().equals(event.getChannelJoined())) { String type = updateActivity(event.getGuild(), event.getChannelJoined()); if(type != null) channel.sendMessage((type.equals("loop") ? "Looping" : "Autoplay") + " mode disabled due to new users joining the music channel.").queue(); } } else { //moved bot to empty channel if(event.getChannelJoined().getMembers().size() < 2) { channel.sendMessage( "The bot has been moved to an empty channel, the player is now paused.\nThe queue will be cleared in 1 minute if there is no activity.") .queue(); waitActivity(event.getGuild()); } else { //moved bot to channel with ppl event.getGuild().getAudioManager().openAudioConnection(event.getChannelJoined()); //seems to need explicit reconnect on bot move, it gets stuck on attempting to reconnect otherwise; probably would be fixed soon but hot patch for now String type = updateActivity(event.getGuild(), event.getChannelJoined()); 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/admin/Settings.java b/src/me/despawningbone/discordbot/command/admin/Settings.java index 0821bf6..826be52 100644 --- a/src/me/despawningbone/discordbot/command/admin/Settings.java +++ b/src/me/despawningbone/discordbot/command/admin/Settings.java @@ -1,252 +1,252 @@ package me.despawningbone.discordbot.command.admin; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.Arrays; import java.util.EnumSet; import java.util.List; import java.util.ArrayList; import java.util.stream.Collectors; import org.apache.commons.lang3.exception.ExceptionUtils; import me.despawningbone.discordbot.DiscordBot; import me.despawningbone.discordbot.command.Command; import me.despawningbone.discordbot.command.CommandResult; import me.despawningbone.discordbot.command.CommandResult.CommandResultType; import me.despawningbone.discordbot.utils.MiscUtils; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.Permission; public class Settings extends Command { public static final int DISABLED = 0x80000; //even if i use 0x80000000 and it overflows it should still work //DONE 0x200 is a new permission; JDA 3 will probably not recognize it so i have to circumvent over it manually //changed to 0x80000 private static final List disableExempt = Arrays.asList("admin", "admin.settings", "admin.settings.perms"); public Settings() { this.alias = Arrays.asList("setting", "set"); this.desc = "Change the settings of the bot for this guild!"; this.usage = ""; this.botUserLevel = -BotUserLevel.BOT_OWNER.ordinal(); this.perms = EnumSet.of(Permission.MANAGE_SERVER); //PATCHED? SQL INJECTION PRONE (eg node: games/**/WHERE1--.",*," - returns numberformatexception; games/**/WHERE1--.osu",*,"osu returns N/A; can even drop tables) registerSubCommand("perms", Arrays.asList("permissions", "perm", "permission"), (channel, user, msg, words) -> { //DONE disable commands; use negative? //TODO list all edited permission nodes, clear command? int opType = 3; try { if(words[0].equalsIgnoreCase("list")) { if(words.length != 2) throw new ArrayIndexOutOfBoundsException(); //invalid argument count opType = 0; } else if(words[0].equalsIgnoreCase("wipe") || words[0].equalsIgnoreCase("wipeall")){ opType = 1; } else { if(words.length != 3) throw new ArrayIndexOutOfBoundsException(); //invalid argument count opType = words[0].equalsIgnoreCase("guild") ? 2 : 3; } } catch(ArrayIndexOutOfBoundsException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid argument count specified."); } if(!words[1].matches("[a-zA-Z0-9.]+")) return new CommandResult(CommandResultType.INVALIDARGS, "Invalid node specified."); //prevent SQL injection; might be restricting though, but then again all command names are alphanumeric try(Connection con = DiscordBot.db.getConnection(); Statement s = con.createStatement()){ String node = words[1].contains(".") ? words[1].substring(words[1].indexOf(".") + 1).toLowerCase() : "_GLOBAL_"; String cat = node.equals("_GLOBAL_") ? words[1].toLowerCase() : words[1].substring(0, words[1].indexOf(".")).toLowerCase(); ResultSet rs = null; if(opType == 3) try { if(channel.getGuild().getTextChannelById(words[0]) == null) throw new NumberFormatException(); } catch(NumberFormatException e) { if(words[0].startsWith("#")) { if(!msg.getMentionedChannels().isEmpty()) { words[0] = msg.getMentionedChannels().get(0).getId(); //assuming its to order from left to right; but tbh if they try to trick the command with 2 channels it wont work anyways with the range check and node check } } 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(); + channel.sendMessageEmbeds(eb.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); } else if(opType == 1) { //wipe String actualNode = words[1].toLowerCase(); if(words[0].equalsIgnoreCase("wipeall")) { s.execute("DELETE FROM perms_" + cat + " WHERE id = " + channel.getGuild().getId() + ";"); actualNode = cat; } else { s.execute("UPDATE perms_" + cat + " SET \"" + node + "\" = '" + ((0L << 32) | (Permission.getRaw(def) & 0xffffffffL)) + "\n'"); } channel.sendMessage("Successfully reset `" + actualNode + "` to default permissions.").queue(); return new CommandResult(CommandResultType.SUCCESS); } else { boolean allows = words[2].indexOf("-") == -1; Permission perm = null; try { String p = allows ? words[2].toUpperCase() : words[2].substring(1).toUpperCase(); if(!p.equals("DISABLED")) perm = Permission.valueOf(p); if(perm == null && disableExempt.contains(words[1].toLowerCase())) return new CommandResult(CommandResultType.INVALIDARGS, "You cannot disable this node."); //temporary patch } catch(IllegalArgumentException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid permission specified."); } String[] lines = {""}; String type = ""; if(rs.next()) { //considerations: rs.next(); guild vs channel; allows; add or remove; lines = rs.getString(1).split("\n"); if(lines[0].equals(node)) return new CommandResult(CommandResultType.INVALIDARGS, "Invalid node specified."); //SQL returns the actual string if the table doesnt have the column int i = 0; long orig = 0; if(opType == 2) { orig = Long.parseLong(lines[0]); } else { try { for(i = 0; i <= lines.length; i++) { if(lines[i].startsWith(words[0] + ":")) { orig = Long.parseLong(lines[i].split(":")[1]); break; } } } catch(ArrayIndexOutOfBoundsException e) { lines = Arrays.copyOf(lines, lines.length + 1); //to extend it; should be the same method as using arraylist //orig = Permission.getRaw(def); //use default //actually should already have imposed global default if rs has next; adding default to this will only give channel the global perm, which makes no sense } } String[] split = imposePerms(words[0].equalsIgnoreCase("guild") ? null : words[0], orig, perm, allows); type = split[0]; lines[i] = split[1]; //at this stage theres only 2 options: guild and a valid channel id } else { if(opType == 2) { String[] split = imposePerms(null, Permission.getRaw(def), perm, allows); //initiate guild by imposing perm to default; since allow is in the lower 32 bit, i can just put def as the base type = split[0]; lines[0] = split[1]; } else { lines[0] = String.valueOf(Permission.getRaw(def)); //initiate guild with the default permission of the command lines = Arrays.copyOf(lines, lines.length + 1); String[] split = imposePerms(words[0], 0, perm, allows); //impose with perm base = 0 type = split[0]; lines[1] = split[1]; } } String fin = String.join("\n", lines) + "\n"; //last line must have \n s.execute("INSERT INTO perms_" + cat + "(id, \"" + node + "\") VALUES (" + channel.getGuild().getId() + ", '" + fin + "') ON CONFLICT(id) DO UPDATE SET \"" + node + "\" = '" + fin + "';"); //no need blank update check, because it will never be the same channel.sendMessage("Successfully " + type + " " + (words[0].equalsIgnoreCase("guild") ? "guild-wide" : "channel-specific (<#" + words[0] + ">)") + " permission `" + (allows ? "" : "-") + (perm == null ? "DISABLED" : perm.name()) + "` for `" + words[1].toLowerCase() + "`.").queue(); return new CommandResult(CommandResultType.SUCCESS); } } catch(SQLException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } }, " [-][permission]", Arrays.asList("list admin", "list games.osu.pp", "wipeall admin", "wipe games.osu", "419464253574086656 games MESSAGE_EMBED_LINKS", "guild games.osu -MESSAGE_EMBED_LINKS", "#general music VOICE_CONNECT", "guild admin ADMINISTRATOR"), "Edit default perms required for commands for this guild!", Arrays.asList( "Adding a `-` sign before a permission overrides the parent permission set,", "Where `cat(guild > channel) > cmd(guild > channel) > subcmd(guild > channel)` (parent > child, where subcmd channel is of highest priority).", "*For example, specifying `admin.settings.prefix` with `-MANAGE_SERVER` will override the requirement of `admin.settings`, and allow those without `MANAGE_SERVER` to use `admin.settings.prefix`*.\n", "The permission nodes are as such: `[.cmd][.subcmd]`.", "For permission names, please refer to https://ci.dv8tion.net/job/JDA/javadoc/net/dv8tion/jda/core/Permission.html,", "Along with a special permission `DISABLED` for disabling the node.", "Specifying the permission twice will remove it.\n", "Use `wipe` if you want to reset the permissions for the node, and `wipeall` for resetting all perms in the whole category.", "`list` and `wipe`/`wipeall` only accepts the first 2 parameters."), EnumSet.of(Permission.ADMINISTRATOR), -BotUserLevel.BOT_OWNER.ordinal()); registerSubCommand("prefix", Arrays.asList("pre"), (channel, user, msg, words) -> { //dont allow prefixes with markdown for now String prefix; if(words.length < 1) { prefix = DiscordBot.prefix; channel.sendMessage("No prefix entered. Using default prefix...").queue(); } else { prefix = msg.getContentStripped().substring(msg.getContentDisplay().lastIndexOf(String.join(" ", words))).replaceAll("\\\\", ""); //if prefix/shortcut contains markdown it breaks } if(prefix.length() > 30) return new CommandResult(CommandResultType.INVALIDARGS, "Please do not enter prefixes that are too long."); //arbitrarily set; can change anytime try (Connection con = DiscordBot.db.getConnection()) { PreparedStatement s = con.prepareStatement("INSERT INTO settings(id, prefix) VALUES (" + channel.getGuild().getId() + ", ?) ON CONFLICT(id) DO UPDATE SET prefix = ? WHERE prefix <> ?"); s.setString(1, prefix); s.setString(2, prefix); s.setString(3, prefix); //using prep statement to prevent injection int update = s.executeUpdate(); s.close(); if(update != 0) { //should ever only be 1 or 0 channel.sendMessage("Successfully changed prefix to `" + prefix + "`. Do " + prefix + "help to see the new command syntaxes.").queue(); } else { return new CommandResult(CommandResultType.INVALIDARGS, "You are setting the same prefix!"); } return new CommandResult(CommandResultType.SUCCESS); } catch(SQLException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } }, "[new prefix]", Arrays.asList("`!desp `", "!", "-"), "Change the prefix for this guild!", Arrays.asList("*You can also include spaces with the use of `code block`s.*", "Leave blank to use the default (`" + DiscordBot.prefix + "`).")); } private String[] imposePerms(String cId, long base, Permission perm, boolean allows) { long allow = (int) base; long deny = base >> 32; String type; long newRaw = allows ? allow : deny; if(perm == null) { //only disabled is null; since Permission doesnt have DISABLED mapped, i have to do it manually type = MiscUtils.hasDisabled(newRaw) ? "removed" : "added"; if(type.equals("removed")) { newRaw &= ~DISABLED; } else { newRaw |= DISABLED; } } else { List perms = new ArrayList<>(Permission.getPermissions(newRaw)); if(perms.contains(perm)) { perms.remove(perm); type = "removed"; } else { perms.add(perm); type = "added"; } newRaw = Permission.getRaw(perms) | (MiscUtils.hasDisabled(newRaw) ? DISABLED : 0); //add back as perms remove it } if(allows) allow = newRaw; else deny = newRaw; return new String[]{type, (cId == null ? "" : cId + ":") + ((deny << 32) | (allow & 0xffffffffL))}; } } diff --git a/src/me/despawningbone/discordbot/command/anime/Anime.java b/src/me/despawningbone/discordbot/command/anime/Anime.java index 0c8e876..01ed3aa 100644 --- a/src/me/despawningbone/discordbot/command/anime/Anime.java +++ b/src/me/despawningbone/discordbot/command/anime/Anime.java @@ -1,103 +1,103 @@ package me.despawningbone.discordbot.command.anime; import java.awt.Color; import java.io.IOException; import java.net.URL; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import org.apache.commons.codec.binary.Base64; import org.apache.commons.lang3.exception.ExceptionUtils; import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; import me.despawningbone.discordbot.DiscordBot; import me.despawningbone.discordbot.command.Command; import me.despawningbone.discordbot.command.CommandResult; import me.despawningbone.discordbot.command.CommandResult.CommandResultType; import me.despawningbone.discordbot.utils.MiscUtils; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.entities.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(); + channel.sendMessageEmbeds(em.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); } } diff --git a/src/me/despawningbone/discordbot/command/anime/AnimePic.java b/src/me/despawningbone/discordbot/command/anime/AnimePic.java index b9b3aa8..97ed007 100644 --- a/src/me/despawningbone/discordbot/command/anime/AnimePic.java +++ b/src/me/despawningbone/discordbot/command/anime/AnimePic.java @@ -1,108 +1,108 @@ package me.despawningbone.discordbot.command.anime; import java.io.IOException; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Arrays; import java.util.concurrent.ThreadLocalRandom; import org.apache.commons.lang3.exception.ExceptionUtils; import org.jsoup.Connection; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import me.despawningbone.discordbot.command.Command; import me.despawningbone.discordbot.command.CommandResult; import me.despawningbone.discordbot.command.CommandResult.CommandResultType; import me.despawningbone.discordbot.utils.MiscUtils; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.entities.User; public class AnimePic extends Command { public AnimePic() { this.desc = "Get anime pics!"; this.usage = "[search words] [-f] [| index]"; this.remarks = Arrays.asList("Leave the search words blank for a random pic!", "If you have not specified the index, it will be a random result from the search.", "Include `-f` if you want to load the full picture!", "However, full pictures takes a while to load in."); this.alias = Arrays.asList("apic"); this.examples = Arrays.asList("ryuZU -f", "neptune | 2"); } //TODO migrate to another more popular image board? @Override public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { Connection con = null; channel.sendTyping().queue(); //full res option boolean full = false; ArrayList amend = new ArrayList<>(Arrays.asList(args)); if(amend.contains("-f")) { amend.remove("-f"); full = true; } //random, use most popular last 3 months as order - should return better results if(amend.size() < 1) { con = Jsoup.connect("https://www.zerochan.net/?s=fav&t=2p=" + (ThreadLocalRandom.current().nextInt(1000) + 1)); } String stripped = String.join(" ", amend); String search; int index; if(stripped.contains("|")) { String[] split = stripped.split(" \\|"); search = split[0]; try { index = Integer.parseInt(split[1].trim()) - 1; } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid index inputted"); } } else { search = stripped; index = -1; } try { if(con == null) con = Jsoup.connect("https://www.zerochan.net/search?q=" + URLEncoder.encode(search, "UTF-8")).ignoreHttpErrors(true).followRedirects(true); Document document = con.get(); Element href = document.body(); Elements resList = null; try { resList = href.select("ul[id=\"thumbs2\"]").get(0).select("li"); if (resList.size() == 0) throw new IndexOutOfBoundsException(); if (resList.size() < index) throw new IndexOutOfBoundsException(); } catch (IndexOutOfBoundsException e) { if(!href.select("ul[id=\"children\"]").isEmpty()) return new CommandResult(CommandResultType.FAILURE, "You need to specify your search words more!"); else return new CommandResult(CommandResultType.NORESULT); } Element a = resList.get(index == -1 ? ThreadLocalRandom.current().nextInt(resList.size()) : index).select("a").first(); if(a.hasAttr("rel")) { return new CommandResult(CommandResultType.FAILURE, "This picture is not public!"); } EmbedBuilder eb = new EmbedBuilder(); eb.setTitle((index == -1 ? "Random" : MiscUtils.ordinal(index + 1)) + (search.isEmpty() ? " anime pic" : " pic for " + search), a.absUrl("href")); //fetch img - seems like discord shrinks the full image anyways nowadays, is -f really useful anymore lol String imgName = a.nextElementSibling().child(0).attr("href").replace("+", "."); //spaces are represented as dots in the cdn for some reason String imgPrefix = a.attr("href").substring(1); //remove prefix / String img = full ? "https://static.zerochan.net" + imgName + ".full." + imgPrefix + ".jpg": "https://s1.zerochan.net" + imgName + ".600." + imgPrefix + ".jpg"; eb.setImage(img); eb.setFooter(full ? "The image might need a while to load in." : "Include -f if you want the full resolution!", null); - channel.sendMessage(eb.build()).queue(); + channel.sendMessageEmbeds(eb.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } } } diff --git a/src/me/despawningbone/discordbot/command/anime/Sauce.java b/src/me/despawningbone/discordbot/command/anime/Sauce.java index 1de5d3a..0fecefc 100644 --- a/src/me/despawningbone/discordbot/command/anime/Sauce.java +++ b/src/me/despawningbone/discordbot/command/anime/Sauce.java @@ -1,257 +1,257 @@ package me.despawningbone.discordbot.command.anime; import java.awt.Color; import java.io.IOException; import java.net.ConnectException; import java.net.MalformedURLException; import java.net.SocketTimeoutException; import java.net.URL; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.NoSuchElementException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import org.apache.commons.lang3.exception.ExceptionUtils; import org.jsoup.Jsoup; import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import me.despawningbone.discordbot.command.Command; import me.despawningbone.discordbot.command.CommandResult; import me.despawningbone.discordbot.command.CommandResult.CommandResultType; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.entities.User; public class Sauce extends Command{ public Sauce() { this.desc = "Get the source of an anime pic!"; this.usage = "[-d] [imgurl]"; this.alias = Arrays.asList("source", "saucenao", "iqdb"); this.remarks = Arrays.asList("The URL should be a direct link to an image.", "You can also upload an image as an attachment while calling this command instead of using a url.", " * Specify the `-d` parameter to do a depth search!", " * It is useful for cropped images and edited images, but makes the search much longer,", " * and can sometimes result in a related image instead of the actual source."); } @Override public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { //allow people to not input url to check sauce of the most recent image like u/2dgt3d? List amend = new ArrayList(Arrays.asList(args)); int depth = amend.indexOf("-d"); if(depth != -1) amend.subList(depth, depth + 1).clear(); //get url or use attachment if no url found String url = null; try { if(amend.size() < 1) { if(msg.getAttachments().size() > 0) url = msg.getAttachments().get(0).getUrl(); else throw new MalformedURLException(); } else { url = amend.get(0).trim(); new URL(url); } } catch(MalformedURLException e) { return new CommandResult(CommandResultType.FAILURE, "Please enter a valid URL!"); } try { channel.sendTyping().queueAfter(20, TimeUnit.MILLISECONDS); if(depth != -1) { channel.sendMessage("Performing depth search for the picture... (this can take up to 20 seconds)").queue(); String[] urls = yandexSearch(url); //System.out.println("urls" + Arrays.asList(urls)); url = urls[2] == null ? urls[0] : urls[1] + ";" + urls[2]; //TODO deprecate this wack system //sync //List collect = Arrays.asList(tempSearchSauce(urls[0]),tempSearchSauce(urls[1])); //async - only throw exception if both errored CompletableFuture first = CompletableFuture.supplyAsync(() -> {try {return getSauce(urls[0]);} catch (IOException e){e.printStackTrace(); return null;}}); CompletableFuture ratio = CompletableFuture.supplyAsync(() -> {try {return getSauce(urls[1]);} catch (IOException e){e.printStackTrace(); return null;}}); List collect = CompletableFuture.allOf(first, ratio) .thenApply(future -> Arrays.asList(first.join(), ratio.join())).get(); //checking at whencomplete wouldnt help - the array joining wouldve errored as a whole and returned null regardless; must use individual try catches //return highest similarity if obtained using closest ratio, else return first found (other size then similar) - channel.sendMessage(collect.stream().filter(e -> e != null) + channel.sendMessageEmbeds(collect.stream().filter(e -> e != null) .sorted((a, b) -> urls[2] == null ? 1 : Double.compare( Double.parseDouble(b.build().getFooter().getText().replaceAll(".*?([.0-9]*)%.*", "$1")), Double.parseDouble(a.build().getFooter().getText().replaceAll(".*?([.0-9]*)%.*", "$1")))) .findFirst().get().build()).queue(); } else { //direct search EmbedBuilder eb = getSauce(url); - channel.sendMessage(eb.build()).queue(); + channel.sendMessageEmbeds(eb.build()).queue(); } } catch (IOException | InterruptedException | ExecutionException e) { Throwable t = e instanceof ExecutionException ? e.getCause() : e; return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(t)); } catch (NullPointerException | NoSuchElementException e) { //give possible related images embed info if(e instanceof NoSuchElementException) { EmbedBuilder eb = new EmbedBuilder(); String[] urls = url.split(";"); eb.setThumbnail(urls[0]); eb.setTitle("Possibly related image", urls[0]); eb.setDescription("Maybe [this](" + urls[urls.length > 1 ? 1 : 0] + ") will help in your search for the delicious marinara?"); - channel.sendMessage(eb.build()).queueAfter(20, TimeUnit.MILLISECONDS); + channel.sendMessageEmbeds(eb.build()).queueAfter(20, TimeUnit.MILLISECONDS); } return new CommandResult(CommandResultType.NORESULT); } return new CommandResult(CommandResultType.SUCCESS); } //well known image boards with workable source images that yandex might scrape private List imageBoards = Arrays.asList("danbooru.donmai.us", "safebooru.org", "gelbooru.com", "zerochan.net", "tbib.org", "pixiv.kurocore.com", "pixiv.net", "chan.sankakucomplex.com", "sankakucomplex.com", "e-shuushuu.net"); //TODO internal cooldown of ~5s when command cooldown system is done - too many requests results in captcha //returns String[] {, , } private String[] yandexSearch(String url) throws IOException { Element yandex = Jsoup.connect("https://yandex.com/images/search?url=" + URLEncoder.encode(url, "UTF-8") + "&rpt=imageview").get().body(); //other sites div class new style is slowly rolling out, support both names String osItem, osSnippet, osPreview, osMeta, osSimilar; if(!yandex.select(".other-sites__item").isEmpty()) { //old ver osItem = ".other-sites__item"; osSnippet = ".other-sites__snippet-site-link"; osPreview = ".other-sites__preview-link"; osMeta = ".other-sites__meta"; osSimilar = ".cbir-similar__thumb .cbir-similar__image"; } else { //usually means new //System.out.println("Sauce: new version of yandex found"); //System.out.println(yandex); osItem = ".CbirSites-Item"; osSnippet = ".CbirSites-ItemDomain"; osPreview = ".CbirSites-ItemThumb .Thumb"; osMeta = ".CbirSites-ItemThumb .Thumb-Mark"; osSimilar = ".CbirSimilar-Thumb .Thumb-Image"; } //System.out.println(yandex.select(osItem).stream() // .filter(o -> imageBoards.contains(o.select(osSnippet).text().toLowerCase())).collect(Collectors.toList())); //check if image boards are found, if so return the first one found and discard similar results since imageboards are as standard as we can get //it is possible but unlikely that we would be grabbing the wrong image's source in the lower sections even though there are good sources (that are not imageboards) at the top due to this String boardRes = yandex.select(osItem).stream() .filter(o -> imageBoards.contains(o.select(osSnippet).text().toLowerCase())) .map(o -> o.select(osPreview).attr("href")).findFirst().orElse(null); if(boardRes != null) { if(boardRes.startsWith("https://embed.pixiv.net")) { //pixiv images arent full images, need some post processing - using kurocore's database instead String illustId = boardRes.substring(boardRes.lastIndexOf("illust_id=") + 10); boardRes = "https://img.kurocore.com/thumbnail/pi/" + String.format("%03d", Integer.parseInt(illustId.substring(0, illustId.length() - 6))) + "/" + illustId.substring(illustId.length() - 6, illustId.length() - 3) + "/" + illustId + "_0.jpg"; } return new String[] {null, boardRes, null}; } //else use other options //gets second image coz usually the first one is just a sharper identical image Element simThumb = yandex.select(osSimilar).get(1); String similar = "https:" + (osSimilar.contains(".CbirSimilar") ? simThumb.attr("style").replaceAll(".*url\\((.*?)\\).*", "$1") : simThumb.attr("src")); //use yandex's thumbnails since the actual source might be broken already //use other sizes if found - second most accurate result (unless theres edits that uses the image in it (memes for example), so still retain similar results) if(yandex.select(".CbirOtherSizes-Wrapper").size() > 0) { //merge the sort algorithms? //sorting with ratio seems to perform bad for most crops so dont sort anymore String otherSize = yandex.select(".Tags-Item").first().attr("href"); return new String[] {similar, otherSize, null}; } //last resort: sort by closest ratio (only first 12 results since typically they deviate from original a ton after 12 results) String[] size = new String[]{yandex.selectFirst(".CbirPreview-Placeholder").attr("width"), yandex.selectFirst(".CbirPreview-Placeholder").attr("height")}; //fixed ratio - works better with normal dimensions (eg anime scenes), sucks at everything else //double ratio = Double.parseDouble(size[0]) / Double.parseDouble(size[1]) >= 1 ? 16.0/9 : 9/16.0; //dynamic ratio - works really well only if the crop has the same ratio as the actual image itself double ratio = Double.parseDouble(size[0]) / Double.parseDouble(size[1]); Element eSite = yandex.select(osItem).stream().limit(12).sorted((a, b) -> { String[] aA = a.select(osMeta).first().text().split("×"), bA = b.select(osMeta).first().text().split("×"); return Double.compare(Math.abs(Double.parseDouble(aA[0]) / Double.parseDouble(aA[1]) - ratio), Math.abs(Double.parseDouble(bA[0]) / Double.parseDouble(bA[1]) - ratio));}).findFirst().get(); return new String[] {similar, eSite.select(osPreview).first().attr("href"), eSite.select(osSnippet).first().attr("href")}; //anime checking usually cant be done with depth search since not every scene is screen capped online } private EmbedBuilder getSauce(String url) throws IOException { if(url == null) return null; EmbedBuilder eb = new EmbedBuilder(); eb.setColor(new Color(29, 29, 29)); try { //search iqdb first for the tags; results usually more organized and formatted Element iqdb = Jsoup.connect("https://iqdb.org/?url=" + URLEncoder.encode(url, "UTF-8") + "&service[]=1&service[]=2&service[]=3&service[]=4&service[]=5&service[]=11&service[]=13").post().body(); //services excluding eshuushuu since it has no tags and will fallback to saucenao anyways //post instead of get due to the recent incident making iqdb and saucenao block imgur if(iqdb.select(".err").size() > 0 && iqdb.select(".err").html().contains("HTTP")) //iqdb errors, most likely broken link so dont fallback throw new IOException(iqdb.selectFirst(".err").ownText().split("\\.")[0]); Elements tb = iqdb.select("th:contains(Best match)").get(0).parent().siblingElements(); Element img = tb.get(0).selectFirst("img"); eb.setThumbnail("https://iqdb.org/" + img.attr("src")); eb.setTitle("Source: " + tb.get(1).selectFirst("td").ownText(), (img.parent().attr("href").contains("http") ? "" : "https:") + img.parent().attr("href")); String tags = img.attr("alt").split("Tags:")[1]; eb.setDescription(tags.contains(",") ? tags.replaceAll(",", "\n") : tags.replaceAll(" ", "\n").replaceAll("\\_", " ")); eb.setFooter(tb.get(2).select("td").eachText().get(0) + " | " + tb.get(3).select("td").text(), null); } catch (IndexOutOfBoundsException | SocketTimeoutException | ConnectException e) { try { //fallback to saucenao, usually pixiv source instead of image boards; also falls back if its anime scenes Element saucenao = Jsoup.connect("https://saucenao.com/search.php").requestBody("url=" + URLEncoder.encode(url, "UTF-8")).post().body(); //same reason as iqdb post Element result = saucenao.selectFirst(".resulttable"); if(result == null) return null; //no results if(result.parent().attr("class").equals("result hidden")) return null; //all low similarity results, ignore eb.setThumbnail((result.selectFirst("img").attr("src").contains("http") ? "" : "https:") + result.selectFirst("img").attr("src").replaceAll(" ", "%20")); try { //normal pixiv/deviantart handling Element source = result.selectFirst("strong:contains(ID:)").nextElementSibling(); eb.setAuthor(result.select(".resulttitle strong").text()); eb.setTitle("Source: " + source.previousElementSibling().text().replaceAll("ID:", "#") + source.text(), source.attr("href")); Element member = result.select(".linkify").get(2); eb.setDescription("Author: [" + member.text() + "](" + member.attr("href") + ")"); } catch (NullPointerException e1) { //weird saucenao card formatting (eg episode info) //change line break tags to \n, and use first line as title result.select("br").after("\\n"); String[] title = result.selectFirst(".resulttitle") == null ? new String[]{"No title"} : result.selectFirst(".resulttitle").wholeText().replaceAll("\\\\n", "\n").split("\n", 2); //there can be no titles, like 4chan sources eb.setTitle(title[0], result.selectFirst(".resultmiscinfo a") == null ? null : result.selectFirst(".resultmiscinfo a").attr("href")); if(title.length > 1) eb.appendDescription(title[1] + "\n"); eb.appendDescription(result.selectFirst(".resultcontentcolumn").wholeText().replaceAll("\\\\n", "\n")); } String similarity = result.select(".resultsimilarityinfo").text(); //additional layer above saucenao's low similarity check: discard lower than 65% similarity if(Double.parseDouble(similarity.substring(0, similarity.length() - 1)) < 65) return null; eb.setFooter(similarity + " similarity", null); } catch (IndexOutOfBoundsException e1) { return null; } } return eb; } } diff --git a/src/me/despawningbone/discordbot/command/anime/Waifu.java b/src/me/despawningbone/discordbot/command/anime/Waifu.java index f308843..9bb0b3a 100644 --- a/src/me/despawningbone/discordbot/command/anime/Waifu.java +++ b/src/me/despawningbone/discordbot/command/anime/Waifu.java @@ -1,182 +1,182 @@ 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.regex.Pattern; 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/husbando!"; //, or leave it blank for a random one!"; this.usage = " [| index]"; this.examples = Arrays.asList("neptune", "ryuZU"); this.alias = Arrays.asList("husbando"); } @Override public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { channel.sendTyping().queue(); String[] stripped = String.join(" ", args).split("\\|"); if(args.length < 1) return new CommandResult(CommandResultType.FAILURE, "Please enter something to search for!"); JSONObject main; try { //get required cookies 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") //advancedsearch is no longer a thing .userAgent("Mozilla/4.0") .header("Cookie", cookie) .header("X-CSRF-Token", csrf) .header("X-Requested-With", "XMLHttpRequest") .header("Accept", "application/json, text/plain, */*") .header("Content-Type", "application/json;charset=utf-8") .followRedirects(true).ignoreContentType(true) .requestBody("{\"query\":\"" + stripped[0].trim() + "\"}").post().text(); //check index int index = 0; JSONArray jarr = new JSONArray(new JSONTokener(res)); try { //System.out.println(arr.length()); if(stripped.length > 1) index = Integer.parseInt(stripped[1].trim()) - 1; if(jarr.length() <= index && index != 0) throw new NumberFormatException(); //if set index is out of bounds } catch (NumberFormatException e) { channel.sendMessage("Invalid index inputted. Defaulting to first result.").queue(); channel.sendTyping().queue(); index = 0; } //sort search results for fetching with specified index Pattern wholeWord = Pattern.compile("(?i).*\\b" + stripped[0].trim() + "\\b.*"); List arr = new ArrayList<>(); for(Object obj : jarr) arr.add((JSONObject) obj); arr = arr.stream().filter(o -> !o.isNull("type") && (o.getString("type").equalsIgnoreCase("waifu") || o.getString("type").equalsIgnoreCase("husbando"))) //filter only characters .sorted((a, b) -> Integer.compare( //combine likes and trash to get popularity - sort by popularity since the search result sucks ass (b.has("likes") ? b.getInt("likes") : 0) + (b.has("trash") ? b.getInt("trash") : 0), (a.has("likes") ? a.getInt("likes") : 0) + (a.has("trash") ? a.getInt("trash") : 0))) .sorted((a, b) -> wholeWord.matcher(a.getString("name")).matches() && !wholeWord.matcher(b.getString("name")).matches() ? -1 : 0) //move whole word matches up only if last one was not matched .collect(Collectors.toList()); //fetch 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"); } catch (IndexOutOfBoundsException e) { //e.printStackTrace(); return new CommandResult(CommandResultType.NORESULT); } catch (IOException e) { if(e.toString().contains("Status=422")) return new CommandResult(CommandResultType.NORESULT); //search scope too broad(?) return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } EmbedBuilder em = new EmbedBuilder(); em.setColor(new Color(44, 62, 80)); em.setTitle((main.getBoolean("husbando") ? "Husbando" : "Waifu") + " info of " + main.getString("name"), "https://mywaifulist.moe/waifu/" + main.getString("slug")); //series JSONObject series = main.getJSONObject("series"); em.setAuthor("Series: " + series.getString("name"), "https://mywaifulist.moe/series/" + series.getString("slug")); if(!main.getJSONObject("series").isNull("display_picture") && !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("\\\\", "")); //series appearance + series description ArrayList appearances = new ArrayList<>(); int totalName = 0; for(Object obj : main.getJSONArray("appearances")) { JSONObject jobj = ((JSONObject) obj); appearances.add(jobj); totalName += jobj.getString("name").length() + jobj.getString("slug").length() + 43; //magic number for giving enough leeway } final int avg = (1024 - totalName) / appearances.size(); //get max description average length em.addField("Appearances", appearances.stream().map(jobj -> "[" + jobj.getString("name") + "](https://mywaifulist.moe/series/" + jobj.getString("slug") + (!jobj.isNull("description") ? (" \"" + jobj.getString("description").substring(0, Math.min(jobj.getString("description").length(), avg)).replaceAll("\"", "”")) //trim desc to max length, replacing double quotes since it will interfere with markdown + (jobj.getString("description").length() > avg ? "..." : "") + "\")" : ")")) //append ... if its not finished (only if desc is non null will desc be printed) .collect(Collectors.joining(", ")), false); //aliases if(!main.isNull("original_name") && !main.getString("original_name").isEmpty()) em.addField("Also known as", main.getString("original_name") + (main.isNull("romaji_name") || main.getString("romaji_name").isEmpty() ? "" : ", " + main.getString("romaji_name")), false); em.addBlankField(false); //optionally existing info if (!main.isNull("origin")) em.addField("Origin", main.getString("origin"), true); if (!main.isNull("height")) em.addField("Height", String.valueOf(main.getDouble("height")), true); if (!main.isNull("weight")) em.addField("Weight", String.valueOf(main.getDouble("weight")), true); if (!main.isNull("bust")) em.addField("Bust", String.valueOf(main.getDouble("bust")), true); if (!main.isNull("hip")) em.addField("Hip", String.valueOf(main.getDouble("hip")), true); if (!main.isNull("waist")) em.addField("Waist", String.valueOf(main.getDouble("waist")), true); if (!main.isNull("blood_type")) em.addField("Blood type", main.getString("blood_type"), true); if (!main.isNull("birthday_day") && main.getInt("birthday_day") != 0 && !main.isNull("birthday_month") && !main.getString("birthday_month").isEmpty()) em.addField("Birthday", main.getString("birthday_month") + " " + String.valueOf(main.getInt("birthday_day")) + (main.isNull("birthday_year") || main.getString("birthday_year").isEmpty() ? "" : ", " + main.getString("birthday_year")), true); //only add blank on 5 fields for formatting if (em.getFields().size() == 5) { em.addBlankField(true); } //tags is back! (for some entries at least) String tags = main.getJSONArray("tags").toList().stream().map(o -> o.toString().replaceAll("\\{name=", "").replaceAll(", id=.*\\}", "")).collect(Collectors.joining(", ")); if(!tags.isEmpty()) em.addField("Tags", tags, false); //popularity stats if(em.getFields().size() > 2) em.addBlankField(false); em.addField("Likes", main.getInt("likes") + " (#" + (main.isNull("like_rank") ? "N/A" : main.getInt("like_rank")) + ")", true); em.addField("Popularity rank", "#" + (main.isNull("popularity_rank") ? "N/A" : main.getInt("popularity_rank")), true); em.addField("Trash", main.getInt("trash") + " (#" + (main.isNull("trash_rank") ? "N/A" : main.getInt("trash_rank")) + ")", true); em.setFooter("Created by " + main.getJSONObject("creator").getString("name") + " | MyWaifuList.moe", null); - channel.sendMessage(em.build()).queue(); + channel.sendMessageEmbeds(em.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); } } diff --git a/src/me/despawningbone/discordbot/command/games/Osu.java b/src/me/despawningbone/discordbot/command/games/Osu.java index a640424..3619d56 100644 --- a/src/me/despawningbone/discordbot/command/games/Osu.java +++ b/src/me/despawningbone/discordbot/command/games/Osu.java @@ -1,1159 +1,1159 @@ package me.despawningbone.discordbot.command.games; import java.awt.Color; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.MalformedURLException; import java.net.URL; import java.net.URLConnection; import java.net.URLEncoder; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.text.DateFormat; import java.text.DecimalFormat; import java.text.SimpleDateFormat; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import javax.xml.bind.DatatypeConverter; import org.apache.commons.lang3.exception.ExceptionUtils; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.json.JSONTokener; import org.knowm.xchart.BitmapEncoder; import org.knowm.xchart.XYChart; import org.knowm.xchart.XYChartBuilder; import org.knowm.xchart.BitmapEncoder.BitmapFormat; import org.knowm.xchart.XYSeries.XYSeriesRenderStyle; import org.knowm.xchart.style.Styler.LegendPosition; import me.despawningbone.discordbot.DiscordBot; import me.despawningbone.discordbot.command.Command; import me.despawningbone.discordbot.command.CommandResult; import me.despawningbone.discordbot.command.CommandResult.CommandResultType; import me.despawningbone.discordbot.command.games.Koohii.Accuracy; import me.despawningbone.discordbot.command.games.Koohii.DiffCalc; import me.despawningbone.discordbot.command.games.Koohii.Map; import me.despawningbone.discordbot.command.games.Koohii.PPv2; import me.despawningbone.discordbot.command.games.Koohii.PlayParameters; import me.despawningbone.discordbot.command.games.Koohii.MapStats; import me.despawningbone.discordbot.command.games.Koohii.TaikoPP; import me.despawningbone.discordbot.utils.MiscUtils; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.User; public class Osu extends Command { private static final String osuAPI = DiscordBot.tokens.getProperty("osu"); private static final DecimalFormat df = new DecimalFormat("#.##"); private static final String[] modes = new String[] {"osu!", "osu!taiko", "osu!catch", "osu!mania"}; public Osu() { //TODO add back the todos to respective sub command, automate desc for subcmds, add back typing; CLOSE ALL STREAMS this.desc = "All the info you need with osu!"; this.usage = ""; //TODO add a command to parse replays? registerSubCommand("pp", Arrays.asList("map"), (channel, user, msg, words) -> { List amend = new ArrayList(Arrays.asList(words)); int wParamIndex = amend.indexOf("-w"); //parse weight param and uid; doesnt work if -w is immediately followed by pp params like 100x etc String uid = null; boolean weight = wParamIndex != -1; if(weight) { try { int wParamLength = 1; uid = amend.get(wParamIndex + 1); if(!uid.contains("osu.ppy.sh/u") && (uid.startsWith("http") && uid.contains("://"))) { //url thats not user url means its most likely a beatmap, aka no username param uid = getPlayer(user); } else { wParamLength = 2; //has username } amend.subList(wParamIndex, wParamIndex + wParamLength).clear(); //remove } catch (IndexOutOfBoundsException e) { uid = getPlayer(user); } } String initmap; try { initmap = amend.get(0); } catch (IndexOutOfBoundsException e) { initmap = "null"; //dud } CompletableFuture msgId = new CompletableFuture<>(); //check if no map input, use discord rich presence if (!initmap.startsWith("https://") && !initmap.startsWith("http://")) { //get map name from status String details = null; try (Connection con = DiscordBot.db.getConnection()) { ResultSet rs = con.createStatement().executeQuery("SELECT game FROM users WHERE id = " + user.getId() + ";"); if(rs.next()) { details = rs.getString(1).substring(rs.getString(1).indexOf("||")); } rs.close(); } catch (SQLException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } channel.sendMessage("Trying to retrieve map from discord status...").queue(m -> msgId.complete(m.getId())); //DONE custom status masks presence now, will not be as effective; any way to get manually? //JDA 4 API change fixed this channel.sendTyping().queue(); //parse map name and search if (details != null) { //TODO if name is sth like `Feryquitous - (S).0ngs//---::compilation.[TQR-f3] [-[//mission:#FC.0011-excindell.defer.abferibus]-]` it breaks (but reasonable break tbh); also breaks if difficulty has " in it try { String title = URLEncoder.encode(details.substring(details.indexOf(" - ") + 3, details.lastIndexOf("[")).trim(), "UTF-8"); String diff = URLEncoder.encode(details.substring(details.lastIndexOf("[") + 1, details.lastIndexOf("]")).trim(), "UTF-8"); String url = "https://osusearch.com/query/?title=" + title + "&diff_name=" + diff + "&query_order=play_count&offset=0"; URLConnection stream = new URL(url).openConnection(); stream.addRequestProperty("User-Agent", "Mozilla/4.0"); JSONTokener tokener = new JSONTokener(stream.getInputStream()); initmap = "https://osu.ppy.sh/beatmaps/" + new JSONObject(tokener).getJSONArray("beatmaps").getJSONObject(0).getInt("beatmap_id"); } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } catch (JSONException e) { e.printStackTrace(); System.out.println(details); return new CommandResult(CommandResultType.NORESULT); //returns if osusearch isnt updated fast enough } } else { return new CommandResult(CommandResultType.FAILURE, "There is no account of your rich presence, therefore I cannot get the beatmap from your status."); } } //if still dud aka no presence nor url if(initmap.equals("null")) { //shouldnt throw at all return new CommandResult(CommandResultType.FAILURE, "You haven't played any maps I can recognize yet!"); } //parse beatmap Map beatmap = null; try { beatmap = new Koohii.Parser() .map(new BufferedReader(new InputStreamReader(getMap(initmap), "UTF-8"))); } catch (IOException e1) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1)); } catch (UnsupportedOperationException e1) { return new CommandResult(CommandResultType.FAILURE, "This gamemode is not yet supported."); } catch (IllegalArgumentException e1) { return new CommandResult(CommandResultType.INVALIDARGS, e1.getMessage()); } if (beatmap.title.isEmpty()) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid beatmap."); } //parse pp params double dacc = 100; int combo = beatmap.max_combo(), mods = 0, miss = 0; try { for (int i = 0; i < amend.size(); i++) { String param = amend.get(i); if (param.startsWith("+")) { mods = Koohii.mods_from_str(param.substring(1).toUpperCase()); //invalid mods just get ignored } else if (param.toLowerCase().endsWith("m")) { miss = Integer.parseInt(param.substring(0, param.length() - 1)); if(miss < 0 || miss > beatmap.objects.size()) return new CommandResult(CommandResultType.INVALIDARGS, "Invalid miss count specified."); } else if (param.endsWith("%")) { dacc = Double.parseDouble(param.substring(0, param.length() - 1)); if(dacc < 0) return new CommandResult(CommandResultType.INVALIDARGS, "Invalid accuracy specified."); } else if (param.toLowerCase().endsWith("x")) { combo = Integer.parseInt(param.substring(0, param.length() - 1)); if(combo < 0 || combo > beatmap.max_combo()) return new CommandResult(CommandResultType.INVALIDARGS, "Invalid combo specified."); } } } catch (NumberFormatException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid params specified."); } //compute pp Accuracy acc = null; JSONObject jbm = null; try { acc = new Accuracy(dacc, miss, beatmap); jbm = computePP(beatmap, initmap.substring(initmap.lastIndexOf("/") + 1).split("&")[0], mods, acc.n50, acc.n100, acc.n300, miss, combo); } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } catch (IllegalArgumentException e) { return new CommandResult(CommandResultType.INVALIDARGS, e.getMessage()); } //build embed EmbedBuilder eb = new EmbedBuilder(); if(jbm.has("source") && !jbm.getString("source").isEmpty()) eb.setTitle("Source: " + jbm.getString("source")); eb.setDescription("Mode: " + modes[beatmap.mode]); eb.setAuthor("PP information for " + (beatmap.title_unicode.isEmpty() ? beatmap.title : beatmap.title_unicode), initmap, "https://b.ppy.sh/thumb/" + jbm.getString("beatmapset_id") + ".jpg"); eb.addField("Artist", (beatmap.artist_unicode.isEmpty() ? beatmap.artist : beatmap.artist_unicode), true); eb.addField("Created by", beatmap.creator, true); String totaldur = MiscUtils.convertMillis(TimeUnit.SECONDS.toMillis((int)(jbm.getInt("total_length") / jbm.getDouble("speed")))); String draindur = MiscUtils.convertMillis(TimeUnit.SECONDS.toMillis((int)(jbm.getInt("hit_length") / jbm.getDouble("speed")))); eb.addField("Duration", totaldur + " | Drain " + draindur, true); eb.addField("Difficulty", beatmap.version, true); eb.addField("Mods", (mods == 0 ? "None" : Koohii.mods_str(mods)), true); eb.addField("BPM", df.format(jbm.getDouble("bpm") * jbm.getDouble("speed")), true); eb.addField("Accuracy", df.format(jbm.getDouble("accVal") * 100) + "%", true); eb.addField("Combo", String.valueOf(combo) + "x", true); eb.addField("Misses", String.valueOf(miss), true); eb.addField("300", String.valueOf(acc.n300), true); eb.addField("100", String.valueOf(acc.n100), true); eb.addField("50", String.valueOf(acc.n50), true); eb.addField("PP", df.format(jbm.getDouble("ppVal")), true); if(weight) { try { JSONObject res = getPlayerData("get_user_best", uid, beatmap.mode, user); if(res.has("result")) { String gain = df.format(weightForPP(jbm.getDouble("ppVal"), jbm.getString("beatmap_id"), res.getJSONArray("result"))); eb.addField("Actual pp gain for " + uid, (gain.equals("-1") ? "User has a better score than this" : gain + "pp"), true); } else { channel.sendMessage("Unknown user `" + res.getString("search") + "` specified for pp checking, ignoring...").queue(); } } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } } eb.setFooter("Stars: " + df.format(jbm.getDouble("starsVal")) + " | CS: " + df.format(jbm.getDouble("cs")) + " | HP: " + df.format(jbm.getDouble("hp")) + " | AR: " + df.format(jbm.getDouble("ar")) + " | OD: " + df.format(jbm.getDouble("od")), null); eb.setColor(new Color(239, 109, 167)); //remove retrieve msg if sent if(msgId.isDone()) { msgId.thenAccept(m -> channel.deleteMessageById(m).queue()); } - channel.sendMessage(eb.build()).queue(); + channel.sendMessageEmbeds(eb.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); }, "[beatmap URL] [+|%|x|m] [-w [username]]", Arrays.asList("-w", "+DT", "https://osu.ppy.sh/b/1817768 93.43% -w", "https://osu.ppy.sh/beatmaps/1154509 +HDDT 96.05% 147x 2m -w despawningbone"), "Check PP information about that beatmap!", Arrays.asList( " * Available parameters: +[mod alias], [accuracy]%, [combo]x, [misses]m", " * specify the `-w` parameter with a username (or none for your own) to check how many actual pp the play will get them!", " * You can also not input the URL, if you are currently playing osu! and discord sensed it.")); registerSubCommand("weight", Arrays.asList("w"), (channel, user, msg, words) -> { List params = new ArrayList<>(Arrays.asList(words)); int modeId = getMode(params); final double precision = 1e-4, iterations = 500; //get user String uid; int index = params.size() < 2 ? 0 : 1; try { uid = params.get(index - 1); if(index == 0 || (!uid.contains("osu.ppy.sh/u") && (uid.startsWith("http") && uid.contains("://")))) { uid = getPlayer(user); } } catch (IndexOutOfBoundsException e) { uid = getPlayer(user); } //get user top JSONArray arr = null; try { JSONObject res = getPlayerData("get_user_best", uid.trim(), modeId, user); if(!res.has("result")) return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`."); arr = res.getJSONArray("result"); } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } double limit = arr.getJSONObject(0).getDouble("pp") + 100; //fricc those who wanna get pp over 100pp more than they can lmao //perform bisection search try { double targetPP = params.size() > index ? Double.parseDouble(params.get(index)) : 1; int i = 0; double minPP = 0, maxPP = limit, mid = 0; while(i < iterations && Math.abs(maxPP - minPP) > precision) { mid = (maxPP + minPP) / 2; double temp = weightForPP(mid, null, arr); if(temp > targetPP) { maxPP = mid; } else { minPP = mid; } i++; } if(!df.format(mid).equals(df.format(limit))) { channel.sendMessage("For " + uid + " to actually gain " + df.format(targetPP) + "pp in " + modes[modeId] + ", they have to play a map worth approximately **" + df.format(mid) + "pp** raw.").queue(); return new CommandResult(CommandResultType.SUCCESS); } else { return new CommandResult(CommandResultType.INVALIDARGS, "You can't really achieve such large pp jumps without people thinking you are hacking :P"); } } catch (NumberFormatException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid target pp specified."); } }, "[username] [wished pp gain]", Arrays.asList("", "10", "despawningbone 20"), "Estimate how much raw pp a map needs to have in order to gain you pp!", Arrays.asList(" * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard).")); //DONE NEED EXTREME TIDYING UP //DONE merge parts of the code with getRecent() and getPP(); registerSubCommand("recent", Arrays.asList("r"), (channel, user, msg, words) -> { List params = new ArrayList<>(Arrays.asList(words)); int modeId = getMode(params); if(modeId > 1) modeId = 0; //ignore unsupported modes and default to standard boolean passOnly = params.removeAll(Collections.singleton("-p")); //no need space, as spaced ones are split (if a player has a name with spaced -p it might be a problem) //parse params String[] split = String.join(" ", params).split("\\|"); String search = split[0].trim(); int nrecent = 0; try { nrecent = Integer.parseInt(split[1].trim()) - 1; } catch (NumberFormatException e) { //print to channel? } catch (ArrayIndexOutOfBoundsException e) { ; //nothing special about this } if(nrecent > 50) { return new CommandResult(CommandResultType.FAILURE, "Only the 50 most recent plays can be recalled!"); } //fetch recent plays JSONArray array; String name; try { JSONObject res = getPlayerData("get_user_recent", search, modeId, user); if(!res.has("result")) return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "` or the player has not been playing in the last 24h."); array = res.getJSONArray("result"); if(array.length() == 0) return new CommandResult(CommandResultType.FAILURE, "You have no recent plays in this 24h!"); //set name according to supported formats name = res.getBoolean("isId") ? //isId might return true on cases inputted as https://osu.ppy.sh/users/despawningbone for example, which would make the fetching redundant but still works getPlayerData("get_user", search, 0, user).getJSONArray("result").getJSONObject(0).getString("username"): res.getString("search"); } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } //get which play to return JSONObject mostRecent = null; if(passOnly) { int times = 0; for(int i = 0; i < array.length(); i++) { if(!array.getJSONObject(i).getString("rank").equals("F")) { times++; if(times - 1 >= nrecent) { mostRecent = array.getJSONObject(i); nrecent = i; break; } } } if(mostRecent == null) { return new CommandResult(CommandResultType.INVALIDARGS, "You did not have this much passed plays in the recent 50 plays!"); } } else { try { mostRecent = array.getJSONObject(nrecent); } catch (JSONException e) { return new CommandResult(CommandResultType.FAILURE, "You only played " + array.length() + " times recently!"); } } String bid = mostRecent.getString("beatmap_id"); //get consecutive tries int i; for(i = nrecent + 1; i < array.length(); i++) { if(!array.getJSONObject(i).getString("beatmap_id").equals(bid)) { break; } } //if we reached the end of the list, there might be more that we missed since we can only fetch 50 recent plays String more = ""; if(i == array.length()) { more = " (or more)"; } //parse map of the recent play and compute pp int n50 = mostRecent.getInt("count50"), n100 = mostRecent.getInt("count100"), n300 = mostRecent.getInt("count300"), miss = mostRecent.getInt("countmiss"), combo = mostRecent.getInt("maxcombo"), mods = mostRecent.getInt("enabled_mods"); Map beatmap = null; JSONObject jbm = null; try { beatmap = new Koohii.Parser() .map(new BufferedReader(new InputStreamReader(getMap(bid), "UTF-8"))); jbm = computePP(beatmap, bid, mods, n50, n100, n300, miss, combo); } catch (IOException e1) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1)); } catch (UnsupportedOperationException e1) { return new CommandResult(CommandResultType.FAILURE, "This gamemode is not yet supported."); } catch (IllegalArgumentException e1) { return new CommandResult(CommandResultType.INVALIDARGS, e1.getMessage()); } //build embed String modStr = Koohii.mods_str(mods); EmbedBuilder eb = new EmbedBuilder(); eb.setColor(new Color(0, 0, 0)); eb.setTitle((beatmap.artist_unicode.isEmpty() ? beatmap.artist : beatmap.artist_unicode) + " - " + (beatmap.title_unicode.isEmpty() ? beatmap.title : beatmap.title_unicode) + " [" + beatmap.version + "]" + (modStr.isEmpty() ? "" : " +" + modStr) + " (" + df.format(jbm.getDouble("starsVal")) + "*)" , "https://osu.ppy.sh/beatmaps/" + bid); eb.setAuthor(MiscUtils.ordinal(nrecent + 1) + " most recent " + modes[modeId] + " play by " + name, "https://osu.ppy.sh/users/" + mostRecent.getString("user_id"), "https://a.ppy.sh/" + mostRecent.getString("user_id")); eb.setDescription(MiscUtils.ordinal(i - nrecent) + more + " consecutive try\n" + "Ranking: " + mostRecent.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + (mostRecent.getString("rank").equals("F") ? " (Estimated completion percentage: " + df.format((n50 + n100 + n300 + miss) * 100.0 / beatmap.objects.size()) + "%)" : "")); eb.setThumbnail("https://b.ppy.sh/thumb/" + jbm.getString("beatmapset_id") + ".jpg"); eb.addField("Score", String.valueOf(mostRecent.getInt("score")), true); eb.addField("Accuracy", df.format(jbm.getDouble("accVal") * 100) + " (" + n300 + "/" + n100 + "/" + n50 + "/" + miss + ")", true); eb.addField("Combo", (mostRecent.getInt("perfect") == 0 ? combo + "x / " + beatmap.max_combo() + "x" : "FC (" + combo + "x)"), true); eb.addField("PP", df.format(jbm.getDouble("ppVal")) + "pp / " + df.format(jbm.getDouble("ppMax")) + "pp", true); eb.setFooter("Score submitted", null); eb.setTimestamp(OffsetDateTime.parse(mostRecent.getString("date").replace(" ", "T") + "+00:00")); - channel.sendMessage(eb.build()).queue(); + channel.sendMessageEmbeds(eb.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); }, "[-p] [username] [| index]", Arrays.asList("", "-p | 2", "-p despawningbone"), "Check a user's recent play!", Arrays.asList( " * Specifying the `-p` parameter will only search for plays that did not fail.", " * Supports `-t` for taiko (Defaults to standard).")); //DONE tidy up first part, merge with getTopPlays()? registerSubCommand("ppchart", Arrays.asList("ppc"), (channel, user, msg, words) -> { if(words.length < 30) { List params = new ArrayList<>(Arrays.asList(words)); int modeId = getMode(params); if(params.size() < 1) { params.add(getPlayer(user)); } //get all usernames String[] split = String.join(" ", params).split("\\|"); String concName = String.join(" | ", split); //init chart XYChart chart = new XYChartBuilder().width(800).height(600).title("Top PP plays for " + concName + " (" + modes[modeId] + ")").yAxisTitle("PP").xAxisTitle("Plays (100 = top)").build(); chart.getStyler().setDefaultSeriesRenderStyle(XYSeriesRenderStyle.Scatter); chart.getStyler().setLegendPosition(LegendPosition.InsideSE); chart.getStyler().setPlotContentSize(0.91); chart.getStyler().setMarkerSize(7); chart.getStyler().setChartTitlePadding(12); chart.getStyler().setChartPadding(12); chart.getStyler().setChartBackgroundColor(new Color(200, 220, 230)); //plot each user's top plays onto chart for(String name : split) { List pps = new ArrayList<>(); try { JSONObject res = getPlayerData("get_user_best", name.trim(), modeId, user); if(!res.has("result")) return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`."); JSONArray array = res.getJSONArray("result"); for(int i = 0; i < array.length(); i++) { JSONObject obj = array.getJSONObject(i); pps.add(obj.getDouble("pp")); } } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } Collections.reverse(pps); //so it orders from low to high left to right chart.addSeries(name.trim() + "'s PP", pps); } //write to png ByteArrayOutputStream os = new ByteArrayOutputStream(); try { BitmapEncoder.saveBitmap(chart, os, BitmapFormat.PNG); } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } channel.sendFile(new ByteArrayInputStream(os.toByteArray()), "ppchart.png").queue(); 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).")); //TODO reaction based paging? registerSubCommand("set", Arrays.asList("s"), (channel, user, msg, words) -> { int page = 1; if (words.length < 1) { return new CommandResult(CommandResultType.INVALIDARGS, "Please enter search words or an URL."); } String id = "", set = ""; try { new URL(words[0]); } catch (MalformedURLException e) { //if not url, search try { String[] split = String.join(" ", words).split("\\|"); String search = URLEncoder.encode(split[0].trim(), "UTF-8"); try { if(split.length > 1) page = Integer.parseInt(split[1].trim()); } catch (NumberFormatException e1) { return new CommandResult(CommandResultType.INVALIDARGS, "Please enter a valid page number."); } String url = "https://osusearch.com/query/?title=" + search + "&query_order=play_count&offset=0"; URLConnection stream = new URL(url).openConnection(); stream.addRequestProperty("User-Agent", "Mozilla/4.0"); JSONTokener tokener = new JSONTokener(stream.getInputStream()); id = String.valueOf(new JSONObject(tokener).getJSONArray("beatmaps").getJSONObject(0).getInt("beatmapset_id")); set = "https://osu.ppy.sh/beatmapset/" + id; } catch (IOException e1) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e1)); } catch (JSONException e1) { return new CommandResult(CommandResultType.NORESULT); } } //empty id means words[0] was a valid url - accept and parse if(id.equals("")) { set = words[0]; id = set.substring(set.lastIndexOf("/") + 1); if (set.contains("/beatmapsets/")) { id = set.substring(set.lastIndexOf("/beatmapsets/")); id = id.substring(id.lastIndexOf("/") + 1); } else { //old urls if (set.contains("/b/") || set.contains("#")) { return new CommandResult(CommandResultType.INVALIDARGS, "This is a specific beatmap, not a beatmap set."); } id = id.split("&")[0]; } } //get beatmap set info InputStream stream = null; try { stream = new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&s=" + id).openStream(); } catch (IOException ex) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex)); } JSONArray arr = new JSONArray(new JSONTokener(stream)); //convert it to a list of object for streaming List obj = new ArrayList(); for (int i = 0; i < arr.length(); i++) { obj.add(arr.getJSONObject(i)); } //group by mode sorted by difficulty and flatten back to a list List fobj = obj.stream().sorted(Comparator.comparing(e -> e.getDouble("difficultyrating"))) .collect(Collectors.groupingBy(e -> e.getInt("mode"))).values().stream().flatMap(List::stream) .collect(Collectors.toList()); EmbedBuilder em = new EmbedBuilder(); JSONObject fo; //use first object's metadata for universal info like author try { fo = fobj.get(0); } catch (IndexOutOfBoundsException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid beatmap URL."); } if(page > Math.ceil((double) fobj.size() / 3)) { //each page is 3 entries return new CommandResult(CommandResultType.INVALIDARGS, "The beatmap set does not have this many difficulties!"); } //build embed page heading em.setAuthor("Beatmap set information of " + fo.getString("title"), set, "https://b.ppy.sh/thumb/" + id + ".jpg"); em.setDescription("Author: " + fo.getString("artist")); em.addField("BPM", fo.getString("bpm"), true); em.addField("Creator", fo.getString("creator"), true); em.addField("Approved/Ranked", (fo.getInt("approved") >= 1 ? "Yes" : "No"), true); em.addBlankField(false); //build each entry int n = ((page - 1) * 3); for (int i = n; (n + 3 <= fobj.size() ? i < n + 3 : i < fobj.size()); i++) { JSONObject json = fobj.get(i); em.addField("Difficulty", "[" + json.getString("version") + "](https://osu.ppy.sh/b/" + json.getString("beatmap_id") + ")" + " ([Preview](https://jmir.xyz/osu/#" + json.getString("beatmap_id") + "))", false); //bloodcat is no longer a thing but someone hosted a copy for previewing here em.addField("Max combo", json.get("max_combo").toString().replaceAll("null", "N/A"), true); em.addField("Stars", df.format(json.getDouble("difficultyrating")), true); em.addField("Mode", modes[json.getInt("mode")], true); } //build footer and send em.setFooter("Page: " + String.valueOf(page) + "/" + String.valueOf((int) Math.ceil((double) fobj.size() / 3)), null); - channel.sendMessage(em.build()).queue(); + channel.sendMessageEmbeds(em.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); }, " [| page]\n", Arrays.asList("big black", "high free spirits | 2"), "Check info about a beatmap set!", Arrays.asList( " * Useful to get the specific difficulty URL for !desp osu pp.")); //TODO add userset for setting username to db so getPlayer wont be wrong? registerSubCommand("user", Arrays.asList("u"), (channel, user, msg, words) -> { List params = new ArrayList<>(Arrays.asList(words)); int modeId = getMode(params); String search = String.join(" ", params); //fetch user data JSONObject usr; try { JSONObject res = getPlayerData("get_user", search, modeId, user); if(!res.has("result")) return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`."); usr = res.getJSONArray("result").getJSONObject(0); } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } String id = usr.getString("user_id"); //no matter if its url or not user_id is still gonna be the most accurate one //build embed EmbedBuilder em = new EmbedBuilder(); em.setAuthor("User info of " + usr.getString("username") + " (" + modes[modeId] + ")", "https://osu.ppy.sh/users/" + id); //check if pfp exists, if not fallback to blank String img = "https://a.ppy.sh/" + id; try { new URL(img).openStream(); } catch (IOException e) { img = "https://s.ppy.sh/images/blank.jpg"; } em.setThumbnail(img); //if user doesnt have pp, there wont be any data pretty much - also might be a result of inactivity (?) if(usr.isNull("pp_raw")) return new CommandResult(CommandResultType.FAILURE, "This user has not played any map in " + modes[modeId] + " before."); em.appendDescription("Joined " + usr.getString("join_date")); em.addField("PP", usr.getDouble("pp_raw") == 0 ? "No Recent Plays" : usr.getString("pp_raw"), true); em.addField("Accuracy", df.format(usr.getDouble("accuracy")), true); em.addField("Rank", (usr.getInt("pp_rank") == 0 ? "N/A" : "#" + usr.getString("pp_rank")) + " | " + MiscUtils.countryNameToUnicode(usr.getString("country")) + " #" + usr.getString("pp_country_rank"), true); em.addField("Play count", usr.getString("playcount") + " (" + df.format(Long.parseLong(usr.getString("total_seconds_played")) / 3600.0) + "h)", true); em.addField("Total score", usr.getString("total_score"), true); em.addField("Ranked score", usr.getString("ranked_score") + " (" + df.format((usr.getDouble("ranked_score") / usr.getDouble("total_score")) * 100) + "%)", true); em.addField("Level", df.format(usr.getDouble("level")), true); em.addField("S+", usr.getString("count_rank_sh"), true); em.addField("SS+", usr.getString("count_rank_ssh"), true); em.addField("A", usr.getString("count_rank_a"), true); em.addField("S", usr.getString("count_rank_s"), true); em.addField("SS", usr.getString("count_rank_ss"), true); em.setColor(new Color(66, 134, 244)); em.setFooter("300: " + usr.getString("count300") + " | 100: " + usr.getString("count100") + " | 50: " + usr.getString("count50"), null); //TODO display percentage? - channel.sendMessage(em.build()).queue(); + channel.sendMessageEmbeds(em.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); }, "[gamemode] [user URL|keyword]", Arrays.asList("", "despawningbone", "-t despawningbone"), "Check info about a user!", Arrays.asList( " * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard).")); //DONE api demanding; need to add cooldown //nvm i changed it to use XHR now //parts mergeable with getRecent() registerSubCommand("topplays", Arrays.asList("top", "t"), (channel, user, msg, words) -> { List params = new ArrayList<>(Arrays.asList(words)); int modeId = getMode(params), page; //get page number String[] split = String.join(" ", params).split("\\|"); String search = split[0]; try { page = Integer.parseInt(split[1].trim()); if(page > 10) { return new CommandResult(CommandResultType.INVALIDARGS, "You can only request your top 100 plays!"); } } catch (NumberFormatException e) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid page number!"); } catch (ArrayIndexOutOfBoundsException e) { page = 1; //its normal for people to not input index } //get user id then fetch top plays from XHR req JSONArray array; String name, id; try { JSONObject res = getPlayerData("get_user", search, modeId, user); if(!res.has("result")) return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + res.getString("search") + "`."); JSONObject usr = res.getJSONArray("result").getJSONObject(0); name = usr.getString("username"); id = usr.getString("user_id"); //top plays internal api use different names for modes than listed String[] m = new String[]{"osu", "taiko", "fruits", "mania"}; InputStream stream = new URL("https://osu.ppy.sh/users/" + id + "/scores/best" + "?mode=" + m[modeId] + "&offset=" + (page - 1) * 10 + "&limit=10").openStream(); if (stream.available() < 4) { return new CommandResult(CommandResultType.FAILURE, "This player does not have this many plays in this gamemode!"); } array = new JSONArray(new JSONTokener(stream)); } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } //build embed EmbedBuilder eb = new EmbedBuilder(); eb.setAuthor(name + "'s top plays (" + modes[modeId] + ")", "https://osu.ppy.sh/users/" + id, "https://a.ppy.sh/" + id); DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); for(int i = 0; i < array.length(); i++) { JSONObject obj = array.getJSONObject(i); String mods = obj.getJSONArray("mods").join("").replaceAll("\"", ""); JSONObject stats = obj.getJSONObject("statistics"); JSONObject bInfo = obj.getJSONObject("beatmap"); JSONObject bsInfo = obj.getJSONObject("beatmapset"); int bId = bInfo.getInt("id"); String info = "**" + obj.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + "**" + (!mods.isEmpty() ? " **" + mods + "** " : " ") + df.format(bInfo.getDouble("difficulty_rating")) + "*" + " ([map link](https://osu.ppy.sh/beatmaps/" + bId + "))" + "\nScore: " + obj.getInt("score") + "\n**" + (obj.getDouble("pp") + "pp** | Weighted " + df.format(obj.getJSONObject("weight").getDouble("pp")) + "pp (" + df.format(obj.getJSONObject("weight").getDouble("percentage")) + "%)" + "\n" + df.format(obj.getDouble("accuracy") * 100) + "% " + (obj.getBoolean("perfect") ? "FC " + obj.getInt("max_combo") + "x" : obj.getInt("max_combo") + "x") + " (" + stats.getInt("count_300") + "/" + stats.getInt("count_100") + "/" + stats.getInt("count_50") + "/" + stats.getInt("count_miss") + ")") + "\nPlayed on " + format.format(DatatypeConverter.parseDateTime(obj.getString("created_at")).getTime()); eb.addField(((page - 1) * 10 + (i + 1)) + ". " + bsInfo.getString("artist") + " - " + bsInfo.getString("title") + " [" + bInfo.getString("version") + "]", info, false); //no unicode unfortunately*/ } eb.setFooter("Page: " + page, null); eb.setColor(new Color(5, 255, 162)); - channel.sendMessage(eb.build()).queue(); + channel.sendMessageEmbeds(eb.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); }, "[username] [|page]", Arrays.asList("", "| 2", "despawningbone", "-m despawningbone"), "Get the top plays of a user!", Arrays.asList( " * Supports `-t` for taiko, `-c` for catch, and `-m` for mania (Defaults to standard).")); registerSubCommand("compare", Arrays.asList("c", "comp"), (channel, user, msg, words) -> { String map = "", name = "", img = "", mode = ""; //get which card to compare with, starting from newest to oldest int index = 1; String[] param = String.join(" ", words).split("\\|"); try { if(param.length > 1) index = Integer.parseInt(param[1].trim()); } catch(NumberFormatException e) { channel.sendMessage("Invalid index specified. Defaulting to the most recent scorecard...").queue(); } //iterate through recent 100 messsages for(Message card : channel.getHistory().retrievePast(100).complete()) { List embeds = card.getEmbeds(); if(embeds.size() > 0) { MessageEmbed embed = embeds.get(0); if(embed.getAuthor() == null) continue; String author = embed.getAuthor().getName(); //our own cards if(card.getAuthor().getId().equals(DiscordBot.BotID)) { //recent if(author.contains("most recent osu!")) { map = embed.getUrl(); name = embed.getTitle().lastIndexOf("+") == -1 ? embed.getTitle().substring(0, embed.getTitle().lastIndexOf("(")) : embed.getTitle().substring(0, embed.getTitle().lastIndexOf("+")); img = embed.getThumbnail().getUrl(); mode = author.split("most recent osu!")[1].split(" play")[0]; //pp } else if(author.startsWith("PP information for")) { map = embed.getAuthor().getUrl(); name = author.split("PP information for ")[1] + " [" +embed.getFields().get(3).getValue() + "]"; img = embed.getAuthor().getIconUrl(); mode = embed.getDescription().replace("Mode: osu!", ""); } //owobot support } else if(card.getAuthor().getId().equals("289066747443675143")) { //new score tracker if(author.startsWith("New") && author.contains("in osu!")) { String markdown = embed.getDescription().substring(7, embed.getDescription().indexOf("\n")).trim(); map = markdown.substring(markdown.lastIndexOf("(") + 1, markdown.length() - 2); name = markdown.substring(0, markdown.indexOf("__**]")); img = embed.getThumbnail().getUrl(); mode = author.substring(author.lastIndexOf("in osu! "), author.length()).trim(); //>rs } else if(card.getContentDisplay().contains("Recent ")) { map = embed.getAuthor().getUrl(); name = author.substring(0, author.lastIndexOf("+")); img = embed.getThumbnail().getUrl(); mode = card.getContentDisplay().contains("Mania") ? "mania" : card.getContentDisplay().split("Recent ")[1].split(" ")[0]; //ye fucking blame owobot for being so inconsistent //map url response } else if(card.getContentDisplay().contains("map(s).")) { map = embed.getAuthor().getUrl(); map = map.substring(0, map.length() - (map.endsWith("/") ? 1 : 0)); //fucking hell the url actually changes according to how ppl trigger it name = author.split(" – ")[1]; String[] split = embed.getFields().get(0).getName().split("__", 3); name = name.substring(0, name.lastIndexOf(" by ")) + " [" + split[1] + "]"; img = "https://b.ppy.sh/thumb/" + embed.getDescription().split("\\[map\\]\\(https:\\/\\/osu\\.ppy\\.sh\\/d\\/", 2)[1].split("\\)", 2)[0] + ".jpg"; mode = split[2].isEmpty() ? "std" : split[2].substring(2, split[2].lastIndexOf("]") + 1); } } //if card hit check index and decrement + reset if neded if(index > 1 && !map.isEmpty()) { index--; map = ""; } //short circuit if found if(!map.isEmpty()) break; } } //yet another set of mode names so we have to parse int modeId = 0; if(mode.equalsIgnoreCase("taiko")) modeId = 1; else if(mode.equalsIgnoreCase("catch") || mode.equals("ctb")) modeId = 2; else if(mode.equalsIgnoreCase("mania")) modeId = 3; //if still doesnt exist after iteration, fail if(map.isEmpty()) return new CommandResult(CommandResultType.FAILURE, "There are no recent map/score cards to compare with in the past 100 messages!"); //if name or id is provided in the command use it else fallback to requester String uid = param[0].isEmpty() ? getPlayer(user) : param[0].replaceAll(" ", "%20"); //get scores to compare try { URL url = new URL("https://osu.ppy.sh/api/get_scores?k=" + osuAPI + "&b=" + map.substring(map.lastIndexOf("/") + 1) + "&u=" + uid + "&m=" + modeId); JSONArray arr = new JSONArray(new JSONTokener(url.openStream())); //build embed EmbedBuilder eb = new EmbedBuilder(); eb.setTitle("Top plays for " + arr.getJSONObject(0).getString("username") + " on " + name); eb.setColor(new Color(237, 154, 70)); eb.setThumbnail(img); //iterate through existing scores for this beatmap and add it to the embed for(int i = 0; i < arr.length(); i++) { JSONObject score = arr.getJSONObject(i); String mods = Koohii.mods_str(score.getInt("enabled_mods")); Accuracy acc = new Accuracy(score.getInt("count300"), score.getInt("count100"), score.getInt("count50"), score.getInt("countmiss")); String info = "**`" + (mods.isEmpty() ? "No mods" : mods) + "` Score:\n" + score.getString("rank").replaceAll("H", "+").replaceAll("X", "SS") + " " + (score.isNull("pp") ? "No " : score.getDouble("pp")) + "pp** | Score **" + score.getInt("score") + "**\n" + df.format(acc.value(modeId) * 100) + "% " + (score.getInt("perfect") == 1 ? "FC " + score.getInt("maxcombo") + "x" : score.getInt("maxcombo") + "x") + " (" + acc.n300 + "/" + acc.n100 + "/" + acc.n50 + "/" + acc.nmisses + ")\n" + "Played on " + score.getString("date") + (score.getInt("replay_available") == 1 ? " (Replay available)" : ""); eb.appendDescription(info + "\n\n"); } - channel.sendMessage(eb.build()).queue(); + channel.sendMessageEmbeds(eb.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); } catch (IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } catch (JSONException e) { e.printStackTrace(); return new CommandResult(CommandResultType.INVALIDARGS, "Unknown player `" + uid + "` or the player has no scores on this map."); } }, "[username] [| index]", null, "Compare your score with the most recent map/score card in the channel!", Arrays.asList(" * Specify the index to skip to the nth recent score card in the channel.")); } private static double weightForPP(double pp, String ibid, JSONArray userBest) { double net = 0, change = -1; List pps = new ArrayList<>(); //get new top pp list, replacing old score if needed for(int i = 0; i < userBest.length(); i++) { //something like a treemap might work more elegantly, but i need to add pp to the list anyways so its fine i guess JSONObject obj = userBest.getJSONObject(i); double v = obj.getDouble("pp"); String bid = obj.getString("beatmap_id"); if(bid.equals(ibid)) { if(v >= pp) { return -1; } else { change = v; } } if(v <= pp && !pps.contains(pp)) { //insert on first smaller occurence pps.add(pp); } pps.add(v); } //doesnt even get on top list - no pp gain if(pps.indexOf(pp) == -1) { return 0; } //calculate net weighted gain if(change == -1) { double last = pps.size() > 100 ? pps.remove(100) * (Math.pow(0.95, 99)): 0; for(int i = pps.indexOf(pp); i < pps.size(); i++) { double c = pps.get(i); double w = c*(Math.pow(0.95, i)); if(i == pps.indexOf(pp)) { net += w; } else { net += c*(Math.pow(0.95, i - 1)) * (0.95 - 1); } } net -= last; //because the last one is completely not counted, not just shifted to a lower value } else { int old = pps.indexOf(change) - 1; pps.remove(change); for(int i = pps.indexOf(pp); i <= old; i++) { double c = pps.get(i); double w = c*(Math.pow(0.95, i)); if(c == pp) { net += w - change*(Math.pow(0.95, old)); } else { net += w * (0.95 - 1); } } } return net; } //note: stripped ids are considered usernames - if you are sure that its an id (eg result from api) then wrap it in an url private static JSONObject getPlayerData(String api, String input, int modeId, User requester) throws IOException { //check if user url provided, if not assume username String id = ""; boolean noturl = false; try { new URL(input); id = input.substring(input.lastIndexOf("/") + 1); } catch (MalformedURLException e) { noturl = true; id = input; } //if empty fallback to own if(id.isEmpty()) { id = getPlayer(requester); } //set params and fetch String addparam = ""; if (noturl) { //search as usernames addparam = "&type=string"; } if (modeId != 0) { //change mode addparam += ("&m=" + modeId); } //limit either gets ignored or truncated to highest - is fine if i leave it as 100 (highest of any apis i use) JSONArray a = new JSONArray(new JSONTokener(new URL("https://osu.ppy.sh/api/" + api + "?k=" + osuAPI + "&u=" + URLEncoder.encode(id, "UTF-8") + addparam + "&limit=100").openStream())); //always return the val we did the search with, and only result if it exists JSONObject wrap = new JSONObject("{\"search\": \"" + id + "\", \"isId\": " + !noturl + "}"); if (a.length() > 0) { wrap.put("result", a); } return wrap; } //TODO find a way to parallelize map fetching and meta fetching if possible? private static JSONObject computePP(Map beatmap, String bid, int mods, int n50, int n100, int n300, int miss, int combo) throws IllegalArgumentException, IOException { //set osu-wide beatmap params (non mode specific) PlayParameters p = new Koohii.PlayParameters(); p.beatmap = beatmap; p.n50 = n50; p.n100 = n100; p.n300 = n300; p.nmiss = miss; p.nobjects = p.n300 + p.n100 + p.n50 + p.nmiss; p.mods = mods; if (combo > 0) { p.combo = combo; } else { p.combo = p.beatmap.max_combo(); } //get beatmap metadata String addparam = ""; if(p.beatmap.mode == 1) { //these mods alter metadata that taiko needs if((mods & Koohii.MODS_HT) != 0) addparam = "&mods=256"; if((mods & (Koohii.MODS_DT | Koohii.MODS_NC)) != 0) if(addparam.isEmpty()) addparam = "&mods=64"; else addparam = ""; } URL url = new URL("https://osu.ppy.sh/api/get_beatmaps?k=" + osuAPI + "&b=" + bid + addparam); JSONObject jbm = new JSONArray(new JSONTokener(url.openStream())).getJSONObject(0); //finally calc pp and put it in metadata MapStats stats = new MapStats(p.beatmap); switch(p.beatmap.mode) { case 0: //osu //std specific params - calc diff and acc DiffCalc stars = new Koohii.DiffCalc().calc(p.beatmap, mods); p.speed_stars = stars.speed; p.aim_stars = stars.aim; PPv2 oPP = new PPv2(p); jbm.put("ppVal", oPP.total); jbm.put("accVal", oPP.computed_accuracy.value(0)); //use computed jbm.put("starsVal", stars.total); jbm.put("ppMax", constructPP(stars.aim, stars.speed, p.beatmap, mods).total); Koohii.mods_apply(mods, stats, 1|2|4|8); break; case 1: //taiko //taiko specific params - get diff from metadata since theres no calculating mechanism p.speed_stars = jbm.getDouble("difficultyrating"); TaikoPP tPP = new TaikoPP(p); jbm.put("ppVal", tPP.pp); jbm.put("accVal", tPP.acc); jbm.put("starsVal", p.speed_stars); jbm.put("ppMax", new TaikoPP(p.speed_stars, p.max_combo, mods, 1, 0, p.beatmap).pp); tPP.taiko_mods_apply(mods, stats); break; default: throw new UnsupportedOperationException("This gamemode is not yet supported."); } //mods_apply updates the values, so we cant use beatmap.cs etc and we gotta give back via jbm jbm.put("cs", stats.cs); jbm.put("hp", stats.hp); jbm.put("ar", stats.ar); jbm.put("od", stats.od); jbm.put("speed", stats.speed); return jbm; } private static PPv2 constructPP(double aim_stars, double speed_stars, Map b, int mods) { PlayParameters p = new PlayParameters(); p.aim_stars = aim_stars; p.speed_stars = speed_stars; p.beatmap = b; p.nobjects = b.objects.size(); p.mods = mods; return new PPv2(p); } private static String getPlayer(User user) { //say im getting from presence/name? try (Connection con = DiscordBot.db.getConnection()){ ResultSet rs = con.createStatement().executeQuery("SELECT game FROM users WHERE id = " + user.getId() + ";"); String player = rs.next() ? player = rs.getString(1).substring(0, rs.getString(1).indexOf("||")) : user.getName(); rs.close(); return player; } catch (SQLException e) { return user.getName(); } } private static int getMode(List params) { int modeId = 0; params.replaceAll(String::toLowerCase); //all searching should be case insensitive anyways, is fine if we change the entire thing //pass by "ref" so it removes from the list; //iterates over all mode params and remove, giving priority according to descending id and defaulting to standard (0) if(params.removeAll(Collections.singleton("-t"))) modeId = 1; if(params.removeAll(Collections.singleton("-c"))) modeId = 2; if(params.removeAll(Collections.singleton("-m"))) modeId = 3; return modeId; } private static InputStream getMap(String origURL) throws IOException { //get beatmap id String id = origURL.substring(origURL.lastIndexOf("/") + 1); if (origURL.contains("/beatmapsets/")) { //new urls with this format must include # to specify beatmap, or else its a set if (!origURL.contains("#")) throw new IllegalArgumentException("Please specify a difficulty, instead of giving a set URL."); } else { //either /beatmap/ or old url /b/ /s/, latter of which we dont want if (origURL.contains("/s/")) throw new IllegalArgumentException("Please specify a difficulty, instead of giving a set URL."); id = id.split("&")[0]; //remove things like &m=0, which is no need } //fetch map URLConnection mapURL; try { mapURL = new URL("https://osu.ppy.sh/osu/" + id).openConnection(); //no longer has any IOExceptions, always return 200 no matter what mapURL.setRequestProperty("User-Agent", "Mozilla/4.0"); InputStream stream = mapURL.getInputStream(); if (stream.available() < 4) throw new IOException(); //to fallback return stream; } catch (IOException e) { //try fallback regardless of exception reason //fallback to bloodcat successor - much more convoluted and slower to fetch a single map now //get map info mapURL = new URL("https://api.chimu.moe/v1/map/" + id).openConnection(); mapURL.setRequestProperty("User-Agent", "Mozilla/4.0"); JSONObject mapData = new JSONObject(new JSONTokener(mapURL.getInputStream())).getJSONObject("data"); if(mapData.length() == 0) throw new IOException(e); //invalid beatmap String mapName = mapData.getString("OsuFile").replaceAll("[\\\\/:*?\"<>|]", ""); //apparently the name returned isnt windows safe, so sanitize //fetch beatmapset mapURL = new URL("https://chimu.moe" + mapData.getString("DownloadPath")).openConnection(); mapURL.setRequestProperty("User-Agent", "Mozilla/4.0"); ZipInputStream set = new ZipInputStream(mapURL.getInputStream()); //iterate to get correct map ZipEntry map = null; while((map = set.getNextEntry()) != null) //assume always have the file specified in map info if(map.getName().equals(mapName)) return set; //found at input stream current pointer throw new IOException(e); //not catching this, if even this doesnt work just throw something went wrong. } } } diff --git a/src/me/despawningbone/discordbot/command/info/CityInfo.java b/src/me/despawningbone/discordbot/command/info/CityInfo.java index 2826901..fac821b 100644 --- a/src/me/despawningbone/discordbot/command/info/CityInfo.java +++ b/src/me/despawningbone/discordbot/command/info/CityInfo.java @@ -1,140 +1,140 @@ package me.despawningbone.discordbot.command.info; import java.awt.Color; import java.io.IOException; import java.net.URL; import java.net.URLConnection; import java.net.URLEncoder; import java.text.DecimalFormat; import java.text.NumberFormat; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.Arrays; import java.util.Date; import java.util.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"); @Override public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { if(args.length < 1) { return new CommandResult(CommandResultType.INVALIDARGS, "Please input a city name."); //or a address."); } else { channel.sendTyping().queue(); String sword = String.join(" ", args); try { boolean hasWeather = true; JSONObject info = null; String wQualifiedName = "", woeid = "", lng = "", lat = "", region = "", countryShort = ""; TimeZone timezone = null; try { URLConnection sCon = new URL("https://www.yahoo.com/news/_tdnews/api/resource/WeatherSearch;text=" + URLEncoder.encode(sword, "UTF-8") + "?returnMeta=true").openConnection(); sCon.addRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:73.0) Gecko/20100101 Firefox/73.0"); JSONObject wsearch = new JSONObject(new JSONTokener(sCon.getInputStream())).getJSONArray("data").getJSONObject(0); woeid = String.valueOf(wsearch.getInt("woeid")); //yahoo scrape lat = String.valueOf(wsearch.getDouble("lat")); lng = String.valueOf(wsearch.getDouble("lon")); wQualifiedName = wsearch.getString("qualifiedName"); countryShort = countryCodes.get(wsearch.getString("country")); region = wQualifiedName.split(",")[wQualifiedName.split(",").length - 2]; //get second highest level, highest should always be country code timezone = TimeZone.getTimeZone(new JSONObject(new JSONTokener(new URL("https://api.internal.teleport.org/api/locations/" + lat + "," + lng + "/?embed=location:nearest-cities/location:nearest-city/city:timezone").openStream())).getJSONObject("_embedded").getJSONArray("location:nearest-cities").getJSONObject(0).getJSONObject("_embedded").getJSONObject("location:nearest-city").getJSONObject("_embedded").getJSONObject("city:timezone").getString("iana_name")); //can use metaweather, but not accurate enough //can also broaden the scope for yahoo scrape for it to work better //JSONObject wsearch = new JSONObject(new JSONTokener(new URL("https://api.flickr.com/services/rest/?method=flickr.places.findByLatLon&api_key=bdaafeafab62267931d920dda27a4f90&lat=" + lat + "&lon=" + lng + "&format=json&nojsoncallback=1").openStream())).getJSONObject("places").getJSONArray("place").getJSONObject(0); //gonna use flickr find instead URLConnection iCon = new URL("https://www.yahoo.com/news/_tdnews/api/resource/WeatherService;woeids=[" + woeid + "]?lang=en-US&returnMeta=true").openConnection(); iCon.addRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:73.0) Gecko/20100101 Firefox/73.0"); info = new JSONObject(new JSONTokener(iCon.getInputStream())).getJSONObject("data").getJSONArray("weathers").getJSONObject(0); } catch(IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } catch(JSONException e) { e.printStackTrace(); return new CommandResult(CommandResultType.NORESULT); //hasWeather = false; } Date date = new Date(); //System.out.println(info); EmbedBuilder embedmsg = new EmbedBuilder(); embedmsg.setAuthor("Info for " + wQualifiedName, null, null); embedmsg.setColor(new Color(100, 0, 255)); embedmsg.addField("Country", MiscUtils.countryNameToUnicode(countryShort), true); embedmsg.addField("Region", region, true); embedmsg.addField("Current time", OffsetDateTime.now(timezone.toZoneId()).format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ssa").withLocale(Locale.ENGLISH)).trim(), true); long hours = (timezone.getOffset(date.getTime())/1000/60/60); embedmsg.addField("Timezone" , timezone.getDisplayName(timezone.inDaylightTime(date), TimeZone.LONG, Locale.ENGLISH) + " (UTC" + (Math.signum(hours) == 1 || Math.signum(hours) == 0 ? "+" + hours : hours) + ")" + "\u1160", true); //FLICKR DED String footer = "Weather info not available"; if (hasWeather) { //use another api if weather info not available? JSONObject obs = info.getJSONObject("observation"); JSONObject temp = obs.getJSONObject("temperature"); JSONObject forecast = info.getJSONObject("forecasts").getJSONArray("daily").getJSONObject(0); if(temp.has("now")) { embedmsg.addField("Temperature", fToC(temp.getInt("now")) + "°C (↑" + fToC(temp.getInt("high")) + "°C | ↓" + fToC(temp.getInt("low")) + "°C)", true); embedmsg.addField("Humidity", obs.getInt("humidity") + "% (Chance of rain: " + forecast.getInt("precipitationProbability") + "%)", true); embedmsg.addField("Visibility", miToKm(obs.getDouble("visibility")) + "km (" + obs.getString("conditionDescription") + ")", true); embedmsg.addField("Atmospheric pressure", formatter.format(obs.getDouble("barometricPressure") / 0.029530) + "millibars", true); embedmsg.addField("Wind speed", miToKm(obs.getDouble("windSpeed")) + "km/h", true); embedmsg.addField("Wind direction", obs.getInt("windDirection") + "° (" + obs.getString("windDirectionCode") + ")", true); embedmsg.addField("Feels Like", fToC(temp.getInt("feelsLike")) + "°C", true); embedmsg.addField("UV index", obs.getInt("uvIndex") + " (" + obs.getString("uvDescription") + ")", true); embedmsg.addField("Sunrise", MiscUtils.convertMillis(info.getJSONObject("sunAndMoon").getLong("sunrise") * 1000).substring(0, 5), true); embedmsg.addField("Sunset", MiscUtils.convertMillis(info.getJSONObject("sunAndMoon").getLong("sunset") * 1000).substring(0, 5), true); String imgUrl = info.getJSONArray("photos").getJSONObject(0).getJSONArray("resolutions").getJSONObject(0).getString("url"); //seems to have dead urls, how fix embedmsg.setThumbnail(imgUrl.split(":\\/\\/").length > 2 ? "https://" + imgUrl.split(":\\/\\/")[2] : imgUrl); footer = "Weather info last updated: " + OffsetDateTime.parse(obs.getJSONObject("observationTime").getString("timestamp")).format(DateTimeFormatter.RFC_1123_DATE_TIME) .replace("GMT", timezone.getDisplayName(timezone.inDaylightTime(date), TimeZone.SHORT, Locale.ENGLISH)); //+ " | " + wQualifiedName; //add weather provider to footer? } } embedmsg.addField("Latitude", lat, true); embedmsg.addField("Longitude", lng, true); embedmsg.setFooter(footer, null); try { MessageEmbed fmsg = embedmsg.build(); - channel.sendMessage(fmsg).queue(); + channel.sendMessageEmbeds(fmsg).queue(); } catch(InsufficientPermissionException e2) { return new CommandResult(CommandResultType.FAILURE, "Unfortunately, the bot is missing the permission `MESSAGE_EMBED_LINKS` which is required for this command to work."); } return new CommandResult(CommandResultType.SUCCESS); } catch (Exception e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } } } private String fToC(int f) { //fucking no metric ree return formatter.format((f-32)*5.0/9); } private String miToKm(double mile) { return formatter.format(mile*1.609344); } } diff --git a/src/me/despawningbone/discordbot/command/info/Fandom.java b/src/me/despawningbone/discordbot/command/info/Fandom.java index d9b7e72..3ba8585 100644 --- a/src/me/despawningbone/discordbot/command/info/Fandom.java +++ b/src/me/despawningbone/discordbot/command/info/Fandom.java @@ -1,106 +1,106 @@ package me.despawningbone.discordbot.command.info; import java.awt.Color; 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.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", "gamepedia"); //gamepedia is now fandom basically this.usage = ": [| index]"; this.examples = Arrays.asList("zelda: gate of time", "clockwork planet: ryuZU", "angel beats: kanade"); } @Override public CommandResult execute(TextChannel channel, User author, Message msg, String[] args) { channel.sendTyping().queue(); if (args.length < 1) { return new CommandResult(CommandResultType.INVALIDARGS, "Invalid input, check help for more info."); } try { String[] init = String.join(" ", args).split(":", 2); if(init.length < 2) { return new CommandResult(CommandResultType.INVALIDARGS, "Please use a colon to seperate the wiki and the search query!"); } //get index 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; } } //bruh the gamepedia merge killed a ton of the actual good UCP/nirvana API controller endpointss //now i gotta use wack substitutions //search - since SearchApiController is now gone i gotta use the other ones String search = URLEncoder.encode(sword.trim(), "UTF-8"); HttpURLConnection url = null; String wiki = init[0].replaceAll("[^\\p{L}\\p{N} ]+", "").replaceAll(" ", "-").toLowerCase(); //the wikia domain is still in use; no need to swap to fandom.com for now //alternative search endpoint (more of an autocomplete only but much faster): "https://" + wiki + ".wikia.com/wikia.php?controller=LinkSuggest&method=getLinkSuggestions&format=json&query=" + search url = (HttpURLConnection) new URL("https://" + wiki + ".wikia.com/api.php?action=query&format=json&list=search&srsearch=" + search).openConnection(); if(url.getResponseCode() == 404) { return new CommandResult(CommandResultType.FAILURE, "Unknown wiki name!"); //404 means unknown wiki now } //get result int id; try { JSONObject result = new JSONObject(new JSONTokener(url.getInputStream())); id = result.getJSONObject("query").getJSONArray("search").getJSONObject(num).getInt("pageid"); } catch(JSONException e) { return new CommandResult(CommandResultType.NORESULT, "it in the " + init[0] + " wiki"); } //fetch details about page; way worse formatting than AsSimpleJson but hey its gone what can i do JSONObject details = new JSONObject(new JSONTokener(new URL("https://" + wiki + ".wikia.com/api/v1/Articles/Details?abstract=500&ids=" + id).openStream())); JSONObject info = details.getJSONObject("items").getJSONObject(String.valueOf(id)); //TODO make async EmbedBuilder eb = new EmbedBuilder(); eb.setTitle(info.getString("title"), details.getString("basepath") + info.getString("url")); eb.setAuthor(StringUtils.capitalize(init[0]) + " wiki", details.getString("basepath")); //only use until the last full stop before table of content or end for slightly better formatting //there might be false positives for table of content detection since its just checking 1 after full stop, but honestly rarely less details > commonly being ugly af String desc = info.getString("abstract").replaceAll("^(?:(.*?\\.) ?1 .*|(.*\\.) .*?)$", "$1$2"); //greedy if table of content is present, else lazy to get the last eb.setDescription(desc.matches(".*\\.$") ? desc : (desc + "...")); //if everything fails (aka last char aint a full stop) give it the good ol ... treatment if(info.has("comments")) eb.addField("Comments", String.valueOf(info.getInt("comments")), false); if(!info.isNull("thumbnail")) eb.setThumbnail(info.getString("thumbnail").substring(0, info.getString("thumbnail").indexOf("/revision/"))); //get full img by trimming revision path 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(); + channel.sendMessageEmbeds(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/info/Help.java b/src/me/despawningbone/discordbot/command/info/Help.java index 44d21ee..2211a6a 100644 --- a/src/me/despawningbone/discordbot/command/info/Help.java +++ b/src/me/despawningbone/discordbot/command/info/Help.java @@ -1,108 +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(); + channel.sendMessageEmbeds(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(); + channel.sendMessageEmbeds(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/Translate.java b/src/me/despawningbone/discordbot/command/info/Translate.java index e7c44ea..a2f32cf 100644 --- a/src/me/despawningbone/discordbot/command/info/Translate.java +++ b/src/me/despawningbone/discordbot/command/info/Translate.java @@ -1,117 +1,117 @@ 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.HttpStatusException; 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"); executor.scheduleAtFixedRate(() -> refresh(), 0, 300, TimeUnit.SECONDS); //5 mins refresh } private int id = (ThreadLocalRandom.current().nextInt(1000, 10000) + 1) * 10000 + 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 = null; 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(args.length < 0) return new CommandResult(CommandResultType.INVALIDARGS, "Please enter something to translate!"); 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!"); //need OPTION call before calling this? //req Connection con = Jsoup.connect("https://www2.deepl.com/jsonrpc?method=LMT_handle_jobs").userAgent(agent) .header("Content-type", "application/json").ignoreContentType(true) .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\"],\"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"); //set cookies; only LMTBID is useful, and its set on first call to jsonrpc //formatting JSONObject main = new JSONObject(new JSONTokener(resp.body())).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(); + channel.sendMessageEmbeds(eb.build()).queue(); return new CommandResult(CommandResultType.SUCCESS); } catch(IOException e) { if(e instanceof HttpStatusException) //aka pending code fix return new CommandResult(CommandResultType.FAILURE, "The translator is currently temporarily unavailable, please try again later."); return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } } private void refresh() { try { Connection con = Jsoup.connect("https://www.deepl.com/PHP/backend/clientState.php?request_type=jsonrpc&il=EN&method=getClientState").userAgent(agent) .requestBody("{\"jsonrpc\":\"2.0\",\"method\":\"getClientState\",\"params\":{\"v\":\"20180814\"" + ",\"clientVars\":" + (cVars == null ? "{}" : cVars.toString()) + "},\"id\":" + id++ + "}"); if(cookie != null) con.cookie("LMTBID", cookie); JSONObject json = new JSONObject(new JSONTokener(con.ignoreContentType(true).post().text())); //System.out.println(json); if(cVars == null) cVars = json.getJSONObject("result").getJSONObject("clientVars"); } catch (IOException e) { e.printStackTrace(); id--; //set it back down one } } } diff --git a/src/me/despawningbone/discordbot/command/info/UserInfo.java b/src/me/despawningbone/discordbot/command/info/UserInfo.java index 1e40dbe..9b6487a 100644 --- a/src/me/despawningbone/discordbot/command/info/UserInfo.java +++ b/src/me/despawningbone/discordbot/command/info/UserInfo.java @@ -1,93 +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(); + channel.sendMessageEmbeds(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 index fa8b6e9..6549563 100644 --- a/src/me/despawningbone/discordbot/command/info/Wikipedia.java +++ b/src/me/despawningbone/discordbot/command/info/Wikipedia.java @@ -1,88 +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(); + channel.sendMessageEmbeds(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/Spoiler.java b/src/me/despawningbone/discordbot/command/misc/Spoiler.java index 9aa85be..408f5ab 100644 --- a/src/me/despawningbone/discordbot/command/misc/Spoiler.java +++ b/src/me/despawningbone/discordbot/command/misc/Spoiler.java @@ -1,84 +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(); + channel.sendMessageEmbeds(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/AudioTrackHandler.java b/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java index 9058eeb..28639b0 100644 --- a/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java +++ b/src/me/despawningbone/discordbot/command/music/AudioTrackHandler.java @@ -1,522 +1,522 @@ package me.despawningbone.discordbot.command.music; import java.io.IOException; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; 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.io.IOUtils; import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.utils.URIBuilder; import org.awaitility.Awaitility; import org.awaitility.core.ConditionTimeoutException; 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.tools.io.HttpClientTools; import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface; import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterfaceManager; 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; /** * Helper class for the entire music subbot instance; * For working in tandem with Music.java */ public class AudioTrackHandler { private ConcurrentHashMap musicManagers; private AudioPlayerManager playerManager; final HttpInterfaceManager httpInterfaceManager; //package final since TrackScheduler will also access it //not anymore, but its fine leaving it as is 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 String ytAp = null; private List votes = new ArrayList<>(); public String getUrl() { return url; } public String getUserWithDiscriminator() { return uDis; } public String getFormattedDuration() { return fDur; } public String getYoutubeAutoplayParam() { return ytAp; } 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); //ytAp is default null } //overload for autoplay public TrackData(String url, long durMillis, String ytAutoplayParam) { this.url = url; this.uDis = "Autoplay"; this.fDur = MiscUtils.convertMillis(durMillis); this.ytAp = ytAutoplayParam; //null for other autoplays } //for cloning; reset votes public TrackData(TrackData orig) { this.url = orig.url; this.uDis = orig.uDis; this.fDur = orig.fDur; this.ytAp = orig.ytAp; //null for other autoplays } } public AudioTrackHandler() { this.musicManagers = new ConcurrentHashMap<>(); this.playerManager = new DefaultAudioPlayerManager(); this.httpInterfaceManager = HttpClientTools.createCookielessThreadLocalManager(); 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) { try { url = fetchUrlFromSearch(search, type); } catch (Exception ex) { resFuture.complete(new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(ex))); return resFuture; //something broke, halt further codes } } if (url == null) throw new NoSuchElementException(); load(user, url, (n, l) -> { if(l.isEmpty()) { resFuture.complete(new CommandResult(CommandResultType.NORESULT)); return; } 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()) { //have to check here for pending to always work even though queueTrack would check again 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)) { //put to pending 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 everything passes try queuing int startIndex = queueTracks(l, user) + 1; if (n == null) { //no name == not playlist 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 { 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)); } 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) throws IOException { if (type.startsWith("youtube")) { type = type.substring(8, type.length()); if (type.equals("video")) { return "ytsearch:" + search; } else { //scrape with our own code since YoutubeSearchProvider only scrapes videos //NOADD check if theres a way to search for playlists and vids at the same time //using different commands now try (HttpInterface httpInterface = httpInterfaceManager.getInterface()) { URI url = new URIBuilder("https://www.youtube.com/results") //sp is filter param, EgIQAw== is the base64 encoding of playlist option .addParameter("search_query", search).addParameter("sp", "EgIQAw==").build(); try (CloseableHttpResponse response = httpInterface.execute(new HttpGet(url))) { //connection code borrowed from lavaplayer's YoutubeSearchProvider int statusCode = response.getStatusLine().getStatusCode(); if (!HttpClientTools.isSuccessWithContent(statusCode)) { throw new IOException("Invalid status code for search response: " + statusCode); } //start parsing String data = IOUtils.toString(response.getEntity().getContent(), "UTF-8"); data = data.substring(data.indexOf("var ytInitialData = ") + 20); JSONObject result = new JSONObject(new JSONTokener(data.substring(0, data.indexOf(";")))) .getJSONObject("contents").getJSONObject("twoColumnSearchResultsRenderer").getJSONObject("primaryContents") .getJSONObject("sectionListRenderer").getJSONArray("contents").getJSONObject(0).getJSONObject("itemSectionRenderer"); //iterate to get first playlist for(Object obj : result.getJSONArray("contents")) { JSONObject renderer = (JSONObject) obj; if(renderer.has("playlistRenderer")) { return "https://www.youtube.com" + renderer.getJSONObject("playlistRenderer").getJSONObject("navigationEndpoint") .getJSONObject("commandMetadata").getJSONObject("webCommandMetadata").getString("url"); //return first found } } return null; //if no result } } catch (URISyntaxException e) { e.printStackTrace(); //should be unreachable } } } 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; } String plName = playlist.getName(); List tracks = playlist.getTracks(); String plId = ""; if(playlist.isSearchResult()) { tracks = tracks.subList(0, 1); //only get first result if search plName = null; //no actual playlist name } else { if(url.contains("://soundcloud.com") || url.contains("://www.youtube.com")) //add pl id if is playlist 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(plName, tracks.stream().filter(t -> t != null).collect(Collectors.toList())); } 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() != null && 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()) { + if (!guild.getAudioManager().isConnected()) { 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, String rel) 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(!rel.isEmpty()) millis = track.getPosition() + (rel.equals("-") ? -millis : millis); 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(); } //should ALWAYS be called before discarding this instance 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.isAttemptingToConnect()) { + 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/Music.java b/src/me/despawningbone/discordbot/command/music/Music.java index f8d63e4..2b06802 100644 --- a/src/me/despawningbone/discordbot/command/music/Music.java +++ b/src/me/despawningbone/discordbot/command/music/Music.java @@ -1,680 +1,680 @@ 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 //TODO guild config for non restricted loop/autoplay 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 tracks when you are alone!", Arrays.asList(" * Currently only works if the last track in the queue is a youtube video.", " 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("(?<=[+-])"); //get type of relative pos; only first +/- is used and consecutive +/-s get ignored String[] t = s[s.length - 1].split(":"); int index = t.length > 2 ? 1 : 0; String setT = handler.setTrackPosition(c.getGuild(), index == 1 ? Long.parseLong(t[0]) : 0 , Long.parseLong(t[index]), Long.parseLong(t[index + 1]), s.length == 1 ? "" : s[0]); c.sendMessage("Successfully set the timestamp to `" + setT + "`.").queue(); return new CommandResult(CommandResultType.SUCCESS); } catch (IndexOutOfBoundsException | NumberFormatException e) { 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", "+30:00", "-1:23:45"), "Set the playing position in the current track!", Arrays.asList("timestamps without signs are absolute, whereas `+`/`-` means go forward or backward from the current position for the amount of time specified respectively.", " * 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) { return new CommandResult(CommandResultType.FAILURE, e.getMessage()); } catch(NullPointerException e) { return new CommandResult(CommandResultType.INVALIDARGS, "There is nothing playing currently! Please specify a song title to search the lyrics up."); } catch(IOException e) { return new CommandResult(CommandResultType.ERROR, ExceptionUtils.getStackTrace(e)); } }, "[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(); + c.sendMessageEmbeds(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; } //maybe move the lyrics stuff elsewhere? private final String geniusAuth = DiscordBot.tokens.getProperty("genius"); //annotated usually isnt actual lyrics, but the following are known to be some 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) throws IOException { //DONE dont splice words between embeds; make whole sentence to be spliced instead //System.out.println(search); JSONObject main = null; try { URLConnection searchCon = new URL("https://api.genius.com/search?access_token=" + geniusAuth + "&q=" + URLEncoder.encode(search, "UTF-8").replaceAll("\\+", "%20")).openConnection(); searchCon.addRequestProperty("User-Agent", "Mozilla/4.0"); InputStream searchStream = searchCon.getInputStream(); JSONTokener searchResult = new JSONTokener(searchStream); JSONArray list = new JSONObject(searchResult).getJSONObject("response").getJSONArray("hits"); //get first valid result 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; } searchStream.close(); } catch (JSONException | ArrayIndexOutOfBoundsException e) { e.printStackTrace(); throw new IllegalArgumentException("There were no results unfortunately :cry:"); } if(main == null) { throw new IllegalArgumentException("There were no results unfortunately :cry:"); } //get actual lyrics String url = main.getString("url"); Document document = Jsoup.connect(url).userAgent("Mozilla/4.0").get(); Element html = document.body(); //parse and lint Element el = html.selectFirst("div[class=\"lyrics\"]"); String s; if(el == null) { el = html.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(); el.select("div[class*=\"Lyrics__Footer\"]").remove(); } 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)); //split into lyrics segments 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) { //failover catch for whats most likely not lyrics throw new UnsupportedOperationException("The lyrics is too long for a normal song :poop:"); } //build first embed 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)); //build rest of the embeds List em = new ArrayList(); 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, html); } em.add(loopEm.build()); } } else { //first and last same embed, thus set final setFinalLyricsEmbed(eb, main, html); em = Arrays.asList(eb.build()); } //System.out.println(lyrics); return em; } //set given embed's description, shifting lyrics to the next embed "page" if overflow 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"); 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); } } //appends metadata portion to the given embed (should be the last one) private void setFinalLyricsEmbed(EmbedBuilder eb, JSONObject main, Element html) { //change the format for the supplementary info in artists and albums from italic to sth else? try { String name = main.getJSONObject("primary_artist").getString("name"); try { String others = html.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); } } catch (JSONException e) { e.printStackTrace(); eb.addField("Artist", "Unknown", true); } if(!html.select("div[class=\"lyrics\"]").isEmpty()) { Elements buffer = html.select("span:contains(Album) ~ span[class=\"metadata_unit-info\"] a"); //parse albums if(buffer.size() > 0) { String album = buffer.get(0).ownText(); StringBuilder sb = new StringBuilder(); JSONObject data = new JSONObject(html.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? } //parse release date buffer = html.select("span:contains(Release Date) ~ span[class*=\"metadata_unit-info\"]"); if(buffer.size() > 0) eb.addField("Release Date", buffer.get(0).ownText(), true); //parse tags try { String sbuff = URLDecoder.decode(html.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(); } //parse background info buffer = html.select("div[class=\"annotation_label\"] ~ div[class=\"rich_text_formatting\"]"); if(buffer.size() > 0) { buffer.get(0).select("blockquote p").prepend("> "); buffer.get(0).select("p").prepend("\\n\\n"); String bgInfo = buffer.get(0).text().replaceAll("\\\\n", "\n"); 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 = html.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"))); //parse albums try { StringBuilder sb = new StringBuilder(); 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 } //release date eb.addField("Release Date", song.getString("releaseDateForDisplay"), true); //parse tags ArrayList tags = new ArrayList<>(); for(int i = 0; i < song.getJSONArray("tags").length(); i++) { tags.add(song.getJSONArray("tags").getJSONObject(i).getString("name")); } if(!tags.isEmpty()) eb.addField("Tags", String.join(", ", tags), false); //parse background info 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"); } }