diff --git a/README.md b/README.md index 62d4fd3..f70d46d 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ trickortreat - A trick or treat-based competitive candy eating game with a leade tools - A collection of mod and admin tools, ported from my v2 version. Sitryk is responsible for a lot of the code in tools... thanks for the help with this cog. +warcraftlogs - Fetch player info/metrics from the WarcraftLogs API for World of Warcraft Classic. Does not provide stats for non-Classic characters. + wolfram - A v3 port of Paddo's abandoned Wolfram Alpha cog. youtube - A v3 port of Paddo's youtube search cog for v2. diff --git a/warcraftlogs/__init__.py b/warcraftlogs/__init__.py new file mode 100644 index 0000000..58e8cd7 --- /dev/null +++ b/warcraftlogs/__init__.py @@ -0,0 +1,5 @@ +from .warcraftlogs import WarcraftLogs + + +def setup(bot): + bot.add_cog(WarcraftLogs(bot)) diff --git a/warcraftlogs/info.json b/warcraftlogs/info.json new file mode 100644 index 0000000..fc73a1d --- /dev/null +++ b/warcraftlogs/info.json @@ -0,0 +1,8 @@ +{ + "author": ["aikaterna"], + "description": "Check WarcraftLogs for data on players of World of Warcraft Classic.", + "install_msg": "Check out [p]help WarcraftLogs and set your WCL API key, available by signing into a WarcraftLogs account on their site and visiting the bottom of your settings page. ", + "short": "WarcraftLogs data for World of Warcraft Classic players.", + "tags": ["warcraft"], + "type": "COG" +} \ No newline at end of file diff --git a/warcraftlogs/warcraftlogs.py b/warcraftlogs/warcraftlogs.py new file mode 100644 index 0000000..5de80c0 --- /dev/null +++ b/warcraftlogs/warcraftlogs.py @@ -0,0 +1,334 @@ +import aiohttp +import asyncio +import datetime +import discord +import itertools +import json +from typing import Optional +from redbot.core import Config, commands, checks +from redbot.core.utils.chat_formatting import box, humanize_list, pagify +from redbot.core.utils.menus import menu, DEFAULT_CONTROLS + + +class WarcraftLogs(commands.Cog): + """Access Warcraftlogs stats.""" + + def __init__(self, bot): + self.bot = bot + self.config = Config.get_conf(self, 2713931001, force_registration=True) + self.session = aiohttp.ClientSession() + self.zones = [1002, 1001, 1000] + self.partitions = [2, 1] + + default_user = { + "charname": None, + "realm": None, + "region": None, + } + + default_global = { + "apikey": None, + } + + self.config.register_user(**default_user) + self.config.register_global(**default_global) + + def cog_unload(self): + self.bot.loop.create_task(self.session.close()) + + @commands.command() + async def wclregion(self, ctx, region: str): + """Set your region.""" + valid_regions = ["EU", "US"] + if region.upper() not in valid_regions: + return await ctx.send("Valid regions are: {humanize_list(valid_regions)}") + await self.config.user(ctx.author).region.set(region) + await ctx.send(f"Your server's region was set to {region.upper()}.") + + @commands.command() + async def wclcharname(self, ctx, charname: str): + """Set your character's name.""" + await self.config.user(ctx.author).charname.set(charname) + await ctx.send(f"Your character name was set to {charname.title()}.") + + @commands.command() + async def wclrealm(self, ctx, *, realm: str): + """Set your realm.""" + realmname = realm.replace(" ", "-") + await self.config.user(ctx.author).realm.set(realmname) + await ctx.send(f"Your realm was set to {realm.title()}.") + + @commands.command() + async def wclsettings(self, ctx, user: discord.User = None): + """Show your current settings.""" + if not user: + user = ctx.author + userinfo = await self.config.user(user).all() + msg = f"[Settings for {user.display_name}]\n" + charname = userinfo["charname"].title() if userinfo["charname"] else "None" + realmname = userinfo["realm"].title().replace("-", " ") if userinfo["realm"] else "None" + regionname = userinfo["region"].upper() if userinfo["region"] else "None" + msg += f"Character: {charname}\nRealm: {realmname}\nRegion: {regionname}\n" + await ctx.send(box(msg, lang="ini")) + + @commands.command() + @checks.is_owner() + async def wclapikey(self, ctx, apikey: str): + """Set the api key.""" + await self.config.apikey.set(apikey) + try: + await ctx.message.delete() + except discord.errors.Forbidden: + pass + await ctx.send(f"The WarcraftLogs API key has been set.") + + @commands.command() + @commands.guild_only() + async def wclrank(self, ctx, username=None, realmname=None, region=None): + """Fetch ranking info about a player.""" + userdata = await self.config.user(ctx.author).all() + apikey = await self.config.apikey() + if not apikey: + return await ctx.send( + "The bot owner needs to set a WarcraftLogs API key before this can be used." + ) + if not username: + username = userdata["charname"] + if not username: + return await ctx.send("Please specify a character name with this command.") + if not realmname: + realmname = userdata["realm"] + if not realmname: + return await ctx.send("Please specify a realm name with this command.") + if not region: + region = userdata["region"] + if not region: + return await ctx.send("Please specify a region name with this command.") + + final_embed_list = [] + kill_data = [] + log_data = [] + + async with ctx.channel.typing(): + for zone in self.zones: + for phase in self.partitions: + url = f"https://classic.warcraftlogs.com/v1/parses/character/{username}/{realmname}/{region}?zone={zone}&partition={phase}&api_key={apikey}" + try: + async with self.session.request("GET", url) as page: + data = await page.text() + data = json.loads(data) + except Exception as e: + return await ctx.send( + f"Oops, there was a problem fetching something (Zone {zone}/Phase {phase}): {e}" + ) + if "error" in data: + return await ctx.send( + f"{username.title()} - {realmname.title()} ({region.upper()}) doesn't have any valid logs that I can see.\nError {data['status']}: {data['error']}" + ) + # Logged Kills + zone_name = self.get_zone(zone) + zone_and_phase = f"{zone_name}_{phase}" + area_data = self.get_kills(data, zone_and_phase) + kill_data.append(area_data) + # Log IDs for parses + log_info = self.get_log_id(data, zone_and_phase) + log_data.append(log_info) + + # Logged Kill sorting + embed1 = discord.Embed( + title=f"{username.title()} - {realmname.title()} ({region.upper()})\nLogged Kills" + ) + for item in kill_data: + zone_kills = "" + for boss_info in list(item.values()): + zone_name, phase_num = self.clean_name(list(item)) + for boss_name, boss_kills in boss_info.items(): + zone_kills += f"{boss_name}: {boss_kills}\n" + if zone_kills: + embed1.add_field(name=f"{zone_name}\n{phase_num}", value=zone_kills) + final_embed_list.append(embed1) + + # Log ID sorting + wcl_url = "https://classic.warcraftlogs.com/reports/{}#fight={}" + log_embed_list = [] + + for item in log_data: + log_page = "" + for id_data in list(item.values()): + sorted_item = { + k: v + for k, v in sorted(id_data.items(), key=lambda item: item[1], reverse=True) + } + short_list = dict(itertools.islice(sorted_item.items(), 5)) + zone_name, phase_num = self.clean_name(list(item)) + for log_id, info_list in short_list.items(): + # info_list: [timestamp:int, percentile:int, spec:str, fightid:int, rank:int, outOf:int] + # log_id: encounterid-encountername + log_url = log_id.split("-")[0] + log_name = log_id.split("-")[1] + log_page += f"{wcl_url.format(log_url, info_list[3])}\n{self.time_convert(info_list[0])} UTC\nEncounter: {log_name}\nDPS Percentile: {info_list[1]} [{info_list[4]} of {info_list[5]}] ({info_list[2]})\n\n" + + if id_data: + embed = discord.Embed( + title=f"{username.title()} - {realmname.title()} ({region.upper()})\nWarcraft Log IDs" + ) + embed.add_field(name=f"{zone_name}\n{phase_num}", value=log_page, inline=False) + embed.set_footer(text="Up to the last 5 logs shown per encounter/phase.") + log_embed_list.append(embed) + + for log_embed in log_embed_list: + final_embed_list.append(log_embed) + + await menu(ctx, final_embed_list, DEFAULT_CONTROLS) + + @commands.command() + @commands.guild_only() + async def wclgear(self, ctx, username=None, realmname=None, region=None): + """Fetch gear info about a player.""" + userdata = await self.config.user(ctx.author).all() + apikey = await self.config.apikey() + if not apikey: + return await ctx.send( + "The bot owner needs to set a WarcraftLogs API key before this can be used." + ) + if not username: + username = userdata["charname"] + if not username: + return await ctx.send("Please specify a character name with this command.") + if not realmname: + realmname = userdata["realm"] + if not realmname: + return await ctx.send("Please specify a realm name with this command.") + if not region: + region = userdata["region"] + if not region: + return await ctx.send("Please specify a region name with this command.") + + for zone, phase in [(x, y) for x in self.zones for y in self.partitions]: + url = f"https://classic.warcraftlogs.com/v1/parses/character/{username}/{realmname}/{region}?zone={zone}&partition={phase}&api_key={apikey}" + + async with self.session.request("GET", url) as page: + data = await page.text() + data = json.loads(data) + if "error" in data: + return await ctx.send( + f"{username.title()} - {realmname.title()} ({region.upper()}) doesn't have any valid logs that I can see.\nError {data['status']}: {data['error']}" + ) + if data: + encounter = self.get_recent_gear(data) + if encounter: + break + + wowhead_url = "https://classic.wowhead.com/item={}" + wcl_url = "https://classic.warcraftlogs.com/reports/{}" + itempage = "" + + for item in encounter["gear"]: + if item["id"] == 0: + continue + rarity = self.get_rarity(item) + itempage += f"{rarity} [{item['name']}]({wowhead_url.format(item['id'])})\n" + itempage += f"\nAverage ilvl: {encounter['ilvlKeyOrPatch']}" + + embed = discord.Embed( + title=f"{encounter['characterName']} - {encounter['server']} ({region.upper()})\n{encounter['class']} ({encounter['spec']})", + description=itempage, + ) + embed.set_footer( + text=f"Gear data pulled from {wcl_url.format(encounter['reportID'])}\nEncounter: {encounter['encounterName']}\nLog Date/Time: {self.time_convert(encounter['startTime'])} UTC" + ) + await ctx.send(embed=embed) + + @staticmethod + def get_rarity(item): + rarity = item["quality"] + if rarity == "common": + return "⬜" + elif rarity == "uncommon": + return "🟩" + elif rarity == "rare": + return "🟦" + elif rarity == "epic": + return "🟪" + else: + return "🔳" + + @staticmethod + def time_convert(time): + time = str(time)[0:10] + value = datetime.datetime.fromtimestamp(int(time)).strftime("%Y-%m-%d %H:%M:%S") + return value + + @staticmethod + def get_kills(data, zone_and_phase): + # data is json data + # zone_and_phase: Name_Phasenum + boss_kills = {} + for encounter in data: + if encounter["encounterName"] not in boss_kills.keys(): + boss_kills[encounter["encounterName"]] = 0 + boss_kills[encounter["encounterName"]] += 1 + complete_info = {} + complete_info[zone_and_phase] = boss_kills + return complete_info + + @staticmethod + def get_zone(zone): + # Zone ID and name is available from the API, but why make another + # call to a url when it's simple for now... maybe revisit in phase 5+ + if zone == 1000: + zone_name = "MoltenCore" + elif zone == 1001: + zone_name = "Onyxia" + else: + zone_name = "BWL" + return zone_name + + @staticmethod + def clean_name(zone_and_phase): + zone_and_phase = zone_and_phase[0] + zone_name = zone_and_phase.split("_")[0] + phase_num = zone_and_phase[-1] + + if zone_name == "MoltenCore": + zone_name = "Molten Core" + elif zone_name == "BWL": + zone_name = "Blackwing Lair" + else: + zone_name = zone_name + + if phase_num == "1": + phase_num = "Phase 1 & 2" + else: + phase_num = "Phase 3" + return zone_name, phase_num + + @staticmethod + def get_log_id(data, zone_and_phase): + report_ids = {} + for encounter in data: + keyname = f"{encounter['reportID']}-{encounter['encounterName']}" + report_ids[keyname] = [ + encounter["startTime"], + encounter["percentile"], + encounter["spec"], + encounter["fightID"], + encounter["rank"], + encounter["outOf"], + ] + complete_info = {} + complete_info[zone_and_phase] = report_ids + return complete_info + + @staticmethod + def get_recent_gear(data): + data = reversed(data) + for encounter in data: + try: + item_name = encounter["gear"][0]["name"] + if item_name == "Unknown Item": + continue + else: + return encounter + except KeyError: + return None