From 2dd1970d6d31ea1e2bbfa30b57141ea9a4720834 Mon Sep 17 00:00:00 2001 From: NetheriteMiner <88792142+NetheriteMiner@users.noreply.github.com> Date: Thu, 27 Apr 2023 06:07:33 -0400 Subject: Added Discord RPC (#35) Co-authored-by: hannibal2 <24389977+hannibal00212@users.noreply.github.com> --- .../java/at/hannibal2/skyhanni/SkyHanniMod.java | 5 +- .../hannibal2/skyhanni/config/features/About.java | 5 +- .../hannibal2/skyhanni/config/features/Misc.java | 52 +++++- .../hannibal2/skyhanni/data/ActionBarStatsData.kt | 35 ++++ .../features/misc/discordrpc/DiscordLocationKey.kt | 112 +++++++++++++ .../features/misc/discordrpc/DiscordRPCManager.kt | 183 +++++++++++++++++++++ .../features/misc/discordrpc/DiscordStatus.kt | 149 +++++++++++++++++ 7 files changed, 538 insertions(+), 3 deletions(-) create mode 100644 src/main/java/at/hannibal2/skyhanni/data/ActionBarStatsData.kt create mode 100644 src/main/java/at/hannibal2/skyhanni/features/misc/discordrpc/DiscordLocationKey.kt create mode 100644 src/main/java/at/hannibal2/skyhanni/features/misc/discordrpc/DiscordRPCManager.kt create mode 100644 src/main/java/at/hannibal2/skyhanni/features/misc/discordrpc/DiscordStatus.kt (limited to 'src') diff --git a/src/main/java/at/hannibal2/skyhanni/SkyHanniMod.java b/src/main/java/at/hannibal2/skyhanni/SkyHanniMod.java index 46fcd2e54..cf6ff22b9 100644 --- a/src/main/java/at/hannibal2/skyhanni/SkyHanniMod.java +++ b/src/main/java/at/hannibal2/skyhanni/SkyHanniMod.java @@ -43,6 +43,7 @@ import at.hannibal2.skyhanni.features.itemabilities.abilitycooldown.ItemAbilityC import at.hannibal2.skyhanni.features.minion.MinionCollectLogic; import at.hannibal2.skyhanni.features.minion.MinionFeatures; import at.hannibal2.skyhanni.features.misc.*; +import at.hannibal2.skyhanni.features.misc.discordrpc.DiscordRPCManager; import at.hannibal2.skyhanni.features.misc.tiarelay.TiaRelayHelper; import at.hannibal2.skyhanni.features.misc.tiarelay.TiaRelayWaypoints; import at.hannibal2.skyhanni.features.misc.update.UpdateManager; @@ -149,6 +150,7 @@ public class SkyHanniMod { loadModule(new CropAccessoryData()); loadModule(new MayorElection()); loadModule(new GardenComposterUpgradesData()); + loadModule(new ActionBarStatsData()); // APIs loadModule(new BazaarApi()); @@ -273,6 +275,7 @@ public class SkyHanniMod { loadModule(new AshfangMinisNametagHider()); loadModule(new GardenTeleportPadInventoryNumber()); loadModule(new ComposterOverlay()); + loadModule(new DiscordRPCManager()); loadModule(new GardenCropMilestoneFix()); loadModule(new GardenBurrowingSporesNotifier()); loadModule(new WildStrawberryDyeNotification()); @@ -325,4 +328,4 @@ public class SkyHanniMod { System.out.println("consoleLog: (" + message + ")"); } } -} +} \ No newline at end of file diff --git a/src/main/java/at/hannibal2/skyhanni/config/features/About.java b/src/main/java/at/hannibal2/skyhanni/config/features/About.java index c67e2f7ee..5a97b2410 100644 --- a/src/main/java/at/hannibal2/skyhanni/config/features/About.java +++ b/src/main/java/at/hannibal2/skyhanni/config/features/About.java @@ -73,5 +73,8 @@ public class About { @ConfigEditorButton(buttonText = "Source") public Runnable mixin = () -> OSUtils.openBrowser("https://github.com/SpongePowered/Mixin/"); + @ConfigOption(name = "DiscordIPC", desc = "DiscordIPC is available under the Apache License 2.0") + @ConfigEditorButton(buttonText = "GitHub") + public Runnable discordRPC = () -> OSUtils.openBrowser("https://github.com/jagrosh/DiscordIPC"); } -} +} \ No newline at end of file diff --git a/src/main/java/at/hannibal2/skyhanni/config/features/Misc.java b/src/main/java/at/hannibal2/skyhanni/config/features/Misc.java index 12642106b..23a9824a9 100644 --- a/src/main/java/at/hannibal2/skyhanni/config/features/Misc.java +++ b/src/main/java/at/hannibal2/skyhanni/config/features/Misc.java @@ -231,6 +231,56 @@ public class Misc { @ConfigAccordionId(id = 11) public boolean estimatedIemValueAlwaysEnabled = true; + @ConfigOption(name = "Discord Rich Presence", desc = "") + @Accordion + @Expose + public DiscordRPC discordRPC = new DiscordRPC(); + + public static class DiscordRPC { + + @Expose + @ConfigOption(name = "Enable Discord RPC", desc = "Details about your Skyblock session displayed through Discord.") + @ConfigEditorBoolean + public Property enabled = Property.of(false); + + @Expose + @ConfigOption(name = "First Line", desc = "Decide what to show in the first line.") + @ConfigEditorDropdown(values = { + "Nothing", + "Location", + "Purse", + "Bits", + "Stats", + "Held Item", + "Skyblock Date", + "Profile (Fruit)", + "Slayer", + "Custom" + }) + public Property firstLine = Property.of(0); + + @Expose + @ConfigOption(name = "Second Line", desc = "Decide what to show in the second line.") + @ConfigEditorDropdown(values = { + "Nothing", + "Location", + "Purse", + "Bits", + "Stats", + "Held Item", + "Skyblock Date", + "Profile (Fruit)", + "Slayer", + "Custom" + }) + public Property secondLine = Property.of(0); + + @Expose + @ConfigOption(name = "Custom", desc = "What should be displayed if you select \"Custom\" above.") + @ConfigEditorText + public Property customText = Property.of(""); + } + @Expose public Position itemPriceDataPos = new Position(140, 90, false, true); @@ -279,4 +329,4 @@ public class Misc { @Expose public Position inventoryLoadPos = new Position(394, 124, false, true); -} +} \ No newline at end of file diff --git a/src/main/java/at/hannibal2/skyhanni/data/ActionBarStatsData.kt b/src/main/java/at/hannibal2/skyhanni/data/ActionBarStatsData.kt new file mode 100644 index 000000000..5130e7080 --- /dev/null +++ b/src/main/java/at/hannibal2/skyhanni/data/ActionBarStatsData.kt @@ -0,0 +1,35 @@ +package at.hannibal2.skyhanni.data + +import at.hannibal2.skyhanni.events.LorenzActionBarEvent +import at.hannibal2.skyhanni.utils.LorenzUtils +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent +import java.util.regex.Pattern + +class ActionBarStatsData { + private val pattern = + Pattern.compile("..((?:\\d|,)*)\\/(?:\\d|,)*(.) *..((?:\\d|,)*)..(. \\w*) *..((?:\\d|,)*)\\/(?:\\d|,)*(.*)") +// Sample input: §c2,817/2,817❤ §a703§a❈ Defense §b3,479/3,479✎ Mana +// Returns the following groups: 1 = 2,817; 2 = ❤; 3 = 703; 4 = ❈ Defense; 5 = 3,479; 6 = ✎ Mana + + companion object { + var groups = listOf() + } + + @SubscribeEvent + fun onActionBar(event: LorenzActionBarEvent) { + groups = readGroups(event.message) + } + + private fun readGroups(message: String): List { + if (!LorenzUtils.inSkyBlock) return emptyList() + + val matcher = pattern.matcher(message) + if (!matcher.matches()) return emptyList() + + val list = mutableListOf() + for (i in 1..matcher.groupCount()) { + list.add(matcher.group(i)) + } + return list + } +} diff --git a/src/main/java/at/hannibal2/skyhanni/features/misc/discordrpc/DiscordLocationKey.kt b/src/main/java/at/hannibal2/skyhanni/features/misc/discordrpc/DiscordLocationKey.kt new file mode 100644 index 000000000..8fbfadbd0 --- /dev/null +++ b/src/main/java/at/hannibal2/skyhanni/features/misc/discordrpc/DiscordLocationKey.kt @@ -0,0 +1,112 @@ +package at.hannibal2.skyhanni.features.misc.discordrpc + +class DiscordLocationKey { + + private val normalRPC = setOf( + "auction-house", + "bank", + "canvas-room", + "coal-mine", + "colosseum", + "farm", + "fashion-shop", + "flower-house", + "forest", + "graveyard", + "library", + "mountain", + "ruins", + "tavern", + "village", + "wilderness", + "wizard-tower", + "birch-park", + "spruce-woods", + "savanna-woodland", + "dark-thicket", + "jungle-island", + "gold-mine", + "slimehill", + "diamond-reserve", + "obsidian-sanctuary", + "the-barn", + "mushroom-desert", + "the-end" + ) + // list of tokens where the name can just be lowercased and spaces can be replaced with dashes + + private val specialRPC = mapOf( + "Fisherman's Hut" to "fishermans-hut", "Unincorporated" to "high-level", + "Dragon's Nest" to "dragons-nest", "Void Sepulture" to "the-end", "Void Slate" to "the-end", + "Zealot Bruiser Hideout" to "the-end", "Desert Settlement" to "mushroom-desert", + "Oasis" to "mushroom-desert", "Desert Mountain" to "mushroom-desert", "Jake's House" to "mushroom-desert", + "Trapper's Den" to "mushroom-desert", "Mushroom Gorge" to "mushroom-desert", + "Glowing Mushroom Cave" to "mushroom-desert", "Overgrown Mushroom Cave" to "mushroom-desert", + "Shepherd's Keep" to "mushroom-desert", "Treasure Hunter Camp" to "mushroom-desert", + "Windmill" to "the-barn", "Spider's Den" to "spiders-den", "Arachne's Burrow" to "spiders-den", + "Arachne's Sanctuary" to "spiders-den", "Archaeologist's Camp" to "spiders-den", + "Grandma's House" to "spiders-den", "Gravel Mines" to "spiders-den", "Spider Mound" to "spiders-den", + "Melody's Plateau" to "forest", "Viking Longhouse" to "forest", "Lonely Island" to "forest", + "Howling Cave" to "forest" + ) // maps locations that do have a token, but have parentheses or a legacy key + + private val specialNetherRPC = arrayOf( + "Aura's Lab", + "Barbarian Outpost", + "Belly of the Beast", + "Blazing Volcano", + "Burning Desert", + "Cathedral", + "Chief's Hut", + "Courtyard", + "Crimson Fields", + "Crimson Isle", + "Dojo", + "Dragontail Auction House", + "Dragontail Bank", + "Dragontail Bazaar", + "Dragontail Blacksmith", + "Dragontail Townsquare", + "Dragontail", + "Forgotten Skull", + "Igrupan's Chicken Coop", + "Igrupan's House", + "Mage Council", + "Mage Outpost", + "Magma Chamber", + "Matriarch's Lair", + "Minion Shop", + "Mystic Marsh", + "Odger's Hut", + "Plhlegblast Pool", + "Ruins of Ashfang", + "Scarleton Auction House", + "Scarleton Bank", + "Scarleton Bazaar", + "Scarleton Blacksmith", + "Scarleton Minion Shop", + "Scarleton Plaza", + "Scarleton", + "Smoldering Tomb", + "Stronghold", + "The Bastion", + "The Dukedom", + "The Wasteland", + "Throne Room" + ) + // list of nether locations because there are sooo many (truncated some according to scoreboard) + + fun getDiscordIconKey(location: String): String { + val keyIfNormal = location.lowercase().replace(' ', '-') + + return if (normalRPC.contains(keyIfNormal)) { + keyIfNormal + } else if (specialRPC.containsKey(location)) { + specialRPC[location]!! + } else if (specialNetherRPC.contains(location)) { + "blazing-fortress" + } else { + "skyblock" // future proofing since we can't update the images anymore :( + } + } +} \ No newline at end of file diff --git a/src/main/java/at/hannibal2/skyhanni/features/misc/discordrpc/DiscordRPCManager.kt b/src/main/java/at/hannibal2/skyhanni/features/misc/discordrpc/DiscordRPCManager.kt new file mode 100644 index 000000000..c5dfcdff2 --- /dev/null +++ b/src/main/java/at/hannibal2/skyhanni/features/misc/discordrpc/DiscordRPCManager.kt @@ -0,0 +1,183 @@ +package at.hannibal2.skyhanni.features.misc.discordrpc + +// This entire file was taken from SkyblockAddons code, ported to SkyHanni + +import at.hannibal2.skyhanni.SkyHanniMod.* +import at.hannibal2.skyhanni.events.ConfigLoadEvent +import at.hannibal2.skyhanni.utils.LorenzUtils +import com.google.gson.JsonObject +import com.jagrosh.discordipc.IPCClient +import com.jagrosh.discordipc.IPCListener +import com.jagrosh.discordipc.entities.RichPresence +import io.github.moulberry.moulconfig.observer.Property +import kotlinx.coroutines.launch +import net.minecraftforge.event.world.WorldEvent +import net.minecraftforge.fml.common.eventhandler.SubscribeEvent +import net.minecraftforge.fml.common.gameevent.TickEvent +import net.minecraftforge.fml.common.network.FMLNetworkEvent +import java.util.* +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit + +class DiscordRPCManager : IPCListener { + private val applicationID = 653443797182578707L + private val updatePeriod = 4200L + + private val config get() = feature.misc.discordRPC + + private var client: IPCClient? = null + private lateinit var secondLine: DiscordStatus + private lateinit var firstLine: DiscordStatus + private var startTimestamp: Long? = null + private var startOnce = false + + private var updateTimer: Timer? = null + private var connected = false + + private val DiscordLocationKey = DiscordLocationKey() + + private fun start() { + coroutineScope.launch { + try { + if (isActive()) { + return@launch + } + consoleLog("Starting Discord RPC...") + + firstLine = getStatusByConfigId(config.firstLine.get()) + secondLine = getStatusByConfigId(config.secondLine.get()) + startTimestamp = System.currentTimeMillis() + client = IPCClient(applicationID) + client?.setListener(this@DiscordRPCManager) // why must kotlin be this way + + try { + client?.connect() + } catch (ex: Exception) { + consoleLog("Warn: Failed to connect to RPC!") + consoleLog(ex.toString()) + } + } catch (ex: Throwable) { + consoleLog("Warn: Discord RPC has thrown an unexpected error while trying to start...") + consoleLog(ex.toString()) + } + } + } + + private fun stop() { + coroutineScope.launch { + if (isActive()) { + connected = false + client?.close() + startOnce = false + } + } + } + + private fun isActive() = client != null && connected + + @SubscribeEvent + fun onConfigLoad(event: ConfigLoadEvent) { + for (property in listOf( + config.firstLine, + config.secondLine, + config.customText, + )) { + property.whenChangedWithDifference { + if (isActive()) { + updatePresence() + } + } + } + config.enabled.whenChanged { _, new -> + if (new) { +// start() + } else { + stop() + } + } + } + + fun Property<*>.whenChangedWithDifference(run: () -> (Unit)) { + whenChanged { old, new -> if (old != new) run() } + } + + fun updatePresence() { + val location = LorenzUtils.skyBlockArea + val discordIconKey = DiscordLocationKey.getDiscordIconKey(location) + + secondLine = getStatusByConfigId(config.secondLine.get()) + firstLine = getStatusByConfigId(config.firstLine.get()) + val presence: RichPresence = RichPresence.Builder() + .setDetails(firstLine.getDisplayString()) + .setState(secondLine.getDisplayString()) + .setStartTimestamp(startTimestamp!!) + .setLargeImage(discordIconKey, location) + .build() + client?.sendRichPresence(presence) + } + + override fun onReady(client: IPCClient) { + consoleLog("Discord RPC Started.") + connected = true + updateTimer = Timer() + updateTimer?.schedule(object : TimerTask() { + override fun run() { + updatePresence() + } + }, 0, updatePeriod) + } + + override fun onClose(client: IPCClient, json: JsonObject) { + consoleLog("Discord RPC closed.") + this.client = null + connected = false + cancelTimer() + } + + override fun onDisconnect(client: IPCClient?, t: Throwable?) { + consoleLog("Discord RPC disconnected.") + this.client = null + connected = false + cancelTimer() + } + + private fun cancelTimer() { + updateTimer?.let { + it.cancel() + updateTimer = null + } + } + + private fun getStatusByConfigId(id: Int) = DiscordStatus.values().getOrElse(id) { DiscordStatus.NONE } + + private fun isEnabled() = config.enabled.get() + + @SubscribeEvent + fun onTick(event: TickEvent.ClientTickEvent) { + if (startOnce || !isEnabled()) return // the mod has already started the connection process. this variable is my way of running a function when the player joins skyblock but only running it again once they join and leave. + if (LorenzUtils.inSkyBlock) { + start() + startOnce = true + } + } + + @SubscribeEvent + fun onWorldChange(event: WorldEvent.Load) { + val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() + executor.schedule( + { + if (!LorenzUtils.inSkyBlock) { + stop() + } + }, + 5, + TimeUnit.SECONDS + ) // wait 5 seconds to check if the new world is skyblock or not before stopping the function + } + + @SubscribeEvent + fun onDisconnect(event: FMLNetworkEvent.ClientDisconnectionFromServerEvent) { + stop() + } +} diff --git a/src/main/java/at/hannibal2/skyhanni/features/misc/discordrpc/DiscordStatus.kt b/src/main/java/at/hannibal2/skyhanni/features/misc/discordrpc/DiscordStatus.kt new file mode 100644 index 000000000..ac73c33cf --- /dev/null +++ b/src/main/java/at/hannibal2/skyhanni/features/misc/discordrpc/DiscordStatus.kt @@ -0,0 +1,149 @@ +package at.hannibal2.skyhanni.features.misc.discordrpc + +// SkyblockAddons code, adapted for SkyHanni + +import at.hannibal2.skyhanni.SkyHanniMod +import at.hannibal2.skyhanni.data.ActionBarStatsData +import at.hannibal2.skyhanni.data.HypixelData +import at.hannibal2.skyhanni.data.ScoreboardData +import at.hannibal2.skyhanni.utils.LorenzUtils +import at.hannibal2.skyhanni.utils.StringUtils.firstLetterUppercase +import at.hannibal2.skyhanni.utils.StringUtils.removeColor +import io.github.moulberry.notenoughupdates.util.SkyBlockTime +import java.util.function.Supplier +import java.util.regex.Pattern + +enum class DiscordStatus(private val displayMessageSupplier: Supplier?) { + // implements "ButtonSelect:SelectItem". no idea how to translate that into skyhanni + + NONE(null), + + LOCATION({ + val location = LorenzUtils.skyBlockArea + if (location == "Your Island") { + "Private Island" + } else { + location + /** + * looks slightly weird if visiting someone else's island, + * I was thinking of using LorenzUtils . skyblockIsland to determine if they're visiting, + * but it takes too long to load, so we 'd have to put in some sort of artificial delay + * like what I did in DiscordRPCManager.onWorldChange. + * after that, use the tab-list "Owner:" line to get the person we're visiting, but I don't know if + * that'll work with coops, and you'd have to deal with color codes as well + * as again, I'm pretty sure sba had "'s Island" without the name filled in this entire time, + * so I 'd rather have [RANK] NameThatGetsCutOff for example than 's Island + */ + } + }), + + PURSE({ + val scoreboard = ScoreboardData.sidebarLinesFormatted + var coins = "" + + for (line in scoreboard) { + if (line.startsWith("Purse: ") || line.startsWith("Piggy: ")) { + coins = line.subSequence(9 until line.length).toString() + } + } + + if (coins == "1") "1 Coin" else "$coins Coins" + }), + + BITS({ + var bits = "" + for (line in ScoreboardData.sidebarLinesFormatted) { + if (line.startsWith("Bits: ")) { + bits = line.subSequence(8 until line.length).toString() + } + } + + when (bits) { + "1" -> "1 Bit" + "" -> "0 Bits" + else -> "$bits Bits" + } + }), + + STATS({ + val groups = ActionBarStatsData.groups + var statString = "" + for (item in groups.indices) { + when (groups[item]) { + "❤" -> statString = "❤${groups[item - 1]} " + "❈ Defense" -> statString = "$statString❈${groups[item - 1]} " + "✎ Mana" -> statString = "$statString✎${groups[item - 1]} " + } + } + statString + }), + + ITEM({ + val player: net.minecraft.client.entity.EntityPlayerSP = net.minecraft.client.Minecraft.getMinecraft().thePlayer + if (player.heldItem != null) { + String.format("Holding ${player.heldItem.displayName.removeColor()}") + } else { + "No item in hand" + } + }), + + TIME({ + fun formatNum(num: Int): Int { + val rem = num % 10 + var returnNum = num - rem // floor() + if (returnNum == 0) { + returnNum = "0$num".toInt() + /** + * and this is so that if the minute value is ever + * a single digit (0 after being floored), it displays as 00 because 12:0pm just looks bad + */ + } + return returnNum + } + + val date: SkyBlockTime = SkyBlockTime.now() + val hour = if (date.hour > 12) date.hour - 12 else date.hour + val timeOfDay = if (date.hour > 11) "pm" else "am" // hooray for 12-hour clocks + "${SkyBlockTime.monthName(date.month)} ${date.day}${SkyBlockTime.daySuffix(date.day)}, $hour:${formatNum(date.minute)}$timeOfDay" // Early Winter 1st, 12:00pm + }), + + PROFILE({ + HypixelData.profileName.firstLetterUppercase() + }), + + SLAYER({ + var slayerName = "" + var slayerLevel = "" + var bossAlive = "spawning" + val slayerRegex = + Pattern.compile("((?:\\w| )*) ([IV]+)") // Samples: Revenant Horror I; Tarantula Broodfather IV + + for (line in ScoreboardData.sidebarLinesFormatted) { + val noColorLine = line.removeColor() + val match = slayerRegex.matcher(noColorLine) + if (match.matches()) { + slayerName = match.group(1) + slayerLevel = match.group(2) + } else if (noColorLine == "Slay the boss!") bossAlive = "slaying" + else if (noColorLine == "Boss slain!") bossAlive = "slain" + } + + if (slayerLevel == "") "Planning to do a slayer quest"// selected slayer in rpc but hasn't started a quest + else if (bossAlive == "spawning") "Spawning a $slayerName $slayerLevel boss." + else if (bossAlive == "slaying") "Slaying a $slayerName $slayerLevel boss." + else if (bossAlive == "slain") "Finished slaying a $slayerName $slayerLevel boss." + else "Something went wrong with slayer detection!" + }), + + CUSTOM({ + SkyHanniMod.feature.misc.discordRPC.customText.get() // custom field in the config + }) + ; + + fun getDisplayString(): String { + if (displayMessageSupplier != null) { + return displayMessageSupplier.get() + } + return "" + } +} \ No newline at end of file -- cgit